Dein sync.Mutex überlebt kein kill -9. Er überlebt keinen OOM, kein Deployment-Rollout und keinen Node-Reboot. Sobald der Prozess beendet wird, ist der Lock weg. Wenn dieser Lock einen geplanten Job, eine Datenmigration oder eine Leader Election geschützt hat, sind jetzt zwei Prozesse davon überzeugt, dass sie die Einzigen sind, die laufen.
Das ist kein Bug in deinem Mutex. Es ist ein Kategoriefehler. Ein prozesslokaler Lock kann keine clusterweite Ressource schützen.
Die Lösung ist ein distributed Lease: ein Lock, der in einem externen System gespeichert wird, mit einer Time-To-Live, einem eindeutigen Owner-Token und einem Mechanismus zur sicheren Freigabe. Redis, PostgreSQL, etcd und ZooKeeper implementieren dieselbe Idee mit unterschiedlichen Trade-offs. Das Pattern ist geradlinig. Die Edge Cases nicht.
Was ist ein distributed Lease?
Ein Lease ist ein Versprechen eines externen Stores, dass nur ein Client einen benannten Lock für eine begrenzte Zeitspanne hält. Anders als ein Mutex, der im Heap-Memory lebt, lebt ein Lease in Redis oder PostgreSQL. Er überdauert Prozess-Neustarts, weil der Store überdauert.
Die Grundoperationen sind:
- Acquire: Einen Key atomar mit einer TTL schreiben, aber nur, wenn er noch nicht existiert.
- Renew: Die TTL verlängern, solange du den Lock noch hältst.
- Release: Den Key löschen, aber nur, wenn er noch dein eindeutiges Token enthält.
Das eindeutige Token ist der kritische Teil. Ohne es kann ein langsamer Client einen Lock freigeben, der nach einem Crash von jemand anderem neu erworben wurde.
Warum eine TTL zwingend notwendig ist – und warum sie gefährlich ist
Wenn ein Client einen Lock erwirbt und dann abstürzt, muss der Lock sich irgendwann selbst freigeben. Der einzige Weg, das ohne menschliches Zutun zu erreichen, ist eine Ablaufzeit. Redis unterstützt das nativ mit SET key value NX EX 30. PostgreSQL Advisory Locks sind an die Session gebunden und sterben, wenn die TCP-Verbindung geschlossen wird – das ist elegant, aber weniger portabel.
TTLs führen einen neuen Failure Mode ein: Wenn deine Arbeit länger als die TTL dauert, läuft der Lock ab, während du noch läufst. Ein anderer Client erwirbt den Lock und startet dieselbe Arbeit. Jetzt hast du zwei parallele Prozesse, die dieselben Daten verändern.
Die naive Lösung ist eine sehr lange TTL. Das funktioniert, bis ein Client unmittelbar nach dem Erwerb des Locks abstürzt. Die verbleibende TTL wird zu einem zwingenden Downtime-Fenster, in dem niemand anders übernehmen kann.
Die korrekte Lösung ist ein Heartbeat. Der Holder startet einen Background-Goroutine, der den Lease alle paar Sekunden erneuert. Wenn der Holder abstürzt, stoppt der Heartbeat, die TTL läuft ab, und ein neuer Owner kann den Lock innerhalb von Sekunden erwerben. Wenn der Holder nur langsam ist, hält der Heartbeat den Lease so lange am Leben, wie der Prozess läuft.
Ein funktionierender Redis-Lease in Go
Hier ist eine vollständige Implementierung, die Erwerb, Erneuerung und sichere Freigabe abdeckt. Sie verwendet ein Lua-Skript für die Freigabe, damit das Löschen nur gelingt, wenn das Token übereinstimmt.
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
}
Verwendung:
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()
Die Lua-Skripte sind notwendig, weil Redis kein Compare-and-Delete als einzelnen nativen Befehl unterstützt. Ohne sie könnte eine Freigabe in ein Race mit einer Neuerwerbung geraten und den Lock eines anderen löschen.
Wo das zusammenbricht: das Fencing-Token-Problem
Selbst bei perfekter TTL und Erneuerung gibt es eine subtile Race Condition, die Martin Kleppmann in seiner Kritik an Redlock identifiziert hat. Stell dir vor:
- Client A acquires the lease.
- Client A pauses for 45 seconds (GC stop-the-world, VM suspension, CPU throttling).
- The lease expires.
- Client B acquires the lease.
- Client A unpauses and writes to the database.
- Client B writes to the database.
Beide Clients glaubten, den Lock zu halten. Beide haben den Shared State verändert.
Die TTL schützt vor permanenten Deadlocks, aber nicht vor verzögerten Prozessen. Die Lösung ist ein Fencing Token: eine monotone Zahl oder eine UUID, die der Lock-Holder an jeden Write anhängt. Die Storage Layer lehnt Writes mit einem veralteten Token ab.
In der Praxis bedeutet das, dass deine Datenbanktabelle eine lock_version-Spalte braucht, oder dein Blob Store braucht Conditional Writes. Die meisten Anwendungen überspringen diesen Schritt, weil er eine Änderung der Data Layer erfordert, nicht nur der Locking Layer. Das ist ein vertretbarer Trade-off, aber es ist ein Trade-off. Du solltest wissen, dass du ihn eingehen.
Alternativen, die es wert sind, in Betracht gezogen zu werden
PostgreSQL Advisory Locks sind session-scoped. Wenn die TCP-Verbindung geschlossen wird, gibt sich der Lock automatisch frei. Es gibt kein TTL-Management und keinen Clock Skew. Der Nachteil ist, dass sie an eine einzelne Datenbankverbindung gebunden sind, also funktionieren sie nicht gut mit Connection Pooling oder Multi-Region-Setups.
etcd Leases sind genau für dieses Problem entworfen. Sie unterstützen TTL, automatische Revocation und Watch-basierte Benachrichtigungen, wenn ein Lease stirbt. Wenn du schon Kubernetes betreibst, hast du etcd. Die API ist ausführlicher als Redis, aber die Semantik ist sauberer.
ZooKeeper Ephemeral Sequential Nodes sind die klassische Lösung. Sie sind CP (consistent and partition-tolerant) unter dem CAP-Theorem, was das Clock-Skew-Problem vollständig eliminiert. Sie sind auch langsamer und operationell schwerer als Redis.
Was wir nicht ausprobiert haben
Wir haben kein custom consensus protocol auf einer relationalen Datenbank implementiert. Jedes Team probiert das irgendwann aus: eine locks-Tabelle mit INSERT ... ON CONFLICT und einer last_heartbeat-Spalte, die von einem Cron-Job abgeräumt wird. Es funktioniert im Happy Path. Es fällt unter Contention auseinander, weil MVCC-Datenbanken konfliktbehaftete Writes serialisieren, und dein Lock-Erwerb wird zum Bottleneck für dein gesamtes System. Verwende das richtige Tool für den Job.
Eine TTL wählen
Zu kurz: Heartbeats werden gesprächig, und eine einzelne langsame GC-Pause kann den Lease verlieren.
Zu lang: Ein abgestürzter Holder blockiert das Failover für die gesamte TTL.
Ein guter Ausgangspunkt ist 10 Sekunden mit einem Heartbeat alle 3 Sekunden. Optimiere von dort aus basierend auf deinen beobachteten GC-Pausen und der Netzwerklatenz. Miss deine p99-Heartbeat-Latenz. Wenn sie 500 ms beträgt, muss deine TTL mindestens eine Größenordnung größer sein.
Probier es aus
Wenn du aktuell ein sync.Mutex verwendest, um einen Background-Job zu schützen, ersetze ihn durch einen leased Lock, der von Redis oder PostgreSQL unterstützt wird. Starte mit der Implementierung oben. Füge Metriken für lease_acquired, lease_lost und heartbeat_latency hinzu. Wenn du das erste Mal während eines lang laufenden Jobs deployst und beobachtest, wie die zweite Instance höflich wartet statt zu kollidieren, weißt du, dass der Kategoriefehler behoben ist.