Redis para sistema de reserva de assentos com SET NX PX

1. Visão geral

Em um sistema de reserva de ingressos de cinema, voos ou mesas de restaurante, os usuários geralmente têm seus assentos “mantidos” por alguns minutos para efetuar o pagamento. Se o tempo expirar e o pagamento não for realizado, o assento deve ser automaticamente liberado para outras pessoas. Este artigo o guia para usar o comando SET key value NX PX do Redis para bloquear assentos atomicamente, com expiração automática, e liberar ou estender o bloqueio com segurança usando Lua script. Ao final, você terá um SeatHoldService funcionando no Spring Boot 3.x, com testes usando Testcontainers.

2. O problema de manter assentos

Dois clientes selecionam o mesmo assento E7 da sessão 42 quase simultaneamente. O sistema deve garantir três coisas:

  • Apenas uma pessoa pode manter o assento.
  • Se essa pessoa desistir, o assento deve ser automaticamente liberado após um período de tempo, por exemplo 5 minutos.
  • Apenas a pessoa que mantém o assento pode liberá-lo ou estender o tempo.

Você poderia fazer isso usando um banco de dados com SELECT ... FOR UPDATE e uma coluna held_until. Esse método funcionaria, mas exigiria um job para limpar registros expirados. Grande volume de gravações durante o horário de abertura também pode sobrecarregar o banco de dados. Redis oferece uma solução mais limpa porque tem duas coisas prontas: a operação ‘set se não existir’ é atômica, e as chaves com TTL expiram automaticamente.

3. Como SET NX PX funciona

Teste diretamente com 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

O significado de cada parte:

  • NX: set apenas quando a chave ainda não existe. O segundo comando de Bob retorna nil porque Alice já mantém o assento.
  • PX 300000: define TTL de 300.000 milissegundos, ou seja, 5 minutos. Quando o tempo expira, Redis deleta a chave automaticamente.
  • O valor token-alice é um token de retenção para identificar quem está mantendo o assento. A seção posterior usará esse token para liberar o bloqueio com segurança.

O ponto importante é que NX e PX estão em um único comando, portanto são executados atomicamente. O método antigo de usar SETNX seguido por PEXPIRE tem risco: se a aplicação morrer entre os dois comandos, a chave existirá para sempre e o assento ficará bloqueado permanentemente.

A parte {show:42} na chave é uma hash tag. Com Redis Cluster, todas as chaves com a mesma hash tag ficam no mesmo slot. Isso permite que você use Lua script em vários assentos da mesma sessão, como na seção 7.

4. Configuração do Spring Boot

Adicione a dependência do Spring Data Redis. Lettuce é o cliente padrão:

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

Configure a conexão:

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

Spring Boot cria automaticamente o bean StringRedisTemplate. Chave e valor são ambos strings, adequado para este caso de uso.

5. Manter assento com 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)));
    }
}

Explicação:

  • setIfAbsent(key, value, Duration) gera o comando SET key value NX PX <ms>. Se ttl é um número inteiro de segundos, Spring pode usar EX em vez de PX, mas o significado permanece o mesmo.
  • Cada vez que você mantém um assento, um novo UUID é criado como token. O token é retornado ao cliente, por exemplo na resposta da API POST /holds. O cliente envia o token de volta ao efetuar pagamento ou cancelar.
  • setIfAbsent retorna Boolean que pode ser null quando executado em um pipeline ou transação. Por isso o código compara usando Boolean.TRUE.equals(ok) em vez de fazer unboxing direto.

Exemplo de uso em um 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());
}

Quem chegar depois recebe 409 Conflict. O frontend usa isso para informar “O assento já foi selecionado por outra pessoa”.

6. Liberando e estendendo bloqueios com segurança usando Lua

6.1. Por que não usar DEL diretamente

Veja o seguinte cenário:

  1. Alice mantém o assento com TTL de 5 minutos.
  2. Alice fica presa na página de pagamento por mais de 5 minutos, a chave expira automaticamente.
  3. Bob consegue manter o assento E7.
  4. Alice clica em ‘Cancelar’ e o servidor chama DEL. O assento de Bob é deletado.

Você deve verificar o token antes de deletar. Se você fizer GET seguido de DEL em dois comandos separados, a chave ainda pode expirar e mudar de proprietário entre os dois comandos. Lua script executa atomicamente no Redis, então elimina essa lacuna.

6.2. Release e 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 acima

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

Explicação:

  • KEYS[1] é a chave do assento, ARGV[1] é o token enviado pelo cliente. Apenas quando o valor atual corresponde ao token é que o script executa DEL ou PEXPIRE.
  • O script retorna 1 quando bem-sucedido, 0 quando o token não corresponde ou a chave expirou.
  • DefaultRedisScript calcula automaticamente SHA1 e chama EVALSHA. Se Redis ainda não fez cache do script, Spring automaticamente volta para EVAL. Você deve declarar o script como static final para não recriá-lo a cada chamada.

extend é útil quando o usuário vai para o gateway de pagamento e você quer adicionar mais 2 minutos. Se extend retornar false, você sabe com certeza que perdeu o assento e precisa informar ao usuário para selecionar novamente.

7. Manter vários assentos simultaneamente

Um grupo de amigos reserva três assentos E7, E8, E9. O requisito é manter todos os três ou nenhum. Chamar hold três vezes e fazer rollback em caso de falha funcionaria, mas é verboso e há um momento em que outros veem um estado intermediário. Lua script lida com isso de forma mais elegante:

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

O primeiro loop verifica se algum assento já está sendo mantido. Se não houver, o segundo loop define todos com o mesmo token e TTL. O script inteiro executa atomicamente, portanto nenhuma requisição pode se interpor no meio.

Graças à hash tag {show:42}, todas as chaves da mesma sessão ficam no mesmo slot. Se faltar a hash tag, Redis Cluster retornará erro CROSSSLOT quando o script toca várias chaves.

8. Escrevendo testes com Testcontainers

Adicione dependências de teste:

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

Testes para os comportamentos principais:

@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") informa ao Spring Boot que este container é Redis. Spring Boot configura automaticamente o host e a porta apontando para o container, você não precisa de @DynamicPropertySource. O teste staleTokenCannotReleaseSomeoneElsesHold reproduz exatamente o cenário da seção 6.1. O token antigo de Alice não pode deletar o bloqueio de Bob.

Você também pode testar a contenção real com múltiplas 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);
    }
}

O teste usa virtual threads do Java 21 para enviar 100 requisições simultaneamente. O resultado sempre tem exatamente um vencedor.

9. Erros comuns e limitações

Nota: Bloqueios Redis são apenas uma camada de proteção ‘suave’. Ao confirmar uma reserva, você ainda deve ter um unique constraint em (show_id, seat_id) na tabela booking. Redis pode perder dados durante failover porque a replica recebe dados de forma assíncrona. Sem o constraint, duas pessoas podem ser confirmadas para o mesmo assento.

Alguns outros erros comuns:

  • Usar SETNX seguido de EXPIRE separadamente. Se a aplicação morrer entre os dois comandos, o bloqueio nunca expira. Sempre use SET ... NX PX em um único comando.
  • Liberar bloqueio com DEL sem verificar token. Você vai deletar acidentalmente o bloqueio de outra pessoa como no cenário da seção 6.1.
  • Confirmar reserva sem verificar bloqueio. Antes de escrever a reserva, chame um script para verificar se o token ainda corresponde, ou use release e escreva apenas se o resultado for true. Se o pagamento foi bem-sucedido mas o bloqueio foi perdido, precisa haver um fluxo de reembolso.
  • TTL muito curto ou muito longo. Muito curto e o usuário perde o assento enquanto digita o cartão. Muito longo e o assento fica ‘falsamente ocupado’ durante horários de pico. Mantenha TTL curto combinado com extend em cada etapa.
  • Esquecer hash tag ao executar Cluster. Script com múltiplas chaves resultará em erro CROSSSLOT.
  • Confiar no relógio da aplicação para calcular limite de retenção. Deixe Redis gerenciar o TTL. Se a UI precisa mostrar uma contagem regressiva, leia PTTL em vez de calcular você mesmo.

Quando não usar essa abordagem:

  • Quando você precisa de garantia absoluta no nível de bloqueio, por exemplo, recursos financeiros sem uma camada de banco de dados restritiva por trás. Um Redis único com replicação assíncrona não oferece essa garantia.
  • Quando o volume é pequeno e o banco de dados já atende. Uma coluna held_until junto com unique constraint pode ser mais simples do que operar um Redis adicional.
  • Quando o estado de retenção precisa ser armazenado por um longo tempo para reconciliação. Chaves Redis desaparecem automaticamente, portanto você precisa registrar eventos separadamente.

10. Conclusão

O comando SET key value NX PX oferece um bloqueio de assento atômico e expiração automática em uma única chamada. Combinado com token aleatório e Lua script, você pode liberar ou estender o bloqueio sem se preocupar em deletar o de outra pessoa. Você também pode manter vários assentos no padrão all-or-nothing. Use hash tags ao executar Redis Cluster. Por fim, sempre deixe o banco de dados com unique constraint como camada final de proteção ao confirmar reservas.

redisspring-bootdistributed-lockjavaconcurrency