Skip to main content

Redis locks: the DEL with no owner and how to fix it

An unconditional DEL deletes the lock of whoever took over after the expiry. The flaw reproduced with redis-cli, the fix with a token and Lua, and its limit.

What do you think about this article?

Redis locks: the DEL with no owner and how to fix it
Redisfedericocalo.dev

At a Glance

An unconditional DEL deletes the lock of whoever took over after the expiry. The flaw reproduced with redis-cli, the fix with a token and Lua, and its limit.

In short. SET key value NX PX 30000 is a lock that expires: it takes the key if it is not there and frees it by itself after 30 seconds. That is enough as long as the critical section lasts less than the TTL. The flaw is in the release: if whoever holds the lock frees it with an unconditional DEL, and in the meantime the TTL has expired and another process has taken the lock, that DEL deletes the other one's lock, and a third process can get in. In my backend the flaw was real and I fixed it on 2026-11-08 (commit 67a92d3f33): every acquisition writes a UUID, and the release is a Lua script that deletes only if the value is its own. Reproduced with redis-cli, the DEL returns 1 and B's lock disappears; with the script it returns 0 and B keeps it. But the fix has a limit that must be stated right away: it prevents deleting somebody else's lock, it does not prevent two holders from working together after the expiry.

Method. The story is about the flaw already fixed: I read the code from the commit, not from the working tree. I ran the commands on 12 November 2026 on Redis 8.0.5 (native, port 6392) and on 7.4.7 (redis/redis-stack-server:latest), with identical results. I did not run the commit's Java tests: I report their content and what the commit message declares, without confirming it. I do not know how severe the flaw was in production, and I explain why further down.

Before writing I checked that the fix was really in the history and not just in my working tree. The commit is there, it is the last one touching the file, and no file of the service or of its tests shows up as modified or untracked:

$ 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

The second number matters: a zero means that what I read with git show is also what is on disk. Had I found modified files, I would have had to wait, because an article that tells of a "fixed" flaw cannot lean on a fix that is still being worked on.

What an expiring lock promises

An acquisition that leaves no eternal keys

Taking a lock on Redis means creating a key only if it does not exist, and giving it an expiry right away. The command that does both at once is SET with NX and PX: if the key is there it answers with a null value, otherwise it creates it and sets its expiry in one go. Doing it with two commands (SETNX then EXPIRE) leaves a window in which, if the process dies between the two, the lock stays forever; and it is one of the reasons why the SET page flags SETNX as a candidate for deprecation. The backend's acquisition, even before the fix, used the single command, and indeed the commit says so: the flaw was only the value.

What the TTL is for

The expiry is a safety valve: if whoever holds the lock crashes, after 30 seconds the key disappears and the others can try again. The price is that the lock is no longer guaranteed beyond that time. The Redis documentation on distributed locks says it at the point that matters here: a client can acquire the lock, stay blocked in an operation for longer than the lock's validity time, and then remove a lock that in the meantime had been taken by another. This is why a Redis lock is advisory: it tells you "it looks free", not "it is yours as long as you work".

The flaw, reproduced

Why does a DEL with no owner delete somebody else's lock?

The old code always wrote the value "1" and released with a DEL. The lines are those of the previous commit (git show 67a92d3f33^:...): at line 184 .setIfAbsent(LOCK_PREFIX + key, "1", ttl), at line 199 redis.delete(LOCK_PREFIX + key). You can find them again with one command:

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

The same value for everybody makes the holders indistinguishable. This is the sequence that leads to the failure, with a TTL of one second so as not to wait, three processes and no refinement:

$ 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

The first line is A taking the lock with a TTL of one second. After 1.2 seconds the key has expired and B takes it (the second OK): so far the lock has done its job, because A is slow and B took over. Then A finishes and releases with DEL, which answers 1: it deleted a key, but it was B's. EXISTS gives 0, and C takes the lock (the last OK) while B is still working. Two processes both believe they hold the lock.

Where it bites in my backend

The only caller is DatasetQueryService, which takes the lock for the user-dataset pair before consuming credits and running the query. The TTL is a constant, LOCK_TTL = Duration.ofSeconds(30), at line 85, and the release happens in the finally. Here is the point of use after the fix, taken from the commit's diff:

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 and credit consumption ...
} finally {
    idempotency.releaseLock(lock);
}

For the flaw to show up you need a query slower than 30 seconds, and during that slowness a second request for the same pair must ask for the lock. How often that happens I do not know: the code computes durationMs and writes it to the log, but I did not read the runtime logs, so I do not say that it is rare or that it is frequent, nor that it ever charged twice.

The fix, line by line

What changed in the code

The LockHandle is the proof of ownership, and it is one line of code:

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

The commit changes three things in the service. The acquisition writes a random identifier for every attempt and returns it inside a LockHandle, a record with the key and the token. The release receives the LockHandle, no longer the string. And the release runs a script. This is the excerpt, from the repository, not run by 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());
    }
}

In the script, KEYS[1] is the lock key and ARGV[1] is the caller's token: the comparison and the deletion happen inside Redis, in a single step, with no way for another client to slip in between the GET and the DEL. With two commands issued separately by the client, the window between them would be exactly the earlier scenario; that is why the script is the right form, and I showed the details in the article on Lua, EVAL and EVALSHA. The deleted == 0L branch is new: a late release is no longer silent damage but a log line. (In the excerpt I removed the try around the release for brevity: it is in the file.)

The same scenario, after

I repeat the sequence with tokens. Here the OK lines mean the same as before; the empty line after tokC is the null reply of redis-cli when it is not attached to a terminal:

$ 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

A's late release returns 0 and the key still contains tokB: B's lock is in its place, and EXISTS confirms it with 1. C, which tries to get in while B works, receives the null value: refused. Only when B releases with its own token is the reply 1 and does EXISTS give 0. The figures are the same on 7.4.7 and on 8.0.5; the only line that differs in my automatic comparison is the version one.

The test that pins it down

The commit adds a test that rebuilds the same sequence with a stand-in for StringRedisTemplate: an in-memory map replaces Redis, and a when(...) reproduces the script ("delete only if the value is ARGV[1]"). This is the excerpt, from the repository, not run by 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();

The test is called staleReleaseDoesNotStealLock, and the commit message states that it fails on the previous code and passes on the new one, with 16 tests on the class and zero failures. I did not rerun it, and I did not reproduce the red. There is, however, a limit of the test that the code itself declares: the stand-in replaces the script with a Java function, so the test proves the release's logic, not the Lua. The Lua is proved by a second test, IdempotencyServiceRedisIT, which runs against a real Redis and requires Docker; I did not run that one either, but the manual reproduction above performs the same check on the same script text.

What the fix does not solve

Is compare-and-delete enough to guarantee mutual exclusion?

Compare-and-delete prevents deleting somebody else's lock. It does not prevent A, still at work after the 30 seconds, and B, who took over as soon as the TTL expired, from both being inside the critical section: in the first sequence, the moment the second OK appears is already that one. To enforce mutual exclusion beyond the expiry you would need a fencing token, an increasing number that the lock returns at every acquisition and that the protected resource checks on every write, rejecting those from an expired holder. Martin Kleppmann describes it in the article on distributed locks, and the same Redis page recommends implementing them when consistency and correctness matter. Here they are not there.

With Redis down the lock does not exist

The second limit is in the code above: if Redis does not answer, tryAcquireLock logs a warning and returns a LockHandle anyway, with the wording "degrading to lock-free". It is a choice, not an oversight: the service stays available and mutual exclusion vanishes. Which of the two weighs more depends on the cost of a double execution, and it is the subject of the article on fail-open and fail-closed. In this article it is enough to know that the lock I am talking about holds only if Redis answers.

Another novelty, and why I do not use it

The Redis locks page reports that from Redis 8.4 there is a command DELEX key IFEQ value that does compare-and-delete without a script. On my Redis 8.0.5 it is not there:

$ 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'

The error is what you would expect, and it confirms that the page speaks of a version newer than mine. If your Redis is 8.4 or later the command simplifies the code; if it is not, or if you do not yet know which version you run in production (I do not), the script remains the portable form. Before choosing, check with INFO server.

The site's other two idioms, to tell them apart

A window, not a lock

In the backend there are two uses of Redis with an expiry that look like a lock and are not. The first is RedisHeartbeatDedupGate: it writes a key with setIfAbsent and a window of 20 seconds (DEDUP_WINDOW), and the first to arrive wins; nobody releases it, it expires on its own. The second is BookingTokenService.validateAndBurn, which uses getAndDelete to consume a token only once. I reproduced them with EX 2 so as not to wait:

$ 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

In the first case, the second attempt inside the window gets the null value and, after two seconds, the key opens again: it is a deduplication, its purpose is to discard nearby duplicates, and the fixed value is fine because nobody has to tell the holders apart. In the second, GETDEL returns the value to the first to arrive and the null value to whoever comes after: the read and the deletion are a single command, and that is why a single-use token does not have the lock's problem. The flaw concerned only the case in which whoever releases must prove to be the holder.

The trade-off: what I would do

For an advisory lock with a critical section that almost always stays under the TTL, I would use what is there now: SET NX PX with a token and a conditional release. It is simple, it reads well, and it fails in a known way. I discarded Redlock because it requires several independent Redis instances, which the site does not have; I discarded the database mechanisms (a row lock, an advisory lock) because they change the cost of every request and do not solve fencing on their own. If the critical section can exceed the TTL, and the damage is a double charge, the fencing token is what is missing, not a more elaborate lock.

  • Write a unique value per holder (a UUID) and always release with a comparison: a plain DEL is right only for keys that nobody else can have taken over.
  • Take the lock with a single command (SET ... NX PX), never with SETNX followed by EXPIRE.
  • Choose the TTL on the worst duration, not on the average: the average is the one that does not break the lock.
  • Decide what the lock does when Redis is down, and write it in the code: "lock-free" is a choice, not a default.
  • If a double entry costs money, add a fencing token on the resource: the lock alone is not enough.

The first two rules are the ones the commit put right, and they cost a few lines. The third and the fourth are design decisions to be taken once and documented. The last is the only one that requires changing the protected resource, and for that reason it is the one usually postponed; it is worth doing only after measuring how often a critical section exceeds the TTL, which in my case is the missing figure.

What you can do now

You can reproduce with a few commands the ownerless-release flaw and recognise it in a lock you see in a backend: a value that is the same for everybody and a final DEL. You can write the fix, a token per holder and a script that compares and deletes, and you can say precisely what it does not solve: two holders together after the expiry and the behaviour with Redis down. You can tell a lock from a deduplication window and from a single-use token, and you know that from version 8.4 there is a command to do compare-and-delete without a script.

For the script in detail, including EVALSHA and NOSCRIPT, there is the article on Lua in Redis; for the prefix that separates a cache's keys from a lock's, Spring Cache on Redis. On the idempotency of the operations the lock protects there is the article on deduplication and idempotency keys, and on the next step, what happens if Redis does not answer, fail-open and fail-closed.

Have a Redis lock and not sure it survives a slow section? Book a call →

Sources

  • Redis, Distributed Locks with Redis - the compare-and-delete script, the sentence on the risk of removing a lock created by another client, the DELEX ... IFEQ command from Redis 8.4, the warning on non-monotonic clocks and the advice to implement fencing tokens. Consulted on 2026-11-12.
  • Martin Kleppmann, How to do distributed locking - the explanation of the fencing token: an increasing number at every acquisition, checked by the protected resource. Consulted on 2026-11-12.
  • Redis, SET - the NX and PX options and the warning that SETNX and similar may be deprecated. Read on 8 November 2026 for the article on Lua, not reread today.
  • Commit 67a92d3f33 of the repository, read on 12 November 2026 with git show: IdempotencyService.java (before and after), LockHandle.java, DatasetQueryService.java, IdempotencyServiceTest.java and IdempotencyServiceRedisIT.java.
  • Own measurements: the commands in the blocks, run on 12 November 2026 on native Redis 8.0.5 and on Redis Stack 7.4.7 in a container; no key of the development instance was touched.

The first two entries support the theoretical statements (what a fencing token is, why a slow client can remove another's lock) and are worth reading in full, because a single sentence is not enough. The third is only for the SET commands. The last two can be redone: the commit with git show, the commands on an instance of your own. Where the Redis page and the behaviour of your version diverge, the command you ran prevails.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects