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:
- Cliente A obtém a lock no master.
- O master cai antes de conseguir replicar a chave para a replica (a replicação no Redis é assíncrona).
- A replica é promovida a novo master, e ele não tem a chave de lock.
- 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:
- Registre o timestamp inicial.
- Envie
SET key token NX PX ttlsequencialmente 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. - Calcule o tempo transcorrido. A lock é considerada adquirida com sucesso quando ambas as condições são verdadeiras:
- adquirida em pelo menos
N/2 + 1nodes (quorum), - o tempo transcorrido é menor que o TTL.
- adquirida em pelo menos
- O tempo de validade efetivo da lock é
ttl - elapsed - drift, ondedriftcompensa a desvio de relógio entre os nodes. - 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.lockInstanceignora exceções e retornafalse. 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
GETeDELem dois comandos separados, você pode deletar acidentalmente a lock que outro cliente acaba de adquirir. - Se falhar,
unlockAllenvia para todos os nodes. A razão é que um comandoSETpode 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,
RedissonRedLockeRedissonMultiLockforam marcados como deprecated. Redisson recomenda usarRLockcomum, que possui um watchdog para renovar automaticamente e pode sincronizar com replicas, ouRFencedLockse 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 (
@Versionem JPA),SELECT ... FOR UPDATEou 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
DELem 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.