Um estádio tem 60.000 assentos. Quando uma grande turnê começa a vender ingressos, milhões de pessoas chegam no mesmo minuto, todas querendo os mesmos poucos milhares de bons assentos. Em novembro de 2022, a Ticketmaster relatou 3,5 bilhões de requisições ao sistema no total durante a pré-venda da Eras Tour, quatro vezes o seu pico anterior. Armazenar 60.000 linhas é trivial. Arbitrar milhões de requisições concorrentes por essas linhas, de forma justa e sem nunca vender um assento duas vezes, é a parte difícil.
Este post projeta um sistema de venda de ingressos para esse tipo de abertura de vendas: requisitos, o formato do tráfego, uma sala de espera virtual, bloqueios de assentos com expiração, a transação de reserva, pagamentos, assentos disputados e bots, com um diagrama de arquitetura.
Requisitos
Funcionais:
- Navegar por eventos e por um mapa de assentos com disponibilidade.
- Escolher assentos e bloqueá-los enquanto paga.
- Pagar, receber os ingressos e liberar os assentos se o pagamento não acontecer.
Não funcionais:
- Nunca vender um assento duas vezes. Essa é a única regra que não pode ceder.
- Tratar as pessoas com justiça: ordem de chegada ou um sorteio divulgado, não quem atualiza a página mais rápido.
- Aguentar um tráfego uma ou duas ordens de grandeza acima do normal nos primeiros minutos de uma abertura de vendas.
- Falhar rápido e com clareza: um “esgotado” rápido é melhor do que uma página que fica carregando.
O formato de uma abertura de vendas
Suponha 60.000 assentos e 2 milhões de pessoas chegando nos dez minutos após o início. São cerca de 3.300 novas chegadas por segundo, e mais de 30 pessoas para cada assento. Nenhuma capacidade dá um ingresso a todo mundo. O trabalho real do sistema é deixar um número sustentável de pessoas comprar ao mesmo tempo, dizer não rapidamente ao resto e manter o inventário correto sob disputa extrema.
Isso divide o design em duas partes: um portão que absorve a multidão e um núcleo de reservas que só vê tantos compradores quanto consegue atender.
A sala de espera virtual
Coloque uma fila na frente do fluxo de reserva:
- Os fãs que chegam antes do início recebem uma posição aleatória entre todos os que chegaram cedo, então abrir cem abas às 9:59 não ajuda. Quem chega depois entra na fila por ordem.
- A sala de espera é uma página estática servida por um CDN, então milhões de navegadores esperando custam quase nada.
- Os fãs são admitidos em lotes, num ritmo ajustado à capacidade de reserva medida, por exemplo 2.000 por minuto.
- Cada fã admitido recebe um token assinado e de vida curta, vinculado à sua sessão. A API de reservas rejeita requisições sem um token válido.
Produtos gerenciados como Cloudflare Waiting Room e Queue-it fazem isso. Uma versão caseira é um sorted set do Redis com os horários de chegada como scores, em que a posição é o rank do membro:
ZADD queue:evt42 1793606400123 session:9f2c # score = arrival time in ms
ZRANK queue:evt42 session:9f2c # position, 0-based
ZPOPMIN queue:evt42 2000 # admit the next batch
Para o grupo dos que chegaram cedo, atribua scores aleatórios em vez dos horários de chegada. De qualquer forma, a fila transforma um pico ilimitado em um fluxo constante, para o qual o resto do sistema está dimensionado.
O modelo de inventário
Lugares marcados exigem uma linha por assento:
| Coluna | Exemplo | Finalidade |
|---|---|---|
event_id, seat_id |
evt42, S112-R7-14 |
Identidade; únicos em conjunto |
status |
available, held, sold |
Estado atual |
hold_id, held_until |
h_81f, timestamp |
Quem bloqueou e até quando |
order_id |
ord_5521 |
Preenchido na venda |
Ingressos sem lugar marcado são mais simples: um contador por setor de preço, decrementado com uma atualização condicional, em vez de assentos individuais.
Bloqueio de assentos com expiração
Um bloqueio reserva assentos para um comprador por alguns minutos enquanto ele paga. O lugar mais seguro para os bloqueios é o mesmo banco de dados que registra as vendas, usando uma atualização condicional:
UPDATE seats
SET status = 'held', hold_id = $3, held_until = now() + interval '10 minutes'
WHERE event_id = $1
AND seat_id = ANY($2)
AND (status = 'available' OR (status = 'held' AND held_until < now()));
-- If fewer rows changed than seats requested, roll back: someone else got one.
Dois compradores podem enviar esse comando para o mesmo assento no mesmo milissegundo. O banco de dados serializa as atualizações nessa linha: uma delas a altera, e a outra vê zero linhas e recebe uma resposta clara de “assento acabou de ser ocupado”. Bloqueios expirados são recuperados no mesmo comando, então a expiração de um bloqueio nunca depende de um job em segundo plano rodar na hora certa. Duas requisições para conjuntos de assentos sobrepostos podem ocasionalmente entrar em deadlock; o banco detecta isso e aborta uma delas, então tente de novo uma vez em caso de erro de deadlock.
Em escala muito alta, alguns times colocam um filtro rápido na frente: SET seat:evt42:S112-R7-14 h_81f NX PX 600000 no Redis, que só cria a chave se ela não existir, com expiração de dez minutos. Isso tira carga do banco de dados, mas a replicação do Redis é assíncrona, então um failover pode perder bloqueios. Trate o Redis como uma otimização; quem decide continua sendo o banco de dados.
A última linha de defesa é uma restrição de unicidade em (event_id, seat_id) na tabela de ingressos. Mesmo que todas as camadas acima tenham um bug, o banco de dados se recusa a emitir o segundo ingresso.
Assentos disputados e melhor disponível
Todo mundo quer as primeiras fileiras, então milhares de requisições miram as mesmas poucas centenas de linhas, e a maioria falha. Três técnicas reduzem a disputa:
- Melhor disponível. Em vez de deixar todo mundo escolher no mapa, deixe os compradores escolherem um setor e uma quantidade e aloque os assentos no servidor com
SELECT … FOR UPDATE SKIP LOCKED, para que requisições concorrentes peguem linhas diferentes em vez de fazer fila nas mesmas. - Particione o inventário por setor, para que setores diferentes travem linhas diferentes e possam ficar em shards de banco de dados diferentes.
- Coloque o mapa de assentos em cache com expiração curta e aceite que ele pode estar alguns segundos desatualizado. Quem manda na disponibilidade é o bloqueio, não o mapa.
Pagamento sem cobrança dupla
- Crie um pedido no estado
pendingvinculado ao bloqueio. - Chame o provedor de pagamentos com uma chave de idempotência derivada do ID do pedido. No Stripe, por exemplo, as chaves de idempotência têm até 255 caracteres, e uma requisição repetida com a mesma chave retorna o resultado original em vez de cobrar de novo.
- Em caso de sucesso, em uma única transação de banco de dados, marque os assentos como vendidos, vincule-os ao pedido e marque o pedido como pago.
- Escute os webhooks do provedor para pegar o caso em que o pagamento deu certo, mas o seu serviço nunca viu a resposta.
A expiração do bloqueio não pode competir com o pagamento. Estenda o bloqueio quando o pagamento começar, ou exclua da expiração os assentos com um pedido pendente. Do contrário, um assento pode expirar, ser vendido para outra pessoa e depois ter o primeiro pagamento aprovado. O diagrama de integração de gateway de pagamento trata de webhooks e conciliação em detalhes.
Liberando bloqueios expirados
Os bloqueios expiram de forma preguiçosa, pela condição held_until < now() acima, e de forma ativa, por um varredor que roda a cada poucos segundos e marca os bloqueios expirados como disponíveis, para que o mapa de assentos se atualize. Não construa a expiração em cima das notificações de keyspace do Redis: o Redis documenta o seu Pub/Sub como “fire and forget”, então um assinante que se desconecta perde eventos.
Bots e abuso
A sala de espera é a principal defesa, porque torna a velocidade irrelevante. Adicione limites de compra por conta, limites de taxa por conta, IP e dispositivo, cadastro antes da abertura de vendas para eventos de alta demanda e desafios na sala de espera. O design de sistema de rate limiter trata da limitação em si.
O diagrama de venda de ingressos
Conteúdo do diagrama
- Fans
- Waiting room (static, on CDN)
- Queue positions + admissions
- API gateway: token check
- Booking service
- Seats, holds, orders
- Hold sweeper, every 5 s
- Payment provider
- order.confirmed events
- Ticket delivery
- arrive at 10:00
- ZADD / ZRANK
- batch + signed token
- admitted?
- hold seats, pay
- hold: conditional UPDATE, 10 min
- release expired holds
- charge + Idempotency-Key
- webhook
- order paid
- send tickets
Como ler:
- Os fãs chegam à sala de espera no CDN, que os segura e os admite em lotes com tokens assinados.
- O gateway de API e o serviço de reservas só veem os fãs admitidos. O Redis guarda as posições na fila e o estado de admissão.
- O Postgres guarda os assentos. A atualização condicional cria os bloqueios, e a restrição de unicidade protege as vendas.
- O provedor de pagamentos é chamado com uma chave de idempotência, e os webhooks dele voltam para o serviço de reservas.
- A função varredora libera os bloqueios expirados; a fila de pedidos alimenta a entrega de ingressos por e-mail e no app.
- O bloco Markdown lista os números da abertura de vendas e as regras de bloqueio.
Desenhar sistemas de reserva para que os revisores achem as condições de corrida
- Desenhe o portão separado do núcleo. A sala de espera e o serviço de reservas têm modelos de escala e donos diferentes.
- Escreva a duração do bloqueio e a regra de unicidade no diagrama. Esses dois fatos explicam o design.
- Coloque a chave de idempotência na aresta do pagamento. Ela é o motivo de as novas tentativas serem seguras.
- Mostre o caminho da expiração. É onde os assentos voltam que muitos bugs de venda dupla se escondem.
Para o método geral, veja o exemplo de diagrama de design de sistemas. Os eventos de pedido podem ser publicados com o padrão transactional outbox, e os e-mails de ingressos por um pipeline como o do design de sistema de notificações.
Monte no Archboard
- Entre com Google ou GitHub e crie um diagrama.
- Adicione uma Pessoa do pack Modelo C4 para os fãs e um nó CDN do pack Nuvem para a sala de espera.
- Adicione um Gateway de API e um nó Instância de computação do pack Nuvem para o serviço de reservas.
- Adicione um nó Redis do pack Dados para o estado da fila e um nó Postgres para assentos, bloqueios e pedidos.
- Adicione um Sistema externo do pack Modelo C4 para o provedor de pagamentos, uma Função do pack Nuvem para o varredor de bloqueios, uma Fila para os eventos de pedido e outro nó Instância de computação para a entrega de ingressos.
- Rotule as arestas com os tokens, as regras de bloqueio e a chave de idempotência, e adicione um bloco Markdown do pack Básico com os números.
O exemplo usa 11 nós de diagrama e fica dentro do plano Free.
Perguntas frequentes
Como os sistemas de ingressos evitam a reserva dupla? Com uma atualização condicional na linha do assento, para que só uma requisição consiga passá-lo de disponível para bloqueado, e uma restrição de unicidade na tabela de ingressos como salvaguarda final. Caches e filas reduzem a carga, mas quem decide é o banco de dados.
Quanto tempo deve durar o bloqueio de um assento? O suficiente para pagar com tranquilidade e pouco o bastante para que assentos abandonados voltem rápido. De cinco a dez minutos é uma faixa comum. Estenda o bloqueio quando o pagamento começar, para que a expiração não possa competir com uma cobrança bem-sucedida.
O que é uma sala de espera virtual? Uma fila na frente do fluxo de reserva que admite usuários num ritmo que o sistema consegue suportar. Normalmente é uma página estática em um CDN, com posições sorteadas para quem chega cedo e por ordem depois.
Os bloqueios de assentos devem ficar no Redis ou no banco de dados? O banco de dados deve ser a fonte da verdade, porque a replicação do Redis é assíncrona e um failover pode perder chaves. O Redis funciona bem como um filtro rápido na frente, desde que o banco de dados continue garantindo a unicidade.
Como lidar com um pagamento aprovado depois que o bloqueio expirou? Evite isso estendendo os bloqueios durante o pagamento. Se ainda assim acontecer, reembolse automaticamente ou ofereça um assento equivalente, e use os webhooks do provedor para detectar o caso.