InterviewKata
System design · Technology guide T11 · Builds on Module 21

Coordination services and distributed locks

"Only one server may run the nightly billing job." "If the leader dies, another must take over within seconds, and never two at once." These sound like one-line requirements, and they're among the hardest to get right, because servers pause, clocks drift and networks split. Coordination services exist so you don't build consensus yourself. This guide explains how they work, the products, and when a simpler lock is enough, with a chooser and code you can run.

A 2-minute conversation: Sam asks, Priya explains
0:00 / 2:23

1. What coordination services do

A small cluster (three or five nodes) agrees on every change with a consensus protocol (Module 21), so it stays correct when a minority fails. On top of that agreement it offers a few building blocks:

Building blockWhat it gives youUsed for
Consistent key-value storeSmall values everyone reads the same wayConfiguration, cluster membership, feature switches
Leases and ephemeral keysA key that disappears when its owner stops renewing itDetecting dead servers; locks that free themselves
WatchesA notification when a key changesReacting at once to a new leader or new config
Compare-and-set and revisionsChanges that succeed only if nothing changed since you readLeader election, fencing tokens

2. How leader election works

3. The products

Coordination · open source (Apache)

Apache ZooKeeper

A tree of small nodes (znodes) replicated with its own consensus protocol, ZAB. Ephemeral znodes vanish when the client's session ends; sequential znodes get increasing numbers, the basis of locks and elections; watches notify on change. Long used by Hadoop, HBase and Kafka; Kafka 4.0 (2025) removed ZooKeeper in favour of its built-in KRaft.

Best for: systems that already depend on it, and the Java ecosystem.

Coordination · open source (CNCF)

etcd

A key-value store replicated with Raft, with leases, watches, transactions and a revision number on every change. It stores all of Kubernetes' state.

Best for: cloud-native coordination, and anything already on Kubernetes.

Service discovery and KV · BSL 1.1

HashiCorp Consul

Raft-replicated service catalogue, health checks, a key-value store with sessions for locks, and a service mesh. Licensed under the Business Source License since August 2023, so not OSI open source.

Best for: service discovery across VMs and data centres.

Built into Kubernetes

Kubernetes Leases

A Lease object, updated through the API server (and so stored in etcd), that client libraries use for leader election among replicas of a controller or service.

Best for: "one active replica" inside a Kubernetes cluster, with no extra system.

Database-backed locks · AWS and others

DynamoDB conditional writes

A lock is an item written only if absent or expired, with a lease time and a version number; AWS publishes a lock client built this way. Spanner, Firestore or PostgreSQL (advisory locks, or a row with SELECT … FOR UPDATE) work similarly.

Best for: locks when you already have a strongly consistent database.

Cache-backed locks

Redis and Valkey locks

SET key value NX PX 30000 takes a lock with an expiry. Fine for avoiding duplicate work, but a failover can lose the lock, and the multi-node Redlock algorithm is debated for correctness, so don't use it where a double holder corrupts data.

Best for: best-effort locks (deduplicating jobs, rate-limiting work).

4. Differences at a glance

OptionAgreementLock frees itself byFencing tokenRuns as
ZooKeeperZAB consensusSession expiry (ephemeral znode)Sequential znode number, zxid3 or 5 servers
etcdRaftLease expiryKey revision3 or 5 servers; inside Kubernetes
ConsulRaftSession invalidationLock indexServer cluster plus agents
Kubernetes Leaseetcd underneathLease durationResource version, holder transitionsAny Kubernetes cluster
DynamoDB lockStrongly consistent conditional writesLease time on the itemA version you incrementManaged
Redis lockNone (single primary, async replicas)Key expiryNot built inYour cache

5. When to choose which, and why

  1. Do you need a lock at all? An idempotent operation, a unique constraint, or a single queue consumer often removes the need.
  2. Only efficiency at stake (two servers doing the same work is wasteful, not harmful)? A Redis lock with an expiry is fine.
  3. Correctness at stake (two holders would corrupt data)? Use a consensus-backed store or a strongly consistent database, and fencing tokens checked by the resource you protect.
  4. On Kubernetes? Use Lease-based leader election from the client libraries; it's already there.
  5. Building infrastructure that many services coordinate through (membership, config, discovery)? etcd, ZooKeeper or Consul.

Try it: choose for your requirements

6. Why locks need fencing tokens, in code

Client A takes a lock with a 10-second lease, then pauses for 15 seconds (a garbage-collection pause, a stalled VM). Its lease expires and B takes the lock. When A wakes, it still believes it holds the lock and writes. Without fencing, A's stale write overwrites B's. With fencing, each grant carries a higher token, and the storage rejects any write with a token lower than one it has already seen. The program was run in all three languages; each prints the output below it.

class LockService:
    """A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token."""

    def __init__(self, lease):
        self.lease, self.holder, self.expires, self.token = lease, None, 0, 32

    def acquire(self, client, now):
        if self.holder and now < self.expires:
            return None                          # someone else holds a live lease
        self.token += 1
        self.holder, self.expires = client, now + self.lease
        return self.token


class AccountStore:
    def __init__(self, fenced):
        self.fenced, self.value, self.highest = fenced, None, 0

    def write(self, client, token, value):
        if self.fenced and token < self.highest:
            return f"{client} write rejected (token {token} < {self.highest})"
        self.highest = max(self.highest, token)
        self.value = value
        return f"{client} wrote {value!r} with token {token}"


for fenced in (False, True):
    lock, store = LockService(lease=10), AccountStore(fenced)
    print("with fencing tokens:" if fenced else "without fencing:")
    a = lock.acquire("A", now=0)
    print(f"  t=0   A gets the lock, token {a}; then pauses for 15 s (garbage collection)")
    b = lock.acquire("B", now=12)
    print(f"  t=12  A's lease has expired; B gets the lock, token {b}")
    print("  t=13 ", store.write("B", b, "balance=90"))
    print("  t=15 ", store.write("A", a, "balance=100"), "<- A wakes, still thinks it holds the lock")
    print(f"  final value: {store.value!r}")
// A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token.
class LockService {
  holder: string | null = null;
  expires = 0;
  token = 32;
  constructor(readonly lease: number) {}

  acquire(client: string, now: number): number | null {
    if (this.holder && now < this.expires) return null; // someone else holds a live lease
    this.token++;
    this.holder = client;
    this.expires = now + this.lease;
    return this.token;
  }
}

class AccountStore {
  value: string | null = null;
  highest = 0;
  constructor(readonly fenced: boolean) {}

  write(client: string, token: number, value: string): string {
    if (this.fenced && token < this.highest) return `${client} write rejected (token ${token} < ${this.highest})`;
    this.highest = Math.max(this.highest, token);
    this.value = value;
    return `${client} wrote '${value}' with token ${token}`;
  }
}

for (const fenced of [false, true]) {
  const lock = new LockService(10), store = new AccountStore(fenced);
  console.log(fenced ? "with fencing tokens:" : "without fencing:");
  const a = lock.acquire("A", 0)!;
  console.log(`  t=0   A gets the lock, token ${a}; then pauses for 15 s (garbage collection)`);
  const b = lock.acquire("B", 12)!;
  console.log(`  t=12  A's lease has expired; B gets the lock, token ${b}`);
  console.log("  t=13 ", store.write("B", b, "balance=90"));
  console.log("  t=15 ", store.write("A", a, "balance=100"), "<- A wakes, still thinks it holds the lock");
  console.log(`  final value: '${store.value}'`);
}
public class Main {
    // A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token.
    static class LockService {
        final int lease;
        String holder = null;
        int expires = 0, token = 32;
        LockService(int lease) { this.lease = lease; }

        Integer acquire(String client, int now) {
            if (holder != null && now < expires) return null; // someone else holds a live lease
            token++;
            holder = client;
            expires = now + lease;
            return token;
        }
    }

    static class AccountStore {
        final boolean fenced;
        String value = null;
        int highest = 0;
        AccountStore(boolean fenced) { this.fenced = fenced; }

        String write(String client, int token, String value) {
            if (fenced && token < highest) return client + " write rejected (token " + token + " < " + highest + ")";
            highest = Math.max(highest, token);
            this.value = value;
            return client + " wrote '" + value + "' with token " + token;
        }
    }

    public static void main(String[] args) {
        for (boolean fenced : new boolean[] {false, true}) {
            LockService lock = new LockService(10);
            AccountStore store = new AccountStore(fenced);
            System.out.println(fenced ? "with fencing tokens:" : "without fencing:");
            int a = lock.acquire("A", 0);
            System.out.println("  t=0   A gets the lock, token " + a + "; then pauses for 15 s (garbage collection)");
            int b = lock.acquire("B", 12);
            System.out.println("  t=12  A's lease has expired; B gets the lock, token " + b);
            System.out.println("  t=13  " + store.write("B", b, "balance=90"));
            System.out.println("  t=15  " + store.write("A", a, "balance=100") + " <- A wakes, still thinks it holds the lock");
            System.out.println("  final value: '" + store.value + "'");
        }
    }
}
without fencing:
  t=0   A gets the lock, token 33; then pauses for 15 s (garbage collection)
  t=12  A's lease has expired; B gets the lock, token 34
  t=13  B wrote 'balance=90' with token 34
  t=15  A wrote 'balance=100' with token 33 <- A wakes, still thinks it holds the lock
  final value: 'balance=100'
with fencing tokens:
  t=0   A gets the lock, token 33; then pauses for 15 s (garbage collection)
  t=12  A's lease has expired; B gets the lock, token 34
  t=13  B wrote 'balance=90' with token 34
  t=15  A write rejected (token 33 < 34) <- A wakes, still thinks it holds the lock
  final value: 'balance=90'

7. Say it in the interview

Two-sentence answers
  • "How do you make sure only one scheduler runs?" Leader election through a lease in etcd (a Kubernetes Lease on Kubernetes): the leader renews it every few seconds, and a standby takes over when it expires. Every action the leader takes carries the lease's fencing token, so a paused old leader can't act.
  • "Can you use Redis for the lock?" For avoiding duplicate work, yes, with an expiry. Not where two holders would corrupt data, because a failover can lose the lock; there I'd use a consensus-backed store and fencing.
  • "Why not just a timeout?" A process can pause longer than any timeout and wake up believing it still holds the lock; only the protected resource checking a token stops it.

8. Common mistakes

Locks without fencingLeases expire while a holder is paused. The resource must reject stale holders.
Redis locks for correctnessAsynchronous replication can lose a lock on failover. Use them only where a double holder is harmless.
Putting data in the coordination serviceetcd and ZooKeeper are for small, critical values; big or busy data slows every election.
An even number of nodesFour nodes tolerate one failure, the same as three, at more cost. Use three or five.
Leases shorter than pausesToo-short leases cause leadership flapping during normal garbage collection; too long delays failover. Measure.

9. Check yourself

A lock holder pauses for longer than its lease. What prevents its stale write?

Any timeout can be exceeded; the resource must reject older tokens.

Why do coordination clusters use three or five nodes, not four?

A majority of four is three, so only one may fail.

A cron job on three replicas should run once; running twice just wastes a minute. A reasonable lock?

When a double holder is harmless, a best-effort lock is fine.

Inside Kubernetes, how do controllers usually elect a leader?

Client libraries implement leader election with Leases, stored in etcd.

10. Sources and further reading

Reviewed on 4 November 2026.

SourceUsed for
etcd documentationLeases, watches, revisions
ZooKeeper recipesLocks and leader election with ephemeral sequential znodes
Kubernetes: LeasesLeader election inside clusters
Martin Kleppmann: How to do distributed lockingFencing tokens and the Redlock debate
Module 21: Consensus and coordinationRaft, quorums and leases