O problema da escrita dupla — e tudo o que ele obriga você a construir
Duas linhas de código, e boa parte da arquitetura distribuída existe por causa delas:
db.save(pedido);
broker.publish(new PedidoCriado(pedido.id));
Não existe transação em volta desse par. Não pode existir: o banco e o broker são sistemas separados, com protocolos de commit separados. O par não é atômico, e a pergunta útil não é se isso quebra, e sim de que jeito quebra.
Tudo o que vem abaixo — tabela de outbox, consumidor idempotente, sagas, backoff com jitter, registro de schemas — é consequência dessas duas linhas. Vale enxergar a corrente como corrente, e não como sete padrões soltos para decorar.
Quatro formas de falhar, não duas
Em geral as pessoas imaginam uma falha aqui. São quatro, e cada uma pede uma resposta diferente.
O commit funciona e a publicação falha. O pedido existe; ninguém é avisado. A expedição nunca fica sabendo, o e-mail de confirmação não sai, e o seu banco e os serviços a jusante passam a discordar permanentemente. Essa é a falha famosa.
A publicação funciona e o commit falha. Pior, e muito menos comentada. Você anunciou um pedido que não existe. Os serviços a jusante começam a processar um fantasma. Todo consumidor que confiou em você agora tem dado sem fonte da verdade por trás.
A publicação funciona e o reconhecimento se perde. O broker recebeu a mensagem; a rede derrubou o ack no caminho de volta. Seu cliente tenta de novo porque precisa — do ponto de vista dele isso é indistinguível do primeiro caso — e a mensagem agora está duplicada no log.
O processo morre entre as duas chamadas. Sem linha de log, sem exceção, sem stack trace. Você descobre por um chamado de suporte três dias depois.
Repare que inverter a ordem das duas instruções não resolve nada. Só troca qual das duas primeiras falhas você recebe. Não existe ordenação de um par não atômico que o torne atômico.
Por que two-phase commit não é a resposta
A correção de livro-texto é uma transação distribuída: XA, two-phase commit, um coordenador que faz os dois sistemas concordarem. Na prática esse caminho é raro, e vale ser específico sobre o motivo.
O Kafka simplesmente não suporta XA, então para o broker mais comum nesse cenário a opção nem existe. Onde XA existe, o coordenador vira um componente cuja falha trava participantes segurando locks — na janela entre o prepare e o commit os recursos ficam bloqueados e os participantes não podem decidir sozinhos. Essa janela é exatamente quando você menos quer o coordenador fora do ar. Segurar locks de banco pelo tempo de uma ida e volta de rede até o broker também coloca o teto de vazão nas mãos do participante mais lento.
Nada disso quer dizer que 2PC seja errado em todo lugar. Quer dizer que a indústria escolheu outra troca: manter uma escrita atômica em um sistema só, e derivar o resto dela.
Transactional outbox
A sacada é pequena. Você não consegue fazer dois sistemas commitarem atomicamente, então pare de escrever em dois sistemas. Escreva em um — o banco — e faça a mensagem fazer parte dessa mesma escrita.
┌──────────────────────────────────────────────┐
│ UMA transação de banco │
│ │
│ INSERT INTO pedidos ... │
│ INSERT INTO outbox ... ← o evento │
│ │
│ COMMIT ── os dois, ou nenhum │
└──────────────────────────────────────────────┘
│
│ um processo separado lê a outbox
▼
┌───────────────┐
│ Relay │ ── publica ──▶ Broker
│ (poll / CDC) │ ◀── ack
└───────────────┘
│
▼
marca a linha como publicada
A linha de negócio e a linha do evento caem no mesmo commit, então as duas nunca podem discordar. Ou o pedido existe e o evento está na fila, ou nada aconteceu. As duas primeiras falhas somem por construção — não tratadas, não repetidas, sumidas.
Um schema mínimo:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_nao_publicados
ON outbox (id) WHERE published_at IS NULL;
O índice parcial importa mais do que parece. A tabela é quase toda de linhas já publicadas e o relay só quer a cauda não publicada, então indexar a tabela inteira desperdiça a maior parte do índice. O WHERE published_at IS NULL mantém o índice do tamanho do seu backlog, e não do tamanho do seu histórico.
Tirando os eventos de lá
Duas formas de levar as linhas da tabela para o broker.
Polling. Um laço seleciona as linhas não publicadas, publica e marca. É fácil de construir, fácil de raciocinar e fácil de depurar — a fila é uma tabela que você consulta. O custo é a latência do intervalo de polling e carga constante no banco.
Se você roda mais de uma instância do relay, as linhas precisam ser reivindicadas uma vez só:
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
O SKIP LOCKED é o que faz vários relays cooperarem em vez de colidirem — cada um pega linhas que os outros não travaram, sem coordenação e sem espera. Sem ele, a instância dois fica bloqueada atrás da instância um e você comprou uma concorrência que não consegue usar.
Change data capture. Em vez de fazer polling, você lê o log de replicação do banco — o Debezium é a implementação comum — e transforma inserts na tabela de outbox em mensagens no broker. Latência menor, sem carga de polling, e é impossível perder uma linha commitada porque o log é o commit. O custo é peso operacional real: mais um sistema distribuído para operar, com falhas próprias e lag próprio para monitorar.
Comece com polling. Vá para CDC quando a latência ou a carga no banco virarem de fato o gargalo, não antes.
Ordenação, e o que o relay não consegue prometer
Se os consumidores se importam com a ordem dos eventos de uma entidade — e geralmente se importam, porque PedidoCancelado antes de PedidoCriado não faz sentido — publique na ordem do id e particione por aggregate_id, para que todos os eventos de um pedido caiam na mesma partição. Ordenação global entre todos os pedidos quase nunca é necessária e é caríssima de obter; ordenação por agregado é quase sempre o que as pessoas realmente querem dizer.
Agora a parte importante. O relay pode publicar uma mensagem e morrer antes de marcar a linha como publicada. Ao reiniciar, ele publica de novo. Isso não é um bug que dê para engenheirar até sumir — é o mesmo problema do ack perdido do terceiro caso, deslocado uma camada para baixo.
A outbox te dá entrega at-least-once. Ela não consegue dar menos que isso. É sobre esse fato que trata a próxima seção.
Por fim, limpe a tabela. Linhas publicadas são peso morto, e uma outbox que só cresce vai um dia ser o motivo de o banco ficar sem disco. Apague ou use partições descartáveis para linhas mais antigas que a sua janela de replay.
At-least-once, exactly-once, e o que dá para conseguir de verdade
Três garantias de entrega recebem nome, e uma delas é quase um termo de marketing.
At-most-once: dispara e esquece. Rápido, e perde mensagem. Serve para um ping de métrica, não serve para um pedido.
At-least-once: repete até receber confirmação. Nunca perde mensagem, às vezes entrega duas vezes. É o que a outbox te dá, e é sobre isso que praticamente todo sistema real roda.
Exactly-once: entregue exatamente uma vez. No caso geral, entre dois sistemas independentes, isso não é alcançável — reduz ao Problema dos Dois Generais. O remetente não consegue distinguir "o destinatário nunca recebeu" de "o destinatário recebeu e o ack se perdeu", e nenhuma quantidade extra de mensagens resolve isso.
O exactly-once do Kafka é real, e vale ser preciso sobre o escopo, porque o nome convida ao exagero. O Kafka consegue tornar atômico um ciclo consumir-transformar-produzir dentro do Kafka: ler de um tópico, escrever em outro, commitar o offset, tudo ou nada. No instante em que o seu consumidor também escreve no Postgres ou chama uma API de pagamento, você voltou para uma escrita dupla e a transação do Kafka não cobre isso.
Ou seja: o objetivo não é entrega exactly-once. É efeito exactly-once — a mensagem pode chegar várias vezes, e o resultado tem que ser o mesmo de se tivesse chegado uma. Isso é idempotência, e é responsabilidade do consumidor.
Deixando um consumidor idempotente
Algumas operações são naturalmente idempotentes. UPDATE pedidos SET status = 'PAGO' WHERE id = ? pode rodar cem vezes com um resultado só. UPDATE contas SET saldo = saldo + 100 não pode — isso são cem resultados.
Quando a operação não é naturalmente idempotente, deduplique de forma explícita:
BEGIN;
INSERT INTO mensagens_processadas (message_id) VALUES (?);
-- violação de unicidade aqui significa: já tratada, faça rollback e dê ack
UPDATE contas SET saldo = saldo + 100 WHERE id = ?;
COMMIT;
Os dois detalhes críticos são fáceis de errar. O insert de deduplicação e o efeito de negócio precisam estar na mesma transação — se você registra o id da mensagem em outra transação, ou no Redis, você reinventou a escrita dupla um nível abaixo. E o id precisa vir do produtor, carregado na mensagem, e não ser gerado no recebimento. Uma reentrega do broker tem que carregar o mesmo id que carregou na primeira vez.
Ids de evento gerados no produtor querem ser únicos e, de preferência, ordenados no tempo, para que o índice da tabela de deduplicação se mantenha saudável — o mesmo argumento que vale para chaves primárias. Gere alguns se quiser ver a diferença, e UUID v4, v7 ou ULID explica por que a escolha afeta o desempenho de insert muito mais do que o risco de colisão.
Quando realmente não existe id natural — um evento de um sistema que você não controla — um hash do conteúdo dos campos estáveis do payload é uma alternativa viável. É mais fraca: dois eventos legitimamente idênticos viram um. Use só quando a alternativa for nada.
A tabela de deduplicação também precisa de política de retenção, limitada por até onde o seu broker consegue reentregar. Guardar ids para sempre é um vazamento lento; guardar por menos que a janela de retenção é um bug de correção.
Sagas: transações que atravessam serviços
Idempotência resolve uma mensagem. Um processo de negócio atravessa vários serviços — reservar estoque, cobrar o cartão, acionar a transportadora — e uma transação distribuída entre todos eles é exatamente o que você abriu mão. A saga é a substituta: uma sequência de transações locais, cada uma publicando um evento ou comando que dispara a próxima, e cada uma com uma ação compensatória para desfazê-la.
Coreografia
Sem coordenador central. Cada serviço escuta eventos e reage.
PedidoCriado ──▶ Pagamentos ──▶ PagamentoCapturado ──▶ Estoque
│
▼
EstoqueReservado ──▶ Envio
O atrativo é o baixo acoplamento: acrescentar uma checagem antifraude é assinar um evento, sem redeploy de nenhum serviço existente. Funciona bem enquanto o fluxo é curto.
A degradação tem um formato específico. O processo não existe em lugar nenhum inteiro, então responder "o que acontece quando um pedido é criado?" exige ler todos os serviços que assinam eventos. Depurar um pedido travado vira rastrear eventos atravessando fronteiras de serviço. E quando os passos passam de meia dúzia, o grafo de eventos desenvolve ciclos que ninguém projetou e ninguém consegue ver.
Orquestração
Um coordenador é dono do fluxo e emite comandos.
┌──────────────────┐
│ SagaDePedido │
└──────────────────┘
│ │ │
┌─────┘ │ └─────┐
▼ ▼ ▼
Pagamentos Estoque Envio
O fluxo agora é um objeto legível e testável em unidade. Timeouts têm um dono óbvio — o orquestrador sabe que um passo foi emitido e não respondido. "Onde está esse pedido?" tem uma resposta, em um lugar.
O custo é um componente para construir e operar, e a tentação permanente de ele acumular regra de negócio que pertence aos serviços que ele chama. Um orquestrador que cresce regras de decisão de todos os participantes virou o monolito distribuído que a arquitetura queria evitar.
Uma regra que funciona: coreografia para dois ou três passos sem tratamento de falha interessante; orquestração quando o fluxo fica longo o bastante para alguém precisar desenhá-lo num quadro branco. Processos de longa duração com timeouts e aprovação humana são território de orquestração sem discussão.
Compensação não é rollback
É aqui que sagas mais dão errado na prática. Um rollback de banco apaga a história. Uma compensação é uma ação nova que reverte semanticamente uma anterior, e a original continua visível.
Estornar uma cobrança não é descobrar. O cliente vê as duas linhas na fatura. Liberar estoque reservado não é desreservar — outro pedido pode ter levado aquela unidade no meio do caminho. Alguns passos não têm compensação nenhuma: um e-mail enviado foi enviado, e a melhor compensação disponível é um segundo e-mail dizendo que o primeiro estava errado.
Essa última categoria comanda o projeto. Ordene a saga de forma que os passos irreversíveis venham por último, depois do ponto em que o sucesso é quase certo. O passo a partir do qual só dá para seguir em frente costuma ser chamado de pivô. Tudo antes dele precisa ser compensável; tudo depois precisa ser repetido até dar certo. Colocar "enviar e-mail de confirmação" antes de "cobrar o cartão" é erro de projeto, não bug de código.
Explicando consistência eventual para quem não escreve código
Normalmente esta é a seção pulada, e ela causa mais estrago em projeto do que qualquer material técnico acima.
Comece não dizendo "consistência eventual". A expressão é ouvida como "às vezes errado", e o argumento que volta é "então faça ficar certo", que não é uma negociação que se ganhe por argumento técnico.
Descreva o comportamento observável no lugar. Não o modelo de leitura é projetado de forma assíncrona, e sim: "quando o cliente faz um pedido, a página do pedido mostra na hora. O painel do estoque mostra em cerca de dois segundos. Nesses dois segundos, o total do painel é o número antigo."
Esse enquadramento faz três coisas. É concreto, então as pessoas conseguem visualizar. É falsificável, então dá para medir. E transforma a decisão em decisão de negócio — a janela aceitável e o que o usuário vê durante ela são perguntas de produto, e as partes interessadas são perfeitamente capazes de respondê-las quando perguntadas nos termos delas.
Perguntas úteis para essa conversa:
- Quanto tempo é aceitável aqui? Dois segundos e dois minutos são sistemas muito diferentes; as pessoas costumam aceitar bem mais do que o time de engenharia supõe.
- O que a tela deve mostrar enquanto isso? Um número desatualizado sem explicação é a pior opção. "Processando" quase sempre resolve.
- Quem nunca pode ver dado velho? Em geral uma lista bem menor que "todo mundo" — e a resposta honesta costuma ser só a pessoa que acabou de agir.
Esse último ponto tem nome que vale conhecer: read-your-own-writes. O usuário perdoa a alteração de um colega demorar alguns segundos para aparecer. Ele não perdoa a própria alteração sumir, porque lê aquilo como perda de dado. Rotear as leituras de um usuário para o primário logo depois de ele escrever, ou segurar o valor otimista na tela, compra a maior parte da consistência percebida por uma fração do custo da consistência síncrona de verdade.
Sobre analogias, um cuidado. Transferência bancária é o exemplo popular e engana, porque as pessoas acreditam que banco é consistente e que a demora é burocrática. Rastreamento de encomenda funciona melhor: todo mundo já viu um pacote que está fisicamente andando antes de a página de rastreio admitir, e ninguém conclui que o pacote se perdeu.
Resiliência: os quatro padrões, na ordem em que importam
Chamada distribuída falha. Esses quatro não são um cardápio — eles se compõem, e a ordem importa.
Timeout vem primeiro, e não é opcional. Uma chamada sem timeout é um vazamento de recurso com pavio: threads e conexões se acumulam até o pool esgotar, e aí uma dependência lenta derrubou um serviço que estava saudável. Todo timeout também precisa caber dentro do orçamento de quem chamou. Se a sua API tem que responder em três segundos e faz duas chamadas sequenciais com timeout de cinco segundos cada, os timeouts são decoração.
Retry, mas só para as falhas certas. Repita erros transitórios — timeout, 503, conexão resetada. Não repita um 400; o request vai estar igualmente inválido na segunda vez. E não repita uma escrita não idempotente a menos que o receptor deduplique, porque repetir uma cobrança é como um pagamento vira três. Retry e idempotência são a mesma decisão de projeto vista de duas pontas.
Backoff exponencial com jitter. Backoff é intuitivo: espere mais depois de cada falha — 100ms, 200ms, 400ms, 800ms, com teto. O jitter é a parte que as pessoas pulam, e é a parte que importa em escala.
Imagine uma dependência que falha para todos os chamadores ao mesmo tempo. Todo cliente começa o backoff no mesmo instante, então todo cliente repete no mesmo instante, e a dependência — que estava tentando se recuperar — recebe uma onda perfeitamente sincronizada. Ela falha de novo, e agora os clientes estão ainda mais sincronizados que antes. Backoff determinístico não espalha carga; ele organiza um estouro de boiada.
O jitter quebra a sincronização aleatorizando a espera:
espera = aleatorio_entre(0, min(teto, base * 2 ** tentativa))
Essa forma — "full jitter" — sorteia o intervalo inteiro em vez de uma janela em torno do alvo. É a que mais espalha, ao custo de às vezes repetir mais cedo do que um backoff estrito faria. Existem variantes que garantem uma espera mínima; a escolha entre elas importa muito menos do que ter jitter. O material da AWS sobre isso é a referência canônica e vale ler direto da fonte.
Circuit breaker. Quando uma dependência está claramente fora, repetir é pior que inútil: queima as suas threads e nega à dependência o sossego de que ela precisa para voltar. Um circuit breaker observa a taxa de falha e, passado um limite, abre — as chamadas seguintes falham na hora, sem sair do processo. Depois de um resfriamento ele vai para meio aberto e deixa passar algumas chamadas de teste. Sucesso fecha; falha reabre.
Dispare pela taxa de falha numa janela deslizante, não por contagem bruta, e exija um volume mínimo de chamadas para a taxa significar alguma coisa. Um breaker que dispara com "cinco falhas" vai disparar com cinco falhas em cinco milhões de chamadas, o que é ruído. O comportamento por trás de um breaker aberto é decisão de produto: dado em cache, resposta degradada ou erro limpo — mas decidido de propósito, e não o que o tratador de exceção fizer por acaso.
Bulkhead. O nome vem das anteparas de navio: um rombo alaga um compartimento, não a embarcação. Dê a cada dependência seu próprio pool limitado de threads ou conexões, para que uma lenta esgote a cota dela e nada mais. Sem bulkheads, uma dependência degradada consome todas as threads de um pool compartilhado, e um serviço com nove dependências saudáveis e uma doente cai inteiro.
Compondo, do mais externo ao mais interno: o bulkhead limita quanto da sua capacidade aquela dependência pode consumir; o circuit breaker decide se vale tentar a chamada; o retry trata a falha transitória; o timeout limita cada tentativa individual. Acrescente um orçamento de retry — um teto de repetições como fração do tráfego — se você opera numa escala em que as próprias repetições podem virar a carga.
Versionamento de contratos de evento
A última peça, e a que decide se algo disso sobrevive dois anos.
Um endpoint REST tem consumidores que você consegue enumerar e, no fim, drenar. Um evento publicado tem consumidores que você desconhece e — se o seu broker retém histórico — versões antigas do evento que vivem no log e vão ser lidas de novo a cada replay. Você não consegue migrar dado que já foi escrito. Os consumidores precisam lidar com toda versão que você já emitiu.
Duas direções de compatibilidade valem manter claras:
- Compatível para trás: um consumidor novo consegue ler eventos antigos. É o que o replay exige.
- Compatível para frente: um consumidor antigo consegue ler eventos novos. É o que permite subir produtores e consumidores de forma independente.
Você quer as duas, o que na prática vira uma regra: só adicione campos opcionais.
- Adicionar um campo opcional com default sensato é seguro. Consumidor antigo ignora; consumidor novo lida com a ausência.
- Remover um campo quebra todo consumidor que ainda o lê.
- Renomear é remover e adicionar ao mesmo tempo, então quebra do mesmo jeito.
- Trocar um tipo quebra a desserialização, e a falha costuma ser feia e tardia.
- Redefinir o que um campo significa mantendo nome e tipo é o pior de todos, porque nada falha. Um
valorque passa silenciosamente de centavos para unidades de moeda passa por qualquer validação de schema e corrompe dado em silêncio pelo tempo que alguém levar para perceber.
Imponha isso por máquina, não por revisão. Um schema registry — Avro, Protobuf ou JSON Schema — consegue checar um schema proposto contra a regra de compatibilidade e rejeitar a mudança na CI, antes de ela chegar a um tópico. O que depender de vigilância humana entre vários times vai escapar uma hora.
Quando uma quebra genuína for inevitável, não mute o evento existente. Publique um novo tipo de evento ou um novo tópico, faça o produtor emitir os dois durante uma janela de migração, mova os consumidores e aposente o antigo quando a lista de assinantes esvaziar. A versão pertence ao nome do tipo do evento, onde o consumidor não consegue não ver — PedidoCriado.v2 — e não só a um campo que um desserializador vai ignorar com prazer.
Duas práticas barateiam isso. Mantenha payloads pequenos e explícitos; um evento que despeja a entidade inteira transforma todo campo interno em parte do seu contrato público. E quando a mudança acontecer, compare um payload real antes e depois em vez de raciocinar sobre o schema no abstrato — o campo que você esqueceu costuma ser óbvio na hora e invisível no papel.
A corrente
Lidos como lista, são sete padrões. Lidos como corrente, são um problema e suas consequências:
Você não consegue escrever atomicamente em dois sistemas, então escreve o evento no seu banco na mesma transação e faz o relay — a outbox. O relay pode morrer depois de publicar, então a entrega é at-least-once e os consumidores precisam ser idempotentes. Idempotência cobre uma mensagem, mas não um fluxo entre vários serviços, então você precisa de sagas, e sagas significam compensação em vez de rollback. Tudo isso deixa o sistema eventualmente consistente, então a demora vira decisão de produto que precisa ser dita em termos observáveis. As chamadas entre serviços falham, então timeout, backoff com jitter, circuit breaker e bulkhead. E todo evento é um contrato que você não consegue recolher, então o versionamento é aditivo e imposto por máquina.
Pular um elo não elimina o elo. Só o transfere para um incidente.
Continue lendo
UUID v4, v7 ou ULID: qual ID o seu banco deveria usar
Identificadores aleatórios são péssimas chaves primárias por motivos que nada têm a ver com colisão. Veja o que dá errado de verdade — e a correção.
Por que seu regex não casa: os 7 erros mais comuns
Um padrão que não retorna nada raramente está com a sintaxe quebrada. Normalmente é um destes comportamentos fazendo exatamente o que você mandou.
O que tem dentro de um JWT — e o que você nunca deve colocar lá
Um JWT são três strings Base64url e uma assinatura. Entender qual parte é secreta (nenhuma) muda como você projeta com ele.
JSON ou YAML: quando cada um faz sentido de verdade
Os dois codificam os mesmos dados. As diferenças que importam são sobre quem escreve o arquivo, quem lê e o que acontece quando alguém erra de digitação.
Ferramentas relacionadas
Gere UUID v4, v7 e ULID em massa
Compare dois documentos JSON lado a lado
Hashes MD5, SHA-1, SHA-256 e SHA-512