Estratégias de Implementação de Locks Distribuídos com Redis

Locks distribuídos são essenciais em sistemas onde múltiplos processos, operando em diferentes máquinas, necessitam de acesso exclusivo a um recurso compartilhado. O Redis, com sua capacidade de gerenciar chaves e valores de forma atômica e rápida, é uma escolha popular para implementar esses mecanismos. A ideia central é utilizar uma chave no Redis para representar o lock; a posse dessa chave indica que um processo adquiriu o lock.

Abordagem 1: Utilizando SETNX e EXPIRE (com ressalvas)

Uma implementação inicial pode envolver o comando SETNX (Set if Not Exists) para adquirir o lock. Se o comando retornar 1, o processo obteve o lock. Para liberá-lo, o comando DEL é usado. No enttanto, se o processo que detém o lock falhar antes de liberá-lo, o lock nunca será liberado, levando a um deadlock.

Para mitigar isso, adiciona-se um tempo de expiração usando EXPIRE. Contudo, a sequência SETNX seguido por EXPIRE não é atômica. Se o servidor Redis falhar após SETNX mas antes de EXPIRE, o lock ficará sem expiração, resultando em deadlock.

Abodragem 2: O Comando SET com NX e EX

O Redis oferece uma solução mais robusta com o comando SET, que permite especificar as opções NX (somente definir se a chave não existe) e EX (definir tempo de expiração em segundos) em uma única operação atômica.

O formato do comando é:

SET lock.key lock.value NX EX <tempo_expiracao>

Este método resolve o problema da não atomicidade entre SETNX e EXPIRE. No entanto, um novo problema surge:

  1. Um processo adquire o lock com um tempo de expiração de 30 segundos.
  2. O processo demora mais que 30 segundos para completar a operação. O lock expira.
  3. Um segundo processo adquire o lock.
  4. O primeiro processo, ao terminar, executa DEL para liberar o lock, mas acaba deletando o lock adquirido pelo segundo processo.

Para evitar a deleção indevida, o valor associado à chave do lock deve ser um identificador único do processo (e.g., ID da thread). Antes de deletar, o processo verifica se o valor atual da chave corresponde ao seu próprio identificador. Se corresponder, ele deleta. Para garantir a atomicidade desta verificação e deleção, pode-se utilizar scripts Lua.

Abordagem 3: Implementação Baseada em SETNX, GET, GETSET e Scripts Lua

Esta abordagem tenta gerenciar a aquisição e liberação de locks de forma mais granular:

  1. Tentar adquirir o lock com SETNX e armazenar o tempo de expiração + tempo atual como valor.
  2. Se falhar, usar GET para obter o tempo de expiração antigo. Comparar com o tempo atual. Se expirado, tentar atualizar o valor com GETSET, retornando o valor antigo.
  3. Comparar o valor retornado por GETSET com o valor que se esperava ter. Se forem iguais, o lock foi adquirido com sucesso.
  4. Ao liberar, verificar se o tempo de processamento é menor que o tempo de expiração do lock e se o processo ainda detém o lock antes de deletá-lo.

Problemas potenciais:

  • Dependência da sincronização de tempo entre servidores.
  • Conflitos em GETSET se múltiplos processos tentarem adquirir um lock expirado simultaneamente.
  • Possibilidade de múltiplos processos manterem o lock se o tempo de processamento exceder a expiração (resolvível com "renovação de lock").

Abordagem 4: RedLock - Alta Disponibilidade com Múltiplos Nós Redis

Para superar as limitações de um único nó Redis (ponto único de falha), o algoritmo RedLock utiliza um conjunto de nós Redis independentes (geralmente um número ímpar, como 5). A aquisição de um lock requer sucesso em uma maioria desses nós.

Princípio de Aquisição:

  1. Obter o tempo atual em milissegundos e definir um Time-To-Live (TTL) para o lock. O TTL deve ser maior que o tempo de execução esperado + tempo de rede + possível drift de relógio.
  2. Tentar adquirir o lock sequencialmente em cada nó Redis usando a mesma chave e um valor único para o cliente. Definir um timeout curto para cada requisição individual, menor que o TTL.
  3. Se um número maior que a metade dos nós retornar sucesso, o lock é considerado adquirido.
  4. O tempo de vida efetivo do lock é calculado como TTL - tempo\_aquisição - drift\_relógio.
  5. Se a aquisição falhar (não obter maioria ou demorar muito), liberar o lock em todos os nós onde ele foi adquirido.
  6. Em caso de falha na aquisição, o cliente deve esperar um tempo aleatório antes de tentar novamente, com um limite de tentativas.

Considerações sobre RedLock:

  • Alta Disponibilidade: Melhora a resiliência comparado a um único nó.
  • Persistência e Recuperação: A segurança do lock depende da configuração de persistência do Redis. Sem persistência ou com AOF configurado de forma a perder dados em caso de falha rápida, a exclusividade pode ser comprometida após reinicializações.
  • "Reinicialização Atrasada" (Delayed Restart): Uma estratégia para mitigar problemas de persistência é atrasar a reinicialização de nós Redis após uma falha, permitindo que os locks existentes expirem naturalmente.

Abordagem 5: Redisson e o Mecanismo "Watchdog"

A biblioteca Redisson para Java oferece implementações robustas de locks distribuídos, incluindo uma versão do RedLock e um mecanismo chamado "Watchdog".

Mecanismo Watchdog:

Quando um lock é adquirido com sucesso, o Redisson inicia um thread de "guarda" (watchdog). Este thread monitora o tempo de expiração do lock. Se o lock estiver prestes a expirar e o processo ainda o detiver, o watchdog o renovará automaticamente, estendendo seu tempo de vida. Isso resolve o problema de processos que demoram mais que o tempo de expiração original.

Implementação do Redisson (Exemplos):

O Redisson utiliza scripts Lua para garantir a atomicidade das operações de aquisição e liberação, incluindo a lógica de reentrada e a geração de IDs únicos para os clientes.

Exemplo de Aquisição (simplificado via script Lua):


-- Verifica se a chave do lock existe
if (redis.call('exists', KEYS[1]) == 0) then
   -- Se não existe, define o campo com o ID do cliente e o valor 1
   redis.call('hset', KEYS[1], ARGV[2], 1);
   -- Define o tempo de expiração do lock
   redis.call('pexpire', KEYS[1], ARGV[1]);
   return nil; -- Sucesso
end;
-- Se a chave existe, verifica se o ID do cliente é o mesmo
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
   -- Se for o mesmo, incrementa o contador de reentrada e renova a expiração
   redis.call('hincrby', KEYS[1], ARGV[2], 1);
   redis.call('pexpire', KEYS[1], ARGV[1]);
   return nil; -- Sucesso (reentrada)
end;
-- Se a chave existe mas o ID é diferente, retorna o tempo de vida restante
return redis.call('pttl', KEYS[1]);
   

Exemplo de Liberação (simplificado via script Lua):


-- Verifica se a chave do lock existe
if (redis.call('exists', KEYS[1]) == 0) then
   redis.call('publish', KEYS[2], ARGV[1]); -- Publica mensagem de desbloqueio
   return 1; -- Lock não existia, já liberado
end;
-- Verifica se o ID do cliente corresponde
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
   return nil; -- ID não corresponde, não é o dono do lock
end;
-- Decrementa o contador de reentrada
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
-- Se o contador for maior que 0, apenas renova a expiração
if (counter > 0) then
   redis.call('pexpire', KEYS[1], ARGV[2]);
   return 0; -- Lock reentrante, não totalmente liberado
else
   -- Se o contador for 0, deleta a chave e publica mensagem de desbloqueio
   redis.call('del', KEYS[1]);
   redis.call('publish', KEYS[2], ARGV[1]);
   return 1; -- Lock liberado com sucesso
end;
return nil;
   

Uso com Redisson:


Config config = new Config();
// Configuração para Sentinel ou Cluster
config.useSentinelServers()
     .addSentinelAddress("127.0.0.1:6369", "127.0.0.1:6379", "127.0.0.1:6389")
     .setMasterName("myMaster")
     .setPassword("secret");

RedissonClient redissonClient = Redisson.create(config);
RLock redLock = redissonClient.getLock("myDistributedLock");

try {
   // Tenta adquirir o lock por 500ms, com expiração automática em 10000ms
   boolean acquired = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
   if (acquired) {
       // Executar lógica crítica aqui
   }
} catch (InterruptedException e) {
   Thread.currentThread().interrupt();
} finally {
   if (redLock.isLocked()) {
       redLock.unlock();
   }
}
   

Desvantagem Fundamental da Replicação Assíncrona:

Mesmo com abordagens como RedLock ou configurações Master-Slave/Sentinel, um cenário crítico pode ocorrer: um nó Master pode falhar logo após a escrita de um lock, mas antes que essa escrita seja replicada para o Slave. Quando o Slave se torna o novo Master, ele não terá o lock, permitindo que outro cliente o adquira, violando a exclusividade.

Tags: Redis Distributed Lock Concurrency scalability RedLock

Publicado em 11-1 04:05