Pular para o conteúdo

Trilha de Go · parte 36 de 37

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

Tamanho da fonte:
Largura da coluna:

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:

  1. Safety (Exclusão Mútua Estrita): Em qualquer instante de tempo, apenas um único cliente pode deter o lock sobre aquele recurso.
  2. 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).
  3. Liveness B (Tolerância a Falhas): Enquanto a maioria dos nós do cluster estiver ativa, clientes conseguem adquirir e liberar locks.
Aquisição de Lock Distribuído no Redis
figura 1 · Aquisição de Lock Distribuído no Redis

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!
Armadilha do DEL Ingênuo vs Script Lua Atômico
figura 2 · Armadilha do DEL Ingênuo vs Script Lua Atômico

O Cenário do Desastre:

  1. O Pod A adquire o lock com TTL de 5 segundos.
  2. O Pod A sofre uma pausa de Garbage Collection ou lentidão de rede e demora 7 segundos para processar.
  3. No segundo 5, o Redis expira o lock do Pod A automaticamente.
  4. O Pod B vê o recurso livre e adquire com sucesso o lock para si mesmo!
  5. No segundo 7, o Pod A finalmente termina seu trabalho e executa DEL lock:pedido:42.
  6. 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:

  1. Ao adquirir o lock, gravamos um Token Único Aleatório (UUID v4) como valor da chave.
  2. 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?

MecanismoEscopoComplexidadeCaso de Uso Ideal
sync.MutexProcesso local (Memória Go)NulaSincronizar goroutines no mesmo pod
Lock Otimista (version)Banco RelacionalMínimaBaixa taxa de colisão em entidades SQL
Lock Pessimista (FOR UPDATE)Banco Relacional (PostgreSQL com pgx)BaixaTransações financeiras com consistência ACID estrita
Redis Distributed Lock (Single Node + Lua)Múltiplos PodsMédiaTarefas em background, desduplicação de webhooks, envio de emails
Redlock (Multi-Node Quorum)Múltiplos ClustersAltaOperações distribuídas críticas sem banco relacional centralizado

8. Resumo Conceitual

  • sync.Mutex não funciona em ambientes distribuídos conteinerizados (Kubernetes / múltiplos pods).
  • Nunca adquira locks com SETNX puro seguido de um DEL incondicional; 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 UPDATE não resolve seu problema de forma mais simples antes de adicionar complexidade de infraestrutura com Redis Locks.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?