Skip to content

Instantly share code, notes, and snippets.

@rponte
Created June 16, 2026 10:44
Show Gist options
  • Select an option

  • Save rponte/0ea9189e451aa086d491377cfa30386a to your computer and use it in GitHub Desktop.

Select an option

Save rponte/0ea9189e451aa086d491377cfa30386a to your computer and use it in GitHub Desktop.
System Design: Sistema tipo Ticketmaster e Ingressos.com

System Design — Sistema tipo Ticketmaster

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.


Índice

  1. Modelagem de schema (Postgres)
  2. Concorrência no banco
  3. Fila virtual / waiting room
  4. Hold + pagamento (ordem crítica)
  5. Escala de leitura
  6. Sharding
  7. Proteção de borda
  8. Load Balancer vs API Gateway
  9. Filosofia de resiliência e escala
  10. Diagramas de fluxo

Visão única (as 3 defesas)


1. Modelagem de schema (Postgres)

Separação física vs inventário

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.

  • venuesseats: layout físico permanente (seção, fila, número). FK seat → 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).

Tabelas de base

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
);

A tabela que sustenta a entrevista: event_seats (a mais importante)

É 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.
  • status como máquina de estados (available → held → sold) torna a venda concorrente segura.
  • held_until / held_by na própria linha simplifica o lock (atualiza uma linha só). Alternativa: tabela seat_holds separada (melhor para auditoria/histórico, mais complexa). Trade-off.

Pedido e pagamento

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_ref guarda o ID no PSP para reconciliação.
  • ORDERS → PAYMENTS é o único 1:1; o resto é 1:N.

Pista / general admission

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 = esgotado

DDL 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
);

Índices que importam

  • 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.

MER (modelo entidade-relacionamento)

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
  }
Loading

2. Concorrência no banco

O erro nº 1: confundir "reserva" com "lock de banco"

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.

Compare-and-set (a abordagem default)

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 → rollback

O 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.

Isolation level: muda o modo de falha, não a correçã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 por UNIQUE + WHERE).

Regra: use o isolamento mais baixo que ainda preserve seu invariante. Subir isolamento "por segurança" só transfere o problema para retries.

Hot rows: onde a contenção realmente mora

  • "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 → DECR atô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.

SKIP LOCKED — "me dá N assentos quaisquer"

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.

Deadlock ao reservar múltiplos assentos

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.

Onde o hold dispara no fluxo de UX

  • 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.

3. Fila virtual / waiting room

Propósito: admission control (regulador de vazão), não "fila justa"

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.

Mecânica

  • Fila = sorted set no Redis (ZADD com score = chegada → FIFO). Front faz polling de posição.
  • Admissor puxa do topo (ZPOPMIN em lote) a taxa derivada da capacidade medida do backend (controle de fluxo com feedback, não número no escuro).

Fluxo de ENTRADA na fila (a ponta de entrada do sorted set)

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):

  1. Clique chega na borda → filtros baratos (rate limit, anti-bot, sessão autenticada). Falhou → barrado antes de tocar o Redis.
  2. 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.
  3. Borda chama o serviço de waiting room (POST server-to-server, invisível ao navegador) → ZADD queue:event:123 <score> <user_id>.
  4. 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).
  5. Página vira tela de espera → polling de posição via GET /queue/status?id=... (ZRANK).
  6. 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: fetch POST + re-render da mesma página, sem trocar URL.

O que se registra no Redis (ZADD):

  • Membro = user_id (ou queue_session_id ligado 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.

O admission controller (worker de admissão)

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
Loading

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.

Como o contador de "sessões ativas" se mantém honesto

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.

Por que é serviço separado (e os perigos)

  • 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). ZPOPMIN atô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).

Sorted set (Redis) vs fila num broker (Kafka/RabbitMQ + leaky bucket)

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.

Recuperabilidade: persistência best-effort vs durabilidade ACID

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.

O token de admissão cola tudo

  • 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.

O que significa "emitir token"

Gerar uma credencial assinada que prova admissão e entregá-la ao cliente. Três passos:

  1. Monta o payload: objeto/JSON com user_id, event_id, exp (expiração), talvez session_id.
  2. 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.
  3. 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).

Random draw (sorteio) vs FIFO

  • 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.

  1. 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".
  2. Janela fecha → balde congela (conjunto fechado) → roda um shuffle → todos recebem posição numa sequência densa de 1 a N (sem buracos).
  3. 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.

Onde os filtros ficam (distribuídos)

  • 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.

Login

  • 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.

Quando o Redis cai / partição de rede (graceful degradation)

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):

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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 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.


4. Hold + pagamento (ordem crítica)

Inventário PRIMEIRO, pagamento DEPOIS

O erro: decrementar inventário só após o pagamento → janela para vender o mesmo assento 2×.

Ordem correta:

  1. Hold atômico no inventário (compare-and-set) — garante exclusividade antes de qualquer dinheiro.
  2. Dispara pagamento no PSP (assíncrono; frontend faz polling/espera webhook; order pending → paid).
  3. Confirmação via webhook (fonte de verdade do pagamento, mais confiável que resposta síncrona) → promove held → sold e emite ingresso. Falha/expiração → assento volta a available.

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.

O que não esquecer

  • 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 de pagamento e renovação de hold

  • 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.


5. Escala de leitura

Insight: leitura e escrita têm requisitos opostos

  • 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.

Camadas (cada uma absorve a anterior)

Usuários → CDN/edge → Cache em memória (Redis) → Read replicas → Postgres primário (só escrita)

Read replicas

  • 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.

Cache do mapa (a dificuldade é invalidar, não guardar)

  • 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.

Mapa "ao vivo" → push, não polling

  • 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).

Eventos passados

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.


6. Sharding

Por que é relacional, não Cassandra

  • 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.

Arquitetura poliglota

  • 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.

Sharding por event_id = sharding de instância relacional

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.

Dois eixos de particionamento

  • 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 por event_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.

FK cross-shard não existe — e tudo bem

  • orders.user_id no shard do evento é um BIGINT comum, sem REFERENCES (a tabela users nem está naquele banco).
  • Trade-off padrão e esperado de qualquer sistema shardado. A validação migra para a aplicação: o user_id vem 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).

7. Proteção de borda

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 e NAT

  • 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.

8. Load Balancer vs API Gateway

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.

Padrão típico

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.

Nuances

  • 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).


9. Filosofia de resiliência e escala

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

Backpressure vs load shedding

Sistema sobrecarregado tem 2 saídas:

  1. Load shedding (dropar requisição) — inaceitável em venda (perder venda).
  2. 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.

A fila virtual não é "deficiência reconhecida"

É 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.

Os três movimentos de escala

Escalar é saber, para cada parte do sistema, qual movimento ela pede:

  1. Descentralizar (o que tolera staleness): cache, CDN, validação stateless de token, push. Move a execução para fora do núcleo.
  2. 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.
  3. 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).

"Empurrar para as pontas" — dois sentidos

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.


10. Diagramas de fluxo (System Design)

Fluxo de ESCRITA (compra)

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
Loading

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 + criar order na mesma transação ACID (co-localizadas no mesmo shard).

Fluxo de LEITURA (ver o mapa de assentos)

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
Loading

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.


Visão única (as 3 defesas)

  • 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment