Terminbuchungssystem-Design: Zeitslots und Doppelbuchungsprävention
1. Übersicht
Einen Arzttermin zu buchen klingt einfach: Patient wählt Arzt, wählt Zeit und klickt „Buchen”. Die Schwierigkeit liegt anderswo. Hunderte Menschen können zur gleichen Zeit, wenn die Praxis die Terminvergabe öffnet, um 7 Uhr morgens auf einen Slot klicken. Das System muss sicherstellen, dass nicht zwei Personen denselben Platz erhalten.
Dieser Artikel durchläuft das gesamte Design: Anforderungen, Datenmodell, API, Buchungs- und Stornierungsablauf. Anschließend konzentriert sich der Artikel auf den wichtigsten Teil: Doppelbuchungsprävention und Burst-Load-Handling. Nach dem Lesen können Sie den Kern des Terminbuchungssystems mit Spring Boot und PostgreSQL aufbauen. Dieser Kern funktioniert korrekt, auch wenn viele Anfragen gleichzeitig ankommen.
2. Anforderungsanalyse
2.1. Funktionale Anforderungen
- Ein Arzt (oder Admin) erstellt einen Arbeitsplan. Das System generiert feste Slots, z.B. mit einer Dauer von 15 Minuten pro Slot.
- Jeder Slot hat eine Kapazität. Facharzttermine haben normalerweise 1 Platz. Impftermine können 10 Personen pro Zeitfenster haben.
- Patienten sehen verfügbare Slots nach Arzt und Datum, dann buchen oder stornieren einen Termin.
- Ein Patient darf nicht zwei gültige Termine im selben Slot haben.
- Nach erfolgreicher Buchung sendet das System eine SMS oder E-Mail-Bestätigung.
2.2. Skalierung und Einschränkungen
- Es gibt 2.000 Ärzte. Jeder Arzt hat etwa 40 Slots pro Tag. Die Terminvergabe öffnet 14 Tage im Voraus, also etwa 1,1 Millionen aktive Slots.
- Lesezugriffe überwiegen Schreibzugriffe deutlich, Verhältnis etwa 50:1.
- Burst-Load: Die Slots eines bekannten Arztes öffnen um 7:00 Uhr. In den ersten 30 Sekunden können 5.000 Anfragen pro Sekunde zu wenigen Dutzend Slots kommen.
- Starke Konsistenz für Buchungsvorgänge. Die Kapazität darf niemals überschritten werden. Verfügbare Slot-Listen können einige Sekunden alt sein.
- Clients können bei Netzwerklücken Anfragen wiederholen. Daher muss der Buchungsvorgang idempotent sein.
3. Datenmodell
Es gibt eine wichtige Entscheidung: Slots als einzelne Zeilen vorausberechnen statt „freie Zeiten” dynamisch aus dem Arbeitsplan zu berechnen. Wenn jeder Slot eine eigene Zeile ist, haben Sie einen konkreten Punkt zum Sperren und Überprüfen der Kapazität. Konflikte werden im Umfang einer Zeile gelöst.
CREATE TABLE time_slot (
id BIGSERIAL PRIMARY KEY,
doctor_id BIGINT NOT NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
booked_count INT NOT NULL DEFAULT 0,
CONSTRAINT chk_booked CHECK (booked_count BETWEEN 0 AND capacity),
CONSTRAINT uq_doctor_start UNIQUE (doctor_id, start_at)
);
CREATE TABLE appointment (
id BIGSERIAL PRIMARY KEY,
slot_id BIGINT NOT NULL REFERENCES time_slot(id),
patient_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- BOOKED, CANCELLED
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_active_patient_slot
ON appointment (slot_id, patient_id)
WHERE status = 'BOOKED';
Jede Einschränkung im Schema erfüllt eine eigene Rolle:
chk_bookedist die letzte Verteidigungslinie. Wenn Code einen Fehler hat, lehnt die Datenbank immer noch ab, wennbooked_countdiecapacityüberschreitet.uq_doctor_startverhindert doppelte Slots, wenn der Slot-Generierungsjob erneut läuft.uq_active_patient_slotist ein partieller eindeutiger Index von PostgreSQL. Ein Patient darf pro Slot maximal einenBOOKED-Termin haben. Stornierte Termine werden nicht gezählt, sodass der Patient den Slot erneut buchen kann.idempotency_key UNIQUEhilft, dass Anfragenwiedererholungen des Clients keinen zweiten Termin erstellen.
Die entsprechende JPA-Entity für time_slot (gekürzte Getter):
@Entity
@Table(name = "time_slot")
public class TimeSlot {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long doctorId;
private Instant startAt;
private Instant endAt;
private int capacity;
private int bookedCount;
protected TimeSlot() {}
public TimeSlot(Long doctorId, Instant startAt, int capacity) {
this.doctorId = doctorId;
this.startAt = startAt;
this.endAt = startAt.plus(15, ChronoUnit.MINUTES);
this.capacity = capacity;
}
// getters...
}
4. API-Design
GET /api/doctors/{doctorId}/slots?date=2026-10-12 -> Liste der verfügbaren Slots
POST /api/appointments -> Termin buchen
Header: Idempotency-Key: 6f1c...
Body: { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id} -> Termin stornieren
HTTP-Statuscodes für POST /api/appointments:
201 Created -> Buchung erfolgreich
200 OK -> Wiederholung mit gleichem Idempotency-Key, gibt alten Termin zurück
409 Conflict -> Slot ist voll oder Patient hat diesen Slot bereits gebucht
429 Too Many Requests -> Rate Limit überschritten
Der Client generiert selbst einen Idempotency-Key (UUID) für jeden Klick auf den Button. Bei einer Wiederholung sendet der Client denselben Key erneut.
5. Schutz vor Doppelbuchungen
5.1. Warum „Prüfen dann Schreiben” falsch ist
Der erste Ansatz, den viele Menschen denken:
TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) { // (1) lesen
slot.setBookedCount(slot.getBookedCount() + 1); // (2) schreiben
appointmentRepository.save(appointment);
}
Angenommen, zwei Threads A und B lesen in Schritt (1) gleichzeitig und sehen booked_count = 0, capacity = 1. Beide bestehen die Bedingung und beide schreiben. Das Ergebnis sind zwei Termine für denselben Platz. Mit der Standard-Isolationsstufe READ COMMITTED von PostgreSQL ist dies ein verlorenes Update.
5.2. Conditional Update: Prüfung und Schreiben in einer Anweisung
Die Lösung besteht darin, Prüfung und Schreiben in einer einzelnen UPDATE-Anweisung mit Bedingung zu kombinieren. Die Datenbank sperrt die Zeile während der Aktualisierung. Der zweite Thread muss warten und bewertet dann die WHERE-Bedingung erneut mit dem neuen Wert.
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
@Modifying
@Query("""
update TimeSlot s
set s.bookedCount = s.bookedCount + 1
where s.id = :id
and s.bookedCount < s.capacity
and s.startAt > :now
""")
int tryReserve(@Param("id") long id, @Param("now") Instant now);
@Modifying
@Query("update TimeSlot s set s.bookedCount = s.bookedCount - 1 where s.id = :id and s.bookedCount > 0")
int release(@Param("id") long id);
}
tryReserve gibt die Anzahl der betroffenen Zeilen zurück. Wenn das Ergebnis 1 ist, haben Sie den Platz reserviert. Wenn es 0 ist, ist der Slot voll, die Zeit ist vorbei, oder es existiert nicht. Sie benötigen weder SELECT ... FOR UPDATE noch optimistic locking mit einer version-Spalte. Das Lock wird nur für einen sehr kurzen Moment gehalten.
Der Booking-Service:
@Service
public class BookingService {
private final TimeSlotRepository slotRepository;
private final AppointmentRepository appointmentRepository;
private final Clock clock;
public BookingService(TimeSlotRepository slotRepository,
AppointmentRepository appointmentRepository,
Clock clock) {
this.slotRepository = slotRepository;
this.appointmentRepository = appointmentRepository;
this.clock = clock;
}
@Transactional
public Appointment book(long slotId, long patientId, String idempotencyKey) {
var existing = appointmentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return existing.get(); // Wiederholung: geben Sie das alte Ergebnis zurück
}
if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
throw new SlotFullException(slotId);
}
// saveAndFlush, um sicherzustellen, dass Einschränkungsverletzungen sofort in dieser Transaktion auftreten
return appointmentRepository.saveAndFlush(
Appointment.booked(slotId, patientId, idempotencyKey));
}
}
Clock sollte als Bean deklariert werden, z.B. Clock.systemUTC(), damit Tests es durch eine feste Zeit ersetzen können. Der Code funktioniert wie folgt:
- Die Idempotenz-Prüfung läuft zuerst. Wenn der Client nach erfolgreichem Commit der ersten Anfrage erneut versucht, erhält der Client exakt denselben alten Termin zurück.
- Wenn
tryReserveerfolgreich ist, abersaveAndFlushverletzt dieuq_active_patient_slot-Einschränkung (Patient hat bereits einen Termin), wirft SpringDataIntegrityViolationException. Die Transaktion wird vollständig zurückgerollt, auch dasbooked_count-Inkrement. Der Platz wird automatisch zurückgegeben. - Zwei Wiederholungsanfragen können fast gleichzeitig ankommen und beide sehen den Key nicht. In diesem Fall verletzt eine Anfrage die Eindeutigkeit von
idempotency_key. Auf der Controller-Ebene fangen Sie diesen Fehler ab, lesen den Termin nach dem Key erneut und geben200zurück.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Sie haben bereits einen Termin in diesem Zeitfenster"));
}
Hinweis: Das obige Beispiel gibt
409für alle Einschränkungsverletzungen zurück, um den Code kurz zu halten. In einem echten System sollten Sie die beiden Fälle nach Constraint-Name unterscheiden. Verletzung vonuq_active_patient_slotergibt409. Verletzung des eindeutigenidempotency_keybedeutet, dass Sie den Termin nach dem Key zurücklesen und200zurückgeben.
5.3. Auch Stornierung muss bedingt sein
Stornierung hat auch Race Conditions. Wenn ein Patient zweimal die „Stornieren”-Schaltfläche klickt, könnte booked_count zweimal reduziert werden. Die Lösung ist dieselbe: Nur dann reduzieren, wenn der Statusübergang tatsächlich stattfindet.
@Transactional
public void cancel(long appointmentId) {
Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
int changed = appointmentRepository.markCancelled(appointmentId); // where status = 'BOOKED'
if (changed == 1) {
slotRepository.release(appt.getSlotId());
}
}
markCancelled ist eine @Modifying-Query: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. Eine zweite Stornierungsanfrage erhält changed = 0 und tut nichts Weiteres.
5.4. Test mit vielen gleichzeitigen Threads
Der folgende Test lässt 20 Patienten um einen Slot mit Kapazität 1 konkurrieren. Sie sollten den Test mit echter PostgreSQL über Testcontainers ausführen, da H2 keinen partiellen Index unterstützt.
@SpringBootTest
class BookingServiceConcurrencyTest {
@Autowired BookingService bookingService;
@Autowired TimeSlotRepository slotRepository;
@Test
void onlyOnePatientGetsSingleCapacitySlot() throws Exception {
TimeSlot slot = slotRepository.save(
new TimeSlot(42L, Instant.now().plus(1, ChronoUnit.DAYS), 1));
int patients = 20;
var start = new CountDownLatch(1);
var success = new AtomicInteger();
try (ExecutorService pool = Executors.newFixedThreadPool(patients)) {
for (int i = 0; i < patients; i++) {
long patientId = i;
pool.submit(() -> {
start.await();
try {
bookingService.book(slot.getId(), patientId, "key-" + patientId);
success.incrementAndGet();
} catch (SlotFullException ignored) {
}
return null;
});
}
start.countDown(); // geben Sie 20 Threads gleichzeitig frei
} // close() wartet darauf, dass alle Tasks beendet sind
assertThat(success.get()).isEqualTo(1);
assertThat(slotRepository.findById(slot.getId()).orElseThrow().getBookedCount())
.isEqualTo(1);
}
}
CountDownLatch stellt sicher, dass alle Threads gleichzeitig starten, wodurch eine Race Condition wahrscheinlicher wird. Das erwartete Ergebnis:
success = 1
booked_count = 1
19 verbleibende Threads erhalten SlotFullException
Wenn Sie tryReserve durch den Ansatz „Lesen dann Schreiben” aus Abschnitt 5.1 ersetzen, schlägt dieser Test normalerweise mit einem success-Wert größer als 1 fehl.
6. Handling von Burst-Lasten bei Terminöffnung
Conditional Update sichert Korrektheit, nicht Lasttragfähigkeit. 5.000 Anfragen pro Sekunde auf wenigen Zeilen erzeugen Lock-Warteschlangen in der Datenbank. Der Connection Pool wird erschöpft und andere APIs verlangsamen sich. Es gibt drei Schichten der Behandlung.
6.1. Cache der verfügbaren Slot-Liste
Der größte Teil des Traffics ist GET slots. Sie können das Ergebnis für doctorId + date in Redis mit kurzem TTL cachen, z.B. 3–5 Sekunden. Wenn eine Buchung oder Stornierung erfolgreich ist, löschen Sie den entsprechenden Schlüssel. Die Liste kann etwas älter sein, aber das ist in Ordnung: Die endgültige Entscheidung trifft immer noch tryReserve auf der Datenbank.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6.2. Rate Limiting und frühes Blockieren vollständiger Slots
@Component
public class BookingGate {
private final StringRedisTemplate redis;
public BookingGate(StringRedisTemplate redis) { this.redis = redis; }
public boolean allowPatient(long patientId) {
String key = "rl:booking:" + patientId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(10));
}
return count != null && count <= 5; // maximal 5 Buchungsversuche / 10 Sekunden
}
public boolean isKnownFull(long slotId) {
return Boolean.TRUE.equals(redis.hasKey("slot:full:" + slotId));
}
public void markFull(long slotId) {
redis.opsForValue().set("slot:full:" + slotId, "1", Duration.ofSeconds(30));
}
}
Der Controller ruft zuerst allowPatient auf. Wenn das Ergebnis false ist, gibt der Controller 429 zurück. Wenn tryReserve 0 zurückgibt, rufen Sie markFull auf. Nachfolgende Anfragen zum selben Slot werden sofort in Redis blockiert und erreichen nie die Datenbank. Ein TTL von 30 Sekunden ermöglicht dem Slot eine „Wiederöffnung”, wenn jemand einen Termin storniert.
Hinweis: Verwenden Sie Redis nicht als Quelle der Wahrheit für die Kapazität, z.B. indem Sie einen Zähler mit
DECRdekrementieren und dann als gebucht behandeln. Wenn sich Redis und Datenbank unterscheiden (Redis-Neustart, Transaction Rollback), verkaufen Sie zu viel oder verlieren Sie Plätze. Redis sollte nur als Filter vor der Datenbank fungieren.
6.3. Benachrichtigungen asynchron über Outbox versenden
Rufen Sie den SMS-Dienst nicht innerhalb der Buchungs-Transaktion auf. Fügen Sie stattdessen eine weitere Zeile zur outbox_event-Tabelle in derselben Transaktion hinzu. Ein Worker (oder Debezium) wird das AppointmentBooked-Event zu Kafka pushen, und der Benachrichtigungsdienst wird das Event verbrauchen. Die Buchungs-Transaktion wird dadurch kurz und hängt nicht von externen Diensten ab. Sie senden auch niemals eine SMS für einen Termin, der zurückgerollt wurde.
7. Wann Sie das Design erweitern müssen
- Platz halten bei erforderlicher Vorauszahlung: Fügen Sie Status
HELDmit einerexpires_at-Spalte hinzu.tryReserveerhöht immer nochbooked_count. Ein regelmäßiger Job konvertiert abgelaufeneHELD-Termine inEXPIREDund ruftreleaseauf. Verhindern Sie, dass Plätze unbegrenzt gehalten werden. - Verhindern Sie, dass der Patient zwei Ärzte zur selben Zeit bucht: Ein eindeutiger Index nur nach
slot_idreicht nicht für diesen Fall. Speichern Sie zusätzlichtstzrange(start_at, end_at)inappointmentund verwenden Sie den Exclusion Constraint von PostgreSQL (EXCLUDE USING gist, benötigt diebtree_gist-Extension) für das Paarpatient_id, Zeitbereich. - Flexible Slot-Dauern (Arzt nimmt 15, 30 oder 45 Minuten an): Das Fixed-Slot-Modell passt nicht mehr. Sie benötigen einen Exclusion Constraint für den Zeitbereich pro Arzt anstelle des Zählens der Kapazität.
- Verwenden Sie nicht
SERIALIZABLEfür das gesamte System nur, um Duplizierung zu verhindern. Dies ist korrekt, verursacht aber viele Serialisierungsfehler unter Last. Conditional Updates mit Constraints lösen das Problem mit deutlich niedrigeren Kosten. - Verwenden Sie keine verteilten Locks (Redisson, Zookeeper), wenn Sie nur eine Zeile sperren müssen. Die Datenbank sperrt bereits für Sie, und jede zusätzliche Komponente ist ein weiterer möglicher Fehler.
8. Fazit
Das Design eines Terminbuchungssystems dreht sich um ein Prinzip: Legen Sie den Doppelbuchungsschutz dort ab, wo es starke Konsistenz gibt, nämlich in die Datenbank. Konkret:
- Modellieren Sie jeden Slot als eine Zeile mit
capacityundbooked_count. - Verwenden Sie Conditional Updates, um Prüfung und Schreiben in einem atomaren Schritt durchzuführen.
- Verwenden Sie
CHECK-Constraints und partielle eindeutige Indexes als letzte Verteidigungslinie. - Verwenden Sie
Idempotency-Keyfür sichere Wiederholungen.
Die Schicht darüber (Redis-Cache, Rate Limiting, Markierung voller Slots, Outbox mit Kafka) hilft dem System, Burst-Lasten bei Terminöffnung zu bewältigen. Diese Schicht beeinflusst nicht die Korrektheit der Buchung. Schließlich sollten Sie immer einen Multi-Thread-Test wie in Abschnitt 5.4 auf einer echten Datenbank haben. Das ist der zuverlässigste Weg, um zu beweisen, dass das System niemals zwei Personen denselben Slot geben wird.