Resumo estruturado para revisão. Organizado por camada, com a justificativa de cada decisão (que é o que pontua em entrevista) e as frases-síntese para verbalizar.
- Modelagem de schema (Postgres)
- Concorrência no banco
- Fila virtual / waiting room
- Hold + pagamento (ordem crítica)
- Escala de leitura
- Sharding
- Proteção de borda
- Load Balancer vs API Gateway
- Filosofia de resiliência e escala
- Diagramas de fluxo
A distinção-chave: o layout físico permanente do local existe independente de qualquer show; o que é vendido é a instância daquele assento para um evento específico.
venues→seats: layout físico permanente (seção, fila, número). FKseat → venue.events: um show em data/hora, pertence a um venue.event_seats: o inventário de um evento — a instância vendável. É o nó central do modelo (ver abaixo).
CREATE TABLE users (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE venues (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name TEXT NOT NULL,
city TEXT,
capacity INT
);
-- Layout físico permanente do local
CREATE TABLE seats (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
venue_id BIGINT NOT NULL REFERENCES venues(id),
section TEXT NOT NULL, -- "Pista VIP", "Setor A"
seat_row TEXT,
number TEXT,
UNIQUE (venue_id, section, seat_row, number)
);
-- Um show específico em data/hora
CREATE TABLE events (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
venue_id BIGINT NOT NULL REFERENCES venues(id),
name TEXT NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
sales_start TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'scheduled' -- scheduled|on_sale|sold_out|cancelled
);É o coração do modelo: é nela que mora o invariante de não-double-booking, e é o nó ligado a quatro coisas ao mesmo tempo (assento físico, evento, usuário que detém o hold, item de pedido).
CREATE TABLE event_seats (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
event_id BIGINT NOT NULL REFERENCES events(id),
seat_id BIGINT NOT NULL REFERENCES seats(id),
price_cents INT NOT NULL,
status TEXT NOT NULL DEFAULT 'available', -- available|held|sold
held_until TIMESTAMPTZ,
held_by BIGINT REFERENCES users(id),
UNIQUE (event_id, seat_id)
);Justificativas:
UNIQUE (event_id, seat_id)é o invariante mais importante: uma única linha por assento por evento. Impede inventário duplicado no nível do banco.statuscomo máquina de estados (available → held → sold) torna a venda concorrente segura.held_until/held_byna própria linha simplifica o lock (atualiza uma linha só). Alternativa: tabelaseat_holdsseparada (melhor para auditoria/histórico, mais complexa). Trade-off.
CREATE TABLE orders (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending', -- pending|paid|failed|cancelled
total_cents INT NOT NULL,
idempotency_key TEXT UNIQUE, -- evita pedido duplicado em retry
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id BIGINT NOT NULL REFERENCES orders(id),
event_seat_id BIGINT NOT NULL REFERENCES event_seats(id),
price_cents INT NOT NULL -- preço COPIADO, não só referenciado
);
CREATE TABLE payments (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id BIGINT NOT NULL REFERENCES orders(id),
amount_cents INT NOT NULL,
status TEXT NOT NULL, -- pending|succeeded|failed
provider_ref TEXT, -- id na Stripe/PSP
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Justificativas:
idempotency_key UNIQUE— evita pedido duplicado em retry/clique-duplo.order_items.price_centsé copiado, não só referenciado — congela o valor cobrado mesmo se o preço do evento mudar depois.provider_refguarda o ID no PSP para reconciliação.ORDERS → PAYMENTSé o único1:1; o resto é1:N.
Sem assento marcado, use contador com decremento atômico:
UPDATE ticket_pools
SET available = available - :qty
WHERE id = :pool_id
AND event_id = :event_id
AND available >= :qty; -- rowcount 0 = esgotadoDDL da tabela:
CREATE TABLE ticket_pools (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
event_id BIGINT NOT NULL REFERENCES events(id),
name TEXT NOT NULL, -- "Pista"
price_cents INT NOT NULL,
total INT NOT NULL,
available INT NOT NULL
);event_seats(event_id, status)— consulta mais quente ("mostre assentos disponíveis").- Índice parcial em
held_until WHERE status='held'— para o job de expiração não fazer full scan.
event_seats é o nó central, cruzando o eixo físico (venues → seats) com o eixo do evento (events). ticket_pools é o caminho alternativo para pista. orders → payments é o único 1:1.
erDiagram
VENUES ||--o{ SEATS : "tem"
VENUES ||--o{ EVENTS : "hospeda"
EVENTS ||--o{ EVENT_SEATS : "abre inventario"
SEATS ||--o{ EVENT_SEATS : "instanciado em"
EVENTS ||--o{ TICKET_POOLS : "tem pista"
USERS ||--o{ EVENT_SEATS : "segura hold"
USERS ||--o{ ORDERS : "faz"
ORDERS ||--o{ ORDER_ITEMS : "contem"
ORDERS ||--|| PAYMENTS : "pago por"
EVENT_SEATS ||--o{ ORDER_ITEMS : "vendido em"
VENUES {
bigint id PK
text name
text city
int capacity
}
SEATS {
bigint id PK
bigint venue_id FK
text section
text seat_row
text number
}
EVENTS {
bigint id PK
bigint venue_id FK
text name
timestamptz starts_at
text status
}
EVENT_SEATS {
bigint id PK
bigint event_id FK
bigint seat_id FK
int price_cents
text status
timestamptz held_until
bigint held_by FK
}
TICKET_POOLS {
bigint id PK
bigint event_id FK
text name
int total
int available
}
USERS {
bigint id PK
text email
text name
}
ORDERS {
bigint id PK
bigint user_id FK
text status
int total_cents
text idempotency_key
}
ORDER_ITEMS {
bigint id PK
bigint order_id FK
bigint event_seat_id FK
int price_cents
}
PAYMENTS {
bigint id PK
bigint order_id FK
int amount_cents
text status
text provider_ref
}
A reserva de 10 minutos não é um SELECT FOR UPDATE aberto por 10 minutos (isso esgotaria o pool de conexões, bloquearia o VACUUM). A reserva é dado, não lock: status='held' + held_until. A transação grava e commita em milissegundos. O "tempo de pensar" do humano acontece com a transação já fechada. Um job (ou checagem lazy) limpa holds vencidos.
Frase-síntese: modele reservas como dado e não como lock; mantenha transações curtíssimas.
UPDATE event_seats
SET status = 'held',
held_until = now() + interval '10 min',
held_by = :user
WHERE id = ANY(:seat_ids)
AND status = 'available';
-- rowcount < quantidade pedida → algum foi pego → rollbackO Postgres aplica lock de linha durante o UPDATE (microssegundos). Sob READ COMMITTED, a transação perdedora bloqueia, e quando a vencedora commita, o Postgres faz re-check (EvalPlanQual): re-avalia o WHERE contra a versão nova. Como status agora é 'held', a condição falha e a perdedora afeta zero linhas. Sem erro, sem exceção.
READ COMMITTED(default recomendado): perdedora afeta 0 linhas. Simples, sem retry. O re-check faz o trabalho.REPEATABLE READ/SERIALIZABLE: perdedora recebe erro de serialização → exige retry-loop na app. Complexidade extra sem ganho aqui (invariante já protegido porUNIQUE+WHERE).
Regra: use o isolamento mais baixo que ainda preserve seu invariante. Subir isolamento "por segurança" só transfere o problema para retries.
- "Todo mundo bate no mesmo evento" não é hot row — cada pessoa quer um assento diferente = linhas diferentes. Lock é por tupla, então rodam em paralelo.
- A contenção real é o contador de pista (
ticket_pools.available): uma linha que toda compra de pista decrementa → serializa 100%.- Solução 1 (preferida): counter shardado — quebra em N sub-linhas, decrementa um shard aleatório, soma = disponível. Throughput ×N.
- Solução 2: tirar o contador do Postgres →
DECRatômico no Redis; Postgres reconcilia async. Troca consistência forte por eventual. - Solução 3: aceitar a serialização se o volume não justificar.
Importante: a colisão real (duas pessoas no mesmo assento) existe e é até esperada, porque o mapa visto é stale (cache/réplica). O compare-and-set resolve limpo; o stale do mapa concentra cliques.
SELECT id
FROM event_seats
WHERE event_id = :ev
AND status = 'available'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 2;Cada transação concorrente pula linhas já travadas em vez de bloquear. Mil pessoas pedindo "2 lugares" não fazem fila. Mesmo padrão de filas de jobs.
Se A trava 1→2 e B trava 2→1, há deadlock (Postgres aborta uma). Prevenção: ordenar os locks deterministicamente (ORDER BY id). UPDATE ... WHERE id = ANY(...) num único statement já minimiza o risco.
Filosofia: empurre a correção para o banco (UNIQUE + WHERE condicional); o único hot row verdadeiro é o contador de pista.
- Assento marcado (mapa): selecionar assentos é estado local do cliente (nada no servidor). O hold (compare-and-set atômico do grupo inteiro) só dispara no "Continuar". Evita reserva por curiosidade e dá tudo-ou-nada.
- General admission ("melhor lugar"): o hold coincide com o clique de comprar — servidor escolhe e reserva na hora (caso de
SKIP LOCKED). - Em falha parcial: re-sincronizar o mapa (via delta), porque mais coisas mudaram além do assento que falhou. Com push ativo isso é reconciliação; sem push é a correção primária. Reserva parcial precisa de rollback para não deixar holds órfãos.
Transforma um pico instantâneo impossível (spike) num fluxo constante sustentável (steady stream). Admite pessoas à taxa que o backend aguenta (ex.: ~1k sessões ativas) e segura o resto numa sala barata.
Frase-síntese: o waiting room protege a vazão; o compare-and-set protege a correção. Duas defesas em camadas diferentes; uma não substitui a outra.
- Fila = sorted set no Redis (
ZADDcom score = chegada → FIFO). Front faz polling de posição. - Admissor puxa do topo (
ZPOPMINem lote) a taxa derivada da capacidade medida do backend (controle de fluxo com feedback, não número no escuro).
A fila só existe a partir do on-sale (antes, botão inativo). Gatilho: usuário clica "Comprar" após as vendas abrirem.
Ponto não-óbvio: o clique não vai ao backend de aplicação — se 500k cliques batessem nele só para decidir "te mando à fila", o ato de enfileirar já o derrubaria. O enfileiramento acontece na borda, que aguenta o pico bruto.
Quem faz na borda — não é "CDN burra", é edge compute:
- CDN com edge functions (Cloudflare Workers, Lambda@Edge, Fastly Compute): roda lógica e faz HTTP de saída no PoP, perto do usuário. Lugar ideal — absorve o pico antes de tocar a origem.
- Produto de waiting room pronto (Queue-it, Cloudflare Waiting Room): a peça de borda já embutida; você configura, não programa o
ZADD. - API Gateway pode, mas está atrás da CDN → o pico já entrou mais fundo. Melhor para roteamento/auth pós-filtro.
Passo a passo (clique às 10:00:01):
- Clique chega na borda → filtros baratos (rate limit, anti-bot, sessão autenticada). Falhou → barrado antes de tocar o Redis.
- Borda verifica: já tem token de admissão válido? (cookie/header). Sim → vai direto ao serviço de compra (pula a fila). Não → entra na fila.
- Borda chama o serviço de waiting room (POST server-to-server, invisível ao navegador) →
ZADD queue:event:123 <score> <user_id>. - Devolve um ID de fila assinado (não é token de admissão — é o que liga a pessoa à entrada no sorted set, usado para consultar posição).
- Página vira tela de espera → polling de posição via
GET /queue/status?id=...(ZRANK). - Loop até o admission controller puxá-la (
ZPOPMIN) e emitir o token de admissão.
Verbos HTTP e por quê:
- POST para entrar (cria estado no Redis; REST reserva POST para mutação).
- GET para consultar posição (idempotente, só lê).
- 302 redirect (opcional, UX): manda o navegador para uma sala de espera separada (
queue.site.com/...) — desacopla a fila do site. Alternativa SPA:fetchPOST + re-render da mesma página, sem trocar URL.
O que se registra no Redis (ZADD):
- Membro =
user_id(ouqueue_session_idligado a ele). Vantagem: dedup natural — clicar "Comprar" 3× não cria 3 entradas (mesmo membro = atualiza a existente). Uma pessoa = uma posição, de graça. Reforça a justiça. - Score define a ordem: timestamp (FIFO) ou conforme o random draw (ver abaixo).
- TTL / "visto por último" atualizado no polling → quem fecha a aba é eventualmente removido (desistência silenciosa).
Entrada (
ZADD, ~500k/s no pico, na borda) e saída (ZPOPMIN, ~1k/s, no admissor) operam sobre o mesmo sorted set, em pontas opostas. A diferença entre as duas taxas é a fila crescendo.
Serviço de backend dedicado que roda durante o on-sale, separado do serviço de compra. Responsabilidade única: decidir quando e quantos tirar da fila e emitir token. É o porteiro da catraca.
Ponto-chave: "1k" não é um lote despejado de uma vez — é um teto de concorrência (sessões ativas simultâneas que o backend aguenta). O admissor mantém o sistema cheio até o teto, repondo conforme as sessões vagam. Não é "libera 1k e para"; é fluxo contínuo de reposição.
O loop (a cada ~1s):
flowchart TD
START[A cada ~1s] --> COUNT[Conta sessoes ativas<br/>tokens nao-expirados no Redis]
COUNT --> CALC{Folga ate o teto?<br/>ex: 1000 - 850 = 150}
CALC -->|folga = 0| WAIT[Nao admite ninguem<br/>segura a fila]
CALC -->|folga > 0| POP[ZPOPMIN da folga<br/>puxa N do topo da fila]
POP --> TOK[Emite token assinado<br/>1 por pessoa]
TOK --> REG[Registra sessao no Redis<br/>com TTL = expiry do token]
REG --> START
WAIT --> START
A vazão real de admissão é ditada pela velocidade com que as sessões terminam (compra, desistência, expiração), não por um número fixo. Se cada compra leva ~3 min, admite-se ~1k a cada 3 min, em ondas pequenas por segundo.
Não use um contador incrementa/decrementa manual (frágil — um decremento perdido faz o número driftar para sempre). Use a expiração natural do Redis:
- Ao emitir token, registra a sessão com TTL = expiry do token (sorted set de ativas com score = timestamp de expiração, ou chaves com
EXPIRE). - Contagem de ativas = entradas ainda não expiradas.
- Compra concluída → serviço de compra remove a entrada explicitamente.
- Sessão abandonada (fechou o navegador) → Redis expira sozinho; a vaga volta automaticamente.
Por que importa: sem expiração, sessões-fantasma (admitido que sumiu sem comprar nem cancelar) ocupariam vagas para sempre e o sistema "encheria" até travar. A expiração auto-corrige o contador.
- Decisão global e serializada: "quantas vagas há no total agora?" é pergunta sobre o sistema inteiro. Se cada instância de compra admitisse por conta própria, 10 instâncias "até 1k" = 10k → teto furado. Centralizar evita isso.
- Single point of failure: se o admissor morre, a fila para. Roda com redundância (eleição de líder, ou particionado por evento).
ZPOPMINatômico garante que múltiplos admissores nunca puxem a mesma pessoa duas vezes. - Teto dinâmico (ideal): lê métricas de saúde (latência do Postgres, profundidade de fila, taxa de erro) e ajusta o teto — abaixa se sofre, sobe se folga. O "1k" é ponto de partida, não valor gravado em pedra (controle de fluxo com feedback).
A intuição "isso é uma fila, use um broker consumido em leaky bucket" é boa — e o admissor é um leaky bucket (chega em rajada, vaza a taxa controlada). Mas a fila virtual precisa de três coisas que um broker tradicional não dá nativamente, todas vindas da natureza observável e manipulável da fila:
| Necessidade | Sorted set (Redis) | Broker (Kafka/RabbitMQ) |
|---|---|---|
| Posição na fila ("sou o 34.812?") | ZRANK O(log n) trivial |
Mensagens são opacas até consumir; não há "posição consultável" sem índice paralelo |
| Lookup por ID ("estado do usuário X") | Acesso endereçável direto | Sem endereço; só consumo em ordem |
| Inspecionar/contar/remover | Estrutura de dados consultável | Pipe de transporte; mensagem some ao consumir |
| Reordenar (random draw) | Reatribui scores aleatórios | Ordem imutável (FIFO por partição); embaralhar é contra a corrente |
| Expiração por item (sessão-fantasma) | TTL nativo | Mensagens não "expiram por inatividade do dono" no meio da fila |
| Exactly-once na admissão | ZPOPMIN atômico |
At-least-once por padrão; exactly-once é pesado |
A distinção: a fila virtual é uma estrutura de dados consultável (precisa de posição, contagem, reordenação, expiração, lookup por ID); o broker é um transporte de mensagens opacas. Compartilham o nome "fila", mas são abstrações diferentes.
Sinal de decisão: o requisito "lookup por ID / posição de X" já te tira do território de broker e te põe no de store (Redis/banco). Presença de endereçamento = isto é estado, não fluxo.
A fila precisa sobreviver a restart/crash com recovery — mas não precisa de durabilidade transacional. São duas garantias diferentes, e nomeá-las separado evita escolher a tecnologia pelo motivo errado:
- Persistência / recuperabilidade (best-effort): o estado não evapora num restart; recupera o último snapshot/log, podendo perder os últimos segundos de escrita. É o que Redis com AOF/RDB entrega — recarrega ao reiniciar, sem começar do zero. Suficiente para a fila.
- Durabilidade ACID (o "D"): transação commitada nunca se perde, mesmo em desastre. Zero perda. Cara — só se justifica para o que é sagrado. É o que o Postgres entrega à venda (
sold,order,payment).
Por que Redis para a fila e Postgres para a venda, se "ambos persistem"? A fila tolera recovery best-effort (e pode até ser reconstruída: ninguém comprou ainda, e o random draw torna a ordem perdida pouco sagrada). A venda exige durabilidade ACID (representa dinheiro + contrato). Escolhe-se Redis apesar da durabilidade fraca — pela velocidade e estruturas consultáveis —, não por causa dela.
Nuance de HA vs durabilidade: "zero durabilidade" é forte demais. Roda-se o Redis com replicação (réplicas em standby) + AOF para sobreviver à queda de um nó sem perder a fila — isso é alta disponibilidade, não durabilidade transacional. Protege contra "um servidor caiu", não promete a garantia de uma transação financeira. Garantia dimensionada ao valor do dado.
Onde o broker é a ferramenta certa: no trabalho assíncrono pós-venda — e-mail de confirmação, gerar PDF do ingresso, analytics, notificar downstream. Isso é fila de tarefas (fire-and-forget, taxa controlada, ninguém pergunta "qual a posição do meu e-mail"). Aí Kafka/RabbitMQ + leaky bucket de consumo se aplicam perfeitamente.
Regra: escolha a abstração pela forma de acesso e pela garantia que você precisa, não pela palavra "fila". "Durável" não é binário — é um espectro; posicione cada dado no ponto certo dele.
- Stateless e assinado (JWT com
user_id,event_id,expiry). - O sistema de compra valida a assinatura localmente, sem ir ao banco/Redis → verificação O(1), distribuída. Se exigisse lookup, recriaria um gargalo central.
- TTL curto (alinhado ao hold). Expirado → volta à fila / sessão encerrada.
Gerar uma credencial assinada que prova admissão e entregá-la ao cliente. Três passos:
- Monta o payload: objeto/JSON com
user_id,event_id,exp(expiração), talvezsession_id. - Assina criptograficamente: com JWT, calcula a assinatura usando chave secreta (HMAC) ou privada (RSA/ECDSA). Resultado: string
header.payload.signature. A assinatura torna o token infalsificável — qualquer um lê o conteúdo, ninguém forja sem a chave. - Devolve ao cliente (corpo da resposta do polling, ou cookie). Daí em diante toda requisição ao serviço de compra carrega o token.
O ganho: o serviço de compra recalcula a assinatura e compara — se bate, confia em user_id/event_id/exp sem consultar banco/Redis. A prova viaja com a requisição.
Analogia: emitir token = carimbar a mão na entrada da festa. O segurança de cada área não liga para a bilheteria — só olha o carimbo (difícil de falsificar). O carimbo é a prova, e está com você, não num registro central.
Stateless vs stateful: JWT (conteúdo é a prova, sem lookup, difícil revogar antes de expirar) vs ID opaco apontando para sessão no Redis (revoga na hora, mas recria o lookup central). Para waiting room, stateless com TTL curto costuma vencer — "revogar" = deixar expirar (minutos).
- A hora exata de chegada não determina a posição. Objetivo: justiça.
- Por quê: FIFO puro premia latência de rede, proximidade do servidor e bots (clicam em 5ms). Sorteio neutraliza isso. Com demanda >> oferta, o que se otimiza é a percepção de justiça.
O conceito central: conjunto fechado. O sorteio é feito uma única vez, sobre o lote da janela inicial do on-sale — NÃO continuamente conforme a galera entra.
- Durante a janela de coleta (ex.: on-sale até +2 min, ou até os pré-registrados entrarem), ninguém tem posição — todos num "balde" indiferenciado. Tela mostra "você está dentro, sorteio às 10:02".
- Janela fecha → balde congela (conjunto fechado) → roda um shuffle → todos recebem posição numa sequência densa de 1 a N (sem buracos).
- A partir daí: FIFO normal sobre a ordem sorteada. Quem chega depois da janela vai para o fim (FIFO puro), sem re-sortear o que já foi.
Trade-off: durante a coleta não dá para mostrar posição (ela ainda não existe) → limbo/ansiedade. Mitiga-se com UX ("sorteio às 10:02, todos têm chance igual"). É escolha de produto, não só técnica.
A armadilha (o que NÃO fazer): usar número aleatório como a posição exibida, em tempo real, conforme a galera entra. Quebra tudo:
- Pessoa que tira 80.412 vê posição 80.412 mesmo sendo a 30ª a chegar (com só 1k na fila). Posição deixa de significar "quantos na minha frente".
- Posições flutuam: quem entra depois com número menor "passa na frente", fazendo a posição exibida de outros mudar sem ação deles. Números pulando, caos.
- Raiz: mistura ordem (quem vai antes) com posição exibida (quantos na frente). Posição precisa ser rank denso, não número cru.
Como embaralhar corretamente — duas opções equivalentes:
- Fisher-Yates: algoritmo padrão de shuffle uniforme (toda permutação com prob.
1/n!, sem viés). O(n), in-place.para i de n-1 até 1: troca lista[i] com lista[aleatório(0..i)]. Shuffles ingênuos introduzem viés (algumas ordens mais prováveis) → injusto. - Sort-shuffle (atribui número aleatório a cada um + ordena por ele, posição = rank): matematicamente equivalente a um shuffle. O(n log n), precisa guardar a chave. Honesto SE feito sobre o conjunto fechado e usando o rank (não o número cru) como posição.
A disciplina que torna o sort-shuffle correto: sorteie sobre o lote fechado e use o rank como posição. O erro é re-ordenar a cada nova entrada (ranks de quem já estava mudam) ou exibir o número cru. Congele o balde → atribua → ordene uma vez → ranks definitivos → novos vão para o fim.
Frase-síntese: o random draw só define a ordem inicial do FIFO, uma vez, sobre o lote do on-sale. Depois é FIFO comum. Posição = rank denso num conjunto fechado, nunca número aleatório cru em tempo real.
- Entrada da fila (alto volume, barato): rate limit, CAPTCHA, fingerprinting, conta autenticada. Barra abuso antes do custo do
ZADD(entrar na fila = ganhar uma posição disputada; bot não pode roubar posições). - Dentro da fila: sem filtro novo; só expiração por inatividade.
- Saída / emissão do token (caro, dependente de estado): limite de N ingressos por conta, situação da conta, decisão de admissão por capacidade. Só roda para a fração admitida (~1k).
Regra: filtro barato e de alto volume → entrada; filtro caro e dependente de estado → saída.
- Exige conta e login, normalmente antes de entrar na fila (não há compra anônima).
- Razões: limite N por pessoa (impossível sem identidade), justiça do sorteio (1 conta = 1 entrada), ingresso é nominal (reembolso/transferência precisam de dono).
- Não é gargalo no on-sale porque: (1) login é deslocado no tempo (incentiva-se logar antes); (2) sessão vira token/cookie stateless, validado por assinatura sem tocar o auth; (3) auth escala horizontalmente fácil (sem invariante disputado); (4) opção: pôr a fila na frente do login para o auth também só ver tráfego regulado.
Cenário "e quando o componente que protege tudo é o que falha?". O Redis da fila guarda duas coisas que falham diferente:
- Fila de espera (sorted set de quem aguarda): se evapora, ninguém entra/consulta posição — mas é reconstruível e ninguém perdeu dinheiro.
- Contador de sessões ativas (estado das admissões): mais perigoso — sem ele, o admissor perde a decisão central ("há vaga até o teto?").
Dois modos de falha:
- Partição de rede (Redis vivo, admissor não fala com ele): o pior caso — não dá para saber se morreu ou é só a rede; risco de split-brain (dois admissores se achando líderes). É o CAP na prática: sob partição, escolha entre operar (disponibilidade, arrisca inconsistência) ou parar (consistência, fica indisponível).
- Crash total: sem ambiguidade; estado sumiu, modo de recuperação.
O dilema central — fail-open vs fail-closed:
- Fail-closed (parar de admitir): sem saber a contagem, não admite ninguém novo. A fila congela, mas quem já tem token continua comprando (token stateless não depende do Redis). Degrada para "ninguém novo entra, mas quem entrou termina".
- Fail-open (admitir às cegas): catastrófico — pode admitir 50k para um backend de 1k e derrubar o Postgres (o componente sagrado). Transforma falha contida em colapso.
Regra: falhe na direção que protege o componente mais valioso. A fila existe para proteger a venda → se a fila falha, deve falhar parando, não vazando. Um portão que trava fechado é seguro; um que trava aberto é desastre.
Estratégias de degradação (em ordem de prioridade):
- HA primeiro: Redis com replicação + failover automático (Sentinel/Cluster). Resolve a maioria dos "Redis caiu" reais (queda de um nó) antes de virar o cenário bizarro.
- Degradação invisível ao usuário: em vez de 500, mostra "alta demanda, sua vez será mantida, aguarde" + polling com backoff. Numa fila gigante, "parou de andar" é indistinguível de "ainda não chegou sua vez" → degradação invisível.
- Reconstruir a fila: AOF/RDB ou réplica recarrega o estado quase intacto. No pior caso, clientes reportam sua posição conhecida ao reconectar ("eu era o 34.812") → reconstrói aproximação (ok, porque o random draw já tornava a ordem pouco sagrada).
- Fail-slow no contador: perdeu a contagem exata mas sabe o teto → admite a taxa fixa conservadora por tempo ("1 a cada 2s cegamente") em vez de parar 100%. Troca precisão por vazão pequena e segura.
- Segunda fonte de verdade: o admissor lê saúde do backend (latência/erro do Postgres) direto, não só o contador do Redis. Cego para a contagem mas com Postgres saudável → admite devagar; sofrendo → para. Desacopla a decisão de depender só do Redis.
Lição que generaliza: o token stateless já tornava a queda do Redis contida, não catastrófica (compras em andamento não dependem dele) e a fila é efêmera/reconstruível. Não deixe a proteção do sistema depender de um único ponto que, se cair, te deixa cego e desprotegido ao mesmo tempo.
O erro: decrementar inventário só após o pagamento → janela para vender o mesmo assento 2×.
Ordem correta:
- Hold atômico no inventário (compare-and-set) — garante exclusividade antes de qualquer dinheiro.
- Dispara pagamento no PSP (assíncrono; frontend faz polling/espera webhook; order
pending → paid). - Confirmação via webhook (fonte de verdade do pagamento, mais confiável que resposta síncrona) → promove
held → solde emite ingresso. Falha/expiração → assento volta aavailable.
Frase-síntese: pagamento é assíncrono e idempotente; o inventário é reservado de forma síncrona antes do pagamento e confirmado depois. Order e inventário ficam juntos no mesmo shard para que o hold seja transacional.
- Idempotência (
orders.idempotency_key): webhooks de PSP chegam duplicados rotineiramente. Sem isso, emite ingresso/cobra 2×. - Expiração do hold durante o pagamento: o hold tem que cobrir folgadamente o tempo máximo de pagamento, ou ser estendido por marco objetivo. Pix é o caso crítico (compensação fora do seu controle).
- Order co-localizada com inventário (mesmo shard) permite hold + criar order na mesma transação ACID. Não precisa de DynamoDB separado — o volume de orders por evento (~1k/s admitidos) cabe num Postgres shardado, e a co-localização dá atomicidade de graça.
- Falha recuperável (cartão recusado, Pix pendente, timeout): assento não é solto; usuário tenta de novo na mesma sessão enquanto o hold é válido. Não volta à fila.
- Falha terminal (hold expirou / desistiu): assento volta a
available; na maioria dos sistemas reais, usuário volta ao fim da fila (guardar posição é vetor de scalper). Possível janela de carência curta. - Renovação de hold a cada passo: tecnicamente barato (um UPDATE), mas evitado — vira vetor de hoarding (segurar assento indefinidamente clicando avançar/voltar). Padrão: hold único e generoso com cronômetro visível. Extensão só atrelada a marco objetivo (início de pagamento) e com teto absoluto.
Regra: a posse temporária deve ter um teto que o usuário não controla.
- Venda: consistência forte, baixo volume.
- Leitura do mapa: altíssimo volume, tolera staleness (mapa stale por 2s é ok; o compare-and-set resolve a corrida real no clique).
Frase-síntese: a leitura pode ser stale porque a fonte de verdade não é o que o usuário vê, é o que o UPDATE condicional decide na compra.
Usuários → CDN/edge → Cache em memória (Redis) → Read replicas → Postgres primário (só escrita)
- Primário recebe escrita; N réplicas recebem leitura (replicação assíncrona).
- Lag de replicação: réplica está atrás. Aceitável para UI.
- Armadilha clássica: nunca leia da réplica no caminho da venda (veria "livre" um assento já vendido). UI → réplica; decisão de venda → sempre primário.
- Opção A (default): TTL curto + aceitar staleness. Simples, robusto. Cuidado com cache stampede (todas as cópias expirando juntas) → mitiga com expiração jitterada + single-flight.
- Opção B: invalidação ativa (write-through / pub/sub no evento de venda). Mais fresco, muito mais complexo (precisa de pipeline confiável; lida mal com falha parcial).
Regra: invalidação de cache é difícil; prefira expiração a invalidação ativa, a menos que o produto exija o mapa quase ao vivo.
- Polling agressivo multiplica leitura por centenas de milhares.
- Solução: WebSocket / SSE — servidor empurra deltas ("47, 48 vendidos") via pub/sub por evento. Troca "N × polling" por "1 conexão por cliente recebendo deltas".
- Custo: manter as conexões abertas → camada dedicada de servidores WS (fan-out, desacoplada do banco).
Assentos de shows passados nunca mudam → mapa imutável, cacheável para sempre na CDN. Só eventos ativos precisam da maquinaria de cache fresco/push.
- A operação central ("vender este assento exatamente uma vez") é um problema de coordenação — exatamente o que Cassandra foi feito para evitar.
- LWT do Cassandra (
IF NOT EXISTS, via Paxos) resolveria em tese, mas é caro (mata a vantagem de perf) e não dá transações multi-linha de verdade.
Regra: escolha o banco pela operação mais restritiva (a venda — atomicidade + unicidade forte), não pela mais frequente (leitura do mapa). Source of truth = Postgres/MySQL com ACID nativo.
- Relacional (source of truth): inventário, holds, orders, payments. Shardado por evento.
- Ao lado (toleram staleness / append-only): Redis (fila, cache), Cassandra/DynamoDB (analytics, histórico, feeds, notificações). Nenhum decide quem ficou com o assento.
Não é partition key de keyspace distribuído. É roteamento de qual servidor Postgres atende qual evento.
- Hot partition? Não, porque um shard é uma instância inteira com milhares de linhas de
event_seats(contenção baixa, cada comprador mira linha diferente). Evento grande ocupar um shard inteiro é desejável (capacidade proporcional). Evento gigante demais → sub-particiona dentro do evento (ex.: por seção). - Quebra FK/UNIQUE? Não, porque nenhuma transação de compra cruza dois eventos. Todas as invariantes críticas (
UNIQUE(event_id, seat_id),order_items → orders) ficam dentro de um shard.
Regra de ouro: sharding funciona bem quando o limite do shard coincide com o limite da transação. Em ticketing, o evento é esse limite natural.
Se "não couber num Postgres" → sharding por evento, não trocar por Cassandra.
- Dado do evento → shardado por
event_id. - Dado do usuário (identidade, auth, segurança, perfil, métodos de pagamento) → global replicado, ou shardado por
user_id. Nunca porevent_id. - Antifraude é global e precisa de visão cross-event (scalper só é visível olhando vários shows).
- O encontro dos dois eixos é a compra: o
user_id(mundo global) entra no shard do evento via o token de admissão.
orders.user_idno shard do evento é umBIGINTcomum, semREFERENCES(a tabelausersnem está naquele banco).- Trade-off padrão e esperado de qualquer sistema shardado. A validação migra para a aplicação: o
user_idvem carimbado pelo token assinado (já validado upstream). - O que a FK protegeria (delete cascateando) quase não ocorre — contas são desativadas, não deletadas (há orders/ingressos pendurados).
- FKs intra-shard mantêm-se com rigor (são as que protegem os invariantes da venda).
Não se constrói do zero — integra-se. Camadas de fora para dentro:
- CDN (Cloudflare, Akamai, Fastly, CloudFront): absorve picos, cache estático, primeira mitigação de DDoS volumétrico.
- WAF (Web Application Firewall): inspeciona forma da requisição contra padrões de ataque (injeção, exploit, malformação).
- Bot management (Cloudflare Bot Mgmt, DataDome, HUMAN/PerimeterX, Kasada): distingue bot de humano. Coração do anti-scalper.
- Device fingerprinting: identifica o dispositivo/navegador sem cookie/login, combinando atributos (browser, SO, resolução, fontes, timezone, canvas/audio fingerprinting). Combinados, formam algo estatisticamente raro. Reconecta 500 contas falsas ao mesmo dispositivo real. Trade-off: corrida armamentista (bots spoofam) + privacidade (GDPR).
- CAPTCHA (hCaptcha, reCAPTCHA, Turnstile): ferramenta dentro do bot management, usada adaptativamente (só quando há sinal de risco; tráfego limpo passa invisível).
- Reputação de IP resolve o problema de NAT/CGNAT (classifica residencial vs datacenter vs CGNAT).
- Antifraude transacional (Stripe Radar, Sift, Riskified, ou o PSP): age no pagamento (cartão roubado, mismatch de geo).
- Velocity de compras: taxa de ação num intervalo (mesmo cartão/dispositivo/IP com volume anômalo). Captura a assinatura temporal de fraude/scalping (que são operações de escala). Ex.: card testing (micro-transações em rajada para achar cartões válidos).
- Regras de negócio anti-abuso (suas, não terceirizáveis): limite N por conta/cartão/evento, bloqueio de contas em massa, detecção de revenda. Dependem da identidade e histórico que só você tem.
Divisão: anti-bot protege o acesso; antifraude protege a transação. Você integra CDN/WAF/bot-mgmt/antifraude e constrói as regras de limite por conta.
- Rate limit por IP cru prejudica NAT empresarial / CGNAT (operadora móvel) — muitos usuários legítimos num punhado de IPs.
- Chave primária = conta autenticada (
user_id), imune a NAT. IP entra como sinal secundário ponderado por reputação, nunca bloqueio cru. - Sinal mais forte contra bot = comportamento (fingerprinting, timing, headers), não volume.
Não são alternativas — operam em camadas distintas.
- Load Balancer: distribui tráfego entre N réplicas do mesmo serviço. Pergunta: "para qual das N cópias idênticas?". Resolve quantidade.
- API Gateway: ponto de entrada inteligente — autenticação, rate limiting de negócio, transformação, roteamento por serviço (
/orders→ serviço de pedidos). Pergunta: "o que é isto e qual serviço trata?". Resolve direção.
CDN/WAF → LB (externo) → API Gateway (N réplicas) → LB interno / discovery → serviço (N réplicas)
- LB na frente do gateway: para escalar o próprio gateway (senão é ponto único de falha).
- LB atrás do gateway: para escalar cada serviço downstream. Balanceamento aparece em várias camadas.
- Gateways gerenciados (AWS API Gateway) embutem balanceamento → o LB explícito some da sua visão.
- LBs L7 modernos (ALB, nginx) fazem roteamento por path/header → assumem parte do papel do gateway; em sistemas simples, dispensam o gateway dedicado.
Regra: segurança de borda (WAF/bot/DDoS) vem o mais cedo possível (edge, antes do LB); o gateway vem depois (responsabilidades de aplicação, não de segurança bruta — não gaste lógica de app em tráfego que a borda já deveria barrar).
Frase norteadora: "Empurrar trabalho para as pontas é como fazemos um sistema escalar." ("Pushing work to the edges is how your system scales.") Thread relacionada (sobre fila virtual e backpressure): https://threadreaderapp.com/thread/1403139365069594627.html
Sistema sobrecarregado tem 2 saídas:
- Load shedding (dropar requisição) — inaceitável em venda (perder venda).
- Backpressure (desacelerar a entrada) — sobra esta.
A fila virtual é backpressure com UX em cima — por baixo é um "503 / too busy", mas em vez de erro cru, dá posição, estimativa e a promessa de que a vez chega. Diferença de contrato com o usuário, não de mecânica.
É decisão de design correta mesmo com recursos infinitos, porque:
- A oferta é estruturalmente finita (50k ingressos) — escassez que máquina nenhuma resolve.
- Protege sistemas terceiros que não escalam na sua proporção (PSP processa X tps e ponto; escalar você só faz bater mais rápido num teto que não é seu).
- Habilita sorteio justo e combate a bots.
Escalar é saber, para cada parte do sistema, qual movimento ela pede:
- Descentralizar (o que tolera staleness): cache, CDN, validação stateless de token, push. Move a execução para fora do núcleo.
- Regular o fluxo (o que exige consistência forte e tem teto): a venda, no primário único. Backpressure / fila virtual. O trabalho continua no centro; só a cadência de entrada é controlada.
- Particionar (o que tem fronteira natural): sharding por evento. Divide em pedaços independentes que escalam lado a lado.
Erro comum: aplicar um movimento onde outro era necessário (escalar a venda "comprando máquinas"; cachear o que precisa ser forte).
Tudo isso é "empurrar para as pontas", mas o que se empurra varia:
- Empurrar execução (cache, edge): a ponta computa o que o centro faria → o centro faz menos.
- Empurrar controle de fluxo (fila virtual / backpressure): a ponta recebe a espera e a decisão (desistir / re-tentar / aguardar) → o centro faz a mesma coisa, na cadência que aguenta. Backpressure literalmente reflui a pressão para montante (o cliente).
Síntese final: escalar é perguntar sistematicamente "o que dá para empurrar para a ponta — a execução ou o controle?". O centro que resiste (a venda) não resiste a tudo: ele resiste a ter a execução empurrada, mas aceita de bom grado ter o controle de entrada empurrado para o cliente. É exatamente isso que a fila virtual faz.
O caminho que exige consistência forte. Tudo passa pela borda e pela fila antes de tocar o primário; o pagamento é assíncrono e o inventário é reservado antes dele.
flowchart TD
U[Usuario autenticado] --> EDGE[CDN / WAF / bot mgmt]
EDGE --> WR[Waiting room - Redis]
WR -->|admite ~1k por vez| TOK[Token assinado - JWT]
TOK --> BUY[Servico de compra]
BUY -->|1. hold atomico<br/>compare-and-set| PG[(Postgres primario<br/>shard do evento)]
BUY -->|2. dispara cobranca| PSP[PSP / gateway pagamento]
PSP -->|3. webhook idempotente| CONF[Confirmacao]
CONF -->|held to sold + emite ingresso| PG
CONF -.->|falha / expira: volta a available| PG
Pontos-chave do fluxo de escrita:
- A borda e a fila garantem que o primário só recebe tráfego regulado (~1k/s), nunca o pico bruto.
- Ordem crítica: hold (síncrono) antes do pagamento (assíncrono). Confirmação via webhook idempotente promove
held → sold. hold+ criarorderna mesma transação ACID (co-localizadas no mesmo shard).
O caminho de altíssimo volume que tolera staleness. A tela do mapa combina duas coisas distintas: o layout (estático) e o estado de disponibilidade (dinâmico).
flowchart TD
V[Centenas de milhares<br/>olhando o mapa] --> CDN[CDN / edge]
CDN --> LAYOUT[Layout do mapa<br/>estatico, TTL longo<br/>nunca invalidado na compra]
CDN --> SNAP[Snapshot de disponibilidade<br/>TTL de segundos<br/>expira sozinho]
SNAP -->|miss / regenera| CACHE[(Redis<br/>estado available/held/sold)]
CACHE -->|miss| RR[(Read replicas)]
RR -.->|nunca no caminho da venda| PG[(Postgres primario)]
PG ==>|replicacao assincrona| RR
PG -->|delta de venda| PUB[Pub/Sub por evento]
PUB -->|push WebSocket / SSE<br/>atualizacao ao vivo| V
Pontos-chave do fluxo de leitura:
- Layout vs estado: a CDN serve o layout estático (planta, seções, posições, preços) com TTL longo — a compra não o altera, logo nunca se invalida. O estado de disponibilidade (
available/held/sold) vive no Redis. - Snapshot de estado na CDN (opcional): um JSON de disponibilidade pode ser cacheado na borda com TTL curtíssimo (segundos) para absorver a carga de page-load. Ele expira sozinho, não é invalidado na compra (ver explicação abaixo do documento).
- Atualização ao vivo (assento ficando cinza em tempo real) vem do push (WS/SSE), alimentado pelo pub/sub que dispara na venda — não da CDN nem de polling no Redis.
- Na compra, o que se atualiza síncrono é o Postgres; o que se propaga async é o Redis e o canal de push. A CDN não é tocada ativamente.
- Cache, réplicas e canais pub/sub são particionados por evento (um show não contamina outro).
Sobre o snapshot que "expira sozinho": com TTL curto (
Cache-Control: max-age=2), a CDN serve a cópia em cache por ~2s e, passado esse tempo, o próximo request gera um cache miss — a CDN busca a versão fresca na origem (Redis/serviço de mapa) e re-cacheia. Ninguém "empurra" a atualização para a CDN na hora da venda; a CDN se reconcilia por demanda quando o TTL vence. É pull por expiração, não push por invalidação. Trade-off: o estado pode estar até ~TTL segundos atrasado — aceitável, porque o compare-and-set resolve a corrida real no clique.
- Waiting room → regula quanta gente entra (protege a vazão de escrita).
- Compare-and-set no primário → cada assento vende uma vez só (protege a correção da escrita).
- Pilha de leitura (CDN, cache, réplicas, push) → absorve o volume de quem só observa, longe do primário.
E tudo particionado por evento, para que nenhum show contamine outro.