1

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çãoPrimitiva mais simples
Tudo mora no mesmo banco relacionalLock de linha, constraint única, advisory lock ou update condicional na transação
O trabalho cabe como job durávelFila com claim, ack e retry
A mesma requisição pode chegar de novoChave de idempotência
Vários processos alteram um recurso externoLease + fencing aceito por esse recurso
Duas atualizações de cache só desperdiçam CPULease 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?

Carregando publicação patrocinada...
1