Voucher-Systemdesign: Nutzungslimit unter Last

1. Übersicht

Ein Voucher „100k Rabatt, nur 1.000 Nutzungen” klingt einfach. Wenn dann der Flash Sale kommt, treffen zehntausende Anfragen gleichzeitig ein. Dann kann das System leicht 1.050 Nutzungen ausgeben statt 1.000, und der Überschuss ist echtes Geld, das verloren geht. Dieser Artikel behandelt eine umfassende Fallstudie: Anforderungen, Datenmodell, API und dann schrittweise Lösungen für die schwierigen Punkte: Race Conditions, Konsistenz und Lastspitzen. Am Ende weißt du, wann ein atomares UPDATE reicht und wann du Redis und Kafka einbringen musst.

2. Anforderungen

2.1. Funktional

  • Erstelle einen Voucher mit Code, Rabattsatz, Mindestestellwert und Gültigkeitszeitraum.
  • Begrenze die Gesamtanzahl der Nutzungen und die Nutzungen pro Kunde.
  • Beim Checkout wird der Voucher reserviert. Nach erfolgreicher Zahlung wird er bestätigt. Bei Storno oder Überschreitung der Frist wird er freigegeben.

2.2. Skalierung und Constraints

  • Rund 10 Millionen Kunden. Ein Flash-Sale-Voucher mit 1.000 Nutzungen kann aber 50.000 Anfragen/Sekunde in den ersten Sekunden erhalten.
  • Das p99 der Reserve-API liegt unter 100 ms.
  • Auf keinen Fall überverkaufen. Temporäres Unterverkaufen ist akzeptabel, z. B. wenn eine Nutzung reserviert wird und erst nach 15 Minuten freigegeben wird.
  • Ein wiederholter Request (Retry) darf keine zusätzliche Nutzung verbrauchen. Mit anderen Worten: die API muss idempotent basierend auf orderId sein.

3. Datenmodell

Die Datenbank (PostgreSQL) ist die endgültige Wahrheitsquelle:

CREATE TABLE voucher (
    code             VARCHAR(32) PRIMARY KEY,
    discount_amount  BIGINT      NOT NULL,
    min_order_amount BIGINT      NOT NULL DEFAULT 0,
    total_limit      INT         NOT NULL,
    used_count       INT         NOT NULL DEFAULT 0,
    per_user_limit   INT         NOT NULL DEFAULT 1,
    starts_at        TIMESTAMP   NOT NULL,
    ends_at          TIMESTAMP   NOT NULL,
    CONSTRAINT ck_used CHECK (used_count <= total_limit)
);

CREATE TABLE voucher_redemption (
    id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    voucher_code VARCHAR(32) NOT NULL REFERENCES voucher(code),
    customer_id  BIGINT      NOT NULL,
    order_id     VARCHAR(64) NOT NULL UNIQUE,
    status       VARCHAR(16) NOT NULL, -- RESERVED, CONFIRMED, RELEASED
    created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uq_voucher_customer UNIQUE (voucher_code, customer_id)
);

Ein paar Punkte beachten:

  • Das CHECK (used_count <= total_limit) ist das letzte Sicherheitsnetz. Selbst wenn es einen Bug im Code gibt, erlaubt die Datenbank keinen Überverkauf.
  • order_id UNIQUE garantiert Idempotenz: eine Bestellung kann nur eine Nutzung nutzen.
  • UNIQUE (voucher_code, customer_id) stimmt nur wenn per_user_limit = 1, was der häufigste Fall ist. Wenn mehrere Nutzungen pro Kunde erlaubt sind, entfernst du diesen Constraint und zählst in einer separaten Tabelle.

4. API

POST   /vouchers/{code}/reservations        body: {customerId, orderId, orderAmount}
       201 {orderId, discountAmount}
       409 {error: "OUT_OF_STOCK" | "ALREADY_USED"}
       422 {error: "NOT_ELIGIBLE"}           (abgelaufen, Bestellung unter Mindestwert)
POST   /vouchers/{code}/reservations/{orderId}/confirm
DELETE /vouchers/{code}/reservations/{orderId}

orderId wird vom Client (Order Service) generiert. Dadurch sendet der Client beim Retry denselben alten orderId und der Server erkennt das als Duplikat.

5. Der naive Ansatz und warum er falsch ist

@Transactional
public boolean redeemNaive(String code) {
    Voucher v = voucherRepository.findById(code).orElseThrow();
    if (v.getUsedCount() >= v.getTotalLimit()) {
        return false;
    }
    v.setUsedCount(v.getUsedCount() + 1);
    return true;
}

Dies ist der klassische Fehler des “Check-Then-Act”. Zwei Threads lesen gleichzeitig usedCount = 999, beide sehen noch Platz, beide schreiben 1000. Das Ergebnis: zwei Kunden bekommen Rabatt, aber der Zähler erhöht sich nur um eins. Bei der Standard-Isolation READ_COMMITTED kann transaction dich in diesem Fall nicht retten.

6. Lösung 1: Bedingter UPDATE in der Datenbank

Die einfachste Lösung ist, Prüfung und Schreib-Operation in eine atomare Anweisung zu kombinieren:

@Service
public class VoucherService {

    private final JdbcTemplate jdbc;

    public VoucherService(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public boolean redeem(String code, long customerId, String orderId) {
        int updated = jdbc.update("""
                UPDATE voucher SET used_count = used_count + 1
                WHERE code = ?
                  AND used_count < total_limit
                  AND CURRENT_TIMESTAMP BETWEEN starts_at AND ends_at
                """, code);
        if (updated == 0) {
            return false; // Quoten aufgebraucht oder abgelaufen
        }
        jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'CONFIRMED')
                """, code, customerId, orderId);
        return true;
    }
}

Der obige Code funktioniert wie folgt:

  • Die UPDATE-Anweisung sperrt die Voucher-Zeile. Die Datenbank evaluiert used_count < total_limit neu, nachdem sie das Lock hat, daher können nicht zwei Requests gleichzeitig bei der letzten Nutzung passieren.
  • updated == 0 bedeutet die Bedingung ist falsch: Quoten aufgebraucht oder Voucher abgelaufen.
  • Wenn der Kunde den Voucher bereits verwendet hat oder orderId erneut gesendet wird, wirft die INSERT-Anweisung DuplicateKeyException. Die Transaction wird zurückgerollt, used_count kehrt zum alten Wert zurück. Im Controller-Layer mappst du diese Exception zu 409 ALREADY_USED.

6.1. Test des Verhaltens unter paralleler Last

@JdbcTest
@Import(VoucherService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class VoucherServiceTest {

    @Autowired JdbcTemplate jdbc;
    @Autowired VoucherService voucherService;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM voucher_redemption");
        jdbc.update("DELETE FROM voucher");
        jdbc.update("""
                INSERT INTO voucher(code, discount_amount, total_limit, starts_at, ends_at)
                VALUES ('FLASH100', 100000, 10, DATEADD('DAY', -1, CURRENT_TIMESTAMP),
                        DATEADD('DAY', 1, CURRENT_TIMESTAMP))
                """);
    }

    @Test
    void shouldNeverExceedTotalLimit() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 200; i++) {
                long customerId = i;
                executor.submit(() -> {
                    if (voucherService.redeem("FLASH100", customerId, "order-" + customerId)) {
                        success.incrementAndGet();
                    }
                });
            }
        }

        Integer used = jdbc.queryForObject(
                "SELECT used_count FROM voucher WHERE code = 'FLASH100'", Integer.class);
        assertThat(success.get()).isEqualTo(10);
        assertThat(used).isEqualTo(10);
    }
}

@Transactional(propagation = NOT_SUPPORTED) deaktiviert die Standard-Transaktion von @JdbcTest. Ohne das sieht die im setUp erstellten Daten nicht committed sein und andere Threads können sie nicht sehen. Mit 200 Virtual Threads um 10 Nutzungen konkurrierend, ist das Ergebnis immer 10:

success = 10, used_count = 10

6.2. Grenzen von Lösung 1

Dieser Weg ist korrekt und für die meisten normalen Voucher (ein paar hundert Anfragen/Sekunde) ausreichend. Das Problem tritt nur auf, wenn die Last auf einer einzelnen Zeile konzentriert ist (Hot Row). 50.000 Anfragen/Sekunde warten alle in einer Schlange auf das Lock der Zeile FLASH100. Jede Transaktion hält das Lock für ein paar Millisekunden, daher wird der effektive Durchsatz auf einige tausend pro Sekunde begrenzt. Der Rest timeouted, der Connection Pool wird erschöpft, und sogar die ganze Datenbank verlangsamt sich, auch für nicht-Voucher-Tabellen.

7. Lösung 2: Redis als “Gating” mit Lua Script

Die Idee: Lege den Zähler der verbleibenden Nutzungen in Redis. Redis läuft Single-Threaded, jedes Lua Script wird atomar ausgeführt und dauert nur Mikrosekunden. Nur Anfragen, die einen Platz reserviert haben, gehen weiter zur Datenbank. Bei einem 1.000er Voucher empfängt die Datenbank maximal 1.000 Schreiboperationen statt 50.000.

Dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Script Reserve, gespeichert unter resources/scripts/reserve.lua:

-- KEYS[1] = voucher:{code}:remaining
-- KEYS[2] = voucher:{code}:users      (hash customerId -> Anzahl Nutzungen)
-- KEYS[3] = voucher:{code}:orders     (hash orderId -> customerId)
-- ARGV[1] = customerId, ARGV[2] = perUserLimit, ARGV[3] = orderId
if redis.call('HEXISTS', KEYS[3], ARGV[3]) == 1 then return 0 end   -- Retry, bereits reserviert
if redis.call('EXISTS', KEYS[1]) == 0 then return -3 end            -- Voucher nicht geladen
local used = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if used >= tonumber(ARGV[2]) then return -2 end                      -- Kunde hat Limit erreicht
if tonumber(redis.call('GET', KEYS[1])) <= 0 then return -1 end      -- Quoten aufgebraucht
redis.call('DECR', KEYS[1])
redis.call('HINCRBY', KEYS[2], ARGV[1], 1)
redis.call('HSET', KEYS[3], ARGV[3], ARGV[1])
return 1

Die Keys verwenden Hash Tag {code}. Dadurch landen alle drei Keys im gleichen Slot auf Redis Cluster und das Script kann laufen.

Auf der Java-Seite:

@Component
public class VoucherGate {

    public enum Result { RESERVED, DUPLICATE, OUT_OF_STOCK, ALREADY_USED, NOT_LOADED }

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("scripts/reserve.lua"), Long.class);

    private final StringRedisTemplate redis;

    public VoucherGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Result reserve(String code, long customerId, int perUserLimit, String orderId) {
        String p = "voucher:{" + code + "}:";
        Long r = redis.execute(RESERVE,
                List.of(p + "remaining", p + "users", p + "orders"),
                String.valueOf(customerId), String.valueOf(perUserLimit), orderId);
        return switch (r.intValue()) {
            case 1 -> Result.RESERVED;
            case 0 -> Result.DUPLICATE;
            case -1 -> Result.OUT_OF_STOCK;
            case -2 -> Result.ALREADY_USED;
            default -> Result.NOT_LOADED;
        };
    }
}

DUPLICATE gilt als erfolgreich: bei Client-Retry erhält er das gleiche alte Ergebnis zurück. NOT_LOADED bedeutet, der Voucher wurde noch nicht “warm up”. Du kannst ihn lazy von der DB laden mit SET ... NX, oder besser: pre-load vor der Sale mit einem Job:

redis-cli SET "voucher:{FLASH100}:remaining" 1000

Eine weitere Optimierung: wenn remaining schon 0 ist, kann das API Gateway oder ein lokales Cache-Flag (local cache, TTL ein paar Sekunden) sofort OUT_OF_STOCK zurückgeben, ohne Redis zu kontaktieren. Zehntausende verspätete Anfragen werden auf der äußersten Schicht gestoppt.

8. Konsistenz zwischen Redis und Datenbank

Redis ist jetzt der Entscheidungsort, aber die Datenbank ist die Wahrheit über das Geld. Es gibt zwei Synchronisierungsweisen.

Weg A: DB-Schreib synchron direkt nach Redis. Du führst immer noch die UPDATE aus Abschnitt 6 aus. Wenn DB fehlschlägt, rufst du das Release-Script auf, um die Nutzung an Redis zurückzugeben. Dies ist einfacher und hat straffe Konsistenz, passt wenn die Quoten klein sind.

Weg B: Ereignisse über Kafka pushen, wenn du die DB vollständig aus dem kritischen Pfad herausnehmen möchtest:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
public record VoucherReserved(String code, long customerId, String orderId) {}

@Component
public class RedemptionConsumer {

    private final JdbcTemplate jdbc;

    public RedemptionConsumer(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @KafkaListener(topics = "voucher-reserved", groupId = "voucher-writer")
    @Transactional
    public void on(VoucherReserved e) {
        int inserted = jdbc.update("""
                INSERT INTO voucher_redemption(voucher_code, customer_id, order_id, status)
                VALUES (?, ?, ?, 'RESERVED')
                ON CONFLICT (order_id) DO NOTHING
                """, e.code(), e.customerId(), e.orderId());
        if (inserted == 1) {
            jdbc.update("UPDATE voucher SET used_count = used_count + 1 WHERE code = ?", e.code());
        }
    }
}

Auf der Service-Seite, nachdem Lua RESERVED zurückgibt, rufst du kafkaTemplate.send("voucher-reserved", code, event) mit code als Key auf. Der Consumer hat diese Eigenschaften:

  • Kafka garantiert at-least-once, daher kann dieselbe Ereignis zweimal ankommen. ON CONFLICT (order_id) DO NOTHING macht das zweite Mal zu no-op, und used_count steigt nur wenn tatsächlich eine neue Zeile eingefügt wird.
  • Das CHECK (used_count <= total_limit) Constraint bleibt bestehen. Wenn Redis falsch gefüllt wurde, lehnt die DB ab und das Ereignis landet im Dead-Letter Topic zur Behandlung durch den Operator.

Hinweis: Wenn Redis erfolgreich reserviert aber Kafka fehlschlägt, bleibt die Nutzung in Redis “stecken”. Das ist Underverkauf, sicher für Geld aber muss bereinigt werden. Daher brauchst du einen Reconciliation-Job (Abschnitt 8.1).

8.1. Reservierungs-Ablauf und Abstimmung

Wenn ein Kunde einen Voucher reserviert, aber den Warenkorb verlässt, muss die Nutzung freigegeben werden. Ein regelmäßiger Job macht das:

@Scheduled(fixedDelay = 60_000)
public void releaseExpired() {
    List<Map<String, Object>> expired = jdbc.queryForList("""
            UPDATE voucher_redemption SET status = 'RELEASED'
            WHERE status = 'RESERVED' AND created_at < CURRENT_TIMESTAMP - INTERVAL '15 minutes'
            RETURNING voucher_code, customer_id, order_id
            """);
    expired.forEach(row -> voucherGate.release(
            (String) row.get("voucher_code"),
            ((Number) row.get("customer_id")).longValue(),
            (String) row.get("order_id")));
}

Die UPDATE ... RETURNING Anweisung (PostgreSQL) ändert den Status und gibt gleichzeitig die betroffenen Zeilen zurück. Daher kann selbst wenn zwei Instanzen den Job gleichzeitig laufen, nicht doppelt freigegeben: Zeilen, die bereits auf RELEASED geändert wurden, passen nicht mehr auf die Bedingung. Du musst auch used_count in der DB entsprechend reduzieren. Die Funktion release ruft ein umgekehrtes Lua Script auf: nur wenn HDEL orders orderId 1 zurückgibt, dann INCR remaining und HINCRBY users customerId -1. Dadurch ist es sicher, Release zweimal aufzurufen.

Füge einen Reconciliation-Job hinzu, der nach jedem Sales-Run läuft: vergleiche total_limit - used_count in der DB mit remaining in Redis und warne bei Abweichungen.

9. Häufige Fehler und wann man es nicht verwenden sollte

  • Verwende GET dann DECR als zwei separate Befehle von Java aus. Das ist wieder Check-Then-Act, nur auf Redis verlagert. Die Prüfung und die Subtraktion müssen im gleichen Lua-Script sein.
  • Verwende nur DECR und prüfe dann auf negativ, dann INCR zurück. Das kann funktionieren, aber im Moment, in dem der Wert negativ ist, kann ein anderer Request falsch ablesen. Es macht auch die Release-Logik komplizierter.
  • Optimistic Locking (@Version) für Hot-Voucher. Wenn 50.000 Anfragen um eine Zeile konkurrieren, scheitern fast alle und müssen retried werden. Die Last ist sogar schlimmer als bei Pessimistic Lock.
  • Redis ohne Persistence/Replication. Wenn Redis neustartet und Daten verliert, und der Warm-Up-Job lädt von total_limit, wirst du überverkaufen. Lade immer von total_limit - used_count aus der DB, minus die RESERVED Nutzungen.
  • Wende Redis + Kafka auf alle Voucher an. Normale Voucher mit niedriger Last brauchen nur Lösung 6. Zwei weitere Systeme bedeuten zwei weitere Orte für Datendiskrepanzen. Aktiviere nur den “Flash-Sale-Modus” für gekennzeichnete Voucher.

Hinweis: Alle Schichten oben sind nur Optimierungen. Die Constraints in der Datenbank (CHECK, UNIQUE) sind die letzte Garantie, dass keine Nutzung überverkauft wird.

10. Fazit

Das Problem der Begrenzung von Voucher-Nutzungen ist im Grunde ein Problem des atomaren Zählens unter Konkurrenz. Bei angemessener Last reicht ein UPDATE ... WHERE used_count < total_limit mit Unique Constraints völlig aus und ist schnell genug. Bei Flash Sales und Hot Rows nutzt du Redis Lua Scripts als atomares Gating. Kafka hilft, DB-Schreibvorgänge aus dem kritischen Pfad zu nehmen, während die zeitgebundene Reservierung und Reconciliation-Jobs Unstimmigkeiten handhaben. Das durchgehende Prinzip: temporäres Underverkaufen akzeptieren, niemals überverkaufen, und die Datenbank als letzte Schutzschicht behalten.

system-designspring-bootrediskafkaconcurrency