Transações Distribuídas
A fã segurou o assento A-12 e toca em Pagar R$ 450. Agora três coisas precisam acontecer juntas: o inventory muda o assento de HELD para SOLD, o payment cobra o cartão por meio de um provedor externo e o order registra um pedido confirmado. Dentro de um único banco PostgreSQL isso seria uma única transação — BEGIN … COMMIT, tudo ou nada. Mas as três partes vivem em três serviços com três bancos de dados (e uma delas é de outra empresa). O que acontece quando a cobrança dá certo e o banco do inventory está fora do ar? Ou quando o assento é marcado SOLD e a cobrança é recusada?
Uma transação distribuída é uma unidade de trabalho que altera dados em vários nós e precisa ser atômica: ou todas as mudanças acontecem, ou nenhuma acontece. Esta página mostra o protocolo clássico que garante isso — o two-phase commit (2PC) —, por que ele raramente é usado entre microsserviços e o que o substitui na prática: sagas, o padrão de reserva (try–confirm–cancel) e o outbox transacional.
Atomicidade entre nós: o problema
Em um único nó, a atomicidade vem do log do banco de dados: as mudanças de uma transação se tornam duráveis e visíveis em um único ponto, o registro de commit. Entre nós não há um log único, e cada participante pode falhar de forma independente — então um ingênuo "faça commit em todos, um depois do outro" deixa uma janela em que alguns participantes fizeram commit e outros não:
sequenceDiagram
participant O as order (checkout)
participant I as banco do inventory
participant P as provedor de pagamento
O->>I: UPDATE do assento A-12 para SOLD, depois COMMIT
I-->>O: commit feito
O->>P: cobrar R$ 450
P-->>O: recusado (limite do cartão)
Note over O,P: o assento está SOLD mas ninguém pagou:<br/>nenhum lugar único consegue desfazer os dois Existem duas famílias de soluções:
| Abordagem | Ideia | Isolamento | Disponibilidade |
|---|---|---|---|
| Commit atômico (2PC) | Todos se preparam, depois um coordenador decide commit/abort para todos | ACID completo entre os participantes | Todos os participantes e o coordenador precisam estar no ar ao mesmo tempo |
| Sagas e compensações | Uma sequência de transações locais; em caso de falha, execute transações de compensação que desfazem os passos anteriores | Nenhum entre os passos (estados intermediários ficam visíveis) | Cada passo só precisa do próprio serviço |
Two-phase commit (2PC)
O 2PC introduz um coordenador (muitas vezes o serviço ou o nó de banco que iniciou a transação) e divide o commit em duas fases1:
- Prepare (votação): o coordenador pergunta a cada participante "você consegue fazer commit?". Um participante que responde sim precisa antes gravar de forma durável em disco todas as mudanças e um registro de prepared, e manter os locks: a partir desse momento ele prometeu fazer commit se for pedido — não pode mais abortar por conta própria.
- Commit (decisão): se todos votaram sim, o coordenador grava commit no próprio log e manda todos fazerem commit; se alguém votou não (ou estourou o timeout), ele decide abort.
sequenceDiagram
participant C as coordenador
participant I as banco do inventory
participant R as banco do order
C->>I: PREPARE (assento A-12 → SOLD)
C->>R: PREPARE (pedido 981 → CONFIRMED)
I-->>C: YES (mudanças + registro prepared em disco, locks mantidos)
R-->>C: YES
Note over C: todos YES: grava COMMIT no log do coordenador
C->>I: COMMIT
C->>R: COMMIT
I-->>C: feito (locks liberados)
R-->>C: feito O PostgreSQL expõe o lado do participante como PREPARE TRANSACTION 'id' seguido, mais tarde, de COMMIT PREPARED 'id' ou ROLLBACK PREPARED 'id'; o padrão X/Open XA define o mesmo protocolo para bancos de dados e message brokers coordenados por um gerenciador de transações (como no Jakarta EE).
O problema do bloqueio
O ponto fraco é o momento depois que um participante votou sim e antes de ele ouvir a decisão. Se o coordenador cai ali, o participante fica em dúvida: não pode fazer commit (talvez alguém tenha votado não) e não pode abortar (talvez o coordenador tenha decidido commit e outros já tenham feito commit). Ele precisa esperar, segurando os locks, até o coordenador se recuperar:
sequenceDiagram
participant C as coordenador
participant I as banco do inventory
participant R as banco do order
C->>I: PREPARE
C->>R: PREPARE
I-->>C: YES (locks no assento A-12 mantidos)
R-->>C: YES
Note over C: cai antes de enviar a decisão
Note over I: em dúvida: não pode fazer commit, não pode abortar
Note over I,R: o assento A-12 fica travado até<br/>o coordenador voltar Você mesmo pode provocar isso no simulador em Sagas: escolha 2PC e queda do coordenador.
Durante a abertura das vendas às 10:00, a linha travada de um assento bloqueia todos os outros fãs que tentam segurá-lo, e um coordenador que caiu pode congelar um bloco inteiro de assentos. O three-phase commit (3PC) tenta eliminar o bloqueio, mas pressupõe atrasos de rede limitados, que redes reais não garantem; sistemas em produção, em vez disso, tornam o próprio coordenador tolerante a falhas replicando sua decisão com consenso — exatamente o que fazem o Google Spanner (2PC sobre grupos Paxos) e o CockroachDB (sobre Raft)2.
Quanto o 2PC custa
- Latência: duas idas e voltas a cada participante, mais gravações forçadas em disco (fsync) em cada prepare e na decisão. Os locks ficam presos durante todo o processo — o participante mais lento decide por quanto tempo.
- Disponibilidade: o coordenador e todos os participantes precisam estar no ar ao mesmo tempo. As disponibilidades se multiplicam, como serviços em série:
- Acoplamento: todo participante precisa falar o mesmo protocolo e expor um estado "prepared". Um provedor de pagamento externo nunca vai entrar no seu 2PC: a API dele cobra ou recusa, sem um passo de "prepare e espere minha decisão".
Onde o 2PC continua vivo e bem
O 2PC é o padrão dentro de um banco de dados ou broker distribuído, onde um único fornecedor controla todos os participantes e o coordenador é replicado: Spanner, CockroachDB, YugabyteDB, TiDB e as transações do Kafka (escritas atômicas em várias partições mais os offsets dos consumidores). O que se desaconselha é o 2PC entre microsserviços implantados de forma independente e terceiros.
Pense antes: inventory e order compartilham o mesmo cluster PostgreSQL, mas usam schemas diferentes. Você precisa de 2PC entre eles?
Não — se as duas tabelas estão no mesmo banco de dados, uma única transação local cobre as duas, e isso é muito mais simples e rápido do que qualquer protocolo distribuído. Separar os dados em bancos diferentes é uma decisão com preço; quando dois dados precisam mudar atomicamente o tempo todo, é um forte indício de que pertencem ao mesmo serviço ou pelo menos ao mesmo banco de dados (veja Compute — serviços).
Sagas
Uma saga (Garcia-Molina e Salem, 1987)3 divide uma transação longa em uma sequência de transações locais \(T_1, T_2, …, T_n\), cada uma com commit no banco de dados do próprio serviço. Se o passo \(T_k\) falha, a saga executa transações de compensação \(C_{k-1}, …, C_1\) que desfazem semanticamente os passos já realizados. Não há lock global nem ponto de commit global.
O checkout do BoxOffice como uma saga:
| Passo | Transação local (serviço) | Compensação |
|---|---|---|
| 1 | Criar o pedido PENDING (order) | Marcar o pedido CANCELLED |
| 2 | Confirmar a reserva: assento HELD → RESERVED_FOR_PAYMENT (inventory) | Liberar o assento → FREE |
| 3 | Cobrar o cartão (payment → provedor) | Estornar a cobrança |
| 4 | Assento → SOLD, pedido → CONFIRMED, emitir o ingresso | (último passo: sem compensação; precisa dar certo — repetido até dar) |
Experimente
Dê play na execução padrão, cobrança recusada: T1 e T2 fazem commit, T3 falha, e o orquestrador executa C2 (libera A-12) e depois C1 (cancela o pedido 981) — o log da saga as lista na ordem inversa dos passos que desfazem, e o tempo segurando locks fica em 0 ms. Escolha timeout na cobrança: o cartão foi cobrado, mas a resposta se perdeu, então o orquestrador acrescenta C3, um estorno com a mesma chave de idempotência, antes de liberar o assento. Escolha ticket fora: a falha vem depois do pivô (a cobrança bem-sucedida), então nada é compensado — o passo do ingresso falha duas vezes, dá certo na terceira tentativa, e o pedido termina CONFIRMED. Depois mude para 2PC: sem falha, a linha do assento fica travada por 145 ms; com queda do coordenador, todos os participantes votaram YES e esperam em dúvida — o tempo de lock cresce um segundo por passo e os fãs fazem fila atrás do assento A-12 até você reiniciar o coordenador, que não encontra decisão no seu log e aborta.
Duas formas de coordenar os passos:
Um orquestrador central (aqui, o serviço order ou um motor de workflow como Temporal ou AWS Step Functions) diz a cada participante o que fazer e decide quando compensar. A lógica da saga fica em um só lugar, fácil de ler, monitorar e mudar; o orquestrador precisa persistir seu estado para sobreviver a reinícios. Preferível quando há mais do que alguns poucos passos.
Sem cérebro central: cada serviço reage a eventos dos outros (OrderCreated → inventory reserva → SeatReserved → payment cobra → PaymentFailed → inventory libera…). Pouco acoplada e simples para dois ou três passos; com mais, o fluxo fica espalhado por muitos serviços e difícil de acompanhar ("quem compensa o quê?"). Tratada com Kafka em Mensageria — padrões.
Sagas abrem mão do isolamento
Entre os passos, outras transações veem estados intermediários: um pedido PENDING, um assento RESERVED_FOR_PAYMENT. Sagas são ACD sem o I. Projete contramedidas explicitamente4:
- Locks semânticos: o estado intermediário é o lock — enquanto um assento está
RESERVED_FOR_PAYMENT, nenhum outro fã consegue segurá-lo, e a reserva expira depois de 10 minutos se a saga nunca terminar. - Passos comutativos e idempotentes: todo passo e toda compensação podem ser repetidos (at-least-once), então cada um precisa de uma chave de idempotência (Idempotência).
- Ordene os passos por risco: faça primeiro os passos baratos, invisíveis e fáceis de reverter, e por último os difíceis ou impossíveis de compensar (uma cobrança, um e-mail), de modo que uma falha no fim da saga tenha pouco a desfazer. Um estorno é uma compensação que o fã vê; liberar um assento é invisível.
- Passo pivô: o passo depois do qual a saga só pode seguir em frente (aqui, a cobrança bem-sucedida) — depois dele, os passos restantes precisam ser repetidos até darem certo, nunca compensados.
No código do BoxOffice: um passo da saga em torno da cobrança (lab 9)
Os labs constroem uma versão encurtada desta saga no order: o lab 7 cria o pedido PENDING_PAYMENT, e o Hands-on 9 — Padrões acrescenta o passo de pagamento. Observe três escolhas. A cobrança acontece fora de qualquer transação de banco (uma conexão nunca fica presa enquanto se espera o provedor). O resultado é aplicado com um UPDATE … WHERE status = 'PENDING_PAYMENT' condicional, então duas chamadas pay concorrentes — ambas deduplicadas pelo payment por meio da mesma chave de idempotência — mudam o status uma vez e emitem um evento. E o resultado (OrderConfirmed ou OrderCancelled, o gatilho da compensação) sai pelo outbox, na mesma transação local que a mudança de status.
/** No @Transactional here: never hold a DB connection during a remote call. */
public OrderView pay(UUID id) {
OrderView order = find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!"PENDING_PAYMENT".equals(order.status())) {
return order; // already paid or cancelled: paying twice is a no-op
}
PaymentResponse payment = resilience
? payments.charge(order.id(), order.amount())
: payments.chargeUnprotected(order.id(), order.amount());
String next = "AUTHORIZED".equals(payment.status()) ? "CONFIRMED" : "CANCELLED";
tx.executeWithoutResult(status -> {
int changed = jdbc.sql("UPDATE orders SET status = ? WHERE id = ? AND status = 'PENDING_PAYMENT'")
.params(next, id)
.update();
if (changed == 1) {
String type = "CONFIRMED".equals(next) ? "OrderConfirmed" : "OrderCancelled";
OrderEvent event = new OrderEvent(UUID.randomUUID(), type, order.id(), order.showId(),
order.holdId(), order.customerEmail(), order.amount(), Instant.now());
outbox.append(event.eventId(), "order", order.id(), type, event);
}
});
return find(id).orElseThrow();
}
Reserva: try, confirm, cancel
O padrão TCC (try–confirm–cancel) faz cada participante oferecer uma operação provisória com um timeout. O BoxOffice já funciona assim: o hold é o try (o assento fica reservado por 10 minutos), a venda é o confirm, e a expiração ou liberação é o cancel. Provedores de pagamento oferecem o mesmo formato: autorizar (reservar o valor) → capturar (efetivar a cobrança) ou cancelar (void, liberar o valor). Como todo estado provisório expira sozinho, uma queda em qualquer lugar deixa o sistema convergindo para um estado seguro em vez de bloquear.
O problema da escrita dupla e o outbox
Todo passo de uma saga faz duas coisas: altera o próprio banco de dados e avisa o próximo passo (envia um evento ou um comando). Fazer isso como duas escritas separadas é o problema da escrita dupla (dual write) — se o serviço cai entre elas, o banco diz "assento reservado", mas nenhum evento é enviado, e a saga trava:
sequenceDiagram
participant I as inventory
participant DB as banco do inventory
participant K as Kafka
I->>DB: UPDATE do assento + INSERT INTO outbox (SeatReserved), um COMMIT
Note over I,DB: uma transação local: as duas ou nenhuma
DB-->>I: commit feito
Note over DB,K: um relay (poller ou CDC como o Debezium)<br/>lê a tabela de outbox
DB->>K: publica SeatReserved
K-->>DB: ack: marca a linha do outbox como enviada O outbox transacional grava o evento em uma tabela outbox na mesma transação local que a mudança de negócio; um relay separado publica as linhas do outbox no broker e as marca como enviadas. A publicação é at-least-once, então os consumidores precisam ser idempotentes. Os detalhes (polling vs change data capture, ordenação, limpeza) estão em Mensageria — padrões e no Hands-on 7.
No código do BoxOffice: pedido + outbox em uma transação (lab 7)
O Hands-on 7 — Outbox grava o pedido e seu evento OrderPlaced em um método @Transactional; Outbox.append é declarado Propagation.MANDATORY, então falha se alguém o chamar fora da transação de negócio. O mesmo método é idempotente: ON CONFLICT (hold_id) DO NOTHING transforma uma requisição repetida para o mesmo hold em um replay que devolve o pedido existente e não acrescenta um segundo evento. O lab prova a garantia parando o Kafka: o pedido foi aceito enquanto o broker estava fora, e seu evento foi publicado 71 s depois, quando o Kafka voltou — o fã nunca viu a falha.
@Transactional
public PlaceResult place(OrderRequest req) {
int inserted = jdbc.sql("""
INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
ON CONFLICT (hold_id) DO NOTHING
""")
.params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
.update();
OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
.param(req.holdId())
.query(OrderView.class)
.single();
if (inserted == 0) {
// same hold already ordered: idempotent replay, no second event
return new PlaceResult(order, false);
}
OrderEvent event = OrderEvent.placed(order);
if (props.notifyMode() == NotifyMode.SYNC) {
notifications.send(event); // BEFORE: the fan waits for the e-mail
} else {
outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
}
return new PlaceResult(order, true);
}
Escolhendo
| Transação local | 2PC / banco distribuído | Saga (orquestrada) | Saga (coreografada) | TCC / reserva | |
|---|---|---|---|---|---|
| Atomicidade | Sim | Sim | Eventual, via compensação | Eventual, via compensação | Eventual, via confirm/cancel |
| Isolamento | Sim | Sim | Não (locks semânticos) | Não | Parcial (a reserva) |
| Disponibilidade | Um banco | Todos os participantes ao mesmo tempo | Cada passo de forma independente | Cada passo de forma independente | Cada passo de forma independente |
| Latência | A menor | 2 idas e voltas + fsyncs, locks mantidos | Soma dos passos, pode ser assíncrona | Assíncrona | Try agora, confirm depois |
| Funciona com terceiros | — | Não | Sim | Sim | Sim, se oferecerem reservar/capturar |
| Complexidade | A menor | No banco de dados (se ele oferecer) | Estado do orquestrador, compensações | Fluxo de eventos espalhado pelos serviços | Lógica de timeouts e expiração |
| Uso no BoxOffice | Segurar um assento; assento + linha do outbox | Dentro do CockroachDB/Spanner, se usados | Checkout | Notificações, analytics | Hold do assento; autorizar/capturar o cartão |
BoxOffice: o checkout, de ponta a ponta
- Hold (o try do TCC): o
inventoryexecuta uma transação local —UPDATEcondicional do assento paraHELDcomexpires_at, mais uma linhaSeatHeldno outbox. - Saga do checkout, orquestrada pelo
order: cria o pedidoPENDING→ oinventorymove o assento paraRESERVED_FOR_PAYMENT→ opaymentautoriza o cartão com uma chave de idempotência → oinventorymarca o assentoSOLD→ opaymentcaptura → pedidoCONFIRMED→ onotificationenvia o ingresso. - Falhas: cartão recusado → libera o assento, cancela o pedido. Timeout ao falar com o provedor → consulta o provedor pela chave de idempotência antes de decidir (a cobrança pode ter acontecido — veja Lento ou morto?). Queda do orquestrador → ele retoma do seu estado persistido. Hold abandonado → expira depois de 10 minutos e o sweeper libera o assento.
- Nenhum 2PC entre serviços — o provedor externo não poderia participar de um, de qualquer forma.
Na entrevista
Quando um design mexe "atomicamente" nos dados de vários serviços, diga: "Não vou usar 2PC entre serviços — ele bloqueia se o coordenador falha, acopla a disponibilidade de todos os participantes, e o provedor de pagamento não consegue participar. Vou usar uma saga orquestrada com compensações, passos idempotentes, um lock semântico no assento e um outbox transacional para publicar o evento de cada passo." Depois nomeie o passo pivô e o que acontece em um timeout com o provedor. Se os dados podem viver em um único banco, diga isso — a melhor transação distribuída é a que você evita.
Boas práticas
- Mantenha chamadas remotas fora das transações de banco — uma conexão presa enquanto o provedor leva 3 s é um pool de 10 esgotado por 4 pedidos por segundo. No BoxOffice:
OrderService.paycobra primeiro e depois abre uma transação curta (lab 9, quadro acima). - Modele cada saga como estados explícitos com transições condicionais —
UPDATE … WHERE status = 'PENDING_PAYMENT'torna todo passo seguro de repetir e todo salto ilegal impossível. - Torne impossível chamar o outbox fora da transação —
@Transactional(propagation = MANDATORY)emOutbox.appendtransforma uma transação esquecida em um erro no primeiro teste (lab 7). - Dê a todo estado provisório uma expiração e um sweeper — um hold que ninguém confirma se libera sozinho. No BoxOffice: holds de 10 minutos e um sweeper a cada 5 s no lab 6.
- Alerte sobre sagas presas além do timeout e concilie com o provedor diariamente — um pedido
PENDING_PAYMENThá uma hora, ou uma cobrança sem pedido, é um bug ou uma mensagem perdida; encontre-o antes do fã.
Armadilhas
- Escritas duplas (banco, depois broker) sem outbox: uma queda entre elas trava a saga silenciosamente.
- Passos ou compensações não idempotentes: retries cobram ou estornam em dobro.
- Esquecer que compensações podem falhar: elas precisam ser repetidas até darem certo, e gerar alerta quando não derem.
- Tratar sagas como isoladas: outro fã vê
RESERVED_FOR_PAYMENT; projete para isso (locks semânticos, expiração). - 2PC com coordenador não replicado: uma queda do coordenador deixa os participantes em dúvida com locks mantidos.
Como se conecta
flowchart LR
IDEM["chaves de idempotência<br/>(visão geral)"] --> SAGA["passos da saga<br/>repetidos com segurança"]
OUT["outbox transacional"] --> SAGA
SAGA --> ORCH["orquestração<br/>(motor de workflow)"]
SAGA --> CHOR["coreografia<br/>(eventos no Kafka)"]
TCC["reservas: hold, autorizar"] --> SAGA
TPC["2PC"] --> BLOCK["bloqueia se o<br/>coordenador falha"]
BLOCK --> CONS["replicar o coordenador<br/>→ Consenso"]
CONS --> NEWSQL["dentro do Spanner,<br/>CockroachDB, transações do Kafka"] Componentes da paleta envolvidos: stream / kafka (eventos da saga, relay do outbox) · queue (comandos aos participantes) · payment (autorizar / capturar / cancelar) · sql (transações locais + tabela de outbox) · worker (orquestrador, relay). Experimente os cenários payment-system e flash-sale no playground. Continua em: Mensageria — padrões, Compute — integrações, Estudo de caso: venda de ingressos.
Pontos-chave
- Uma transação distribuída precisa ser atômica entre nós; não existe um log único que garanta isso.
- O 2PC (prepare, depois commit) dá ACID entre participantes, mas bloqueia quando o coordenador falha, segura locks por duas idas e voltas e precisa de todos os participantes no ar ao mesmo tempo — use-o dentro de bancos de dados distribuídos, não entre microsserviços.
- Sagas encadeiam transações locais com compensações; trocam isolamento por disponibilidade — use locks semânticos, passos idempotentes e um passo pivô.
- TCC / reservas (hold → venda / expiração; autorizar → capturar / cancelar) fazem os estados provisórios expirarem com segurança.
- O outbox transacional resolve o problema da escrita dupla entre um banco de dados e um broker.
-
GRAY, J.; LAMPORT, L. Consensus on Transaction Commit. ACM TODS, 2006. ↩
-
CORBETT, J. C. et al. Spanner: Google's Globally-Distributed Database. OSDI, 2012. ↩
-
RICHARDSON, C. Pattern: Saga e Microservices Patterns. Manning, 2018. ↩