Extração de faturamento: uma história de otimização de API interna do GraphQL

Publicados: 2022-03-11

Uma das principais prioridades da equipe de engenharia da Toptal é a migração para uma arquitetura baseada em serviços. Um elemento crucial da iniciativa foi o Billing Extraction , um projeto no qual isolamos a funcionalidade de faturamento da plataforma Toptal para implantá-la como um serviço separado.

Nos últimos meses, extraímos a primeira parte da funcionalidade. Para integrar o faturamento com outros serviços, usamos uma API assíncrona (baseada em Kafka) e uma API síncrona (baseada em HTTP).

Este artigo é um registro de nossos esforços para otimizar e estabilizar a API síncrona.

Abordagem incremental

Esta foi a primeira etapa da nossa iniciativa. Em nossa jornada para a extração total do faturamento, nos esforçamos para trabalhar de maneira incremental, entregando pequenas e seguras alterações na produção. (Veja slides de uma excelente palestra sobre outro aspecto deste projeto: extração incremental de uma engine de um aplicativo Rails.)

O ponto de partida foi a plataforma Toptal, uma aplicação monolítica Ruby on Rails. Começamos identificando as costuras entre o faturamento e a plataforma Toptal no nível dos dados. A primeira abordagem foi substituir as relações do Active Record (AR) por chamadas de método regulares. Em seguida, precisávamos implementar uma chamada REST para o serviço de cobrança buscando os dados retornados pelo método.

Implantamos um pequeno serviço de cobrança acessando o mesmo banco de dados da plataforma. Conseguimos consultar o faturamento usando a API HTTP ou com chamadas diretas ao banco de dados. Essa abordagem nos permitiu implementar um fallback seguro; caso a solicitação HTTP tenha falhado por qualquer motivo (implementação incorreta, problema de desempenho, problemas de implantação), usamos uma chamada direta e retornamos o resultado correto ao chamador.

Para tornar as transições seguras e contínuas, usamos um sinalizador de recurso para alternar entre HTTP e chamadas diretas. Infelizmente, a primeira tentativa implementada com REST provou ser inaceitavelmente lenta. A simples substituição das relações AR por solicitações remotas causava travamentos quando o HTTP era ativado. Embora o tenhamos habilitado apenas para uma porcentagem relativamente pequena de chamadas, o problema persistiu.

Sabíamos que precisávamos de uma abordagem radicalmente diferente.

A API interna de faturamento (também conhecida como B2B)

Decidimos substituir o REST pelo GraphQL (GQL) para obter mais flexibilidade no lado do cliente. Queríamos tomar decisões baseadas em dados durante essa transição para poder prever os resultados desta vez.

Para isso, instrumentamos todas as solicitações da plataforma Toptal (monólito) para faturamento e registramos informações detalhadas: tempo de resposta, parâmetros, erros e até rastreamento de pilha neles (para entender quais partes da plataforma usam faturamento). Isso nos permitiu detectar hotspots — locais no código que enviam muitas solicitações ou que causam respostas lentas. Então, com stacktrace e parâmetros , poderíamos reproduzir os problemas localmente e ter um curto ciclo de feedback para muitas correções.

Para evitar surpresas desagradáveis ​​na produção, adicionamos outro nível de sinalizadores de recursos. Tínhamos um sinalizador por método na API para passar do REST para o GraphQL. Estávamos habilitando o HTTP gradualmente e observando se “algo ruim” aparecia nos logs.

Na maioria dos casos, “algo ruim” era um tempo de resposta longo (de vários segundos), 429 Too Many Requests ou 502 Bad Gateway . Empregamos vários padrões para corrigir esses problemas: pré-carregamento e armazenamento em cache de dados, limitação de dados obtidos do servidor, adição de jitter e limitação de taxa.

Pré-carregamento e armazenamento em cache

O primeiro problema que notamos foi uma enxurrada de solicitações enviadas de uma única classe/visão, semelhante ao problema N+1 no SQL.

O pré-carregamento do Active Record não funcionou além da fronteira de serviço e, como resultado, tivemos uma única página enviando cerca de 1.000 solicitações para cobrança a cada recarga. Mil pedidos de uma única página! A situação em alguns empregos secundários não era muito melhor. Preferimos fazer dezenas de pedidos em vez de milhares.

Um dos trabalhos em segundo plano foi buscar dados do trabalho (vamos chamar esse modelo de Product ) e verificar se um produto deve ser marcado como inativo com base nos dados de cobrança (neste exemplo, chamaremos o modelo BillingRecord ). Embora os produtos fossem buscados em lotes, os dados de faturamento eram solicitados sempre que necessário. Todos os produtos precisavam de registros de faturamento, portanto, o processamento de cada produto causava uma solicitação ao serviço de faturamento para buscá-los. Isso significou uma solicitação por produto e resultou em cerca de 1.000 solicitações enviadas de uma única execução de trabalho.

Para corrigir isso, adicionamos o pré-carregamento em lote de registros de cobrança. Para cada lote de produtos buscados no banco de dados, solicitamos registros de faturamento uma vez e os atribuímos aos respectivos produtos:

 # fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end

Com lotes de 100 e uma única solicitação ao serviço de cobrança por lote, passamos de ~1.000 solicitações por trabalho para ~10.

Junções do lado do cliente

As solicitações de lote e os registros de cobrança em cache funcionaram bem quando tínhamos uma coleção de produtos e precisávamos de seus registros de cobrança. Mas e o contrário: se buscássemos registros de faturamento e depois tentássemos usar seus respectivos produtos, buscados no banco de dados da plataforma?

Como esperado, isso causou outro problema N+1, desta vez no lado da plataforma. Quando estávamos usando produtos para coletar N registros de faturamento, estávamos realizando N consultas ao banco de dados.

A solução foi buscar todos os produtos necessários de uma só vez, armazená-los como um hash indexado por ID e depois atribuí-los aos seus respectivos registros de faturamento. Uma implementação simplificada é:

 def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end

Se você acha que é semelhante a uma junção de hash, você não está sozinho.

Filtragem e underfetching do lado do servidor

Lutamos contra os piores picos de solicitações e problemas N+1 no lado da plataforma. Nós ainda tivemos respostas lentas, no entanto. Identificamos que eles foram causados ​​por carregar muitos dados na plataforma e filtrá-los lá (filtragem do lado do cliente). Carregar dados na memória, serializá-los, enviá-los pela rede e desserializar apenas para descartar a maior parte era um desperdício colossal. Foi conveniente durante a implementação porque tínhamos endpoints genéricos e reutilizáveis. Durante as operações, mostrou-se inutilizável. Precisávamos de algo mais específico.

Resolvemos o problema adicionando argumentos de filtragem ao GraphQL. Nossa abordagem foi semelhante a uma otimização bem conhecida que consiste em mover a filtragem do nível do aplicativo para a consulta do banco de dados ( find_all vs. where no Rails). No mundo do banco de dados, essa abordagem é óbvia e está disponível como WHERE na consulta SELECT . Nesse caso, exigia que implementássemos o tratamento de consultas por nós mesmos (no Faturamento).

Implantamos os filtros e esperamos para ver uma melhoria de desempenho. Em vez disso, vimos 502 erros na plataforma (e nossos usuários também os viram). Não é bom. Nada bom!

Por que isso aconteceu? Essa mudança deve melhorar o tempo de resposta, não interromper o serviço. Introduzimos um bug sutil inadvertidamente. Mantivemos ambas as versões da API (GQL e REST) ​​no lado do cliente. Mudamos gradualmente com um sinalizador de recurso. A primeira versão infeliz que implantamos introduziu uma regressão na ramificação REST legada. Concentramos nossos testes na ramificação GQL, então perdemos o problema de desempenho no REST. Lição aprendida: se os parâmetros de pesquisa estiverem ausentes, retorne uma coleção vazia, não tudo o que você tem em seu banco de dados.

Dê uma olhada nos dados do NewRelic para faturamento. Implementamos as alterações com a filtragem do lado do servidor durante uma pausa no tráfego (desativamos o tráfego de cobrança após encontrar problemas de plataforma). Você pode ver que as respostas são mais rápidas e previsíveis após a implantação.

Imagem: Dados do NewRelic para o serviço de cobrança. As respostas são mais rápidas após a implantação.

Não foi muito difícil adicionar filtros a um esquema GQL. As situações em que o GraphQL realmente se destacou foram os casos em que buscamos muitos campos, não muitos objetos. Com REST, estávamos enviando todos os dados possivelmente necessários. A criação de um endpoint genérico nos obrigou a empacotá-lo com todos os dados e associações usados ​​na plataforma.

Com GQL, fomos capazes de escolher os campos. Em vez de buscar mais de 20 campos que exigiam o carregamento de várias tabelas de banco de dados, selecionamos apenas os três a cinco campos necessários. Isso nos permitiu remover picos repentinos de uso de cobrança durante implantações de plataforma porque algumas dessas consultas foram usadas por trabalhos de reindexação de pesquisa elástica executados durante a implantação. Como efeito colateral positivo, tornou as implantações mais rápidas e confiáveis.

O pedido mais rápido é aquele que você não faz

Limitamos o número de objetos buscados e a quantidade de dados empacotados em cada objeto. O que mais poderíamos fazer? Talvez não busque os dados?

Percebemos outra área com espaço para melhorias: estávamos usando uma data de criação do último registro de faturamento na plataforma com frequência e, sempre, ligamos para o faturamento para buscá-lo. Decidimos que, em vez de buscá-lo de forma síncrona sempre que fosse necessário, poderíamos armazená-lo em cache com base nos eventos enviados do faturamento.

Planejamos com antecedência, preparamos tarefas (quatro a cinco delas) e começamos a trabalhar para que isso fosse feito o mais rápido possível, pois essas solicitações estavam gerando uma carga significativa. Tínhamos duas semanas de trabalho pela frente.

Felizmente, pouco depois de começarmos, analisamos o problema novamente e percebemos que poderíamos usar dados que já estavam na plataforma, mas de uma forma diferente. Em vez de adicionar novas tabelas aos dados de cache do Kafka, passamos alguns dias comparando os dados do faturamento e da plataforma. Também consultamos especialistas de domínio para saber se poderíamos usar os dados da plataforma.

Por fim, substituímos a chamada remota por uma consulta de banco de dados. Essa foi uma grande vitória do ponto de vista do desempenho e da carga de trabalho. Também economizamos mais de uma semana de tempo de desenvolvimento.

Imagem: desempenho e carga de trabalho com uma consulta de banco de dados em vez de uma chamada remota.

Distribuindo a Carga

Estávamos implementando e implantando essas otimizações uma a uma, mas ainda houve casos em que o faturamento respondeu com 429 Too Many Requests . Poderíamos ter aumentado o limite de solicitações no Nginx, mas queríamos entender melhor o problema, pois era uma dica de que a comunicação não está se comportando conforme o esperado. Como você deve se lembrar, poderíamos ter esses erros na produção, pois eles não eram visíveis para os usuários finais (por causa do fallback para uma chamada direta).

O erro ocorria todos os domingos, quando a plataforma agendava lembretes para os membros da rede de talentos sobre planilhas de horas atrasadas. Para enviar os lembretes, um trabalho busca dados de cobrança de produtos relevantes, que incluem milhares de registros. A primeira coisa que fizemos para otimizá-lo foi agrupar e pré-carregar dados de faturamento e buscar apenas os campos obrigatórios. Ambos são truques bem conhecidos, então não entraremos em detalhes aqui.

Deslocamos e esperamos pelo domingo seguinte. Estávamos confiantes de que havíamos resolvido o problema. No entanto, no domingo, o erro ressurgiu.

O serviço de cobrança era chamado não apenas durante o agendamento, mas também quando um lembrete era enviado a um membro da rede. Os lembretes são enviados em trabalhos em segundo plano separados (usando o Sidekiq), portanto, o pré-carregamento estava fora de questão. Inicialmente, assumimos que não seria um problema porque nem todos os produtos precisavam de um lembrete e porque os lembretes são todos enviados de uma só vez. Os lembretes são agendados para as 17h no fuso horário do membro da rede. No entanto, perdemos um detalhe importante: nossos membros não estão distribuídos uniformemente pelos fusos horários.

Estávamos agendando lembretes para milhares de membros da rede, cerca de 25% dos quais vivem em um fuso horário. Cerca de 15% vivem no segundo fuso horário mais populoso. Como o relógio marcava 17h nesses fusos horários, tivemos que enviar centenas de lembretes de uma só vez. Isso significou uma explosão de centenas de solicitações ao serviço de cobrança, o que era mais do que o serviço podia atender.

Não foi possível pré-carregar dados de cobrança porque os lembretes são agendados em trabalhos independentes. Não foi possível buscar menos campos do faturamento, pois já otimizamos esse número. Mover membros da rede para fusos horários menos populosos também estava fora de questão. Então, o que nós fizemos? Mudamos os lembretes, só um pouquinho.

Adicionamos jitter ao horário em que os lembretes foram programados para evitar uma situação em que todos os lembretes seriam enviados exatamente ao mesmo tempo. Em vez de agendar às 17h em ponto, nós os agendamos em um intervalo de dois minutos, entre 17h59 e 18h01.

Implantamos o serviço e esperamos pelo domingo seguinte, confiantes de que finalmente resolvemos o problema. Infelizmente, no domingo, o erro apareceu novamente.

Estávamos intrigados. De acordo com nossos cálculos, as solicitações deveriam ter sido distribuídas em um período de dois minutos, o que significava que teríamos, no máximo, duas solicitações por segundo. Isso não era algo que o serviço não pudesse lidar. Analisamos os logs e os horários das solicitações de cobrança e percebemos que nossa implementação de jitter não funcionou, então as solicitações ainda estavam aparecendo em um grupo restrito.

Imagem: Alto número de solicitações causadas por implementação inadequada de jitter.

O que causou esse comportamento? Foi a maneira como o Sidekiq implementa o agendamento. Ele pesquisa o redis a cada 10 a 15 segundos e, por causa disso, não pode fornecer resolução de um segundo. Para obter uma distribuição uniforme de solicitações, usamos Sidekiq::Limiter – uma classe fornecida pelo Sidekiq Enterprise. Empregamos o limitador de janela que permitia oito solicitações para uma janela móvel de um segundo. Escolhemos esse valor porque tínhamos um limite Nginx de 10 solicitações por segundo no faturamento. Mantivemos o código de jitter porque ele fornecia uma dispersão de solicitação granular: distribuía os trabalhos do Sidekiq em um período de dois minutos. Em seguida, o Sidekiq Limiter foi usado para garantir que cada grupo de trabalhos fosse processado sem quebrar o limite definido.

Mais uma vez, nós o implantamos e esperamos pelo domingo. Estávamos confiantes de que finalmente resolvemos o problema – e o fizemos. O erro desapareceu.

Otimização de API: Nihil Novi Sub Sole

Acredito que você não ficou surpreso com as soluções que empregamos. Batching, filtragem do lado do servidor, envio apenas de campos obrigatórios e limitação de taxa não são técnicas novas. Engenheiros de software experientes, sem dúvida, os usaram em diferentes contextos.

Pré-carregamento para evitar N+1? Temos em cada ORM. Junções de hash? Até o MySQL os tem agora. Subvalorização? O campo SELECT * vs. SELECT field é um truque conhecido. Espalhando a carga? Também não é um conceito novo.

Então, por que eu escrevi este artigo? Por que não fizemos isso desde o início ? Como de costume, o contexto é fundamental. Muitas dessas técnicas pareciam familiares apenas depois que as implementamos ou apenas quando percebemos um problema de produção que precisava ser resolvido, não quando olhamos para o código.

Havia várias explicações possíveis para isso. Na maioria das vezes, estávamos tentando fazer a coisa mais simples que pudesse funcionar para evitar o excesso de engenharia. Começamos com uma solução REST chata e só então mudamos para GQL. Implementamos alterações por trás de um sinalizador de recurso, monitoramos como tudo se comportava com uma fração do tráfego e aplicamos melhorias com base em dados do mundo real.

Uma de nossas descobertas foi que a degradação do desempenho é fácil de ignorar durante a refatoração (e a extração pode ser tratada como uma refatoração significativa). Adicionar um limite estrito significou que cortamos os laços que foram adicionados para otimizar o código. Não era aparente, porém, até que medimos o desempenho. Por fim, em alguns casos, não conseguimos reproduzir o tráfego de produção no ambiente de desenvolvimento.

Nós nos esforçamos para ter uma pequena superfície de uma API HTTP universal do serviço de cobrança. Como resultado, obtivemos vários endpoints/consultas universais que transportavam dados necessários em diferentes casos de uso. E isso significava que, em muitos casos de uso, a maioria dos dados era inútil. É um pouco de compensação entre DRY e YAGNI: com DRY, temos apenas um endpoint/consulta retornando registros de cobrança, enquanto com YAGNI, acabamos com dados não utilizados no endpoint que só prejudicam o desempenho.

Também notamos outra compensação ao discutir o jitter com a equipe de cobrança. Do ponto de vista do cliente (plataforma), toda solicitação deve receber uma resposta quando a plataforma precisar. Problemas de desempenho e sobrecarga do servidor devem ser ocultados por trás da abstração do serviço de cobrança. Do ponto de vista do serviço de cobrança, precisamos encontrar maneiras de conscientizar os clientes sobre as características de desempenho do servidor para suportar a carga.

Novamente, nada aqui é novo ou inovador. Trata-se de identificar padrões conhecidos em diferentes contextos e entender as compensações introduzidas pelas mudanças. Aprendemos isso da maneira mais difícil e esperamos ter poupado você de repetir nossos erros. Em vez de repetir nossos erros, você sem dúvida cometerá seus próprios erros e aprenderá com eles.

Agradecimentos especiais aos meus colegas e companheiros de equipe que participaram de nossos esforços:

  • Makar Ermokhin
  • Gabriele Renzi
  • Samuel Vega Caballero
  • Lucas Guidi