Efficiency locks vs correctness locks: what a distributed lock is for

A distributed lock is a coordination primitive that lets processes on different machines agree that only one of them should act on a shared resource at a time. In a microservices system that resource might be a scheduled job, a third-party API with strict rate limits, a cache rebuild, or a row in a ledger. The lock lives outside any single service, in a store such as Redis or PostgreSQL, because no in-process mutex can see across hosts.

Two different reasons to lock

Efficiency locks prevent duplicate work. If two replicas both regenerate the same report or both send the same nightly digest, you waste compute or annoy a user, but nothing breaks permanently. An occasional double run is acceptable, so a lock that is usually right is good enough.

Correctness locks protect invariants. If two holders both debit an account, allocate the same inventory unit, or write conflicting state to object storage, the data is corrupted and may not be recoverable. Here "usually right" is a bug report waiting to happen.

The thesis

Neither Redlock nor PostgreSQL advisory locks guarantees mutual exclusion when a holder pauses (for garbage collection, VM migration, or swapping) or when the network partitions. A paused client can wake up still believing it holds a lock that has already passed to someone else. The real choice is about which failure modes you can tolerate and whether the protected resource can reject stale writers through fencing tokens.

Teams using locks only for efficiency can choose on operational convenience. Teams using locks for correctness need to read the rest of this article closely.

How Redis locks and Redlock work

Single instance

A client acquires the lock with SET key <random-value> NX PX <ms>. NX makes the write succeed only if the key does not exist, and PX attaches an expiry so a crashed holder cannot block others forever. The random value identifies this particular acquisition, not the client process.

Release must check that value. A plain DEL is unsafe: client A acquires the lock, stalls past the TTL, and the key expires. Client B acquires it. When A resumes and calls DEL, it removes B's lock, and a third client can now enter alongside B. The fix is a Lua script that runs GET and, if the stored value matches the caller's token, DEL. Redis executes the script atomically, so no other command can run between the comparison and the delete.

Redlock

Redlock runs the same operation against N independent masters, commonly five, with no replication between them. The client records the start time, tries each node with a short per-node timeout, and holds the lock only if a majority (N/2 + 1) accepted it. Its remaining validity is the TTL minus the elapsed time minus an allowance for clock drift. If acquisition fails, or validity has already run out, the client releases on all nodes, including those that appeared to refuse, because a reply may have been lost after the write succeeded.

This protects against the loss of a single node. That protection depends on three assumptions:

  • Clock drift between nodes is bounded.
  • Clients do not pause for longer than the validity window.
  • A restarted node does not come back missing keys it had acknowledged.

If a node restarts with an empty dataset, a second client can assemble a new majority while the first client still holds the lock.

Primary/replica failover breaks the single-instance case for a similar reason. Replication is asynchronous, so a primary can acknowledge the SET and crash before the write reaches its replica. The promoted replica has no key, and it grants the same lock to another client.

#!/usr/bin/env bash
# Single-instance Redis lock: acquire with SET NX PX, release with a
# compare-and-delete Lua script so we never delete another client's lock.
set -euo pipefail

KEY="lock:orders:invoice-42"   # resource being protected
TTL_MS=10000                    # lock expires after 10 s if we crash

# Random token identifies THIS acquisition, not the process.
TOKEN=$(openssl rand -hex 16)

# NX: only set if the key does not exist. PX: expiry in milliseconds.
RESULT=$(redis-cli SET "$KEY" "$TOKEN" NX PX "$TTL_MS")

if [ "$RESULT" != "OK" ]; then
  echo "lock held by someone else" >&2
  exit 1
fi

echo "acquired $KEY"
# ... critical section; must finish well inside TTL_MS ...

# Unsafe: 'redis-cli DEL "$KEY"' would remove the lock even if our TTL
# expired and another client has since acquired it.

# Safe: GET and DEL run inside one atomic script, so nothing can change
# the key between the comparison and the delete.
RELEASE_SCRIPT='
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end'

DELETED=$(redis-cli EVAL "$RELEASE_SCRIPT" 1 "$KEY" "$TOKEN")

# 1 = we released our own lock; 0 = it had expired or belongs to another client.
echo "released: $DELETED"

Acquiring a Redis lock with SET NX PX and a per-acquisition random token, then releasing it with an atomic Lua compare-and-delete instead of a plain DEL.

How PostgreSQL advisory locks work

PostgreSQL advisory locks are named by numbers that only your application interprets. The server enforces mutual exclusion and nothing else. pg_advisory_lock takes a session-level lock. It stays held until you call pg_advisory_unlock or the session ends. It is also reentrant, so acquiring it twice requires two unlocks. pg_advisory_xact_lock takes a transaction-level lock, which is released automatically at commit or rollback and cannot be unlocked early. Both block until the lock is granted. pg_try_advisory_lock and pg_try_advisory_xact_lock return a boolean immediately, which suits "skip if someone else is already doing this" jobs.

Keys come in two forms: a single bigint, or a pair of ints. The two forms occupy separate key spaces, so (1, 2) never conflicts with any single bigint key. To lock by resource name, hash a namespaced string such as billing:invoice-run:2024-06 in the application. Taking the first 8 bytes of a SHA-256 digest as a signed 64-bit integer works well. Collisions are rare but possible. The bigger risk is organisational. If two teams share a database and both lock small integers like 1, their unrelated jobs will block each other. Locks are scoped to the database, not the schema. A namespace prefix inside the hashed string prevents most of these accidental overlaps.

A lock has no TTL. Its lifetime is the connection's lifetime. If the connection drops because of a network blip, an idle timeout, or a server restart, the lock is released while the client process keeps running and still believes it holds the lock.

Poolers make this worse. Under transaction-mode pooling, consecutive statements can run on different server connections. Your unlock may hit the wrong backend. A leaked lock may also stay attached to a connection that is later handed to another client. Use transaction-level locks in this setup, or hold session locks on a dedicated, unpooled connection.

-- Map a namespaced resource name to a signed bigint key:
-- first 8 bytes of SHA-256, reinterpreted as int8 (sha256() needs PostgreSQL 11+).
-- Computing it in SQL keeps every client on the same mapping.
SELECT ('x' || left(encode(sha256(convert_to('billing:invoice-run:2024-06', 'UTF8')), 'hex'), 16))::bit(64)::bigint AS lock_key;

-- Transaction-level, non-blocking: safe behind a transaction-mode pooler,
-- because the lock cannot outlive the transaction that took it.
BEGIN;
SELECT pg_try_advisory_xact_lock(
  ('x' || left(encode(sha256(convert_to('billing:invoice-run:2024-06', 'UTF8')), 'hex'), 16))::bit(64)::bigint
) AS got_lock;
-- If got_lock is false, another worker is running this job: ROLLBACK and skip.
-- ... do the invoice run here ...
COMMIT;  -- lock released here, no unlock call exists for xact locks

-- Session-level, blocking: held until unlocked or the connection drops.
-- Only use on a dedicated, non-pooled connection.
SELECT pg_advisory_lock(42);
SELECT pg_advisory_lock(42);    -- reentrant: same session acquires again
SELECT pg_advisory_unlock(42);  -- true, but still held once
SELECT pg_advisory_unlock(42);  -- true, now actually released
SELECT pg_advisory_unlock(42);  -- false plus a WARNING: not held

-- The two-int form is a separate key space: this does not conflict with key 42.
SELECT pg_try_advisory_lock(1, 42);
SELECT pg_advisory_unlock(1, 42);

-- Inspect who holds advisory locks in the current database.
SELECT pid, classid, objid, objsubid, mode, granted
FROM pg_locks
WHERE locktype = 'advisory' AND database = (SELECT oid FROM pg_database WHERE datname = current_database());

Hashing a namespaced resource name into a bigint key in SQL, taking a non-blocking transaction-level lock that is safe behind a pooler, showing session-level reentrancy and the separate two-int key space, and inspecting held locks in pg_locks.

When both approaches fail: paused holders and fencing tokens

Both lock types share one flaw: the holder cannot know, at the moment it writes, that it still holds the lock. Consider this timeline:

  1. Client A acquires the lock with a 10-second lease.
  2. A hits a long GC pause, a VM stall, or a slow network path, and freezes for 15 seconds.
  3. The lease expires. Client B acquires the lock and writes to the resource.
  4. A resumes. Its code already passed the "do I hold the lock?" check, so it writes too and overwrites B's work.

Advisory locks do not avoid this. If A's database connection drops while A is stalled, PostgreSQL ends the session and releases the lock. B acquires it. A's write to some other system, such as a cache, a queue, or a second database, still lands, because A has not yet learned that its connection is gone.

Fencing tokens

The fix moves the check to the resource. Each lock grant issues a number that only goes up. The holder sends that number with every write. The resource remembers the highest number it has accepted and rejects anything lower. In the timeline above, B's token is greater than A's, so A's late write is refused.

Redlock has no built-in monotonic token. The random value it stores exists only for safe release, and it cannot be ordered. You can get a token from PostgreSQL instead:

  • Call nextval() on a sequence when you acquire the lock.
  • Or increment a version column on the resource.

The write is then a conditional UPDATE ... WHERE fence_token < $1. Zero affected rows means the token is stale.

The limit

Fencing only works when the downstream resource can compare tokens. Many third-party APIs and object or file stores cannot. For those, make the operation idempotent, for example with idempotency keys or deterministic object names, so a duplicate or late write does no harm.

-- One sequence issues fencing tokens. Values only go up, across all clients.
CREATE SEQUENCE lock_fence_seq;

-- The protected resource records the highest token it has accepted.
CREATE TABLE inventory (
  sku         text PRIMARY KEY,
  quantity    integer NOT NULL,
  fence_token bigint  NOT NULL DEFAULT 0
);

INSERT INTO inventory (sku, quantity) VALUES ('SKU-1001', 50);

-- On lock acquisition (Redlock or advisory lock), fetch a token.
-- Client A gets 1. Client B, acquiring after A's lease expired, gets 2.
SELECT nextval('lock_fence_seq') AS fence_token;

-- Every write carries the holder's token. The WHERE clause rejects stale ones.
-- '<=' lets the current holder write more than once with its own token.
PREPARE fenced_write(text, integer, bigint) AS
  UPDATE inventory
     SET quantity = $2,
         fence_token = $3
   WHERE sku = $1
     AND fence_token <= $3;

-- B writes with token 2: UPDATE 1, and the row now stores fence_token = 2.
EXECUTE fenced_write('SKU-1001', 42, 2);

-- A resumes after its pause and writes with token 1: UPDATE 0.
-- Zero affected rows means the token is stale. A must abort, not retry.
EXECUTE fenced_write('SKU-1001', 49, 1);

A PostgreSQL sequence issues monotonically increasing fencing tokens, and a conditional UPDATE refuses the late write from a paused client whose token is lower than the one already stored on the row.

Leader election with each approach

With Redis, leadership is a lease: a candidate sets a key with NX and a TTL, then renews it in a loop. Each renewal must be atomic and conditional, extending the TTL only if the key still holds this candidate's random value, usually through a Lua script. Renew at roughly one third of the TTL so that one or two slow or failed attempts still leave time before expiry. The leader should track expiry on its own monotonic clock, measured from when it sent the last successful renewal. If renewal fails, or the lease's remaining time drops below a safety margin, it stops leading immediately. It should not wait for the next attempt to succeed.

With PostgreSQL, a candidate calls pg_try_advisory_lock on a dedicated session and holds it for as long as it leads. There is no renewal loop, but the lock lives only as long as that connection. Each candidate permanently occupies one backend connection. That connection cannot go through a transaction-pooling proxy, because the lock would stay with whichever server session the proxy assigned. If the connection drops, the leader must treat leadership as lost.

Failover is the risky part. Advisory locks are not replicated, so a promoted standby starts with no locks, and another candidate can acquire leadership at once. Meanwhile, the old leader's socket to the dead primary may look healthy until TCP keepalives or a periodic health query time out.

In both designs, leadership can be lost between a check and a write. Check leadership immediately before each side effect, and send a fencing token with every write. With PostgreSQL, the token can be a counter row incremented on acquisition, which does replicate.

import time, uuid, redis

# Extend the TTL only if the key still holds our value (atomic check-and-extend).
RENEW = """
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('pexpire', KEYS[1], ARGV[2])
end
return 0
"""
KEY = "leader:billing-scheduler"
TTL_MS = 9000
INTERVAL = TTL_MS / 3 / 1000   # renew at ~1/3 of TTL; tolerates 1-2 failed attempts
MARGIN = 2.0                   # seconds; stop leading before the lease can expire

r = redis.Redis(host="redis.internal", port=6379, socket_timeout=1.0)
me = uuid.uuid4().hex          # random value proves ownership of the key

def still_leader(expires):
    # Local monotonic view of the lease; call right before every side effect.
    return expires - time.monotonic() > MARGIN

def run(do_work):
    while True:
        sent = time.monotonic()  # measure expiry from when the request was sent
        try:
            ok = r.set(KEY, me, nx=True, px=TTL_MS)
        except redis.RedisError:
            ok = False
        while ok:
            expires = sent + TTL_MS / 1000
            do_work(lambda: still_leader(expires))  # work must re-check before writes
            time.sleep(INTERVAL)
            sent = time.monotonic()
            try:
                ok = r.eval(RENEW, 1, KEY, me, TTL_MS) == 1
            except redis.RedisError:
                ok = False  # failed renewal: step down now, don't wait for a retry
            ok = ok and still_leader(sent + TTL_MS / 1000)
        time.sleep(INTERVAL)  # follower: retry acquisition later

A Redis lease election loop: acquire with NX and a TTL, renew via a conditional Lua script at one third of the TTL, track expiry on a monotonic clock, and step down on the first failed renewal.

Trade-offs side by side: failure modes, operations, and cost

Dimension Single-instance Redis Redlock PostgreSQL advisory locks
Safety assumption One node never loses acknowledged writes Bounded clock drift, network delay, and process pauses Lock lives exactly as long as the session or transaction
Node failure or failover Async replica can be promoted without the lock, so two holders can coexist Tolerates a minority of masters failing, but restarts without persistence can reissue a lock Session dies and the lock releases; failover drops all sessions
Expiry model TTL plus renewal TTL plus renewal No TTL; tied to connection liveness
Fencing Not built in Not built in; random values are not ordered Counter row in the same database, updated in the same transaction
Operations One more service Several independent masters to run and monitor Existing database

TTLs create a tension that advisory locks avoid. A short TTL can expire while the holder is still writing. A long TTL leaves work stalled after a crash. Renewal fixes neither problem cleanly, because the renewal path can itself fail or be delayed.

Advisory locks have their own pressure points. A session-level lock pins a connection for as long as it is held. Transaction-mode poolers can silently break session semantics, so use transaction-level locks or a dedicated connection. Lock keys are plain integers shared across the whole database, so two services that hash to the same key will block each other. Give each service its own key namespace, for example the two-integer form with a per-service prefix.

On cost, reusing PostgreSQL that is already your source of truth means no new infrastructure and lets fencing share the transaction with the data. The more expensive mistake is using a lock built for efficiency where correctness depends on it. That surfaces later as corrupted data that rarely reproduces and takes a long time to trace.

Choosing a lock: decision guide, when to avoid each, and alternatives

Decision guide

  • PostgreSQL holds the data being protected: do the work inside one transaction that takes pg_advisory_xact_lock or row locks via SELECT ... FOR UPDATE. The lock is released at commit or rollback, so no other writer can act between the lock and the write.
  • Losing the lock only costs duplicate work (cache rebuilds, deduplicating cron jobs): a single-instance Redis lock with a TTL is enough. Redlock adds operational cost without making it safe for correctness.
  • Correctness spans systems: use a consensus-backed coordinator and have the protected resource reject stale fencing tokens. A lock without enforcement at the resource only reduces how often failures happen.

When to avoid each

Option Avoid when Why
Redlock Correctness depends on it and no fencing is in place Clock drift and process pauses let two holders overlap
Session advisory locks Connections pass through a transaction-mode pooler Successive statements can run on different server sessions, so a lock can be held by a session you no longer control
Advisory locks generally You need very many locks held for a long time They use the shared lock table, which has a fixed size

Alternatives that remove the lock

Idempotency keys make retries harmless, so duplicate execution stops being a failure. Optimistic concurrency with a version column (UPDATE ... WHERE version = $n) turns a conflict into a zero-row update that the caller retries. Single-writer partitioning sends each key to one queue partition with one consumer, so concurrent writes to that key never happen. Each option removes code for acquiring, renewing, and releasing locks and handling timeouts. That is the code that is hardest to test and fails least predictably.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to distributed lock microservices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.