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 retornanilporque 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 comandoSET key value NX PX <ms>. Settlé um número inteiro de segundos, Spring pode usarEXem vez dePX, 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 APIPOST /holds. O cliente envia o token de volta ao efetuar pagamento ou cancelar. setIfAbsentretornaBooleanque pode sernullquando executado em um pipeline ou transação. Por isso o código compara usandoBoolean.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:
- Alice mantém o assento com TTL de 5 minutos.
- Alice fica presa na página de pagamento por mais de 5 minutos, a chave expira automaticamente.
- Bob consegue manter o assento
E7. - 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 executaDELouPEXPIRE.- O script retorna
1quando bem-sucedido,0quando o token não corresponde ou a chave expirou. DefaultRedisScriptcalcula automaticamente SHA1 e chamaEVALSHA. Se Redis ainda não fez cache do script, Spring automaticamente volta paraEVAL. Você deve declarar o script comostatic finalpara 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 tabelabooking. 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
SETNXseguido deEXPIREseparadamente. Se a aplicação morrer entre os dois comandos, o bloqueio nunca expira. Sempre useSET ... NX PXem um único comando. - Liberar bloqueio com
DELsem 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
releasee escreva apenas se o resultado fortrue. 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
extendem 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
PTTLem 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_untiljunto 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.