Locks Distribuídos com Redis em Go: Exclusão Mútua, Redlock e Lua
Como implementar exclusão mútua entre processos com locks distribuídos no Redis, incluindo o Redlock e scripts Lua para atomicidade.
Publicada em · 6 min de leitura
Locks Distribuídos com Redis em Go: Exclusão Mútua, Redlock e Lua
Em uma única aplicação Go rodando em um único processo, primitivas nativas da linguagem como sync.Mutex ou canais são suficientes para proteger seções críticas contra condições de corrida (race conditions).
No entanto, em arquiteturas modernas em nuvem, microsserviços rodam distribuídos em múltiplos pods, contêineres e servidores. Uma instrução mutex.Lock() protege apenas as goroutines daquele processo específico; ela não tem nenhum poder sobre outro pod rodando no mesmo cluster Kubernetes.
Se dois pods executarem o faturamento do mesmo pedido ou aplicarem um cupom único de desconto simultaneamente, teremos uma condição de corrida distribuída. Para coordenar múltiplos nós independentes, utilizamos Locks Distribuídos centralizados em um cluster Redis.
1. As 3 Propriedades Obrigatórias de um Lock Distribuído
Para ser considerado seguro em sistemas distribuídos, qualquer implementação de lock deve garantir:
- Safety (Exclusão Mútua Estrita): Em qualquer instante de tempo, apenas um único cliente pode deter o lock sobre aquele recurso.
- Liveness A (Imunidade a Deadlock): Se o cliente que adquiriu o lock sofrer um crash, for morto pelo Kubernetes (
OOMKilled) ou a rede cair, o lock deve ser liberado automaticamente após um período (Time-To-Live - TTL). - Liveness B (Tolerância a Falhas): Enquanto a maioria dos nós do cluster estiver ativa, clientes conseguem adquirir e liberar locks.
2. A Armadilha Clássica da Liberação Indevida
Muitas implementações ingênuas cometem o erro grave de liberar o lock com um simples comando DEL:
// ❌ CÓDIGO PERIGOSO E COM BUG SILENCIOSO:
client.SetNX(ctx, "lock:pedido:42", "1", 5*time.Second) // 1. Adquire com 5s de TTL
processarPedido() // 2. Sofre lentidão e demora 7 segundos!
client.Del(ctx, "lock:pedido:42") // 3. 💥 CATASTRÓFICO!
O Cenário do Desastre:
- O Pod A adquire o lock com TTL de 5 segundos.
- O Pod A sofre uma pausa de Garbage Collection ou lentidão de rede e demora 7 segundos para processar.
- No segundo 5, o Redis expira o lock do Pod A automaticamente.
- O Pod B vê o recurso livre e adquire com sucesso o lock para si mesmo!
- No segundo 7, o Pod A finalmente termina seu trabalho e executa
DEL lock:pedido:42. - Resultado: O Pod A acabou de apagar o lock do Pod B! Agora um terceiro Pod C também consegue entrar na seção crítica, quebrando completamente a exclusão mútua.
3. A Solução: Tokens Únicos e Script Lua Atômico
Para evitar a liberação acidental de locks de terceiros:
- Ao adquirir o lock, gravamos um Token Único Aleatório (UUID v4) como valor da chave.
- Ao liberar o lock, executamos um Script Lua. O Redis garante que scripts Lua são executados de forma atômica e transacional no servidor: ele só apaga a chave se o valor atual for rigorosamente idêntico ao UUID que o pod gravou.
-- Script Lua de Liberação Atômica
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
4. Implementação Completa em Go com go-redis/v9
package distlock
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrLockNotAcquired = errors.New("lock distribuído indisponível: recurso já em uso")
ErrLockReleaseFail = errors.New("falha ao liberar lock: expirado ou detido por outro processo")
)
// Script Lua pré-compilado para liberação atômica
var releaseLuaScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`)
type RedisLock struct {
client *redis.Client
key string
token string
ttl time.Duration
}
func NewRedisLock(client *redis.Client, resourceKey string, ttl time.Duration) *RedisLock {
// Gera um token aleatório único e imprevisível
tokenBytes := make([]byte, 16)
_, _ = rand.Read(tokenBytes)
return &RedisLock{
client: client,
key: fmt.Sprintf("lock:%s", resourceKey),
token: hex.EncodeToString(tokenBytes),
ttl: ttl,
}
}
// TryAcquire tenta adquirir o lock imediatamente sem bloqueio
func (l *RedisLock) TryAcquire(ctx context.Context) error {
// SET key token NX PX ttl (NX = somente se não existir; PX = milissegundos)
ok, err := l.client.SetNX(ctx, l.key, l.token, l.ttl).Result()
if err != nil {
return fmt.Errorf("erro de I/O no redis: %w", err)
}
if !ok {
return ErrLockNotAcquired
}
return nil
}
// Release libera o lock com segurança atômica via Lua
func (l *RedisLock) Release(ctx context.Context) error {
res, err := releaseLuaScript.Run(ctx, l.client, []string{l.key}, l.token).Int64()
if err != nil {
return fmt.Errorf("falha ao executar script lua de liberação: %w", err)
}
if res == 0 {
return ErrLockReleaseFail
}
return nil
}
5. Renovação Automática de TTL (Watchdog / Keep-Alive)
Se a operação for imprevisível (ex: geração de um relatório que pode levar de 2 a 15 segundos), fixar um TTL estático alto é perigoso (se o pod morrer, o lock fica travado por 15 segundos).
A solução de engenharia recomendada é configurar um TTL curto (ex: 3 segundos) e disparar uma goroutine de Watchdog / Heartbeat que renova periodicamente o TTL enquanto o processo estiver vivo:
func (l *RedisLock) StartAutoRenew(ctx context.Context, renewInterval time.Duration) func() {
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(renewInterval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
return
case <-ticker.C:
// Renova o TTL se o token ainda pertencer a este processo
extendScript := redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`)
_ = extendScript.Run(ctx, l.client, []string{l.key}, l.token, l.ttl.Milliseconds()).Err()
}
}
}()
return func() { close(stop) }
}
6. O Algoritmo Redlock e a Biblioteca redsync
Em infraestruturas críticas com clusters Redis onde há replicação assíncrona entre Master e Replica, um failover pode perder a chave recém-gravada antes da sincronização.
O algoritmo Redlock (proposto por Salvatore Sanfilippo) utiliza $N$ instâncias independentes e isoladas de Redis (sem replicação entre si, geralmente 3 ou 5 nós). Um cliente só considera o lock adquirido se conseguir gravá-lo com sucesso na maioria estrita dos nós ($\lfloor N/2 \rfloor + 1$).
Em Go, a biblioteca padrão para Redlock é o github.com/go-redsync/redsync/v4:
package main
import (
"log"
"time"
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v9"
goredislib "github.com/redis/go-redis/v9"
)
func main() {
// Conecta em 3 nós independentes do Redis
client1 := goredislib.NewClient(&goredislib.Options{Addr: "redis1:6379"})
client2 := goredislib.NewClient(&goredislib.Options{Addr: "redis2:6379"})
client3 := goredislib.NewClient(&goredislib.Options{Addr: "redis3:6379"})
pool := goredis.NewPool(client1)
pool2 := goredis.NewPool(client2)
pool3 := goredis.NewPool(client3)
rs := redsync.New(pool, pool2, pool3)
mutex := rs.NewMutex("pedido:999",
redsync.WithExpiry(8*time.Second),
redsync.WithTries(3),
)
// Adquire em quórum majoritário
if err := mutex.Lock(); err != nil {
log.Fatalf("Falha ao adquirir Redlock: %v", err)
}
// Seção crítica protegida entre clusters
log.Println("Lock adquirido com quórum! Executando faturamento...")
// Liberação segura
if ok, err := mutex.Unlock(); !ok || err != nil {
log.Fatalf("Falha ao liberar Redlock: %v", err)
}
}
7. Comparativo de Engenharia: Qual Lock Usar?
| Mecanismo | Escopo | Complexidade | Caso de Uso Ideal |
|---|---|---|---|
sync.Mutex | Processo local (Memória Go) | Nula | Sincronizar goroutines no mesmo pod |
Lock Otimista (version) | Banco Relacional | Mínima | Baixa taxa de colisão em entidades SQL |
Lock Pessimista (FOR UPDATE) | Banco Relacional (PostgreSQL com pgx) | Baixa | Transações financeiras com consistência ACID estrita |
| Redis Distributed Lock (Single Node + Lua) | Múltiplos Pods | Média | Tarefas em background, desduplicação de webhooks, envio de emails |
| Redlock (Multi-Node Quorum) | Múltiplos Clusters | Alta | Operações distribuídas críticas sem banco relacional centralizado |
8. Resumo Conceitual
sync.Mutexnão funciona em ambientes distribuídos conteinerizados (Kubernetes / múltiplos pods).- Nunca adquira locks com
SETNXpuro seguido de umDELincondicional; isso apaga locks de outros pods após pausas ou lentidões. - Sempre gere um Token Único (UUID) por tentativa e utilize Scripts Lua atômicos para validar a posse antes de deletar a chave.
- Defina sempre um TTL (Time-To-Live) finito para evitar deadlocks perpétuos caso um pod morra repentinamente.
- Para operações de longa duração, implemente um Watchdog / Heartbeat para renovação periódica do TTL.
- Avalie se a consistência do seu banco relacional via
SELECT ... FOR UPDATEnão resolve seu problema de forma mais simples antes de adicionar complexidade de infraestrutura com Redis Locks.