Redlock : verrous distribués sur plusieurs nœuds Redis
1. Vue d’ensemble
Quand plusieurs instances d’un service traitent la même ressource, par exemple réduire l’inventaire d’une Order, vous avez besoin d’un verrou distribué. Un verrou sur un seul nœud Redis est simple, mais si le nœud meurt, le verrou disparaît. Redlock est un algorithme proposé par l’auteur de Redis (antirez) pour acquérir un verrou sur plusieurs nœuds Redis indépendants.
Cet article présente le fonctionnement de Redlock, comment l’implémenter avec Jedis, comment écrire des tests avec Testcontainers. La dernière partie analyse la controverse entre Martin Kleppmann et antirez. Après la lecture, vous saurez quand Redlock est suffisant et quand choisir un autre outil.
2. Pourquoi un seul nœud Redis ne suffit pas
Un verrou basique sur un seul nœud ne nécessite qu’une commande :
SET lock:order:42 "c3a1-uuid" NX PX 30000
NX signifie définir uniquement si la clé n’existe pas encore. PX 30000 définit un TTL de 30 secondes pour que le verrou expire automatiquement si le client meurt. La valeur est un jeton aléatoire, utilisé pour s’assurer que seul le client propriétaire du verrou peut le supprimer.
Le problème réside dans la disponibilité. Si vous utilisez la réplication master-replica, voici un scénario possible :
- Le client A acquiert le verrou sur le master.
- Le master meurt avant de répliquer la clé vers le replica (la réplication Redis est asynchrone).
- Le replica est promu en nouveau master, et sur celui-ci il n’y a pas de clé de verrou.
- Le client B acquiert le même verrou.
À ce stade, A et B détiennent tous les deux le verrou. Redlock a été créé pour traiter ce scénario.
3. L’algorithme Redlock
Redlock utilise N nœuds Redis indépendants, sans réplication ni cluster entre eux. Généralement N = 5. Le client acquiert le verrou selon les étapes suivantes :
- Enregistrez l’heure de début.
- Envoyez successivement
SET key token NX PX ttlà tous les N nœuds. Chaque requête a un timeout court (quelques dizaines de ms) pour qu’un nœud mort ne bloque pas le client. - Calculez le temps écoulé. Le verrou est considéré comme acquis avec succès si les deux conditions sont remplies :
- acquis sur au moins
N/2 + 1nœuds (quorum), - le temps écoulé est inférieur au TTL.
- acquis sur au moins
- Le temps de validité réel du verrou est
ttl - elapsed - drift, oùdriftcompense le décalage d’horloge entre les nœuds. - En cas d’échec, le client envoie la commande unlock à tous les nœuds, y compris ceux dont il pense n’avoir pas acquis le verrou.
L’idée centrale est que deux clients ne peuvent pas tous les deux dominer la majorité des nœuds pendant le même laps de temps.
4. Implémentation de Redlock avec Jedis
4.1. Dépendance
<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; // nœud mort ou timeout : considéré comme non acquis
}
}
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) {
// si le nœud ne répond pas, la clé expirera automatiquement selon le TTL
}
}
}
public record LockHandle(String resource, String token, long validityMillis) {}
}
Points clés :
quorum = N/2 + 1. Avec 3 nœuds, quorum est 2. Avec 5 nœuds, quorum est 3.lockInstanceabsorbe l’exception et retournefalse. Un nœud mort réduit seulement le nombre de votes, il ne ruine pas toute l’opération.validityest le temps pendant lequel vous êtes réellement autorisé à considérer que vous déteniez le verrou. Ce temps est toujours inférieur au TTL.- Unlock utilise un script Lua pour que l’opération « comparer le token puis supprimer » s’exécute atomiquement. Si vous faites
GETpuisDELen deux commandes séparées, vous pourriez accidentellement supprimer le verrou qu’un autre client vient d’acquérir. - En cas d’échec,
unlockAllenvoie à tous les nœuds. La raison est qu’une commandeSETpeut avoir réussi côté serveur mais la réponse a timed out côté client.
4.3. Initialiser les nœuds avec un timeout court
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);
Le dernier paramètre est le timeout de 50 ms. Si vous laissez le timeout par défaut de 2 secondes, un nœud qui traîne peut « manger » presque la totalité du TTL du verrou.
4.4. Utiliser dans 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; // laisser l'appelant réessayer avec 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 est toujours dans finally. Le code dans la section du verrou doit s’exécuter dans validityMillis. Si l’opération peut prendre du temps, vous avez besoin d’un mécanisme de renouvellement du verrou (watchdog) ou de diviser le travail en plus petits morceaux.
5. Tests avec 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();
}
}
Le premier test vérifie l’exclusivité : B échoue alors que A détient le verrou, et l’acquiert immédiatement après que A le libère. Le deuxième test montre que Redlock fonctionne toujours quand on perd 1 nœud sur 3, car les 2 nœuds restants forment le quorum. Ce test arrête complètement le conteneur, donc placez-le à la fin, ou mettez-le dans une classe séparée.
6. Qu’en est-il de Redisson ?
En pratique, peu de gens implémentent Redlock eux-mêmes. Redisson fournissait autrefois RedissonRedLock, acceptant plusieurs RLock depuis des clients indépendants.
Remarque : Dans les versions récentes de Redisson,
RedissonRedLocketRedissonMultiLockont été marquées comme deprecated. Redisson recommande d’utiliserRLockordinaire, qui dispose d’un watchdog pour renouveler automatiquement et peut synchroniser vers une replica, ouRFencedLocksi vous avez besoin d’un fencing token. Vérifiez votre version avant de choisir l’API.
Le fait que la bibliothèque la plus populaire ait également abandonné Redlock nous amène à la section sur la controverse.
7. Controverse : Kleppmann vs antirez
7.1. L’argumentation de Martin Kleppmann
Kleppmann, auteur du livre Designing Data-Intensive Applications, souligne que Redlock n’est pas sûr pour les problèmes nécessitant la correctness. Il y a deux raisons principales.
Process pause. Regardez ce scénario :
t=0 Client A acquiert le verrou (TTL 10s)
t=1 A subit un garbage collection stop-the-world de 15 secondes
t=10 Le verrou expire sur tous les nœuds
t=11 Client B acquiert le verrou, écrit des données dans la BD
t=16 A se réveille, pense toujours détenir le verrou, écrase les données de B
Aucun algorithme de verrou basé sur TTL ne peut empêcher ce scénario. Vérifier validity juste avant d’écrire n’aide pas non plus, car la pause peut se produire immédiatement après la vérification. La pause n’est pas seulement due au GC. Les défauts de page, la migration de VM ou les paquets retardés sur le réseau causent le même effet.
Dépendance à l’horloge. Redlock suppose que les horloges des nœuds tournent à peu près à la même vitesse. Si l’horloge d’un nœud s’accélère, par exemple à cause d’une correction NTP agressive ou d’une modification manuelle de l’heure, une clé sur ce nœud peut expirer prématurément. Scénario concret avec 5 nœuds :
A acquiert le verrou sur les nœuds 1, 2, 3
L'horloge du nœud 3 s'accélère, la clé sur le nœud 3 expire
B acquiert le verrou sur les nœuds 3, 4, 5
=> A et B détiennent tous les deux le quorum
Conclusion de Kleppmann : pour être sûr, vous avez besoin d’un fencing token, c’est-à-dire d’un nombre qui augmente de manière monotone accompagnant le verrou. Redlock ne fournit pas ce nombre, car son token est une chaîne aléatoire.
7.2. À quoi ressemble un fencing token
L’idée est que le stockage refuse les écritures avec un token obsolète :
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;
}
}
Appliquons-le au scénario GC ci-dessus : B écrit avec le token 34, A se réveille et écrit avec le token 33. La clause UPDATE de A affecte 0 lignes, donc les données de B sont préservées. Le point crucial est que le stockage, pas le verrou, est ce qui assure la correctness. Le token doit provenir d’un système ayant le consensus, comme ZooKeeper (zxid) ou etcd (revision).
7.3. Réponse d’antirez
antirez a répliqué avec ces points principaux :
- Si le stockage supporte déjà la vérification de token avec compare-and-set, vous pouvez utiliser le token aléatoire lui-même pour vérifier, et le verrou devient pratiquement inutile.
- Redlock a une étape de vérification de temps après l’acquisition du verrou, donc le retard réseau lors de l’acquisition est détecté. Une pause après cette étape affecte vraiment tout verrou basé sur TTL.
- Les hypothèses d’horloge sont raisonnables si vous exploitez correctement : ne pas laisser NTP sauter l’heure brutalement (utiliser le mode slew) et ne pas corriger manuellement l’heure.
À ce jour, aucun camp n’a « gagné » de manière absolue. Cependant, la communauté s’accorde largement avec la classification de Kleppmann, présentée à la section suivante.
8. Quand l’utiliser, quand ne pas l’utiliser
Posez-vous la question : si deux clients détiennent le verrou en même temps, quelles sont les conséquences ?
- Efficiency lock (éviter de faire du travail en double) : envoyer des emails de rapport en double, exécuter deux fois un cron job, recalculer le cache. Un doublon occasionnel est inoffensif. Pour ce cas, un seul nœud Redis avec
SET NX PXsuffit. Redlock coûte 3 à 5 fois plus en infrastructure sans amélioration réelle. - Correctness lock (l’erreur casse les données) : transférer de l’argent, réduire l’inventaire, émettre des numéros de facture. Redlock n’est pas suffisant. Vous devriez utiliser etcd ou ZooKeeper avec un fencing token, ou mieux encore, pousser la contrainte dans la base de données avec optimistic locking (
@Versionen JPA),SELECT ... FOR UPDATEou unique constraint.
Remarque : Erreurs courantes lors de l’utilisation de Redlock :
- Utiliser Redis Cluster ou master-replica comme « nœuds » de Redlock. Les nœuds doivent être totalement indépendants.
- Laisser le timeout de connexion par défaut trop long.
- Supprimer le verrou avec
DELau lieu du script Lua qui vérifie le token.- Activer la persistance sans configurer
fsync=always, ou ne pas retarder le redémarrage du nœud. Un redémarrage de nœud et la perte de la clé peuvent faire en sorte que deux clients atteignent le quorum. antirez recommande que le nœud redémarré attende au moins un intervalle égal au plus grand TTL avant de se rejoindre.
9. Conclusion
Redlock acquiert un verrou sur la majorité de N nœuds Redis indépendants, avec un calcul du temps de validité pour compenser la latence réseau et le décalage d’horloge. Vous avez vu comment l’implémenter avec Jedis, comment libérer le verrou en toute sécurité avec un script Lua et comment faire des tests avec Testcontainers.
Redlock repose toujours sur TTL et l’horloge, il ne peut donc pas résister aux pausses de processus ou aux sauts d’horloge. Par conséquent, pour un verrou visant seulement à éviter les doublons, un seul nœud Redis suffit. Pour un verrou protégeant la correctness des données, vous avez besoin d’un fencing token provenant d’un système avec consensus, ou laisser la base de données appliquer elle-même les contraintes.