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 gibtnilzurü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-aliceist 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 BefehlSET key value NX PX <ms>. Wennttleine gerade Anzahl von Sekunden ist, könnte SpringEXstattPXverwenden, aber die Bedeutung bleibt die gleiche.- Jedes Mal, wenn ein Platz reserviert wird, wird eine neue
UUIDals Token generiert. Der Token wird dem Client zurückgegeben, z.B. in der Antwort vonPOST /holds. Der Client sendet den Token zurück, wenn er bezahlt oder abbricht. setIfAbsentgibt möglicherweiseBooleannullzurück, wenn es in einer Pipeline oder Transaktion läuft. Daher vergleicht der Code mitBoolean.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:
- Alice reserviert den Platz mit TTL 5 Minuten.
- Alice hängt auf der Zahlungsseite fest, länger als 5 Minuten, und der Schlüssel läuft automatisch ab.
- Bob reserviert jetzt Platz
E7. - Alice klickt „Abbrechen” und der Server ruft
DELauf. 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ührtDELoderPEXPIREnur aus, wenn der aktuelle Wert dem Token entspricht.- Das Skript gibt
1zurück bei Erfolg,0wenn der Token nicht übereinstimmt oder der Schlüssel bereits abgelaufen ist. DefaultRedisScriptberechnet selbst die SHA1 und ruftEVALSHAauf. Wenn Redis das Skript noch nicht gepuffert hat, fallback Spring automatisch aufEVAL. Du solltest das Skript alsstatic finaldeklarieren, 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 Tabellebookinghaben. 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:
SETNXdannEXPIREverwenden. Wenn die Anwendung zwischen den zwei Befehlen abstürzt, läuft die Sperre nie ab. Verwende immerSET ... NX PXin einem Befehl.- Sperre mit
DELfreigeben 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
releaseund schreibe nur, wenn das Ergebnistrueist. 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
extendbei 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
PTTLstatt 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.