Verteilung von Locks mit Redis – SETNX, Redlock und ihre Kontroversen
Grace Collins
Solutions Engineer · Leapcell

Einleitung
In der Welt der verteilten Systeme ist die Verwaltung gemeinsam genutzter Ressourcen über mehrere unabhängige Prozesse eine kritische Herausforderung. Ohne ordnungsgemäße Synchronisationsmechanismen kann der gleichzeitige Zugriff zu Datenbeschädigung, inkonsistenten Zuständen und unvorhersehbarem Verhalten führen. Verteilte Locks sind ein grundlegendes Hilfsmittel zum Schutz dieser gemeinsam genutzten Ressourcen und stellen sicher, dass zu jedem Zeitpunkt nur ein Prozess auf einen kritischen Abschnitt zugreifen kann. Redis ist aufgrund seines rasend schnellen In-Memory-Datenspeichers und seiner vielseitigen Befehle zu einer beliebten Wahl für die Implementierung solcher Locks geworden. Der Weg zu einem robusten und zuverlässigen verteilten Lock mit Redis ist jedoch voller Nuancen, von einfachen SETNX
-Ansätzen bis hin zu komplexeren Algorithmen wie Redlock, die jeweils eigene Stärken, Schwächen und vor allem hitzige Debatten mit sich bringen. Dieser Artikel befasst sich mit den praktischen Aspekten der Verwendung von Redis für verteilte Locks, untersucht die zugrunde liegenden Mechanismen, häufige Fallstricke und die anhaltenden Kontroversen, die Best Practices prägen.
Grundlegende Konzepte verteilter Locks verstehen
Bevor wir uns mit Redis-spezifischen Implementierungen befassen, wollen wir ein grundlegendes Verständnis der Schlüsselkonzepte im Zusammenhang mit verteilten Locks schaffen.
- Gegenseitiger Ausschluss: Die wichtigste Eigenschaft eines Locks, die sicherstellt, dass zu jedem Zeitpunkt nur ein Client den Lock halten und auf den kritischen Abschnitt zugreifen kann.
- Deadlock-Freiheit: Das System sollte nicht in einen Zustand geraten, in dem zwei oder mehr Prozesse unendlich aufeinander warten, um eine Ressource freizugeben, was zu einem Stillstand führt.
- Liveness/Fehlertoleranz: Wenn ein Client abstürzt oder auf einen Fehler stößt, während er einen Lock hält, sollte sich das System schließlich erholen und anderen Clients die Übernahme des Locks ermöglichen. Dies beinhaltet häufig Timeouts oder Lease-Mechanismen.
- Leistung: Der Locking-Mechanismus sollte minimale Overhead verursachen und kein Engpass für die verteilte Anwendung werden.
Nun wollen wir untersuchen, wie Redis diese Konzepte ermöglicht, beginnend mit grundlegenden Ansätzen und weiter zu ausgefeilteren Lösungen.
Einfache verteilte Locks mit SETNX
Die einfachste Möglichkeit, einen verteilten Lock in Redis zu implementieren, ist die Nutzung des SETNX
(SET if Not eXists) Befehls. Dieser Befehl setzt einen Schlüssel nur, wenn er noch nicht existiert.
Mechanismus:
- Ein Client versucht, einen Lock zu erwerben, indem er
SETNX my_lock_key my_client_id
ausführt. - Wenn
SETNX
1 zurückgibt, hat der Client den Lock erfolgreich erworben.my_client_id
kann eine eindeutige Kennung für den Client sein, die sich zur Fehlersuche oder Überprüfung des Lock-Besitzes eignet (obwohl sie für grundlegende Mutexe oft nicht unbedingt erforderlich ist). - Wenn
SETNX
0 zurückgibt, hält ein anderer Client bereits den Lock, und der aktuelle Client muss warten und es erneut versuchen oder andere Aktionen ausführen. - Um den Lock freizugeben, löscht der Client einfach den Schlüssel:
DEL my_lock_key
.
Codebeispiel (Konzeptionelles Python):
import redis import time r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_resource_lock" CLIENT_ID = "client_A_123" def acquire_lock_setnx(resource_name, client_id, timeout=10): start_time = time.time() while time.time() - start_time < timeout: if r.setnx(resource_name, client_id): print(f"{client_id} acquired lock on {resource_name}") return True time.sleep(0.1) # Wait and retry print(f"{client_id} failed to acquire lock on {resource_name}") return False def release_lock_setnx(resource_name, client_id): # This is problematic for safety, see explanation below if r.get(resource_name).decode('utf-8') == client_id: r.delete(resource_name) print(f"{client_id} released lock on {resource_name}") return True return False # Usage demonstration # if acquire_lock_setnx(LOCK_KEY, CLIENT_ID): # try: # print(f"{CLIENT_ID} is performing critical operation...") # time.sleep(2) # Simulate work # finally: # release_lock_setnx(LOCK_KEY, CLIENT_ID)
Einschränkungen des einfachen SETNX
:
Der SETNX
-Ansatz ist zwar einfach, leidet aber unter einem entscheidenden Mangel: fehlende ordnungsgemäße Ablaufzeitkontrolle. Wenn ein Client einen Lock erwirbt und dann abstürzt, bevor er ihn freigibt, bleibt der Lock-Schlüssel auf unbestimmte Zeit in Redis bestehen, was zu einem permanenten Deadlock führt.
Verbesserung von SETNX
mit Ablaufzeitkontrolle
Um das Deadlock-Problem zu lösen, können wir SETNX
mit einem Ablaufmechanismus kombinieren, entweder mit EXPIRE
oder robuster mit dem atomaren SET
-Befehl.
Verwendung von SETNX
und EXPIRE
(problematisch):
# Problematic sequence: not atomic if r.setnx(resource_name, client_id): r.expire(resource_name, 30) # Set expiration for 30 seconds return True
Diese Sequenz hat eine Race Condition: Wenn ein Client den Lock erwirbt (SETNX
gibt 1 zurück), aber abstürzt, bevor er EXPIRE
ausführt, wird der Lock wieder permanent.
Der atomare SET
-Befehl:
Redis 2.6.12 führte kombinierte Argumente für den SET
-Befehl ein, wodurch SET key value NX EX seconds
atomar sein kann. Dies ist die empfohlene Methode für einen einfachen Lock mit Ablaufzeit.
import redis import time import uuid r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_atomic_resource_lock" def acquire_lock_atomic_set(resource_name, expire_time_seconds, client_id): # SET key value NX EX seconds # NX: Only set the key if it does not already exist. # EX: Set the specified expire time, in seconds. if r.set(resource_name, client_id, nx=True, ex=expire_time_seconds): print(f"{client_id} acquired lock on {resource_name} with expiration") return True return False def release_lock_atomic_set(resource_name, client_id): # Use LUA script for atomic read-and-delete to prevent deleting # a lock set by another client (due to original lock expiring). lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r.register_script(lua_script) if script(keys=[resource_name], args=[client_id]): print(f"{client_id} released lock on {resource_name}") return True else: print(f"{client_id} failed to release lock (not owner or already expired)") return False # Usage demonstration # client_id = str(uuid.uuid4()) # if acquire_lock_atomic_set(LOCK_KEY, 30, client_id): # try: # print(f"{client_id} is performing critical operation...") # time.sleep(5) # finally: # release_lock_atomic_set(LOCK_KEY, client_id) # else: # print(f"Another client holds the lock.")
Wichtige Überlegung zur Freigabe: Bei der Freigabe des Locks ist es entscheidend zu überprüfen, ob der Client, der versucht, den Lock freizugeben, tatsächlich derjenige ist, der ihn erworben hat. Andernfalls könnte ein Client versehentlich (oder bösartig) einen Lock eines anderen Clients löschen, wenn sein eigener Lock abgelaufen ist und ein anderer Client ihn während seiner kritischen Sektion neu erworben hat. Das obige Lua-Skript handhabt dies korrekt, indem es den Wert atomar prüft, bevor es löscht.
Einführung des Redlock-Algorithmus
Obwohl eine einzelne Redis-Instanz mit SET ... NX EX
in vielen Szenarien akzeptable verteilte Lock-Semantik bietet, stellt sie einen einzelnen Ausfallpunkt dar. Wenn die Redis-Instanz ausfällt (und nicht sofort wiederhergestellt wird oder Daten verloren gehen), gehen alle gehaltenen Locks verloren, was zu einem Verlust des gegenseitigen Ausschlusses führt. Hier kommt Redlock ins Spiel, ein verteilter Lock-Algorithmus, der von Salvatore Tridici (Redis-Erfinder) entwickelt wurde.
Ziel von Redlock: Redlock zielt darauf ab, über mehrere unabhängige Redis-Instanzen hinweg einen robusteren und fehlertoleranteren verteilten Lock bereitzustellen. Die Kernidee ist, Locks auf einer Mehrheit von Redis-Instanzen zu erwerben, anstatt nur auf einer.
Redlock-Algorithmoschritte:
Gehen Sie von N unabhängigen Redis-Master-Instanzen aus, und der Client muss einen Lock mit einem resource_name
und einer validity_time
(wie lange der Lock gültig ist) erwerben.
- Einen Zufallswert generieren: Der Client generiert einen zufälligen, eindeutigen Wert (z. B. eine große Zufallszeichenfolge oder UUID), der später als seine "Signatur" für den Lock verwendet wird. Dieser Wert wird verwendet, um den Lock später sicher freizugeben.
- Auf Instanzen erwerben (parallel): Der Client versucht so gleichzeitig wie möglich, den Lock (
SET resource_name my_rand_value NX PX validity_time_milliseconds
) auf allen N Redis-Instanzen zu erwerben oder bis er eine Mehrheit erreicht hat. Für jeden Erwerbsversuch sollte ein kurzer Timeout verwendet werden (z. B. einige hundert Millisekunden). - Lock-Erwerbszeit berechnen: Der Client zeichnet die Zeit auf, zu der er mit dem Lock-Erwerbsprozess begonnen hat (nennen wir sie
start_time
). - Auf Mehrheit und Gültigkeit prüfen:
- Der Client berechnet, wie viel Zeit seit
start_time
bis zur aktuellen Zeit vergangen ist. - Wenn der Client den Lock auf einer Mehrheit der Instanzen (N/2 + 1) erwerben konnte UND die verstrichene Zeit kleiner als
validity_time
ist, dann hat der Client den Lock erfolgreich erworben. - Die effektive
validity_time
für den Lock wird um die während des Erwerbs verstrichene Zeit reduziert.
- Der Client berechnet, wie viel Zeit seit
- Freigeben oder Wiederholen:
- Wenn der Lock erfolgreich erworben wurde, kann der Client mit seinem kritischen Abschnitt fortfahren.
- Wenn der Lock nicht erfolgreich erworben wurde (entweder wurde keine Mehrheit erreicht oder
validity_time
ist abgelaufen), muss der Client versuchen, den Lock auf allen Instanzen freizugeben, auf denen er ihn erworben hat. Dies ist entscheidend für die Bereinigung.
- Lock verlängern (optional): Wenn der Client mehr Zeit als die anfängliche
validity_time
benötigt, kann er versuchen, den Lock zu verlängern, indem er den Erwerbsprozess mit einer neuenvalidity_time
und demselbenrand_value
wiederholt.
Codebeispiel (Konzeptionelles Python, vereinfacht zur Klarheit):
import redis import time import uuid # Assume multiple Redis instances REDIS_INSTANCES = [ redis.Redis(host='localhost', port=6379, db=0), # redis.Redis(host='localhost', port=6380, db=0), # redis.Redis(host='localhost', port=6381, db=0), ] MAJORITY = len(REDIS_INSTANCES) // 2 + 1 LOCK_KEY = "my_redlock_resource" def acquire_lock_redlock(resource_name, lock_ttl_ms): my_id = str(uuid.uuid4()) acquired_count = 0 start_time = int(time.time() * 1000) # Milliseconds for r_conn in REDIS_INSTANCES: try: # Use PX for milliseconds if r_conn.set(resource_name, my_id, nx=True, px=lock_ttl_ms): acquired_count += 1 except redis.exceptions.ConnectionError: # Handle connection errors pass end_time = int(time.time() * 1000) elapsed_time = end_time - start_time if acquired_count >= MAJORITY and elapsed_time < lock_ttl_ms: print(f"Redlock acquired by {my_id} on {acquired_count} instances.") return my_id, lock_ttl_ms - elapsed_time # Return actual validity else: # If not acquired or validity expired, release locks we might have acquired for r_conn in REDIS_INSTANCES: lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r_conn.register_script(lua_script) script(keys=[resource_name], args=[my_id]) print(f"Redlock not acquired by {my_id}. Acquired count: {acquired_count}") return None, 0 def release_lock_redlock(resource_name, my_id): for r_conn in REDIS_INSTANCES: lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r_conn.register_script(lua_script) script(keys=[resource_name], args=[my_id]) print(f"Redlock released by {my_id}.")
Kontroversen rund um Redlock
Trotz Redlocks ausgeklügeltem Design war es Gegenstand erheblicher Debatten und Kritik, vor allem von Experten für verteilte Systeme. Die prominenteste Kritik stammt von Martin Kleppmann, Autor von "Designing Data-Intensive Applications".
Wichtige Kritikpunkte:
-
Bietet KEINE "stärkeren" Sicherheitsgarantien: Kleppmann argumentiert, dass Redlock im Vergleich zu einer einzelnen Redis-Instanz mit ordnungsgemäßer Persistenz und Fencing nicht tatsächlich sicherere Mechanismen bietet.
- Clock Skew und Systemzeit: Redlock beruht auf dem synchronisierten Zeitkonzept über verschiedene Maschinen und Instanzen hinweg, was in verteilten Systemen bekanntermaßen unzuverlässig ist. Wenn Uhren stark abweichen, könnte ein Client glauben, einen Lock erworben zu haben, der bereits abgelaufen ist, laut einer anderen Instanz, oder umgekehrt.
- Pausen bei der Ausführung (GC, Netzwerk-Latenz, Kontextwechsel): Wenn ein Prozess einen Redlock erwirbt und dann eine lange Pause erfährt (z. B. langer Garbage-Collection-Zyklus, Pause des Betriebssystem-Schedulers, Netzwerkpartition), könnte der Lock auf einigen oder allen Redis-Instanzen ablaufen. Wenn der Prozess fortgesetzt wird, glaubt er möglicherweise immer noch, den Lock zu halten, und setzt seinen kritischen Abschnitt fort, während ein anderer Client den Lock bereits erworben hat, was den gegenseitigen Ausschluss verletzt.
- Kein Fencing Token: Redlock fehlt ein "Fencing Token" (eine monoton steigende Zahl, die mit jedem Lock-Erwerbsversuch verbunden ist). Ein Fencing Token, wenn es an die geschützte Ressource übergeben wird, ermöglicht es der Ressource, Operationen von einem veralteten, abgelaufenen Lock-Halter abzulehnen. Ohne ihn kann ein Client mit einem abgelaufenen Lock immer noch in eine gemeinsam genutzte Ressource schreiben, wenn die Ressource die Gültigkeit des Tokens nicht prüft. Dies ist vielleicht Redlocks kritischstes Versäumnis, die Sicherheit angesichts von Verzögerungen wirklich zu gewährleisten.
-
Komplexität vs. Nutzen: Die zusätzliche Komplexität, mehrere Redis-Instanzen für Redlock einzurichten und zu verwalten, sowie der Overhead der Koordination von Lock-Erwerbsvorgängen, rechtfertigen möglicherweise nicht die tatsächlichen Sicherheitsgarantien, insbesondere angesichts der praktischen Ausfallmodi verteilter Systeme.
-
Machbare Alternativen: Kritiker verweisen oft auf praxiserprobte Konsensalgorithmen wie Paxos oder Raft (implementiert von Systemen wie Apache ZooKeeper oder etcd) als robustere und theoretisch fundiertere Lösungen für verteilte Koordination und Locking, da sie Netzwerkpartitionen, Clock Skew und Knotenausfälle von Natur aus mit starken Konsistenzgarantien behandeln.
Wann ist Redlock potenziell nützlich (und für welche "Sicherheit")?
Trotz der Kritik kann Redlock für die Liveness nützlich sein – wenn eine Redis-Instanz ausfällt, können Locks immer noch erworben und freigegeben werden, was einen vollständigen Systemausfall verhindert. Seine Behauptungen, starke gegenseitige Ausschlussgarantien angesichts von Maschinenpausen und Netzwerkproblemen bereitzustellen, sind jedoch ohne externe Fencing Tokens stark umstritten. Für viele Anwendungsfälle, bei denen gelegentliche Nebenläufigkeitsfehler tolerierbar sind oder bei denen sich das System von solchen Ereignissen auf elegante Weise erholen kann, kann eine einzelne Redis-Instanz mit SET ... NX EX
und anwendungsspezifischen Schutzmaßnahmen (z. B. Idempotenz, Wiederholungsversuche) ausreichend und einfacher sein.
Fazit
Die Implementierung verteilter Locks mit Redis bietet eine Reihe von Optionen, vom einfachen SETNX
bis zum Multi-Instanz-Redlock-Algorithmus. Während SETNX
in Kombination mit atomischer Ablaufzeitkontrolle (SET ... NX EX
) eine einfache und effektive Lösung für viele gängige Szenarien bietet, bleibt es ein einzelner Ausfallpunkt. Redlock zielt darauf ab, die Fehlertoleranz zu verbessern, indem der Lock-Zustand auf mehrere Redis-Instanzen verteilt wird und bessere Liveness-Garantien geboten werden. Seine Sicherheitsansprüche, insbesondere gegen Maschinenpausen und Clock Skews, wurden jedoch von Experten für verteilte Systeme rigoros angefochten, was darauf hindeutet, dass es ohne einen Fencing-Token-Mechanismus möglicherweise keinen stärkeren gegenseitigen Ausschluss bietet als eine sorgfältig verwaltete Single-Instanz-Konfiguration. Letztendlich hängt die Wahl der Locking-Strategie stark von den spezifischen Anforderungen der Anwendung an Konsistenz, Verfügbarkeit und den akzeptablen Kompromissen für Komplexität und potenzielle Fehlerfälle ab. Für kritische Abschnitte, die absoluten gegenseitigen Ausschluss und Widerstandsfähigkeit gegen willkürliche Verzögerungen erfordern, ist die Untersuchung robuster Konsenssysteme wie ZooKeeper oder etcd oft ein zuverlässigerer Weg.