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

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.

Todos os Posts
2/4

Quando vejo "não pode haver double-booking", meu reflexo é o mesmo que a maioria dos engenheiros de backend tem: uma transação, uma coluna de versão, retries com jitter. É a coisa correta mais barata em uma stack que eu já rodo. Eu queria sentir a alternativa de ponta a ponta antes de cair no padrão novamente, então construí a mesma carga de trabalho de reserva de assentos duas vezes e empurrei contenção até que ambos os designs gritassem. Os achados não bateram com minha intuição.

A carga de trabalho contra a qual fixei ambos os designs

200 compradores concorrentes disputando 50 assentos. Cada comprador dispara uma única requisição de reserva contra um assento escolhido aleatoriamente. A cauda quente é brutal: 80% das requisições caem em 10 desses assentos. O invariante é de uma única linha — um assento tem no máximo um detentor — mas o formato da contenção faz essa linha carregar todo o peso.

Rodei ambos os designs em um laptop com Postgres 18 local, um pool de conexões fixo de 32, e Kotlin 1.9 em uma única JVM. Não estou medindo throughput distribuído. Estou medindo como cada design se comporta quando 200 chamadores brigam pela mesma linha.

Design A: Postgres com uma coluna de versão

O padrão da coluna de versão é bem trilhado. Leia a linha do assento, verifique holder IS NULL contra a version atual, escreva o detentor e incremente a versão, faça retry em falhas de serialização 40001. A documentação do PostgreSQL 18 é explícita sobre o contrato: aplicações rodando em Repeatable Read ou Serializable devem estar preparadas para fazer retry em SQLSTATE 40001, e o banco de dados não oferece um retry automático "uma vez que não pode fazê-lo com qualquer garantia de corretude". A mesma página também alerta que, sob contenção muito alta, concluir uma única transação pode exigir muitas tentativas antes que uma vença.

Sob baixa contenção, isso funciona bem. Sob a carga de trabalho de cauda quente acima, duas coisas acontecem.

Primeiro, o orçamento de retry é consumido. A maioria das reservas precisa de duas ou três tentativas; os assentos mais quentes veem retries de dois dígitos antes que qualquer transação única vença. O bloqueio otimista degrada quando os conflitos são frequentes porque os retries multiplicam o trabalho sem fazer progresso — o anti-padrão de longa data documentado para ciclos read-modify-write no Postgres.

Segundo, o trabalho amplifica upstream. Cada retry queima uma conexão do pool, a segura por um round-trip de rede, e compete com outros retries pela mesma linha. O pool enche, a latência sobe, e alguns compradores azarados esgotam meu limite de retry e devolvem um 409 ao chamador. Em minhas execuções descartáveis, o p99 esticou para as centenas de milissegundos bem antes de o throughput estabilizar.

O invariante de corretude se mantém — esse é o ponto inteiro da coluna de versão. Mas o custo de mantê-lo escala com a taxa de conflito, não com o tráfego, e uma linha de assento sob uma cauda quente é puro conflito.

Design B: um actor por assento

O esboço de actor-per-entity é o que o Microsoft Orleans chama de virtual actor e o que o Akka Cluster Sharding chama de sharded entity. Um único actor em memória possui o assento. Toda requisição de reserva chega na sua mailbox. O actor as processa uma de cada vez, em uma única thread, e o conflito simplesmente não existe — o segundo comprador para o mesmo assento lê "já tomado" porque o primeiro já mutou o estado quando a mensagem dois é desenfileirada. Orleans documenta isso como uma garantia de execução de single-activation, single-threaded sob condições sem falha.

Nas minhas anotações, a versão local dessa ideia é apenas uma corrotina Kotlin com um channel. Sem cluster, sem persistência, mas o invariante é o mesmo: a ordem da mailbox colapsa o controle de concorrência em mutação local comum.

kotlin
#!/usr/bin/env kotlin
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0")

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

sealed interface SeatCmd {
    data class Reserve(
        val buyer: String,
        val reply: CompletableDeferred<Boolean>
    ) : SeatCmd
}

fun CoroutineScope.seatActor(): Channel<SeatCmd> {
    val mailbox = Channel<SeatCmd>(Channel.UNLIMITED)
    launch {
        var holder: String? = null
        for (msg in mailbox) when (msg) {
            is SeatCmd.Reserve -> {
                if (holder == null) {
                    holder = msg.buyer
                    msg.reply.complete(true)
                } else {
                    msg.reply.complete(false)
                }
            }
        }
    }
    return mailbox
}

runBlocking {
    val seat = seatActor()
    val results = (1..200).map { i ->
        async(Dispatchers.Default) {
            val reply = CompletableDeferred<Boolean>()
            seat.send(SeatCmd.Reserve("buyer-$i", reply))
            reply.await()
        }
    }
    val winners = results.awaitAll().count { it }
    check(winners == 1) { "expected one winner, got $winners" }
    println("one buyer won out of 200; mailbox order made the invariant local")
    seat.close()
}

Execute com kotlin seat.main.kts.

A linha que carrega todo o modelo de concorrência é for (msg in mailbox). Não há SELECT FOR UPDATE, nem checagem de versão, nem retry. A mailbox da corrotina serializa todo comando para a entidade, e a verificação do detentor é apenas um if local. Em um sistema real, esse actor seria respaldado por um cluster Akka ou Orleans que fixa uma ativação por ID de assento ao longo do cluster. A versão local é a pedagógica.

Sob a mesma carga de trabalho de cauda quente, a contenção não produz retries. Ela produz enfileiramento. O actor processa sua mailbox em ordem de chegada, então a latência nos assentos quentes se torna uma função da profundidade da mailbox, não da probabilidade de conflito. A latência de cauda permanece linear na carga. O throughput se torna "quão rápido uma CPU pode rodar minha função de reserva", o que para lógica trivial é o limite de um único core, não o limite de uma linha contestada.

O que mudou quando um nó desapareceu

No Design A, o problema difícil é o controle de concorrência na camada de armazenamento. O banco de dados impõe o invariante; a aplicação lida com os conflitos.

No Design B, o problema difícil é qual nó possui essa entidade agora, e o que acontece quando essa resposta muda no meio de uma escrita. Todo framework de actor com sharding em cluster precisa resolver isso. O procedimento de handoff do Akka Cluster Sharding foi o mais minuciosamente documentado que li enquanto pesquisava: quando o coordenador decide rebalancear o shard 7 da região A para a região B, a região A começa a fazer buffer das mensagens de entrada, envia PoisonPill para todos os seus actors de entidade, dá ack de HandoffComplete ao coordenador, e somente então a região B ativa a entidade e drena as mensagens em buffer.

O diagrama abaixo traça essa linha do tempo. A propriedade que vale a pena notar é que as mensagens fazem buffer durante todo o stop-and-restart, mas elas não são reordenadas ou duplicadas enquanto os invariantes do framework se mantêm.

O Akka também deixa explícito que o estado da entidade não é transferido durante o handoff. Se o actor do assento se importasse com quem detinha o assento após um rebalanceamento, ele tinha que persistir esse estado em um journal e fazer replay no novo nó. A máquina de estados se move; os bytes não.

O Orleans faz o mesmo ponto com uma borda mais afiada: sob condições livres de falha, um actor tem exatamente uma ativação, mas o diretório distribuído é eventualmente consistente, e durante mudanças de topologia do cluster "múltiplas ativações de uma única activation grain podem coexistir" até o diretório convergir. Essa não é uma preocupação teórica. O Split Brain Resolver do Akka existe precisamente porque duas metades de cluster podem cada uma concluir que são a maioria sobrevivente e iniciar duas cópias da mesma entidade — e se ambas as cópias escreverem em um journal compartilhado, o journal está agora corrompido.

Então o invariante de actor-per-entity repousa sobre três coisas, todas elas que agora possuo:

  • a camada de roteamento mapeia corretamente seat-7 para um e somente um nó
  • o protocolo de rebalanceamento drena as mensagens em voo antes de realocar
  • a decisão de membership do cluster é consistente o suficiente para que duas metades não decidam ambas que possuem seat-7

Bloqueio otimista tem uma superfície de ataque menor. Ele também é um encaixe pior para hot keys.

O que eu efetivamente escolheria

Para uma carga de trabalho onde o invariante é por entidade e a contenção é de cauda quente, agora eu escolho actor-per-entity primeiro. A ordem da mailbox é uma história de concorrência mais econômica do que orçamentos de retry, e o trabalho de roteamento e rebalanceamento é limitado — ele vive no framework, não em todo handler de negócio. O artigo da InfoQ sobre Durable Objects enquadrou esse padrão como uma ferramenta de corretude em vez de uma ferramenta de performance, e esse enquadramento bateu com o que senti rodando os dois designs lado a lado.

Para cargas de trabalho onde o invariante atravessa múltiplas entidades — uma transferência que debita uma conta e credita outra — o design com actor se torna mais difícil, não mais fácil. O padrão de serviço-stateless-mais-banco-de-dados ainda tem a melhor história ali, porque a transação do banco é o lugar natural para compor duas escritas. "Life Beyond Distributed Transactions" de Pat Helland continua sendo a articulação mais limpa de por que cruzar a fronteira de entidade única em um mundo de actor com estado merece uma longa pausa.

Se eu tivesse que comprimir isso em um checklist para amanhã:

  • prefira actor-per-entity quando o invariante vive dentro de uma entidade e a carga de trabalho tem hot keys
  • prefira Postgres com uma coluna de versão e retries quando os invariantes atravessam entidades ou a contenção é baixa
  • em qualquer caso, nomeie o novo modo de falha que você acabou de adotar: tempestades de retry em um, corretude de roteamento e rebalanceamento no outro

A conclusão que quero guardar é que "escala melhor" é o enquadramento errado. Actor-per-entity não escala melhor que bloqueio otimista. Ele move o problema difícil para um lugar onde, para cargas de trabalho transacionais com hot keys, os modos de falha são mais fáceis de raciocinar — desde que eu trate a camada de roteamento como o novo invariante que tenho que defender.

Referências

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.