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 retourne nil car 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-alice est 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 commande SET key value NX PX <ms>. Si le ttl est un nombre entier de secondes, Spring peut utiliser EX à la place de PX, mais l’effet reste identique.
  • À chaque réservation, un nouvel UUID est généré comme jeton. Ce jeton est retourné au client, par exemple dans la réponse de l’API POST /holds. Le client le renvoie lors du paiement ou de l’annulation.
  • setIfAbsent retourne un Boolean qui peut être null lors d’une utilisation dans un pipeline ou une transaction. C’est pourquoi le code compare avec Boolean.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 :

  1. Alice réserve le siège avec un TTL de 5 minutes.
  2. Alice reste bloquée sur la page de paiement pendant plus de 5 minutes, la clé expire automatiquement.
  3. Bob réserve maintenant le siège E7.
  4. 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écute DEL ou PEXPIRE que si la valeur actuelle correspond au jeton.
  • Le script retourne 1 en cas de succès, 0 si le jeton ne correspond pas ou si la clé a déjà expiré.
  • DefaultRedisScript calcule automatiquement le SHA1 et appelle EVALSHA. Si Redis n’a pas encore caché le script, Spring bascule automatiquement sur EVAL. Vous devriez déclarer le script comme static final pour é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 table booking. 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 SETNX puis EXPIRE séparément. Si l’application plante entre les deux commandes, le verrou ne s’expire jamais. Utilisez toujours SET ... NX PX en une seule commande.
  • Libérer un verrou avec DEL sans 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 release et enregistrez seulement si le résultat est true. 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 PTTL au 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_until avec 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.

redisspring-bootdistributed-lockjavaconcurrency