Hotelbuchungssystem: Inventarmodell pro Nacht und Überbuchungsschutz
1. Übersicht
Das Hotelbuchungsproblem klingt einfach: Prüfe, ob noch Zimmer vorhanden sind, und erlaube die Buchung. Aber wenn zwei Gäste gleichzeitig auf „Buchen” klicken, um das letzte Zimmer für die Nacht des 31. Dezember zu reservieren, kann das System leicht ein Zimmer an zwei Personen verkaufen.
Dieser Artikel führt dich durch einen realistischen Entwurf: Anforderungen, das Inventarmodell pro Nacht, API-Design, der Ablauf von Zimmerreservierungen (Hold) und Bestätigung, wie man Überbuchungen mittels PostgreSQL verhindert, und wie man mit Lastspitzen umgeht. Nach dem Lesen kannst du den Kern eines Buchungssystems mit Spring Boot 3.x selbst aufbauen, ohne ungewollte Überbuchungen zu verursachen.
2. Anforderungen
2.1. Funktionale Anforderungen
- Verfügbare Zimmer nach Hotel, Zimmertyp, Check-in-Datum und Check-out-Datum finden.
- Zimmer für 10 Minuten reservieren, während Gäste bezahlen.
- Buchung nach erfolgter Zahlung bestätigen.
- Buchung stornieren und Inventar zurückgeben.
2.2. Skalierbarkeit und Einschränkungen
- Etwa 50.000 Hotels. Jedes Hotel hat durchschnittlich 5 Zimmertypen. Das System akzeptiert Buchungen bis zu 365 Tage im Voraus.
- Lesevorgänge sind deutlich häufiger als Schreibvorgänge. Das Verhältnis zwischen Suchanfragen und Buchungen liegt etwa bei 100:1.
- Etwa 50 Buchungen pro Sekunde während Stoßzeiten. Bei Flash Sales kann sich diese Zahl auf das 20-fache vervielfachen.
- Strikte Anforderung: Nicht mehr Zimmer verkaufen als konfiguriert. Suchergebnisse dürfen um einige Sekunden abweichen.
Die Anzahl der Inventareinträge liegt bei etwa 50.000 × 5 × 365 ≈ 91 Millionen Zeilen. Eine gut indizierte PostgreSQL-Tabelle kann diese Menge bewältigen.
3. Datenmodell
Die wichtigste Entscheidung in diesem Design: Gäste buchen nicht ein bestimmtes Zimmer (z.B. Zimmer 302), sondern einen Zimmertyp für einen Nachtzeitraum. Das System weist die Zimmernummer erst beim Check-in zu. Daher wird das Inventar nach dem Schlüssel (hotel_id, room_type_id, stay_date) gespeichert.
CREATE TABLE room_inventory (
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
stay_date DATE NOT NULL,
total_rooms INT NOT NULL,
reserved INT NOT NULL DEFAULT 0,
PRIMARY KEY (hotel_id, room_type_id, stay_date),
CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL,
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
quantity INT NOT NULL,
status VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';
Wichtige Punkte:
- Ein Aufenthalt vom 10.12. bis 13.12. umfasst 3 Nächte: 10., 11. und 12. Das Check-out-Datum zählt nicht. Alle Abfragen verwenden das halboffene Intervall
[check_in, check_out). CHECK (reserved <= total_rooms)ist die letzte Verteidigungslinie. Sollte der Code einen Fehler enthalten, wird die Datenbank ihn ablehnen, anstatt stillschweigend zu überbuchen.- Der partielle Index
idx_booking_expiryenthält nur Buchungen im StatusHOLD. Der Cleanup-Job durchsucht ihn schnell, ohne dass der Index durch die Historie aufgebläht wird.
Hinweis: Viele Hotels überbuchen absichtlich um 3–5%, da sie mit Stornierungen rechnen. Du kannst diese Richtlinie umsetzen, indem du
total_roomsgrößer als die tatsächliche Anzahl physischer Zimmer setzt. Lockere die Anti-Overbooking-Logik nicht auf.
4. API
GET /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings (Header: Idempotency-Key) -> 201, status=HOLD
POST /bookings/{id}/confirm (nach erfolgreicher Zahlung aufrufen)
POST /bookings/{id}/cancel
Request zum Erstellen einer Buchung:
public record HoldRequest(long customerId, long hotelId, long roomTypeId,
LocalDate checkIn, LocalDate checkOut, int quantity) {
public HoldRequest {
if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOut muss nach checkIn sein");
if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
}
public int nights() {
return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
}
}
Der Header Idempotency-Key wird vom Client generiert (UUID). Wenn das Netzwerk ausfällt, kann der Client beruhigt Anfragen wiederholen, ohne zwei Buchungen zu erstellen.
5. Verarbeitungsablauf
Gast ──► POST /bookings ──► Inventar für alle Nächte reduzieren (1 Transaktion) ──► Buchung HOLD, läuft in 10' ab
──► Zahlung ──► POST /confirm ──► HOLD → CONFIRMED
└► Nach 10' keine Zahlung ──► Cleanup-Job: HOLD → EXPIRED, Inventar zurückgeben
Das Inventar wird sofort beim Erstellen des Hold reduziert, nicht erst nach der Zahlung. Damit ist sichergestellt, dass Gäste, die die Zahlungsseite erreichen, definitiv noch ein Zimmer haben. Dafür muss das System einen Mechanismus haben, um Zimmer zurückzugeben, wenn das Hold abläuft.
6. Schwierigkeit 1: Verhinderung von Überbuchungen
6.1. Warum „Lesen dann Schreiben” nicht sicher ist
Der naive Ansatz ist, SELECT zu verwenden, um die verbleibende Zimmerzahl zu lesen, dies in Java zu prüfen, und dann UPDATE zu machen. Zwei Transaktionen können beide reserved = 9, total = 10 lesen und dann beide reserved = 10 schreiben. Das Ergebnis ist, dass zwei Gäste das gleiche Zimmer reservieren. Dies ist der klassische Lost-Update-Fehler.
6.2. Sperren in Reihenfolge und bedingtes Update
Abhängigkeit hinzufügen:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Repository mit native query:
public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {
@Query(value = """
SELECT stay_date FROM room_inventory
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
ORDER BY stay_date
FOR UPDATE
""", nativeQuery = true)
List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);
@Modifying
@Query(value = """
UPDATE room_inventory SET reserved = reserved + :qty
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
AND total_rooms - reserved >= :qty
""", nativeQuery = true)
int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
@Modifying
@Query(value = """
UPDATE room_inventory SET reserved = reserved - :qty
WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
AND stay_date >= :checkIn AND stay_date < :checkOut
""", nativeQuery = true)
int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}
Service zum Erstellen eines Hold:
@Service
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final InventoryRepository inventory;
private final BookingRepository bookings;
private final Clock clock;
public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
this.inventory = inventory;
this.bookings = bookings;
this.clock = clock;
}
@Transactional
public Booking hold(HoldRequest req, String idempotencyKey) {
Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();
inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
req.checkIn(), req.checkOut(), req.quantity());
if (updated != req.nights()) {
throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
}
return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
}
}
So funktioniert es Schritt für Schritt:
lockNightssperrt die Inventarzeilen in aufsteigender Reihenfolge nachstay_date. Angenommen, Gast A bucht 10.–13. und Gast B bucht 11.–14. Wenn die Sperrung nicht in einer festen Reihenfolge erfolgt, könnte A die Nacht 10. halten und auf 11. warten, während B die Nacht 12. hält und auf 11. wartet, und beide Seiten könnten einen Deadlock erleiden. Wenn alle Transaktionen in der gleichen Reihenfolge sperren, kann dies nicht passieren.reserveaddiertreservednur zu denjenigen Nächten, bei denen noch genügend Platz vorhanden ist. Es gibt die Anzahl der aktualisierten Zeilen zurück.- Wenn die Anzahl der Zeilen kleiner als die Anzahl der Nächte ist, liegt dies möglicherweise daran, dass eine Nacht vollständig gebucht ist oder noch nicht zum Verkauf freigegeben wurde (keine Zeile existiert). Der Service wirft
RoomUnavailableException. Dies ist eineRuntimeException, also werden alle Changes von@Transactionalzurückgerollt, einschließlich der Nächte, die bereits addiert wurden.
Ergebnis mit dem letzten Zimmer, wenn zwei Anfragen gleichzeitig eintreffen:
T1: lockNights (hält die Sperre) → reserve = 3 Zeilen → INSERT booking → COMMIT
T2: lockNights (wartet auf T1) → reserve = 0 Zeilen → RoomUnavailableException → ROLLBACK → HTTP 409
Hier musst du das Isolation Level nicht auf SERIALIZABLE erhöhen. READ COMMITTED zusammen mit Zeilensperren ist ausreichend.
6.3. Idempotenz bei gleichzeitigen Wiederholungen
Zwei Anfragen mit dem gleichen Idempotency-Key können beide den findByIdempotencyKey-Check passieren. Die UNIQUE-Einschränkung wird die zweite Anfrage mit DataIntegrityViolationException blockieren. Du fängst diese Exception auf Controller-Ebene, liest die Buchung erneut nach Schlüssel und gibst das alte Ergebnis zurück. Die gescheiterte Transaktion wurde bereits zurückgerollt, sodass auch das Inventar, das sie reduziert hatte, zurückgegeben wird.
7. Schwierigkeit 2: Ablauf von Reservierungen und Bestätigungskonflikte
Der Cleanup-Job läuft alle 30 Sekunden:
@Component
public class HoldExpiryJob {
private final BookingRepository bookings;
private final InventoryRepository inventory;
public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
this.bookings = bookings;
this.inventory = inventory;
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
// SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
for (Booking b : bookings.lockExpiredHolds(500)) {
inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
b.markExpired();
}
}
}
FOR UPDATE SKIP LOCKED erlaubt dir, mehrere Instanzen auszuführen, ohne dass sie eine Buchung doppelt verarbeiten. Jeder Batch ruft nur 500 Zeilen ab, um die Transaktion kurz zu halten.
Denk daran, @EnableScheduling auf eine @Configuration-Klasse zu setzen. Wenn du Inventar freigeben musst, rufst du auch release in der gleichen Reihenfolge der Nächte wie beim Sperren auf, um Deadlocks mit dem hold-Thread zu vermeiden.
Der gefährlichste Konflikt ist: Der Gast zahlt gerade, während der Job das Hold ablaufen lässt. Du löst dies mit einem bedingten UPDATE auf den Status:
UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();
Wenn die Anweisung 0 Zeilen zurückgibt, ist das Hold abgelaufen und das Zimmer gehört möglicherweise bereits jemand anderem. In diesem Fall muss der Service die Zahlung rückgängig machen oder erneut ein Hold versuchen, bevor es bestätigt wird. Konvertiere EXPIRED niemals zu CONFIRMED, ohne das Inventar erneut zu reduzieren.
8. Schwierigkeit 3: Plötzliche Lastspitzen
Da Lesevorgänge 100-mal häufiger sind als Schreibvorgänge, sollten Suchanfragen nicht auf gesperrte Zeilen zugreifen. Du kannst den Zimmerstatus mit Redis mit kurzem TTL zwischenspeichern:
@Service
public class AvailabilityCache {
private final StringRedisTemplate redis;
public AvailabilityCache(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
return Optional.ofNullable(v).map(Integer::valueOf);
}
public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
}
private String key(long h, long rt, LocalDate d) {
return "avail:%d:%d:%s".formatted(h, rt, d);
}
}
Wichtige Regeln:
- Redis ist nur für Lesevorgänge. Die Quelle der Wahrheit ist PostgreSQL. Wenn Suchergebnisse „1 Zimmer verfügbar” zeigen, aber der
hold-Schritt fehlschlägt, erhält der Gast nur eine HTTP 409 mit der Meldung „gerade ausgebucht”. Keine falsche Buchung wird erstellt. - Nach jedem
hold,releaseodercancelschreibst du ein Ereignis in eine Outbox-Tabelle in der gleichen Transaktion. Ein Relay leitet dieses Ereignis an ein Kafka Topicinventory-changedweiter. Der Consumer invalidiert den Redis-Key oder aktualisiert den Search-Index. Die Outbox stellt sicher, dass kein Ereignis verloren geht, wenn die App direkt nach dem Commit ausfällt. - Bei einem Flash Sale konzentriert sich die Sperrung auf wenige „heiße” Hotels. Du solltest die Rate nach
hotelIdauf dem API Gateway begrenzen undlock_timeoutauf etwa 2 Sekunden für Buchungstransaktionen setzen. So schlagen Anfragen schnell fehl, anstatt dass sie den gesamten Connection Pool aufbrauchen.
9. Testen von Überbuchungen
Der wichtigste Test ist, wenn mehrere Threads um das letzte Zimmer konkurrieren. Du brauchst echtes PostgreSQL, da H2 das Sperrverhalten nicht korrekt simuliert. Abhängigkeit hinzufügen:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BookingService bookingService;
@Autowired JdbcTemplate jdbc;
@Test
void onlyOneGuestGetsTheLastRoom() throws Exception {
for (int d = 10; d <= 12; d++) {
jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
}
var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);
AtomicInteger success = new AtomicInteger();
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = IntStream.range(0, 20)
.mapToObj(i -> pool.submit(() -> {
try {
bookingService.hold(req, "key-" + i);
success.incrementAndGet();
} catch (RoomUnavailableException ignored) { }
}))
.toList();
for (Future<?> f : futures) f.get();
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
.containsOnly(1);
}
}
Zwanzig Virtual Threads rufen gleichzeitig hold mit unterschiedlichen Schlüsseln auf. Der Test bestätigt, dass genau ein Thread erfolgreich ist und alle drei Nächte reserved = 1 haben. Keine Nacht wird aufgrund eines unvollständigen Rollback überzählt. Die Tabelle wird mit einem schema.sql-Skript aus Abschnitt 3 oder mit Flyway-Migration erstellt.
10. Häufige Fehler
- Das Check-out-Datum als eine weitere Nacht zählen. Dieser Fehler führt dazu, dass Gäste um eine Nacht zu viel Inventar reduzieren. Verwende überall das
[check_in, check_out)-Intervall. - Den Bestand in Java nach
SELECTohne Sperrung prüfen. Dies verursacht Lost-Update-Fehler. Die Bedingung muss in derUPDATE-Anweisung stehen oder nachFOR UPDATEkommen. - Die Anzahl der aktualisierten Zeilen nicht mit der Anzahl der Nächte vergleichen. Wenn eine Nacht keine Inventarrzeile hat, ist das
UPDATEfür die anderen Nächte „erfolgreich”. - Die Zahlungs-API innerhalb der Transaktion aufrufen, die Sperren hält. Sperren werden für Sekunden gehalten, und das ganze Hotel wird blockiert. Du solltest den Prozess trennen: hold (commit), zahlen, dann confirm.
- Redis
DECRals Wahrheitsquelle verwenden. Wenn Redis failover und Daten verliert, verlierst du auch das Inventar. Dieser Ansatz ist nur dann in Betracht zu ziehen, wenn die Last die Kapazität der Datenbank deutlich übersteigt, und selbst dann brauchst du noch eine Abstimmung mit der DB.
Hinweis: Das Modell, das Zimmer pro Typ zählt, ist nicht geeignet, wenn Gäste ein bestimmtes Zimmer buchen, z.B. bei Ferienhäusern oder privaten Villen. In diesem Fall brauchst du eine Tabelle
room_night(room_id, stay_date)mitUNIQUE-Einschränkung oder ein Exclusion-Constraint aufdaterangevon PostgreSQL.
11. Fazit
Der Kern eines Hotelbuchungssystems ist eine Inventartabelle pro Nacht. Oben drauf ist eine kurze Transaktion: Nächte in aufsteigender Reihenfolge sperren, bedingt aktualisieren, und die Anzahl der betroffenen Zeilen prüfen. Die CHECK-Einschränkung ist das letzte Sicherheitsnetz. Reservierungen mit Ablaufzeit, gepaart mit einem Cleanup-Job mit SKIP LOCKED, lösen das Problem von Gästen, die die Zahlung abbrechen. Das UPDATE mit bedingtem Status löst Konflikte zwischen Bestätigung und Ablauf.
Der Leseteil wird in Redis und mit Outbox und Kafka synchronisiert, sodass Suchanfragen große Lasten bewältigen können, ohne die Konsistenz beim Schreiben zu beeinträchtigen. Du kannst mit diesem Design beginnen und es nach hotel_id sharden, wenn ein einzelner PostgreSQL-Knoten wirklich nicht mehr ausreicht.