Redlock: distributed lock en múltiples nodos Redis y su controversia

1. Descripción general

Cuando múltiples instancias de un servicio procesan el mismo recurso, por ejemplo restar inventario de un Order, necesitas un distributed lock. Un lock en un único nodo Redis es simple, pero si ese nodo muere, el lock desaparece. Redlock es un algoritmo propuesto por el autor de Redis (antirez) para obtener un lock en múltiples nodos Redis independientes.

Este artículo presenta cómo funciona Redlock, cómo implementarlo por tu cuenta con Jedis, cómo escribir pruebas con Testcontainers. La última parte analiza la controversia entre Martin Kleppmann y antirez. Después de leerlo, sabrás cuándo Redlock es suficiente y cuándo deberías elegir otra herramienta.

2. Por qué un único nodo Redis no es suficiente

Un lock básico en un nodo solo requiere un comando:

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

NX significa que solo establece el valor si la clave aún no existe. PX 30000 establece un TTL de 30 segundos para que el lock expire automáticamente si el cliente muere. El valor es un token aleatorio, usado para asegurar que solo el cliente propietario del lock puede borrarlo.

El problema está en la disponibilidad. Si usas replicación master–replica, entonces tienes este escenario:

  1. Cliente A obtiene lock en el master.
  2. El master muere antes de poder replicar la clave a la réplica (la replicación en Redis es asincrónica).
  3. La réplica es promovida a nuevo master, y en ella no existe la clave del lock.
  4. Cliente B obtiene el mismo lock.

Ahora A y B ambos tienen el lock. Redlock fue creado para manejar este escenario.

3. El algoritmo Redlock

Redlock usa N nodos Redis independientes, sin replicación y sin agruparse entre sí. Típicamente N = 5. El cliente obtiene el lock en estos pasos:

  1. Registra la hora de inicio.
  2. Envía consecutivamente SET key token NX PX ttl a todos los N nodos. Cada solicitud tiene un timeout corto (decenas de ms) para que un nodo muerto no bloquee el cliente.
  3. Calcula el tiempo transcurrido. El lock se considera obtenido exitosamente cuando ambas condiciones se cumplen:
    • obtener en al menos N/2 + 1 nodos (quórum),
    • el tiempo transcurrido es menor que el TTL.
  4. El tiempo de validez real del lock es ttl - elapsed - drift, donde drift compensa el desajuste de reloj entre nodos.
  5. Si falla, el cliente envía el comando unlock a todos los nodos, incluso aquellos en los que cree que no obtuvo el lock.

La idea central es que dos clientes no pueden ocupar la mayoría de los nodos al mismo tiempo.

4. Implementación de Redlock con Jedis

4.1. Dependencia

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

4.2. Clase 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; // nodo muere o timeout: considerado como no obtenido
        }
    }

    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) {
                // el nodo no responde entonces la clave expirará según el TTL
            }
        }
    }

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

Puntos principales:

  • quorum = N/2 + 1. Con 3 nodos, quorum es 2. Con 5 nodos, quorum es 3.
  • lockInstance captura la excepción y retorna false. Un nodo muerto solo reduce el número de votos, no arruina toda la operación.
  • validity es el tiempo en que realmente se permite considerar que tienes el lock. Este tiempo siempre es menor que el TTL.
  • Unlock usa un script Lua para que la operación “comparar token y luego eliminar” se ejecute atómicamente. Si usas GET y luego DEL como dos comandos separados, podrías eliminar por error un lock que otro cliente acaba de obtener.
  • Cuando falla, unlockAll envía a todos los nodos. La razón es que un comando SET podría haber tenido éxito en el servidor pero la respuesta fue rechazada por timeout en el cliente.

4.3. Inicializar los nodos con timeout corto

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);

El último parámetro es el timeout de 50 ms. Si dejas el timeout predeterminado de 2 segundos, un nodo colgado podría “consumir” casi todo el TTL del lock.

4.4. Usar en 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; // dejar que el llamador reintente con 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 siempre está en finally. El código dentro de la región del lock debe completarse en validityMillis. Si la operación puede prolongarse, necesitas un mecanismo de renovación del lock (watchdog) o dividir el trabajo en partes.

5. Pruebas con 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();
    }
}

La primera prueba verifica la exclusión mutua: B falla mientras A tiene el lock, y lo obtiene inmediatamente después de que A lo libera. La segunda prueba muestra que Redlock sigue funcionando cuando se pierde 1 de 3 nodos, porque los 2 nodos restantes forman quórum. Esta prueba detiene el contenedor completamente, así que colócala al final o sepárala en una clase diferente.

6. ¿Y qué hay de Redisson?

En la práctica, pocas personas escriben Redlock por su cuenta. Redisson solía proporcionar RedissonRedLock, aceptando múltiples RLock de clientes independientes.

Nota: En versiones recientes de Redisson, RedissonRedLock y RedissonMultiLock han sido marcadas como obsoletas (deprecated). Redisson recomienda usar RLock normal, que tiene un watchdog que se renueva automáticamente y puede sincronizar a una réplica, o RFencedLock si necesitas un fencing token. Verifica la versión que estás usando antes de elegir una API.

El hecho de que la biblioteca más popular también abandone Redlock nos lleva a la sección de controversia.

7. Controversia: Kleppmann vs antirez

7.1. Argumentación de Martin Kleppmann

Kleppmann, autor de Designing Data-Intensive Applications, señala que Redlock no es seguro para problemas que requieren correctness. Hay dos razones principales.

Pausa de proceso. Mira el siguiente escenario:

t=0     Cliente A obtiene lock (TTL 10s)
t=1     A es pausado por GC stop-the-world 15 segundos
t=10    Lock expira en todos los nodos
t=11    Cliente B obtiene lock, escribe datos en DB
t=16    A se despierta, aún cree que tiene el lock, sobrescribe datos de B

Ningún algoritmo de lock basado en TTL puede prevenir este escenario. Verificar validity justo antes de escribir tampoco ayuda, porque la pausa puede ocurrir inmediatamente después de la verificación. Las causas de pausa no son solo GC. Un page fault, una máquina virtual migrada o paquetes retrasados en la red causan efectos similares.

Dependencia del reloj. Redlock asume que los relojes de los nodos corren a una velocidad similar. Si el reloj de un nodo se adelanta, por ejemplo debido a un ajuste NTP agresivo o a un administrador cambiando la hora, la clave en ese nodo podría expirar prematuramente. Escenario específico con 5 nodos:

A obtiene lock en nodos 1, 2, 3
El reloj del nodo 3 se adelanta, la clave en el nodo 3 expira
B obtiene lock en nodos 3, 4, 5
=> A y B ambos tienen quórum

La conclusión de Kleppmann: para ser seguro, necesitas un fencing token, es decir, un número que aumenta monotónicamente junto con el lock. Redlock no proporciona este número, porque su token es una cadena aleatoria.

7.2. Cómo se ve un fencing token

La idea es que el storage rechace automáticamente las escrituras con tokens antiguos:

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;
    }
}

Aplicado al escenario de GC anterior: B escribe con token 34, A se despierta y escribe con token 33. El UPDATE de A afecta 0 filas, así que los datos de B se mantienen intactos. El punto crucial es que el storage, no el lock, es lo que garantiza correctness. El token debe generarse desde un sistema con consenso, como ZooKeeper (zxid) o etcd (revision).

7.3. Respuesta de antirez

antirez respondió con los siguientes puntos principales:

  • Si el storage ya soporta verificación de token tipo compare-and-set, puedes usar el mismo token aleatorio para verificar, y en ese caso el lock es casi innecesario.
  • Redlock tiene un paso de verificación de tiempo después de obtener el lock, así que la latencia de red durante la obtención se detecta. La pausa después de ese paso ciertamente afecta a todos los locks basados en TTL por igual.
  • Las suposiciones sobre el reloj son razonables si operas correctamente: no permitas que NTP cambie la hora abruptamente (usa modo slew) y no ajustes la hora manualmente.

Hasta ahora, ninguno de los lados ha “ganado” completamente. Sin embargo, la mayoría de la comunidad está de acuerdo con la clasificación de Kleppmann, presentada en la siguiente sección.

8. Cuándo usarlo, cuándo no

Pregúntate a ti mismo: ¿cuál es la consecuencia si dos clientes tienen el lock simultáneamente?

  • Efficiency lock (evitar duplicar trabajo): enviar correos de informe duplicados, ejecutar un cron job duplicado, recalcular caché. A veces la duplicación no es problema. En este caso, un único nodo Redis con SET NX PX es suficiente. Redlock consume 3–5 veces más infraestructura sin mejorar prácticamente nada.
  • Correctness lock (error = datos corruptos): transferencias de dinero, decremento de inventario, emisión de números de factura. Redlock no es suficiente. Deberías usar etcd o ZooKeeper con fencing token, o mejor aún, empujar las restricciones hacia la base de datos usando optimistic locking (@Version en JPA), SELECT ... FOR UPDATE o restricciones únicas.

Nota: Errores comunes al usar Redlock:

  • Usar Redis Cluster o master–replica como los “nodos” de Redlock. Los nodos deben ser completamente independientes.
  • Dejar el timeout de conexión predeterminado demasiado largo.
  • Desbloquear usando DEL en lugar de un script Lua que verifique el token.
  • Habilitar persistence pero no configurar fsync=always, o no retrasar el reinicio del nodo. Un reinicio del nodo y la pérdida de clave podrían hacer que dos clientes alcancen quórum simultáneamente. antirez recomienda que un nodo reiniciado espere al menos un tiempo igual al TTL máximo antes de reincorporarse.

9. Conclusión

Redlock obtiene un lock en la mayoría de N nodos Redis independientes, con cálculos de tiempo de validez para compensar latencia de red y desajuste de reloj. Has visto cómo implementarlo con Jedis, cómo desbloquear de manera segura con un script Lua y cómo probarlo con Testcontainers.

Redlock aún se basa en TTL y reloj, por lo que no puede resistir pausas de proceso ni saltos de reloj. Por lo tanto, con un lock solo para evitar duplicar trabajo, un único nodo Redis es suficiente. Para un lock que proteja la integridad de los datos, necesitas un fencing token de un sistema con consenso, o dejar que la base de datos mantenga las restricciones.

redisredlockdistributed-lockjavaconcurrency