Efficiency locks vs correctness locks: what a distributed lock is for
A distributed lock is an agreement among processes on different machines that only one of them may perform a given action at a time. Services reach for one when a shared resource has no built-in way to reject concurrent access: a cron job running on every replica, a payment record that two workers might settle, a cache that is expensive to rebuild.
The useful question is not "do I need a lock?" but "what breaks if two holders exist at once?" The answer splits locks into two kinds.
Efficiency locks
These locks prevent wasted work. If two workers both regenerate the same report or both send the same nightly digest, you pay extra CPU or a user gets a duplicate email. That is annoying, but the system stays correct. An occasional double execution is acceptable, so a lock that is usually right is good enough. Simplicity and low latency matter more than strict guarantees.
Correctness locks
These locks protect invariants. If two holders write to the same ledger row, assign the same inventory unit twice, or apply a migration concurrently, data is corrupted. A lock that is usually right is not good enough. A single overlap after a GC pause or network partition is a bug, and it may not be discovered until reconciliation.
Most teams have both kinds and treat them the same, and that causes trouble. The guarantees you need decide everything that follows: which backend to use, whether leases are safe, and whether the protected resource must check fencing tokens.
How Redis locks work: single-instance SET NX and Redlock
The single-instance pattern rests on one atomic command: SET with the NX option (only set if the key does not exist) and an expiry via PX or EX. Atomicity matters because a separate SETNX followed by EXPIRE can leave a lock with no expiry if the client crashes between the two calls. The expiry turns the lock into a lease, so a dead holder cannot block everyone forever. The value should be a unique random token per acquisition, not a constant. On release, a short Lua script compares the stored value to the caller's token and deletes the key only if they match. Redis runs the script atomically. Without the check, a client whose lease already expired could delete a lock that another client now holds.
Redlock tries to remove the single point of failure. It uses N independent Redis primaries with no replication between them, typically five. The client attempts the same SET NX on each node with a short per-node timeout and records the elapsed time. It holds the lock only if a majority granted it and the total elapsed time is less than the TTL. The remaining validity is the TTL minus the elapsed time minus an allowance for clock drift. If acquisition fails, the client releases on every node.
That arithmetic assumes bounded clock drift, bounded network delay, and bounded process pauses such as GC stops. These assumptions have been publicly debated. Critics argue that real systems violate all three, so Redlock cannot guarantee correctness. Defenders counter that the bounds are reasonable in practice.
A single primary with async replicas is weaker still. The primary acknowledges the lock and crashes before replicating the key. A replica is promoted without it, and a second client acquires the same lock.
import secrets
import redis
r = redis.Redis(host="localhost", port=6379)
# Delete the key only if it still holds our token. Redis runs the script
# atomically, so no other command can run between the GET and the DEL.
RELEASE = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""")
def acquire(key: str, ttl_ms: int) -> str | None:
# A fresh random token per acquisition identifies this holder.
token = secrets.token_hex(16)
# One atomic command: set only if absent (NX), with a lease expiry (PX).
# This avoids the SETNX-then-EXPIRE race that can leave a lock with no TTL.
if r.set(key, token, nx=True, px=ttl_ms):
return token
return None
def release(key: str, token: str) -> bool:
# Returns False if our lease already expired and someone else holds the lock.
return RELEASE(keys=[key], args=[token]) == 1
token = acquire("lock:invoice:42", ttl_ms=10_000)
if token:
try:
pass # critical section; must finish well within the TTL
finally:
if not release("lock:invoice:42", token):
print("lease expired before release; work may have overlapped")
This shows a single-instance Redis lock in redis-py: it acquires with an atomic SET NX PX and a random token, then releases with a Lua compare-and-delete so a client never removes a lock it no longer owns.
How PostgreSQL advisory locks work
Postgres sidesteps that failure mode because the lock lives in the same server, and on failover the same timeline, as the database you are protecting. Advisory locks are application-defined locks on an arbitrary key. Postgres enforces mutual exclusion on the key but attaches no meaning to it.
Session vs transaction scope
pg_advisory_lock(key) takes a session-level lock. It is held until you call pg_advisory_unlock(key) or the connection closes. These locks are re-entrant: acquiring the same key twice requires two unlocks. A leaked lock on a long-lived pooled connection stays held indefinitely.
pg_advisory_xact_lock(key) takes a transaction-level lock. It releases automatically at commit or rollback and has no unlock function. The pg_try_ variants of both return false immediately instead of blocking, which makes them suitable for "skip if someone else is working" jobs.
The safest pattern is to take pg_advisory_xact_lock and perform the guarded write in the same transaction. The lock and the data change then commit or roll back together, and a crashed client cannot leave the lock behind.
If the contended data is a row in this database, you often don't need an advisory lock at all. SELECT ... FOR UPDATE locks the actual rows, and adding SKIP LOCKED lets multiple workers claim queue rows without blocking on each other.
Keys and collisions
Keys are one bigint or two int4 values, and the two forms do not overlap. Every team shares this keyspace, so adopt a convention. Use the two-integer form with the first value as a fixed namespace per lock type and the second as the entity ID. If you hash strings into keys, document the hash function and accept that collisions cause false contention but never false exclusivity.
-- Namespace convention: first int4 = lock type, second int4 = entity ID.
-- 1001 = invoice generation (example value; record it in a shared registry).
-- Pattern 1: blocking transaction-level lock with the guarded write in the same transaction.
BEGIN;
SELECT pg_advisory_xact_lock(1001, 42); -- waits until no other session holds (1001, 42)
INSERT INTO invoices (account_id, period, total_cents)
VALUES (42, '2026-10', 129900);
COMMIT; -- lock released here; on ROLLBACK or a dropped connection it is released too
-- Pattern 2: non-blocking, for "skip if someone else is working" jobs.
BEGIN;
SELECT pg_try_advisory_xact_lock(1001, 42) AS got_lock;
-- The application reads got_lock: if false, ROLLBACK and move on; if true, do the work.
COMMIT;
-- Pattern 3: data lives in this database, so lock the rows instead.
-- Each worker claims up to 10 pending jobs without blocking on rows other workers hold.
BEGIN;
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- The application processes the returned rows, then marks them done by id, for example:
UPDATE jobs SET status = 'done' WHERE id IN (101, 102);
COMMIT;
-- Debugging: list advisory locks currently held or awaited.
-- For the two-int4 form, classid = namespace and objid = entity ID.
SELECT pid, classid, objid, granted
FROM pg_locks
WHERE locktype = 'advisory';
Shows a transaction-scoped advisory lock taken with a namespaced two-integer key and released at commit, the non-blocking pg_try_ variant, row-level claiming with FOR UPDATE SKIP LOCKED when the data is in the same database, and a pg_locks query for inspecting advisory locks.
Why lease-based locks fail and how fencing tokens fix it
Every lock in this article is a lease, and leases fail in a way that correct code cannot prevent. Consider this sequence. Client A acquires a Redis lock with a 10-second TTL, then hits a long GC pause, a VM live migration, or a stalled network write. The TTL expires, and client B acquires the lock and writes. A then resumes, still believing it holds the lock, and writes too. Neither client has a bug. A cannot detect that time passed while it was frozen. PostgreSQL advisory locks fail the same way. If A's connection is dropped by a TCP timeout or a failover, the server releases the lock, but A's process may still be running and acting.
The fix is to make the resource reject stale holders. A fencing token is a monotonically increasing number issued with each lock grant. The client sends the token with every write. The resource stores the highest token it has accepted and refuses any write that carries a lower one.
Redlock does not produce fencing tokens. Its random lock values prove ownership, but they have no order. In PostgreSQL, you can take a value from a sequence when you acquire the lock, or increment a version column. The write then enforces the token in a single statement: UPDATE jobs SET ..., fence = $token WHERE id = $id AND fence < $token. If zero rows are updated, the client's lease was superseded.
This approach has a limit. Fencing only works if the resource can check the token. Most third-party APIs, object stores, and file systems cannot. For those, you have no strict safety guarantee. You can only shorten the window in which two clients act at once.
-- One-time setup. CACHE 1 (the default) keeps token order matching the order of nextval calls across sessions.
CREATE SEQUENCE job_fence_seq CACHE 1;
CREATE TABLE jobs (
id bigint PRIMARY KEY,
status text NOT NULL,
result jsonb,
fence bigint NOT NULL DEFAULT 0 -- highest token this row has accepted
);
INSERT INTO jobs (id, status) VALUES (1001, 'pending');
-- Client acquires the lease: a session-level advisory lock keyed on the job id.
SELECT pg_try_advisory_lock(1001); -- true = acquired
-- Immediately after acquiring, take a fencing token.
-- A later holder always calls nextval later, so it gets a higher number.
SELECT nextval('job_fence_seq') AS token; -- suppose this returns 17
-- ... long-running work happens here; the lease may silently be lost ...
-- Fenced write: the row rejects any token lower than one it has already seen.
UPDATE jobs
SET status = 'done',
result = '{"rows_processed": 5000}',
fence = 17
WHERE id = 1001
AND fence < 17
RETURNING id;
-- 1 row returned: the write was accepted.
-- 0 rows returned: a newer holder already wrote with a higher token.
-- This lease was superseded, so stop and do not retry with the same token.
SELECT pg_advisory_unlock(1001);
A PostgreSQL advisory lock paired with a sequence-issued fencing token, where a conditional UPDATE rejects writes from a holder whose lease was superseded.
When to avoid each option: pitfalls and trade-offs
Redis: the lease is only as good as your TTL guess
A TTL has to cover the longest the work could take, including GC pauses, slow I/O and retries. Set it too short and the lease expires while the holder is still writing, so a second worker starts the same job. Set it too long and a crashed holder blocks everyone until the key expires. Renewing the lease in the background narrows this gap. It does not close it, because a paused process also stops renewing without knowing it has stopped.
Redlock adds two more conditions. It needs several independent primaries, usually five, and replicas of a single primary do not count. It is also safe only if clock drift, network delay and process pauses stay small relative to the TTL. Nothing enforces those bounds. A clock jump on one node can let two clients each claim a majority.
PostgreSQL: the lock lives and dies with a connection
A session-level advisory lock belongs to the server connection, not to your client. A transaction-mode pooler can hand that connection to another client after a commit. The lock then leaks to that client, or your unlock runs on a different connection and fails.
Under a network partition, the server keeps the lock until it detects the dead connection, while your client cannot tell whether it still holds it. Each held lock also occupies one pooled connection. Every lock call goes to the primary, which adds load there. Locks cannot coordinate work across separate clusters.
| Concern | Redis / Redlock | PostgreSQL advisory lock |
|---|---|---|
| Release on crash | After TTL expires | When the session ends |
| Main failure mode | Lease expires while work continues | Lock tied to wrong or dead connection |
| Infrastructure cost | Several independent nodes for Redlock | Pinned connections, extra load on primary |
| Scope | Any client that can reach the nodes | One cluster only |
Avoid Redlock when a duplicate execution corrupts data and the resource cannot check a fencing token. Avoid advisory locks when your clients reach the database only through a transaction-mode pooler, when the work spans more than one cluster, or when the primary is already short on connections.
Leader election with a Redis lease or a Postgres advisory lock
Leader election is a long-held lock, so the failure modes above last longer and matter more.
Redis: a renewed lease
The candidate runs SET with NX, a unique token as the value, and a TTL. While it leads, a heartbeat extends the TTL with a script that changes the expiry only if the stored value still matches its token. Renew at a fraction of the TTL, such as a third, so that one slow round trip does not cost leadership.
The leader detects loss in two ways:
- A renewal returns "not mine."
- Its own deadline passes before a renewal succeeds. The leader computes this deadline from a monotonic clock, starting when it sent the last successful renewal and subtracting a margin for clock drift.
The second check is the important one. During a partition, renewals do not fail cleanly. They time out, and the leader has to assume the worst before the key actually expires.
Postgres: a session advisory lock
The candidate calls pg_try_advisory_lock on a dedicated connection that bypasses the pooler. The lock lasts exactly as long as that session. To detect loss, the leader pings the connection with a short statement timeout. If the ping errors or the connection drops, leadership is gone. On the server side, a dead client releases the lock only after TCP keepalives or the session's own timeouts notice the failure. Tune these settings, or failover will stall.
After deposition
A deposed leader must stop issuing writes and cancel in-flight work. It must not retry anything under its old role. It can then rejoin the election as a follower.
Fencing
Stopping is never instantaneous. A GC pause or a queued request can land after a successor has taken over. On every acquisition, increment a term number: INCR in Redis, or a sequence or term row in Postgres. Attach the term to every leader write, and have the storage layer reject writes with a lower term. In Postgres, the advisory lock on one connection does not guard writes made on other connections, so the term check is still required.
import time, uuid, redis
r = redis.Redis(host="redis.internal", port=6379, socket_timeout=1.0)
KEY, TERM_KEY = "leader:billing-scheduler", "leader:billing-scheduler:term"
TTL_MS, DRIFT = 9000, 0.1 # assume local and server clocks may differ by up to 10%
# Extend the TTL only if the key still holds our token.
RENEW = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0""")
def local_deadline(sent):
# Count from when the request was SENT, on a monotonic clock, minus a drift margin.
return sent + (TTL_MS / 1000) * (1 - DRIFT)
def run_as_leader(step):
token, sent = uuid.uuid4().hex, time.monotonic()
if not r.set(KEY, token, nx=True, px=TTL_MS):
return # someone else leads; stay a follower
deadline = local_deadline(sent)
term = r.incr(TERM_KEY) # fencing token: every leader write carries it
while time.monotonic() < deadline:
step(term) # short unit of work; storage must reject writes with term < highest seen
time.sleep(TTL_MS / 3000) # renew at about a third of the TTL
sent = time.monotonic()
try:
if RENEW(keys=[KEY], args=[token, TTL_MS]) == 0:
break # "not mine": the lease expired and someone else may hold it
deadline = local_deadline(sent)
except redis.RedisError:
pass # timeout or partition: keep the OLD deadline and assume the worst
# Deposed: stop writing, cancel in-flight work, do not retry under this term.
# Then rejoin the election as a follower.
A Redis leader lease that renews with a token-checked script, treats its own monotonic-clock deadline as the real loss signal during timeouts, and takes a new INCR term on each acquisition so that writes from a stale leader can be fenced.
Decision guide, alternatives, and impact on cost and engineering time
Start from what a second, simultaneous lock holder would cost you, not from which tool your team already runs.
| Situation | Choice | Why it fits |
|---|---|---|
| Duplicate work is only wasteful (cache rebuild, report generation) | Single-instance Redis SET NX with a TTL |
One round trip and no new infrastructure. A rare overlap wastes CPU but does not corrupt data. |
| The protected data lives in one Postgres database | Advisory lock or SELECT ... FOR UPDATE inside the writing transaction |
The lock and the write commit or abort together, so there is no lease expiry or clock to reason about. |
| Correctness spans systems that cannot share a transaction | Consensus-backed coordinator, with fencing tokens checked by every resource | It holds up through pauses and partitions, but only if each downstream system rejects stale tokens. |
Often you can remove the lock entirely:
- Idempotency key: turns "only one caller may run" into "any number of callers may run, with one effect."
- Unique constraint: the database rejects the second insert.
- Queue partition with a single consumer: serializes work by design.
Each option enforces safety where the write happens. That is also where fencing checks would have to live.
A dedicated consensus cluster adds three or five nodes that you must patch, monitor, back up and upgrade. It also brings new failure modes:
- Quorum loss blocks every lock acquisition.
- Sessions expire during GC pauses or network blips.
- Client libraries can mishandle reconnects.
- Every team that uses it has to learn its semantics.
Reusing Postgres adds lock traffic to connections you already budget for. An efficiency-only lock spares every downstream service from fencing work. Pay for consensus only when a second holder would corrupt data you cannot reconcile later.
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 redis vs postgres, 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 shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.