Seu sync.Mutex não sobrevive a um kill -9. Não sobrevive a um OOM, a um rollout de deploy, nem a um reboot de node. No instante em que o processo termina, o lock desaparece. Se aquele lock estava protegendo um job agendado, uma migration de dados ou uma eleição de liderança, você agora tem dois processos convencidos de que são os únicos em execução.
Isso não é um bug no seu mutex. É um erro de categoria. Um lock local ao processo não pode proteger um recurso de todo o cluster.
A solução é um lease distribuído: um lock armazenado em um sistema externo com um Time-To-Live, um token de proprietário único e um mecanismo de liberação segura. Redis, PostgreSQL, etcd e ZooKeeper implementam a mesma ideia com diferentes trade-offs. O padrão é simples. Os edge cases não são.
O que é um lease distribuído?
Um lease é uma promessa de um store externo de que apenas um cliente detém um lock nomeado por um período limitado de tempo. Diferentemente de um mutex, que vive na memória heap, um lease vive no Redis ou PostgreSQL. Ele persiste entre reinícios de processo porque o store persiste.
As operações básicas são:
- Acquire: escrever atomicamente uma key com um TTL, mas apenas se ela ainda não existir.
- Renew: estender o TTL enquanto você ainda detém o lock.
- Release: deletar a key, mas apenas se ela ainda contiver seu token único.
O token único é a parte crítica. Sem ele, um cliente lento pode liberar um lock que foi re-adquirido por outro alguém após um crash.
Por que TTL é obrigatório, e por que é perigoso
Se um cliente adquire um lock e depois morre, o lock deve eventualmente se liberar. A única forma de fazer isso sem intervenção humana é um tempo de expiração. O Redis suporta isso nativamente com SET key value NX EX 30. Os advisory locks do PostgreSQL são vinculados à sessão e morrem quando a conexão TCP fecha, o que é elegante, mas menos portátil.
TTLs introduzem um novo modo de falha: se seu trabalho leva mais tempo que o TTL, o lock expira enquanto você ainda está executando. Outro cliente adquire o lock e inicia o mesmo trabalho. Você agora tem dois processos concorrentes mutando os mesmos dados.
A solução ingênua é um TTL muito longo. Isso funciona até que um cliente morra imediatamente após adquirir o lock. O TTL restante se torna uma janela de downtime obrigatória na qual ninguém mais pode assumir.
A solução correta é um heartbeat. O detentor cria uma goroutine em background que renova o lease a cada poucos segundos. Se o detentor sofrer um crash, o heartbeat para, o TTL expira, e um novo proprietário pode adquirir o lock em segundos. Se o detentor é apenas lento, o heartbeat mantém o lease vivo enquanto o processo existir.
Um lease Redis funcional em Go
Aqui está uma implementação completa que lida com aquisição, renovação e liberação segura. Ela usa um script Lua para a liberação, de modo que o delete só tenha sucesso se o token corresponder.
package lease
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
type Lease struct {
client *redis.Client
key string
token string
ttl time.Duration
renewEvery time.Duration
stopRenew chan struct{}
once sync.Once
}
func generateToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
token := generateToken()
ok, err := client.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("lock already held")
}
l := &Lease{
client: client,
key: key,
token: token,
ttl: ttl,
renewEvery: ttl / 3,
stopRenew: make(chan struct{}),
}
go l.renew()
return l, nil
}
func (l *Lease) renew() {
ticker := time.NewTicker(l.renewEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`
l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
cancel()
case <-l.stopRenew:
return
}
}
}
func (l *Lease) Release(ctx context.Context) error {
l.once.Do(func() { close(l.stopRenew) })
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
if err != nil {
return err
}
if res == 0 {
return fmt.Errorf("lock was lost or stolen")
}
return nil
}
Uso:
lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
// Another instance is running the job.
return
}
defer lease.Release(ctx)
// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()
Os scripts Lua são necessários porque o Redis não suporta compare-and-delete como um comando nativo único. Sem eles, uma liberação poderia competir com uma re-aquisição e deletar o lock de outra pessoa.
Onde isso falha: o problema do fencing token
Mesmo com TTL e renovação perfeitos, existe uma race condition sutil identificada por Martin Kleppmann em sua crítica ao Redlock. Imagine:
- O cliente A adquire o lease.
- O cliente A pausa por 45 segundos (GC stop-the-world, suspensão de VM, CPU throttling).
- O lease expira.
- O cliente B adquire o lease.
- O cliente A retoma e escreve no banco de dados.
- O cliente B escreve no banco de dados.
Ambos os clientes acreditavam deter o lock. Ambos mutaram o estado compartilhado.
O TTL protege contra deadlocks permanentes, mas não pode proteger contra processos atrasados. A solução é um fencing token: um número monotônico ou UUID que o detentor do lock anexa a cada escrita. A camada de storage rejeita escritas com um token desatualizado.
Na prática, isso significa que sua tabela de banco de dados precisa de uma coluna lock_version, ou seu blob store precisa de escritas condicionais. A maioria das aplicações pula essa etapa porque exige mudar a camada de dados, não apenas a camada de locking. Isso é um trade-off razoável, mas é um trade-off. Você deveria saber que está fazendo isso.
Alternativas que valem a pena considerar
PostgreSQL advisory locks são delimitados por sessão. Quando a conexão TCP fecha, o lock se libera automaticamente. Não há gerenciamento de TTL nem clock skew. A desvantagem é que estão vinculados a uma única conexão de banco de dados, então não funcionam bem com connection pooling ou setups multi-region.
etcd leases são projetados exatamente para esse problema. Eles suportam TTL, revogação automática e notificações baseadas em watch quando um lease morre. Se você já está executando Kubernetes, você tem etcd. A API é mais verbosa que a do Redis, mas as semânticas são mais limpas.
ZooKeeper ephemeral sequential nodes são a solução clássica. Eles são CP (consistent e partition-tolerant) sob o teorema CAP, o que elimina o problema de clock skew inteiramente. Eles também são mais lentos e operacionalmente mais pesados que o Redis.
O que não tentamos
Não implementamos um protocol de consensus customizado em cima de um banco de dados relacional. Todo time eventualmente tenta isso: uma tabela locks com INSERT ... ON CONFLICT e uma coluna last_heartbeat varrida por um cron job. Funciona no happy path. Desmorona sob contenção porque bancos de dados MVCC serializam escritas conflitantes, e sua aquisição de lock se torna o bottleneck para todo o seu sistema. Use a ferramenta certa para o trabalho.
Escolhendo um TTL
Curto demais: heartbeats se tornam barulhentos, e uma única pausa lenta de GC pode perder o lease.
Longo demais: um detentor que sofreu crash bloqueia o failover pelo TTL inteiro.
Um bom ponto de partida é 10 segundos com um heartbeat a cada 3 segundos. Ajuste a partir daí com base nas pausas de GC observadas e na latência de rede. Meça sua latência p99 de heartbeat. Se for 500 ms, seu TTL deve ser pelo menos uma ordem de magnitude maior.
Experimente
Se você está atualmente usando um sync.Mutex para proteger um job em background, substitua-o por um leased lock com Redis ou PostgreSQL. Comece com a implementação acima. Adicione metrics para lease_acquired, lease_lost e heartbeat_latency. Na primeira vez que você fizer deploy durante um job de longa duração e observar a segunda instância esperar educadamente em vez de colidir, você saberá que o erro de categoria foi corrigido.