Design de Sistema de Venda de Ingressos: Fila Virtual para Milhões
1. Visão Geral
Um grande concerto abre as vendas às 10:00. Poucos segundos depois, milhões de pessoas clicam em “Comprar ingresso” para disputar cerca de 50.000 assentos. Se você jogar toda essa quantidade de requisições diretamente no banco de dados, o sistema desabará. Se você bloquear incorretamente, duas pessoas podem comprar o mesmo assento.
Este artigo passa por cada etapa do design: requisitos, modelo de dados, APIs e fluxo de processamento. O foco está em três desafios principais: picos de tráfego, disputa por assentos e consistência em pagamentos. Após ler, você saberá construir uma fila virtual com Redis, um mecanismo de reserva de assentos à prova de duplicação e um fluxo de confirmação de pedidos seguro contra chamadas repetidas.
2. Requisitos do Problema
2.1. Requisitos Funcionais
- O usuário acessa a página do evento e é colocado em uma fila virtual antes do horário de abertura das vendas.
- O sistema admite usuários à área de seleção de assentos em lotes, respeitando a ordem de chegada (FIFO).
- O usuário pode selecionar até 4 assentos. Os assentos são reservados por 5 minutos para pagamento.
- Após o pagamento bem-sucedido, o assento muda para o status vendido e o ingresso é emitido.
- Se o tempo de reserva expirar sem pagamento, o assento volta ao inventário.
2.2. Escala e Restrições
| Métrica | Valor Estimado |
|---|---|
| Usuários simultâneos no pico de abertura de vendas | 2.000.000 |
| Número de assentos | 50.000 |
| Pico de requisições para a página do evento | ~500.000 RPS |
| Número de usuários simultaneamente na área de seleção de assentos | 5.000 |
A restrição mais importante é nunca vender o mesmo assento duas vezes. A consistência aqui deve ser strong consistency. Em contraste, a posição na fila só precisa ser aproximada: exibir “cerca de 12.000 pessoas à sua frente” é suficiente.
Desses dois números podemos extrair uma observação crucial: apenas 2,5% dos usuários conseguirão ingresso. Portanto, o sistema deve rejeitar ou atrasar a maioria das requisições na camada mais barata possível, antes de elas chegarem ao banco de dados.
3. Arquitetura e Fluxo de Processamento
Client ──> CDN (página estática, página de espera)
│
├─> Queue Service ──> Redis (ZSET fila, access token)
│ ▲
│ └── Admitter (agendado, libera N pessoas/segundo)
│
└─> Booking Service (requer access token)
├─> PostgreSQL (seat, order, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
O fluxo principal funciona assim:
- O cliente chama
POST /events/{id}/queuee recebe de volta umqueueId. - O cliente faz polling de
GET /events/{id}/queue/{queueId}a cada poucos segundos para verificar sua posição. Quando chega a sua vez, o cliente recebe umaccessToken. - O cliente usa o token para chamar
POST /events/{id}/holdscom a lista de assentos. - O cliente realiza o pagamento. O provedor de pagamento chama um webhook de volta, o sistema chama
POST /orders/{id}/confirmcom uma chave de idempotência. - O Booking Service grava o pedido e o evento da outbox na mesma transação. Depois, o evento é enviado para Kafka para emissão do ingresso.
A página de espera é servida estaticamente via CDN. Apenas as APIs de join e polling acessam o backend, e ambas tocam apenas em Redis.
4. Modelo de Dados
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
O status do assento fica na tabela seat. Graças a isso, a reserva de assentos requer apenas uma única instrução UPDATE condicional. A restrição UNIQUE em idempotency_key é a última barreira contra a gravação duplicada de pedidos.
5. Fila Virtual com Redis
Adicione a dependência:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Entrando na Fila
A fila é um Redis Sorted Set. O score é o número sequencial crescente, obtido de INCR:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Explicação:
incrementretorna o número sequencial global. Você não usa timestamp como score porque múltiplas requisições podem ter o mesmo milissegundo.addIfAbsentcorresponde aZADD NX. Mesmo se o usuário apertar F5 repetidamente, ele não será movido para o final da fila.rank(ZRANK) tem complexidade O(log N). Com 2 milhões de elementos, este comando ainda consome apenas alguns microssegundos.
Três pessoas entrando na fila sequencialmente receberão os seguintes resultados:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (entra novamente, mantém posição)
Observação: Se você precisar bloquear bots, autentique o usuário (login, captcha) antes de permitir que ele entre. Uma vez no ZSET, um bot reserva um lugar na fila assim como um usuário real.
5.2. Admitter: Liberando Usuários em Lotes
Um job é executado a cada segundo. Este job verifica quantos slots estão disponíveis na área de seleção de assentos e, em seguida, retira exatamente essa quantidade de pessoas do início da fila:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Explicação:
active:{eventId}é um ZSET com score igual ao momento de expiração.removeRangeByScoreremove usuários cuja sessão expirou e, em seguida,zCarddiz quantas pessoas estão na área de seleção de assentos.popMin(ZPOPMIN) recupera e remove os N primeiros usuários da fila.- Ao fazer polling, o cliente lê
token-of:.... Se a chave existe, significa que chegou sua vez e o cliente recebe o token.
Você precisa ativar @EnableScheduling e executar este job em exatamente uma instância, por exemplo usando ShedLock ou leader election. Se duas instâncias rodarem juntas, a área de seleção de assentos pode exceder MAX_ACTIVE.
Você também não precisa de múltiplos Admitters para lidar com 2 milhões de pessoas. A taxa de admissão é limitada pela capacidade do Booking Service, não pelo Redis.
5.3. Bloqueando Requisições sem Token
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Não admitido");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
Este filtro usa o token como chave de lookup e não confia no customerId enviado pelo cliente. O usuário não pode burlar a fila chamando diretamente a API /holds.
6. Reserva de Assentos: Tratando Disputas
Múltiplas pessoas podem selecionar o mesmo assento A-12. A forma mais simples e correta é deixar o banco de dados decidir com uma única instrução UPDATE condicional:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Máximo 4 assentos");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Assento ocupado: " + seatId); }
}
Explicação:
- A cláusula
WHEREcheca e atualiza em uma única instrução atômica. O banco de dados bloqueia a linha. As requisições subsequentes aguardarão, então reavaliarão a condição, verãostatus = 'HELD'e receberãoupdated = 0. - A condição
hold_expires_at < ?permite reclamar assentos com reserva expirada sem aguardar um job de limpeza. - Se um assento no lote falhar, a exceção faz rollback de toda a transação. O usuário nunca fica com uma reserva “parcial” de assentos.
- Ordenar
seatIdsantes da atualização garante que todas as transações adquiram locks na mesma ordem, evitando deadlock quando dois usuários escolhem assentos cruzados, como {A-1, A-2} e {A-2, A-1}.
Observação: Não faça “SELECT para verificar status e depois UPDATE” sem locks. Isso é a race condition clássica que leva à venda duplicada de assentos.
6.1. Testando o Comportamento Anti-Duplicação
Adicione a dependência:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
A tabela seat é criada a partir de src/test/resources/schema.sql. Use a instrução CREATE TABLE seat exata da seção 4, mudando BIGSERIAL para BIGINT AUTO_INCREMENT se criar as tabelas restantes. @Transactional(NOT_SUPPORTED) desativa a transação que envolve o teste, para que os dados sejam commitados e outras threads os vejam.
7. Pagamento e Consistência
O webhook do provedor de pagamento pode ser chamado múltiplas vezes. Portanto, a etapa de confirmação do pedido deve ser idempotente:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // já foi processado antes
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Reserva expirou, reembolso necessário");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
Este trecho de código faz três coisas:
ON CONFLICT DO NOTHING(sintaxe PostgreSQL) garante que webhooks repetidos não criem pedidos duplicados.- O assento só muda para
SOLDse ainda estiver sendo reservado por este cliente. Se a reserva expirou e o assento agora pertence a outra pessoa, a transação faz rollback. Nesse caso, o pedido precisa ser encaminhado para o fluxo de reembolso. - Transactional outbox: o evento é gravado na mesma transação que o pedido. Um processo separado (ou Debezium) lê a tabela
outboxe o envia para Kafka. Com isso, você nunca tem o caso de um pedido salvo mas sem ingresso emitido, ou vice-versa.
Observação: Defina o TTL da reserva um pouco maior que o timeout da página de pagamento, por exemplo, 5 minutos de reserva e 4 minutos de timeout de pagamento. Esta margem reduz os casos de clientes que pagaram mas a reserva expirou no meio do caminho.
8. Pontos Difíceis e Erros Comuns
-
Picos no topo da pilha: a página de espera deve ser estática em CDN. A API de polling deve retornar um header sugerindo o intervalo de polling, por exemplo 5-15 segundos dependendo da posição. Se o cliente fazer polling a cada segundo, 2 milhões de pessoas gerarão 2 milhões de RPS para o Redis.
-
Redis é um ponto único de falha: use Redis Cluster ou Sentinel. Se houver apenas um grande evento, todo o ZSET fica em uma chave, portanto em um shard. Um nó Redis ainda suporta centenas de milhares de ops/s, mas você precisa medir primeiro.
-
Exibição imprecisa de “assentos restantes”: o número de assentos disponíveis na interface pode ser lido de um cache e aceitar uma defasagem de alguns segundos. Apenas o UPDATE da seção 6 é a fonte da verdade.
-
Reservas órfãs: o usuário reserva um assento e fecha a aba. A condição de expiração no UPDATE já trata a recuperação do assento. Você só precisa adicionar um job periódico que devolva assentos expirados para
AVAILABLEpara exibição correta. -
Quando NÃO usar fila virtual: com eventos de centenas de assentos e tráfego moderado, rate limiting mais UPDATE condicional é suficiente. A fila virtual adiciona complexidade operacional e torna a experiência mais lenta. Use apenas quando o volume de quem quer comprar vastamente exceder a capacidade do layer de booking.
9. Conclusão
O sistema de venda de ingressos suporta bem a carga separando duas preocupações. A fila virtual em Redis absorve picos de tráfego e mantém a equidade. O banco de dados processa apenas os poucos usuários admitidos e garante nenhuma venda duplicada via UPDATE condicional.
Você viu como usar ZADD NX e ZPOPMIN para enfileirar e admitir em lotes, como reservar assentos atomicamente e evitar deadlock, e como confirmar pedidos idempotentemente combinado com outbox transacional para enviar eventos para Kafka. A partir deste framework, você pode expandir com proteção contra bots, fila com prioridade para membros ou múltiplos eventos rodando em paralelo.