In short. Two Redis commands run one after the other are not atomic: between the first and the second anything can happen, even a TTL expiring and another client taking your place. EVAL solves the problem by running a Lua script inside Redis: the documentation guarantees that during execution all server activity is blocked, so the script's effects either have yet to happen or have already happened, never halfway. The case I show you, and which this site's backend really uses since commit 67a92d3f33 of 8 November 2026, is compare-and-delete: "delete this key only if it still contains my value". With GET followed by DEL I reproduced the deletion of someone else's lock (DEL returns 1 and the key disappears); with the script the same sequence returns 0 and the other client's lock stays. The price is real and I measured it: a script that doesn't finish blocks Redis for everyone, until BUSY kicks in and someone runs SCRIPT KILL. The outputs are taken on Redis 8.0.5 and 7.4.7.
Method. Every command was run on 8 November 2026 on throwaway instances (port 6391 with 8.0.5, port 6398 with 7.4.7 in a container). Except where I write otherwise, the two versions gave the same output. The Java code is read from the repository, not rewritten.
Revision of 13 November 2026. In this revision I did not start any server: the outputs of the blocks are those of 8 November and I did not reproduce them. One check without a server exists: the SHA1 that the error message attributes to the script return redis.call('incr', KEYS[1]) and the one returned by SCRIPT LOAD for return redis.call('get', KEYS[1]) can be recomputed with sha1 on that text and coincide with the printed values. The documentation pages were reopened on 13 November.
The problem: time passes between two commands
GET, then DEL: the window nobody sees
A client takes a lock with SET ... NX PX (if the key doesn't exist, create it with an expiry) and, when done, releases it. The temptation is to write the release in two steps: I read the value to know whether it is mine, then I delete. I simulate the interleaving by hand, with a 300 ms TTL for client A:
$ redis-cli -p 6391 set blocco A NX PX 300
OK
$ redis-cli -p 6391 get blocco # A checks: it's mine
A
$ sleep 0.4 # A's TTL expires while A is working
$ redis-cli -p 6391 set blocco B NX PX 30000 # B takes the lock
OK
$ redis-cli -p 6391 del blocco # A runs its DEL
1
$ redis-cli -p 6391 exists blocco
0
A's GET was correct at the moment it was run. It is the DEL that arrives too late and deletes B's lock: from that moment a third client C can get in, and B and C both believe they are the only ones. No command was wrong; what was wrong was the idea that two commands are a single thing.
You have three ways to make an operation that requires a check and an action into a single one, and the rest of the article compares them in the order in which it is worth trying them:
- A single command that already does everything, like
SET ... NX PXto take the lock: it is always the first choice, when it exists. - A transaction with
WATCH, which repeats the attempt if another client has touched the key. - A Lua script, which moves the check inside Redis.
The order of the list is by increasing complexity: each step moves more logic into the server and more responsibility onto you. A single command has nothing to get wrong, WATCH forces you to write the retry loop, Lua asks you to maintain code in another language. Go down to the next step only when the previous one can't express the condition you need.
EVAL: the logic where the data lives
KEYS and ARGV: the rule you can't skip
The syntax is EVAL script numkeys key... argument.... Inside the script, the keys are in the KEYS table and the rest in ARGV. The Redis page is clear: for scripts to work both on a single instance and in a cluster, all the key names the script accesses must be passed as key arguments, never built inside the script or derived from the content of the data.
$ redis-cli -p 6391 eval "return {KEYS[1], ARGV[1], ARGV[2]}" 1 chiave uno due
chiave
uno
due
Return types and errors
The value the script returns is converted. A Lua number with decimals becomes a Redis integer, truncated; an error from redis.call interrupts the script and reaches the client with the script line:
$ 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.
The first result is a classic pitfall: 3.99 comes back as 3, not 4. If you need a decimal, return it as a string. The second shows that the message contains the script's SHA1, which you will meet again shortly.
The script that closes the case
I repeat the earlier scenario, with the same expiry time, but with the release done by a script. Client A tries to release when the lock is already B's:
$ 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
A's release returns 0 and B's lock is intact. Only B, which carries its own value, manages to delete it (1). The comparison and the deletion both happen inside Redis, without any other command being able to slip in between: this is what the two separate commands couldn't guarantee.
EVALSHA: don't resend the script every time
SCRIPT LOAD, EVALSHA and the NOSCRIPT error
The script above is about a hundred characters long and you send it at every release. With SCRIPT LOAD the server compiles it and puts it in a cache, returning the SHA1; with EVALSHA you call it back by that value:
$ 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.
The last error is the reason EVALSHA alone is never enough. The documentation says that EVAL scripts are considered part of the client application: they have no name, version or persistence, and after a server restart or a failover to a replica the cache may be empty. The client must be ready to fall back to EVAL.
What Spring does for you
In this site's backend the work is done by Spring Data Redis. Its scripting page explains that the default executor computes the SHA1, tries EVALSHA first and falls back to EVAL if the script isn't in the cache; and it recommends keeping a single DefaultRedisScript instance so as not to recompute the SHA1 at every execution. In the code you will read further down, the script is indeed a static constant.
MULTI/EXEC and WATCH: they work, at a different cost
MULTI doesn't branch
A MULTI/EXEC transaction serializes the commands, but it can't decide on the basis of an intermediate result, because the commands are only queued:
$ printf 'MULTI\nGET k\nDEL k\nEXEC\n' | redis-cli -p 6391
OK
QUEUED
QUEUED
v
1
The GET returns nothing until EXEC arrives; at that point the DEL is already decided and is executed anyway. There is no "if". For this reason the conditional release is not written with MULTI alone.
WATCH: optimistic checking
With WATCH the transaction becomes conditional: if a watched key is modified before EXEC, the whole transaction is cancelled and EXEC returns a null value (so says the transactions page). I reproduced the case with a second client that writes while the first has already done WATCH and 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 # the other client, while the first waits
OK
OK
A
OK
QUEUED
# empty line: EXEC answered nil
$ redis-cli -p 6391 get blocco
B
The transaction was cancelled (the empty line is the nil of EXEC) and B's value remained. So WATCH solves the problem, and for honesty it must be said: I made it work. But it costs two things: one extra network round trip for the GET and a retry loop in the client when EXEC returns null. The script does the work in a single round trip and without a loop; the counterpart is the risks in the next section.
The price of atomicity: a script that doesn't finish
What happens to everyone else
"All server activity is blocked" is not a figure of speech. To see it I launch a script that never terminates, after lowering the wait threshold to 200 ms (busy-reply-threshold, which on my instance was 5000, on both versions):
$ 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
Once the threshold has passed, Redis answers BUSY to any command except SCRIPT KILL and SHUTDOWN NOSAVE. The client that had launched the script received ERR Script killed by user with SCRIPT KILL.... I then put the threshold back to 5000. In your code this means a simple rule: scripts must be short and without open-ended loops, and the heavy logic stays in the application.
There is a limit to that SCRIPT KILL, and the command's page states it: it works as long as the script has not performed any write, because stopping one that has already written would break atomicity. My endless loop wrote nothing, so it let itself be killed. One that has already written cannot be stopped: only SHUTDOWN NOSAVE remains, that is shutting the server down without saving. This second case I report from the documentation and I did not try it.
Redis Functions: scripts with a name
A library instead of a string
Since version 7.0 Functions are an alternative: instead of a text that the application sends every time, the server hosts a named library. The Redis page requires an initial line #!lua name=<library> and at least one registered function; without them, FUNCTION LOAD answers with an error. I loaded the same logic:
$ 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
The behavior is the same (B can't release A's lock, A can) and there is no more NOSCRIPT: the function is part of the database. The Redis page says functions are written to the AOF file and replicated like data, and specifies that if you use Redis as an ephemeral cache, additional mechanisms are needed to keep them. The cost is organizational: the library has to be distributed and versioned together with the deploy, and REPLACE overwrites the existing one.
In this site's code
The script in the repository
The backend has only one place where it uses a script, in the IdempotencyService class. It is the release of a lock that serializes requests per user-dataset pair (DatasetQueryService, with a 30-second expiry). The following text is the constant read from backend/.../application/service/IdempotencyService.java at 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);
It is the same script you ran above. At every acquisition the code writes a random UUID as the value and keeps it in a LockHandle object; the release passes that token as ARGV[1]. Before the fix the value was the fixed string "1" and the release a DEL without a check, so no holder could be told apart from another. The full story of the defect and its limits is the subject of a later article in the series; here what matters is that the script is the tool that closes it.
Compared with the previous code the fix changed four things, all readable in the commit:
- The value written at acquisition: from a fixed string to a UUID for each attempt.
- The type returned by
tryAcquireLock: from boolean to anOptionalofLockHandle, a record with key and token. - The release: from direct
DELtoredis.executewith the script, which receives the token. - The observable behavior: a late release, after the expiry, is recorded in the log ("not released: expired or owned by another holder") and deletes nothing.
The first two changes are the means and the third is the use: the per-holder token and the LockHandle serve to be able to say "this lock is mine", and the script verifies it at release. The fourth is the consequence visible to whoever runs the service, because a late release stops being silent damage and becomes a log line. None of this extends the critical section beyond the expiry: that limit remains, and the article on the lock deals with it.
The tests that pin it down
The commit brings two tests. staleReleaseDoesNotStealLock, in IdempotencyServiceTest, simulates A's TTL expiring, B's acquisition and A's late release; it declares, however, that it replaces the script with a Java equivalent, because it is a test with mocks. The test that exercises the real Lua, IdempotencyServiceRedisIT, runs against a real Redis image via Testcontainers and requires Docker. I ran the IdempotencyServiceTest class with mvn -o test -Dtest=IdempotencyServiceTest (JDK 21): 16 tests run, 0 failed, 0 errors (5 + 2 + 4 + 1 + 4 in the five inner classes). I did not run IdempotencyServiceRedisIT, which requires Docker and a redis:7-alpine image: for that test I rely on the commit, not on a proof of my own. The red test on the previous code, which the commit message declares, I did not reproduce; the reproduction I showed you at the start was done by hand with redis-cli.
Five rules before writing a script
- Pass every key in
KEYS: if you build it inside the script, in a cluster it stops working. - Parameterize with
ARGVinstead of generating variants of the text: the Redis page calls it an anti-pattern because of the script cache. - No loop of unknown duration: you already know what happens,
BUSY. - Handle
NOSCRIPT(or use a client that does, like Spring). - Test the script against a real Redis, not against a mock: a Java double tells you your idea is consistent, not that the Lua is correct.
The five rules split into two groups. The first three concern how the script is written and are checked by reading it: key names, parameters, absence of loops. The last two concern how you run it, the NOSCRIPT case and the test against a real Redis, and are checked only by executing it. A code review without execution finds the first and lets the second through.
The trade-off, and when not to use Lua
I chose the script for the lock release because the release is a "check and act" on a single key, short, without loops, and because the client stays in a single network round trip. I discarded WATCH because it forced a retry loop for an operation that the code wanted to attempt once. I didn't choose Functions because the site has a single script and a library to distribute at every deploy would cost more than the advantage; I would change my mind with ten or so scripts shared among services.
- Don't use Lua if the logic has loops over data of unknown size: it blocks Redis for everyone, as you saw with
BUSY. - Don't use Lua for domain logic: a Lua script can't be tested with your language's tools, and you see it in the test above, which for the real case needs a real Redis.
- Don't use Lua if you access keys whose name is computed inside the script: the documentation forbids it, and in a cluster it breaks.
- Use it for short sequences of "read, decide, write" on a few declared keys.
The list is asymmetric on purpose, with three prohibitions and a single permission. The permission is narrow: few declared keys and a short sequence of "read, decide, write". Everything outside that perimeter is a case where the cost, the server blocked for everyone and code that can't be tested with your language's tools, exceeds the advantage of atomicity.
What you can do now
You can recognize the moment when two commands should be one, and you can write compare-and-delete in Lua and prove with a command that it doesn't delete someone else's lock. You know why NOSCRIPT must be handled and how Spring does it, and you know the cost no tutorial makes you feel: a long script stops everyone else. For the opposite side, where the data is chosen and sized before writing on it, there is the article on data structures and their costs; for messaging between services, Pub/Sub and Streams. If the operation you want to protect is a request repeated by the client, the underlying idea is the same as in the article on consumer idempotency. The lock with the owner in its value, the case where this script is really needed, is developed in Redis locks: the DEL with no owner; the next step in the series, what survives a restart, is in the article on RDB, AOF and restart.
Do you have a Redis operation you suspect isn't atomic? Book a call →
Frequently asked questions
Does EVAL really make a sequence of Redis commands atomic?
Yes: the documentation guarantees atomic execution and says that all server activity is blocked while the script runs. The price is that a slow or endless script blocks Redis for everyone: in a test, after the threshold, every command answered BUSY until SCRIPT KILL was run.
Why does EVALSHA return NOSCRIPT?
Because the script is not in the server's cache, for example after SCRIPT FLUSH, a restart or a failover. The client must fall back to EVAL: that is what the default executor of Spring Data Redis does, trying EVALSHA first.
Is GET followed by DEL enough to release a Redis lock?
No. If the TTL expires between the two commands and another client acquires the lock, the DEL deletes the other client's lock. It is solved with a Lua script that compares the value and deletes in the same step, or with WATCH and a retry loop.
Sources
- Redis, Scripting with Lua - atomicity, server blocking, KEYS and ARGV, rule on key names, non-persistent nature of scripts, consulted on 2026-11-13.
- Redis, Redis functions - the
#!lua name=line, the requirement of a registered function, persistence on AOF and replication, consulted on 2026-11-13. - Redis, Transactions - WATCH as optimistic checking, null EXEC if a key changes, consulted on 2026-11-13.
- Redis, SET - the options of
SETcan replaceSETNXand similar, which may be deprecated, consulted on 2026-11-08. - Spring Data Redis, Scripting - EVALSHA with fallback to EVAL and a single
DefaultRedisScriptinstance, consulted on 2026-11-08. - Redis, SCRIPT KILL - the script stops only if it has not written yet, otherwise
SHUTDOWN NOSAVEremains, consulted on 2026-11-13. - Measurements and code: the commands in the blocks above, run on 8 November 2026 on Redis 8.0.5 (native) and 7.4.7 (
redis/redis-stack-server:latest);IdempotencyService.javaandIdempotencyServiceTest.javaat commit67a92d3f33.
The sources are official pages with the consultation date, because the guarantees on scripts and the options of SET can change with versions. The last item is not documentation but the way to redo the measurements on your instance and, for the Java part, on the repository code: where a page and an executed command say different things, the command on your version prevails.

Commenti
Caricamento commenti...
Sign in to comment