distributed lock

5 Falhas do Distributed Lock e a Integridade de Dados

Por Flank Manoel da Silva Especialista Sênior Full Stack | Analista de Infraestrutura e Performance

Eu vou direto ao ponto: o seu distributed lock não falha quando “dá erro”. Ele falha quando parece funcionar. Em sistemas financeiros, isso é pior do que um crash, porque você não ganha alerta, você ganha reconciliação manual, chargeback, auditoria e uma fila de clientes furiosos no suporte.

O cenário clássico é o “saldo” (wallet, gift card, store credit, rewards) sendo atualizado por múltiplos workers em paralelo, em Kubernetes, atrás de autoscaling, com Redis/Redlock ou Etcd “garantindo exclusão mútua”. Aí você coloca pressão real (picos tipo Black Friday/Cyber Monday), introduz latência de rede e… pronto: race conditions silenciosas começam a vazar por uma brecha que quase ninguém modela direito.

Essa brecha tem nome: TTL do lock versus pausas de runtime (GC / stop-the-world) e jitter de scheduler. E sim, eu já vi isso acontecer em stacks “boas” (Java/Kotlin, Node.js, Go, .NET) com APM (Datadog/New Relic) aparentemente “verde”.

Sumário

O resumo que eu daria ao meu time antes do deploy

Se você só levar 5 ideias daqui, leve estas:

  1. O distributed lock só protege enquanto você consegue provar que ainda “possui” o lock no momento exato da escrita. Muita implementação não prova — ela “assume”.
  2. TTL é uma aposta, não uma garantia. Se o seu processo congelar (GC, CPU throttling, noisy neighbor, pausa de VM, page fault), o TTL expira e outro worker pega o distributed lock. O primeiro “volta do coma” e escreve mesmo assim.
  3. Split-brain e partição de rede transformam lock em ilusão estatística. “Baixa probabilidade” vira “evento semanal” quando você roda milhões de operações.
  4. Banco relacional não “barra” automaticamente esse tipo de corrupção. Se você faz update ingênuo (sem fencing token/versão), o DB aceita a escrita velha como se fosse nova.
  5. A correção real quase sempre envolve um mecanismo de fencing (token monotônico) + escrita condicional, ou um desenho que evite concorrência por chave (single-writer / partitioning / ledger). Sim: ainda dá para usar distributed lock, mas ele vira só uma camada, não a parede principal.

Como eu quebrei um saldo “protegido” com split-brain + latência artificial

Eu montei um ambiente bem parecido com o que vejo na prática nos EUA:

  • Workers em Kubernetes (EKS), HPA ligado, múltiplas réplicas.
  • Redis em cluster (pense em ElastiCache/Redis Enterprise como equivalente operacional), com Redlock.
  • Uma rota crítica: “applyCredit(accountId, amount)” atualizando o saldo.
  • Tráfego concorrente simulando checkout + ajustes de backoffice + reprocessamento de eventos.

O que eu fiz de diferente foi tratar a rede como inimiga, não como pano de fundo.

O teste que revela o “abismo da consistência”

Eu forcei três coisas ao mesmo tempo:

  1. Contenção real por chave (várias requisições no mesmo accountId).
  2. Partição/latência entre nós do cluster Redis (para simular split-brain e timeouts).
  3. Pausas longas no runtime do worker (GC/stop-the-world) para o TTL perder a corrida.

Exemplo (conceitual) do que eu injetei com tc (Linux netem) em um dos nós/paths relevantes:

  • Atraso médio: 250ms
  • Jitter: 200ms
  • Perda: 1–2%
  • Reordenação ocasional

O objetivo não era “derrubar” o sistema. Era deixá-lo vivo, porém mentiroso. Essa instabilidade muitas vezes não nasce no código, mas na camada de rede, como acontece nos gargalos gerados pela convivência entre NAT/CGNAT e IPv6, onde a tradução de protocolos adiciona uma latência imprevisível.

O bug que aparece quando ninguém está olhando

O fluxo típico que eu encontro em times bons é algo assim:

  1. Worker A pega o distributed lock com TTL = 2s
  2. Worker A faz lógica, chama serviços, calcula, prepara update
  3. Em algum ponto, Worker A sofre uma pausa de 3–8s (GC, CPU throttling)
  4. TTL expira
  5. Worker B pega o distributed lock e atualiza o saldo
  6. Worker A “acorda” e faz o update atrasado
  7. Tudo retorna 200 OK (às vezes com logs “normais”)

Isso é a definição de race condition silenciosa: ninguém “quebrou”, mas a ordem lógica dos eventos foi violada.

Ilustração 1 — Linha do tempo (TTL vs pausa do runtime)

Tempo →

Worker A: lock acquired |—— do work ——| [GC PAUSE] | resumes | WRITE (late)

TTL: |—- 2s —-| EXPIRES

Worker B: lock acquired | do work | WRITE (new)

Resultado: WRITE velho chega depois do novo, mas parece válido

Por que isso virou urgência agora para o americano comum (não só “problema de big tech”)

Eu vejo três fatores tornando o distributed lock mais traiçoeiro em 2026 do que era “anos atrás”:

  1. Kubernetes + autoscaling aumentam a frequência de “pausas estranhas”Você tem:
    • Pods sendo throttled por CPU limit
    • Node pressure (memory/IO)
    • Evictions e reschedules
    • Cold start de dependênciasTudo isso cria janelas em que o TTL do distributed lock vira uma roleta.
  2. Finanças embutidas (embedded finance) virou padrão. Não é só banco. É:
    • marketplaces com saldo interno
    • apps de delivery com créditos
    • plataformas de rewards
    • gift cards e store credit no e-commerceIsso aumenta o volume de “saldo compartilhado” e o custo do erro.
  3. Observabilidade costuma medir média e p95 — mas esse bug mora no p99.99GC pause e jitter de rede que “não aparecem” no dashboard comum ainda são suficientes para estourar o TTL do distributed lock, e é ali que o dinheiro escapa.

Dois perfis que mais sofrem (e por motivos diferentes)

Eu vou segmentar em dois perfis bem típicos nos EUA, porque eles quebram de jeitos diferentes:

Perfil 1: Checkout de alto volume (Shopify-like, Stripe-like, promoções agressivas)

Aqui o problema é concorrência por chave em tempo real:

  • múltiplas tentativas de pagamento
  • retries de gateway
  • idempotência parcial
  • carrinho + wallet + rewards batendo no mesmo saldo

Se o distributed lock falha, você ganha: saldo negativo indevido, double-spend de crédito e reconciliação com parceiros e chargebacks.

Perfil 2: Processamento assíncrono (event-driven, filas, reprocessamento)

Aqui o problema é “ordem” e “replay”:

  • reprocessar eventos
  • consumer lag
  • redelivery
  • dead-letter reingestion

Mesmo com distributed lock, se você não tiver fencing/versão, o worker atrasado pode sobrescrever o estado correto.

Tabela 1 — Onde o distributed lock engana mais

CaracterísticaCheckout alto volumeAssíncrono/reprocessamento
“Contenção por chave”Alto (mesmo accountId)Médio (depende do particionamento)
“Probabilidade de retries”Alta (timeouts, gateways)Alta (redelivery, replay)
“Dano por escrita fora de ordem”Muito alto (dinheiro agora)Alto (corrupção lenta, difícil de detectar)
“Sintoma típico”Saldo errado imediatamenteDivergência depois de horas/dias
“Armadilha mais comum”TTL curto para performanceFalta de versionamento na projeção

O lock expira, mas seu processo continua se achando dono

A venda implícita do distributed lock em muitos times é: “pegou lock, então está seguro”. Não está.

O Redlock (documentação pública) tenta endereçar parte do problema ao exigir quórum de nós para adquirir o lock. Só que a crítica do Martin Kleppmann não é sobre “ser impossível adquirir lock com quórum”. É sobre algo mais sutil: você não consegue transformar “eu acredito que tenho o lock” em “eu provo que ainda tenho o lock no instante da escrita”, se seu mecanismo não inclui fencing e se seu sistema sofre pausas e partições.

O que eu vejo times errarem na prática:

TTL calculado com base em “tempo médio de request” (erro clássico)

Eles olham p95 do handler e falam “2s dá e sobra”. Aí o runtime dá uma pausa de 6s porque:

  • JVM Full GC
  • Node.js com stop-the-world em heap grande
  • Go com GC + CPU throttling
  • container com CPU limit baixo e burst esgotadoResultado: o distributed lock expira no “pior momento possível”.

Renovação de lock (watchdog) que falha silenciosamente

Alguns implementam renew (estilo “keep-alive”). Só que:

  • durante GC o thread/event-loop não roda, então não renova
  • durante partição de rede, a renovação não chega
  • durante overload, a renovação atrasaOu seja: o watchdog dá a sensação de segurança e só.

“Eu libero no finally” não ajuda quando o lock já expirou

Essa é a parte que eu mais vejo em review de PR: “pego o distributed lock, faço, libero no finally”. Ótimo para higiene… irrelevante para o bug principal. O bug ocorre antes do finally.

Por que “atualizar saldo” é um convite ao desastre

Aqui vai a diferença que separa sistemas que sobrevivem de sistemas que fazem auditoria com dor.

Cenário A: saldo como número mutável (update in-place)

Você tem uma linha “accounts.balance” e faz updates concorrentes. Mesmo com distributed lock, basta um worker atrasado escrever depois e você corrompe a verdade.

Ilustração 2 — Update in-place (onde a corrupção passa)

Worker A: lê 100, aplica -30, quer escrever 70

Worker B: lê 100, aplica -10, escreve 90

Worker A: escreve 70 (tarde)

Final: 70, mas deveria ser 60 se ambos fossem válidos e ordenados corretamente, ou 90/70 dependendo da semântica. Você perdeu a trilha.

Cenário B: ledger (razão) append-only + projeção

Você registra eventos imutáveis (“debit 30”, “debit 10”) com idempotency key, e o saldo é projeção. O distributed lock pode até existir para reduzir contenção na projeção, mas a fonte de verdade não depende dele. O estrago fica muito mais difícil.

Conclusão prática: se o seu produto tem “dinheiro” (mesmo store credit), ledger reduz sua dependência de distributed lock como item “sagrado”.

O protocolo que eu aplico quando o time insiste em usar distributed lock

Eu não sou “anti-lock”. Eu sou anti-mito. Se você vai usar distributed lock em fluxo financeiro, aqui está meu protocolo mínimo para você parar de depender de sorte.

Passo 1: Meça o que realmente mata TTL (p99.99, não p95)

Colete e guarde por pelo menos 7 dias (incluindo picos):

  • GC pause p99.99 (JVM: gc logs; Go: runtime/metrics; .NET: ETW; Node: perf hooks)
  • event-loop lag p99.99 (Node)
  • container CPU throttling time
  • network RTT entre worker e Redis p99.9
  • redis command latency p99.9
  • timeouts e retries reaisRegra que eu uso: se você não sabe seu p99.99 de pausas, você não escolheu TTL; você chutou.

Passo 2: Pare de tratar TTL como “timeout”; trate como “contrato de validade”

Você precisa responder: “Se o contrato expirar, como eu impeço escrita tardia?” Se a resposta for “não impede”, então seu distributed lock é só um rate limiter chique.

Passo 3: Adote fencing token (token monotônico) e torne a escrita condicional

Esse é o ponto que separa “lock de blog” de engenharia que segura pancada.

Fluxo robusto (conceito):

  1. Ao adquirir o distributed lock, você recebe um token monotônico (fencing token).
  2. Toda escrita no banco inclui esse token.
  3. O banco só aceita se token > last_token.Isso transforma “ordem lógica” em regra verificável no storage.

Ilustração 3 — Fencing token salvando seu pescoço

  • Worker A pega token 101 (mas congela)
  • Worker B pega token 102 e escreve (aceito)
  • Worker A volta e tenta escrever com token 101 (rejeitado pelo DB)Resultado: o atraso não corrompe o estado. Sem isso, seu distributed lock está baseado em fé.

Passo 4: Faça a operação ser idempotente de verdade (não “mais ou menos”)

Em checkout americano, retries são inevitáveis (gateway, rede, user refresh, mobile switching). Se você mistura distributed lock com não-idempotência, você multiplica dano.

  • idempotency key por operação financeira
  • unique constraint no ledger/event store
  • retry seguro no consumer (exatamente-uma-vez é promessa cara; prefira pelo menos-uma-vez + idempotência)

Passo 5: Injete caos como rotina, não como evento

Eu não confio em distributed lock que não passou por:

  • tc netem (latência, jitter, loss)
  • CPU throttling proposital (reduzir limit do container)
  • pauses artificiais (sleep em pontos críticos, simular stop-the-world)
  • kill -STOP / kill -CONT (em ambiente controlado) para simular congelamento
  • testes com clock skew se sua infra permitir variação (NTP drift)

Gráfico 1 — “Mapa de risco” (quanto maior, pior)

Eixo X: Pausas de runtime (GC / throttling) → baixo para alto

Eixo Y: Instabilidade de rede (jitter / loss / partição) → baixo para alto

  • Quadrante baixo/baixo: distributed lock “parece perfeito”
  • Quadrante alto/baixo: TTL perde para GC, corrupção silenciosa
  • Quadrante baixo/alto: split-brain/timeouts, duplicidade e reorder
  • Quadrante alto/alto: o lock vira ruído; só fencing/ledger segura

Onde eu permito distributed lock (e onde eu veto) depois de ver sistemas sangrarem

Eu permito distributed lock quando:

  • o recurso é “best-effort” (ex: gerar PDF, cache warmup)
  • o dano de uma execução duplicada é baixo e reversível
  • há idempotência forte
  • o storage final tem proteção contra escrita tardia (version check / fencing)Nesses casos, distributed lock é útil como “controle de concorrência operacional”.

Eu veto distributed lock como “única defesa” quando:

  • há dinheiro/saldo/limite/estoque que vira dinheiro
  • há múltiplos writers e latência variável
  • o runtime tem GC relevante (heap grande, picos)
  • você depende de “update in-place” no DBNesses casos, distributed lock sem fencing é convite para corrupção com recibo de sucesso (HTTP 200).

A decisão de longo prazo: o que custa menos, de verdade (não no PowerPoint)

Aqui é onde eu sou mais cético: o mercado empurra distributed lock porque parece mais barato do que mexer no modelo de dados. Só que a conta chega.

  • Custo imediato (aparente): “coloca Redis lock e pronto”
  • Custo real (12–18 meses): incidentes estranhos, reconciliação, perda de confiança, auditoria, retrabalho no core financeiro

Quando você investe em ledger + idempotência, fencing token no storage, projeções reconstruíveis e particionamento por chave (single-writer por accountId), você reduz a área onde distributed lock é necessário. E isso é o tipo de arquitetura que aguenta Black Friday, falha parcial de AZ, jitter de rede, pods congelando e retries de gateway.

Meu veredito: use distributed lock como camada auxiliar, nunca como garantia final de integridade. Em finanças, a garantia final precisa estar no storage (escrita condicional) ou no desenho (single-writer/ledger). Se você tratar o distributed lock como “o guardião”, você está terceirizando sua consistência para o relógio e para a sorte.

Conclusão:

O grande erro da última década foi tratar o distributed lock como uma solução mágica de “um clique”, quando ele é, na verdade, uma aposta contra o relógio. Em sistemas de baixa escala, essa aposta quase sempre ganha. Mas, quando você escala para milhões de transações, a estatística se torna cruel: o que era “improvável” vira um incidente de severidade 1 na sua madrugada de segunda-feira.

Se você gerencia infraestrutura web ou desenha sistemas que movem valores, sejam eles financeiros, estoque ou dados críticos, a regra de ouro é: nunca terceirize sua integridade apenas para o tempo.

O checklist de saída para sua próxima revisão de arquitetura:

  1. Assuma a falha do Lock: Desenhe seu banco de dados para rejeitar escritas que não provem posse do lock (use Fencing Tokens).
  2. Imutabilidade acima de tudo: Migre de saldos mutáveis para modelos de Ledger (Razão). É mais fácil somar eventos do que tentar descobrir quem corrompeu um valor fixo.
  3. Monitore o invisível: Se você não mede as pausas de GC e o jitter da sua rede, você não tem um sistema sob controle, você tem um sistema que “ainda não quebrou”.

No final do dia, a diferença entre um desenvolvedor e um engenheiro de infraestrutura é que o segundo sabe que a rede vai falhar, o processo vai travar e o relógio vai mentir. O distributed lock é uma ferramenta útil para evitar trabalho duplicado, mas é uma defesa pífia para proteger a integridade do dado.

Construa sistemas que não precisem de sorte para serem consistentes.