Como implementar distributed locking com Redis para evitar race conditions

1. Introdução ao Distributed Locking e Race Conditions

Em sistemas distribuídos, race conditions ocorrem quando múltiplos processos ou threads concorrentes acessam e modificam um recurso compartilhado sem coordenação adequada, levando a estados inconsistentes. Imagine dois workers processando pagamentos simultaneamente e debitando o mesmo saldo — sem controle, ambos poderiam considerar o saldo suficiente, gerando inconsistência financeira.

O distributed locking é uma técnica que garante que apenas um nó do sistema tenha acesso exclusivo a um recurso crítico em um determinado momento. Redis se destaca como escolha popular por sua baixa latência, operações atômicas e simplicidade de implementação. Com um lock distribuído baseado em Redis, podemos serializar o acesso a recursos compartilhados, prevenindo race conditions sem sacrificar desempenho.

2. Fundamentos do Redis para Locking

O Redis oferece comandos essenciais para implementar locks distribuídos:

  • SET key value NX EX timeout: Comando atômico que define uma chave apenas se ela não existir (NX), com expiração automática (EX). Essa atomicidade é crucial para evitar condições de corrida durante a aquisição do lock.
  • EXPIRE key seconds: Define um TTL para liberar automaticamente o lock em caso de falha.
  • DEL key: Remove a chave para liberar o lock manualmente.

O comando SETNX (SET if Not eXists) era tradicionalmente usado, mas o comando SET com opções NX e EX é preferível por ser atômico, evitando a necessidade de múltiplas operações.

3. Implementação Básica de um Lock com Redis

A implementação mais simples utiliza o comando SET com opções NX e EX:

# Aquisição do lock
SET resource_lock "unique_identifier" NX EX 30
# Retorna OK se adquiriu, nil se já existe

# Liberação do lock
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Exemplo prático em pseudocódigo:

def acquire_lock(redis_client, lock_name, timeout_seconds):
    identifier = generate_uuid()
    success = redis_client.set(lock_name, identifier, nx=True, ex=timeout_seconds)
    if success:
        return identifier
    return None

def release_lock(redis_client, lock_name, identifier):
    script = """
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    """
    redis_client.eval(script, 1, lock_name, identifier)

O timeout (TTL) é fundamental como mecanismo de segurança contra deadlocks: se o processo que adquiriu o lock falhar, o lock será automaticamente liberado após o tempo configurado.

4. Lidando com Problemas de Consistência e Segurança

Um problema crítico ocorre quando o processo que adquiriu o lock trava antes de liberá-lo. Sem validação, outro processo poderia liberar acidentalmente o lock de outro. A solução é usar um identificador único:

# Script Lua para liberação segura
local lock_value = redis.call("GET", KEYS[1])
if lock_value == ARGV[1] then
    redis.call("DEL", KEYS[1])
    return 1
else
    return 0
end

Esse script verifica se o valor armazenado corresponde ao identificador do proprietário antes de liberar, prevenindo que processos maliciosos ou atrasados removam locks alheios.

5. Redlock: Algoritmo para Alta Disponibilidade

Para ambientes críticos, o algoritmo Redlock proposto por Salvatore Sanfilippo (criador do Redis) oferece maior resiliência:

# Algoritmo Redlock simplificado
1. Obter timestamp atual T1
2. Tentar adquirir lock em N instâncias Redis independentes
3. Considerar lock adquirido se obteve em maioria (N/2 + 1) dentro do timeout
4. Tempo total gasto = T2 - T1
5. Se lock adquirido, tempo de validade efetivo = TTL - (T2 - T1)
6. Para liberar, deletar de todas as instâncias

# Exemplo com 5 instâncias Redis
instances = ["redis://node1:6379", "redis://node2:6379", "redis://node3:6379",
             "redis://node4:6379", "redis://node5:6379"]
success_count = 0
start_time = current_time_millis()

for instance in instances:
    if acquire_lock(instance, "resource", identifier, ttl=1000):
        success_count += 1
    elapsed = current_time_millis() - start_time
    if elapsed > 1000:
        break

if success_count >= 3 and elapsed <= 1000:
    lock_validity = 1000 - elapsed
    # Lock adquirido com sucesso
else:
    # Falha na aquisição, liberar locks parciais

Trade-offs: Redlock oferece alta disponibilidade, mas assume que clocks dos servidores estão sincronizados. Em cenários com clock drift severo, pode haver inconsistências.

6. Padrões Avançados e Boas Práticas

Watchdog para renovação automática: Em operações longas, implemente um mecanismo que renove o TTL periodicamente:

def acquire_lock_with_watchdog(redis_client, lock_name, ttl=30, renewal_interval=10):
    identifier = generate_uuid()
    if not redis_client.set(lock_name, identifier, nx=True, ex=ttl):
        return None

    def renewal_loop():
        while True:
            sleep(renewal_interval)
            redis_client.expire(lock_name, ttl)

    thread = start_background_thread(renewal_loop)
    return (identifier, thread)

Fences e leases: Utilize números de sequência (fences) para evitar problemas de ordem. Um fence token incrementa a cada aquisição, permitindo que o recurso rejeite operações com tokens desatualizados.

Bibliotecas maduras: Redisson (Java) e redis-py-lock (Python) implementam esses padrões de forma robusta, lidando com edge cases como falhas de rede e timeouts.

7. Casos de Uso e Exemplos Práticos

Sincronização de jobs agendados:

# Job que deve executar apenas em um worker
def process_daily_report():
    lock_id = acquire_lock(redis, "report_job_lock", 300)
    if not lock_id:
        return  # Outro worker já está processando
    try:
        generate_report()
        send_emails()
    finally:
        release_lock(redis, "report_job_lock", lock_id)

Controle de estoque:

def reserve_product(product_id, quantity):
    lock_name = f"stock_lock:{product_id}"
    lock_id = acquire_lock(redis, lock_name, 5)
    if not lock_id:
        raise Exception("Recurso ocupado")
    try:
        current_stock = redis.get(f"stock:{product_id}")
        if current_stock >= quantity:
            redis.decrby(f"stock:{product_id}", quantity)
            return True
        return False
    finally:
        release_lock(redis, lock_name, lock_id)

Comparado a ZooKeeper ou etcd, Redis oferece menor latência e simplicidade, mas com garantias de consistência mais fracas. Para sistemas financeiros críticos, ZooKeeper pode ser mais adequado.

8. Monitoramento e Debug de Locks Distribuídos

Métricas essenciais para monitorar:

# Métricas via Redis INFO
- hit_rate: locks adquiridos com sucesso / total tentativas
- contention_rate: tentativas de lock que falharam por contenção
- avg_lock_time: tempo médio que locks permanecem ativos
- expiration_rate: locks que expiraram por timeout

# Logging estruturado
log_lock_event(
    lock_name="resource_lock",
    action="acquire" | "release" | "expire",
    identifier="uuid-1234",
    duration_ms=150,
    success=True
)

Estratégias de fallback: implemente circuit breakers para evitar cascateamento de falhas e use locks com timeout decrescente para mitigar contenção excessiva.

Referências