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:
SELECT FOR UPDATEauf Raumebene — die Transaktion sperrt die Raum-Zeile, der zweite Request wartet. Einfach, robust, ausreichend für die meisten Systeme.- Exclusion Constraint (nur PostgreSQL) —
EXCLUDE USING gistauf(room_id, tstzrange(start_at, end_at))verlagert die Garantie in die Datenbank. Die stärkste Lösung, aber PostgreSQL-spezifisch. - 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.