iGamingSep 11, 202610 min de leitura

Postgres de sportsbook no pico das partidas: linhas quentes, atraso na liquidação e aceite de apostas que não espera

Engenharia de sportsbookPostgreSQLLiquidação de apostasIdempotência
Erro ao carregar a imagem

Um sportsbook cujo Postgres fica lento durante grandes partidas e liquida as apostas muito depois do fim do evento está passando duas cargas de trabalho pelo mesmo conjunto de linhas: o aceite de apostas, uma escrita curta por requisição, e a liquidação, uma rajada disparada por um único resultado. É assim que os dois caminhos são separados, de onde vem a contenção e como os saldos continuam corretos enquanto a liquidação atrasa.

Um sportsbook cujo Postgres vira gargalo durante uma partida grande costuma ter um sintoma e duas causas. O aceite e a liquidação de apostas disputam as mesmas linhas, locks e conexões exatamente no pico de tráfego, e a liquidação roda como um trabalho que segura essas linhas, em vez de rodar como uma fila que pode esperar a sua vez.

Mais hardware eleva o nível de tráfego em que isso acontece sem remover a causa. O que vem a seguir separa os dois caminhos, localiza a contenção e mantém os saldos corretos enquanto a liquidação atrasa. O comportamento do PostgreSQL citado abaixo é o da documentação da versão 18.

A resposta curta é estrutural. O aceite e a liquidação de apostas deixam de compartilhar transações: o aceite grava a aposta, uma reserva de saldo e uma linha de outbox em uma única transação curta sob uma chave de idempotência, e a liquidação consome eventos de resultado em pequenos lotes cujos efeitos também têm chave, então uma mensagem reentregue não movimenta dinheiro. O que a amBrain pode sustentar publicamente é a engenharia de plataformas de cassino, e um número que publicamos como medido ali é 12 operadores em produção. O design abaixo vem da mecânica do problema, não de um caso nosso, e nenhum número nele é medido em um sistema nosso.

Aceite e liquidação de apostas são duas cargas de trabalho que compartilham linhas

O aceite de apostas é uma requisição com uma pessoa esperando: ler o estado do mercado, verificar um saldo, gravar uma aposta, responder. A liquidação parte de um único resultado e se espalha de uma vez para todas as apostas abertas dos mercados afetados. Uma partida grande termina enquanto outros eventos ainda estão abertos, então essa rajada cai sobre as linhas de saldo de contas que já estão apostando de novo.

Se os dois caminhos gravam essas linhas nas suas próprias transações, a latência do aceite de apostas vira função da transação de liquidação mais longa na mesma conta. Desacoplar é um conjunto de promessas sobre locks e tempo:

  • A liquidação não segura uma linha de saldo por mais tempo do que uma transação de aceite de aposta segura
  • A liquidação pode ficar para trás, e o backlog dela é uma fila com idade, e não uma pilha de transações abertas
  • Todo efeito sobre dinheiro acontece uma única vez, não importa quantas vezes a mensagem por trás dele seja entregue
  • Leituras que não precisam do primário não tocam nele

Uma linha de saldo é um lock, quer você tenha projetado um ou não

O capítulo sobre locks do PostgreSQL diz que locks de nível de linha bloqueiam apenas quem escreve ou trava a mesma linha, não quem lê, e que uma transação que busca um lock espera indefinidamente, a menos que um deadlock seja detectado. Uma linha por conta, atualizada a cada aceite de aposta, é portanto uma fila, e com razão: o lock impede que dois aceites de aposta gastem o mesmo dinheiro. O que importa é por quanto tempo cada detentor o mantém.

O Read Committed, nível de isolamento padrão, mantém a reserva simples. Um UPDATE que encontra uma linha já atualizada por uma transação concorrente espera que ela faça commit ou rollback e, se ela fez commit, reavalia a sua cláusula WHERE contra a versão atualizada. Um update condicional que subtrai o valor apenas onde o saldo disponível o cobre não consegue sobrevender o saldo e dispensa SELECT FOR UPDATE.

  • Tome o lock do saldo por último e faça commit logo depois; a validação que não precisa de lock roda antes
  • Trave várias contas em uma ordem consistente, que o capítulo sobre locks indica como a forma de evitar deadlocks
  • Mantenha a exposição do mercado fora de uma linha única que todo aceite de aposta atualiza, ou um mercado popular serializa os seus aceites de aposta atrás de um único lock; distribua o contador por um conjunto fixo de linhas
  • Defina lock_timeout no caminho de aceite de apostas, para que uma espera indefinida vire um erro contabilizado, com nova tentativa sob a mesma chave de idempotência
  • Mantenha as colunas de saldo fora dos índices: o capítulo sobre armazenamento só permite um update HOT quando nenhuma coluna indexada muda e a página que contém a linha antiga tem espaço, o que um fillfactor mais baixo torna mais provável

Transações longas e o autovacuum mantêm a rajada em disco

Uma liquidação que marca todas as apostas abertas de um mercado em um único comando segura esses locks de linha até o commit e deixa uma versão morta de cada linha. O capítulo sobre vacuum diz que uma versão antiga não pode ser removida enquanto outras transações ainda puderem vê-la, então uma liquidação longa, ou um relatório ocioso dentro de uma transação, mantém a rajada inteira em disco.

O autovacuum chega atrasado por design. O PostgreSQL 18 faz vacuum em uma tabela quando as linhas atualizadas ou excluídas desde o último vacuum passam do menor valor entre autovacuum_vacuum_max_threshold e autovacuum_vacuum_threshold mais autovacuum_vacuum_scale_factor vezes o número de linhas. Com os padrões, 100.000.000, 50 e 0.2, uma tabela de apostas de 50 milhões de linhas espera por cerca de dez milhões de linhas atualizadas ou excluídas.

  • Sobrescreva esses limiares por tabela nas tabelas de saldos e de apostas abertas, o que o capítulo sobre vacuum permite por meio de parâmetros de armazenamento
  • Defina idle_in_transaction_session_timeout, cuja documentação alerta que uma transação aberta impede que tuplas mortas recentemente passem por vacuum e pode contribuir para o bloat de tabela
  • Acrescente linhas de liquidação em vez de alternar uma coluna de status indexada, porque um update que altera uma coluna indexada não pode ser HOT
  • Aposente o histórico desanexando ou removendo partições, o que o capítulo sobre particionamento descreve como muito mais rápido do que uma operação em massa e livre do overhead de VACUUM de um DELETE em massa

Tabelas de fila tornam o efeito fácil de ver. Em um post de 2015 no brandur.org, Postgres Job Queues & Failure By MVCC, uma transação deixada ociosa ao lado de uma fila de jobs elevou o tempo para travar um job de menos de 0,01 segundo para picos de 15 vezes esse nível, porque as linhas de job mortas ainda não podiam ser removidas.

Conexões e réplicas pertencem ao mesmo pico

Cada conexão é um processo de backend, e a documentação diz que aumentar max_connections, normalmente 100 por padrão, aumenta os recursos dimensionados a partir dele, incluindo a memória compartilhada. Em vez disso, dê pools separados ao aceite de apostas e à liquidação, para que um backlog da liquidação fique na fila pelas suas próprias conexões.

  • O transaction pooling do PgBouncer atribui uma conexão de servidor apenas pela duração de uma transação, então muitos clientes compartilham menos backends
  • Recursos de sessão quebram nesse modo: o PgBouncer lista SET e RESET, LISTEN, cursores WITH HOLD e advisory locks de nível de sessão como não suportados
  • Prepared statements nomeados em nível de protocolo funcionam nesse modo desde o PgBouncer 1.21.0, lançado em outubro de 2023, quando max_prepared_statements é diferente de zero

Réplicas aliviam as leituras a dois custos. A replicação por streaming é assíncrona por padrão, então um commit fica visível no standby depois de um pequeno atraso. E o capítulo sobre hot standby diz que consultas no standby que conflitam com a limpeza do vacuum vinda do primário são canceladas depois de um atraso configurado, enquanto hot_standby_feedback evita isso atrasando a limpeza no primário, o que pode causar bloat de tabela lá.

O aceite de apostas é uma transação curta, com chave antes da primeira retentativa

Projete o aceite de apostas de trás para a frente, a partir da falha dele: um cliente dá timeout e tenta de novo, e a retentativa precisa receber o primeiro resultado, não criar uma segunda aposta.

  • O cliente, ou o edge que recebe a requisição primeiro, cria uma chave de idempotência por envio, e toda retentativa a carrega sem alteração
  • Uma transação grava o registro da aposta, a reserva como um update condicional do saldo e uma linha de outbox para a aposta aceita
  • Os registros de aposta são append-only: liquidação, anulações e correções são novas linhas que referenciam a aposta, nunca edições
  • Uma constraint de unicidade transforma uma retentativa em um conflito: INSERT com ON CONFLICT DO NOTHING não insere nada, RETURNING devolve apenas as linhas inseridas, e o caminho lê de volta o resultado armazenado
  • O Stripe documenta o mesmo contrato para a sua API: o primeiro resultado para uma chave é salvo e devolvido às requisições seguintes, tenha ele sido sucesso ou falha, e uma chave reutilizada com parâmetros diferentes é rejeitada

Particione o armazenamento por tempo e o trabalho por mercado. O capítulo sobre particionamento exige que uma constraint de unicidade em uma tabela particionada inclua todas as colunas da chave de partição, então a chave de idempotência ou carrega a coluna de partição ou vive em uma tabela própria. Ele também diz que o planner lida razoavelmente bem com até alguns milhares de partições quando as consultas podam todas, exceto poucas, e o conjunto de mercados não tem limite, então partições por mercado colocam tempo de planejamento no caminho de aceite de apostas.

A linha de outbox torna o evento confiável. No padrão transactional outbox, como Chris Richardson o descreve, a mensagem é armazenada no banco de dados dentro da transação que atualiza as entidades de negócio, e um processo separado a encaminha. A mesma descrição aponta o custo: o relay pode publicar uma mensagem mais de uma vez, então os consumidores precisam ser idempotentes.

A liquidação é uma fila que pode atrasar

A partir do momento em que um resultado chega, a liquidação é um backlog com idade, e nada nela segura uma linha pela qual o aceite de apostas espera por mais tempo do que um lote:

  • Ordene por mercado, não globalmente: o Kafka grava eventos com a mesma chave na mesma partição e documenta que os consumidores leem uma partição na ordem de escrita, então eventos de resultado com chave por mercado permanecem em sequência
  • Uma tabela de fila funciona dentro de limites: a documentação considera SKIP LOCKED inadequado para trabalho de uso geral, mas utilizável para evitar contenção de locks entre consumidores de uma tabela do tipo fila
  • Cada lote liquida um número limitado de apostas, grava os lançamentos delas no ledger, atualiza as linhas de saldo na ordem das contas e faz commit
  • O progresso entra no mesmo commit que os efeitos, então um worker que morre no meio de um lote retoma a partir do último lote com commit
  • Um resultado corrigido é um novo evento: lançamentos de estorno, depois novos lançamentos de liquidação, nunca edições dos antigos

A liquidação pode atrasar. Não pode acontecer duas vezes. O aceite de apostas não pode nem uma coisa nem outra, e é por isso que os dois não podem compartilhar uma transação.

Saldos precisam de dois números e de lançamentos que entram uma única vez

Uma única coluna de saldo não consegue descrever uma aposta aceita e ainda não liquidada. Mantenha dois números por conta, disponível e reservado, e mova dinheiro entre eles apenas por lançamentos do ledger que carregam, cada um, uma chave:

  • O aceite da aposta move o valor de disponível para reservado no seu update condicional
  • A liquidação libera a reserva e lança o débito final e o eventual crédito em uma única transação, com chave por aposta, tipo de lançamento e versão da liquidação
  • Uma anulação libera a reserva, e uma reserva cuja liquidação nunca chega tem um responsável nomeado e um prazo
  • A linha de saldo é uma projeção do ledger, e uma reconciliação agendada que soma os lançamentos por conta reporta o desvio como incidente, em vez de corrigi-lo em silêncio

A entrega pode se repetir: o relay do outbox pode republicar, e, quando o outbox é lido por decodificação lógica, a documentação diz que um slot pode reenviar mudanças recentes depois de uma queda. Então o requisito é um efeito que acontece uma única vez. Cada lançamento do ledger tem uma chave única, a atualização do saldo entra no mesmo commit que o insert, e uma mensagem reentregue esbarra na constraint e não movimenta dinheiro.

O histórico de apostas pertence a um modelo de leitura, não ao caminho de escrita

Muitas leituras no pico ficam ao lado do aceite de apostas, e não sobre ele: apostas abertas, histórico, telas de saldo atualizadas a cada evento. A descrição de CQRS de Chris Richardson atende essas consultas a partir de um banco de dados de views mantido atualizado pela assinatura de eventos do serviço dono dos dados, e aponta o lag de replicação e as views com consistência eventual como o custo. O outbox do aceite de apostas já publica esses eventos.

  • A resposta do aceite devolve a aposta aceita, então o cliente a exibe sem ler de volta de uma view que pode estar atrasada
  • Telas que precisam do estado mais recente leem do primário explicitamente, e essa lista continua curta
  • synchronous_commit definido como remote_apply faz cada commit esperar até que os standbys síncronos o tenham reaplicado: read-your-writes na réplica, pago em latência do aceite de apostas

O que medir com a partida ainda em andamento

Faça as leituras durante o pico, em um único eixo de tempo junto com a latência do aceite de apostas:

  • Esperas por lock: amostre o pg_stat_activity pelo tipo de wait event Lock e encontre os bloqueadores com pg_blocking_pids, que, como a documentação alerta, pode afetar a performance se for chamado com frequência
  • log_lock_waits vem desligado por padrão e só reporta esperas mais longas que deadlock_timeout, de um segundo por padrão, então o log não mostra nenhuma das esperas mais curtas
  • A transação mais antiga, pelo xact_start em pg_stat_activity, e toda sessão em idle in transaction
  • Limpeza nas tabelas quentes: n_dead_tup, last_autovacuum e n_tup_hot_upd em relação a n_tup_upd
  • Pressão no pool: cl_waiting e maxwait de SHOW POOLS, em que o PgBouncer interpreta um maxwait crescente como um pool que não está dando conta
  • Backlog da liquidação medido como idade, porque uma contagem não distingue uma fila grande de uma fila parada
  • replay_lag por standby, e wal_status e safe_wal_size para os slots de replicação lógica

Lidas em conjunto, essas leituras localizam a falha. Uma fila de pool crescendo com esperas por lock estáveis aponta para as conexões; esperas por lock subindo junto com os lotes de liquidação apontam para linhas compartilhadas; nenhuma das duas se mexendo enquanto as linhas mortas sobem aponta para a transação mais antiga.

Como saber quais empresas de engenharia realmente fazem esse trabalho

A segunda metade da pergunta, quais empresas são especializadas nisso, tem um teste que não precisa de uma lista de fornecedores. Uma empresa que já separou esses caminhos antes faz o seguinte em uma primeira conversa:

  • Pede a latência do aceite de apostas e o backlog da liquidação de um pico real, em um único eixo de tempo, antes de pedir o schema
  • Nomeia o mecanismo que espera que detenha a latência, e a leitura que provaria o contrário
  • Trata dinheiro como uma suíte de testes: entrega duplicada, um worker morto no meio do lote, um resultado corrigido
  • Faz teste de carga de uma rajada de resultados com o tráfego de aceite de apostas em andamento, e não de um dos caminhos isoladamente
  • Define os critérios de saída com antecedência: um percentil do aceite de apostas durante a rajada e uma idade de backlog aceitável depois dela
  • Consegue colocar alguém de plantão para os workers de liquidação, os slots de replicação e os pools de conexões na noite da final

Uma resposta que fica no genérico em qualquer um desses pontos significa que o trabalho começaria sem diagnóstico.

Então a primeira decisão não é um banco de dados maior. É qual mecanismo detém a latência na noite em que o aceite de apostas fica lento, e se o aceite e a liquidação ainda compartilham uma transação em algum ponto do caminho.

O que a amBrain pode sustentar publicamente: a amBrain é uma empresa de desenvolvimento de software especializada em plataformas de trading, matching engines, sistemas de real-time bidding e engenharia de plataformas de cassino. A amBrain constrói software desde 2019. Um número que publicamos como medido em iGaming é 12 operadores em produção. Trabalhamos em três formatos: entrega completa, time dedicado ou engenheiros embarcados no seu time.

Tem um projeto assim na mesa?

Traga sua arquitetura atual e o modo de falha que preocupa você, e vamos analisá-lo juntos em meia hora.

Artigos relacionados

Erro ao carregar a imagem
iGaming
Feb 28, 20266 min de leitura

Escalando plataformas de iGaming: lições de 10M de usuários simultâneos

Ler post
Erro ao carregar a imagem
iGaming
Feb 7, 20265 min de leitura

Construindo recursos de jogo responsável: um mergulho técnico

Ler post
Erro ao carregar a imagem
iGaming
Jan 15, 20267 min de leitura

Arquitetura de apostas ao vivo: processando atualizações de odds em menos de 50ms

Ler post