Zum Inhalt springen
← Alle Artikel

Race Conditions in Buchungssystemen: Konflikterkennung mit Symfony 7

Veröffentlicht am · Marten Ackermann

Kurzantwort: Doppelbuchungen verhindert man mit einer Transaktion, die vor dem INSERT eine Overlap-Query ausführt. Ob das race-condition-frei ist, hängt von der Datenbank ab: SQLite serialisiert Schreibvorgänge von selbst, PostgreSQL und MySQL brauchen zusätzlich Locking, etwa SELECT FOR UPDATE.

Das Problem

In einem Raum-Buchungssystem, das ich als Arbeitsprobe mit Symfony 7 und Nuxt 4 gebaut habe, war die interessanteste Anforderung unscheinbar: Ein Raum darf nicht doppelt belegt werden.

Klingt trivial — ist es aber nicht, sobald zwei Requests gleichzeitig eintreffen. Beide prüfen „ist der Raum frei?”, beide bekommen „ja”, beide buchen. Klassische Race Condition zwischen Check und Insert.

Die Overlap-Query

Zwei Zeiträume überlappen genau dann, wenn der eine beginnt, bevor der andere endet — und umgekehrt:

SELECT * FROM booking
WHERE room_id = :room
  AND cancelled_at IS NULL
  AND start_at < :newEnd
  AND end_at > :newStart

Diese Bedingung deckt alle Fälle ab: vollständige Überdeckung, Teilüberlappung am Anfang, am Ende und den Fall, dass die neue Buchung eine bestehende komplett umschließt. Kein Sonderfall-Katalog nötig.

Im Symfony-Projekt lebt diese Logik als findOverlapping() im BookingRepository — mit acht Unit-Tests allein für die Grenzfälle (angrenzende Slots, exakt gleiche Zeiten, stornierte Buchungen).

Warum das mit SQLite reicht

Die Demo läuft auf SQLite im WAL-Modus. SQLite erlaubt genau einen Schreiber zur Zeit — Schreibtransaktionen sind serialisiert. Eine Transaktion, die erst findOverlapping() ausführt und dann einfügt, kann deshalb nicht von einer zweiten Schreibtransaktion überholt werden. Check-then-insert ist hier race-condition-frei, ohne explizites Locking.

Warum das mit PostgreSQL/MySQL nicht reicht

PostgreSQL und MySQL erlauben parallele Schreiber. Zwei Transaktionen können gleichzeitig die Overlap-Query ausführen, beide sehen keine Konflikte, beide fügen ein. Die Optionen:

  1. SELECT FOR UPDATE auf Raumebene — die Transaktion sperrt die Raum-Zeile, der zweite Request wartet. Einfach, robust, ausreichend für die meisten Systeme.
  2. Exclusion Constraint (nur PostgreSQL)EXCLUDE USING gist auf (room_id, tstzrange(start_at, end_at)) verlagert die Garantie in die Datenbank. Die stärkste Lösung, aber PostgreSQL-spezifisch.
  3. Unique-Index? Funktioniert nicht — Überlappung lässt sich nicht als einfacher Constraint abbilden.

Ein simpler Unique-Index scheidet aus, ein Overlap ist keine Gleichheit. Das ist der Kern, warum Buchungskonflikte ein Architekturthema sind und kein Schema-Detail.

Was ich daraus mitnehme

Die Wahl der Konfliktstrategie gehört ins Entscheidungslog, nicht in den Code-Kommentar. Im Projekt steht der Trade-off explizit dokumentiert: „SQLite-spezifisch — bei Migration auf PostgreSQL/MySQL wäre SELECT FOR UPDATE auf Raumlevel nötig.” Wer das System später skaliert, weiß sofort, wo die Annahme steckt.

Das vollständige Projekt — Symfony 7 REST-API, Nuxt-4-SPA, 42 PHPUnit-Tests, Docker-Setup — beschreibe ich in der Case Study zum Buchungssystem.