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:

  1. O cliente chama POST /events/{id}/queue e recebe de volta um queueId.
  2. 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 um accessToken.
  3. O cliente usa o token para chamar POST /events/{id}/holds com a lista de assentos.
  4. O cliente realiza o pagamento. O provedor de pagamento chama um webhook de volta, o sistema chama POST /orders/{id}/confirm com uma chave de idempotência.
  5. 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:

  • increment retorna o número sequencial global. Você não usa timestamp como score porque múltiplas requisições podem ter o mesmo milissegundo.
  • addIfAbsent corresponde a ZADD 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. removeRangeByScore remove usuários cuja sessão expirou e, em seguida, zCard diz 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 WHERE checa 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ão status = 'HELD' e receberão updated = 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 seatIds antes 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 SOLD se 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 outbox e 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 AVAILABLE para 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.

system-designredisspring-bootconcurrencykafka