Redis para sistemas de reserva de asientos: bloquea con SET NX PX y vencimiento automático

1. Descripción general

En un sistema de reserva de entradas de cine, vuelos o mesas de restaurante, los usuarios normalmente pueden “reservar” un asiento durante unos minutos para pagar. Si pasan los minutos sin pagar, el asiento debe devolverse automáticamente a otros. Este artículo te guía en el uso del comando Redis SET key value NX PX para bloquear asientos de forma atómica, que el bloqueo expire automáticamente, y liberar o renovar el bloqueo de forma segura con scripts Lua. Al terminar, tendrás un SeatHoldService funcional en Spring Boot 3.x, con tests usando Testcontainers.

2. El problema de reservar un asiento

Dos clientes presionan casi simultáneamente para seleccionar el asiento E7 de la sesión 42. El sistema debe garantizar tres cosas:

  • Solo una persona obtiene el asiento.
  • Si esa persona se va, el asiento se libera automáticamente después de un tiempo, por ejemplo 5 minutos.
  • Solo el propietario del asiento puede liberarlo o renovarlo.

Puedes hacerlo con una base de datos usando SELECT ... FOR UPDATE y una columna held_until. Así funciona, pero necesitas un job para limpiar los registros vencidos. Un gran volumen de escrituras en la hora de apertura de ventas también puede congestionar la base de datos. Redis lo resuelve de forma más limpia porque ya tiene dos cosas: la operación “set si no existe” es atómica, y la clave tiene un TTL que expira automáticamente.

3. Cómo funciona SET NX PX

Prueba directamente con 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

El significado de cada parte:

  • NX: solo set si la clave no existe. El segundo comando de Bob retorna nil porque Alice ya tiene el asiento.
  • PX 300000: establece el TTL a 300.000 milisegundos, es decir, 5 minutos. Cuando vence el tiempo, Redis elimina la clave automáticamente.
  • El valor token-alice es el hold token para identificar quién está reservando el asiento. Más adelante usaremos este token para liberar el bloqueo de forma segura.

Lo importante es que NX y PX están en el mismo comando, por lo que se ejecutan de forma atómica. La forma antigua de hacer SETNX y luego PEXPIRE tiene riesgo: si la aplicación se cae entre los dos comandos, la clave permanece para siempre y el asiento queda bloqueado indefinidamente.

La parte {show:42} en la clave es un hash tag. Con Redis Cluster, todas las claves con el mismo hash tag están en el mismo slot. Gracias a esto, puedes usar scripts Lua en múltiples asientos de la misma sesión, como en la sección 7.

4. Configuración de Spring Boot

Añade la dependencia de Spring Data Redis. Lettuce es el cliente por defecto:

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

Configuración de conexión:

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

Spring Boot crea automáticamente el bean StringRedisTemplate. Las claves y valores son cadenas, lo cual es perfecto para este caso de uso.

5. Reservar un asiento con 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)));
    }
}

Explicación:

  • setIfAbsent(key, value, Duration) genera el comando SET key value NX PX <ms>. Si el ttl es un número par de segundos, Spring puede usar EX en lugar de PX, pero el significado es el mismo.
  • Cada vez que se reserva un asiento, se crea un nuevo UUID como token. El token se retorna al cliente, por ejemplo en la respuesta de la API POST /holds. El cliente lo reenva cuando paga o cancela.
  • setIfAbsent retorna un Boolean que puede ser null cuando se ejecuta en pipeline o transacción. Por eso el código compara con Boolean.TRUE.equals(ok) en lugar de hacer unbox directo.

Ejemplo de uso en un 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());
}

El cliente que llega tarde recibe 409 Conflict. El frontend lo usa para mostrar “El asiento ya está siendo reservado”.

6. Liberar y renovar el bloqueo de forma segura con Lua

6.1. Por qué no usar DEL directamente

Mira este escenario:

  1. Alice reserva el asiento con TTL de 5 minutos.
  2. Alice se queda atorada en la página de pago durante más de 5 minutos, la clave vence automáticamente.
  3. Bob obtiene el asiento E7.
  4. Alice presiona “Cancelar”, el servidor llama a DEL. El asiento de Bob se elimina.

Debes verificar el token antes de eliminar. Si lo haces con GET y luego DEL en dos comandos separados, entre los dos la clave aún puede vencer y cambiar de propietario. Un script Lua se ejecuta de forma atómica en Redis, eliminando este hueco.

6.2. Release y 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() como arriba

    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;
    }
}

Explicación:

  • KEYS[1] es la clave del asiento, ARGV[1] es el token que envía el cliente. El script solo hace DEL o PEXPIRE si el valor actual coincide con el token.
  • El script retorna 1 si tiene éxito, 0 si el token no coincide o la clave ya ha vencido.
  • DefaultRedisScript calcula automáticamente el SHA1 y llama a EVALSHA. Si Redis aún no ha cacheado el script, Spring cambia automáticamente a EVAL. Deberías declarar el script como static final para no recrearlo cada vez.

extend es útil cuando el usuario va a la puerta de pago y quieres añadir 2 minutos más. Si extend retorna false, sabes con certeza que perdiste el asiento y necesitas decirle al usuario que lo elija de nuevo.

7. Reservar múltiples asientos a la vez

Un grupo de amigos reserva tres asientos E7, E8, E9. La exigencia es obtener los tres o ninguno. Llamar a hold tres veces y luego rollback si falla aún funciona, pero es verbose y hay un momento donde otros ven un estado incompleto. Un script Lua lo maneja de forma más limpia:

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();
}

El primer bucle verifica si algún asiento ya está reservado. Si no hay ninguno, el segundo bucle establece todos con el mismo token y TTL. Todo el script se ejecuta de forma atómica, así que ninguna request puede intercalarse en el medio.

Gracias al hash tag {show:42}, todas las claves de la misma sesión están en el mismo slot. Sin hash tag, Redis Cluster lanzará un error CROSSSLOT cuando el script toque múltiples claves.

8. Escribir tests con Testcontainers

Añade las dependencias 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 para los comportamientos principales:

@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") le dice a Spring Boot que este contenedor es Redis. Spring Boot configura automáticamente el host y puerto para apuntar al contenedor, sin necesidad de @DynamicPropertySource. El test staleTokenCannotReleaseSomeoneElsesHold recrea exactamente el escenario de la sección 6.1. El token antiguo de Alice no puede eliminar el bloqueo de Bob.

También puedes probar la contención real con múltiples 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);
    }
}

El test usa virtual threads de Java 21 para lanzar 100 requests concurrentes. El resultado siempre es que hay exactamente un ganador.

9. Errores comunes y limitaciones

Nota: Un bloqueo Redis es solo una protección “blanda”. Cuando confirmes una reserva, debes tener un unique constraint en (show_id, seat_id) en la tabla booking. Redis puede perder datos en una conmutación por error, ya que las réplicas reciben datos de forma asíncrona. Sin la restricción, dos personas podrían ser confirmadas para el mismo asiento.

Otros errores comunes:

  • Usar SETNX y luego EXPIRE por separado. Si la aplicación se cae entre los dos comandos, el bloqueo nunca expira. Siempre usa SET ... NX PX en un solo comando.
  • Liberar el bloqueo con DEL sin verificar el token. Eliminarás accidentalmente el bloqueo de otra persona como en el escenario de la sección 6.1.
  • Confirmar una reserva sin verificar el bloqueo. Antes de escribir la reserva, ejecuta un script para verificar que el token sigue siendo válido, o usa release y solo escribe cuando el resultado sea true. Si el pago ya se procesó pero el bloqueo se perdió, necesitas un flujo de reembolso.
  • TTL demasiado corto o demasiado largo. Si es demasiado corto, los usuarios pierden el asiento mientras escriben la tarjeta. Si es demasiado largo, el asiento queda “ocupado fantasma” en horas pico. Mantén el TTL corto combinado con extend en cada paso.
  • Olvidar hash tag en Cluster. Scripts con múltiples claves lanzarán CROSSSLOT.
  • Contar con el reloj de la aplicación para calcular la expiración. Deja que Redis gestione el TTL. Si la UI necesita mostrar una cuenta regresiva, lee PTTL en lugar de calcular por tu cuenta.

Cuándo no usar este enfoque:

  • Cuando necesites garantizar absolutamente a nivel de bloqueo, por ejemplo un recurso financiero sin una capa de base de datos con restricciones detrás. Un Redis individual con replicación asíncrona no proporciona esa garantía.
  • Cuando el volumen es pequeño y la base de datos ya es suficiente. Una columna held_until con unique constraint puede ser más simple que mantener Redis adicional.
  • Cuando el estado de la reserva debe conservarse a largo plazo para auditoría. Las claves Redis desaparecen por sí solas, así que necesitarás registrar eventos por separado.

10. Conclusión

El comando SET key value NX PX te da un bloqueo atómico de asiento con vencimiento automático en un solo comando. Combinado con un token aleatorio y un script Lua, puedes liberar o renovar el bloqueo sin riesgo de eliminar el de otro usuario. También puedes reservar múltiples asientos con semántica todo-o-nada. Usa hash tags cuando corras Redis Cluster. Finalmente, siempre deja la base de datos con unique constraint como última capa de defensa cuando confirmes la reserva.

redisspring-bootdistributed-lockjavaconcurrency