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.
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:
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.
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.
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.
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.
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á.
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.
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 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:
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.
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:
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.
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.
Faça as leituras durante o pico, em um único eixo de tempo junto com a latência do aceite de apostas:
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.
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:
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.
Traga sua arquitetura atual e o modo de falha que preocupa você, e vamos analisá-lo juntos em meia hora.