Introduction
Face à la popularité croissante des architectures microservices, la garantie de la cohérence des données dans un environnement distribué est devenue une préoccupation majeure. Les verrous distribués sont une technologie couramment utilisée pour adresser ce défi, et Redis est l'une des implémentations les plus répandues. Cependant, diverses versions de verrous distribués basés sur Redis circulent, présentant souvent des failles de conception. Une mauvaise utilisation peut entraîner des incidents critiques en production. Cet article passe en revue les différentes approches, analyse leurs défauts et propose des recommandations pour une implémentation robuste.
V1.0 : L'approche naïve avec SETNX et EXPIRE
La première version souvent rencontrée repose sur deux commandes distinctes :
function acquérirVerrou(clé, délai)
if redis.call("SETNX", clé, 1) == 1 then
redis.call("EXPIRE", clé, délai)
return 1
end
return 0
end
function libérerVerrou(clé)
redis.call("DEL", clé)
end
L'idée est d'acquérir un verrou avec SETNX (qui ne fonctionne que si la clé n'existe pas) et de lui définir un temps d'expiration pour éviter un blocage permanent en cas de panne du client. La faille principale est l'absence d'atomicité entre les deux commandes. Si le processus s'arrête entre le SETNX et l'EXPIRE, le verrou est acquis pour une durée indéfinie. Une tentative d'amélioration utilise un script Lua pour combiner les deux, mais le risque persiste en cas de crash du master Redis avant la réplication sur un esclave.
V1.1 : La variante avec GETSET
Cette version tente de résoudre le problème du verrou sans expiration en utilisant GETSET. La valeur de la clé contient un horodatage d'expiration.
function acquérirVerrou(clé, durée)
local nouvelleExpiration = tonumber(redis.call("TIME")[1]) + durée
if redis.call("SETNX", clé, nouvelleExpiration) == 1 then
return 1 -- Verrou acquis
end
local ancienneExpiration = tonumber(redis.call("GET", clé))
if ancienneExpiration < tonumber(redis.call("TIME")[1]) then
-- Le verrou semble expiré, on tente de le récupérer
nouvelleExpiration = tonumber(redis.call("TIME")[1]) + durée
local expirationActuelle = redis.call("GETSET", clé, nouvelleExpiration)
if expirationActuelle == tostring(ancienneExpiration) then
return 1 -- Succès, on a récupéré le verrou
end
end
return 0 -- Échec
end
Inconvénients :
- Corruption du verrou : En cas de forte contention, la valeur du verrou peut être constamment écrasée par des clients qui échouent à l'acquérir, sans qu'aucun n'y parviennne réellement.
- Prolongation non intentionnelle : Un client B peut exécuter
GETSETet modifier le temps d'expiration d'un verrou détenu par un client A. Si A ne libère pas le verrou correctement, sa durée de vie est prolongée, retardant les autres clients.
V2.0 : L'atomicité avec SET (Redis >= 2.6.12)
Redis 2.6.12 a permis de rendre SET atomique en y intégrant les options NX (pas d'écrasement) et EX/PX (expiration).
function acquérirVerrou(clé, identifiant, durée)
if redis.call("SET", clé, identifiant, "NX", "PX", durée) then
return 1
end
return 0
end
function libérerVerrou(clé)
redis.call("DEL", clé)
end
Ceci résout le problème de l'atomicité pour l'acquisition. Cependant, un nouveau scénario problématique émerge :
- Le client A acquiert le verrou.
- Une longue pause (GC, réseau) fait que le traitement de A dépasse la durée de vie du verrou. Le verrou expire.
- Le client B acquiert le verrou à son tour et commence son traitement.
- A et B exécutent leur tâche simultanément, ce qui viole le principe d'exclusion mutuelle et peut causer une incohérence de données.
- Si A termine sa tâche en premier, il exécute
DELet libère le verrou de B. - Un client C pourrait alors acquérir le verrou pendant que B est encore en train de travailler.
Le problème fondamental est que libérerVerrou ne vérifie pas si le verrou appartient bien au client qui tente de le libérer.
V3.0 : Vérification du propriétaire avec un identifiant unique
Pour éviter que A ne libère le verrou de B, la solution est d'associer un identifiant unique au verrou et de le vérifier lors de la libération. Un script Lua garantit l'atomicité de cette vérification.
function acquérirVerrou(clé, identifiantUnique, durée)
if redis.call("SET", clé, identifiantUnique, "NX", "PX", durée) then
return 1
end
return 0
end
function libérerVerrou(clé, identifiantUnique)
if redis.call("GET", clé) == identifiantUnique then
return redis.call("DEL", clé)
else
return 0
end
end
Ici, la clé contient un identifiant unique généré par le client (par exemple, un UUID). Au moment de la libération, le script vérifie que la valeur actuelle de la clé correspond à l'identifiant fourni. Ce n'est que si la correspondance est établie que le verrou est supprimé.
Inconvénient mineur : Une version précédente de cette apporche utilisait un timestamp comme identifiant. En cas de forte concurrence ou de désynchronisation d'horloge entre les nœuds, des collisions d'identifiants pouvaient survenir, bien que ce soit très rare. L'utilisation d'un UUID ou d'un identifiant généré de manière cryptographiquement sécurisée élimine ce risque.
V3.1 : La solution de référence pour une instance unique
Cette version, qui combine SET ... NX PX ... avec un identifiant unique et un script Lua pour la libération, est considérée comme l'implémentation la plus sûre et robuste pour un verrou sur une instance Redis unique.
-- Le script Lua pour la libération atomique
local scriptLibération =
'if redis.call("get", KEYS[1]) == ARGV[1] then ' ..
' return redis.call("del", KEYS[1]) ' ..
'else ' ..
' return 0 ' ..
'end'
-- Utilisation
function libérerVerrouSécurisé(clé, identifiantUnique)
return redis.call('EVAL', scriptLibération, 1, clé, identifiantUnique)
end
Redlock : Gérer les verrous sur un cluster Redis
L'approche précédente est efficace sur une seule instance. Dans un cluster Redis, où la réplication est asynchrone, une défaillance du master juste après l'acquisition du verrou peut conduire à une situation où un nouveau master permet à un autre client d'acquérir le même verrou. L'algorithme Redlock, proposé par Salvatore Sanfilippo (antirez), vise à résoudre ce problème.
Principe de Redlock :
- Le client obtient l'heure actuelle (en ms).
- Il tente d'acquérir le verrou sur N instances Redis indépendantes (ex: 5), en utilisant la même clé et le même identifiant unique, avec un temps d'expiration court. Chaque tentative a un timeout pour éviter de bloquer indéfiniment sur une instance défaillante.
- Le client calcule le temps total écoulé depuis l'étape 1.
- Il ne considère l'acquisition comme réussie que s'il a obtenu le verrou sur la majorité des nœuds (N/2 + 1) et si le temps écoulé est inférieur à la durée de vie initiale du verrou.
- En cas de succès, la durée de vie effective du verrou est réduite du temps écoulé.
- En cas d'échec, le client doit libérer tous les verrous qu'il a pu acquérir.
Critiques de Redlock et alternatives
Martin Kleppmann a critiqué Redlock, arguant qu'il repose sur des hypothèses de synchronisation d'horloge qui ne sont pas garanties dans les systèmes distribués. Un "GC pause" prolongé ou un saut d'horloge pourrait briser les garanties de sécurité de l'algorithme.
Une alternative plus robuste, bien que plus complexe à mettre en œuvre, est l'utilisation d'un fencing token. Le système génére un numéro d'ordre croissnat (token) pour chaque opération d'écriture. Le client doit inclure ce token avec sa requête. Le service vérifie que le token est bien supérieur au dernier traité. Cela garantit qu'une requête obsolète (d'un client A ayant perdu le verrou) sera rejetée même si elle parvient à s'exécuter.
Conclusion : Choisir la bonne solution
Un verrou distribué doit garantir deux propriétés :
- Sûreté (Safety) : Un seul client peut détenir le verrou à un instant T (exclusion mutuelle).
- Vivacité (Liveness) : Le verrou doit toujours pouvoir être acquis et libéré, même en cas de panne (pas d'interblocage), et le système doit être tolérant aux pannes.
Le choix de l'implémentation dépend du cas d'usage :
- Pour l'efficacité : Si l'objectif est simplement d'éviter qu'un même traitement ne soit lancé en parallèle (par ex. pour économiser des ressources), un verrou simple (V3.1) sur une instance Redis unique est suffisant. Une libération accidentelle n'est pas catastrophique.
- Pour la correction : Si l'exclusion mutuelle est critique pour la cohérence des données (ex. écriture sur un compte bancaire), une approche beaucoup plus rigoureuse est nécessaire. L'algorithme Redlock peut être envisagé, mais ses limites doivent être comprises. Pour une sécurité maximale, une solution basée sur un système de consensus comme ZooKeeper ou etcd, ou l'utilisation d'un fencing token combiné à un verrou, est préférable.