Pular para o conteúdo

Design de sistema de venda de ingressos: bloqueio de assentos, filas virtuais e nenhuma venda dupla

Projete um sistema de ingressos para grandes aberturas de vendas: sala de espera virtual, bloqueios que expiram, sem venda dupla e pagamentos idempotentes.

· Fundador e desenvolvedor

Publicado em

Uma sala de espera em um CDN admite os fãs com token assinado até o gateway de API e o serviço de reservas, que bloqueia assentos no Postgres, guarda posições no Redis e chama o provedor de pagamentos. Uma função libera bloqueios expirados; uma fila alimenta a entrega de ingressos.

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:

  1. 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.
  2. A sala de espera é uma página estática servida por um CDN, então milhões de navegadores esperando custam quase nada.
  3. Os fãs são admitidos em lotes, num ritmo ajustado à capacidade de reserva medida, por exemplo 2.000 por minuto.
  4. 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

  1. Crie um pedido no estado pending vinculado ao bloqueio.
  2. 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.
  3. 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.
  4. 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

Uma sala de espera em um CDN admite os fãs com token assinado até o gateway de API e o serviço de reservas, que bloqueia assentos no Postgres, guarda posições no Redis e chama o provedor de pagamentos. Uma função libera bloqueios expirados; uma fila alimenta a entrega de ingressos.

11 componentes · 11 conexões

Abrir no Archboard
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
Um sistema de ingressos em plena abertura de vendas. Uma sala de espera na borda admite os fãs em ritmo controlado; o serviço de reservas bloqueia assentos com expiração via atualização condicional, cobra com chave de idempotência e confirma pedidos por uma fila.

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

  1. Entre com Google ou GitHub e crie um diagrama.
  2. Adicione uma Pessoa do pack Modelo C4 para os fãs e um nó CDN do pack Nuvem para a sala de espera.
  3. Adicione um Gateway de API e um nó Instância de computação do pack Nuvem para o serviço de reservas.
  4. Adicione um nó Redis do pack Dados para o estado da fila e um nó Postgres para assentos, bloqueios e pedidos.
  5. 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.
  6. 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.

Comece grátis — um assento, sem cartão de crédito.

Artigos relacionados

← Voltar ao blog

Abra um canvas em um minuto

Entre com Google ou GitHub. Grátis para um assento. Faça upgrade quando a equipe entrar.