Quando throughput não é o objetivo certo: restrições sequenciais em sistemas distribuídos

Ary Lima

Ary Lima

Engenheiro de Software

Em sistemas distribuídos, a gente costuma partir da premissa de que mais concorrência significa maior throughput e menor latência. Na maioria das vezes, isso funciona.

Este texto é sobre um caso em que não funcionou.

O problema

Operamos um sistema distribuído com vários microserviços. Um desses serviços expõe uma operação que, para uma conta específica, contém uma etapa que precisa ser executada de forma estritamente sequencial.

Se duas requisições para a mesma conta entram nessa seção crítica ao mesmo tempo, a segunda enxerga um recurso desatualizado e falha. Não existe uma forma segura de paralelizar essa parte do fluxo sem quebrar a corretude.

Com pouco tráfego, essa restrição era quase invisível. Em produção, virou um problema sério.

Quando chegavam várias requisições concorrentes para a mesma conta, cerca de metade falhava por causa dessa dependência sequencial.

Nesse ponto, a pergunta deixou de ser “como escalamos isso?” e passou a ser:

Como fazer a concorrência se comportar de forma previsível quando parte do sistema, por definição, não pode ser concorrente?

Primeira tentativa: retries

A reação comum em situações assim é colocar retries.

Se uma requisição falha porque outra já está em andamento, talvez tentar de novo depois de um pequeno atraso permita que ela passe quando a primeira terminar.

Colocamos um mecanismo de retries ao redor da parte sequencial da operação:

  • Um número pequeno de tentativas
  • Atrasos curtos com backoff
  • Jitter para evitar tentativas sincronizadas

Isso melhorou bastante. A taxa de falhas caiu de cerca de 50% para algo em torno de 20%.

Mas retries não resolvem o problema de fundo. Elas só deslocam a contenção no tempo. Com concorrência sustentada, as requisições continuam colidindo, só que um pouco depois.

Retries reduzem sintomas, não mudam o formato do sistema.

Segunda tentativa: serialização explícita com distributed lock

O passo seguinte foi parar de torcer para as requisições se desviarem e tornar a restrição sequencial explícita.

Introduzimos um distributed lock usando um armazenamento de coordenação compartilhado. Antes de entrar na seção crítica, a requisição adquiriu o lock daquela conta. Só uma requisição podia seguir por vez; as demais aguardavam.

O efeito foi imediato:

  • A taxa de falhas caiu para zero
  • O sistema passou a se comportar corretamente sob carga concorrente
  • Conseguimos lançar uma versão inicial do produto com confiança

A partir daí, a corretude deixou de ser o problema.

O problema virou latência.

O custo oculto da corretude

Depois de estabilizar o sistema, apareceu um novo modo de falha.

A seção crítica em si levava algumas centenas de milissegundos para executar. Com serialização estrita, isso significa que mesmo taxas modestas de requisições para a mesma conta criam fila.

À medida que a concorrência aumentava:

  • As requisições esperavam mais tempo para adquirir o lock
  • A latência de cauda aumentava
  • Eventualmente, as requisições começavam a estourar o timeout antes de concluir

O sistema agora estava correto, mas o throughput por conta tinha um teto claro.

Isso expôs uma realidade importante:

Locks não removem trabalho. Eles só ordenam.

Uma vez serializadas, as requisições não conseguiam mais esconder o custo da etapa sequencial atrás da concorrência.

Considerando batching e coalescência de requisições

Uma ideia que exploramos foi coalescer requisições.

Em vez de processar uma a uma, o sistema poderia:

  1. Executar a primeira requisição
  2. Acumular as requisições que chegam enquanto ela está em andamento
  3. Processar essas requisições em lote quando a seção crítica terminar

Essa abordagem pode melhorar bastante o throughput ao amortizar o custo da operação sequencial entre várias requisições.

Do ponto de vista técnico, é uma opção válida.

Mas vinha com trade-offs importantes:

  • Novas APIs orientadas a batch em vários serviços
  • Mudanças em validação e semântica de erros
  • Lógica adicional de coordenação para garantir corretude
  • Aumento de escopo de implementação e testes

Mais importante: batching não remove a restrição sequencial, apenas eleva o teto de throughput. Depois de certo ponto, o sistema ainda encontraria um limite rígido, com retornos decrescentes para a complexidade adicional.

Dadas as prioridades de produto e os prazos de negócio, essa abordagem não representava o melhor uso do esforço de engenharia naquele momento.

A decisão não foi sobre evitar complexidade, mas sobre escolher onde essa complexidade gerava mais valor.

A solução “ideal” - e por que é difícil

Arquiteturalmente, a solução mais limpa para esse tipo de problema é tornar a operação assíncrona.

Em vez de forçar quem chama a usar um modelo síncrono de request/response, o sistema poderia:

  • Aceitar as requisições
  • Enfileirar o trabalho por conta
  • Processar as requisições sequencialmente em background
  • Expor a conclusão via polling ou callbacks

Esse desenho torna a restrição sequencial explícita e elimina a necessidade de distributed locks.

Na prática, essa abordagem traz outro conjunto de desafios.

No nosso caso, o serviço é chamado por clientes externos que esperam semântica síncrona. Migrar para um modelo assíncrono exigiria:

  • Novas APIs versionadas
  • Mudanças em clientes fora do nosso controle direto
  • Novas regras de retries, timeout e falhas
  • Componentes operacionais adicionais (filas, workers, dead-letter)

Apesar de tecnicamente atraente, essa solução deslocaria a complexidade entre áreas em vez de eliminá-la. Dadas as restrições atuais, não era viável no curto prazo.

Onde isso nos deixa

Não existe uma solução final e perfeita para esse problema - pelo menos não uma que se encaixe em todas as restrições técnicas e organizacionais.

Essa experiência reforçou que throughput nem sempre é o alvo correto de otimização.

Em sistemas com etapas sequenciais inevitáveis:

  • Adicionar concorrência frequentemente desloca modos de falha em vez de removê-los
  • Retries reduzem sintomas, mas não atacam a causa raiz
  • Locks melhoram a corretude, mas expõem limites de throughput
  • Soluções mais complexas podem ser tecnicamente sólidas, mas desalinhadas com a realidade do negócio

O trabalho real passa a ser tornar essas restrições:

  • Explícitas
  • Previsíveis
  • Observáveis
  • Compreensíveis tanto por sistemas quanto por pessoas

Às vezes, a decisão mais responsável não é forçar uma solução elegante, mas reconhecer o formato do problema e escolher as trocas menos danosas.

Ary Lima

Sobre o autor

Ary Lima é engenheiro de software na 1Password, baseado em Calgary, AB.

Tags