Redis für Platzreservierungen: SET NX PX mit automatischem Ablauf

1. Überblick

In Buchungssystemen für Kinos, Flüge oder Restauranttische werden Benutzer normalerweise für einige Minuten einen Platz „reserviert”, um zu bezahlen. Wenn die Zeit abläuft und der Benutzer nicht bezahlt hat, muss der Platz automatisch für andere freigegeben werden. Dieser Artikel zeigt dir, wie du mit Redisbefehl SET key value NX PX einen Platz atomar sperrst, die Sperre automatisch ablaufen lässt und sie sicher mit einem Lua-Skript freigibst oder verlängerst. Danach wirst du einen SeatHoldService haben, der auf Spring Boot 3.x läuft, mit Tests mittels Testcontainers.

2. Das Platz-Reservierungsproblem

Zwei Kunden klicken fast gleichzeitig auf Platz E7 von Vorstellung 42. Das System muss drei Dinge garantieren:

  • Nur eine Person kann den Platz reservieren.
  • Wenn diese Person geht, wird der Platz automatisch nach einer gewissen Zeit freigegeben, z.B. 5 Minuten.
  • Nur die Person, die den Platz hält, kann ihn freigeben oder verlängern.

Du könntest das mit einer Datenbank mit SELECT ... FOR UPDATE und einer Spalte held_until machen. Das funktioniert immer noch, erfordert aber einen Job, um abgelaufene Datensätze zu bereinigen. Ein großer Schreibaufwand während der Vorverkaufszeit kann auch die Datenbank überlasten. Redis löst das eleganter, da es von Natur aus zwei Dinge hat: Eine atomare „setze wenn nicht vorhanden”-Operation und Schlüssel mit TTL, die automatisch ablaufen.

3. Wie SET NX PX funktioniert

Versuchen wir es direkt mit redis-cli:

redis-cli SET seat-hold:{show:42}:E7 "token-alice" NX PX 300000
redis-cli SET seat-hold:{show:42}:E7 "token-bob" NX PX 300000
redis-cli PTTL seat-hold:{show:42}:E7
OK
(nil)
(integer) 299412

Erklärung der einzelnen Teile:

  • NX: setze nur, wenn der Schlüssel noch nicht existiert. Bobs zweiter Befehl gibt nil zurück, weil Alice den Platz bereits reserviert.
  • PX 300000: setzt die TTL auf 300.000 Millisekunden, also 5 Minuten. Nach dieser Zeit löscht Redis den Schlüssel automatisch.
  • Der Wert token-alice ist der Hold-Token, um zu identifizieren, wer den Platz hält. Später wird dieser Token verwendet, um die Sperre sicher freizugeben.

Wichtig ist, dass NX und PX in einem Befehl sind, daher atomar ausgeführt. Die alte Methode, erst SETNX dann PEXPIRE zu nutzen, hat ein Risiko: Wenn die Anwendung zwischen den beiden Befehlen abstürzt, existiert der Schlüssel für immer und der Platz ist dauerhaft gesperrt.

Der {show:42}-Teil im Schlüssel ist ein Hash Tag. Bei Redis Cluster liegen alle Schlüssel mit dem gleichen Hash Tag im gleichen Slot. So kannst du Lua-Skripte auf mehreren Plätzen der gleichen Vorstellung ausführen, wie in Abschnitt 7.

4. Spring Boot Konfiguration

Füge die Abhängigkeit Spring Data Redis hinzu. Lettuce ist der Standard-Client:

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

Konfiguriere die Verbindung:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s

Spring Boot erstellt automatisch das Bean StringRedisTemplate. Schlüssel und Werte sind beide Strings, was für dieses Problem passt.

5. Platz mit setIfAbsent reservieren

@Service
public class SeatHoldService {

    private final StringRedisTemplate redis;

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

    static String key(long showId, String seatId) {
        return "seat-hold:{show:%d}:%s".formatted(showId, seatId);
    }

    public Optional<String> hold(long showId, String seatId, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(key(showId, seatId), token, ttl);
        return Boolean.TRUE.equals(ok) ? Optional.of(token) : Optional.empty();
    }

    public boolean isHeld(long showId, String seatId) {
        return Boolean.TRUE.equals(redis.hasKey(key(showId, seatId)));
    }
}

Erklärung:

  • setIfAbsent(key, value, Duration) erzeugt den Befehl SET key value NX PX <ms>. Wenn ttl eine gerade Anzahl von Sekunden ist, könnte Spring EX statt PX verwenden, aber die Bedeutung bleibt die gleiche.
  • Jedes Mal, wenn ein Platz reserviert wird, wird eine neue UUID als Token generiert. Der Token wird dem Client zurückgegeben, z.B. in der Antwort von POST /holds. Der Client sendet den Token zurück, wenn er bezahlt oder abbricht.
  • setIfAbsent gibt möglicherweise Boolean null zurück, wenn es in einer Pipeline oder Transaktion läuft. Daher vergleicht der Code mit Boolean.TRUE.equals(ok) statt direkt zu unboxen.

Beispiel der Verwendung in einem Controller:

@PostMapping("/shows/{showId}/seats/{seatId}/hold")
public ResponseEntity<Map<String, String>> hold(@PathVariable long showId,
                                                @PathVariable String seatId) {
    return seatHoldService.hold(showId, seatId, Duration.ofMinutes(5))
            .map(token -> ResponseEntity.ok(Map.of("holdToken", token)))
            .orElseGet(() -> ResponseEntity.status(HttpStatus.CONFLICT).build());
}

Der nächste Benutzer erhält 409 Conflict. Das Frontend kann damit anzeigen, dass der Platz bereits ausgewählt wurde.

6. Sperre sicher mit Lua freigeben und verlängern

6.1. Warum nicht direkt DEL verwenden

Betrachte dieses Szenario:

  1. Alice reserviert den Platz mit TTL 5 Minuten.
  2. Alice hängt auf der Zahlungsseite fest, länger als 5 Minuten, und der Schlüssel läuft automatisch ab.
  3. Bob reserviert jetzt Platz E7.
  4. Alice klickt „Abbrechen” und der Server ruft DEL auf. Bobs Platz wird gelöscht.

Du musst den Token vor dem Löschen überprüfen. Wenn du GET und dann DEL in zwei separaten Befehlen machst, könnte der Schlüssel zwischen den Befehlen ablaufen und den Besitzer wechseln. Ein Lua-Skript läuft atomar auf Redis aus, daher entfernt es diese Lücke.

6.2. Release und extend

@Service
public class SeatHoldService {

    private static final RedisScript<Long> RELEASE = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private static final RedisScript<Long> EXTEND = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('PEXPIRE', KEYS[1], ARGV[2])
            end
            return 0
            """, Long.class);

    // ... constructor, key(), hold(), isHeld() wie oben

    public boolean release(long showId, String seatId, String token) {
        Long n = redis.execute(RELEASE, List.of(key(showId, seatId)), token);
        return n != null && n == 1L;
    }

    public boolean extend(long showId, String seatId, String token, Duration ttl) {
        Long n = redis.execute(EXTEND, List.of(key(showId, seatId)),
                token, String.valueOf(ttl.toMillis()));
        return n != null && n == 1L;
    }
}

Erklärung:

  • KEYS[1] ist der Platz-Schlüssel, ARGV[1] ist der Token, den der Client sendet. Das Skript führt DEL oder PEXPIRE nur aus, wenn der aktuelle Wert dem Token entspricht.
  • Das Skript gibt 1 zurück bei Erfolg, 0 wenn der Token nicht übereinstimmt oder der Schlüssel bereits abgelaufen ist.
  • DefaultRedisScript berechnet selbst die SHA1 und ruft EVALSHA auf. Wenn Redis das Skript noch nicht gepuffert hat, fallback Spring automatisch auf EVAL. Du solltest das Skript als static final deklarieren, damit es nicht jedes Mal neu erstellt wird.

extend ist nützlich, wenn der Benutzer zum Zahlungsportal wechselt und du weitere 2 Minuten hinzufügen möchtest. Wenn extend false zurückgibt, weißt du definitiv, dass der Platz weg ist und musst den Benutzer informieren, einen anderen zu wählen.

7. Mehrere Plätze gleichzeitig reservieren

Eine Gruppe von Freunden reserviert drei Plätze E7, E8, E9. Die Anforderung ist: alle drei oder keiner reserviert werden. Du könntest hold dreimal aufrufen und zurückrollen, wenn es fehlschlägt, aber das ist umständlich und es gibt Momente, in denen andere einen halb reservierten Zustand sehen. Ein Lua-Skript macht das eleganter:

private static final RedisScript<Long> HOLD_ALL = new DefaultRedisScript<>("""
        for _, k in ipairs(KEYS) do
          if redis.call('EXISTS', k) == 1 then return 0 end
        end
        for _, k in ipairs(KEYS) do
          redis.call('SET', k, ARGV[1], 'PX', ARGV[2])
        end
        return 1
        """, Long.class);

public Optional<String> holdAll(long showId, List<String> seatIds, Duration ttl) {
    String token = UUID.randomUUID().toString();
    List<String> keys = seatIds.stream().map(s -> key(showId, s)).toList();
    Long n = redis.execute(HOLD_ALL, keys, token, String.valueOf(ttl.toMillis()));
    return n != null && n == 1L ? Optional.of(token) : Optional.empty();
}

Die erste Schleife prüft, ob irgendwelche Plätze bereits reserviert sind. Wenn nicht, setzt die zweite Schleife alle mit dem gleichen Token und der gleichen TTL. Das gesamte Skript läuft atomar ab, daher kann keine andere Anfrage dazwischen eingreifen.

Dank des Hash Tags {show:42} liegen alle Schlüssel der gleichen Vorstellung im gleichen Slot. Ohne Hash Tag würde Redis Cluster einen CROSSSLOT-Fehler werfen, wenn das Skript mehrere Schlüssel erreicht.

8. Test mit Testcontainers schreiben

Füge die Test-Abhängigkeit hinzu:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Test für die Hauptverhalten:

@SpringBootTest
@Testcontainers
class SeatHoldServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired SeatHoldService service;
    @Autowired StringRedisTemplate template;

    @BeforeEach
    void clean() {
        template.getConnectionFactory().getConnection().serverCommands().flushAll();
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() {
        Optional<String> alice = service.hold(42, "E7", Duration.ofMinutes(5));
        Optional<String> bob = service.hold(42, "E7", Duration.ofMinutes(5));

        assertThat(alice).isPresent();
        assertThat(bob).isEmpty();
    }

    @Test
    void holdExpiresAutomatically() throws InterruptedException {
        service.hold(42, "E7", Duration.ofMillis(200));
        Thread.sleep(400);

        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.hold(42, "E7", Duration.ofMinutes(5))).isPresent();
    }

    @Test
    void staleTokenCannotReleaseSomeoneElsesHold() {
        String aliceToken = service.hold(42, "E7", Duration.ofMillis(100)).orElseThrow();
        await(200);
        String bobToken = service.hold(42, "E7", Duration.ofMinutes(5)).orElseThrow();

        assertThat(service.release(42, "E7", aliceToken)).isFalse();
        assertThat(service.isHeld(42, "E7")).isTrue();
        assertThat(service.release(42, "E7", bobToken)).isTrue();
    }

    @Test
    void holdAllIsAllOrNothing() {
        service.hold(42, "E8", Duration.ofMinutes(5));

        assertThat(service.holdAll(42, List.of("E7", "E8", "E9"), Duration.ofMinutes(5))).isEmpty();
        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.isHeld(42, "E9")).isFalse();
    }

    private static void await(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

@ServiceConnection(name = "redis") teilt Spring Boot mit, dass dieser Container Redis ist. Spring Boot konfiguriert automatisch Host und Port zu dem Container, du brauchst keine @DynamicPropertySource. Der Test staleTokenCannotReleaseSomeoneElsesHold reproduziert genau das Szenario aus Abschnitt 6.1. Alices alter Token kann Bobs Sperre nicht löschen.

Du kannst auch echte Parallelität mit mehreren Threads testen:

@Test
void hundredConcurrentRequestsYieldExactlyOneWinner() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Optional<String>>> results = IntStream.range(0, 100)
                .mapToObj(i -> executor.submit(() -> service.hold(42, "E7", Duration.ofMinutes(5))))
                .toList();

        long winners = 0;
        for (var f : results) if (f.get().isPresent()) winners++;
        assertThat(winners).isEqualTo(1);
    }
}

Der Test verwendet Virtual Threads von Java 21, um 100 gleichzeitige Anfragen zu senden. Es gibt immer genau einen Gewinner.

9. Häufige Fehler und Grenzen

Hinweis: Redis-Sperren sind nur eine „weiche” Schutzschicht. Wenn du eine Buchung bestätigst, solltest du immer noch einen Unique Constraint auf (show_id, seat_id) in der Tabelle booking haben. Redis kann während eines Failovers Daten verlieren, da Replicas Daten asynchron empfangen. Ohne Constraint könnten zwei Personen den gleichen Platz bestätigt bekommen.

Andere häufige Fehler:

  • SETNX dann EXPIRE verwenden. Wenn die Anwendung zwischen den zwei Befehlen abstürzt, läuft die Sperre nie ab. Verwende immer SET ... NX PX in einem Befehl.
  • Sperre mit DEL freigeben ohne Token zu prüfen. Du kannst aus Versehen die Sperre von jemand anderem löschen wie im Szenario in Abschnitt 6.1.
  • Buchung bestätigen, ohne die Sperre zu prüfen. Vor dem Schreiben der Buchung, rufe ein Skript auf, das den Token prüft, oder nutze release und schreibe nur, wenn das Ergebnis true ist. Wenn die Zahlung erfolgreich ist, aber die Sperre verloren geht, brauchst du einen Rückerstattungsprozess.
  • TTL zu kurz oder zu lang. Zu kurz und der Benutzer verliert den Platz beim Eingeben der Karte. Zu lang und der Platz wird während der Spitzenzeit „virtuell blockiert”. Halte TTL kurz und nutze extend bei jedem Schritt.
  • Hash Tag vergessen, wenn du Cluster laufen lässt. Skripte mit mehreren Keys geben Fehler CROSSSLOT.
  • Auf die Uhr der Anwendung verlassen, um die Haltezeit zu berechnen. Lass Redis die TTL verwalten. Wenn die UI einen Countdown anzeigen muss, lies PTTL statt selbst zu berechnen.

Wann du diese Methode nicht verwenden solltest:

  • Wenn du absolute Garantie auf Sperren-Ebene brauchst, z.B. finanzielle Ressourcen ohne Datenbank-Constraint dahinter. Ein einzelner Redis mit asynchroner Replikation bietet nicht diese Garantie.
  • Wenn der Durchsatz klein ist und die Datenbank ausreicht. Eine held_until-Spalte mit Unique Constraint könnte einfacher sein als zusätzliche Redis-Verwaltung.
  • Wenn der Reservierungsstatus lange dokumentiert werden muss. Redis-Keys verschwinden von selbst, daher brauchst du separate Event-Logging.

10. Fazit

Der Befehl SET key value NX PX gibt dir eine atomare, selbstablaufende Platz-Sperre in einem Aufruf. Kombiniert mit zufälligen Tokens und Lua-Skripten kannst du die Sperre freigeben oder verlängern, ohne versehentlich die eines anderen zu löschen. Du kannst auch mehrere Plätze im All-or-Nothing-Stil reservieren. Verwende Hash Tags, wenn du Redis Cluster laufen lässt. Schließlich, lass die Datenbank mit Unique Constraint als letzte Schutzschicht beim Bestätigen der Buchung fungieren.

redisspring-bootdistributed-lockjavaconcurrency