Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 22 of 45

Lesson 22 · Distributed Systems

Distributed Locks, Leases, Leader Election and Consensus

When only one node may do something at a time: when you actually need a distributed lock and when you do not, locks for efficiency versus correctness, leases and why they need fencing tokens, Redis and database locks and their limits, leader election, and consensus (Raft, majorities, ZooKeeper and etcd) explained at interview depth.

On this page

Key takeaways

  • Before reaching for a distributed lock, try to remove the need: unique constraints, conditional writes, idempotent work, or partitioning work so each item has one owner.
  • Distinguish locks for efficiency, where a rare double execution is acceptable, from locks for correctness, where it is not.
  • Distributed locks are leases that expire, and a paused process can keep acting after expiry, so correctness requires fencing tokens checked by the resource being protected.
  • Consensus algorithms such as Raft let a majority of nodes agree on a leader and an ordered log, tolerating f failures with 2f + 1 nodes.
  • Use a proven coordination service (etcd, ZooKeeper, or your database) for leader election and locks rather than inventing a protocol.

Pixly sends every user a weekly digest email on Sunday morning. The job was written when there was one server. Now there are eighteen, the job runs on each of them, and two million users receive eighteen copies of the same email. The obvious fix, "make sure only one server runs the job", turns out to be one of the trickier problems in distributed systems.

This article is about one-at-a-time behaviour across many machines: distributed locks, leases, leader election, and the consensus algorithms underneath them. Its most useful lesson comes first: you often do not need a lock at all.

What you will learn

#
  • How to avoid needing a distributed lock.
  • The difference between locks for efficiency and locks for correctness.
  • Leases, renewal and expiry, and why a paused process breaks naive locks.
  • Fencing tokens, with a runnable demonstration.
  • Database-based and Redis-based locks, and their limits.
  • Leader election patterns.
  • Consensus: what Raft does, why majorities matter, and what ZooKeeper and etcd provide.

First, try not to need a lock

#

A distributed lock adds a dependency, latency and subtle failure modes. Many "we need a lock" problems have simpler answers you have already met:

Problem

Lock-free answer

Two users booking one seat

A conditional update or unique constraint (article 14)

A payment processed twice

Idempotency key or unique constraint (article 20)

A queue message handled by two workers

Idempotent consumer (article 20)

A weekly job running on every server

A scheduler that enqueues the job once, plus idempotent processing (article 37)

Many workers processing many items

Partition the work: each worker owns a set of keys or partitions (consumer groups, article 11)

A counter updated by many servers

Atomic increment in one store

Notice the pattern: let the data store be the referee. A database can enforce "only one" for a specific row far more reliably than application servers can coordinate among themselves.

Efficiency versus correctness

#

Martin Kleppmann's analysis of distributed locking draws a distinction that clarifies every later decision (How to do distributed locking):

  • A lock for efficiency prevents doing the same work twice when doing it twice is merely wasteful: recomputing a cache entry, sending a non-critical report twice. If the lock occasionally fails and two nodes do the work, the cost is small.
  • A lock for correctness prevents two nodes from acting when both acting would corrupt data or cause harm: two leaders writing to the same file, two processes moving the same money.

For efficiency, a simple, fast lock (for example a single Redis key with a timeout) is fine. For correctness, a lock alone is not enough, for reasons the next section explains, and you need fencing.

Locks are leases

#

A lock held across a network cannot be "held until released", because the holder might crash and never release it. So distributed locks are leases: the lock is granted for a limited time (say 30 seconds). The holder must renew it before it expires, or lose it. If the holder dies, the lease expires and another node can take over.

Leases solve the crash problem and create a new one. From article 18: a process can pause (a long garbage-collection pause, a VM pause, heavy swapping) without knowing it. The timeline:

Code
t=0   Client A acquires the lease (30 s)t=5   A starts a long garbage-collection pauset=30  The lease expires. The lock service gives it to client B.t=31  B writes to the shared resourcet=45  A wakes up, still believing it holds the lock, and writes too      -> two writers; A's stale write may overwrite B's

Checking "do I still hold the lock?" right before writing does not fix it, because the pause can happen between the check and the write. And the lease's timing assumes clocks on the lock service and the client run at similar rates.

Fencing tokens

#

The fix moves the check to the resource being protected. Each time the lock service grants the lock, it returns a fencing token: a number that increases with every grant. The client sends the token with every write. The resource (database, storage service) remembers the highest token it has accepted and rejects any write carrying a lower one. Kleppmann's article describes exactly this mechanism, and the Redis documentation on distributed locks recommends implementing fencing tokens when correctness matters (Distributed Locks with Redis).

This Java program scripts the timeline above twice: once with storage that accepts any write, once with storage that checks fencing tokens.

Java
// File: FencingDemo.javapublic class FencingDemo {    static long nextToken = 33;          // the lock service hands out increasing tokens    static String storedValue;    static long highestTokenSeen;    static long acquireLock(String client) {        long token = nextToken++;        System.out.println("  " + client + " acquires the lock, token " + token);        return token;    }    // Storage without fencing accepts any write.    static void writeUnfenced(String client, String value) {        storedValue = value;        System.out.println("  " + client + " writes: value = " + storedValue);    }    // Storage with fencing rejects a token older than one it has already accepted.    static void writeFenced(String client, long token, String value) {        if (token < highestTokenSeen) {            System.out.println("  " + client + " write REJECTED: token " + token + " < " + highestTokenSeen);            return;        }        highestTokenSeen = token;        storedValue = value;        System.out.println("  " + client + " writes with token " + token + ": value = " + storedValue);    }    public static void main(String[] args) {        System.out.println("Without fencing:");        storedValue = "initial";        acquireLock("A");        System.out.println("  A pauses for 40 s (garbage collection); its 30 s lease expires");        acquireLock("B");        writeUnfenced("B", "B's update");        writeUnfenced("A", "A's stale update");        System.out.println("  final value: " + storedValue);        System.out.println("With fencing:");        storedValue = "initial";        highestTokenSeen = 0;        long tokenA = acquireLock("A");        System.out.println("  A pauses for 40 s (garbage collection); its 30 s lease expires");        long tokenB = acquireLock("B");        writeFenced("B", tokenB, "B's update");        writeFenced("A", tokenA, "A's stale update");        System.out.println("  final value: " + storedValue);    }}
Code
Without fencing:  A acquires the lock, token 33  A pauses for 40 s (garbage collection); its 30 s lease expires  B acquires the lock, token 34  B writes: value = B's update  A writes: value = A's stale update  final value: A's stale updateWith fencing:  A acquires the lock, token 35  A pauses for 40 s (garbage collection); its 30 s lease expires  B acquires the lock, token 36  B writes with token 36: value = B's update  A write REJECTED: token 35 < 36  final value: B's update

Fencing requires the protected resource to cooperate. A database can do it with a conditional write (WHERE fencing_token < :token, much like optimistic concurrency in article 14). If the resource cannot check tokens (for example an external email provider), you cannot make the lock safe for correctness, and you should fall back to idempotency on that resource instead.

Implementing locks and leases

#

In your database

#

If the work already touches a relational database, it is often the simplest coordination service you have:

  • Row locks (SELECT ... FOR UPDATE) for short critical sections inside one transaction (article 14).
  • Advisory locks: PostgreSQL offers application-defined locks identified by a number, held per session or per transaction (PostgreSQL: Explicit Locking). Useful for "only one migration runner at a time".
  • A lease table for longer leadership, with a version column that doubles as a fencing token:
SQL
CREATE TABLE leases (    name        VARCHAR(100) PRIMARY KEY,   -- for example 'weekly-digest-scheduler'    owner       VARCHAR(100),    expires_at  TIMESTAMPTZ NOT NULL,    token       BIGINT NOT NULL DEFAULT 0);-- Acquire or renew: succeeds if the lease is free, expired, or already mine.UPDATE leasesSET owner = :me, expires_at = now() + interval '30 seconds', token = token + 1WHERE name = 'weekly-digest-scheduler'  AND (expires_at < now() OR owner = :me)RETURNING token;

Using the database's clock (now()) for expiry avoids depending on each client's clock. The returned token is the fencing token for writes.

In Redis

#

The basic single-instance recipe in the Redis documentation is SET resource_name my_random_value NX PX 30000: set the key only if it does not exist (NX), with a 30-second expiry (PX), and a random value unique to this client. Release it only if the value still matches, using an atomic compare-and-delete (newer Redis versions provide a command for this; older ones use a short Lua script), so a client never deletes a lock that has since passed to someone else (Distributed Locks with Redis).

Its limits, also described on that page:

  • With a single Redis primary and asynchronous replication, a failover can lose a just-acquired lock: client A locks on the primary, the primary crashes before replicating, the replica is promoted, and client B acquires the same lock.
  • The documentation describes Redlock, which acquires the lock on a majority of several independent Redis instances, and links to Kleppmann's critique and the Redis author's response. Its own disclaimer advises implementing fencing tokens for correctness and notes that Redis does not use a monotonic clock for key expiry.

A balanced conclusion: a Redis lock is a good efficiency lock. For correctness, use fencing with a resource that checks tokens, or a coordination service built on consensus.

Leader election

#

Many systems need exactly one node to act as leader: the scheduler that enqueues jobs, the primary database, the node that assigns partitions. Leader election chooses one node among several and replaces it when it fails. Microsoft's Leader Election pattern describes the idea: elect one instance to coordinate others, so tasks are not duplicated and conflicts are avoided (Leader Election pattern). The Amazon Builders' Library discusses how leader election is used inside AWS systems and its pitfalls, including the need for leaders to stop acting promptly when they lose leadership (Leader election in distributed systems).

The usual implementation is a lease on a well-known name in a strongly consistent store:

  1. Every candidate tries to acquire the lease (leader/weekly-digest).
  2. The winner is leader; it renews the lease regularly (for example every 10 seconds for a 30-second lease).
  3. Others watch the lease and try again when it expires.
  4. The leader stops leader work if it fails to renew in time, and uses the lease's fencing token on every protected write.

Important properties:

  • At most one effective leader is guaranteed only with fencing; without it, a paused old leader can still act briefly.
  • Failover time is roughly the lease duration plus detection. Shorter leases mean faster failover and more renewal traffic and false failovers.
  • The leader is a bottleneck if all work goes through it. Prefer leaders that coordinate (assign work, hold a schedule) over leaders that do all the work.

Consensus: the foundation

#

How does a "strongly consistent store" agree on who holds a lease, even when some of its own nodes fail? With a consensus algorithm: a protocol by which a group of nodes agrees on a value (or an ordered log of values) despite failures, as long as a majority of them are working and can communicate.

Raft in brief

#

Raft was designed to be understandable, and its authors' site collects the paper and visualisations (The Raft Consensus Algorithm). At interview depth:

  1. Terms and elections. Time is divided into numbered terms. If a follower hears nothing from a leader for a randomised timeout, it becomes a candidate, increments the term and asks the others for votes. A node votes for at most one candidate per term, and a candidate needs a majority of votes to become leader. At most one leader can win in a term.
  2. Log replication. Clients send commands to the leader, which appends them to its log and replicates them to followers. An entry is committed once a majority has stored it; committed entries are applied in order by every node.
  3. Safety. A candidate cannot win an election unless its log is at least as up to date as a majority's, so committed entries are never lost when leadership changes.

Paxos is the older, more famous family of consensus algorithms; many systems use variants of it or Raft. You do not need to implement either. You need to know what they give you and what they cost.

Why majorities

#

A majority of 2f + 1 nodes is f + 1. Any two majorities overlap in at least one node, so two partitions can never both elect a leader or both commit conflicting entries. That is what prevents split brain (article 15).

Cluster size

Majority

Failures tolerated

3

2

1

4

3

1

5

3

2

7

4

3

Even-sized clusters add cost without adding tolerance, which is why consensus clusters are usually 3 or 5 nodes. Every committed write needs a round trip to a majority, so spreading a consensus cluster across distant regions makes writes slow.

Coordination services

#

You rarely run consensus yourself. You use a service built on it:

  • Apache ZooKeeper, described in its documentation as a centralised service for maintaining configuration information, naming and distributed synchronisation, offering features such as ephemeral nodes that disappear when a client's session ends (ZooKeeper overview).
  • etcd, a strongly consistent, distributed key-value store built on Raft, used for configuration, service discovery and coordination, and known as Kubernetes' backing store (Why etcd).

Both provide leases, watches (be notified when a key changes) and revision numbers that can serve as fencing tokens. Keep them for small, critical metadata: who is leader, which node owns which partition, configuration. They are not general-purpose databases.

Worked example: Pixly's weekly digest

#

Bad: a cron entry on every server.

Better, with a lock: every server tries to acquire a lease row in PostgreSQL at 06:00 on Sunday; the winner sends all 2 million emails. Problems: one server does all the work (slow), and if it pauses or dies half-way, the next lease holder starts over and resends.

Best, with no lock in the hot path:

  1. A single scheduler (elected with a lease, or a managed scheduler service) enqueues one send-digest job per user (or per batch of users) into a queue, keyed by (user_id, week).
  2. Many workers process jobs in parallel (competing consumers).
  3. Each worker records digest_sent (user_id, week) with a unique constraint in the same transaction as queuing the email for sending, so a redelivered job does nothing.
  4. If the scheduler fails half-way, the new leader re-enqueues the week's jobs; the unique constraint makes duplicates harmless.

The "only one" requirement is now enforced per user by the database, and leadership is needed only for the cheap act of scheduling. Article 37 designs a general job scheduler on these ideas.

Failure modes

#

Failure

Effect

Mitigation

Lock holder pauses past its lease

Two holders act; stale writes

Fencing tokens checked by the resource

Lock service failover loses a lock (async replication)

Two holders

Consensus-based store; fencing

Leader keeps working after losing its lease

Duplicate or conflicting work

Stop on missed renewal; fencing

Lease too short

Frequent false failovers

Lease well above normal pause and network delay

Lease too long

Slow recovery after a real failure

Balance with renewal frequency

Consensus cluster loses its majority

No leader, no writes to coordination data

Odd sizes across failure domains; monitoring

Leader does all the work

Bottleneck and long recovery

Leader coordinates; workers do the work

Trade-offs

#

Choice

You gain

You pay

No lock (constraints, idempotency, partitioning)

Simplicity, no coordinator

Must find the right data-level rule

Redis lock

Fast, simple

Not safe for correctness without fencing

Database lease

Uses existing infrastructure; database clock

Load on the database; failover tied to it

etcd or ZooKeeper

Consensus-backed leases, watches, revisions

Another critical system to run

Short leases

Fast failover

More false failovers and traffic

Common mistakes

#
  • Reaching for a distributed lock where a unique constraint would do.
  • Using a lock for correctness without fencing.
  • Releasing a lock with a plain delete, removing someone else's lock.
  • Locks without expiry, which stay held forever after a crash.
  • Running consensus clusters with an even number of nodes, or stretched across distant regions without considering latency.
  • Building your own election protocol on heartbeats and timeouts.

In an interview

#
  • When "only one node should do X" comes up, first propose a lock-free design.
  • If a lock is truly needed, call it a lease, give its duration and renewal, and add fencing tokens if correctness depends on it.
  • For leader election, name a consensus-backed store (etcd, ZooKeeper) or the database, and say the leader stops when it cannot renew.
  • If asked how consensus works, give the Raft summary: terms, majority votes, log replication, commit on majority.

Exercises

#

Exercise 1

Lock or not?

#

For each, say whether you need a distributed lock and, if not, what to use instead:

  1. Ensuring a coupon code can be redeemed only once.
  2. Running a database schema migration exactly once during a deployment to 30 servers.
  3. Making sure only one instance of a Kafka-like consumer processes partition 7 at a time.
  4. Regenerating an expensive cached report when it expires, without 50 servers regenerating it at once.

Exercise 2: Review the lock

#
Code
acquire():  SET job-lock "locked"           (no NX, no expiry)release():  DEL job-lock

List every problem and rewrite it.

Exercise 3: Leader election with a table

#

Design leader election for a "partition assigner" component using a PostgreSQL lease table. Give the lease duration and renewal period, explain how followers detect a dead leader, and how writes made by the leader are fenced.

Exercise 4: Cluster sizing

#

You need a coordination service that keeps working if any one availability zone (out of three) fails completely. How many nodes, and how would you place them? What if you also want to survive one more node failure during maintenance?

Show solutionHide solution
  1. No lock. A conditional update (UPDATE coupons SET redeemed_by = ? WHERE code = ? AND redeemed_by IS NULL) or a unique constraint on redemptions.
  2. A lock (efficiency and safety) is reasonable, but simpler: run migrations as a separate deployment step executed once (a job in the pipeline) before the new servers start. If migrations must run from the app, use a database advisory lock so only one runner proceeds, and make migrations idempotent (tracked in a migrations table).
  3. Not a separate lock: the broker's consumer-group protocol assigns each partition to one consumer in the group. Make processing idempotent for the brief overlaps during rebalancing.
  4. An efficiency lock is fine: a short Redis SET NX PX lock per report key, or request coalescing with stale-while-revalidate (article 09). If two servers occasionally regenerate it, nothing breaks.

Exercise 2

Show solutionHide solution

Problems:

  • No NX: acquire always succeeds, so everyone "gets" the lock. There is no mutual exclusion at all.
  • No expiry: if the holder crashes, the lock is held forever.
  • Same value for everyone: release cannot tell whose lock it is, so a slow client can delete a lock that now belongs to someone else.
  • No fencing: even when fixed, a paused holder can keep acting after expiry.

Rewrite:

Code
acquire():  token = random unique value            SET job-lock token NX PX 30000        -> succeeds only if free; expires in 30 s            if not set: someone else holds it; retry later with jitterwork:       renew before expiry if the work is long (extend only if the value is still mine)release():  atomically delete job-lock only if its value equals token

If the work's correctness depends on exclusivity, also obtain a fencing token (for example from a database sequence or a consensus store) and have the protected resource reject older tokens. Otherwise, make the work idempotent.

Exercise 3

Show solutionHide solution
  • Lease row leases(name = 'partition-assigner', owner, expires_at, token).
  • Duration 30 s, renewal every 10 s (three chances to renew before expiry). Renewal uses UPDATE ... WHERE name = ? AND owner = :me AND expires_at > now(); if it updates 0 rows, the leader has lost the lease and immediately stops assigning partitions.
  • Followers attempt the acquire statement (succeeds only if expires_at < now()) every few seconds, with jitter. A dead leader is "detected" simply by its lease expiring; no heartbeat protocol is needed beyond renewals.
  • Fencing: every acquire increments token. The leader writes assignments to an assignments table with UPDATE ... SET ..., leader_token = :token WHERE leader_token <= :token (or a check against a stored highest token). A deposed leader's writes carry an older token and change nothing.
  • All expiry checks use the database's now(), not client clocks.

Exercise 4

Show solutionHide solution

One zone failure: use 3 nodes, one per zone. Losing any zone removes one node and leaves 2 of 3, which is a majority, so the service keeps working.

One zone failure while one other node is down for maintenance: count the worst case. The cluster must still have a majority after losing an entire zone plus one more node.

  • 3 nodes (1-1-1): zone + 1 = 2 lost, 1 of 3 left. Not a majority.
  • 5 nodes (2-2-1): lose a 2-node zone + 1 = 3 lost, 2 of 5 left. Not a majority.
  • 7 nodes (3-2-2): lose the 3-node zone + 1 = 4 lost, 3 of 7 left. Not a majority.
  • 9 nodes (3-3-3): zone + 1 = 4 lost, 5 of 9 left. A majority.

So with only three zones you need 9 nodes, 3 per zone, and every write must reach 5 of them, which is slower and costlier. The alternative is more failure domains: with 5 zones (or racks and zones treated as separate domains), 5 nodes placed one per domain survive a domain failure plus one more node (3 of 5 remain). In practice many teams run 5 nodes across three zones, which tolerates any two individual node failures, and simply avoid maintenance during a zone incident. The general lesson: count failures in terms of failure domains, not nodes, and remember that every added node adds write latency.

Where to go next

#

Sources & further reading

  1. martin.kleppmann.com
  2. redis.io
  3. raft.github.io
  4. zookeeper.apache.org
  5. etcd.io
  6. learn.microsoft.com
  7. aws.amazon.com
  8. postgresql.org
  • Guide · Technology

    Start Here: Your Roadmap from Zero to Job-Ready DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages