Arquitetura de software
Transações distribuídas e consistência: 2PC, sagas, outbox e CAP
Avançado12 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Quando o estado se espalha por vários serviços e bancos de dados, a transação de um único banco deixa de existir e algo precisa ocupar o lugar dela. Este guia explica o que CAP e PACELC realmente restringem, por que o commit em duas fases raramente é a resposta fora de uma única stack confiável, como sagas com compensação se comportam na prática e como o outbox e a idempotência transformam a entrega at-least-once em resultados de negócio corretos.
O que você vai aprender
- Enunciar com precisão o que CAP e PACELC dizem, e o que não dizem, sobre um sistema
- Explicar o protocolo de commit em duas fases e as falhas que o fazem bloquear
- Projetar uma saga com ações de compensação e escolher entre coreografia e orquestração
- Implementar o transactional outbox para que mudanças de estado e mensagens não possam divergir
- Raciocinar sobre entrega at-least-once, idempotência e a ilusão do exactly-once
Dentro de um único banco de dados, a consistência vem por padrão: você abre a transação, faz várias coisas, dá commit e ou tudo aconteceu, ou nada aconteceu. Essa garantia moldou a forma como a maioria dos desenvolvedores pensa em corretude, e ela desaparece no momento em que uma operação de negócio envolve dois bancos ou dois serviços. Nada a substitui exatamente. O que existe no lugar é um conjunto de protocolos e padrões, cada um abrindo mão de algo específico, e o trabalho de engenharia é escolher com qual perda você consegue conviver.
Este guia começa pelo que a teoria realmente restringe, porque o CAP, em particular, é mais citado do que compreendido. Depois aborda as duas famílias de resposta: os protocolos de commit atômico, que tentam preservar a garantia antiga, e as abordagens do tipo saga, que abandonam o isolamento e reconstroem a corretude a partir de compensações e idempotência. O catálogo de padrões de projeto de microsserviços apresenta esses padrões pelo nome; aqui, eles são examinados pela forma como realmente falham.
ACID, BASE e o que o isolamento oferece
ACID descreve a transação local: atomicidade (tudo ou nada), consistência (as restrições valem no commit), isolamento (transações concorrentes não enxergam os estados intermediários umas das outras) e durabilidade (dados confirmados sobrevivem a uma queda).
O isolamento é a propriedade de que mais se sente falta quando ela vai embora, e vale lembrar que, mesmo localmente, ele costuma ser mais fraco que "serializável". A maioria dos bancos usa read committed por padrão, em que uma transação pode ver linhas que outra confirmou enquanto ela estava em execução, então leituras não repetíveis e fantasmas já são possíveis. Entender as anomalias que o seu nível de isolamento permite (leitura suja, leitura não repetível, fantasma, write skew) é essencial antes de distribuir qualquer coisa, porque a distribuição só alarga essas janelas.
BASE é o contraponto deliberado: basically available, soft state, eventually consistent. O sistema continua disponível e aceita escritas, as réplicas podem discordar por um tempo e, se não houver novas atualizações, elas convergem. Isso não é um padrão de engenharia inferior; é um contrato diferente, e exige mais trabalho de design, não menos, porque cada consumidor precisa ser escrito para tolerar um valor brevemente desatualizado.
A pergunta de negócio que decide entre os dois não é "precisamos de consistência?" (todo mundo responde que sim), e sim "o que acontece se esses dois fatos discordarem por dois segundos?". Duas contas de um livro-razão em desacordo são inaceitáveis. A contagem de avaliações de um produto atrasada, não. A maioria dos sistemas reais precisa de consistência forte em um núcleo pequeno e tolera consistência eventual em todo o resto, e é por isso que os agregados importam: o agregado é exatamente a fronteira dentro da qual você exige ACID.
CAP e PACELC, enunciados com cuidado
O CAP diz que um sistema distribuído não pode garantir ao mesmo tempo consistência (toda leitura vê a escrita mais recente), disponibilidade, ou availability (toda requisição a um nó que não falhou recebe resposta), e tolerância a partições (o sistema continua funcionando mesmo com perda de mensagens entre nós). E como partições acontecem quer você se planeje para elas ou não, a escolha real é o que fazer durante uma partição: recusar requisições para preservar a consistência (CP) ou responder a partir de um estado possivelmente desatualizado para preservar a disponibilidade (AP).
Três esclarecimentos eliminam a maior parte da confusão:
- A escolha vale apenas durante uma partição. Um sistema saudável pode ser consistente e disponível ao mesmo tempo, e normalmente é.
- É uma escolha por operação e por conjunto de dados, não por sistema. A mesma plataforma pode recusar uma transferência de saldo durante uma partição e continuar servindo um catálogo de produtos.
- A "consistência" do CAP significa linearizabilidade, uma garantia específica e forte. Não é o C do ACID, e a coincidência acidental das letras rendeu anos de discussões confusas.
O PACELC estende isso de forma útil: se houver uma Partição, escolha entre disponibilidade (Availability) e Consistência; caso contrário (Else), escolha entre Latência e Consistência. A segunda cláusula cobre os 99,9 % do tempo em que a rede está bem, e dá nome ao trade-off que você faz com muito mais frequência. Confirmar uma escrita assim que uma réplica a recebe é rápido e arrisca perdê-la; esperar por um quórum é mais lento e mais seguro. Todo armazenamento replicado expõe isso como um parâmetro ajustável (write concern, nível de consistência, número de confirmações exigidas), e defini-lo deliberadamente por operação tem mais consequência do que qualquer rótulo atribuído ao banco de dados.
Commit em duas fases e XA
A resposta clássica para atomicidade entre recursos é o commit em duas fases (2PC), coordenado por um gerenciador de transações.
Fase 1 — preparação
O coordenador pergunta a cada participante se ele pode dar commit. Cada um executa o trabalho, grava-o de forma durável no seu log, adquire os locks necessários e vota sim ou não. Um voto sim é uma promessa firme: o participante precisa ser capaz de dar commit depois, por isso mantém seus locks.Fase 2 — commit ou abort
Se todos votaram sim, o coordenador grava sua decisão no próprio log e manda todos darem commit. Se algum votou não, manda todos fazerem rollback. Os participantes aplicam a decisão e liberam os locks.
O protocolo é correto, e seu modo de falha é que ele bloqueia. Se o coordenador cair depois de coletar os votos sim, mas antes de divulgar a decisão, os participantes ficam presos: prometeram ser capazes de dar commit, então não podem abortar por conta própria, e não conhecem a decisão, então também não podem dar commit. Os locks ficam retidos até o coordenador se recuperar e ler seu log. Transações em dúvida desse tipo são um problema operacional conhecido, e resolvê-las manualmente pode quebrar a atomicidade de verdade.
Os outros custos são estruturais. Todo participante precisa implementar o protocolo, o que exclui a maioria dos serviços HTTP modernos e muitos armazenamentos de dados. A disponibilidade se multiplica para baixo: a transação só tem sucesso se todos os participantes estiverem no ar. A latência inclui pelo menos duas idas e voltas, mais escritas duráveis no log de cada participante. E o throughput sofre porque os locks ficam retidos durante todo o protocolo, de modo que linhas muito disputadas serializam o sistema inteiro.
XA é a interface padrão para isso, implementada na JVM por meio do JTA, com um XAResource para cada recurso participante. Ele continua genuinamente adequado em uma faixa estreita: um único servidor de aplicação coordenando um banco de dados e um provedor JMS, ambos locais, ambos sob uma mesma equipe de operações, com throughput modesto, que é o caso tratado em transações e JTA. É a ferramenta errada entre serviços implantados de forma independente, e "transações distribuídas não funcionam com microsserviços" é um atalho para dizer exatamente isso.
O commit em três fases e o commit baseado em consenso (usando Paxos ou Raft para replicar a decisão do coordenador) resolvem o problema do bloqueio ao custo de mais idas e voltas e de bem mais maquinaria. Sistemas que precisam de atomicidade entre shards, como os bancos SQL distribuídos, implementam internamente algo dessa família, o que é um bom motivo para deixar o banco de dados cuidar disso em vez de construir por conta própria.
Sagas
Uma saga substitui uma transação distribuída por uma sequência de transações locais, cada uma confirmada de forma independente, mais uma ação de compensação para cada passo, capaz de desfazer seu efeito se um passo posterior falhar.
A troca é explícita e merece ser dita com todas as letras: você mantém a atomicidade no sentido fraco de que a saga ou é concluída ou é compensada, e perde totalmente o isolamento. Os estados intermediários ficam visíveis. Entre reservar o estoque e capturar o pagamento, o estoque está de fato reservado e outras transações conseguem ver isso. Todo design de saga precisa, portanto, responder o que os outros atores observam no meio do caminho, e ou tornar isso aceitável, ou escondê-lo atrás de um status.
Compensação não é rollback. É uma nova ação de negócio com semântica própria: um reembolso não apaga um pagamento, e um e-mail de cancelamento pode já ter sido lido. Alguns passos não podem ser compensados de jeito nenhum (um e-mail enviado, um arquivo transmitido a uma câmara de compensação), e é por isso que a recomendação padrão é ordenar os passos de forma que os compensáveis venham primeiro, depois um passo pivô que decide o resultado e, por fim, os passos reexecutáveis, que precisam acabar tendo sucesso e nunca são desfeitos.
Coreografia
Cada serviço reage a eventos e emite os seus. Não existe coordenador.
OrderService → OrderCreated
InventoryService → (on OrderCreated) reserve stock → StockReserved | StockUnavailable
PaymentService → (on StockReserved) charge → PaymentCaptured | PaymentDeclined
InventoryService → (on PaymentDeclined) release stock [compensation]
OrderService → (on PaymentCaptured) confirm | (on PaymentDeclined) cancel
O acoplamento continua baixo e cada equipe é dona do próprio passo. Os custos aparecem conforme o fluxo cresce: nenhum lugar único descreve o processo, então entendê-lo exige ler todos os serviços; dependências cíclicas entre eventos vão surgindo; e responder "onde o pedido 4711 está travado?" exige correlacionar eventos entre sistemas.
Orquestração
Um coordenador mantém o estado da saga e emite comandos.
// Orchestrator: explicit state machine, persisted after every transition.
@Component
class PlaceOrderSaga {
@SagaStep(order = 1, compensate = "releaseStock")
void reserveStock(SagaState s) { inventory.reserve(s.orderId(), s.lines()); }
@SagaStep(order = 2, compensate = "refund") // pivot: after this, we go forward
void capturePayment(SagaState s) { payments.capture(s.orderId(), s.total(), s.orderId()); }
@SagaStep(order = 3) // retriable: never compensated
void arrangeShipment(SagaState s) { shipping.schedule(s.orderId(), s.address()); }
void releaseStock(SagaState s) { inventory.release(s.orderId()); }
void refund(SagaState s) { payments.refund(s.orderId(), s.total(), s.orderId() + ":refund"); }
}
O fluxo é explícito, testável e observável; timeouts e escalonamento ficam em um só lugar; uma ferramenta de suporte consegue mostrar cada instância em andamento. Os custos são um componente que precisa conhecer todos os participantes, o risco de a lógica de negócio se acumular no orquestrador em vez de nos serviços e mais uma peça para tornar altamente disponível. Repare que tanto capture quanto refund passam uma chave de idempotência estável: sem ela, uma compensação reexecutada reembolsa duas vezes.
| Critério | Coreografia | Orquestração |
|---|---|---|
| Acoplamento | Baixo; os serviços só conhecem eventos | O coordenador conhece cada participante |
| Visibilidade do fluxo | Implícita, espalhada pelos serviços | Explícita e consultável de forma centralizada |
| Tamanho adequado | 3–4 passos | Qualquer um, e essencial acima de uns 5 |
| Timeouts e escalonamento | Por serviço, duplicados | Centralizados |
| Testar o fluxo inteiro | Difícil | Simples |
| Risco a observar | Cadeias cíclicas de eventos | Um monólito distribuído no coordenador |
Em qualquer um dos casos, sagas precisam de timeouts em todos os passos (um passo que nunca responde precisa, em algum momento, ser tratado como falho), de persistência do estado da saga para que uma reinicialização do coordenador retome em vez de recomeçar e de um plano para o caso em que a própria compensação falha, o que geralmente significa reexecutá-la indefinidamente e disparar um alerta operacional, porque não há nenhum outro recurso.
O problema da escrita dupla e o outbox
Há uma falha mais sutil por baixo de tudo isso. Um serviço que atualiza seu banco de dados e depois publica um evento está fazendo duas escritas em dois sistemas, sem atomicidade. Se ele cair entre as duas, o estado mudou e nenhum evento foi publicado; se publicar primeiro e o commit falhar, os consumidores agem sobre algo que nunca aconteceu. Nenhum dos dois casos se resolve com retentativas às cegas.
O transactional outbox elimina o problema tornando as duas escritas locais:
BEGIN;
UPDATE orders SET status = 'CONFIRMED' WHERE id = '8f21c' AND status = 'PENDING';
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload, created_at)
VALUES (gen_random_uuid(), 'order', '8f21c', 'OrderConfirmed',
'{"orderId":"8f21c","total":"89.90","currency":"EUR"}'::jsonb, now());
COMMIT;
Um relay separado publica depois as linhas de outbox no broker e as marca ou exclui. Se ele cair depois de publicar, mas antes de marcar, a mensagem é enviada duas vezes: at-least-once, algo que os consumidores já precisam tolerar. O relay pode fazer polling na tabela ou ler o write-ahead log do banco por meio de change data capture, o que evita a carga do polling e preserva a ordem dos commits.
O espelho disso no lado do consumidor é o inbox: registrar o identificador de cada mensagem processada na mesma transação que o seu efeito e ignorar tudo o que já estiver registrado. Juntos, outbox e inbox transformam um par de sistemas não confiáveis em um pipeline confiável, sem nenhum protocolo distribuído.
Uma alternativa para serviços com event sourcing é o padrão listen-to-yourself: anexar o evento ao event store como única escrita e publicar a partir do store. Não há escrita dupla porque o store é a fonte da verdade.
At-least-once, idempotência e a ilusão do exactly-once
Todo sistema de mensageria oferece uma escolha entre at-most-once (dispara e esquece; pode perder), at-least-once (retenta até receber confirmação; pode duplicar) e exactly-once (anunciado, e vale ler com atenção). Quase todos os sistemas reais funcionam em at-least-once, porque perder uma instrução de pagamento é pior do que processar uma duplicata que você consegue detectar.
A entrega exactly-once é impossível em uma rede que pode descartar mensagens: um remetente que não recebe confirmação não consegue distinguir uma mensagem perdida de uma confirmação perdida, e precisa escolher entre reenviar (arriscando duplicar) ou não (arriscando perder). O que é alcançável é o efeito exactly-once, e há dois caminhos. O primeiro é o processamento idempotente: o consumidor detecta e ignora repetições usando um identificador de mensagem armazenado, uma chave natural de negócio ou uma atualização condicionada ao estado, como UPDATE ... WHERE status = 'PENDING', que simplesmente afeta zero linhas na segunda vez. O segundo é uma fronteira transacional dentro de um único sistema: o producer transacional do Kafka com leitura-processamento-escrita, por exemplo, confirma atomicamente os registros produzidos e os offsets consumidos, o que dá semântica exactly-once dentro do Kafka, mas não diz nada sobre o banco de dados externo em que você também escreveu. As garantias e seus limites são tratados em padrões e garantias de mensageria.
Implicações práticas: torne todo consumidor idempotente, não importa o que o broker prometa; propague de ponta a ponta uma chave de idempotência de negócio em vez de depender dos identificadores de mensagem do broker, que mudam quando uma mensagem é republicada; e guarde os registros de deduplicação por mais tempo do que a maior janela plausível de retentativas.
Consistência eventual no design
Escolher consistência eventual é uma decisão de produto tanto quanto técnica, e os designs que funcionam tornam o atraso visível em vez de escondê-lo.
- Mostre o estado com honestidade. "Pagamento em processamento" é um status verdadeiro e aceitável; um saldo que exibe silenciosamente um valor desatualizado não é.
- Leia suas próprias escritas. Um usuário que acabou de mudar o endereço precisa ver o novo. Direcione as leituras dele para o primário por uma janela curta ou sirva o valor pendente a partir do lado de escrita.
- Torne a convergência observável. Monitore o atraso de replicação e de projeção como métricas de primeira classe, com alertas, porque uma projeção parada fica em silêncio até alguém reclamar.
- Projete para a reconciliação. Rode um job periódico que compare a visão que diferentes serviços têm dos mesmos fatos e reporte as discrepâncias. Todo sistema eventualmente consistente as acumula; a questão é se quem as encontra é você ou um cliente.
- Versione e ordene os eventos para que um consumidor possa descartar o que já foi superado, já que a ordem entre partições não é garantida por padrão.
Principais conclusões
- O que você realmente perde quando uma transação envolve vários serviços é o isolamento, não a atomicidade, então todo design precisa deixar claro o que os outros atores podem observar no meio do caminho; o CAP restringe o comportamento apenas durante uma partição e apenas por conjunto de dados; o PACELC dá nome ao trade-off que você faz o tempo todo: latência versus consistência em operação normal.
- O commit em duas fases é correto e bloqueante: uma falha do coordenador entre a preparação e a decisão mantém locks em todos os participantes, e é por isso que o XA serve para uma única stack, e não para serviços independentes.
- Uma saga é uma sequência de transações locais com compensações; coloque os passos compensáveis antes do pivô, torne cada passo idempotente, persista o estado da saga e coloque timeouts em tudo.
- A coreografia serve para fluxos curtos e mantém o acoplamento baixo; a orquestração se torna necessária quando um fluxo tem vários passos ou o negócio precisa ver onde uma instância está travada.
- O transactional outbox elimina localmente o problema da escrita dupla, e um inbox no lado do consumidor torna segura a entrega at-least-once.
- Entrega exactly-once não existe; efeito exactly-once existe, por meio de consumidores idempotentes e de chaves de idempotência de negócio propagadas de ponta a ponta.
Fontes e leituras complementares
Perguntas frequentes
- O teorema CAP significa que eu preciso escolher entre consistência e disponibilidade?
- Só durante uma partição de rede, e só para os dados que a partição separa. Quando a rede está saudável, o que acontece quase o tempo todo, você pode ter as duas, e o trade-off interessante passa a ser latência versus consistência, que é justamente o que o PACELC acrescenta. Tratar o CAP como uma escolha arquitetural permanente entre dois rótulos leva a sistemas que abrem mão de uma consistência da qual nunca precisaram abrir mão.
- Por que o commit em duas fases é considerado prejudicial para microsserviços?
- Porque ele bloqueia. Entre o voto de preparação e a decisão de commit, cada participante mantém seus locks e não pode seguir por conta própria, então uma falha do coordenador nesse momento deixa os recursos travados até ele se recuperar. Ele também exige que todos os participantes suportem o protocolo, acopla a disponibilidade de todos e escala mal. Dentro de um servidor de aplicação com dois recursos locais, funciona bem; entre serviços implantados de forma independente, não.
- Como escolher entre coreografia e orquestração para uma saga?
- Conte os passos e pergunte quem precisa enxergar o processo. Com até três ou quatro passos e tratamento de falhas simples, a coreografia mantém os serviços desacoplados e não exige nenhum componente novo. Acima disso, ou quando o negócio precisa perguntar onde uma instância específica está travada, um orquestrador torna o fluxo explícito, testável e observável, ao custo de um componente que conhece todos os participantes.
- A entrega exactly-once é possível?
- Entrega exactly-once sobre uma rede não confiável não é alcançável, porque o remetente nunca consegue distinguir uma mensagem perdida de uma confirmação perdida. O que é alcançável é o efeito exactly-once: entrega at-least-once combinada com processamento idempotente, ou um ciclo transacional de leitura-processamento-escrita dentro de um único sistema, como o producer transacional do Kafka. Os sistemas que anunciam semântica exactly-once oferecem a segunda opção, dentro das próprias fronteiras.