Two-Phase Commit na JVM: O Problema de Bloqueio Que Ninguém Coloca no Diagrama
Eu derrubei de propósito um coordenador de Two-Phase Commit em uma pequena simulação Kotlin para medir por quanto tempo os participantes ficam travados quando o coordenador desaparece entre as fases. O resultado é a parte do 2PC que os diagramas nunca mostram — e a razão pela qual eu modelaria a maior parte das escritas cross-service como uma saga em vez disso.
Os diagramas que continuo vendo para Two-Phase Commit mostram duas fases limpas. Prepare, vote, commit. Três ou quatro caixas, algumas setas, problema resolvido. O que os diagramas nunca mostram é o que acontece no pior momento possível: o coordenador desaparecendo no intervalo entre a fase um e a fase dois. Nesse momento os participantes já travaram linhas, escreveram registros de preparado em seus logs, e prometeram comitar se solicitado. Eles não podem abortar. Não podem comitar. Ficam sentados esperando.
A entrada de Two-Phase Commit de Unmesh Joshi no catálogo Patterns of Distributed Systems enuncia a regra com clareza: cada participante usa durabilidade no estilo Write-Ahead Log para que, depois de um crash e um restart, ainda possa completar o protocolo. Essa frase parece uma nota de rodapé. É o jogo inteiro. O custo do 2PC não é o round trip extra — é a duração dessa espera, e o fato de que ninguém no diagrama é responsável por encerrá-la.
Eu queria colocar um número nessa espera em vez de gesticular a respeito dela, então construí uma simulação deliberadamente pequena em Kotlin e derrubei o coordenador de propósito.
O que eu realmente queria medir
Duas perguntas, ambas sobre o estado de preparado:
- Uma vez que um participante votou sim, por quanto tempo ele fica segurando seus locks se ninguém nunca lhe disser o desfecho?
- Quando o coordenador de fato volta, o que seu log de fato lhe permite recuperar?
Essas não são perguntas de benchmark. São perguntas de correção vestidas como perguntas de timing. A resposta para a primeira é "enquanto o coordenador ficar fora do ar". A resposta para a segunda é "menos do que eu esperava". As duas respostas são mais fáceis de sentir depois de rodar a falha do que depois de ler sobre ela.
A simulação
Um único arquivo Kotlin, dois participantes, um coordenador, todos escrevendo em um Write-Ahead Log em disco. O coordenador tem uma flag que o faz lançar exceção entre a fase um e a fase dois. Nada mais.
import java.io.File
import java.time.Instant
private val walDir = File("./wal").apply { mkdirs() }
class Participant(private val name: String) {
private val log = File(walDir, "$name.wal")
private var lockedSince: Instant? = null
fun prepare(txn: String): Boolean {
Thread.sleep(20) // pretend to do local work
lockedSince = Instant.now()
log.appendText("PREPARED $txn at $lockedSince\n")
println("[$name] prepared $txn — locks held")
return true
}
fun commit(txn: String) {
log.appendText("COMMITTED $txn at ${Instant.now()}\n")
lockedSince = null
println("[$name] committed $txn")
}
fun lockHeldMs(): Long? =
lockedSince?.let { Instant.now().toEpochMilli() - it.toEpochMilli() }
}
class Coordinator(
private val participants: List<Participant>,
var crashAfterPrepare: Boolean = false,
) {
private val log = File(walDir, "coordinator.wal")
fun run(txn: String) {
log.appendText("BEGIN $txn\n")
val votes = participants.map { it.prepare(txn) }
log.appendText("PREPARED $txn\n")
if (crashAfterPrepare) {
println(">>> coordinator crashed before commit phase <<<")
throw RuntimeException("simulated crash")
}
log.appendText("COMMIT $txn\n")
if (votes.all { it }) participants.forEach { it.commit(txn) }
}
}
fun main() {
val p1 = Participant("user-svc")
val p2 = Participant("billing-svc")
val coordinator = Coordinator(listOf(p1, p2), crashAfterPrepare = true)
runCatching { coordinator.run("txn-42") }
.onFailure { println("coordinator dead: ${it.message}") }
repeat(5) {
Thread.sleep(1000)
println("after ${it + 1}s — user-svc locked ${p1.lockHeldMs()}ms, billing-svc locked ${p2.lockHeldMs()}ms")
}
println("\nrecovery would now read coordinator.wal — PREPARED found, no COMMIT.")
println("participants must wait until the coordinator restarts or a human resolves.")
}Rode com:
kotlinc TwoPhaseCommitDemo.kt -include-runtime -d demo.jar && java -jar demo.jar
As partes interessantes não são o I/O. É a ordem: escreva PREPARED em disco antes de se declarar preparado, e escreva a linha PREPARED de cada participante antes da linha COMMIT do coordenador. Essa ordenação é o que torna a recuperação possível em primeiro lugar. Sem ela, uma queda de energia entre votar e escrever deixaria um participante preparado-em-memória, não-decidido-em-disco — exatamente a inconsistência que o WAL deveria prevenir.
O throw deliberado vive entre as escritas de preparado dos participantes e a escrita de commit do coordenador. Essa é a única janela que me importava. Crashes do coordenador em outros lugares são recuperáveis: antes do prepare, a transação nunca aconteceu; depois do commit, a recuperação lê o commit e termina o trabalho. Entre os dois, os dois lados escreveram o suficiente para estarem travados e não o suficiente para se mover.
O que o timing de fato mostra
O diagrama abaixo é a imagem que eu gostaria que acompanhasse toda explicação de 2PC — a janela de retenção de locks se abrindo no vote-yes e nunca se fechando por conta própria.
Rode o arquivo e o estado de preparado fica. O contador lockHeldMs() incrementa para sempre, porque nada na simulação vai setar lockedSince de volta para null. Os participantes não estão quebrados. Eles estão fazendo a coisa certa — recusando abortar uma transação que prometeram comitar, recusando comitar uma transação que ninguém confirmou.
Em um sistema real a espera é limitada por aquilo que roda o coordenador. Se o processo do coordenador reinicia em segundos, a janela de lock é em segundos. Se exige que um humano repare uma transação travada na view DBA_2PC_PENDING do Oracle e decida seu destino, a janela é de horas. A documentação de transações distribuídas do Oracle descreve exatamente esse caso e entrega um processo de recuperação chamado RECO cujo trabalho inteiro é caçar esses órfãos.
Essa é a forma do custo: não um overhead constante adicionado a um número de latência do caminho feliz, mas uma cauda longa que depende inteiramente do mean-time-to-recover operacional do coordenador.
O que a recuperação de fato restaura
A próxima parte me surpreendeu quando investiguei cuidadosamente. O WAL de um participante só diz ao participante o que ele prometeu. Não diz ao participante o que foi decidido. A decisão vive no log do coordenador.
Então quando o coordenador reinicia e lê seu WAL, ele pode fazer uma de duas coisas:
- Se encontra um registro
COMMIT, ele diz aos participantes para comitar, e a recuperação tem sucesso. - Se encontra apenas um registro
PREPAREDe nunca chegou aCOMMIT, ele tem que pedir aos participantes para abortar — mas os participantes, do seu lado, só sabem que votaram sim.
A reconciliação depende do coordenador sobreviver com seu WAL intacto. Isso é um ponto único de falha fantasiado de passo de protocolo. A literatura sabe disso desde o final dos anos 1980, e o artigo de Pat Helland na ACM Queue "Life beyond Distributed Transactions: an Apostate's Opinion" argumentou sem rodeios que transações distribuídas entre serviços deveriam ser substituídas por mensageria e trabalho idempotente em nível de aplicação — não porque 2PC é errado, mas porque o coordenador é importante demais e frágil demais para a forma como serviços são de fato implantados.
Em um único banco de dados com um gerenciador de transações forte — Oracle, transações preparadas do PostgreSQL, um resource manager XA — a história de durabilidade do coordenador é sólida. O suporte a JTA do Spring Boot — historicamente apoiado por gerenciadores embarcados como Atomikos ou Narayana, e no Spring Boot 4 se apoiando em um transaction manager JTA exposto via JNDI — escreve seu log de coordenador em um local conhecido junto à aplicação. O contrato se mantém porque o coordenador e os recursos estão fortemente colocalizados e são operacionalmente do mesmo grupo.
Em um sistema distribuído abrangendo dois serviços com runtimes separados, deploys separados e rotações de on-call separadas, esse contrato começa a vazar. O coordenador é um processo de um grupo; as linhas travadas vivem em bancos de dados de outros grupos. Quando o processo do coordenador se foi, alguém tem que entrar no banco certo e resolver a transação em dúvida à mão. Esse é acoplamento operacional que o diagrama nunca mostra.
Quando a janela de bloqueio está bem, e quando sagas vencem
A simulação é pequena o suficiente para ser honesta sobre ela. Dois participantes. Disco local. Sem partição de rede. A janela de lock só é ilimitada porque eu escolhi não reiniciar o coordenador. Em uma transação colocalizada entre duas instâncias de banco de dados operadas pelo mesmo operador, com um RTO apertado no processo do coordenador, a janela é curta e o protocolo paga seu custo.
Entre serviços, o cálculo se inverte. Uma saga — uma cadeia de transações locais, cada uma seguida por uma ação compensatória se um passo posterior falhar — nunca segura um lock global. O custo é real: cada passo comita isoladamente, estados intermediários são visíveis para outros leitores, e compensações têm que ser projetadas e testadas em vez de entregues a um gerenciador de transações. Mas o modo de falha é aquele que a maioria dos operadores já sabe tratar. Se uma compensação falha, alerte e retente, e outras transações continuam andando enquanto isso. Não há estado em dúvida, nem lock órfão, nem linha travada esperando por um humano.
Para o cenário que motivou este estudo — um serviço Spring escrevendo em um serviço de usuário e um serviço de billing — eu não usaria 2PC. A janela de bloqueio que acabei de medir é a razão. Ela é limitada apenas pela rapidez com que o coordenador volta, e "rapidez" está fazendo muito trabalho nessa frase.
Takeaways
- O custo real do 2PC é o tempo que os participantes passam travados enquanto o coordenador está ausente. Meça essa janela antes de adotar o protocolo.
- O Write-Ahead Log em cada participante torna a recuperação possível, mas o log do coordenador faz a recuperação decidir. Perca esse log e o protocolo não consegue resolver.
- Para dois bancos de dados sob o mesmo operador, com um gerenciador de transações forte e um tempo curto de restart do coordenador, 2PC é um default razoável.
- Para dois serviços com runtimes independentes e rotações de on-call independentes, modele o trabalho como uma saga e aceite consistência eventual. A janela de bloqueio do 2PC é um modo de falha pior do que a janela de visibilidade de uma saga.
Use 2PC quando
- Todos os recursos participantes são colocalizados e operacionalmente do mesmo time.
- A transação é curta e a contenção sobre as linhas travadas é baixa.
- O tempo de restart do coordenador é limitado e bem praticado.
Evite 2PC quando
- Participantes vivem em serviços diferentes com ciclos de deploy independentes.
- O runtime do coordenador não é tão bem protegido quanto os dados que ele trava.
- Compensações mais longas e visibilidade de estados intermediários são aceitáveis em troca de liveness.
Fontes em que me apoiei
- Unmesh Joshi, Two-Phase Commit — martinfowler.com/articles/patterns-of-distributed-systems/two-phase-commit.html
- Pat Helland, Life beyond Distributed Transactions: an Apostate's Opinion — queue.acm.org/detail.cfm?id=3025012
- Spring Boot Reference, Distributed Transactions with JTA — docs.spring.io/spring-boot/reference/io/jta.html
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Actor-per-Entity vs Bloqueio Otimista no Postgres: Um Comparativo em Reserva de Assentos
Executei a mesma carga de trabalho de reserva de assentos com hot key de duas formas: Postgres com coluna de versão e retries, e um único actor por assento. O design com actor não escalou melhor — ele moveu o problema difícil do controle de concorrência para a corretude de roteamento e rebalanceamento, e essa troca foi a mais fácil de raciocinar sob hot keys.
Execução Durável Não É Sobre Agentes — É Sobre Workflows de Backend com Replay
Cheguei aos runtimes de execução durável pela hype dos agentes, mas a restrição que surpreende todo mundo é o determinismo no replay. Estas são minhas anotações trabalhando uma reconciliação de pagamentos de seis passos como um workflow do Restate em TypeScript — a linha que quebrou o replay, o modelo mental que consertou, e os trade-offs que vêm com o padrão.
Idempotência É um Protocolo, Não uma Chave
Na primeira vez em que entreguei idempotência como um header UUID e uma consulta no Redis, uma cobrança duplicada escapou uma semana depois. Estas são minhas notas sobre tratar idempotência como um protocolo de quatro partes — deduplicação, determinismo, segurança concorrente, propagação downstream — com uma implementação mínima em Kotlin mais Postgres que se mantém firme sob retry.