Redis Distributed Locks: 10 Häufige Fehler und wie man sie vermeidet
Ethan Miller
Product Engineer · Leapcell

In der täglichen Entwicklung werden verteilte Redis-Locks oft verwendet, um Datenlese-/Schreibprobleme bei gleichzeitigen Anfragen zu lösen. Die Verwendung von verteilten Redis-Locks birgt jedoch viele Fallstricke. Dieser Artikel analysiert und erklärt 10 Fallstricke verteilter Redis-Locks.
1. Nicht-atomare Operationen (setnx + expire)
Wenn es um die Implementierung eines verteilten Redis-Locks geht, denken viele Entwickler sofort an die Verwendung der Befehle setnx + expire
. Das heißt, setnx
verwenden, um den Lock zu erhalten, und wenn dies erfolgreich ist, dann expire
verwenden, um eine Ablaufzeit für den Lock festzulegen.
Pseudo-Code:
if (jedis.setnx(lock_key, lock_value) == 1) { // Acquire lock jedis.expire(lock_key, timeout); // Set expiration time doBusiness // Business logic }
Dieser Code hat einen großen Fallstrick: setnx
und expire
werden separat ausgeführt und sind nicht atomar! Wenn der Prozess abstürzt oder neu gestartet wird, direkt nachdem setnx
ausgeführt wurde, aber vor expire
, läuft der Lock niemals ab. Infolgedessen können andere Threads den Lock niemals erhalten.
2. Von der Anfrage eines anderen Clients überschrieben (setnx + Wert als Ablaufzeit)
Um das Problem zu lösen, dass Locks aufgrund von Ausnahmen nicht freigegeben werden, schlagen einige vor, den Ablauf-Zeitstempel in den Wert von setnx
einzutragen. Wenn die Lock-Akquisition fehlschlägt, kann man dann den gespeicherten Wert mit der aktuellen Systemzeit vergleichen, um festzustellen, ob der Lock abgelaufen ist. Pseudo-Code-Implementierung:
long expireTime = System.currentTimeMillis() + timeout; // Aktuelle Zeit + Timeout String expireTimeStr = String.valueOf(expireTime); // In String konvertieren // Wenn der Lock nicht existiert, gib true zurück if (jedis.setnx(lock_key, expireTimeStr) == 1) { return true; } // Wenn der Lock existiert, rufe seine Ablaufzeit ab String oldExpireTimeStr = jedis.get(lock_key); // Wenn die gespeicherte Ablaufzeit kleiner als die aktuelle Zeit ist, ist sie abgelaufen if (oldExpireTimeStr != null && Long.parseLong(oldExpireTimeStr) < System.currentTimeMillis()) { // Lock ist abgelaufen; versuche, ihn mit neuer Ablaufzeit zu überschreiben String oldValueStr = jedis.getSet(lock_key, expireTimeStr); if (oldValueStr != null && oldValueStr.equals(oldExpireTimeStr)) { // In gleichzeitigen Szenarien erhält nur der Thread den Lock, dessen Set-Wert mit dem alten Wert übereinstimmt return true; } } // Lock-Akquisition in allen anderen Fällen fehlgeschlagen return false;
Dieser Ansatz hat auch einen Fallstrick: Wenn der Lock abläuft und mehrere Clients gleichzeitig jedis.getSet()
aufrufen, erhält nur einer erfolgreich den Lock. Die Ablaufzeit dieses Clients könnte jedoch von einem anderen überschrieben werden, was zu Inkonsistenzen führt.
3. Vergessen, eine Ablaufzeit festzulegen
Beim Überprüfen von Code habe ich einmal eine verteilte Lock-Implementierung wie diese gesehen:
try { if (jedis.setnx(lock_key, lock_value) == 1) { // Acquire lock doBusiness // Business logic return true; // Lock acquired and business logic processed } return false; // Lock acquisition failed } finally { unlock(lockKey); // Release lock }
Was ist hier falsch? Richtig – die Ablaufzeit fehlt. Wenn das Programm während der Ausführung abstürzt und den finally
-Block nicht erreicht, wird der Lock nicht gelöscht. Dies macht das Entsperren unzuverlässig. Daher sollte man bei der Verwendung von verteilten Locks immer eine Ablaufzeit festlegen.
4. Vergessen, den Lock nach der Geschäftsverarbeitung freizugeben
Viele Entwickler verwenden den Befehl set
von Redis mit erweiterten Parametern, um verteilte Locks zu implementieren.
Erweiterte Parameter von SET key value
:
NX
: Nur setzen, wenn der Schlüssel nicht existiert, um sicherzustellen, dass nur der erste Client den Lock erhält.EX seconds
: Ablauf in Sekunden festlegen.PX milliseconds
: Ablauf in Millisekunden festlegen.XX
: Nur setzen, wenn der Schlüssel existiert.
Einige schreiben möglicherweise Pseudo-Code wie diesen:
if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // Acquire lock doBusiness // Business logic return true; // Lock acquired and business logic processed } return false; // Lock acquisition failed
Auf den ersten Blick sieht das gut aus, aber es gibt ein Problem – man vergisst, den Lock freizugeben! Wenn man immer darauf wartet, dass der Lock abläuft, leidet die Effizienz. Man sollte den Lock freigeben, nachdem die Geschäftslogik abgeschlossen ist.
Korrekte Verwendung:
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // Acquire lock doBusiness // Business logic return true; // Lock acquired and business logic processed } return false; // Lock acquisition failed } finally { unlock(lockKey); // Release lock }
5. Der Lock von Thread B wird von Thread A freigegeben
Betrachten Sie den folgenden Pseudo-Code:
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // Acquire lock doBusiness // Business logic return true; // Lock acquired and business logic processed } return false; // Lock acquisition failed } finally { unlock(lockKey); // Release lock }
Was ist hier das Problem?
In einem gleichzeitigen Szenario, in dem die Threads A und B beide versuchen, den Lock zu erhalten, nehmen wir an, dass Thread A den Lock zuerst erhält (Ablauf in 3 Sekunden). Wenn seine Geschäftslogik langsam ist und länger als 3 Sekunden dauert, lässt Redis den Lock automatisch ablaufen. Dann erhält Thread B den Lock und beginnt mit der Ausführung. Wenn Thread A seine Aufgabe beendet und den Lock anschließend freigibt, gibt er versehentlich den Lock von Thread B frei.
Der richtige Ansatz ist, beim Erhalten des Locks eine eindeutige Anfragekennung (z. B. requestId
) hinzuzufügen und den Lock nur freizugeben, wenn die Kennung übereinstimmt:
try { if (jedis.set(lockKey, requestId, "NX", "PX", expireTime) == 1) { // Acquire lock doBusiness // Business logic return true; // Lock acquired and business logic processed } return false; // Lock acquisition failed } finally { if (requestId.equals(jedis.get(lockKey))) { // Überprüfen, ob es sich um dieselbe RequestId handelt unlock(lockKey); // Release lock } }
6. Das Freigeben des Locks ist nicht atomar
Sogar der vorherige Code hat einen Fehler:
if (requestId.equals(jedis.get(lockKey))) { // Überprüfen, ob es sich um dieselbe RequestId handelt unlock(lockKey); // Release lock }
Da die Überprüfung (get
) und die Freigabe (del
) zwei getrennte Operationen sind, sind sie nicht atomar. Wenn der Lock bereits abgelaufen ist, wenn unlock(lockKey)
aufgerufen wird, wurde der Lock möglicherweise von einem anderen Client erhalten. Ihn jetzt freizugeben, würde den Lock einer anderen Person entfernen, was gefährlich ist.
Dies führt zu einem Konsistenzproblem – die Überprüfung und Löschung müssen atomar sein. Um Atomizität beim Freigeben des Locks zu gewährleisten, kann man Redis + Lua-Skript verwenden, wie dieses:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
7. Lock läuft ab, aber die Geschäftslogik ist noch nicht abgeschlossen
Nachdem der Lock erhalten wurde, wird er automatisch von Redis gelöscht, wenn er aufgrund eines Timeouts abläuft. Die Geschäftslogik ist aber möglicherweise noch nicht abgeschlossen, was zu einer vorzeitigen Freigabe des Locks führt.
Einige EntwicklerInnen meinen, die einfache Lösung sei es, eine längere Ablaufzeit festzulegen. Aber überlege Folgendes: Es ist möglich, einen Watchdog-Thread für den Thread zu starten, der den Lock erhalten hat. Dieser Thread kann regelmäßig überprüfen, ob der Lock noch existiert, und gegebenenfalls seine Ablaufzeit verlängern, um eine vorzeitige Freigabe zu verhindern.
Dieses Problem wurde durch das Open-Source-Framework Redisson angegangen.
Sobald ein Thread den Lock erhält, startet Redisson einen Watchdog, einen Hintergrundthread, der den Lock alle 10 Sekunden überprüft. Wenn der Thread den Lock noch hält, verlängert der Watchdog seine TTL immer wieder. Auf diese Weise löst Redisson das Problem der vorzeitigen Lock-Ablaufzeit, wenn die Geschäftslogik noch nicht abgeschlossen ist.
8. Der verteilte Redis-Lock wird bei Verwendung mit @Transactional
unwirksam
Schau dir diesen Pseudo-Code an:
@Transactional public void updateDB(int lockKey) { boolean lockFlag = redisLock.lock(lockKey); if (!lockFlag) { throw new RuntimeException("Please try again later"); } doBusiness // Business logic redisLock.unlock(lockKey); }
In diesem Fall wird ein verteilter Redis-Lock innerhalb einer Transaktionsmethode verwendet. Sobald diese Methode ausgeführt wird:
- Die Transaktion beginnt aufgrund des Spring AOP.
- Der Redis-Lock wird abgerufen.
- Nach Ausführung der Geschäftslogik wird der Redis-Lock freigegeben.
- Erst dann wird die Transaktion übernommen.
Dies verursacht ein Problem: Der Lock wird freigegeben, bevor die Transaktion übernommen wird. Ein anderer Thread kann den Lock abrufen und seine Logik ausführen, indem er veraltete Daten liest, die noch nicht von der ersten Transaktion übernommen wurden.
Warum passiert das?
Spring AOP startet die Transaktion, bevor updateDB()
ausgeführt wird. Der Redis-Lock wird dann innerhalb der Methode abgerufen. Sobald die Methode abgeschlossen ist, wird der Lock freigegeben, aber die Transaktion wurde noch nicht übernommen.
Korrekter Ansatz: den Lock vor dem Aufrufen der Transaktionsmethode abrufen – bevor die Transaktion überhaupt beginnt. Auf diese Weise befindet sich der durch den Lock geschützte Code vollständig in einem konsistenten Zustand.
9. Reentrante Locks
Die verteilten Redis-Locks, die wir bisher besprochen haben, sind nicht reentrant.
Nicht-Reentranz bedeutet, dass, wenn ein Thread bereits einen Lock hält und versucht, ihn erneut zu erhalten (innerhalb desselben Threads), er blockiert oder fehlschlägt. Mit anderen Worten kann ein Thread denselben Lock nur einmal erhalten.
Diese Art von Lock funktioniert für die meisten Geschäftsfälle, aber einige Szenarien erfordern Reentranz. Berücksichtige bei der Entwicklung deines verteilten Locks, ob deine Anwendung einen reentrant verteilten Lock benötigt.
Um reentrantes Verhalten in Redis zu implementieren, müssen zwei Probleme gelöst werden:
- Möglichkeit, zu verfolgen, welcher Thread den Lock gerade hält.
- Möglichkeit, die Anzahl der Abrufe des Locks zu verwalten (Reentranz-Anzahl).
Um einen reentrant verteilten Lock zu erstellen, kannst du dich am Design von Java's ReentrantLock
orientieren. Alternativ kannst du Redisson verwenden, das nativ reentrante Locks unterstützt.
10. Probleme, die durch die Master-Slave-Replikation von Redis verursacht werden
Bei der Implementierung eines verteilten Redis-Locks ist Vorsicht geboten vor Problemen, die durch das Master-Slave-Replikations-Setup von Redis verursacht werden. Redis wird oft als Cluster bereitgestellt:
Stell dir vor, Thread A erhält einen Lock auf dem Master-Knoten, aber der Lock-Schlüssel wurde noch nicht auf die Slave-Knoten repliziert. Wenn der Master-Knoten ausfällt, kann einer der Slaves zum Master befördert werden. Nun kann Thread B denselben Lock-Schlüssel erhalten, da der Schlüssel im neuen Master nicht existiert. Aber Thread A glaubt immer noch, dass er den Lock hält. Nun glauben beide Threads, dass sie den Lock haben – dies gefährdet die Lock-Sicherheit.
Um dies zu lösen, schlug Redis-Autor antirez einen fortschrittlicheren verteilten Lock-Algorithmus namens Redlock vor.
Die Kernidee von Redlock:
Verwende mehrere Redis-Master-Knoten, um eine hohe Verfügbarkeit zu gewährleisten. Diese Knoten sind völlig unabhängig – keine Replikation zwischen ihnen. Die gleiche Lock-Logik (erhalten/freigeben) wird auf jeden Master angewendet.
Angenommen, wir haben 5 Redis-Master-Knoten auf separaten Servern. Redlocks Schritte:
- Versuche sequentiell, den Lock auf allen 5 Master-Knoten zu erhalten.
- Wenn ein Knoten nicht erreichbar ist (z. B. Netzwerklatenz), überspringen Sie ihn nach einem Timeout.
- Wenn die Lock-Akquisition auf mindestens 3 von 5 Knoten erfolgreich ist und die gesamte verwendete Zeit kürzer ist als die TTL des Locks, gilt der Lock als erfolgreich.
- Wenn die Akquisition fehlschlägt, gib alle zuvor erhaltenen Locks frei.
Wir sind Leapcell, deine erste Wahl für das Hosten von Backend-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Language-Unterstützung
- Entwickle mit Node.js, Python, Go oder Rust.
Stelle unbegrenzt Projekte kostenlos bereit
- zahle nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützt 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur mühelosen Bewältigung hoher Parallelität.
- Null Betriebsaufwand – konzentriere dich einfach auf den Aufbau.
Erfahre mehr in der Dokumentation!
Folge uns auf X: @LeapcellHQ