Salta al contenuto principale

Lua in Redis: EVAL, EVALSHA e il confronto-e-cancella

GET poi DEL cancella il lock di un altro; uno script Lua no. EVAL, EVALSHA, WATCH e il costo di BUSY, misurati su Redis 8.0.5 e 7.4.7.

Cosa ne pensi di questo articolo?

In Breve

GET poi DEL cancella il lock di un altro; uno script Lua no. EVAL, EVALSHA, WATCH e il costo di BUSY, misurati su Redis 8.0.5 e 7.4.7.

In breve. Due comandi Redis eseguiti uno dopo l'altro non sono atomici: fra il primo e il secondo può succedere di tutto, anche che scada un TTL e che un altro client prenda il posto del tuo. EVAL risolve il problema eseguendo uno script Lua dentro Redis: la documentazione garantisce che durante l'esecuzione tutta l'attività del server è bloccata, quindi gli effetti dello script o devono ancora accadere, o sono già accaduti, mai a metà. Il caso che ti mostro, e che il backend di questo sito usa davvero dal commit 67a92d3f33 dell'8 novembre 2026, è il confronto-e-cancella: «cancella questa chiave solo se contiene ancora il mio valore». Con GET seguito da DEL ho riprodotto la cancellazione del lock di un altro (DEL restituisce 1 e la chiave sparisce); con lo script la stessa sequenza restituisce 0 e il lock dell'altro resta. Il prezzo è reale e l'ho misurato: uno script che non finisce blocca Redis per tutti, finché non scatta BUSY e qualcuno lancia SCRIPT KILL. Le uscite sono prese su Redis 8.0.5 e 7.4.7.

Metodo. Ogni comando è stato eseguito l'8 novembre 2026 su istanze usa-e-getta (porta 6391 con 8.0.5, porta 6398 con 7.4.7 in contenitore). Salvo dove scrivo il contrario, le due versioni hanno dato la stessa uscita. Il codice Java è letto dal repository, non riscritto.

Revisione del 13 novembre 2026. In questa revisione non ho avviato nessun server: le uscite dei blocchi sono quelle dell'8 novembre e non le ho riprodotte. Un riscontro senza server c'è: lo SHA1 che il messaggio di errore attribuisce allo script return redis.call('incr', KEYS[1]) e quello restituito da SCRIPT LOAD per return redis.call('get', KEYS[1]) si ricalcolano con sha1 su quel testo e coincidono con i valori stampati. Le pagine della documentazione sono state riaperte il 13 novembre.

Il problema: fra due comandi passa il tempo

GET, poi DEL: la finestra che nessuno vede

Un client prende un lock con SET ... NX PX (se la chiave non esiste, creala con una scadenza) e, quando ha finito, lo rilascia. La tentazione è scrivere il rilascio in due passi: leggo il valore per sapere se è mio, poi cancello. Simulo l'interleaving a mano, con TTL di 300 ms per il client A:

$ redis-cli -p 6391 set blocco A NX PX 300
OK
$ redis-cli -p 6391 get blocco                   # A controlla: è mio
A
$ sleep 0.4                                      # il TTL di A scade mentre A lavora
$ redis-cli -p 6391 set blocco B NX PX 30000     # B prende il lock
OK
$ redis-cli -p 6391 del blocco                   # A esegue il suo DEL
1
$ redis-cli -p 6391 exists blocco
0

Il GET di A era corretto nel momento in cui è stato eseguito. È il DEL che arriva troppo tardi e cancella il lock di B: da quel momento un terzo client C può entrare, e B e C credono entrambi di essere i soli. Nessun comando ha sbagliato; ha sbagliato l'idea che due comandi siano una cosa sola.

Hai tre modi per rendere unica un'operazione che richiede un controllo e un'azione, e il resto dell'articolo li confronta nell'ordine in cui conviene provarli:

  • Un solo comando che fa già tutto, come SET ... NX PX per prendere il lock: è sempre la prima scelta, quando esiste.
  • Una transazione con WATCH, che ripete il tentativo se un altro client ha toccato la chiave.
  • Uno script Lua, che sposta il controllo dentro Redis.

L'ordine dell'elenco è per complessità crescente: ogni scalino sposta più logica dentro il server e più responsabilità su di te. Un comando unico non ha niente da sbagliare, WATCH ti obbliga a scrivere il ciclo di ripetizione, Lua ti chiede di mantenere codice in un altro linguaggio. Scendi al gradino successivo solo quando quello precedente non riesce a esprimere la condizione che ti serve.

EVAL: la logica dove stanno i dati

KEYS e ARGV: la regola che non puoi saltare

La sintassi è EVAL script numchiavi chiave... argomento.... Dentro lo script, le chiavi sono nella tabella KEYS e il resto in ARGV. La pagina di Redis è netta: per far funzionare gli script sia da istanza singola sia in cluster, tutti i nomi di chiave a cui lo script accede devono essere passati come argomenti di chiave, mai costruiti dentro lo script o ricavati dal contenuto dei dati.

$ redis-cli -p 6391 eval "return {KEYS[1], ARGV[1], ARGV[2]}" 1 chiave uno due
chiave
uno
due

Tipi di ritorno ed errori

Il valore che lo script restituisce viene convertito. Un numero Lua con decimali diventa un intero Redis, troncato; un errore di redis.call interrompe lo script e arriva al client con la riga dello script:

$ redis-cli -p 6391 eval "return 3.99" 0
3
$ redis-cli -p 6391 set s abc
OK
$ redis-cli -p 6391 eval "return redis.call('incr', KEYS[1])" 1 s
ERR value is not an integer or out of range script: 2bab3b661081db58bd2341920e0ba7cf5dc77b25, on @user_script:1.

Il primo risultato è un trabocchetto classico: 3.99 torna 3, non 4. Se ti serve un decimale, restituiscilo come stringa. Il secondo mostra che il messaggio contiene lo SHA1 dello script, che ritroverai fra poco.

Lo script che chiude il caso

Ripeto lo scenario di prima, con lo stesso tempo di scadenza, ma con il rilascio fatto da uno script. Il client A prova a rilasciare quando il lock è già di B:

$ redis-cli -p 6391 set blocco A NX PX 300
OK
$ sleep 0.4
$ redis-cli -p 6391 set blocco B NX PX 30000
OK
$ redis-cli -p 6391 eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 blocco A
0
$ redis-cli -p 6391 get blocco
B
$ redis-cli -p 6391 eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 blocco B
1
$ redis-cli -p 6391 exists blocco
0

Il rilascio di A restituisce 0 e il lock di B è intatto. Solo B, che porta il proprio valore, riesce a cancellarlo (1). Il confronto e la cancellazione avvengono entrambi dentro Redis, senza che nessun altro comando possa inserirsi in mezzo: è questo che i due comandi separati non potevano garantire.

EVALSHA: non rispedire lo script ogni volta

SCRIPT LOAD, EVALSHA e l'errore NOSCRIPT

Lo script sopra è lungo un centinaio di caratteri e lo mandi a ogni rilascio. Con SCRIPT LOAD il server lo compila e lo mette in una cache, restituendo lo SHA1; con EVALSHA lo richiami per quel valore:

$ redis-cli -p 6391 script load "return redis.call('get', KEYS[1])"
4e6d8fc8bb01276962cce5371fa795a7763657ae
$ redis-cli -p 6391 set k v
OK
$ redis-cli -p 6391 evalsha 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 k
v
$ redis-cli -p 6391 script flush
OK
$ redis-cli -p 6391 evalsha 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 k
NOSCRIPT No matching script. Please use EVAL.

L'ultimo errore è il motivo per cui EVALSHA da solo non basta mai. La documentazione dice che gli script EVAL sono considerati parte dell'applicazione client: non hanno nome, versione né persistenza, e dopo un riavvio del server o un failover a una replica la cache può essere vuota. Il client deve essere pronto a ricadere su EVAL.

Cosa fa Spring al posto tuo

Nel backend di questo sito il lavoro lo fa Spring Data Redis. La sua pagina sugli script spiega che l'esecutore predefinito calcola lo SHA1, prova prima EVALSHA e ricade su EVAL se lo script non è nella cache; e consiglia di tenere una sola istanza di DefaultRedisScript per non ricalcolare lo SHA1 a ogni esecuzione. Nel codice che leggerai più sotto lo script è appunto una costante statica.

MULTI/EXEC e WATCH: funzionano, a un costo diverso

MULTI non ramifica

Una transazione MULTI/EXEC serializza i comandi, ma non può decidere sulla base di un risultato intermedio, perché i comandi vengono solo accodati:

$ printf 'MULTI\nGET k\nDEL k\nEXEC\n' | redis-cli -p 6391
OK
QUEUED
QUEUED
v
1

Il GET non restituisce nulla finché non arriva EXEC; a quel punto il DEL è già deciso e viene eseguito comunque. Non c'è un «se». Per questo il rilascio condizionato non si scrive con MULTI da solo.

WATCH: il controllo ottimistico

Con WATCH la transazione diventa condizionata: se una chiave osservata viene modificata prima di EXEC, tutta la transazione si annulla e EXEC restituisce un valore nullo (così dice la pagina sulle transazioni). Ho riprodotto il caso con un secondo client che scrive mentre il primo ha già fatto WATCH e GET:

$ ( printf 'WATCH blocco\nGET blocco\n'; sleep 1; printf 'MULTI\nDEL blocco\nEXEC\n' ) | redis-cli -p 6391 &
$ redis-cli -p 6391 set blocco B                 # l'altro client, mentre il primo aspetta
OK
OK
A
OK
QUEUED
                                                 # riga vuota: EXEC ha risposto nil
$ redis-cli -p 6391 get blocco
B

La transazione si è annullata (la riga vuota è il nil di EXEC) e il valore di B è rimasto. Quindi WATCH risolve il problema, e va detto per onestà: l'ho fatto funzionare. Ma costa due cose: un giro di rete in più per il GET e un ciclo di ripetizione nel client quando EXEC torna nullo. Lo script fa il lavoro in un giro solo e senza ciclo; la contropartita sono i rischi della sezione che segue.

Il prezzo dell'atomicità: uno script che non finisce

Cosa succede a tutti gli altri

«Tutta l'attività del server è bloccata» non è un modo di dire. Per vederlo lancio uno script che non termina mai, dopo aver abbassato a 200 ms la soglia di attesa (busy-reply-threshold, che sulla mia istanza valeva 5000, su entrambe le versioni):

$ redis-cli -p 6391 config get busy-reply-threshold
busy-reply-threshold
5000
$ redis-cli -p 6391 config set busy-reply-threshold 200
OK
$ redis-cli -p 6391 eval "while true do end" 0 &
$ redis-cli -p 6391 ping
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
$ redis-cli -p 6391 script kill
OK
$ redis-cli -p 6391 ping
PONG

Passata la soglia, Redis risponde BUSY a qualunque comando tranne SCRIPT KILL e SHUTDOWN NOSAVE. Il client che aveva lanciato lo script ha ricevuto ERR Script killed by user with SCRIPT KILL.... Ho riportato poi la soglia a 5000. Nel tuo codice questo significa una regola semplice: gli script devono essere brevi e senza cicli aperti, e la logica pesante resta nell'applicazione.

C'è un limite a quel SCRIPT KILL, e la pagina del comando lo dice: funziona finché lo script non ha eseguito nessuna scrittura, perché fermarne uno che ha già scritto spezzerebbe l'atomicità. Il mio ciclo infinito non scriveva nulla, quindi si è lasciato uccidere. Uno che ha già scritto non si ferma: resta solo SHUTDOWN NOSAVE, cioè spegnere il server senza salvare. Questo secondo caso lo riporto dalla documentazione e non l'ho provato.

Redis Functions: gli script con un nome

Una libreria al posto di una stringa

Dalla versione 7.0 le Functions sono un'alternativa: invece di un testo che l'applicazione manda ogni volta, il server ospita una libreria con nome. La pagina di Redis richiede una riga iniziale #!lua name=<libreria> e almeno una funzione registrata; senza, FUNCTION LOAD risponde con un errore. Ho caricato la stessa logica:

$ cat rilascia.lua
#!lua name=blocchi
redis.register_function('rilascia', function(keys, args)
  if redis.call('get', keys[1]) == args[1] then
    return redis.call('del', keys[1])
  end
  return 0
end)
$ cat rilascia.lua | redis-cli -p 6391 -x function load replace
blocchi
$ redis-cli -p 6391 set blocco A
OK
$ redis-cli -p 6391 fcall rilascia 1 blocco B
0
$ redis-cli -p 6391 fcall rilascia 1 blocco A
1

Il comportamento è lo stesso (B non può rilasciare il lock di A, A sì) e non c'è più NOSCRIPT: la funzione fa parte del database. La pagina di Redis dice che le funzioni sono scritte nel file AOF e replicate come i dati, e precisa che se usi Redis come cache effimera servono meccanismi aggiuntivi per tenerle. Il costo è organizzativo: la libreria va distribuita e versionata insieme al deploy, e REPLACE sovrascrive quella esistente.

Nel codice di questo sito

Lo script nel repository

Il backend ha un solo posto in cui usa uno script, nella classe IdempotencyService. È il rilascio di un lock che serializza le richieste per coppia utente-dataset (DatasetQueryService, con una scadenza di 30 secondi). Il testo seguente è la costante letta da backend/.../application/service/IdempotencyService.java al commit 67a92d3f33:

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);

È lo stesso script che hai eseguito sopra. A ogni acquisizione il codice scrive come valore un UUID casuale e lo tiene in un oggetto LockHandle; il rilascio passa quel token come ARGV[1]. Prima della correzione il valore era la stringa fissa "1" e il rilascio un DEL senza controllo, quindi nessun titolare era distinguibile dall'altro. Il racconto completo del difetto e dei suoi limiti è argomento di un articolo successivo della serie; qui conta che lo script è lo strumento che lo chiude.

Rispetto al codice precedente la correzione ha cambiato quattro cose, tutte leggibili nel commit:

  • Il valore scritto all'acquisizione: da una stringa fissa a un UUID per ogni tentativo.
  • Il tipo restituito da tryAcquireLock: da booleano a un Optional di LockHandle, un record con chiave e token.
  • Il rilascio: da DEL diretto a redis.execute con lo script, che riceve il token.
  • Il comportamento osservabile: un rilascio tardivo, dopo la scadenza, viene registrato nel log («not released: expired or owned by another holder») e non cancella nulla.

Le prime due modifiche sono i mezzi e la terza è l'uso: il token per titolare e il LockHandle servono a poter dire «questo lock è mio», e lo script lo verifica al rilascio. La quarta è la conseguenza visibile a chi gestisce il servizio, perché un rilascio tardivo smette di essere un danno silenzioso e diventa una riga di log. Nulla di questo allunga la sezione critica oltre la scadenza: quel limite resta, e lo tratta l'articolo sul lock.

I test che lo inchiodano

Il commit porta due test. staleReleaseDoesNotStealLock, in IdempotencyServiceTest, simula la scadenza del TTL di A, l'acquisizione di B e il rilascio tardivo di A; dichiara però di sostituire lo script con un equivalente Java, perché è un test con mock. Il test che esercita il Lua vero, IdempotencyServiceRedisIT, gira contro un'immagine Redis reale via Testcontainers e richiede Docker. Ho eseguito la classe IdempotencyServiceTest con mvn -o test -Dtest=IdempotencyServiceTest (JDK 21): 16 test eseguiti, 0 falliti, 0 errori (5 + 2 + 4 + 1 + 4 nelle cinque classi interne). Non ho eseguito IdempotencyServiceRedisIT, che richiede Docker e un'immagine redis:7-alpine: per quel test mi affido al commit, non a una prova mia. Il test rosso sul codice precedente, che il messaggio del commit dichiara, non l'ho riprodotto; la riproduzione che ti ho mostrato all'inizio è fatta a mano con redis-cli.

Cinque regole prima di scrivere uno script

  • Passa ogni chiave in KEYS: se la costruisci dentro lo script, in cluster smette di funzionare.
  • Parametrizza con ARGV invece di generare varianti del testo: la pagina di Redis lo chiama anti-pattern per via della cache degli script.
  • Nessun ciclo di durata non nota: sai già cosa succede, BUSY.
  • Gestisci NOSCRIPT (o usa un client che lo fa, come Spring).
  • Prova lo script contro un Redis vero, non contro un mock: un doppio in Java ti dice che la tua idea è coerente, non che il Lua è corretto.

Le cinque regole si dividono in due gruppi. Le prime tre riguardano come è scritto lo script e si controllano leggendolo: nomi di chiave, parametri, assenza di cicli. Le ultime due riguardano come lo fai girare, il caso NOSCRIPT e la prova contro un Redis vero, e si controllano solo eseguendolo. Una revisione di codice senza esecuzione trova le prime e lascia passare le seconde.

Il trade-off, e quando non usare Lua

Ho scelto lo script per il rilascio del lock perché il rilascio è un «controlla e agisci» su una sola chiave, breve, senza cicli, e perché il client resta in un solo giro di rete. Ho scartato WATCH perché obbligava a un ciclo di ripetizione per un'operazione che il codice voleva tentare una volta. Non ho scelto le Functions perché il sito ha un solo script e una libreria da distribuire a ogni deploy costerebbe più del vantaggio; cambierei idea con una decina di script condivisi fra servizi.

  • Non usare Lua se la logica ha cicli su dati di dimensione non nota: blocca Redis per tutti, come hai visto con BUSY.
  • Non usare Lua per la logica di dominio: uno script Lua non si testa con gli strumenti del tuo linguaggio, e lo vedi nel test sopra, che per il caso reale ha bisogno di un Redis vero.
  • Non usare Lua se accedi a chiavi il cui nome è calcolato dentro lo script: la documentazione lo vieta, e in cluster si rompe.
  • Usalo per sequenze brevi di «leggi, decidi, scrivi» su poche chiavi dichiarate.

L'elenco è asimmetrico di proposito, con tre divieti e un solo permesso. Il permesso è stretto: poche chiavi dichiarate e una sequenza breve di «leggi, decidi, scrivi». Tutto ciò che sta fuori da quel perimetro è un caso in cui il costo, il blocco del server per tutti e un codice che non si testa con gli strumenti del tuo linguaggio, supera il vantaggio dell'atomicità.

Cosa sai fare adesso

Sai riconoscere il momento in cui due comandi dovrebbero essere uno, e sai scrivere il confronto-e-cancella in Lua e dimostrare con un comando che non cancella il lock altrui. Sai perché serve gestire NOSCRIPT e come lo fa Spring, e conosci il costo che nessun tutorial ti fa sentire: uno script lungo ferma tutti gli altri. Per la parte opposta, quella in cui i dati vengono scelti e dimensionati prima di scriverci sopra, c'è l'articolo sulle strutture dati e i loro costi; per la messaggistica fra servizi, Pub/Sub e Streams. Se l'operazione che vuoi proteggere è una richiesta ripetuta dal client, l'idea di fondo è la stessa dell'articolo sull'idempotenza dei consumatori. Il lock con il proprietario nel valore, il caso in cui questo script serve davvero, è sviluppato in Lock Redis: il DEL senza proprietario; il passo dopo nella serie, cosa sopravvive a un riavvio, è nell'articolo su RDB, AOF e riavvio.

Hai un'operazione Redis che sospetti non sia atomica? Prenota una call →

Domande frequenti

EVAL rende davvero atomica una sequenza di comandi Redis?

Sì: la documentazione garantisce l'esecuzione atomica e dice che durante lo script tutta l'attività del server è bloccata. Il prezzo è che uno script lento o infinito blocca Redis per tutti: in una prova, dopo la soglia, ogni comando ha risposto BUSY finché non è stato lanciato SCRIPT KILL.

Perché EVALSHA restituisce NOSCRIPT?

Perché lo script non è nella cache del server, per esempio dopo SCRIPT FLUSH, un riavvio o un failover. Il client deve ricadere su EVAL: è quello che fa l'esecutore predefinito di Spring Data Redis, che prova prima EVALSHA.

Per rilasciare un lock Redis basta GET seguito da DEL?

No. Se il TTL scade fra i due comandi e un altro client acquisisce il lock, il DEL cancella il lock dell'altro. Si risolve con uno script Lua che confronta il valore e cancella nello stesso passo, oppure con WATCH e un ciclo di ripetizione.

Fonti

  • Redis, Scripting with Lua - atomicità, blocco del server, KEYS e ARGV, regola sui nomi di chiave, natura non persistente degli script, consultata il 2026-11-13.
  • Redis, Redis functions - riga #!lua name=, requisito di una funzione registrata, persistenza su AOF e replica, consultata il 2026-11-13.
  • Redis, Transactions - WATCH come controllo ottimistico, EXEC nullo se una chiave cambia, consultata il 2026-11-13.
  • Redis, SET - le opzioni di SET possono sostituire SETNX e simili, che potrebbero essere deprecati, consultata il 2026-11-08.
  • Spring Data Redis, Scripting - EVALSHA con ripiego su EVAL e una sola istanza di DefaultRedisScript, consultata il 2026-11-08.
  • Redis, SCRIPT KILL - lo script si ferma solo se non ha ancora scritto, altrimenti resta SHUTDOWN NOSAVE, consultata il 2026-11-13.
  • Misure e codice: i comandi dei blocchi sopra, eseguiti l'8 novembre 2026 su Redis 8.0.5 (nativo) e 7.4.7 (redis/redis-stack-server:latest); IdempotencyService.java e IdempotencyServiceTest.java al commit 67a92d3f33.

Le fonti sono pagine ufficiali con la data di consultazione, perché le garanzie sugli script e le opzioni di SET possono cambiare con le versioni. L'ultima voce non è documentazione ma il modo di rifare le misure sulla tua istanza e, per la parte Java, sul codice del repository: dove una pagina e un comando eseguito dicono cose diverse, vale il comando sulla tua versione.

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