Conexão · Interrompida

Algo não carregou

Parte desta página não chegou até você. Recarregue para tentar novamente — se persistir, verifique sua conexão.

Pular para o conteúdo principal
Distributed Systems9 min de leitura

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.

Todos os Posts
2/4

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.

kotlin
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 PREPARED e nunca chegou a COMMIT, 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

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.