Sagas vs 2PC: transações distribuídas sem perder o sono
Você tem um microserviço de pedidos, um de pagamento, um de estoque. Um pedido precisa: reservar estoque, cobrar o cartão, agendar entrega. Se qualquer um falha, você precisa desfazer o que foi feito. Mas cada serviço tem seu próprio banco — não existe BEGIN TRANSACTION global. O que fazer?
Duas respostas sérias: Two-Phase Commit (2PC), a solução clássica (e bloqueante), e Sagas, a solução moderna baseada em compensações. Este módulo explica as duas, quando cada uma faz sentido, e como implementar Sagas corretamente com orchestration, choreography e o outbox pattern.
Por que transações distribuídas são difíceis
Numa tabela única, o banco te dá ACID de graça. Atomicidade, consistência, isolamento, durabilidade — tudo garantido. Em sistemas distribuídos com vários serviços independentes, você perde:
| Propriedade | Monolito/Single DB | Microserviços/Multi DB |
|---|---|---|
| Atomicidade | BEGIN/COMMIT do banco | Precisa coordenar múltiplos commits |
| Isolamento | Locks locais + MVCC | Não existe — cada banco só vê o próprio |
| Consistência global | Constraints + FKs | Impossível; precisa de eventual consistency |
| Durabilidade | WAL + fsync | Por banco; o problema é orquestração |
O desafio real não é só técnico — é de semântica de negócio. Se "cobrar cartão" funciona mas "agendar entrega" falha, você precisa reembolsar. Isso não é infraestrutura, é fluxo de negócio que precisa ser codificado.
Two-Phase Commit (2PC)
2PC é o protocolo clássico, usado por XA (X/Open), bancos Oracle/SQL Server com DTC, JTA em Java. Dois papéis: coordinator e participants.
Problemas do 2PC em sistemas modernos:
| Problema | Impacto |
|---|---|
| Blocking protocol | Se coordinator cair entre prepare e commit, participants ficam com locks presos. Não podem decidir sozinhos. |
| Locks durante a janela | Em transações longas, locks mantidos bloqueiam outras transações — throughput morre. |
| Exige 2PC em TODOS participants | Kafka, MongoDB, Redis, REST APIs não suportam. 2PC só roda em bancos XA-compliant. |
| Performance em WAN | Duas rodadas de mensagens + fsync. Latência cresce com a pior latência do participant. |
| Complexidade operacional | Coordinator requer recovery log próprio, failover, monitoring. Grande fonte de dor. |
3PC (Three-Phase Commit) adiciona uma fase "pre-commit" pra mitigar o blocking. Ainda é vulnerável a partições de rede, quase ninguém implementa em produção. Na prática, sistemas que precisam de 2PC em 2026 são minoria absoluta (bancos legacy, mainframe). Todo o mundo greenfield foi pra Sagas.
Saga Pattern: compensações > 2PC
Saga foi proposto em 1987 (Garcia-Molina & Salem) originalmente como otimização pra transações longas em banco único. Renasceu com microserviços como o padrão dominante. A ideia central:
Saga: uma transação distribuída é uma sequência de transações locais (uma por serviço). Se alguma falha, o sistema executa compensações em ordem inversa pra desfazer efeitos já aplicados.
Em vez de locks globais, você aceita que os dados temporariamente inconsistentes e desfaz com ações compensatórias. Não é rollback — é uma nova operação que anula a anterior.
Cuidado: a Saga não é ACID. Durante a saga, outras operações podem ver estados intermediários (ex: dinheiro já debitado mas pedido cancelado 100ms depois). O termo é semantic consistency — você garante consistência eventual com regras de negócio, não isolamento estrito.
Orchestration: o coordinator explícito
Um serviço central (o orchestrator) conhece o workflow completo e comanda cada step explicitamente. É uma state machine.
Implementação com Temporal (workflow engine):
# order_saga_workflow.py — orchestration com Temporal (temporal.io)
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
@workflow.defn
class OrderSagaWorkflow:
@workflow.run
async def run(self, order: dict) -> dict:
# Passo 1: reservar estoque
reservation_id = await workflow.execute_activity(
stock_reserve, order, start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=5),
)
try:
# Passo 2: cobrar cartão
charge_id = await workflow.execute_activity(
payment_charge, order, start_to_close_timeout=timedelta(seconds=30),
)
try:
# Passo 3: agendar entrega
shipment_id = await workflow.execute_activity(
shipping_schedule, order, start_to_close_timeout=timedelta(seconds=30),
)
return {"status": "ok", "order_id": order["id"]}
except Exception as e:
# compensa: reembolsar cartão
await workflow.execute_activity(
payment_refund, charge_id,
start_to_close_timeout=timedelta(seconds=60),
retry_policy=RetryPolicy(maximum_attempts=10), # crítico, retria muito
)
raise
except Exception as e:
# compensa: liberar estoque
await workflow.execute_activity(
stock_release, reservation_id,
start_to_close_timeout=timedelta(seconds=60),
)
raisePor que Temporal/Cadence e não orchestrator próprio? Workflow engines persistem o estado após cada step automaticamente. Se o processo crashar, na próxima boot o workflow retoma do último step confirmado. Implementar isso na mão é um projeto de 6 meses. Alternativas: AWS Step Functions, Camunda Zeebe, Netflix Conductor, Uber Cadence, dbos-inc.
Choreography: eventos puros, sem orquestrador
Cada serviço publica um evento após concluir sua parte. Outros serviços escutam e reagem. Não há um coordinator central — a "lógica do workflow" está distribuída.
| Aspecto | Orchestration | Choreography |
|---|---|---|
| Acoplamento | Orchestrator conhece todos | Serviços desacoplados via eventos |
| Visibilidade do workflow | Alta (state machine explícita) | Baixa — lógica espalhada em N listeners |
| Adicionar novo step | Altera o orchestrator | Adiciona novo listener, talvez publica evento novo |
| Debug | Trace do orchestrator | Pesadelo — precisa de distributed tracing forte |
| Performance | Latência = soma dos passos | Geralmente paralelo, pode ser mais rápido |
| Risco | SPOF no orchestrator (mitigado com HA) | "Spaghetti de eventos" conforme cresce |
| Quando usa | Workflows complexos, multi-step | Fluxos simples, baixo acoplamento |
Regra prática: comece com Orchestration. É mais fácil de debugar e evoluir. Vá pra Choreography quando tem um fluxo claramente desacoplado e simples, ou quando o orchestrator virou gargalo. Muitos sistemas misturam os dois (orchestration no fluxo principal, choreography pra side effects como analytics/notificações).
Compensating actions: o que é e o que não é
Compensação ≠ rollback. É uma nova operação que anula o efeito visível da anterior, mas pode deixar rastros auditáveis.
| Ação original | Compensação adequada | Nota |
|---|---|---|
| Reservar estoque (INSERT) | Liberar reserva (DELETE ou UPDATE status=released) | Simples |
| Cobrar cartão (charge) | Reembolsar (refund) | Cria TX nova no gateway — auditável |
| Agendar entrega | Cancelar entrega + talvez notificar motorista | Se já despachou, compensação vira "recall" |
| Enviar email ao cliente | (não-compensável: enviar email corrigido) | Texto: "desconsidere a mensagem anterior" |
| Gerar nota fiscal | Emitir nota de cancelamento | Fiscalmente relevante — não apague o original |
3 regras de ouro pra compensação:
Tratando o worst case: semantic lock + pivot transaction
Alguns sagas têm um ponto de não-retorno: operações que se foram, foram. Ex: "despachar container pro navio". Padrões:
| Padrão | Uso |
|---|---|
| Semantic lock | Marca o registro como "pendente" (não-cancelável). Outras operações veem o lock e respeitam. Saga libera no final. |
| Pivot transaction | A última transação "cara" da saga. A partir dela, sem retorno. Antes dela, cancela fácil. |
| Compensable transactions | Steps que podem ser compensados sem perda (reserva, hold). |
| Retriable transactions | Steps após o pivot que precisam eventualmente acontecer (retry com backoff até sucesso). |
Outbox pattern: garantindo que eventos saem
Num saga de choreography (ou orchestration com eventos), o outbox pattern garante que a operação local e o evento são atômicos:
# order_service.py — outbox garantindo atomicidade
async def create_order(data: dict) -> dict:
async with db.begin() as tx:
# 1. Inserir pedido na tabela de domínio
order = await tx.execute(
text("INSERT INTO orders (...) VALUES (...) RETURNING *"),
data,
)
order_row = order.one()
# 2. Inserir evento na outbox — MESMA transação
await tx.execute(
text("""
INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
VALUES (:id, 'OrderCreated', :payload, NOW())
"""),
{"id": order_row.id, "payload": json.dumps(data)},
)
# commit = ambos salvos ou nenhum
return {"id": order_row.id}
# relay.py — worker separado publicando
async def relay_loop():
while True:
async with db.begin() as tx:
rows = await tx.execute(text("""
SELECT id, event_type, payload FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED
"""))
events = rows.fetchall()
for e in events:
await kafka.publish(topic="orders", key=str(e.id), value=e.payload)
await tx.execute(
text("UPDATE outbox SET sent_at = NOW() WHERE id = :id"),
{"id": e.id},
)
await asyncio.sleep(0.1)Alternativa mais escalável: CDC (Change Data Capture) com Debezium lendo o WAL do Postgres. Zero polling, latência baixa. Mas acopla pipeline à schema da tabela.
Decisões reais
📋 Workflow de checkout e-commerce com 4-5 serviços, sub-segundo
Você precisa de visibilidade (qual step falhou?), retries configuráveis por step, e evolução de workflow (adicionar passo = editar state machine). Temporal ou Step Functions te dão isso out-of-the-box. 2PC não serve porque seus serviços não são XA-compliant, e escalar travas globais mataria o throughput.
Alt: —
Alt: —
📋 Fluxo longo: onboarding de cliente B2B (dias, 10+ steps, aprovações humanas)
Workflows longos com espera por humanos, timeouts de dias, retries caso backoffice demore — Temporal foi literalmente feito pra isso. Camunda BPM é melhor se você quer visualização BPMN pra stakeholders não-técnicos.
Alt: —
Alt: —
📋 Microserviços pequenos com eventos simples, sem dependências complexas
Se seu workflow é <5 steps e você não precisa de visibilidade centralizada, choreography é mais barato e desacoplado. Cada serviço reage a eventos via consumer group. Cuidado pra não virar spaghetti conforme cresce — considere Orchestration antes do segundo cruzamento de eventos.
Alt: —
Perguntas típicas (Q&A)
Sagas substituem ACID?
Não. Sagas dão semantic consistency (eventual), não ACID strict. Durante a saga, outras queries podem ver estado intermediário inconsistente. Se a sua lógica de negócio exige isolamento estrito, Saga não é a resposta — repense o design (talvez um serviço só).
Como testar Sagas?
Testes determinísticos: Temporal tem replay de workflow. Testes de caos: force cada step a falhar em todos os pontos possíveis, verifique que compensações rodam. Falha mid-compensation também — seu orchestrator deve retomar após crash.
O que é "Saga inválida"?
Quando uma compensação falha e não pode ser resolvida (ex: cliente deletou conta antes do reembolso processar). Nesses casos, você entra em modo manual: alerta pra operador, deadletter queue, compensação humana. Design pra isso acontecer.
Múltiplas sagas ao mesmo tempo no mesmo registro — race condition?
Possível. Solução: optimistic locking (version column + CAS) no agregado. Se duas sagas tentam modificar a mesma reserva, a segunda falha e retria (ou compensa).
Kafka Transactions são 2PC disfarçado?
Kinda. Kafka EOS usa transactional producer com 2PC entre produtor e broker. É 2PC dentro do Kafka, mas não te ajuda em sagas que atravessam DB + Kafka — pra isso precisa de outbox.
Take-aways:
Próximo módulo: e se os eventos forem a fonte da verdade? Event Sourcing e CQRS.
Próximos passos sugeridos
Discussão
Carregando…