Evitando pagamentos duplos em um sistema de pagamentos distribuídos

Como criamos uma estrutura de idempotência genérica para alcançar consistência e correção eventuais em toda a nossa arquitetura de micro-serviços de pagamentos.

Jon Chew em Airbnb Engineering & Data Science Seguir 16 de abril · 14 min ler

Autores: Jon Chew e Ninad Khisti

Uma das salas de conferências no nosso escritório em San Francisco

fundo

O irbnb tem migrado sua infraestrutura para uma Arquitetura Orientada a Serviços (“SOA”). O SOA oferece muitos benefícios, como a ativação da especialização do desenvolvedor e a capacidade de iterar mais rapidamente. No entanto, também apresenta desafios para aplicativos de faturamento e pagamentos, porque dificulta a manutenção da integridade dos dados. Uma chamada de API para um serviço que faz chamadas de API adicionais a serviços downstream, onde cada serviço muda de estado e potencialmente tem efeitos colaterais, é equivalente a executar uma transação distribuída complexa.

Para garantir a consistência entre todos os serviços, protocolos como o commit de duas fases podem ser usados. Sem esse protocolo, as transações distribuídas apresentam desafios para manter a integridade dos dados, permitindo uma degradação elegante e a obtenção de consistência. As solicitações também inevitavelmente falham nos sistemas distribuídos – as conexões vão cair e o tempo limite acabará em algum ponto, especialmente para transações que consistem em várias solicitações de rede.

Existem três técnicas comuns diferentes usadas em sistemas distribuídos para obter consistência eventual : reparo de leitura , reparo de gravação e reparo assíncrono. Existem benefícios e trade-offs para cada abordagem. Nosso sistema de pagamentos usa todos os três em várias funções.

O reparo assíncrono envolve o servidor sendo responsável por executar verificações de consistência de dados, como varreduras de tabela, funções lambda e tarefas agendadas. Além disso, as notificações assíncronas do servidor para o cliente são amplamente usadas no setor de pagamentos para forçar a consistência no lado do cliente. O reparo assíncrono, junto com as notificações, pode ser usado em conjunto com as técnicas de reparo de leitura e gravação, oferecendo uma segunda linha de defesa com compensações na complexidade da solução.

Nossa solução neste post específico utiliza reparo de gravação , em que cada chamada de gravação do cliente para o servidor tenta reparar um estado inconsistente e interrompido. O reparo de gravação requer que os clientes sejam mais inteligentes (expandiremos isso mais tarde) e permite que eles disparem repetidamente a mesma solicitação e nunca precisem manter o estado (além das novas tentativas). Os clientes podem, portanto, solicitar consistência eventual sob demanda, dando-lhes controle sobre a experiência do usuário. Idempotência é uma propriedade extremamente importante ao implementar o reparo de gravação.

O que é idempotência?

Para que uma solicitação de API seja idempotente, os clientes podem fazer a mesma chamada repetidamente e o resultado será o mesmo. Em outras palavras, fazer várias solicitações idênticas deve ter o mesmo efeito que fazer uma única solicitação.

Essa técnica é comumente usada em sistemas de faturamento e pagamento envolvendo movimentação de dinheiro – é crucial que uma solicitação de pagamento seja processada completamente uma única vez (também conhecida como “ entrega exatamente uma vez ”). É importante ressaltar que, se uma única operação para movimentar dinheiro for chamada várias vezes, o sistema subjacente deve movimentar dinheiro no máximo uma vez . Isso é essencial para as APIs do Airbnb Payments, a fim de evitar vários pagamentos ao host e, ainda pior, várias cobranças para o hóspede.

Por padrão, a idempotência permite várias chamadas idênticas dos clientes usando um mecanismo de repetição automática para que uma API atinja uma consistência eventual. Essa técnica é comum entre relacionamentos cliente-servidor com idempotência e algo que usamos em nossos sistemas distribuídos hoje.

Em um nível alto, o diagrama abaixo ilustra alguns cenários de exemplo simples com solicitações duplicadas e comportamento idempotente ideal . Não importa quantas solicitações de cobrança sejam feitas, o hóspede sempre será cobrado no máximo uma vez.

Uma solicitação idempotente é uma solicitação feita com parâmetros idênticos e o resultado será sempre o mesmo, consistentemente (o convidado é cobrado no máximo uma vez).

A declaração do problema

Garantir a consistência eventual para o nosso sistema de pagamentos é da maior importância. A idempotência é um mecanismo desejável para conseguir isso em um sistema distribuído. Em um mundo SOA, inevitavelmente teremos problemas. Por exemplo, como os clientes se recuperariam se falhassem em consumir a resposta? E se a resposta foi perdida ou o cliente expirou? E as condições de corrida que resultam em um usuário clicando em "Book" duas vezes? Nossos requisitos incluem o seguinte:

  • Em vez de implementar uma única solução personalizada específica para um determinado caso de uso, precisávamos de uma solução de idempotência genérica, ainda que configurável, para ser usada em vários serviços SOA de Payments do Airbnb.
  • Embora os produtos de pagamento baseados em SOA estivessem sendo iterados, não poderíamos comprometer a consistência dos dados, pois isso afetaria diretamente nossa comunidade.
  • Precisávamos de latência ultrabaixa, portanto, construir um serviço de idempotência autônomo separado não seria suficiente. Mais importante ainda, o serviço sofreria dos mesmos problemas que originalmente pretendia resolver.
  • Como a Airbnb está escalando sua organização de engenharia usando SOA, seria altamente ineficiente que todos os desenvolvedores se especializassem em integridade de dados e eventuais desafios de consistência. Queríamos proteger os desenvolvedores de produtos desses incômodos para permitir que eles se concentrassem no desenvolvimento de produtos e interagissem mais rapidamente.

Além disso, consideráveis trade-offs com legibilidade de código, testabilidade e capacidade de solucionar problemas foram considerados não-iniciantes.

Solução explicada

Queríamos poder identificar cada solicitação recebida de maneira exclusiva. Além disso, precisávamos rastrear e gerenciar com precisão onde uma solicitação específica estava em seu ciclo de vida.

Nós implementamos e utilizamos "Orfeu" , uma biblioteca de idempotência de uso geral , em vários serviços de pagamentos. Orfeu é o lendário herói mitológico grego que foi capaz de orquestrar e encantar todos os seres vivos.

Escolhemos uma biblioteca como uma solução porque ela oferece baixa latência e, ao mesmo tempo, fornece uma separação perfeita entre o código do produto de alta velocidade e o código de gerenciamento do sistema de baixa velocidade. Em alto nível, consiste nos seguintes conceitos simples:

  • Uma chave de idempotência é passada para a estrutura, representando uma única solicitação idempotente
  • Tabelas de informações de idempotência, sempre lidas e gravadas a partir de um banco de dados mestre fragmentado (para consistência )
  • Transações de banco de dados são combinadas em diferentes partes da base de código para garantir a atomicidade , usando Java lambdas
  • As respostas de erro são classificadas como “ retryable ” ou “ non-retryable

Detalharemos como um sistema complexo e distribuído, com garantias de idempotência, pode se tornar auto-reparador e eventualmente consistente. Também vamos percorrer alguns dos trade-offs e complexidades adicionais da nossa solução que devemos ter em mente.

Manter o banco de dados comprometido a um mínimo

Um dos principais requisitos em um sistema idempotente é produzir apenas dois resultados, sucesso ou falha, com consistência. Caso contrário, os desvios nos dados podem levar a horas de investigação e pagamentos incorretos. Como os bancos de dados oferecem propriedades ACID , as transações do banco de dados podem ser efetivamente usadas para gravar dados de forma automática , garantindo a consistência. Uma confirmação de banco de dados pode ser garantida para ter sucesso ou falhar como uma unidade .

O Orpheus é centrado na suposição de que quase todas as solicitações de API padrão podem ser separadas em três fases distintas : Pre-RPC, RPC e Post-RPC.

Um "RPC", ou chamadas de procedimento remoto , ocorre quando um cliente faz uma solicitação a um servidor remoto e espera que o servidor conclua o (s) procedimento (s) solicitado (s) antes de retomar o processo. No contexto de APIs de pagamentos, nos referimos a uma RPC como uma solicitação para um serviço downstream em uma rede, que pode incluir processadores de pagamentos externos e bancos adquirentes. Em resumo, aqui está o que acontece em cada fase:

  1. Pré-RPC: os detalhes da solicitação de pagamento são registrados no banco de dados.
  2. RPC: A solicitação é feita ao vivo para o serviço externo pela rede e a resposta é recebida. Esse é um local para fazer um ou mais cálculos idempotentes ou RPCs (por exemplo, o serviço de consulta para o status de uma transação primeiro, se for uma tentativa de repetição).
  3. Pós-RPC: Os detalhes da resposta do serviço externo são registrados no banco de dados, incluindo seu êxito e se uma solicitação incorreta pode ser repetida ou não.

Para manter a integridade dos dados, seguimos duas regras básicas simples :

  1. Nenhuma interação de serviço nas redes nas fases Pré e Pós-RPC
  2. Nenhuma interação com o banco de dados nas fases do RPC

Nós essencialmente queremos evitar misturar a comunicação de rede com o trabalho de banco de dados . Aprendemos da maneira mais difícil que as chamadas de rede (RPCs) durante as fases Pré e Pós-RPC são vulneráveis e podem resultar em coisas ruins, como esgotamento rápido do pool de conexão e degradação do desempenho. Simplificando, as chamadas de rede são inerentemente não confiáveis. Por causa disso, envolvemos as fases Pré e Pós-RPC ao incluir as transações do banco de dados iniciadas pela própria biblioteca.

Também queremos dizer que uma única solicitação de API pode consistir em vários RPCs. O Orpheus suporta solicitações multi-RPC, mas neste post queríamos ilustrar nosso processo de pensamento com apenas o caso simples de RPC.

Como mostrado no diagrama de exemplo abaixo, Cada confirmação de banco de dados em cada uma das fases Pré-RPC e Pós-RPC é combinada em uma única transação de banco de dados . Isso garante atomicidade – unidades inteiras de trabalho (aqui as fases Pre-RPC e Post-RPC) podem falhar ou ter sucesso como uma unidade consistentemente . O motivo é que o sistema deve falhar de uma maneira que possa se recuperar. Por exemplo, se várias solicitações de API falharem no meio de uma longa seqüência de confirmações de banco de dados, seria extremamente difícil controlar sistematicamente onde ocorreu cada falha. Observe que toda a comunicação de rede, a RPC, é explicitamente separada de todas as transações do banco de dados.

A comunicação de rede é estritamente separada das transações do banco de dados

Um commit de banco de dados aqui inclui um commit da biblioteca idempotency e commits do banco de dados da camada de aplicação, todos combinados no mesmo bloco de código. Sem ter cuidado, isso poderia realmente começar a parecer realmente confuso em código real (espaguete, alguém?). Também sentimos que não deveria ser responsabilidade do desenvolvedor do produto chamar certas rotinas de idempotência.

Java Lambdas ao salvamento

Felizmente, as expressões Java lambda podem ser usadas para combinar várias sentenças em uma única transação de banco de dados, sem impacto na testabilidade e na legibilidade do código.

Aqui está um exemplo, uso simplificado de Orfeu, com Java lambdas em ação:

Em um nível mais profundo, aqui está um trecho simplificado do código-fonte:

Nós não implementamos transações de banco de dados aninhadas, mas combinamos instruções de banco de dados da Orpheus e do aplicativo em um único banco de dados, estritamente passando as interfaces funcionais de Java (lambdas).

A separação dessas preocupações oferece alguns trade-offs. Os desenvolvedores devem usar a premeditação para garantir a legibilidade e a manutenção do código, à medida que outros novos contribuem constantemente. Eles também precisam avaliar consistentemente que dependências e dados adequados são repassados. Chamadas de API agora precisam ser refatoradas em três partes menores, o que poderia ser restritivo na forma como os desenvolvedores escrevem código. Pode ser realmente difícil para algumas chamadas complexas de API serem efetivamente divididas em uma abordagem de três etapas. Um de nossos serviços implementou uma máquina de estados finitos com cada transição como uma etapa idempotente usando StatefulJ , onde você pode com segurança multiplexar chamadas idempotentes em uma chamada de API.

Tratamento de exceções – para tentar novamente ou não tentar novamente?

Com um framework como o Orpheus, o servidor deve saber quando uma solicitação é segura para tentar novamente e quando não é. Para que isso aconteça, as exceções devem ser tratadas com uma intenção meticulosa – elas devem ser categorizadas como “ retryable ” ou “ non-retryable ”. Isso, sem dúvida, adiciona uma camada de complexidade para os desenvolvedores e pode criar efeitos colaterais ruins se eles não forem criteriosos e prudentes.

Por exemplo, suponha que um serviço de recebimento de dados estivesse temporariamente offline, mas a exceção gerada foi erroneamente rotulada como "não-repetível" quando deveria ter sido "repetível". A solicitação seria "reprovada" indefinidamente e as solicitações de nova tentativa retornariam perpetuamente o erro incorreto e não repetível. Por outro lado, pagamentos em duplicidade poderiam ocorrer se uma exceção fosse rotulada como “repetível” quando, na verdade, deveria ter sido “não repetível” e exigir intervenção manual.

Em geral, acreditamos que exceções de tempo de execução inesperadas devido a problemas de rede e infra-estrutura (status HTTP 5XX) podem ser repetidas. Esperamos que esses erros sejam transitórios e esperamos que uma nova tentativa posterior da mesma solicitação possa, eventualmente, ser bem-sucedida.

Categorizamos erros de validação, como entrada inválida e estados (por exemplo, você não pode reembolsar um reembolso), como não repetíveis (status HTTP 4XX) – esperamos que todas as tentativas subsequentes da mesma solicitação falhem da mesma maneira. Criamos uma classe de exceção genérica e personalizada que tratava desses casos, padronizando-a como "não repetível" e, em outros casos, categorizada como "repetível".

É essencial que os pedidos de payloads para cada solicitação permaneçam os mesmos e nunca sejam mutados, caso contrário, isso quebraria a definição de uma solicitação idempotente.

Categorização de exceções “retryable” e “non-retryable”

É claro que há casos de borda mais vagos que precisam ser tratados com cuidado, como manipular adequadamente uma NullPointerException em diferentes contextos. Por exemplo, um valor null retornado do banco de dados devido a um blip de conectividade é diferente de um campo null incorreto em uma solicitação de um cliente ou de uma resposta de terceiros.

Os clientes desempenham um papel vital

Como mencionado no início deste post, o cliente deve ser mais inteligente em um sistema de reparo de gravação. Ele deve possuir várias responsabilidades importantes ao interagir com um serviço que usa uma biblioteca de idempotência como o Orpheus:

  • Passe uma chave de idempotência exclusiva para cada nova solicitação; reutilize a mesma chave de idempotência para novas tentativas.
  • Persista essas chaves de idempotência no banco de dados antes de chamar o serviço (para uso posterior para novas tentativas).
  • Consuma corretamente as respostas bem-sucedidas e subseqüentemente cancele (ou anule) as chaves de idempotência.
  • Garantir a mutação da carga útil da solicitação entre tentativas de repetição não é permitido.
  • Elabore e configure cuidadosamente as estratégias de repetição automática com base nas necessidades de negócios (usando backoff exponencial ou tempos de espera aleatórios (“jitter”) para evitar o problema do rebanho em alta velocidade ).

Como escolher uma chave Idempotency?

A escolha de uma chave de idempotência é crucial – o cliente pode optar por ter idempotência em nível de solicitação ou idempotência em nível de entidade com base na chave a ser usada. Essa decisão de usar uma sobre a outra dependeria de casos de uso de negócios diferentes, mas a idempotência no nível da solicitação é a mais direta e comum.

Para a idempotência no nível da solicitação , uma chave aleatória e exclusiva deve ser escolhida no cliente para garantir a idempotência para todo o nível da coleção de entidades. Por exemplo, se quisermos permitir pagamentos múltiplos e diferentes para um reserva de reserva (como Pay Less Upfront ), precisamos apenas garantir que as chaves de idempotência sejam diferentes. O UUID é um bom formato de exemplo para usar para isso.

A idempotência em nível de entidade é muito mais restritiva e restritiva que a idempotência em nível de solicitação. Digamos que queremos garantir que um determinado pagamento de US $ 10 com o ID 1234 só seja reembolsado em US $ 5, uma vez que podemos fazer tecnicamente pedidos de reembolso de US $ 5 duas vezes. Em seguida, queremos usar uma chave de idempotência determinística com base no modelo de entidade para garantir a idempotência em nível de entidade. Um formato de exemplo seria “payment-1234-refund” . Cada solicitação de reembolso para um pagamento único seria consequentemente idempotente no nível da entidade ( Payment 1234 ).

Cada solicitação de API tem uma concessão expirada

Várias solicitações idênticas podem ser disparadas devido a vários cliques do usuário ou se o cliente tiver uma política de repetição agressiva. Isso poderia criar condições de corrida no servidor ou pagamentos duplos para nossa comunidade. Para evitar essas chamadas de API, com a ajuda da estrutura, cada um precisa adquirir um bloqueio em nível de linha de banco de dados em uma chave de idempotência. Isso concede uma concessão, ou uma permissão, para que uma determinada solicitação continue.

Uma concessão vem com uma expiração para cobrir o cenário em que há tempos limite no lado do servidor. Se não houver resposta, uma solicitação de API poderá ser repetida somente após a expiração da concessão atual. O aplicativo pode configurar a expiração de concessão e os tempos limite de RPC de acordo com suas necessidades. Uma boa regra é ter uma expiração de concessão maior que o tempo limite de RPC.

Além disso, Orfeu oferece uma janela máxima de nova tentativa para uma chave de idempotência para fornecer uma rede de segurança, a fim de evitar novas tentativas de comportamento inesperado do sistema.

Gravando a resposta

Também registramos respostas, para manter e monitorar o comportamento idempotente. Quando um cliente faz a mesma solicitação para uma transação que atingiu um estado final determinístico , como um erro não repetível (erros de validação, por exemplo) ou uma resposta bem-sucedida, a resposta é registrada no banco de dados.

As respostas persistentes têm um trade-off de desempenho – os clientes podem receber respostas rápidas nas tentativas subsequentes, mas essa tabela terá um crescimento proporcional ao crescimento da taxa de transferência do aplicativo. Esta tabela pode rapidamente tornar-se inchada na mesa se não formos cuidadosos. Uma solução potencial é remover periodicamente as linhas mais antigas que um determinado período de tempo, mas a remoção de uma resposta idempotente muito cedo também tem implicações negativas. Os desenvolvedores também devem ter cuidado para não fazer alterações incompatíveis com as entidades e estrutura de resposta.

Evite Bancos de Dados de Réplica – Atenha-se ao Mestre

Ao ler e gravar informações de idempotência com o Orpheus, optamos por fazer isso diretamente do banco de dados mestre. Em um sistema de bancos de dados distribuídos, há uma compensação entre consistência e latência. Como não podíamos tolerar alta latência ou ler dados não confirmados, usar o mestre para essas tabelas fazia mais sentido para nós. Ao fazer isso, não há necessidade de usar um cache ou uma réplica de banco de dados. Se um sistema de banco de dados não estiver configurado para uma consistência de leitura forte (nossos sistemas são suportados pelo MySQL), o uso de réplicas para essas operações pode realmente criar efeitos adversos a partir de uma perspectiva de idempotência.

Por exemplo, suponha que um serviço de pagamentos tenha armazenado suas informações de idempotência em um banco de dados de réplica. Um cliente envia uma solicitação de pagamento ao serviço, que acaba sendo bem-sucedida no recebimento de dados, mas o cliente não recebe uma resposta devido a um problema de rede. A resposta, atualmente armazenada no banco de dados mestre do serviço, será eventualmente gravada na réplica. No entanto, no caso de atraso da réplica , o cliente poderia disparar corretamente uma tentativa idempotente para o serviço e a resposta não seria registrada na réplica ainda. Como a resposta “não existe” (na réplica), o serviço poderia executar o pagamento novamente, resultando em pagamentos duplicados. O exemplo abaixo ilustra como apenas alguns segundos de atraso na réplica podem causar um impacto financeiro significativo na comunidade do Airbnb.

Um pagamento duplicado criado como resultado do atraso da réplica

A resposta 1 não é encontrada na réplica na tentativa de repetição (Solicitação 2)

Pagamento duplicado evitado armazenando informações de idempotência apenas no mestre

A resposta 1 foi encontrada imediatamente no mestre e retornada na tentativa de repetição (Solicitação 2)

Ao usar um único banco de dados mestre para idempotência, ficou bastante claro que o dimensionamento seria, sem dúvida e rapidamente, um problema. Nós aliviamos isso abrindo o banco de dados pela chave de idempotência. As chaves de idempotência que usamos têm alta cardinalidade e distribuição uniforme, tornando-as chaves de fragmento eficazes.

Pensamentos finais

Há uma infinidade de soluções diferentes para aliviar os desafios de consistência em sistemas distribuídos. Orfeu é um dos vários que funciona bem para nós porque é generalizável e leve. Um desenvolvedor pode simplesmente importar a biblioteca ao trabalhar em um novo serviço, e a lógica de idempotência é mantida em uma camada separada e abstrata, acima dos conceitos e modelos específicos do aplicativo.

No entanto, alcançar consistência eventual não vem sem introduzir alguma complexidade. Os clientes precisam armazenar e manipular chaves de idempotência e implementar mecanismos de nova tentativa automatizados. Os desenvolvedores exigem um contexto adicional e devem ser cirurgicamente precisos ao implementar e solucionar problemas de Java lambdas. Eles devem ser deliberados ao lidar com exceções. Além disso, como a versão atual do Orpheus é testada em batalha, estamos continuamente descobrindo coisas para melhorar: correspondência de carga útil de solicitação para novas tentativas, suporte aprimorado para alterações de esquema e migrações aninhadas, restringindo ativamente o acesso ao banco de dados durante fases de RPC e assim por diante.

Enquanto estas são considerações em mente, onde Orpheus conseguiu o Airbnb Payments até agora? Desde o lançamento do framework, obtivemos cinco noves em consistência para nossos pagamentos, enquanto nosso volume de pagamento anual duplicou simultaneamente (leia isto se quiser saber mais sobre como medimos a integridade dos dados em escala).

Se você estiver interessado em trabalhar nas complexidades de uma plataforma de pagamentos distribuídos e ajudar os viajantes de todo o mundo a pertencerem a
A equipe do Airbnb Payments está contratando!

Grite para Michel Weksler e Derek Wang por sua liderança de pensamento e filosofia arquitetônica neste projeto!

Texto original em inglês.