Sertaç Yıldırımfield notes

Home → Engineering

Distributed Locks: Getting It Right

The invoicing job runs at 03:00. In the morning it turns out it issued the same invoice twice. The code exists in one place. The reason is simple: that night, two copies of the application were running.

Summary
  • First ask whether you actually need one. If a single row is being protected, you need an atomic update, not a lock.
  • Acquiring is easy, releasing is hard. The most common bug: deleting somebody else's lock.
  • If it expires you get two owners. Renewal helps but guarantees nothing; if you need a guarantee, use a fencing token.
  • If you run Postgres you probably already have a lock and do not need Redis at all.

Stop: do you really need this?

The distributed lock is a good example of everything looking like a nail once you hold a hammer. In most cases there is a cheaper answer:

SituationLock needed?Use instead
Decrementing stock, updating a balanceNoAtomic UPDATE ... WHERE (details)
Single-use couponNoUnique index
Two people editing the same recordNoVersion column (optimistic lock)
Repeated payment webhookNoIdempotency key
Scheduled job, one copy onlyYesLock or leader election
One-off data migrationYesLock
Reconciliation with an external systemYesLock + fencing token

The rule: if the thing you are protecting is a single database row, you do not need a lock. Locks become meaningful when what you must protect lives outside the database.

Step by step: from wrong to right

Attempt 1 — the innocent version (broken)

Do not do this
if (redis.GET("lock:invoice") == null) {
    redis.SET("lock:invoice", "1");   // ← there is a gap here
    doWork();
    redis.DEL("lock:invoice");
}

The same pattern as the previous post: check, then act. If two copies run GET at the same time, both see null.

Attempt 2 — atomic acquire, no expiry (broken)

Do not do this either
SETNX lock:invoice 1      # atomic, good
# ... work happens ...
DEL lock:invoice

Acquisition is now atomic. But if the process dies mid-work (a deploy, an OOM, a node restart), DEL never runs and the lock stays forever. The next day nobody can issue invoices and it takes hours to find out why.

Attempt 3 — expiry, but in two commands (still broken)

A subtle trap
SETNX lock:invoice 1
EXPIRE lock:invoice 60     # ← second command

If the process dies between the two, you get an eternal lock anyway. Rare, but it happens; and when it does you spend hours looking in the wrong place because "we did set an expiry".

Attempt 4 — correct acquisition

One command: atomic + expiring + owned
token = uuid()                                  # unique to this copy
SET lock:invoice <token> NX PX 30000

# NX  -> write only if absent (atomic)
# PX  -> delete itself after 30 seconds
# token -> so we can say "this lock is mine"

That token is a lifesaver, and the subject of the next section.

The real trap: releasing the lock

Most teams acquire correctly. Almost none release correctly. Look at this sequence:

TimeCopy ACopy BLock
00:00acquired (30s)A
00:05working... (GC pause)A
00:30still pausedexpired
00:31acquiredB
00:33woke up, finished, DELworkingdeleted!
00:34workingC can acquire too

A deleted B's lock. Now B and C run at the same time and nobody knows.

The fix: compare and delete, in one script
-- Redis Lua: runs atomically
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

In the application: EVAL <script> 1 lock:invoice <token>. If the lock is not yours, you do not touch it. You cannot do this with two commands in the application — someone slips between GET and DEL again.

What if it expires?

The real problem in that table is not the deletion: B started while A was still running. Compare-and-delete does not solve that, it only reduces the damage.

Partial fix: renew the lock

Watchdog
-- every 10 seconds, extend only if it is still mine
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end

A background timer calls this. If the process dies, renewal stops and the lock falls away on its own. Good practice — but not a guarantee: during a long pause, the renewal pauses too.

The real fix: fencing tokens

This is the most important and least known part of the topic. The idea: every time the lock is granted, also hand out an increasing number. The protected resource then rejects any write carrying a lower number than it has seen.

How it works
-- on the Redis side: a counter that increments on each acquisition
INCR lock:invoice:counter        -- returns 41 for A, 42 for B

-- send the number along when writing:
UPDATE invoice_state
   SET last_run = 'done', fence = 42
 WHERE id = 7
   AND fence < 42;               -- ← rejects the old owner

-- if A (fence=41) wakes up late and tries to write:
-- the condition fence < 41 does not hold, 0 rows affected, it cannot write.

The beauty is this: whether A wakes up, how late it is, whether the lock expired — none of it matters any more. The protected resource itself rejects the stale owner. Lock safety no longer depends on timing.

There is a cost: the protected resource must be able to perform that check. Easy in a database, usually impossible on a file system or a third-party API. If it is impossible, at least you know you are living with it.

A lock entrusts correctness to time. A fencing token entrusts it to order. In distributed systems time is unreliable; order is not.

About Redlock

There is an algorithm that proposes acquiring a lock across several Redis nodes at once, and a long debate about it. In short: a single Redis node is a single point of failure, but multiple nodes do not give a full guarantee either, because of clock drift and pauses.

My practical position:

  • If you lock for efficiency (let us not do the same work twice, let us not waste resources), a single Redis node is fine. A rare overlap only means waste.
  • If you lock for correctness (if this happens twice we lose money), do not trust the lock alone. Add a fencing token or make the work idempotent.

Saying "we added a lock, we are safe" without making that distinction is the most expensive misconception in this area.

Three simpler options than Redis

1. Postgres advisory locks (free if you already use it)

You already have this
-- held for the session, released AUTOMATICALLY if the connection drops
SELECT pg_try_advisory_lock(834721);
-- true  -> the lock is mine, start work
-- false -> someone else is running, exit

SELECT pg_advisory_unlock(834721);   -- when done

The biggest advantage: no expiry needed. If the process dies the connection drops and the lock is released immediately. The entire "what if it expires" problem of a Redis lock disappears. The one thing to watch: with a connection pool, make sure the same connection acquires and releases.

2. A lock table (works on any database)

Simple and readable
CREATE TABLE locks (
  name       text PRIMARY KEY,
  owner      text NOT NULL,
  expires_at timestamptz NOT NULL
);

-- acquire: take over if expired
INSERT INTO locks (name, owner, expires_at)
VALUES ('invoice', :me, now() + interval '30 seconds')
ON CONFLICT (name) DO UPDATE
   SET owner = :me, expires_at = now() + interval '30 seconds'
 WHERE locks.expires_at < now()
RETURNING owner;

-- if the returned owner is me, the lock is mine

Slower than Redis but visible: who holds the lock and until when is one SELECT away. That diagnostic ease is usually worth the speed.

3. No lock at all: a single-consumer queue

Most "only one at a time" needs are really ordering needs. Put the work in a queue and have a single consumer process it; no lock required. If you run on Kubernetes, lease-based leader election does the same job for scheduled work without you writing it.

From the field: what we did with the invoicing job

Back to the opening story. The first instinct was to add a Redis lock. We did, and the problem disappeared for a while. Then one day the job ran longer than expected, the lock expired, and the same bug came back — less frequently this time, which made it harder to notice.

The permanent fix had two parts, and neither was about locking:

  1. We made invoicing idempotent. A unique constraint on (period, customer_id). A second attempt is rejected by the database; duplicate invoices are impossible with or without a lock.
  2. We kept the lock for efficiency. We moved to an advisory lock; its purpose is not correctness but stopping two copies from doing the same work for nothing.

The lesson stayed with me: a lock does not make a broken design safe. The work itself has to be repeatable; the lock only reduces waste.

Checklist AI agent task list

Before writing a lock
  • Does this really want a lock, or is an atomic UPDATE enough?
  • Is the lock for correctness or efficiency? (Write the answer down.)
  • Is acquisition a single command? (SET ... NX PX)
  • Does the lock carry an ownership value?
  • Is release a compare-and-delete? (A plain DEL is wrong.)
  • Is the expiry longer than the worst-case duration of the work?
  • What happens if it expires? If the answer is "two owners", you need a fencing token.
  • Is the work itself idempotent? (If so, the lock carries less weight.)
  • Do you have Postgres? Did you try pg_try_advisory_lock?

Conclusion

The distributed lock is one of the easiest things to get wrong in distributed systems, precisely because the wrong version usually works. The bug only appears when load, latency and luck line up — that is, at the worst moment.

The order to remember: first design so you do not need a lock; if you cannot, use the database's lock; if you cannot do that either, write a Redis lock — but entrust correctness to a fencing token or idempotency, not to the lock.