bigonotes
System DesignMessaging & Coordination

Distributed Lock

How a distributed lock with an expiry (a lease) lets one process at a time own a resource across many servers, and where it goes wrong.

5 min readLast updated Messaging & CoordinationListen · Watch on NotebookLM

A distributed lock makes sure only one process, on any of your servers, works on a resource at a time. A mutex does this inside one program. A distributed lock does it across many machines by keeping the lock in a shared store such as Redis or ZooKeeper. You reach for one when you need to hold something for seconds or minutes, like a concert seat during checkout, which is longer than a database transaction should stay open.

Why not just use a database lock?#

Databases with ACID transactions already lock rows while a transaction updates them. That works for milliseconds. It is the wrong tool for "hold this ticket for 10 minutes while the user types their card number":

  • Keeping a transaction open that long ties up connections and blocks other work.
  • The lock has to span requests, services and sometimes several databases.

A distributed lock is a small record in a fast shared store that says "process X owns resource Y until time T".

How it works#

The store must support an atomic "set only if not already set" with an expiry. In Redis that is one SET command with the NX and PX options. From Python, with the redis client:

r.set("lock:ticket-123", token, nx=True, px=600_000)
  • nx=True (Redis NX): only set the key if it does not exist. If another process already holds the lock, the command fails and you know you didn't get it.
  • px=600_000 (Redis PX 600000): the key expires after 600,000 ms (10 minutes). If the holder crashes, the lock frees itself. A lock with an expiry is often called a lease.
  • token: a random value that identifies this holder, so you never release someone else's lock.
Timeline: process A acquires a 10-minute lease, crashes, the lease expires, and process B then acquires the lock; B's attempt during A's lease fails

Releasing must check the token and delete in one atomic step. Otherwise A's lease could expire, B could take the lock, and A's late "delete" would remove B's lock.

import uuid
import redis

r = redis.Redis()

# Delete the key only if it still holds our token. Running this
# as a Lua script makes the check and the delete one atomic step
# on the Redis server.
RELEASE = r.register_script("""
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
end
return 0
""")


def acquire(resource: str, ttl_ms: int) -> str | None:
    token = str(uuid.uuid4())
    # nx=True: fail if someone else holds it.
    # px: auto-expire if we crash.
    if r.set(f"lock:{resource}", token, nx=True, px=ttl_ms):
        return token
    return None


def release(resource: str, token: str) -> bool:
    return RELEASE(keys=[f"lock:{resource}"], args=[token]) == 1


token = acquire("ticket-123", ttl_ms=10 * 60 * 1000)
if token:
    try:
        print("holding ticket-123, run checkout here")
    finally:
        release("ticket-123", token)
else:
    print("someone else is buying this ticket")

Where you'd use one#

Locks show up wherever a resource must be held for a while.

  1. E-commerce checkout: hold a limited-edition item in a user's cart for 10 minutes so it isn't sold to someone else mid-payment.
  2. Ride-sharing matching: lock a driver while a ride offer is pending so they aren't offered to two riders at once. The lock ends when the driver accepts, declines or the offer times out.
  3. Distributed cron jobs: when a scheduled job runs on many servers, a lock ensures only one server actually runs tonight's report.
  4. Auction bidding: in the final seconds, briefly lock the item while a bid is processed so the highest bid is updated safely.

Things to get right#

A few details decide whether a lock is safe.

Topic What to know
Expiry Always set one, so a crashed holder can't block everyone forever. Pick a TTL longer than the work normally takes. For long work, renew the lease periodically while you still hold it.
Ownership Store a unique token and release only if it matches.
Granularity Lock as little as possible. One seat is better than a whole section, unless the business rule really covers the section.
Deadlocks If process 1 holds lock A and waits for B while process 2 holds B and waits for A, both wait forever. Avoid this by acquiring locks in a fixed order, keeping lock logic in one place rather than scattered across services, and relying on expiries as a backstop.
Implementation A single Redis instance is simple but is a single point of failure. Redlock acquires the lock on a majority of several independent Redis nodes. ZooKeeper and etcd provide locks built on consensus.

Trade-offs and pitfalls#

Locks solve one problem and bring a few of their own.

  • Expiry is a double-edged sword. If the holder pauses (a long garbage-collection pause, a slow network) past its TTL, the lock expires and a second process can enter while the first still thinks it holds it. For correctness-critical work, pair the lock with a fencing token: an increasing number handed out with each lock, which the protected resource checks so it can reject writes from an older holder.
  • Another moving part. The lock store must be available. If it is down, nobody can acquire locks.
  • Often you don't need one. A conditional update (UPDATE seats SET holder = ? WHERE id = ? AND holder IS NULL) or optimistic concurrency in the database is simpler when the operation is short.

Key takeaways#

  • A distributed lock lets one process across many servers own a resource, using an atomic "set if absent" in a shared store.
  • Always give the lock an expiry (a lease) so crashes don't leave it stuck.
  • Use a unique token so you only release your own lock.
  • Expiry can let two holders overlap during long pauses. Fencing tokens protect correctness-critical writes.
  • Prefer a simple conditional database update when the critical section is short.

Found a mistake? Tell me on LinkedIn ↗

Further reading#