Votre sync.Mutex ne survit pas à un kill -9. Il ne survit pas à un OOM, à un déploiement progressif, ni à un redémarrage de nœud. À l’instant où le processus se termine, le verrou disparaît. Si ce verrou protégeait une tâche planifiée, une migration de données ou une élection de leader, vous vous retrouvez désormais avec deux processus convaincus d’être les seuls en cours d’exécution.
Ce n’est pas un bug dans votre mutex. C’est une erreur de catégorie. Un verrou local au processus ne peut pas protéger une ressource à l’échelle du cluster.
La solution est un lease distribué : un verrou stocké dans un système externe avec une durée de vie (TTL), un jeton de propriétaire unique et un mécanisme de libération sécurisée. Redis, PostgreSQL, etcd et ZooKeeper implémentent tous la même idée avec des compromis différents. Le pattern est simple. Les cas limites ne le sont pas.
Qu’est-ce qu’un lease distribué ?
Un lease est une promesse d’un stockage externe selon laquelle un seul client détient un verrou nommé pour une période limitée. Contrairement à un mutex, qui vit dans la mémoire heap, un lease vit dans Redis ou PostgreSQL. Il persiste à travers les redémarrages de processus car le stockage persiste.
Les opérations de base sont :
- Acquire : écrire atomiquement une clé avec un TTL, mais seulement si elle n’existe pas déjà.
- Renew : étendre le TTL tant que vous détenez toujours le verrou.
- Release : supprimer la clé, mais seulement si elle contient encore votre jeton unique.
Le jeton unique est la partie critique. Sans lui, un client lent peut libérer un verrou qui a été réacquis par quelqu’un d’autre après un crash.
Pourquoi le TTL est obligatoire, et pourquoi il est dangereux
Si un client acquiert un verrou puis meurt, le verrou doit finir par se libérer de lui-même. La seule façon de faire cela sans intervention humaine est un temps d’expiration. Redis le supporte nativement avec SET key value NX EX 30. Les verrous consultatifs PostgreSQL sont liés à la session et meurent quand la connexion TCP se ferme, ce qui est élégant mais moins portable.
Les TTLs introduisent un nouveau mode de défaillance : si votre travail prend plus de temps que le TTL, le verrou expire pendant que vous êtes encore en cours d’exécution. Un autre client acquiert le verrou et commence le même travail. Vous avez désormais deux processus concurrents modifiant les mêmes données.
La solution naïve est un TTL très long. Cela fonctionne jusqu’à ce qu’un client meure immédiatement après avoir acquis le verrou. Le TTL restant devient une fenêtre d’indisponibilité obligatoire pendant laquelle personne d’autre ne peut prendre le relais.
La solution correcte est un heartbeat. Le détenteur lance une goroutine en arrière-plan qui renouvelle le lease toutes les quelques secondes. Si le détenteur crash, le heartbeat s’arrête, le TTL expire et un nouveau propriétaire peut acquérir le verrou en quelques secondes. Si le détenteur est simplement lent, le heartbeat maintient le lease en vie tant que le processus vit.
Un lease Redis fonctionnel en Go
Voici une implémentation complète qui gère l’acquisition, le renouvellement et la libération sécurisée. Elle utilise un script Lua pour la libération afin que la suppression ne réussisse que si le jeton correspond.
package lease
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
type Lease struct {
client *redis.Client
key string
token string
ttl time.Duration
renewEvery time.Duration
stopRenew chan struct{}
once sync.Once
}
func generateToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
token := generateToken()
ok, err := client.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("lock already held")
}
l := &Lease{
client: client,
key: key,
token: token,
ttl: ttl,
renewEvery: ttl / 3,
stopRenew: make(chan struct{}),
}
go l.renew()
return l, nil
}
func (l *Lease) renew() {
ticker := time.NewTicker(l.renewEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`
l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
cancel()
case <-l.stopRenew:
return
}
}
}
func (l *Lease) Release(ctx context.Context) error {
l.once.Do(func() { close(l.stopRenew) })
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
if err != nil {
return err
}
if res == 0 {
return fmt.Errorf("lock was lost or stolen")
}
return nil
}
Utilisation :
lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
// Another instance is running the job.
return
}
defer lease.Release(ctx)
// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()
Les scripts Lua sont nécessaires car Redis ne supporte pas la suppression conditionnelle (compare-and-delete) comme une commande native unique. Sans eux, une libération pourrait entrer en compétition avec une réacquisition et supprimer le verrou de quelqu’un d’autre.
Où cela échoue : le problème du jeton de clôture (fencing token)
Même avec un TTL et un renouvellement parfaits, il existe une condition de course subtile identifiée par Martin Kleppmann dans sa critique de Redlock. Imaginez :
- Le client A acquiert le lease.
- Le client A est en pause pendant 45 secondes (arrêt du monde du GC, suspension de VM, limitation du CPU).
- Le lease expire.
- Le client B acquiert le lease.
- Le client A reprend et écrit dans la base de données.
- Le client B écrit dans la base de données.
Les deux clients pensaient détenir le verrou. Les deux ont modifié l’état partagé.
Le TTL protège contre les deadlocks permanents, mais il ne peut pas protéger contre les processus retardés. La solution est un fencing token : un nombre monotone ou un UUID que le détenteur du verrou attache à chaque écriture. La couche de stockage rejette les écritures avec un jeton obsolète.
En pratique, cela signifie que votre table de base de données a besoin d’une colonne lock_version, ou que votre stockage d’objets a besoin d’écritures conditionnelles. La plupart des applications ignorent cette étape car elle nécessite de modifier la couche de données, et pas seulement la couche de verrouillage. C’est un compromis raisonnable, mais c’est un compromis. Vous devriez savoir que vous le faites.
Des alternatives à considérer
Les verrous consultatifs PostgreSQL sont liés à la session. Quand la connexion TCP se ferme, le verrou se libère automatiquement. Il n’y a pas de gestion de TTL ni de décalage d’horloge. L’inconvénient est qu’ils sont liés à une seule connexion de base de données, donc ils ne fonctionnent pas bien avec le pooling de connexions ou les architectures multi-régions.
Les leases etcd sont conçus exactement pour ce problème. Ils supportent le TTL, la révocation automatique et les notifications basées sur les watches quand un lease meurt. Si vous exécutez déjà Kubernetes, vous avez etcd. L’API est plus verbeuse que Redis, mais la sémantique est plus propre.
Les nœuds éphémères séquentiels de ZooKeeper sont la solution classique. Ils sont CP (cohérents et tolérants aux partitions) selon le théorème CAP, ce qui élimine entièrement le problème de décalage d’horloge. Ils sont aussi plus lents et plus lourds en termes d’opérations que Redis.
Ce que nous n’avons pas essayé
Nous n’avons pas implémenté un protocole de consensus personnalisé au-dessus d’une base de données relationnelle. Chaque équipe finit par essayer : une table locks avec INSERT ... ON CONFLICT et une colonne last_heartbeat balayée par un cron job. Cela fonctionne dans le cas nominal. Cela s’effondre sous contention car les bases de données MVCC sérialisent les écritures conflictuelles, et votre acquisition de verrou devient le goulot d’étranglement de l’ensemble de votre système. Utilisez le bon outil pour la tâche.
Choisir un TTL
Trop court : les heartbeats deviennent bavards, et une seule pause GC lente peut faire perdre le lease.
Trop long : un détenteur crashé bloque le basculement pendant la totalité du TTL.
Un bon point de départ est 10 secondes avec un heartbeat toutes les 3 secondes. Ajustez à partir de là en fonction de vos pauses GC observées et de votre latence réseau. Mesurez votre latence de heartbeat p99. Si elle est de 500 ms, votre TTL doit être au moins un ordre de grandeur supérieur.
Essayez-le
Si vous utilisez actuellement un sync.Mutex pour protéger une tâche en arrière-plan, remplacez-le par un verrou à lease soutenu par Redis ou PostgreSQL. Commencez avec l’implémentation ci-dessus. Ajoutez des métriques pour lease_acquired, lease_lost et heartbeat_latency. La première fois que vous déployez pendant une tâche de longue durée et que vous voyez la deuxième instance attendre poliment au lieu d’entrer en collision, vous saurez que l’erreur de catégorie est corrigée.