Seu lock expirou — e o worker antigo ainda escreveu
O worker A pega um lock por 10 segundos e começa a recalcular um documento.
Ele trava por 12 segundos. O lock expira, o worker B assume, grava o resultado novo — e o A volta à vida e sobrescreve tudo.
O serviço de lock pode ter funcionado exatamente como prometido.
Esse é o detalhe que muita implementação esconde: lock distribuído com TTL não é mutex. É um lease, uma posse temporária. Quando o tempo acaba, o coordenador pode entregar a posse a outro worker, mas não consegue arrancar o código antigo da CPU nem cancelar um pacote que ficou preso na rede.
A saída não é aumentar o TTL. É fazer o recurso protegido rejeitar o dono antigo com um fencing token.
O timeout venceu, mas o dono antigo não morreu
O cenário cabe numa linha do tempo:
worker A: pega #41 ───── pausado ─────────────────── grava #41 ✗
lease: expira
worker B: pega #42 ─ grava #42 ✓
recurso: memoriza 42
Essa pausa pode ser GC, CPU disputada, page fault, container congelado ou um SIGSTOP acidental. Também pode nem estar no processo: o worker envia a requisição dentro do prazo, mas ela chega ao banco depois.
Em um incidente do GitHub de 2012, o tráfego entre switches ficou bloqueado por cerca de 90 segundos. Pares de servidores de arquivos perderam heartbeats, comandos de isolamento não chegaram e, quando a rede voltou, vários pares tinham os dois nós esperando ser o ativo do mesmo recurso. O risco declarado era corrupção irrecuperável; restaurar toda a infraestrutura afetada levou mais de cinco horas.
Não era um job com Redis igual ao exemplo acima. O ponto é mais básico: a rede e os processos conseguem atrasar por muito mais tempo do que o nosso “isso nunca passa de dez segundos”.
Timeout é uma decisão de retomada. Não é prova de que o dono anterior parou.
O buraco não está só no release
Uma implementação comum se parece com isto:
const lease = await locks.acquire(resourceId, { ttlMs: 10_000 });
try {
const atual = await carregar(resourceId);
await salvar(resourceId, recalcular(atual));
} finally {
await lease.release();
}
Quem já estudou o padrão do Redis talvez encontre um erro no release: ele precisa apagar a chave somente se o valor ainda for o mesmo valor aleatório criado na aquisição.
Correto. Sem essa comparação, o worker A pode acordar depois do vencimento e apagar o lock novo do B. A documentação de locks distribuídos do Redis usa SET chave valor-aleatorio NX PX ttl; no Redis 8.4 existe DELEX ... IFEQ, e nas versões anteriores a comparação costuma ser feita por Lua.
Só que isso protege a remoção do lock. O salvar continua aberto.
O valor aleatório responde “este ainda é o mesmo lock que eu peguei?”. Ele não responde “esta operação é mais nova que todas as operações aceitas antes?”.
UUID identifica. Não ordena.
Checar o lock imediatamente antes de gravar também não fecha o buraco. O processo pode pausar entre a checagem e a gravação. A requisição pode sair logo depois da checagem e ficar atrasada no caminho.
Enquanto a validação e a mutação acontecerem em lugares diferentes, sobra uma janela.
O banco precisa poder dizer “você é velho”
Na aquisição, o coordenador entrega uma geração estritamente crescente:
primeiro dono → token 41
próximo dono → token 42
próximo dono → token 43
Toda gravação leva essa geração. O recurso guarda o maior número já aceito e recusa qualquer número menor.
Se o estado está no PostgreSQL, a proteção central pode ser uma atualização condicional:
UPDATE documentos
SET conteudo = $1,
fencing_token = $2
WHERE id = $3
AND fencing_token < $2;
Depois que o token 42 foi aceito, a requisição atrasada com token 41 altera zero linhas. O worker A pode jurar que ainda tem o lock; o banco não precisa acreditar nele.
A comparação e a mutação precisam ser atômicas. Este código continua tendo corrida:
const ultimo = await db.buscarFence(id);
if (token > ultimo) {
await db.atualizarDocumento(id, conteudo, token);
}
A e B podem passar no if antes de qualquer atualização terminar. Use um único UPDATE condicional ou tranque a linha do token e faça a atualização do fence e dos dados de negócio na mesma transação.
Para uma operação com várias tabelas, mantenha um registro de geração por recurso lógico e inclua tudo na transação. Para object storage, procure uma escrita condicional equivalente. Gerar o token e ignorá-lo no destino não entrega segurança nenhuma.
Isso já existia no Chubby do Google
O paper do Chubby, publicado pelo Google em 2006, descreve quatro números monotônicos de 64 bits por nó. Um deles é a geração do lock, incrementada quando o lock passa de livre para ocupado.
O cliente podia pedir um sequencer: uma sequência opaca contendo nome, modo e geração do lock. Depois enviava esse valor ao file server ou a outro serviço protegido. O destinatário validava o sequencer ou comparava com o mais recente que já tinha visto.
Essa parte é reveladora: o Chubby não tentava transformar o lock em uma barreira física. Os locks eram consultivos. A segurança nascia quando o serviço que recebia a operação conferia a geração.
Para sistemas antigos que não entendiam sequencers, o Chubby tinha um lock-delay: após uma falha, esperava até um minuto antes de liberar o lock novamente. O próprio paper trata isso como mecanismo imperfeito. Esperar diminui a chance de uma mensagem antiga chegar tarde; não prova que ela desapareceu.
Fencing troca esperança baseada em tempo por uma regra de ordem verificável.
Por que um INCR solto não resolve
O token precisa respeitar a mesma ordem das aquisições, inclusive durante failover.
É tentador colocar INCR lock:contador ao lado do SET NX PX. Mas responda antes:
- se o lock for concedido e o incremento falhar, quem é o dono?
- se um primário cair depois de emitir 42 e o substituto só conhecer 41, qual será o próximo token?
- se aquisição e incremento vivem em sistemas diferentes, qual deles decide a ordem real?
A comparação no SQL é simples. Emitir gerações com uma ordem defensável é a parte distribuída.
Coordenadores baseados em consenso já expõem artefatos úteis. O Chubby tem a geração do lock. A receita de lock do ZooKeeper usa nós efêmeros sequenciais: vence o menor sufixo, e cada cliente espera apenas pelo predecessor imediato. Isso ainda não dispensa enviar uma geração ao recurso, mas evita inventar ordenação em cima de componentes desconectados.
A documentação atual do Redis também recomenda fencing tokens quando consistência e correção importam. O valor aleatório do Redlock não vira monotônico só porque é único.
Renovar ajuda disponibilidade, não prova correção
Heartbeats evitam trocas desnecessárias durante trabalhos longos. No exemplo da AWS para o DynamoDB Lock Client, o lease dura 10 segundos e o heartbeat roda a cada 3.
Isso cria margem para uma renovação falhar sem perder a posse imediatamente. Também custa leitura e escrita na tabela de locks, sofre com skew de relógio em leases curtos e exige ordem consistente se a operação pegar mais de um lock.
Renovação não revoga uma requisição enviada depois do último heartbeat válido.
Na observabilidade, eu acompanharia:
- espera para adquirir o lock
- duração real da posse
- folga restante em cada renovação
- falhas de heartbeat e perda de lease
- quantidade de tokens antigos rejeitados
Um fence_rejected_total maior que zero não é ruído. É uma corrupção que tentou acontecer e foi bloqueada.
TTL maior reduz a probabilidade de troca durante uma pausa, mas deixa a recuperação de crash mais lenta. TTL menor retoma rápido, mas aumenta perdas falsas de posse. Com fencing, essa escolha vira principalmente um compromisso de disponibilidade, não uma aposta com o dado.
Talvez você não precise de lock distribuído
Antes de adicionar outro coordenador, olhe onde está a verdade:
| Situação | Primitiva mais simples |
|---|---|
| Tudo mora no mesmo banco relacional | Lock de linha, constraint única, advisory lock ou update condicional na transação |
| O trabalho cabe como job durável | Fila com claim, ack e retry |
| A mesma requisição pode chegar de novo | Chave de idempotência |
| Vários processos alteram um recurso externo | Lease + fencing aceito por esse recurso |
| Duas atualizações de cache só desperdiçam CPU | Lease simples pode bastar |
Fencing também tem limite: o destino precisa entender o token. Não dá para “desenviar” e-mail. Uma API de Pix ou de pagamento que não aceita sua geração não vai recusar o worker antigo por mágica. Nesse caso, use a idempotência oferecida pelo provedor, registre um outbox na mesma transação ou concentre o efeito em um consumidor durável.
Lock não transforma efeito externo em “exactly once”.
A regra que fica
Um lease pode expirar enquanto o antigo dono ainda executa ou enquanto sua requisição ainda viaja.
Por isso, a gravação sensível precisa provar que sua geração é mais nova que a última aceita. Se o recurso não consegue fazer essa comparação, procure uma transação, fila ou estratégia de idempotência que mantenha a decisão junto do estado.
Onde você usa lock com TTL hoje? O recurso protegido consegue rejeitar um dono atrasado ou só confia que o prazo foi suficiente?
Fonte: https://luciano655.com