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 zu EXPIRED und 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_overlap bedeutet, dass es nicht zwei Zeilen mit derselben car_id geben darf, die period überlappende Bereiche (&&) haben. Die WHERE-Bedingung schließt Buchungen mit den Status CANCELLED, EXPIRED, COMPLETED aus der Einschränkung aus, daher nehmen abgesagte Buchungen keinen Kalenderplatz mehr ein.
  • Die btree_gist-Erweiterung ist erforderlich, um die =-Operation auf BIGINT mit der &&-Operation auf Bereiche in demselben GiST-Index zu kombinieren.
  • provider_txn_id UNIQUE ist 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 period mit 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:

  1. INSERT in payment mit ON CONFLICT DO NOTHING. Wenn inserted == 0, ist dies ein doppelter Webhook und wird übersprungen.
  2. Status mit conditional update ändern (WHERE status = 'HOLD'). Diese Anweisung fungiert als Compare-and-Set, daher ist kein expliziter Lock erforderlich.
  3. Wenn das Update keine Zeile trifft, ist die Reservierung EXPIRED und 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) als cancelledAt, 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 TIMESTAMPTZ und OffsetDateTime, 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_id auf mehrere Datenbanken verteilt wird. Constraints funktionieren nur in einer Datenbank. Wenn du Sharding brauchst, sharde nach car_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.

system-designspring-bootpostgresqlkafkaredis