Distributed Lock mit Redis in Spring Boot
1. Übersicht
Wenn deine Anwendung auf mehreren Instanzen läuft, reichen synchronized oder ReentrantLock nicht mehr aus. Diese Locks wirken nur innerhalb einer JVM. Dieser Artikel zeigt dir, wie du Distributed Locks mit Redis auf zwei Wegen aufbaust: durch eigene Implementierung mit Spring Data Redis und durch die Redisson-Bibliothek. Nach dem Lesen wirst du wissen, wie du Locks sicher erwirbst und freigibst, wie du Tests für Locks schreibst und in welchen Fällen Redis-Locks nicht sicher genug sind.
2. Warum man Distributed Locks braucht
Stell dir vor, du hast einen OrderService, der auf 3 Pods läuft. Zwei Kunden bestellen das letzte Produkt im Lager. Zwei Requests landen auf verschiedenen Pods. Beide sehen gleichzeitig stock = 1, subtrahieren beide vom Bestand und das Ergebnis ist Überverkauf.
Ein anderes Beispiel ist ein @Scheduled Job, der um 2 Uhr morgens einen Bericht erstellt. Bei 3 Instanzen läuft der Job 3-mal und der Kunde erhält 3 identische E-Mails.
In beiden Fällen brauchst du einen Lock, den alle Instanzen sehen können. Redis eignet sich hervorragend für diese Rolle aus drei Gründen:
- Redis verarbeitet Befehle sequenziell in einem Thread, daher ist jeder Befehl atomic.
- Keys können TTL haben, also verfällt der Lock automatisch, wenn der Prozess, der ihn hält, abstürzt.
- Geringe Latenz und die meisten Systeme haben bereits Redis.
3. Prinzip: Der Befehl SET NX PX
Der gesamte Mechanismus basiert auf einem einzigen Befehl:
redis-cli SET lock:product:42 "c1f9-token" NX PX 10000
Die Bedeutung der einzelnen Teile:
NX: Schreibe nur, wenn der Key nicht existiert. Das ist der Schritt “Lock erwerben”.PX 10000: Der Key verfällt automatisch nach 10 Sekunden. Falls der Prozess abrupt endet, bleibt der Lock nicht ewig bestehen."c1f9-token": Ein eindeutiger Zufallswert für jeden Lock-Erwerb. Dieser Wert identifiziert, wer den Lock hält.
Wenn du den Befehl zweimal hintereinander aufrufst, siehst du:
OK
(nil)
Das erste Mal gibt es OK zurück, was bedeutet, dass du den Lock erworben hast. Das zweite Mal gibt es (nil) zurück, weil der Key bereits existiert.
Achtung: Verwende nicht
SETNXgefolgt vonEXPIREin separaten Befehlen. Falls der Prozess zwischen den beiden Befehlen abstürzt, wird der Key nie ablaufen und du hast einen Deadlock.
4. Eigene Implementierung mit Spring Data Redis
4.1. Abhängigkeit
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Konfiguriere die Verbindung in application.properties:
spring.data.redis.host=localhost
spring.data.redis.port=6379
4.2. Lock erwerben und freigeben
@Component
public class RedisLock {
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""", Long.class);
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public Optional<String> tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
}
public boolean unlock(String key, String token) {
Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
return deleted != null && deleted == 1L;
}
}
Erklärung:
setIfAbsent(key, token, ttl)erzeugt genau den BefehlSET key token NX PX ttl. Das Ergebnistruebedeutet, dass du den Lock erworben hast.tryLockgibt das Token zurück, wenn erfolgreich. Du musst dieses Token speichern, um den Lock später freizugeben.unlockführt ein Lua-Skript aus. Das Skript prüft, ob der Wert des Keys genau dein Token ist, und löscht den Key nur dann.
Warum nicht einfach DEL aufrufen? Beachte dieses Szenario. Instanz A erwirbt den Lock mit TTL 10 Sekunden, aber die Verarbeitung dauert 12 Sekunden. Nach 10 Sekunden verfällt der Lock und Instanz B erwirbt ihn. Nach 12 Sekunden ruft A DEL auf und löscht Bs Lock. Jetzt können Instanz C eintreten und B zusammen mit C konkurrieren.
Wenn du GET und dann DEL trennst, bleibt immer noch eine Lücke zwischen den beiden Befehlen. Ein Lua-Skript wird von Redis atomic ausgeführt, sodass diese Lücke nicht existiert.
4.3. Verwendung im Service
public record Order(long productId, int quantity) {}
@Service
public class OrderService {
private final RedisLock redisLock;
private final InventoryRepository inventory;
public OrderService(RedisLock redisLock, InventoryRepository inventory) {
this.redisLock = redisLock;
this.inventory = inventory;
}
public void placeOrder(Order order) {
String key = "lock:product:" + order.productId();
String token = redisLock.tryLock(key, Duration.ofSeconds(10))
.orElseThrow(() -> new IllegalStateException("Produkt wird gerade verarbeitet, bitte später versuchen"));
try {
inventory.decrease(order.productId(), order.quantity());
} finally {
redisLock.unlock(key, token);
}
}
}
Es gibt drei wichtige Punkte:
- Der Lock-Key sollte granular pro Ressource sein, hier
lock:product:{id}. Wenn du einen gemeinsamen Lock für alle Produkte verwendest, werden alle Bestellungen hintereinander verarbeitet. unlocksteht immer infinally, sodass der Lock auch dann freigegeben wird, wenn eine Exception auftritt.- Dieses Beispiel schlägt schnell fehl, wenn der Lock nicht erworben werden kann. Wenn du warten möchtest, kannst du ein paar Mal mit kurzem Backoff neu versuchen. Verwende keine unbegrenzte
while(true)Schleife.
InventoryRepository ist ein beliebiger Repository in deinem Projekt, z.B. ein Spring Data JPA Repository.
5. Mit Redisson arbeiten
Eigene Implementierung hilft dir, den Mechanismus zu verstehen, aber es fehlen einige Funktionen: Lock-Verlängerung bei langer Verarbeitung, Reentranz und Warten auf den Lock über pub/sub statt Polling. Redisson bietet diese Dinge bereits an.
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.45.1</version>
</dependency>
Du solltest die neueste Version überprüfen, die mit deiner Spring Boot Version kompatibel ist. Der Starter liest die spring.data.redis.* Konfiguration und erstellt automatisch den RedissonClient Bean.
@Service
public class DailyReportJob {
private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
private final RedissonClient redisson;
public DailyReportJob(RedissonClient redisson) {
this.redisson = redisson;
}
@Scheduled(cron = "0 0 2 * * *")
public void generate() throws InterruptedException {
RLock lock = redisson.getLock("lock:daily-report");
if (!lock.tryLock(0, TimeUnit.SECONDS)) {
log.info("Eine andere Instanz führt den Bericht aus, überspringe");
return;
}
try {
log.info("Starte die tägliche Berichterstellung");
// Daten abfragen, Datei exportieren, E-Mail senden...
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Wenn der Job auf 3 Instanzen läuft, sieht das Log etwa so aus:
[pod-1] Starte die tägliche Berichterstellung
[pod-2] Eine andere Instanz führt den Bericht aus, überspringe
[pod-3] Eine andere Instanz führt den Bericht aus, überspringe
Wichtige Punkte:
tryLock(0, TimeUnit.SECONDS)wartet nicht. Falls der Lock bereits gehalten wird, gibt die Funktion sofortfalsezurück.- Wenn du keine
leaseTimeübergibst, aktiviert Redisson den Watchdog. Der Lock hat standardmäßig eine TTL von 30 Sekunden (lockWatchdogTimeout) und wird etwa alle 10 Sekunden erneuert, solange der Thread den Lock hält. Falls die JVM abstürzt, stoppt die Erneuerung und der Lock verfällt automatisch. - Wenn du
tryLock(wait, lease, unit)mit explizitemleaseTimeaufrufst, wird der Watchdog deaktiviert. Der Lock verfällt genau nach derleaseZeit, ob die Arbeit fertig ist oder nicht. RLockist an den Thread gebunden und reentrant. Wenn duunlock()von einem anderen Thread aufrufst, erhältst duIllegalMonitorStateException. Das ist besonders wichtig, wenn du mitCompletableFutureoder reaktivem Code arbeitest.
Achtung: Weil
RLockan den Thread gebunden ist, musst du vorsichtig sein, wenn du mit Virtual Threads und Frameworks arbeitest, die Threads zwischendurch wechseln. Der Lock muss auf dem gleichen Thread erworben und freigegeben werden.
6. Tests mit Testcontainers schreiben
Du solltest Locks auf echtem Redis testen, nicht mit Mocks. Füge diese Test-Abhängigkeiten hinzu (Versionen werden von Spring Boot verwaltet):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
RedisLock redisLock;
@Autowired
StringRedisTemplate template;
@Test
void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
.<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
.toList();
try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
long acquired = executor.invokeAll(tasks).stream()
.map(Future::resultNow)
.filter(Optional::isPresent)
.count();
assertThat(acquired).isEqualTo(1);
}
}
@Test
void givenWrongToken_whenUnlock_thenLockIsKept() {
String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();
assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
assertThat(template.hasKey("lock:owner")).isTrue();
assertThat(redisLock.unlock("lock:owner", token)).isTrue();
assertThat(template.hasKey("lock:owner")).isFalse();
}
}
Erklärung:
@DataRedisTestlädt nur die Redis-Konfiguration, sodass Tests schnell laufen.@Import(RedisLock.class)bringt den zu testenden Bean in den Context.@ServiceConnection(name = "redis")leitetStringRedisTemplateautomatisch zum gerade gestarteten Container um. Du musst Host und Port nicht manuell angeben.- Der erste Test lässt 20 Threads um einen Lock konkurrieren und bestätigt, dass genau 1 Thread gewinnt.
- Der zweite Test verifiziert, dass das Lua-Skript korrekt funktioniert: Falsches Token kann den Lock nicht löschen, richtiges Token schon.
7. Häufige Fehler und Grenzen
7.1. TTL kürzer als Verarbeitungszeit
Das ist der häufigste Fehler. Der Lock verfällt, während die Arbeit noch läuft, und eine andere Instanz schlüpft ein. Der Grund ist nicht nur langsamer Code. Garbage Collection Pausen, langsames Netzwerk oder Warten auf einen Connection Pool können dich über die TTL hinaus verzögern.
Es gibt zwei Lösungen. Die erste ist, den Watchdog von Redisson zu verwenden. Die zweite ist, eine großzügige TTL zu setzen, etwa mehrfach der p99 Verarbeitungszeit.
7.2. Lock garantiert nichts absolut
Redis Replication ist asynchron. Angenommen, der Master empfängt den Lock, stürzt aber ab, bevor er zum Replica repliziert wird. Das Replica wird zum neuen Master hochgestuft und hat keinen Lock-Key. Ein anderer Client kann jetzt den gleichen Lock erwerben.
Der Redlock-Algorithmus (Lock auf der Mehrheit von N unabhängigen Knoten erwerben) reduziert dieses Risiko. Allerdings ist Redlock umstritten, weil es Annahmen über Uhren und Latenz macht. In Redisson ist RedissonRedLock mittlerweile deprecated.
Falls kritische Operationen wie doppeltes Geldabbuchen schwere Konsequenzen haben, verlasse dich nicht nur auf Locks. Kombiniere mit einem Fencing Token: Jedes Mal, wenn du einen Lock erwirbst, erhältst du eine steigende Nummer (z.B. aus INCR lock:product:42:fence). Die Speicherschicht lehnt Writes mit älteren Tokens ab:
UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;
Falls die Update-Anweisung 0 Reihen updated, hat ein Prozess mit neuerer Token bereits geschrieben. Die ältere Request wird verworfen.
7.3. Weitere Fehler
- Verwendung eines gemeinsamen Keys für alle Ressourcen, was den Durchsatz stark reduziert.
- Vergessenes
finally, sodass der Lock nur bei TTL-Ablauf freigegeben wird. - Aufruf von
unlock()der Redisson, wenn du den Lock nicht mehr hältst (weil TTL abgelaufen ist). PrüfeisHeldByCurrentThread()vorher. - Kontinuierliches Retry ohne Backoff, was Redis mit Requests bombardiert.
7.4. Wann man NICHT Locks verwenden sollte
- Anwendung hat nur eine Instanz:
ReentrantLockreicht und ist viel einfacher. - Daten befinden sich in einer relationalen Datenbank: Optimistic Locking (
@Version),SELECT ... FOR UPDATEoder Unique Constraints sind meist sicherer. Sie sind in der gleichen Transaction wie die Daten. - Absolute Garantie erforderlich: Nutze Systeme mit Konsens wie ZooKeeper oder etcd, kombiniert mit Fencing Tokens.
- Jede Nachricht soll nur einmal verarbeitet werden: Idempotency Keys oder Consumer Groups von Message Brokern sind meist besser als Locks.
8. Fazit
Distributed Locks mit Redis basieren auf drei Prinzipien: Lock mit atomarem Befehl SET NX PX erwerben, eindeutiges Token für jeden Erwerb binden und Lock mit Lua-Skript mit Token-Prüfung freigeben. Du kannst das selbst in wenigen Dutzend Zeilen mit StringRedisTemplate implementieren oder Redisson verwenden für Watchdog, Reentranz und effiziente Wartemechanismen.
Denke daran, dass Redis-Locks in den meisten Fällen doppelte Verarbeitung verhindern, aber keine absolute Garantie bieten. Bei kritischen Operationen kombiniere Locks mit Fencing Tokens oder Constraints auf der Datenbankebene. Verwende immer echtes Redis zum Testen mit Testcontainers.