Salta al contenuto principale

Lock Redis: il DEL senza proprietario e come correggerlo

Un DEL non condizionato cancella il lock di chi è subentrato dopo la scadenza. Il difetto riprodotto con redis-cli, la cura con token e Lua e il suo limite.

Cosa ne pensi di questo articolo?

Lock Redis: il DEL senza proprietario e come correggerlo
Redisfedericocalo.dev

In Breve

Un DEL non condizionato cancella il lock di chi è subentrato dopo la scadenza. Il difetto riprodotto con redis-cli, la cura con token e Lua e il suo limite.

In breve. SET chiave valore NX PX 30000 è un lock che scade: prende la chiave se non c'è e la libera da sola dopo 30 secondi. Basta finché la sezione critica dura meno del TTL. Il difetto sta nel rilascio: se chi tiene il lock lo libera con un DEL non condizionato, e nel frattempo il TTL è scaduto e un altro processo ha preso il lock, quel DEL cancella il lock dell'altro, e un terzo può entrare. Nel mio backend il difetto era reale e l'ho corretto il 2026-11-08 (commit 67a92d3f33): ogni acquisizione scrive un UUID, e il rilascio è uno script Lua che cancella solo se il valore è il proprio. Riprodotto con redis-cli, il DEL restituisce 1 e il lock di B sparisce; con lo script restituisce 0 e B lo tiene. Ma la correzione ha un limite che va detto subito: impedisce di cancellare il lock altrui, non impedisce che due titolari lavorino insieme dopo la scadenza.

Metodo. Il racconto è del difetto già corretto: il codice lo leggo dal commit, non dal working tree. I comandi li ho eseguiti il 12 novembre 2026 su Redis 8.0.5 (nativo, porta 6392) e su 7.4.7 (redis/redis-stack-server:latest), con identico risultato. I test Java del commit non li ho eseguiti: ne riporto il contenuto e ciò che il messaggio del commit dichiara, senza confermarlo. La gravità del difetto in produzione non la conosco, e lo spiego più sotto.

Prima di scrivere ho controllato che la correzione fosse davvero nella storia e non solo nel mio working tree. Il commit c'è, è l'ultimo che tocca il file, e nessun file del servizio o dei suoi test risulta modificato o non tracciato:

$ git log --oneline -3 -- backend/src/main/java/dev/federicocalo/portfolio/application/service/IdempotencyService.java
67a92d3f33 fix(backend,infra): il rilascio del lock cancellava il lock di un altro titolare
3440bc566a token e data
$ git status --short -- backend/src/main/java/dev/federicocalo/portfolio/application/service/ backend/src/test/java/dev/federicocalo/portfolio/application/service/ | wc -l
0

Il secondo numero conta: uno zero significa che quello che leggo con git show è anche ciò che c'è su disco. Se avessi trovato file modificati, avrei dovuto aspettare, perché un articolo che racconta un difetto «corretto» non può appoggiarsi a una correzione ancora in lavorazione.

Che cosa promette un lock che scade

Un'acquisizione che non lascia chiavi eterne

Prendere un lock su Redis vuol dire creare una chiave solo se non esiste, e darle subito una scadenza. Il comando che fa le due cose insieme è SET con NX e PX: se la chiave c'è risponde con un valore nullo, altrimenti la crea e ne fissa la scadenza in un colpo solo. Farlo con due comandi (SETNX poi EXPIRE) lascia una finestra in cui, se il processo muore fra i due, il lock resta per sempre; ed è una delle ragioni per cui la pagina di SET segnala SETNX come candidato a diventare deprecato. L'acquisizione del backend, già prima della correzione, usava il comando unico, e infatti il commit lo dice: il difetto era solo il valore.

A che cosa serve il TTL

La scadenza è una valvola di sicurezza: se chi tiene il lock crasha, dopo 30 secondi la chiave sparisce e gli altri possono riprovare. Il prezzo è che il lock non è più garantito oltre quel tempo. La documentazione di Redis sui lock distribuiti lo dice nel punto che qui conta: un client può acquisire il lock, restare bloccato in un'operazione più a lungo del tempo di validità del lock, e poi rimuovere un lock che nel frattempo era stato preso da un altro. Per questo il lock di Redis è consultivo: ti dice «sembra libero», non «è tuo finché lavori».

Il difetto, riprodotto

Perché il DEL senza proprietario cancella il lock di un altro?

Il vecchio codice scriveva sempre il valore "1" e rilasciava con un DEL. Le righe sono quelle del commit precedente (git show 67a92d3f33^:...): alla riga 184 .setIfAbsent(LOCK_PREFIX + key, "1", ttl), alla riga 199 redis.delete(LOCK_PREFIX + key). Si ritrovano con un comando:

$ git show 67a92d3f33^:backend/src/main/java/dev/federicocalo/portfolio/application/service/IdempotencyService.java | grep -n 'setIfAbsent(LOCK_PREFIX\|redis.delete(LOCK_PREFIX'
184:                    .setIfAbsent(LOCK_PREFIX + key, "1", ttl);
199:            redis.delete(LOCK_PREFIX + key);

Il valore uguale per tutti rende i titolari indistinguibili. Questa è la sequenza che porta al guasto, con un TTL di un secondo per non aspettare, tre processi e nessuna finezza:

$ redis-cli -p 6392 set dq:lock:r 1 nx px 1000
OK
$ sleep 1.2
$ redis-cli -p 6392 set dq:lock:r 1 nx px 30000
OK
$ redis-cli -p 6392 del dq:lock:r
1
$ redis-cli -p 6392 exists dq:lock:r
0
$ redis-cli -p 6392 set dq:lock:r 1 nx px 30000
OK

La prima riga è A che prende il lock con TTL di un secondo. Dopo 1,2 secondi la chiave è scaduta e B la prende (la seconda OK): fin qui il lock ha fatto il suo lavoro, perché A è lento e B è subentrato. Poi A finisce e rilascia con DEL, che risponde 1: ha cancellato una chiave, ma era quella di B. EXISTS dà 0, e C prende il lock (ultima OK) mentre B sta ancora lavorando. Due processi credono entrambi di tenere il lock.

Dove morde nel mio backend

L'unico chiamante è DatasetQueryService, che prende il lock per la coppia utente-dataset prima di consumare crediti ed eseguire la query. Il TTL è una costante, LOCK_TTL = Duration.ofSeconds(30), alla riga 85, e il rilascio avviene nel finally. Ecco il punto d'uso dopo la correzione, estratto dal diff del commit:

String lockKey = "user:" + userId + ":dataset:" + ds.id();
LockHandle lock = idempotency.tryAcquireLock(lockKey, LOCK_TTL).orElse(null);
if (lock == null) {
    throw new ConcurrentQueryException(
            "Another query for this dataset is already in flight; retry shortly.");
}
// ... query e consumo dei crediti ...
} finally {
    idempotency.releaseLock(lock);
}

Perché il difetto si manifesti serve una query più lenta di 30 secondi, e che durante quella lentezza una seconda richiesta della stessa coppia chieda il lock. Quanto spesso accada non lo so: il codice calcola durationMs e lo scrive nel log, ma non ho letto i log di esecuzione, quindi non dico che sia raro né che sia frequente, né che abbia mai addebitato due volte.

La correzione, riga per riga

Che cosa è cambiato nel codice

Il LockHandle è la prova di proprietà, ed è una riga di codice:

public record LockHandle(String key, String token) {}

Il commit cambia tre cose nel servizio. L'acquisizione scrive un identificatore casuale per ogni tentativo e lo restituisce dentro un LockHandle, un record con la chiave e il token. Il rilascio riceve il LockHandle, non più la stringa. E il rilascio esegue uno script. Questo è l'estratto, dal repository, non eseguito da me:

private static final RedisScript<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);

public Optional<LockHandle> tryAcquireLock(String key, Duration ttl) {
    LockHandle handle = new LockHandle(key, UUID.randomUUID().toString());
    try {
        Boolean acquired = redis.opsForValue().setIfAbsent(LOCK_PREFIX + key, handle.token(), ttl);
        return acquired == null || acquired ? Optional.of(handle) : Optional.empty();
    } catch (Exception e) {
        log.warn("Lock acquire failed (Redis down?): {} - degrading to lock-free", e.getMessage());
        return Optional.of(handle);
    }
}

public void releaseLock(LockHandle handle) {
    if (handle == null) return;
    Long deleted = redis.execute(RELEASE_SCRIPT, List.of(LOCK_PREFIX + handle.key()), handle.token());
    if (deleted == null || deleted == 0L) {
        log.warn("Lock {} not released: expired or owned by another holder", handle.key());
    }
}

Nello script, KEYS[1] è la chiave del lock e ARGV[1] è il token del chiamante: il confronto e la cancellazione avvengono dentro Redis, in un solo passo, senza che tra il GET e il DEL possa infilarsi un altro client. Con due comandi separati dal client, la finestra fra i due sarebbe esattamente lo scenario di prima; è la ragione per cui lo script è la forma giusta, e ne ho mostrato i dettagli nell'articolo su Lua, EVAL ed EVALSHA. Il ramo deleted == 0L è nuovo: un rilascio tardivo non è più un danno silenzioso ma una riga di log. (Nell'estratto ho tolto il try attorno al rilascio per brevità: nel file c'è.)

Lo stesso scenario, dopo

Ripeto la sequenza con i token. Qui le OK hanno lo stesso significato di prima; la riga vuota dopo tokC è la risposta nulla di redis-cli quando non è collegato a un terminale:

$ redis-cli -p 6392 set dq:lock:r tokA nx px 1000
OK
$ sleep 1.2
$ redis-cli -p 6392 set dq:lock:r tokB nx px 30000
OK
$ redis-cli -p 6392 eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 dq:lock:r tokA
0
$ redis-cli -p 6392 get dq:lock:r
tokB
$ redis-cli -p 6392 exists dq:lock:r
1
$ redis-cli -p 6392 set dq:lock:r tokC nx px 30000

$ redis-cli -p 6392 eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 dq:lock:r tokB
1
$ redis-cli -p 6392 exists dq:lock:r
0

Il rilascio tardivo di A restituisce 0 e la chiave contiene ancora tokB: il lock di B è al suo posto, e EXISTS lo conferma con 1. C, che tenta di entrare mentre B lavora, riceve il valore nullo: respinto. Solo quando B rilascia con il proprio token la risposta è 1 e EXISTS dà 0. Le cifre sono le stesse su 7.4.7 e su 8.0.5; la sola riga che differisce nel mio confronto automatico è quella della versione.

Il test che lo inchioda

Il commit aggiunge un test che ricostruisce la stessa sequenza con un doppio di StringRedisTemplate: una mappa in memoria sostituisce Redis, e un when(...) riproduce lo script («cancella solo se il valore è ARGV[1]»). Questo è l'estratto, dal repository, non eseguito da me:

Optional<LockHandle> a = service.tryAcquireLock("res", Duration.ofSeconds(5));
store.clear(); // TTL of A expires on the server
Optional<LockHandle> b = service.tryAcquireLock("res", Duration.ofSeconds(5));
service.releaseLock(a.orElseThrow()); // A wakes up late and releases
Optional<LockHandle> c = service.tryAcquireLock("res", Duration.ofSeconds(5));

assertThat(c).as("B still holds the lock: C must be refused").isEmpty();

Il test si chiama staleReleaseDoesNotStealLock, e il messaggio del commit afferma che fallisce sul codice precedente e passa sul nuovo, con 16 test sulla classe e zero falliti. Io non l'ho rieseguito, e non ho riprodotto il rosso. C'è però un limite del test che il codice stesso dichiara: il doppio sostituisce lo script con una funzione Java, quindi il test prova la logica del rilascio, non il Lua. Il Lua lo prova un secondo test, IdempotencyServiceRedisIT, che gira contro un Redis vero e richiede Docker; neppure quello l'ho eseguito, ma la riproduzione a mano qui sopra fa la stessa verifica sullo stesso testo di script.

Che cosa la correzione non risolve

Il compare-and-delete basta a garantire la mutua esclusione?

Il confronto-e-cancella impedisce di cancellare il lock altrui. Non impedisce che A, ancora al lavoro dopo i 30 secondi, e B, subentrato appena il TTL è scaduto, siano entrambi dentro la sezione critica: nella prima sequenza, il momento in cui la seconda OK compare è già quello. Per far rispettare la mutua esclusione oltre la scadenza servirebbe un fencing token, un numero crescente che il lock restituisce a ogni acquisizione e che la risorsa protetta controlla a ogni scrittura, rifiutando quelle di un titolare scaduto. Lo descrive Martin Kleppmann nell'articolo sui lock distribuiti, e la stessa pagina di Redis consiglia di implementarli quando contano coerenza e correttezza. Qui non ci sono.

Con Redis giù il lock non esiste

Il secondo limite sta nel codice sopra: se Redis non risponde, tryAcquireLock logga un avviso e restituisce comunque un LockHandle, con la dicitura «degrading to lock-free». È una scelta, non una svista: il servizio resta disponibile e la mutua esclusione sparisce. Quale tra le due cose pesi di più dipende dal costo di una doppia esecuzione, ed è il tema dell'articolo sul fail-open e fail-closed. In questo articolo basta sapere che il lock di cui parlo vale solo se Redis risponde.

Un'altra novità, e perché non la uso

La pagina dei lock di Redis riporta che da Redis 8.4 esiste un comando DELEX chiave IFEQ valore che fa il confronto-e-cancella senza script. Sul mio Redis 8.0.5 non c'è:

$ redis-cli -p 6393 set k tokA
OK
$ redis-cli -p 6393 delex k ifeq tokA
ERR unknown command 'delex', with args beginning with: 'k' 'ifeq' 'tokA'

L'errore è quello che ti aspetteresti, e conferma che la pagina parla di una versione più nuova della mia. Se il tuo Redis è 8.4 o successivo il comando semplifica il codice; se non lo è, o se non sai ancora quale versione hai in produzione (io non lo so), lo script resta la forma portabile. Prima di scegliere, controlla con INFO server.

Gli altri due idiomi del sito, per distinguerli

Una finestra, non un lock

Nel backend ci sono due usi di Redis con scadenza che somigliano a un lock e non lo sono. Il primo è RedisHeartbeatDedupGate: scrive una chiave con setIfAbsent e una finestra di 20 secondi (DEDUP_WINDOW), e il primo che arriva vince; nessuno la rilascia, scade da sola. Il secondo è BookingTokenService.validateAndBurn, che usa getAndDelete per consumare un token una volta sola. Li ho riprodotti con EX 2 per non aspettare:

$ redis-cli -p 6392 set hb:dedup:42 1 nx ex 2
OK
$ redis-cli -p 6392 set hb:dedup:42 1 nx ex 2

$ sleep 2.2
$ redis-cli -p 6392 set hb:dedup:42 1 nx ex 2
OK
$ redis-cli -p 6392 set bk:tok:abc 1 ex 60
OK
$ redis-cli -p 6392 getdel bk:tok:abc
1
$ redis-cli -p 6392 getdel bk:tok:abc

Nel primo caso, il secondo tentativo dentro la finestra ottiene il valore nullo e, dopo due secondi, la chiave si riapre: è una deduplicazione, il suo scopo è scartare i duplicati vicini, e il valore fisso va bene perché nessuno deve distinguere i titolari. Nel secondo, GETDEL restituisce il valore al primo che arriva e il valore nullo a chi arriva dopo: la lettura e la cancellazione sono un solo comando, e per questo un token monouso non ha il problema del lock. Il difetto riguardava solo il caso in cui chi rilascia deve dimostrare di essere il titolare.

Il trade-off: che cosa farei

Per un lock consultivo con una sezione critica che sta quasi sempre sotto il TTL, userei quello che c'è ora: SET NX PX con un token e il rilascio condizionato. È semplice, si legge, e sbaglia in modo noto. Ho scartato Redlock perché richiede più istanze Redis indipendenti, che il sito non ha; ho scartato i meccanismi del database (un lock di riga, un advisory lock) perché cambiano il costo di ogni richiesta e non risolvono il fencing da soli. Se la sezione critica può superare il TTL, e il danno è un addebito doppio, il fencing token è la cosa che manca, non un lock più elaborato.

  • Scrivi un valore unico per titolare (un UUID) e rilascia sempre con confronto: un DEL semplice è giusto solo per chiavi che nessun altro può aver ripreso.
  • Prendi il lock con un solo comando (SET ... NX PX), mai con SETNX seguito da EXPIRE.
  • Scegli il TTL sulla durata peggiore, non su quella media: la media è quella che non rompe il lock.
  • Decidi che cosa fa il lock quando Redis è giù, e scrivilo nel codice: il «lock-free» è una scelta, non un default.
  • Se un doppio ingresso costa denaro, aggiungi un fencing token sulla risorsa: il lock da solo non basta.

Le prime due regole sono quelle che il commit ha messo a posto, e costano poche righe. La terza e la quarta sono decisioni di progetto che vanno prese una volta e documentate. L'ultima è l'unica che richiede di cambiare la risorsa protetta, e per questo è quella che di solito si rimanda; conviene farlo solo dopo aver misurato quanto spesso una sezione critica supera il TTL, che nel mio caso è il dato che manca.

Cosa sai fare adesso

Sai riprodurre con pochi comandi il difetto del rilascio senza proprietario e riconoscerlo in un lock che vedi in un backend: un valore uguale per tutti e un DEL finale. Sai scrivere la correzione, un token per titolare e uno script che confronta e cancella, e sai dire con precisione che cosa non risolve: due titolari insieme dopo la scadenza e il comportamento con Redis giù. Sai distinguere un lock da una finestra di deduplicazione e da un token monouso, e sai che dalla versione 8.4 esiste un comando per fare il confronto-e-cancella senza script.

Per lo script in dettaglio, compresi EVALSHA e NOSCRIPT, c'è l'articolo su Lua in Redis; per il prefisso che separa le chiavi di una cache da quelle di un lock, Spring Cache su Redis. Sull'idempotenza delle operazioni che il lock protegge c'è l'articolo sulla deduplicazione e le chiavi di idempotenza, e sul passo successivo, che cosa succede se Redis non risponde, il fail-open e il fail-closed.

Hai un lock Redis e non sai se regge una sezione lenta? Prenota una call →

Fonti

  • Redis, Distributed Locks with Redis - lo script di confronto-e-cancella, la frase sul rischio di rimuovere un lock creato da un altro client, il comando DELEX ... IFEQ da Redis 8.4, l'avviso su orologi non monotoni e il consiglio di implementare i fencing token. Consultata il 2026-11-12.
  • Martin Kleppmann, How to do distributed locking - la spiegazione del fencing token: un numero crescente a ogni acquisizione, controllato dalla risorsa protetta. Consultata il 2026-11-12.
  • Redis, SET - le opzioni NX e PX e l'avviso che SETNX e simili potrebbero essere deprecati. Letta l'8 novembre 2026 per l'articolo su Lua, non riletta oggi.
  • Commit 67a92d3f33 del repository, letto il 12 novembre 2026 con git show: IdempotencyService.java (prima e dopo), LockHandle.java, DatasetQueryService.java, IdempotencyServiceTest.java e IdempotencyServiceRedisIT.java.
  • Misure proprie: i comandi dei blocchi, eseguiti il 12 novembre 2026 su Redis 8.0.5 nativo e su Redis Stack 7.4.7 in contenitore; nessuna chiave dell'istanza di sviluppo è stata toccata.

Le prime due voci sostengono le affermazioni teoriche (che cosa è un fencing token, perché un client lento può rimuovere il lock di un altro) e conviene leggerle intere, perché una frase non basta. La terza serve solo per i comandi di SET. Le ultime due si rifanno: il commit con git show, i comandi su una tua istanza. Dove la pagina di Redis e il comportamento della tua versione divergono, vale il comando che hai eseguito.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti