Évolution des Verrous Distribués Redis : Analyse des Implémentations et de leurs Faiblesses

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 :

  1. 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.
  2. Prolongation non intentionnelle : Un client B peut exécuter GETSET et 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 :

  1. Le client A acquiert le verrou.
  2. Une longue pause (GC, réseau) fait que le traitement de A dépasse la durée de vie du verrou. Le verrou expire.
  3. Le client B acquiert le verrou à son tour et commence son traitement.
  4. 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.
  5. Si A termine sa tâche en premier, il exécute DEL et libère le verrou de B.
  6. 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 :

  1. Le client obtient l'heure actuelle (en ms).
  2. 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.
  3. Le client calcule le temps total écoulé depuis l'étape 1.
  4. 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.
  5. En cas de succès, la durée de vie effective du verrou est réduite du temps écoulé.
  6. 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.

Étiquettes: Redis Verrou Distribué Redlock Lua Microservices

Publié le 23 août à 03h51