Tudo começou com uma mensagem de uma linha de uma equipe financeira em uma tarde de terça-feira: alguns clientes haviam sido cobrados duas vezes naquele dia e um deles estava contestando uma cobrança duplicada com seu banco.

Fui direto para o monitoramento, esperando encontrar algo quebrado. Em vez disso, tudo parecia saudável: pelos próprios registros do sistema, cada pedido foi pago exatamente uma vez. A equipe levou um mês investigando incidentes de produção para preencher a lacuna entre um painel que dizia “tudo bem” e um cliente faturado duas vezes.

Desde então, tenho visto esse tipo de falha em vários sistemas de pagamento, alguns lidando com centenas de milhares de transações por dia. O que se segue é um composto e não descreve nenhum sistema ou organização único. Os números, horários e detalhes de identificação foram alterados para manter qualquer coisa proprietária de fora.

A nova tentativa que cobrou duas vezes

Um cliente clicou em Pagar; o serviço de pedidos denominado serviço de pagamento, que chamava o provedor externo. O provedor cobrou US$ 200 no cartão e registrou um sucesso.

A única coisa que deu errado foi o tempo. O provedor estava sobrecarregado e demorou pouco mais de 3 segundos para responder. O cliente desistiu após 2 segundos, inadimplência herdada de atendimentos internos e nunca sintonizou os pagamentos. Do lado de quem ligou, a ligação simplesmente falhou, então nada foi marcado como pago.

A lógica de nova tentativa fez o que foi criada para fazer e enviou a solicitação novamente. O provedor viu o que parecia ser uma nova cobrança e pegou o dinheiro pela segunda vez. O banco de dados registrou um pagamento: a nova tentativa. A primeira cobrança ficou apenas nos registros do provedor, invisível para nós até a chegada das reclamações.

Violetta Pidvolotska

As duplicatas foram reembolsadas em poucas horas, antes que a disputa pudesse se tornar um estorno. Compreender o que realmente falhou demorou muito mais tempo.

Posteriormente, ampliamos o tempo limite bem além das respostas saudáveis ​​mais lentas do provedor, mas desde que uma nova tentativa possa acionar uma segunda cobrança, um tempo limite mais longo apenas torna a cobrança dupla mais rara.

O verdadeiro erro foi mais antigo que o próprio limite. O sistema foi informado de que um tempo limite significa falha.

O terceiro estado

Tendemos a pensar que uma chamada de rede tem dois resultados: funcionou ou não. Um tempo limite é o terceiro. O pedido pode nunca ter chegado. Pode ter feito o seu trabalho e perdido a resposta no caminho de volta. Foi isso que nos mordeu. Ou ainda pode estar em execução. Do lado do chamador, você não sabe qual.

O código raramente tem um caminho separado para “desconhecido”. Ele é confundido com falha e o caminho da falha é repetido. Quando a solicitação movimenta dinheiro, é assim que você cobra duas vezes de alguém.

Um serviço lento aparece como tempos de resposta crescentes, e um serviço não confiável, como erros. Uma cobrança dupla é um sucesso e ninguém percebeu até que o cliente percebeu.

Os tempos limite, sobre os quais já escrevi, transformam bloqueios silenciosos em falhas visíveis. E as falhas visíveis são repetidas, e foi assim que chegamos à idempotência.

“Exatamente uma vez” é usado como se fosse uma configuração que você pudesse ativar. Você não pode prometer entrega exatamente uma vez em uma rede não confiável, como explica Tyler Treat. O que você pode prometer são efeitos exatamente uma vez: a solicitação pode chegar duas vezes, enquanto a cobrança acontece uma vez.

Meu primeiro instinto foi parar de tentar fazer pagamentos automaticamente e isso ajudou. Mas nem todas as novas tentativas podem ser desativadas: o cliente atualiza a página ou uma política de novas tentativas em algum lugar da infraestrutura a reenvia por conta própria.

As suposições sob a chave

A solução padrão é uma chave de idempotência: o chamador anexa um valor exclusivo a uma tentativa de operação e envia o mesmo valor em cada nova tentativa. Uma nova chave é processada e seu resultado armazenado; um familiar recupera o resultado armazenado, portanto a nova tentativa não tem efeito extra. O passo a passo de Brandur Leach sobre chaves de idempotência do tipo Stripe no Postgres apresenta o padrão de ponta a ponta.

A chave foi enviada e as duplicatas interrompidas. Mas relaxamos muito cedo. A chave acabou sendo a parte fácil.

Uma chave como esta baseia-se em quatro suposições. Desde então, transformei-os em uma lista de verificação que chamo de teste das quatro suposições:

  • Alegar. Reivindicar uma chave é apenas uma questão de verificar primeiro se ela é gratuita.
  • Intenção. A mesma chave sempre carrega a mesma intenção.
  • Memória. Tudo o que uma chave lembra é seguro para ser reproduzido.
  • Limite. Nada por trás da chave está além do seu controle.

No mês seguinte, todos os quatro quebraram: a corrida em teste de carga, os outros três em produção.

Duas solicitações, o mesmo milissegundo

Em um teste de carga, duas solicitações com a mesma chave chegaram no mesmo milissegundo. Cada um verificou a chave; nenhum deles encontrou e ambos começaram o processamento.

Em um teste de carga, duas solicitações com a mesma chave chegaram no mesmo milissegundo. Cada um procurou a chave, nenhum a encontrou e ambos iniciaram o processamento.

Violetta Pidvolotska

“Verifique se a chave existe e depois escreva-a” é uma corrida como qualquer outra e quebrou a suposição da afirmação. Nós consertamos invertendo a ordem: agora escrevendo a chave é o cheque. Cada solicitação é gravada como “iniciada” e o banco de dados permite que apenas uma reivindicação seja vencida. A salvaguarda:

-- Try to claim the key; the UNIQUE index lets only one caller win.
INSERT INTO operations (idempotency_key, state) VALUES (:key, 'started')
ON CONFLICT (idempotency_key) DO NOTHING;

A inserção toca uma linha ou nenhuma, e essa contagem informa em qual caminho você está. Uma linha significa que você ganhou: ligue para o provedor, marque a linha como ‘concluída’ e salve a resposta. Nenhum significa que você perdeu: leia a linha e retorne a resposta salva ou diga ao chamador para tentar novamente mais tarde se ainda estiver ‘iniciado’.

Um detalhe é fácil de errar: confirme a reivindicação antes que a chamada do provedor seja encerrada. Caso contrário, um acidente o reverterá e apagará o único registro de que uma carga pode estar em voo.

O caso mais difícil é uma solicitação vencedora que trava no meio da carga: sua chave fica presa em “iniciado” e a cada nova tentativa é solicitado que espere por uma resposta que nunca virá. Uma reclamação travada é a mesma incógnita novamente: uma vez que ela tenha ficado “iniciada” por mais tempo do que qualquer chamada saudável poderia levar, pergunte ao provedor o que realmente aconteceu antes que alguém cobre novamente.

Mesma chave, solicitação diferente

A segunda lacuna apareceu uma semana após o início da produção e quebrou a suposição de intenção: um chamador reutilizou uma chave para duas solicitações diferentes, US$ 200 e US$ 500, e o sistema retornou a resposta armazenada da primeira solicitação sem perceber que o valor havia mudado.

A segunda lacuna apareceu uma semana após o início da produção e quebrou a suposição de intenção: um chamador reutilizou uma chave para duas solicitações diferentes, US$ 200 e US$ 500, e o sistema retornou a resposta armazenada da primeira solicitação sem perceber que o valor havia mudado.

Violetta Pidvolotska

Corrigimos o problema armazenando uma impressão digital do conteúdo da solicitação ao lado da chave, no mesmo encarte, para que uma solicitação que perdesse a disputa de reivindicação ainda pudesse comparar sua impressão digital com a do vencedor. Se as impressões digitais corresponderem, será uma nova tentativa genuína. Caso contrário, a chave foi reutilizada para uma operação diferente e nós a rejeitamos.

Essa correção rejeitou imediatamente uma nova tentativa válida. Estávamos tirando impressões digitais de toda a solicitação, incluindo um carimbo de data/hora que mudava entre as tentativas e os campos que chegavam em uma ordem diferente, portanto, as impressões digitais não correspondiam.

Uma impressão digital precisa capturar o que uma solicitação significa, e não como seus bytes são organizados. Misture uma lista de campos de negócios escolhida a dedo e você corre o risco de uma colisão silenciosa: o único campo que ninguém se lembrou de adicionar permite que duas solicitações diferentes correspondam. Faça hash de toda a solicitação menos o ruído conhecido, como carimbos de data e hora, e a falha será alta: um campo volátil perdido rejeita uma nova tentativa válida. Escolhemos alto, a correção se resumiu a duas linhas:

intent      = drop_fields(request.json, volatile={"client_ts", "trace_id"})  # strip known noise only
fingerprint = sha256(canonical_json(intent))   # canonical form: keys sorted, numbers and spacing normalized

Até o “canônico” esconde decisões. A RFC 8785 os fixa, mas executa cada número por meio de um duplo IEEE 754, que perde precisão em valores grandes, de modo que quantias em dinheiro são mais seguras como strings ou centavos inteiros. Altere a forma canônica e todas as impressões digitais armazenadas param de corresponder, então fazemos a versão e armazenamos a versão ao lado da impressão digital.

O erro que armazenamos em cache

A terceira lacuna surgiu através do suporte: um cliente sofreu um declínio de fundos insuficientes, adicionou dinheiro, tentou novamente com a mesma chave e recuperou os antigos “fundos insuficientes”. O provedor nunca foi questionado. O sistema estava armazenando em cache todas as respostas, inclusive as recusas, então a falha ficou na chave.

A terceira lacuna surgiu através do suporte: um cliente atingiu um declínio de fundos insuficientes, adicionou dinheiro, tentou novamente com a mesma chave e obteve o antigo

Violetta Pidvolotska

Isso forçou a questão por trás da suposição da memória: Qual é a chave que pode ser lembrada? A regra que adotamos: armazenar em cache apenas o sucesso.

Em vez disso, uma recusa suave ou um erro de validação libera a reivindicação: a linha volta para exigível, impressão digital mantida. A próxima tentativa o recupera com uma atualização de que apenas uma nova tentativa pode vencer e o cliente que adiciona dinheiro obtém uma tentativa ao vivo em vez de um replay. As recusas severas são a exceção: uma resposta a um cartão roubado é definitiva e a reclamação permanece encerrada.

Após o tempo limite, não sabemos se a cobrança chegou, então perguntamos ao provedor se a cobrança já foi processada e agimos de acordo com a resposta.

Onde a garantia acaba

As três primeiras lacunas ocorreram em pontos finais sob controle da equipe. A quarta surgiu durante a reconciliação: uma cobrança no extrato de um fornecedor mais antigo sem registro interno correspondente. Esse provedor não tinha chaves de idempotência e a garantia havia atingido seu limite. Não conseguimos tornar seguro ligar duas vezes.

Chegamos o mais perto que pudemos: um registro pendente antes da ligação, uma verificação de status antes de tentar novamente, reconciliação para capturar e reembolsar o que quer que tenha escapado. Permanece uma janela onde a carga caiu e nosso registro ainda não sabe disso. Continuamos diminuindo aquela janela, mas nunca conseguimos fechá-la.

O banco de dados que contém as chaves força uma decisão própria: quando ele fica inativo, você para de receber pagamentos ou os recebe desprotegidos. Essa escolha é uma chamada de negócios. Para uma gravação de baixo risco, limpar uma duplicata rara pode custar menos do que recusar clientes. Um pagamento não é uma aposta baixa, então falhamos no fechamento e paramos de receber pagamentos até que a loja volte: podemos recuperar uma venda perdida, e acabamos de passar um mês aprendendo o que custa duplicado.

Perguntas que faço em análises de design

Para qualquer coisa que armazene ou altere dados, faço três perguntas:

  • O que acontece se isso for executado duas vezes? Pergunte em voz alta a cada escrita.
  • Podemos provar a resposta? Execute-o duas vezes em testes, em sequência e em paralelo; a segunda execução não deve mudar nada.
  • Onde mora a verdade quando os sistemas discordam? Para pagamentos, é o provedor porque seus registros mostram se o dinheiro realmente foi movimentado. Decida qual resposta vencerá antes que um incidente o faça.

A chave é uma boa ideia e, em tudo que movimenta dinheiro, uma ideia necessária. Simplesmente não é uma garantia. A garantia é o design em torno dela: uma afirmação que não pode ser disputada, uma intenção que a impressão digital confirma, uma memória que mantém apenas o que é seguro para reproduzir e um limite que você mapeou antecipadamente. Esse é o teste das quatro suposições. Cada suposição é testada eventualmente: você faz isso no momento do design ou a produção faz isso por você.

Este artigo foi publicado como parte da Foundry Expert Contributor Network.
Quer participar?