Desenhando um único caminho de computação para uma métrica entre serviços

Dois serviços calculavam de forma independente a mesma métrica voltada ao cliente, causando dados inconsistentes. Redesenhei a arquitetura para que só um serviço fosse dono do cálculo enquanto todo outro serviço simplesmente sinalizava dados desatualizados.

impactoEliminação de race conditionsArquitetura simplificadaConfiança do cliente melhorada
01

Contexto

A plataforma da Dynamox deriva uma métrica de conclusão para rotas de inspeção industrial: quanto de uma rota foi inspecionado, por ativo, por cliente. A métrica é voltada ao cliente, aparecendo em dashboards que operadores usam para planejar trabalho.

Os fatos subjacentes vivem em eventos. Inspeções, edições e exclusões fluem através do Kafka para múltiplos serviços, cada um mantendo sua própria projeção dos dados.

02

Problema

Dois serviços computavam a métrica de forma independente, cada um a partir de sua própria projeção. Em condições normais, concordavam. Sob retries, entrega fora de ordem ou falhas parciais, divergiam, e clientes viam dois números diferentes para a mesma rota dependendo de qual tela abriam.

Cada divergência virava um ticket de suporte, uma reconciliação manual, e uma pequena perda de confiança do cliente. O time tinha construído um job de reconciliação para corrigir diferenças, o que tratava o sintoma e adicionava um terceiro componente que podia discordar.

03

Restrições

Entrega pelo menos uma vez: todo consumer precisa tolerar duplicatas e reordenação.
Sem transações distribuídas, já que os serviços fazem deploy e falham de forma independente.
Zero downtime: a métrica está em uso operacional diário.
Dados históricos precisavam de backfill para um estado consistente.
Os dois serviços que faziam o cálculo eram de responsabilidade de pessoas diferentes; qualquer correção precisava sobreviver a fronteiras de time.
04

Alternativas Consideradas

Manter os dois cálculos, melhorar o job de reconciliação. Rejeitada: reconciliar dois cálculos independentes é trabalho ilimitado, porque todo novo caso extremo reaparece duas vezes.
Extrair o cálculo para uma biblioteca compartilhada. Rejeitada: código idêntico sobre projeções não idênticas ainda diverge. O bug estava nos dados, não na fórmula.
Computar na leitura, no API gateway. Rejeitada: empurrava latência para todo carregamento de dashboard e ainda exigia uma projeção de fonte consistente.
Serviço único dono; todos os outros emitem sinais de desatualização. Escolhida.
05

Decisão

Exatamente um serviço é dono da métrica. É o único caminho de código na empresa autorizado a computá-la.

Todo outro serviço que toca dados subjacentes para de computar qualquer coisa. Em vez disso, emite um sinal leve de 'desatualizado': 'a rota X pode ter mudado'. O dono recomputa a métrica a partir da fonte da verdade, de forma idempotente, sempre que um sinal chega.

Foi aqui que um princípio pessoal se cristalizou: dados derivados eventualmente consistentes deveriam ter exatamente um caminho de computação. E seu corolário: se um valor sempre pode ser recomputado a partir da fonte, prefira recomputação a sincronização.

sinal de desatualizaçãosinal de desatualizaçãovalor recomputadoKafka: eventos de inspeção · edição · exclusãoServiço A(projeção própria)Serviço B(projeção própria)Serviço Dono da Métricarecompute idempotente · caminho único de computaçãoDashboardsvoltado ao cliente✕ removido:2º cálculo em B+ job de reconciliação
06

Trade-offs

Recomputação custa mais do que atualizações incrementais. Aceito: o cálculo é barato em relação ao custo da divergência, e os sinais têm debounce.
Existe uma janela de desatualização entre o sinal e a recomputação. Aceito: segundos de desatualização com convergência garantida vence valores instantâneos que podem estar permanentemente errados.
O serviço dono se torna um caminho crítico. Mitigado: consumers idempotentes, dead-letter queue, e alertas sobre atraso de sinal.
07

Implementação

Sinais de desatualização viajam por um tópico Kafka dedicado, com chave por rota, então recomputações para a mesma rota serializam naturalmente. O dono consome com handlers idempotentes, então recomputar duas vezes é seguro por construção e retries não precisam de tratamento especial.

Um script de backfill recomputou cada métrica histórica a partir da fonte, migrando o sistema para uma baseline consistente antes do novo caminho ir ao ar. O cálculo antigo no segundo serviço foi deletado em vez de desativado, porque deixá-lo dormente convidava a uma ressurreição.

08

Impacto

Tickets de inconsistência da métrica caíram a zero após o rollout.
O job de reconciliação foi deletado, removendo uma classe inteira de manutenção.
Race conditions se tornaram estruturalmente impossíveis em vez de meramente improváveis.
O padrão foi reutilizado para outros dados derivados; tornei-me a referência do time em sincronização entre serviços.
09

Lições Aprendidas

Debates de consistência terminam quando o ownership é explícito. A maior parte do trabalho de design foi conseguir concordância sobre a frase 'só este serviço computa este valor', não escrever código.

Deletar código é parte da arquitetura. A migração não estava concluída até que o segundo caminho de computação deixasse de existir fisicamente.

10

Evidência

Este documento é a evidência primária das capacidades que reivindica: Sistemas Distribuídos (design de consistência orientado a eventos), Arquitetura (fronteiras de ownership), Senso de Dono (proposta até backfill até exclusão), Comunicação (a decisão sobreviveu porque foi escrita e acordada entre dois donos de serviço).