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.
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.
#!/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-7para 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
- PostgreSQL 18 docs — Serialization Failure Handling — https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html
- EnterpriseDB — Postgres anti-patterns: read-modify-write cycles — https://www.enterprisedb.com/blog/postgresql-anti-patterns-read-modify-write-cycles
- Microsoft Orleans overview — https://learn.microsoft.com/en-us/dotnet/orleans/overview
- Akka Cluster Sharding concepts — https://doc.akka.io/libraries/akka-core/current/typed/cluster-sharding-concepts.html
- Akka Split Brain Resolver — https://doc.akka.io/libraries/akka-core/current/split-brain-resolver.html
- InfoQ — One Cache to Rule Them All: Handling Responses and In-Flight Requests with Durable Objects — https://www.infoq.com/articles/durable-objects-handle-inflight-requests/
- Pat Helland — Life Beyond Distributed Transactions — https://www.ics.uci.edu/~cs223/papers/cidr07p15.pdf
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Auditando um serviço Scala contra as quatro restrições regenerativas de Chad Fowler
Levei um serviço Scala de processamento de pedidos das minhas anotações pelas quatro restrições regenerativas de Chad Fowler. Duas passaram de graça, duas forçariam um redesign de verdade. Aqui está o que aprendi sobre onde "módulo fracamente acoplado" termina e "componente regenerativo" começa, e quais partes do redesign eu de fato pagaria.
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.
DBOS vs Temporal: Quando o Postgres É Suficiente para Execução Durável de Workflows
O DBOS reutiliza o Postgres como camada de durabilidade para workflows, enquanto o Temporal roda um cluster dedicado. A escolha certa depende do tamanho do time, da forma do workload e de onde você quer que seu orçamento operacional vá. Este é um critério prático para escolher entre eles.