Systemdesign für Autovermietung: Fahrzeugkalender, Kaution und Stornierung
1. Übersicht
Eine Autovermietung klingt simpel: Kunde wählt Auto, wählt Datum, zahlt Kaution und erhält das Auto. Das Schwierigste ist, sicherzustellen, dass ein Fahrzeug nicht von zwei Personen zur gleichen Zeit gebucht wird. Das System muss auch korrekt damit umgehen, wenn das Payment-Gateway den Webhook mehrfach aufruft, und muss mit sprunghaft ansteigenden Zugriffen vor Tet-Feiertagen oder dem 30. April umgehen können.
Dieser Artikel behandelt das gesamte Design: Anforderungen, Datenmodell, API, Reservierungsfluss, Kaution und Stornierung. Jeder schwierige Punkt wird mit Beispielcode in Java 21, Spring Boot 3.x, PostgreSQL, Redis und Kafka illustriert. Nach dem Lesen hast du ein Designframework für ein zeitraumbasiertes Buchungssystem, egal ob es um Autos, Hotelzimmer, Sportplätze oder Konferenzräume geht.
2. Anforderungen
2.1. Funktionale Anforderungen
- Kunden suchen nach Fahrzeugen nach Stadt und Zeitraum, z.B.
2026-01-25 08:00→2026-01-28 20:00. - Kunden können ein Fahrzeug 15 Minuten lang reservieren (hold), um die Kaution zu zahlen.
- Bei erfolgreicher Kaution wechselt die Buchung zu
CONFIRMED. Nach 15 Minuten ohne Zahlung wechselt die Buchung zuEXPIREDund das Fahrzeug wird freigegeben. - Bei Kundenabsage wird die Kaution gemäß Richtlinie erstattet: 100% vor 72 Stunden, 50% vor 24 Stunden, danach keine Erstattung.
- Wenn der Fahrzeugbesitzer storniert, erhält der Kunde die volle Kaution zurück.
- Benachrichtigungen (SMS, Push) versenden, wenn sich der Buchungsstatus ändert.
2.2. Skalierung und Einschränkungen
| Chiffre | Normal | Spitzenwert (Tet) |
|---|---|---|
| Anzahl der Fahrzeuge | 20.000 | 20.000 |
| Suchanfragen | ~200 req/s | ~3.000 req/s |
| Reservierungsanfragen | ~10 req/s | ~300 req/s |
| Lese-/Schreibverhältnis | ~20:1 | ~10:1, konzentriert auf wenige hundert „beliebte” Fahrzeuge |
Die wichtigste Einschränkung ist niemals eine Doppelbuchung. Das System darf mit „Fahrzeug wurde gerade gebucht” antworten, aber es dürfen niemals zwei Verträge auf dasselbe Fahrzeug für denselben Zeitraum zeigen. Daher müssen Buchungsdaten stark konsistent sein. Die Suchseite kann einige Dutzend Sekunden alte Daten akzeptieren.
3. Datenmodell
PostgreSQL ist die richtige Wahl, weil es den Typ tstzrange und Exclusion Constraints bietet. Mit diesen zwei Tools verhindert die Datenbank selbst, dass sich zwei Zeiträume auf demselben Fahrzeug überlappen.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE car (
id BIGSERIAL PRIMARY KEY,
plate VARCHAR(20) NOT NULL UNIQUE,
model VARCHAR(100) NOT NULL,
city VARCHAR(50) NOT NULL,
daily_price BIGINT NOT NULL
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
car_id BIGINT NOT NULL REFERENCES car(id),
customer_id BIGINT NOT NULL,
period TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL,
deposit_amount BIGINT NOT NULL,
hold_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_overlap EXCLUDE USING gist (
car_id WITH =,
period WITH &&
) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES booking(id),
provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL, -- DEPOSIT | REFUND
amount BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Einige wichtige Punkte:
no_overlapbedeutet, dass es nicht zwei Zeilen mit derselbencar_idgeben darf, dieperiodüberlappende Bereiche (&&) haben. DieWHERE-Bedingung schließt Buchungen mit den StatusCANCELLED,EXPIRED,COMPLETEDaus der Einschränkung aus, daher nehmen abgesagte Buchungen keinen Kalenderplatz mehr ein.- Die
btree_gist-Erweiterung ist erforderlich, um die=-Operation aufBIGINTmit der&&-Operation auf Bereiche in demselben GiST-Index zu kombinieren. provider_txn_id UNIQUEist die Grundlage für Idempotenz bei der Verarbeitung von Payment-Webhooks.- Die
outbox-Tabelle ermöglicht das sichere Senden von Ereignissen an Kafka ohne Datenverlust (siehe Abschnitt 7).
Hinweis: Fahrzeuge benötigen Reinigungs- und Inspektionszeit zwischen Fahrten. Du solltest
periodmit einem Puffer speichern, z.B. 2 Stunden nach der Rückgabezeit. Addiere den Puffer nicht im Anwendungscode, sonst wird irgendwann an einer Stelle vergessen.
4. API-Design
| Methode | Endpoint | Beschreibung |
|---|---|---|
GET |
/cars?city=HN&from=…&to=… |
Verfügbare Fahrzeuge suchen |
POST |
/bookings |
Reservieren und bookingId sowie Zahlungslink zurückgeben |
POST |
/payments/webhook |
Payment-Gateway meldet Ergebnis |
POST |
/bookings/{id}/cancel |
Fahrt stornieren |
POST /bookings akzeptiert den Header Idempotency-Key. Wenn die mobile App wegen Netzwerkproblemen erneut versucht, erstellt eine wiederholte Anfrage keine neue Buchung. Der Server gibt 409 Conflict zurück, wenn das Fahrzeug bereits gebucht ist, und 201 Created mit dem folgenden Body bei erfolgreicher Reservierung:
{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
"paymentUrl": "https://pay.example/checkout/98123" }
5. Reservierungsfluss
5.1. Doppelbuchungen durch Constraints verhindern
Ein bekannter, aber falscher Ansatz ist “SELECT zum Prüfen auf Verfügbarkeit, dann INSERT”. Zwei gleichzeitig ankommende Requests sehen beide das Fahrzeug frei, und beide INSERT erfolgreich. Stattdessen führst du einfach INSERT aus und lässt den Exclusion Constraint entscheiden, welche Anfrage gewinnt.
Ein Beispiel mit JdbcClient benötigt die folgende Abhängigkeit:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class BookingService {
private final JdbcClient jdbc;
public BookingService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public long hold(long carId, long customerId,
OffsetDateTime from, OffsetDateTime to, long deposit) {
try {
return jdbc.sql("""
INSERT INTO booking(car_id, customer_id, period, status,
deposit_amount, hold_expires_at)
VALUES (:carId, :customerId,
tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
'HOLD', :deposit, now() + interval '15 minutes')
RETURNING id
""")
.param("carId", carId)
.param("customerId", customerId)
.param("from", from)
.param("to", to)
.param("deposit", deposit)
.query(Long.class)
.single();
} catch (DataIntegrityViolationException e) {
throw new CarUnavailableException(carId);
}
}
}
public class CarUnavailableException extends RuntimeException {
public CarUnavailableException(long carId) {
super("Fahrzeug " + carId + " ist bereits in diesem Zeitraum gebucht");
}
}
Der Bereich '[)' schließt den Startpunkt ein, aber nicht den Endpunkt. Dadurch überlappen eine Fahrt, die um 20:00 endet, und eine, die um 20:00 beginnt, nicht. Wenn eine Constraint verletzt wird, gibt PostgreSQL SQLState 23P01 zurück, und Spring übersetzt diesen Fehler in DataIntegrityViolationException. Wir wandeln ihn in CarUnavailableException um, damit der Controller ihn auf 409 abbildet.
Wenn zwei Kunden Fahrzeug 42 für denselben Zeitraum reservieren, sieht das Log so aus:
customer=1001 -> 201 Created bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict Fahrzeug 42 ist bereits in diesem Zeitraum gebucht
5.2. Ablauf der Reservierungsfrist
Ein regelmäßiger Job konvertiert abgelaufene HOLD-Buchungen zu EXPIRED. Buchungen mit EXPIRED liegen außerhalb der WHERE-Bedingung der Constraint, daher wird das Fahrzeug sofort freigegeben.
@Component
public class HoldExpiryJob {
private final JdbcClient jdbc;
public HoldExpiryJob(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Scheduled(fixedDelay = 30_000)
public void expireHolds() {
int expired = jdbc.sql("""
UPDATE booking SET status = 'EXPIRED'
WHERE status = 'HOLD' AND hold_expires_at < now()
""").update();
if (expired > 0) {
System.out.println("Abgelaufene Reservierungen: " + expired);
}
}
}
Denke daran, @EnableScheduling auf einer Konfigurationsklasse zu aktivieren. Dieser Job läuft auf mehreren Instanzen parallel und ist dennoch sicher, weil die UPDATE ... WHERE status = 'HOLD'-Anweisung bereits idempotent ist.
6. Kaution: Idempotente Webhooks und Race-Bedingungen mit Ablauf
Das Payment-Gateway ruft den Webhook normalerweise mindestens einmal auf, das bedeutet, es kann zwei- oder dreimal aufgerufen werden. Außerdem kann der Webhook ankommen, nachdem die Reservierung abgelaufen ist, z.B. wenn der Kunde um 14:59 zahlt, aber die Bank langsam ist. Beide Situationen müssen behandelt werden.
@Service
public class PaymentService {
private final JdbcClient jdbc;
private final OutboxWriter outbox;
public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
this.jdbc = jdbc;
this.outbox = outbox;
}
@Transactional
public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
int inserted = jdbc.sql("""
INSERT INTO payment(booking_id, provider_txn_id, type, amount)
VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
ON CONFLICT (provider_txn_id) DO NOTHING
""")
.param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
.update();
if (inserted == 0) {
return; // webhook ist ein Duplikat, wurde bereits verarbeitet
}
int confirmed = jdbc.sql("""
UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
WHERE id = :id AND status = 'HOLD'
""").param("id", bookingId).update();
if (confirmed == 1) {
outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
} else {
outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
}
}
}
Der Verarbeitungsfluss läuft wie folgt ab:
- INSERT in
paymentmitON CONFLICT DO NOTHING. Wenninserted == 0, ist dies ein doppelter Webhook und wird übersprungen. - Status mit conditional update ändern (
WHERE status = 'HOLD'). Diese Anweisung fungiert als Compare-and-Set, daher ist kein expliziter Lock erforderlich. - Wenn das Update keine Zeile trifft, ist die Reservierung
EXPIREDund das Fahrzeug gehört möglicherweise einem anderen. Wir geben automatisch den Betrag zurück und versuchen nicht, die Buchung “wiederherzustellen”.
Alle Operationen finden in einer Transaktion statt. Entweder werden Payment, Buchungsstatus und Outbox-Event zusammen geschrieben, oder gar nichts.
7. Stornierung und Rückerstattung
Die Rückerstattungsrichtlinie ist reine Logik und sollte in einer separaten Klasse extrahiert werden, um sie leicht zu testen:
public final class CancellationPolicy {
private CancellationPolicy() {}
public static long refundAmount(long deposit, OffsetDateTime pickupAt,
OffsetDateTime cancelledAt, boolean cancelledByHost) {
if (cancelledByHost) {
return deposit;
}
long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
if (hoursBefore >= 72) return deposit;
if (hoursBefore >= 24) return deposit / 2;
return 0;
}
}
Test mit JUnit 5 und AssertJ:
class CancellationPolicyTest {
private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");
@Test
void refundsFullWhenCancelledThreeDaysBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
.isEqualTo(2_000_000);
}
@Test
void refundsHalfWhenCancelledOneDayBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
.isEqualTo(1_000_000);
}
@Test
void refundsNothingWhenCancelledLastMinute() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
.isZero();
}
@Test
void refundsFullWhenHostCancels() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
.isEqualTo(2_000_000);
}
}
Auf der Service-Ebene verwendet die Stornierung auch conditional update und RETURNING, um die Daten für die Gebührenberechnung in derselben Anweisung zu erhalten:
UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;
Wenn die Anweisung keine Zeile zurückgibt, wurde die Buchung bereits storniert oder der Kunde hat das Fahrzeug bereits erhalten (ON_TRIP). Die API gibt dann 409 zurück. Wenn eine Zeile zurückkommt, berechnen wir refundAmount und schreiben das Ereignis refund.requested in derselben Transaktion in die Outbox.
Hinweis: Verwende immer die Server-Zeit (injizierter
Clock) alscancelledAt, nicht die Zeit vom Client. Ein Unterschied von wenigen Minuten um die 24-Stunden-Marke herum ist eine Differenz von Millionen.
8. Spitzenlast und zuverlässiges Event-Messaging
8.1. Lesen und Schreiben trennen
Während der Spitzensaison kommt der Großteil der Last von Suchanfragen. Suchergebnisse können in Redis mit dem Schlüssel avail:{city}:{date} und TTL 30–60 Sekunden gecacht werden. Cached-Daten können veraltet sein, und das ist ok, da der Constraint in PostgreSQL die ultimative Wahrheitsquelle ist. Im schlimmsten Fall sieht der Kunde das Fahrzeug als “verfügbar” und erhält 409 beim Reservieren.
8.2. Fahrzeugblockade verhindern
Vor Tet blockieren einige Kunden (oder Bots) viele Fahrzeuge und zahlen nie. Du kannst jeden Kunden auf maximal 3 Reservierungen pro 15 Minuten mit Redis beschränken:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {
private final StringRedisTemplate redis;
public HoldRateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long customerId) {
String key = "hold-limit:" + customerId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofMinutes(15));
}
return count != null && count <= 3;
}
}
increment ist eine atomare Operation, daher zählen mehrere Instanzen, die gleichzeitig aufgerufen werden, immer noch korrekt. Der 4. Versuch innerhalb des 15-Minuten-Fensters erhält false, und die API gibt 429 Too Many Requests zurück.
8.3. Outbox zu Kafka
Kafka-Nachrichten direkt in der Transaktion zu senden, ist sehr riskant. Wenn der DB-Commit erfolgreich ist, aber Kafka fehlschlägt, geht das Ereignis verloren. Wenn die Reihenfolge umgekehrt ist, senden wir ein “Phantom”-Ereignis für eine Änderung, die nie gespeichert wurde. Das Pattern transactional outbox löst dieses Problem: Schreibe Ereignisse in die outbox-Tabelle in derselben Transaktion, dann liest ein Relay diese Tabelle und schiebt sie zu Kafka.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {
private final JdbcClient jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publish() {
var rows = jdbc.sql("""
SELECT id, topic, payload::text AS payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""").query().listOfRows();
for (var row : rows) {
kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
.param("id", row.get("id")).update();
}
}
}
FOR UPDATE SKIP LOCKED ermöglicht mehreren Relay-Instanzen, parallel zu laufen, ohne Zeilen doppelt zu verarbeiten. Das Relay kann immer noch ein Ereignis doppelt senden, z.B. wenn es nach send aber vor UPDATE abstürzt. Daher muss der Consumer (Benachrichtigungsdienst, Rückerstattungsdienst) nach bookingId idempotent sein.
9. Häufige Fehler und wann man diesen Ansatz nicht verwenden sollte
- Mit SELECT auf Verfügbarkeit prüfen, dann INSERT. Diese Methode hat immer Race Conditions. Lass den Constraint seine Arbeit machen.
- Mit Redis-Locks statt Constraints sperren. Locks können in der Mitte ablaufen, z.B. bei GC-Pausen. Redis sollte nur eine sekundäre Schutzschicht sein, nicht die Quelle der Wahrheit.
- Zeit ohne Zeitzonen speichern. Verwende
TIMESTAMPTZundOffsetDateTime, besonders wenn du später auf mehrere Länder erweiterst. - Rückerstattung synchron im Stornierungsrequest. Langsame Payment-Gateways verlangsamen die Stornierungs-API. Schiebe die Rückerstattung über die Outbox.
- Verwende Exclusion Constraints nicht, wenn der Zeitplan nach einem anderen Kriterium als
car_idauf mehrere Datenbanken verteilt wird. Constraints funktionieren nur in einer Datenbank. Wenn du Sharding brauchst, sharde nachcar_id, damit alle Buchungen eines Fahrzeugs an derselben Stelle sind.
10. Fazit
Das Problem der Autovermietung dreht sich um drei Hauptpunkte. Erstens, lass die Datenbank Doppelbuchungen mit tstzrange und Exclusion Constraints verhindern. Zweitens, ändere Status mit conditional update kombiniert mit eindeutigen Keys, um wiederholte Webhooks und Races mit abgelaufenen Reservierungen zu behandeln. Drittens, trenne Nebentasks wie Benachrichtigungen und Rückerstattungen vom Hauptfluss mit transactional Outbox und Kafka. Redis verwaltet den Lastausgleich: Zwischenspeicherung von Suchanfragen und Limitierung von Reservierungsversuchen. Die Wahrheitsquelle bleibt PostgreSQL.
Dieses Designframework gilt für die meisten zeitraumbasierten Buchungssysteme. Du musst nur car durch Hotelzimmer, Sportplätze oder Konferenzräume ersetzen.