Redis pour réservation : verrouillage avec SET NX PX et expiration auto
1. Vue d’ensemble
Dans un système de réservation de places (cinéma, vols aériens, tables de restaurant), les utilisateurs peuvent généralement “réserver” une place quelques minutes le temps de payer. Passé ce délai sans paiement, la place doit être automatiquement libérée pour un autre client. Cet article vous montrera comment utiliser la commande SET key value NX PX de Redis pour verrouiller une place de manière atomique, avec expiration automatique, et libérer ou prolonger le verrou de manière sécurisée via un script Lua. À la fin, vous disposerez d’un service SeatHoldService fonctionnel sur Spring Boot 3.x, accompagné de tests avec Testcontainers.
2. Le problème de la réservation
Deux clients sélectionnent presque simultanément le siège E7 de la séance 42. Le système doit garantir trois choses :
- Seule une personne peut réserver le siège.
- Si cette personne abandonne, le siège est automatiquement libéré après une durée définie, par exemple 5 minutes.
- Seul la personne qui a réservé peut libérer ou prolonger la réservation.
Vous pourriez le faire avec une base de données utilisant SELECT ... FOR UPDATE et une colonne held_until. Cela fonctionne, mais nécessite un job pour nettoyer les enregistrements expirés. De plus, un important volume d’écritures au moment de l’ouverture des ventes peut surcharger la base de données. Redis offre une solution plus élégante grâce à deux fonctionnalités intégrées : une opération “set si absent” atomique, et des clés qui expirent automatiquement.
3. Comment fonctionne SET NX PX
Essayez directement avec 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
Voici ce que signifie chaque partie :
NX: définir la clé seulement si elle n’existe pas encore. La deuxième commande de Bob retournenilcar Alice a déjà réservé le siège.PX 300000: définir un TTL de 300 000 millisecondes, soit 5 minutes. Après ce délai, Redis supprime automatiquement la clé.- La valeur
token-aliceest un jeton de réservation pour identifier qui détient le siège. Nous l’utiliserons plus tard pour libérer le verrou de manière sécurisée.
Le point crucial est que NX et PX font partie d’une même commande, donc exécutés de manière atomique. L’ancienne approche SETNX suivi de PEXPIRE pose risque : si l’application plante entre les deux commandes, la clé reste indéfiniment et le siège reste verrouillé pour toujours.
La partie {show:42} dans la clé est une hash tag. Avec Redis Cluster, toutes les clés ayant la même hash tag résident sur le même slot. Cela permet d’utiliser un script Lua sur plusieurs sièges de la même séance, comme montré à la section 7.
4. Configuration Spring Boot
Ajoutez la dépendance Spring Data Redis. Lettuce est le client par défaut :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configurez la connexion :
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s
Spring Boot crée automatiquement un bean StringRedisTemplate. Les clés et les valeurs sont des chaînes, ce qui convient parfaitement à ce problème.
5. Réserver une place avec setIfAbsent
@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)));
}
}
Explications :
setIfAbsent(key, value, Duration)génère la commandeSET key value NX PX <ms>. Si lettlest un nombre entier de secondes, Spring peut utiliserEXà la place dePX, mais l’effet reste identique.- À chaque réservation, un nouvel
UUIDest généré comme jeton. Ce jeton est retourné au client, par exemple dans la réponse de l’APIPOST /holds. Le client le renvoie lors du paiement ou de l’annulation. setIfAbsentretourne unBooleanqui peut êtrenulllors d’une utilisation dans un pipeline ou une transaction. C’est pourquoi le code compare avecBoolean.TRUE.equals(ok)au lieu de déboxer directement.
Exemple d’utilisation dans un contrôleur :
@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());
}
Le client suivant reçoit 409 Conflict. L’interface peut l’utiliser pour afficher “Ce siège a déjà été sélectionné”.
6. Libérer et prolonger le verrou en toute sécurité avec Lua
6.1. Pourquoi ne pas utiliser DEL directement
Considérez ce scénario :
- Alice réserve le siège avec un TTL de 5 minutes.
- Alice reste bloquée sur la page de paiement pendant plus de 5 minutes, la clé expire automatiquement.
- Bob réserve maintenant le siège
E7. - Alice clique sur “Annuler”, le serveur appelle
DEL. Le siège de Bob est supprimé.
Vous devez vérifier le jeton avant de supprimer. Si vous le faites en deux commandes distinctes GET puis DEL, la clé peut expirer et changer de propriétaire entre les deux. Un script Lua s’exécute de manière atomique sur Redis, éliminant cette faille.
6.2. Release et 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);
// ... constructeur, key(), hold(), isHeld() comme ci-dessus
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;
}
}
Explications :
KEYS[1]est la clé du siège,ARGV[1]est le jeton envoyé par le client. Le script n’exécuteDELouPEXPIREque si la valeur actuelle correspond au jeton.- Le script retourne
1en cas de succès,0si le jeton ne correspond pas ou si la clé a déjà expiré. DefaultRedisScriptcalcule automatiquement le SHA1 et appelleEVALSHA. Si Redis n’a pas encore caché le script, Spring bascule automatiquement surEVAL. Vous devriez déclarer le script commestatic finalpour éviter de le recréer à chaque appel.
La méthode extend est utile si l’utilisateur passe à la passerelle de paiement et que vous souhaitez ajouter 2 minutes. Si extend retourne false, vous savez avec certitude que le siège a été perdu et devez demander à l’utilisateur de sélectionner à nouveau.
7. Réserver plusieurs places à la fois
Un groupe d’amis réserve trois sièges E7, E8, E9. L’exigence est de réserver les trois ou aucun. Appeler hold trois fois et faire un rollback en cas d’échec fonctionne, mais c’est verbeux et il y a un moment où un autre client voit un état intermédiaire. Un script Lua gère cela plus élégamment :
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();
}
La première boucle vérifie qu’aucun siège n’est déjà réservé. Si c’est le cas, le script retourne 0. Sinon, la deuxième boucle réserve tous les sièges avec le même jeton et le même TTL. Le script entier s’exécute de manière atomique, aucune autre requête ne peut s’intercaler.
Grâce à la hash tag {show:42}, toutes les clés de la même séance résident sur le même slot. Sans hash tag, Redis Cluster retournerait l’erreur CROSSSLOT lorsque le script accède à plusieurs clés.
8. Écrire des tests avec Testcontainers
Ajoutez les dépendances de test :
<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>
Tests couvrant les comportements clés :
@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") indique à Spring Boot que ce conteneur est Redis. Spring Boot configure automatiquement l’hôte et le port pointant vers le conteneur, vous n’avez besoin d’aucune @DynamicPropertySource. Le test staleTokenCannotReleaseSomeoneElsesHold reproduit exactement le scénario de la section 6.1. Le jeton expiré d’Alice ne peut pas supprimer le verrou de Bob.
Vous pouvez également tester les conditions de concurrence réelles avec plusieurs threads :
@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);
}
}
Ce test utilise les virtual threads de Java 21 pour envoyer 100 requêtes simultanées. Le résultat montre toujours exactement un gagnant.
9. Erreurs courantes et limitations
Attention : Un verrou Redis n’offre qu’une protection “logicielle”. Lors de la confirmation d’une réservation, vous devez toujours avoir une contrainte unique sur
(show_id, seat_id)dans la tablebooking. Redis peut perdre des données lors d’un basculement car la réplication est asynchrone. Sans la contrainte, deux personnes pourraient se voir confirmer le même siège.
Voici d’autres erreurs courantes :
- Utiliser
SETNXpuisEXPIREséparément. Si l’application plante entre les deux commandes, le verrou ne s’expire jamais. Utilisez toujoursSET ... NX PXen une seule commande. - Libérer un verrou avec
DELsans vérifier le jeton. Vous supprimeriez accidentellement le verrou d’un autre utilisateur comme dans le scénario de la section 6.1. - Confirmer une réservation sans vérifier le verrou. Avant d’insérer une réservation, appelez un script de vérification pour voir si le jeton correspond toujours, ou utilisez
releaseet enregistrez seulement si le résultat esttrue. Si le paiement a réussi mais le verrou est expiré, vous avez besoin d’un flux de remboursement. - TTL trop court ou trop long. Trop court, l’utilisateur perd le siège en saisissant sa carte. Trop long, le siège reste “occupé” lors des pics. Gardez un TTL court combiné avec
extendà chaque étape. - Oublier la hash tag avec Redis Cluster. Un script multi-clés retournera l’erreur
CROSSSLOT. - Compter sur l’horloge applicative pour calculer l’expiration. Laissez Redis gérer le TTL. Si l’interface doit afficher un compte à rebours, lisez
PTTLau lieu de calculer vous-même.
Quand ne pas utiliser cette approche :
- Si vous avez besoin d’une garantie absolue au niveau du verrou, par exemple pour les ressources financières sans couche de base de données de contrôle en arrière-plan. Un Redis autonome avec réplication asynchrone n’offre pas cette garantie.
- Si le volume est faible et que la base de données suffit déjà. Une colonne
held_untilavec une contrainte unique peut être plus simple qu’exploiter Redis. - Si l’état de réservation doit être conservé longtemps pour auditer. Les clés Redis disparaissent d’elles-mêmes, vous devez logger les événements séparément.
10. Conclusion
La commande SET key value NX PX vous offre un verrou de siège atomique et qui expire automatiquement en un seul appel. Combiné avec un jeton aléatoire et un script Lua, vous pouvez libérer ou prolonger le verrou sans risquer de supprimer celui d’un autre. Vous pouvez également réserver plusieurs sièges en mode “tout ou rien”. Utilisez les hash tags avec Redis Cluster. Finalement, laissez toujours la base de données avec sa contrainte unique servir de dernier rempart lors de la confirmation d’une réservation.