Redlock: distributed lock em múltiplos nodes Redis e controvérsias

1. Visão geral

Quando múltiplas instâncias de um serviço processam um recurso em comum, como abater o estoque de um Order, você precisa de uma distributed lock. Uma lock em um único node Redis é simples, mas se esse node cai, a lock desaparece. Redlock é um algoritmo proposto pelo autor do Redis (antirez) para obter lock em múltiplos nodes Redis independentes.

Este artigo apresenta como o Redlock funciona, como implementá-lo você mesmo com Jedis, como escrever testes com Testcontainers. A última parte analisa a controvérsia entre Martin Kleppmann e antirez. Ao final, você saberá quando o Redlock é suficiente e quando deve escolher outra ferramenta.

2. Por que um único node Redis é insuficiente

Uma lock básica em um único node requer apenas um comando:

SET lock:order:42 "c3a1-uuid" NX PX 30000

NX significa “set only if key does not exist”. PX 30000 define o TTL para 30 segundos, de forma que a lock se expire automaticamente se o cliente morrer. O valor é um token aleatório, usado para que apenas o cliente proprietário possa deletar a lock.

O problema está na disponibilidade. Se você usar replicação master–replica, há o seguinte cenário:

  1. Cliente A obtém a lock no master.
  2. O master cai antes de conseguir replicar a chave para a replica (a replicação no Redis é assíncrona).
  3. A replica é promovida a novo master, e ele não tem a chave de lock.
  4. Cliente B obtém a mesma lock.

Neste ponto, A e B ambos mantêm a lock. Redlock foi criado para lidar com este cenário.

3. O algoritmo Redlock

Redlock usa N nodes Redis independentes, sem replicação e sem cluster entre si. Geralmente N = 5. O cliente obtém a lock nos seguintes passos:

  1. Registre o timestamp inicial.
  2. Envie SET key token NX PX ttl sequencialmente para todos os N nodes. Cada requisição tem um timeout curto (dezenas de ms) para que um node morto não deixe o cliente suspenso.
  3. Calcule o tempo transcorrido. A lock é considerada adquirida com sucesso quando ambas as condições são verdadeiras:
    • adquirida em pelo menos N/2 + 1 nodes (quorum),
    • o tempo transcorrido é menor que o TTL.
  4. O tempo de validade efetivo da lock é ttl - elapsed - drift, onde drift compensa a desvio de relógio entre os nodes.
  5. Se falhar, o cliente envia o comando de unlock para todos os nodes, incluindo aqueles que ele acredita que não conseguiu adquirir a lock.

A ideia central é que dois clientes não podem simultaneamente controlar a maioria dos nodes no mesmo período de tempo.

4. Implementando Redlock com Jedis

4.1. Dependência

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>

4.2. Classe Redlock

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class Redlock {

    private static final String UNLOCK_SCRIPT = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;
    private static final double CLOCK_DRIFT_FACTOR = 0.01;

    private final List<JedisPool> nodes;
    private final int quorum;

    public Redlock(List<JedisPool> nodes) {
        this.nodes = nodes;
        this.quorum = nodes.size() / 2 + 1;
    }

    public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
        String token = UUID.randomUUID().toString();
        long start = System.nanoTime();

        int acquired = 0;
        for (JedisPool pool : nodes) {
            if (lockInstance(pool, resource, token, ttlMillis)) {
                acquired++;
            }
        }

        long elapsed = (System.nanoTime() - start) / 1_000_000;
        long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
        long validity = ttlMillis - elapsed - drift;

        if (acquired >= quorum && validity > 0) {
            return Optional.of(new LockHandle(resource, token, validity));
        }
        unlockAll(resource, token);
        return Optional.empty();
    }

    public void unlock(LockHandle handle) {
        unlockAll(handle.resource(), handle.token());
    }

    private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
        try (Jedis jedis = pool.getResource()) {
            return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
        } catch (JedisException e) {
            return false; // node down ou timeout: consideramos como não adquirida
        }
    }

    private void unlockAll(String resource, String token) {
        for (JedisPool pool : nodes) {
            try (Jedis jedis = pool.getResource()) {
                jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
            } catch (JedisException ignored) {
                // se o node não responder, a chave expirará automaticamente pelo TTL
            }
        }
    }

    public record LockHandle(String resource, String token, long validityMillis) {}
}

Pontos principais:

  • quorum = N/2 + 1. Com 3 nodes, quorum é 2. Com 5 nodes, quorum é 3.
  • lockInstance ignora exceções e retorna false. Um node morto apenas reduz o número de votos, não prejudica a operação inteira.
  • validity é o tempo durante o qual você realmente pode considerar que está mantendo a lock. Este tempo é sempre menor que o TTL.
  • Unlock usa um script Lua para que a operação de “comparar token e depois deletar” seja executada atomicamente. Se você fizer GET e DEL em dois comandos separados, você pode deletar acidentalmente a lock que outro cliente acaba de adquirir.
  • Se falhar, unlockAll envia para todos os nodes. A razão é que um comando SET pode ter tido sucesso no servidor, mas a resposta sofrer timeout no cliente.

4.3. Inicializando nodes com timeout curto

import redis.clients.jedis.JedisPoolConfig;

List<JedisPool> pools = List.of(
        new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);

O último parâmetro é o timeout de 50 ms. Se você deixar o timeout padrão de 2 segundos, um node lento pode “consumir” quase todo o TTL da lock.

4.4. Usando em InventoryService

public class InventoryService {

    private final Redlock redlock;
    private final InventoryRepository repository;

    public InventoryService(Redlock redlock, InventoryRepository repository) {
        this.redlock = redlock;
        this.repository = repository;
    }

    public boolean reserve(String sku, int quantity) {
        var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
        if (handle.isEmpty()) {
            return false; // deixe o chamador fazer retry com backoff
        }
        try {
            int stock = repository.findStock(sku);
            if (stock < quantity) return false;
            repository.updateStock(sku, stock - quantity);
            return true;
        } finally {
            redlock.unlock(handle.get());
        }
    }
}

Unlock sempre fica em finally. O código dentro da seção crítica da lock deve terminar dentro de validityMillis. Se a operação pode se prolongar, você precisa de um mecanismo para renovar a lock (watchdog) ou dividir o trabalho em partes menores.

5. Testando com Testcontainers

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {

    @Container static GenericContainer<?> r1 = redis();
    @Container static GenericContainer<?> r2 = redis();
    @Container static GenericContainer<?> r3 = redis();

    static GenericContainer<?> redis() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }

    static Redlock newClient() {
        return new Redlock(Stream.of(r1, r2, r3)
                .map(c -> new JedisPool(new JedisPoolConfig(),
                        c.getHost(), c.getMappedPort(6379), 200))
                .toList());
    }

    @Test
    void secondClientCannotAcquireWhileFirstHoldsLock() {
        Redlock clientA = newClient();
        Redlock clientB = newClient();

        var lockA = clientA.tryLock("lock:order:42", 5_000);
        var lockB = clientB.tryLock("lock:order:42", 5_000);

        assertThat(lockA).isPresent();
        assertThat(lockA.get().validityMillis()).isLessThan(5_000);
        assertThat(lockB).isEmpty();

        clientA.unlock(lockA.get());
        assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
    }

    @Test
    void stillAcquiresWhenOneNodeIsDown() {
        Redlock client = newClient();
        r3.stop();

        assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
    }
}

O primeiro teste verifica a exclusão mútua: B falha enquanto A mantém a lock, e consegue adquiri-la logo após A fazer unlock. O segundo teste mostra que Redlock ainda funciona quando um de três nodes cai, pois os 2 nodes restantes formam um quorum. Este teste para completamente o container, então coloque-o no final ou mova-o para uma classe separada.

6. E quanto ao Redisson?

Na prática, poucas pessoas implementam Redlock por conta própria. Redisson costumava fornecer RedissonRedLock, que recebia múltiplos RLock de clientes independentes.

Nota: Em versões recentes do Redisson, RedissonRedLock e RedissonMultiLock foram marcados como deprecated. Redisson recomenda usar RLock comum, que possui um watchdog para renovar automaticamente e pode sincronizar com replicas, ou RFencedLock se você precisar de um fencing token. Verifique a versão que está usando antes de escolher a API.

O fato de até a biblioteca mais popular abandonar o Redlock nos leva à seção de controvérsias.

7. Controvérsias: Kleppmann vs antirez

7.1. Os argumentos de Martin Kleppmann

Kleppmann, autor de Designing Data-Intensive Applications, aponta que Redlock não é seguro para problemas que exigem correctness. Há dois motivos principais.

Process pause. Veja o seguinte cenário:

t=0     Cliente A obtém a lock (TTL 10s)
t=1     A sofre com GC stop-the-world por 15 segundos
t=10    A lock expira em todos os nodes
t=11    Cliente B obtém a lock, escreve dados no BD
t=16    A acorda, ainda pensa que mantém a lock, sobrescreve os dados de B

Nenhum algoritmo de lock baseado em TTL pode evitar este cenário. Verificar validity logo antes de escrever também não ajuda, pois a pausa pode ocorrer imediatamente após a verificação. A pausa não é apenas causada por GC. Page faults, migração de VM ou atraso de pacotes na rede causam o mesmo efeito.

Dependência de relógio. Redlock assume que os relógios dos nodes correm aproximadamente na mesma velocidade. Se o relógio de um node sofrer um salto, por exemplo devido a ajuste agressivo de NTP ou correção manual de hora, a chave naquele node pode expirar prematuramente. Cenário específico com 5 nodes:

A obtém lock nos nodes 1, 2, 3
O relógio do node 3 salta para frente, a chave no node 3 expira
B obtém lock nos nodes 3, 4, 5
=> A e B ambos mantêm o quorum

A conclusão de Kleppmann: para ser seguro, você precisa de um fencing token, um número monotônico crescente acompanhando a lock. Redlock não fornece isso, pois seu token é uma string aleatória.

7.2. Como é um fencing token

A ideia é que o storage rejeite automaticamente escritas com tokens antigos:

public class FencedInventoryRepository {

    private final JdbcTemplate jdbc;

    public FencedInventoryRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean updateStock(String sku, int newStock, long fencingToken) {
        int rows = jdbc.update("""
                UPDATE inventory SET stock = ?, last_token = ?
                WHERE sku = ? AND last_token < ?
                """, newStock, fencingToken, sku, fencingToken);
        return rows == 1;
    }
}

Aplicando ao cenário de GC acima: B escreve com token 34, A acorda e escreve com token 33. O UPDATE de A afeta 0 linhas, então os dados de B são preservados. O ponto crucial é que o storage, não a lock, é o que garante a correção. O token deve ser gerado por um sistema com consensus, como ZooKeeper (zxid) ou etcd (revision).

7.3. A resposta de antirez

antirez respondeu com os seguintes pontos principais:

  • Se o storage já suporta verificações do tipo compare-and-set de token, você pode usar o próprio token aleatório para verificar, e nesse caso a lock é quase desnecessária.
  • Redlock tem uma etapa de verificação de tempo após adquirir a lock, então latências de rede durante a aquisição são detectadas. Pause após essa etapa é de fato um problema comum a todas as locks com TTL.
  • A suposição sobre relógios é razoável se você operar corretamente: não deixe NTP pular a hora abruptamente (use modo slew) e não ajuste a hora manualmente.

Até agora, nenhum dos dois “venceu” definitivamente. No entanto, a maior parte da comunidade concorda com a classificação de Kleppmann, apresentada na próxima seção.

8. Quando usar e quando não usar

Faça a si mesmo a pergunta: o que acontece se dois clientes mantêm a lock simultaneamente?

  • Efficiency lock (evitar fazer trabalho duplicado): enviar email de relatório duplicado, rodar um cron job duplicado, recalcular cache. Ocasionalmente duplicado não é um problema. Neste caso, um único node Redis com SET NX PX é suficiente. Redlock consome 3–5 vezes a infraestrutura e quase não melhora nada significativo.
  • Correctness lock (errar corrompe dados): transferências, redução de estoque, emissão de números de nota fiscal. Redlock não é suficiente. Você deve usar etcd ou ZooKeeper com fencing token, ou melhor ainda, colocar a restrição no banco de dados usando optimistic locking (@Version em JPA), SELECT ... FOR UPDATE ou unique constraint.

Nota: Erros comuns ao usar Redlock:

  • Usar Redis Cluster ou master–replica como os “nodes” do Redlock. Os nodes devem ser completamente independentes.
  • Deixar o timeout de conexão padrão muito longo.
  • Fazer unlock com DEL em vez de um script Lua que verifica o token.
  • Ativar persistência mas não configurar fsync=always, ou não adiar o reinicializável do node. Um node que reinicia e perde a chave pode fazer dois clientes atingirem o quorum simultaneamente. antirez recomenda que um node reiniciado espere pelo menos um período igual ao TTL máximo antes de se reintegrar.

9. Conclusão

Redlock adquire a lock na maioria entre N nodes Redis independentes, com cálculo de tempo de validade para compensar latência de rede e desvio de relógio. Você viu como implementá-lo com Jedis, como fazer unlock seguro com script Lua e como testá-lo com Testcontainers.

Redlock ainda se baseia em TTL e relógios, então não é resistente a process pause ou saltos de relógio. Portanto, para locks que apenas evitam trabalho duplicado, um único node Redis é suficiente. Para locks que protegem a correção dos dados, você precisa de um fencing token de um sistema com consensus, ou deixar que o banco de dados mantenha as restrições.

redisredlockdistributed-lockjavaconcurrency