LearnThe interviewsstep 8

System design: going deeper

  • 48 min
  • 9 stops
  • 11 diagrams

Lesson 8 keeps the payments ledger (Design 3) and notifications (Design 6) in full. These five designs moved here whole, with their figures, code and Operate-it blocks; a one-page card for each is in "Design cards". Section names such as "consistency" and "rate limiter" refer to lesson 8. Every volume, price and count is invented.

Design 1: a URL shortener

Goal. By the end of this section you can design a service that turns a long URL into a short code and redirects the code, justify each box with a number, and name the one product decision (301 or 302) that changes what the service can measure.

Requirements (agreed in the first five minutes). Create a short code for a long URL; redirect a code to its URL; optionally take a custom alias and an expiry; delete a link. A code, once issued, is never reused for another URL. Redirect p99 under 100 ms is an assumption and is said as one. Out of scope, said aloud: click analytics, abuse scanning, login.

Estimate. Assumption: 100 million new links a month and a hundred reads per write. Computed: 38.6 writes a second average, 116 at peak (factor three); 3,858 reads a second average, 11,574 at peak. Five years of links is 6.0 billion rows; at an assumed 500 bytes a row that is 3.0 TB. So: reads dominate by a hundred to one, a cache in front pays for itself, and the data is small enough for a few shards.

Interface and data. POST /v1/urls takes the long URL and optional alias and expiry and returns 201 with the code; a client that retries sends an idempotency key. GET /{code} returns a redirect with a Location header. DELETE /v1/urls/{code} returns 204. One table, urls(code, long_url, owner_id, created_at, expires_at), keyed and sharded by code.

The architecture figure shows every box. The CDN, the rate limiter and the expiry sweeper are dashed because they are optional extensions, not needed to meet the numbers.

ClientLBApp tierstatelessRate limiterID blockserviceCache (Redis)key = codeKV storesharded by codereplicatedExpirysweeperCDNcheckget/fill/delmiss, writeblockdrop301 onlyDashed boxes are optional. Reads (3,858/s average) go cache first, store on a miss.
Figure A8.1. URL shortener, boxes and arrows. Reads go to the cache and fall to the sharded store; creates take an ID from a block service and write the store, then fill the cache.

The code. Take a number from a counter and write it in base 62 (digits and upper and lower case letters). Python gives the key space: 6 characters hold 56.8 billion codes, 7 hold 3.52 trillion, 8 hold 218 trillion. At 6.0 billion links in five years, six characters would be about 11% full and seven are very comfortable. Sequential codes can be enumerated: anyone can walk the codes in order and read every link, including private ones. If links must not be guessable, use random 7-character codes from a secure random source with an insert-if-absent check (at 6.0 billion links out of 3.52 trillion codes, one random guess hits a live link with probability 0.17%, so rate-limit repeated misses), or pass the counter through a keyed permutation so consecutive IDs look unrelated and still never collide. Either costs the plain ordering of IDs. Do not hash the long URL to make the code: two users shortening the same address would share a code, and any collision needs a retry path. Nor hand out one number per request from one counter: an ID block service gives each app server a block, say 1,000 numbers, so a short outage of it does not stop creates.

The redirect, step by step. The next figure follows one redirect. The row is checked for expiry on every read, not only by a sweeper, so an expired link cannot be served in the gap before the sweep.

ClientApp (behind LB)CacheKV store1 GET /abc2 get(abc)3 URL (hit) or miss4 read(abc), on miss5 row, or none6 set(abc), on miss7 302 Location: URLNo row, or expires_at already past: 404 or 410, never a redirect.Steps 4 to 6 happen on a miss only; a hit is steps 1, 2, 3 and 7.
Figure A8.2. The redirect path. A hit is four messages; a miss adds a store read and a cache fill. The row is checked for expiry on every read.

Components and failures (SDE-2). A stateless app tier behind the load balancer; a Redis cache keyed by code, with a time-to-live of the lesser of a day and the link's expiry; a key-value store sharded by code on a consistent-hash ring, replicated. Cache down: every read goes to the store, and the cache can die at the peak, so size the store for 11,574 a second, not the 3,858 average, and say so; the six shards below are sized for exactly that. ID service down: creates fail, redirects keep working, and prefetched blocks cover a short outage. A viral link: one code takes most reads, the cache absorbs it, and a small in-process cache on each app server stops that one key from loading the cache node. Delete: the app deletes the row, then the cache key; a reader that misses just after reads a replica that still has the row. The mitigation is a short TTL and writing the delete as a tombstone, a marker that replicas honour.

INVENTED example 1 min

At minute 12 an invented candidate is asked: "Should the redirect be 301 or 302?" They answer: "A 301 says permanent, so browsers and CDNs may keep it and reuse it without asking us again. That is cheap, and it means I lose those clicks for analytics and cannot change or delete the target for people who already cached it. A 302 is not reused unless I add explicit cache headers, in which case I choose how long. Caching behaviour varies by client, so either way I set Cache-Control myself. Since analytics is out of scope today I would pick 302 with a short max-age, and say that a 301 is the cost saving if we later decide not to count." (This is the author's simplified reading of HTTP caching, in which a 301 is cacheable by default and a 302 only with explicit freshness headers; the standards text was not opened for this lesson.)

Senior depth (adds). Consistency: a create must be visible to the next redirect in the same region. R + W > N does not give that (lesson 8, consistency), so return the code only after the shard's primary has committed it, and when a replica read misses, check the primary before answering 404; cache a 404 for a few seconds at most. A stale replica then costs one extra primary read and never a wrong "not found". Abuse: a token bucket on creates per user (lesson 8, rate limiter) and a blocklist check on create. Capacity: if one shard serves 2,000 reads a second (an assumption, not a measured figure), the 11,574 peak needs 11,574 / 2,000 = 5.8, so six shards, each a primary and two replicas: 18 nodes. At ten times the traffic, reads reach 115,741 a second at peak; the cache has to hold the working set, and the ID block service is the next bottleneck.

Operate it · Design 1: URL shortener
  • Roll out and roll back. Deploy the app tier to 1% of instances, compare redirect error rate and p99 with the rest for 15 minutes (the canary compares against a control), then 25%, then all. A schema or key-space change goes behind a flag. Roll back by re-pointing the load balancer at the previous image: minutes, no data change.
  • Blast radius. The code counter is the dangerous part: hand each app instance a block of 1,000 codes, so a bad counter deploy can burn at most one block per instance and never reuses a code. One shard lost takes 1/N of the codes, not all of them; the cache absorbs reads for popular links meanwhile.
  • Watch. Page on redirect error rate above 1% for 5 minutes. Ticket on cache hit rate under 90% (a cold cache doubles store reads) and on create failures. Black-box probe: a synthetic link fetched every 30 seconds from outside.
  • Monthly cost line. Assumed: 18 store nodes, six shards of three copies (Rs 7.2 lakh), 8 app servers (Rs 96,000), 3 cache nodes (Rs 48,000), redirect egress at an assumed 500 bytes a redirect (headers plus a small body): 3,858 a second x 2,592,000 seconds = 10.0 billion redirects x 500 B = 5.0 TB at Rs 6.4 per GB (Rs 32,000). Total about Rs 8.96 lakh a month; the store nodes dominate. Two copies instead of three is about Rs 6.56 lakh, and gives up surviving a second loss during a repair. Storage is inside the node price: 9 TB with copies over 18 nodes is 0.5 TB a node, under the 1 TB the price includes.

Next stop: Design 2: a news feed, 6 min

Design 2: a news feed

Goal. By the end of this section you can design a feed, compute why pushing every post to every follower is the bottleneck, and explain the push/pull split that follows from the numbers.

Requirements. Authors post text and media; followers see posts newest first (ranking is out of scope, said aloud). Feed read p99 under 300 ms is an assumption; the Dynamo paper's 300 ms at the 99.9th percentile is its own service target, not a rule. A user's own post shows in their feed at once (read-your-writes). Delivery to followers within seconds is acceptable.

Estimate. Assumptions: 300 million daily users, five feed reads each, 50 million posts a day, 200 followers per author on average. Computed: feed reads 17,361 a second average, 52,083 at peak; post writes 578.7 a second (50,000,000 / 86,400); if every post is pushed to every follower, 578.7 x 200 = 115,741 writes a second on the mean and 347,222 at a peak factor of three. Both are too kind. Posting peaks harder than three times (events and time zones bunch it): 578,704 a second at 5 times, 1,157,407 at 10 (the author's what-ifs). And the mean hides the tail, because follower counts are heavy-tailed. In an invented split the top 1% of authors hold 60% of follow edges: 12,000 followers each (0.6 x 200 / 0.01), everyone else about 81 (0.4 x 200 / 0.99 = 80.8). Pushing only the 99% costs 578.7 x 0.99 x 80.8 = 46,296 writes a second on the mean (the same as 578.7 x 200 x 0.4, since the 99% hold 40% of the edges), 138,889 at three times and 462,963 at ten times, while one post by a 20-million-follower account would be 20,000,000 writes, 1,000 seconds at 20,000 a second. Timeline memory: 800 post IDs at 8 bytes is 6.4 KB a user, 1.92 TB for 300 million users, but that is raw payload only. In-memory lists carry per-element overhead, taken here as 1.5 to 2.5 times the payload, and 2 to 3 copies are kept for failover, so 1.92 x 1.5 x 2 = 5.8 TB at the low end and 1.92 x 2.5 x 3 = 14.4 TB at the high end: about 6 to 15 TB, an author's estimate. At an assumed 64 GB of usable memory per node that is 90 to 225 nodes, and keeping lists only for users active in the last 30 days (state it) shrinks it in proportion. So: the bottleneck is the fan-out write under peak and skew, not the post write, and the timeline cache is a real cost line, not a footnote.

The decision the numbers force. Push (fan-out on write) for ordinary authors: when they post, a worker adds the post ID to each follower's timeline list, so a read is one list fetch. Pull (fan-out on read) for accounts with very many followers: their posts stay in their own list and are merged in when a follower reads. The threshold, for example 10,000 followers, is an assumption and can move. Workers also read the follower list a page at a time, so one large author does not hold a worker for minutes (the author's reading of the design, not a published figure).

Interface and data. POST /v1/posts returns 201 and a post_id. GET /v1/feed?cursor=...&limit=20 returns items and a next cursor. The cursor is an opaque position (post ID, or time plus ID), never an offset, so a new post arriving mid-scroll does not shift pages. PUT and DELETE /v1/follows/{user_id} manage follows. Tables: posts(post_id, author_id, created_at, text, media_key) sharded by post_id; follows(follower_id, followee_id) with a second index on followee for fan-out; and the timeline, a list of at most 800 post IDs per user in Redis (the cap is an assumption).

The architecture figure draws both paths. The write path is the top chain, ending in the timeline cache; the read path is the bottom chain; media goes around both, direct to blob storage, and is read through a CDN.

ClientLBPost serviceQueueFan-outworkersFollow storeTimeline cache800 IDs a userFeed serviceRankingPost storeby post_idBlob storeCDNwrite postpost.createdreadpush IDhydrate by IDsigned-URL upload
Figure A8.3. News feed, boxes and arrows. The write path ends in per-user timeline lists; the read path reads a list and fetches post bodies by ID. Dashed boxes are optional.

Failures (SDE-2). Fan-out lag: a follower sees a post late. The requirements allow seconds; measure the lag as the SLO. A duplicate from the queue pushes the same post ID twice; the timeline list dedupes by post ID. A deleted post still in timelines: the read path hydrates by ID and filters what no longer exists. Loss of the timeline cache: rebuild a user's list by pulling, expensive once, then pushed again. The author's own post is written to the author's list synchronously, which is how read-your-writes holds without waiting for the queue.

The next figure shows the decision itself, and the read path that makes it work.

Write pathAuthor postsPost store+ queue messageFan-out workerfollowers > 10,000?Push post ID into eachfollower timeline listSkip the push; the poststays in author listnoyesRead pathFeed requestRead my list, add the listsof celebrities I followMerge, sort, hydratebodies by IDPush for all: 115,741 writes/s on average; one 20M-follower post is 20,000,000 writes.
Figure A8.4. News feed data flow. Ordinary authors are pushed into follower timelines when they post; accounts above the threshold are merged in at read time. The 10,000 threshold is an assumption.
INVENTED example 1 min

An invented candidate sets the threshold at 10,000 followers, then says: "If it is set too low I do a read-time merge with many lists for every feed read, which costs latency; too high and one post floods the queue. I will start at 10,000, watch the queue lag and the merge latency, and move it. I would also build a switch, because a user who crosses the line must move from push to pull without losing items."

Senior depth (adds). The deep dive is the threshold, as in the example. Consistency: a cursor must survive a new post landing mid-scroll, and a timestamp tie is broken by post ID so the sort is stable. A celebrity's post body is read by millions, so cache bodies by post ID and serve media through the CDN. At ten times the load, peak feed reads reach 520,833 a second; the timeline cache is the scaling question, and fan-out workers scale by partitioning the queue. A ranking service, if added, is a separate call from the feed service; say what happens when it is down (fall back to newest first).

Operate it · Design 2: news feed
  • Roll out and roll back. The ranking or merge logic ships behind a flag per user cohort (1%, then 10%), because a feed that is subtly wrong does not throw an error. Compare feed read p99 and the share of sessions that scroll past item 5. Roll back by turning the flag off; the timeline cache is unchanged so nothing needs rebuilding.
  • Blast radius. Fan-out workers are the wall: a stuck worker delays pushes for the authors on its partitions, not reads for everyone. A celebrity post never fans out (pull path), so one account cannot flood the queue. A bad cache node loses the timelines it holds and those users fall back to a rebuild from the post store.
  • Watch. Page on queue lag above 30 seconds (the promise is "within seconds"). Ticket on merge latency p99 and on timeline cache hit rate. Count "own post missing from own feed": it is read-your-writes failing, and users notice at once.
  • Monthly cost line. From the estimate above: the timeline cache is 6 to 15 TB once overhead and copies are counted (1.9 TB is the raw payload only), so 90 to 225 nodes of 64 GB at an assumed Rs 32,000, Rs 28.8 lakh to Rs 72 lakh; plus 100 app and worker servers (Rs 12 lakh), 20 store nodes (Rs 8 lakh) and the queue (Rs 2.4 lakh). About Rs 51.2 lakh to Rs 94.4 lakh a month before media egress, which is the CDN bill and is quoted separately. The timeline cache dominates; keeping lists only for users active in the last 30 days shrinks it in proportion, and that is the first cost lever to pull.

Next stop: Design 4: a chat service, 8 min

Design 4: a chat service

Goal. By the end of this section you can design one-to-one and group chat with delivery states, per-conversation ordering, presence and offline sync, and say why the hard parts are the connection tier and the sequence number, not the message store.

Requirements. Send text to a person or to a group of up to 256 members (an assumption). Each message shows sent, delivered and read. Everyone sees one conversation in the same order. A user who was offline gets what they missed on reconnect. Presence shows online or last seen. Out of scope, said aloud: media (a message that carries a blob key), end-to-end encryption, search, voice.

Estimate. Assumptions, all invented: 500 million daily users, 40 messages sent each a day, 20% of messages go to groups averaging 10 members, and 20% of users connected at the busiest hour. Computed: 20 billion messages a day is 231,481 a second on average and 694,444 at a peak factor of three. A one-to-one message is one delivery and a group message nine, so the mean is 0.8 x 1 + 0.2 x 9 = 2.6 deliveries a message: 52 billion a day, 601,852 a second on average, 1,805,556 at peak. Connected sockets at peak: 100 million. At an assumed 100,000 sockets and about 10 KB of memory each, a gateway holds 1 GB for sockets, so 1,000 gateways. At an assumed 200 bytes a message (body and metadata) storage is 4 TB a day, 120 TB for 30 days kept on the server and 360 TB with three copies. If every delivery produced a delivered event and a read event, that would add 1.2 million events a second on average. So: the load is 100 million open connections and 1.8 million deliveries a second, not the store (139 MB a second inbound at peak); receipts must be cumulative and batched, and order must be settled without a global lock.

Interface and data. Over a WebSocket: send(conversation_id, client_msg_id, body), ack(conversation_id, kind, up_to_seq) with kind delivered or read, and sync(conversation_id, last_seq). Plain HTTP is for login and paging old history. Tables: messages(conversation_id, seq, sender, client_msg_id, body, sent_at), partitioned by conversation_id (plus a month bucket for very busy groups) and sorted by seq; members(conversation_id, user_id); cursors(conversation_id, user_id, delivered_seq, read_seq). A registry maps user to gateway, with an expiry.

The architecture figure shows where each job lives. The gateway only holds sockets; the chat service is stateless and does the appending; the registry tells it which gateway to push through.

DevicesWebSocketLBGateway tier100,000sockets eachConnectionregistryuser to gatewayChat servicestatelessConversationlog, byconversation_idFan-out queuePush serviceoffline devicesregistersendloglookupgroupsdeliverpushPeak (assumptions): 100 million sockets, 1.8 million deliveries a second.
Figure A8.5. Chat, boxes and arrows. A gateway holds the sockets; the registry says which gateway holds a user; the conversation log assigns the order; offline users get a push and sync later.

One message, and its states. Figure A8.6 follows a message. The chat service appends it to the conversation log and assigns the next sequence number (step 2), then tells the sender "sent" (step 3). Pushing to the receiver is separate: if the registry shows a gateway, the frame goes there (steps 4 and 5); if not, a push notification wakes the device and it syncs later. The state words are cursors: "delivered up to 41" covers every message up to 41. Group state is then cheap: a message is delivered to all when the smallest recipient cursor has passed it. For a 256-member group with 100 messages a day, per-message per-member rows would be 25,600 rows a day; cursors are 256 x 2 numbers in total.

The ordering rule is one writer per conversation: the partition that owns the conversation assigns the next number, and client clocks are never used. A client that sees 40 and then 42 asks for the gap with sync. A retry carries the same client_msg_id and receives the same number. The block below is that logic, run with java --source 21 ConversationLog.java with checks for a retry, a late old acknowledgement, the slowest-cursor rule and gap sync.

SenderChat serviceand logReceivergatewayReceiver1 send(client_id)2 append, seq = 413 ack: sent4 push seq 415 frame 416 ack: delivered up to 417 delivered (two ticks)8 read up to 419 read (blue ticks)
Figure A8.6. One message through the chat service. The order is assigned at step 2; delivered and read are cursors the receiver moves forward (steps 6 and 8), not flags on single messages.
Code, 65 lines
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class ConversationLog {
  record Message(long seq, String clientMsgId, String sender, String body) {}

  private final List<Message> log = new ArrayList<>();
  private final Map<String, Long> seqByClientId = new HashMap<>();
  // member -> highest seq acknowledged
  private final Map<String, Long> delivered = new HashMap<>();
  private final Map<String, Long> read = new HashMap<>();

  // One writer per conversation: the partition owner assigns seq.
  synchronized long append(String clientMsgId, String sender, String body) {
    Long seen = seqByClientId.get(clientMsgId);
    if (seen != null) return seen;             // a retry gets the same seq
    long seq = log.size() + 1;
    log.add(new Message(seq, clientMsgId, sender, body));
    seqByClientId.put(clientMsgId, seq);
    return seq;
  }

  // A cursor only moves forward: "delivered up to seq n" covers 1..n.
  synchronized void ack(Map<String, Long> cursors, String member, long seq) {
    cursors.merge(member, seq, Math::max);
  }
  synchronized void delivered(String m, long seq) { ack(delivered, m, seq); }
  synchronized void read(String m, long seq) { ack(read, m, seq); }

  // A reconnecting device sends its last seq and gets the gap.
  synchronized List<Message> after(long lastSeq) {
    return List.copyOf(log.subList((int) lastSeq, log.size()));
  }

  // The sender sees "delivered to all" when the slowest cursor passes seq.
  synchronized boolean deliveredToAll(List<String> members, long seq) {
    return members.stream()
        .allMatch(m -> delivered.getOrDefault(m, 0L) >= seq);
  }

  public static void main(String[] args) {
    var c = new ConversationLog();
    long a = c.append("c1", "ann", "hi");
    long b = c.append("c2", "ann", "yo");
    check(a == 1 && b == 2, "seq assigned in order");
    check(c.append("c1", "ann", "hi") == 1, "retry gets same seq");
    check(c.after(0).size() == 2, "log has two messages");
    c.delivered("bob", 2);
    c.delivered("bob", 1);               // late old ack
    check(c.delivered.get("bob") == 2, "cursor never moves back");
    var members = List.of("bob", "cy");
    check(!c.deliveredToAll(members, 2), "slowest cursor blocks");
    c.delivered("cy", 2);
    check(c.deliveredToAll(members, 2), "all passed seq 2");
    check(c.after(1).size() == 1 && c.after(1).get(0).seq() == 2,
        "gap sync returns the missing message");
    System.out.println("conversation ok: log=" + c.after(0).size());
  }

  static void check(boolean ok, String what) {
    if (!ok) throw new AssertionError(what);
  }
}

Presence. Heartbeats from 100 million sockets every 30 seconds would be 3.3 million writes a second. Do not store heartbeats. The gateway writes the registry only when a socket connects or disconnects: at an assumed 30-minute session, 100 million sockets give about 111,000 transitions a second, 30 times fewer. The gateway refreshes its sockets' expiry in batches (1,000 gateways, one batch each every 30 seconds, is about 33 calls a second). "Last seen" is a timestamp written on disconnect. Send presence changes only to people who have that conversation open, not to every contact.

Failures (SDE-2). A gateway dies: its 100,000 clients reconnect. Without jitter that is 100,000 connects at once; spread over 30 seconds it is 3,333 a second. Each reconnect runs sync, so nothing is lost, because the append happens before any delivery. The registry points at a dead gateway: the push times out, the recipient is treated as offline, and the message waits in the log. The chat service crashes after the append and before the "sent": the client retries and gets the same number. A very busy group is a hot partition: add the month bucket and cache its last page.

INVENTED example 1 min

An invented interviewer asks: "Two phones send to the same group in the same millisecond. How does everyone see the same order?" The candidate says: "The group lives on one partition, and its owner hands out the next number, so one of them is 41 and the other is 42 for everybody. I never order by phone clocks. If the owner fails over, the new owner first reads the highest number from the store before it hands out another, and it is fenced so the old owner cannot append. That read must be a quorum or leader read: from an asynchronously replicated follower it can return a stale number, and the new owner would reuse a seq."

Senior depth (adds). The deep dives are sequence assignment through an owner failover (the example above, fenced as in lesson 8's consistency section), cursors instead of per-message flags, and the connection tier: sticky load balancing by connection, reconnect storms, and draining a gateway before a deploy. Multi-device: each device has its own delivered cursor and the user has one read cursor. A group above a few hundred members becomes a fan-out problem like the feed's, so cap its size or push through a queue.

Operate it · Design 4: chat service
  • Roll out and roll back. Gateways are the risky tier: deploy by draining a few at a time, which makes clients reconnect with jitter (3,333 a second over 30 seconds for a 100,000-socket gateway). Never restart a zone at once. A new protocol version ships behind a client flag and the server speaks both for a release. Roll back by redeploying the old gateway; the log is untouched.
  • Blast radius. One gateway holds 100,000 sockets, so it is 0.1% of connections at 1,000 gateways. One group partition is one hot conversation, not the service. The append happens before delivery, so a gateway failure delays pushes and loses no message.
  • Watch. Page on send-to-delivered p99 above 2 seconds and on the age of the oldest undelivered message in the log. Ticket on reconnect rate (a spike means a bad deploy or a network event) and on sequence gaps, which should be zero.
  • Monthly cost line. Assumed: 1,000 gateways (Rs 1.2 crore) for 100 million sockets at 100,000 each; 150 log-store nodes (Rs 60 lakh), because 694,444 appends a second at peak over an assumed 15,000 batched appends a node is 47 shards, rounded to 50, with three copies each; 360 TB for 30 days with three copies is 2.4 TB a node, more than the 1 TB a node's price includes, so it is a data volume at Rs 8 per GB (Rs 28.8 lakh). About Rs 2.08 crore a month (the lines sum to Rs 2.09 crore); the gateways are more than half, because sockets cost memory.

Next stop: Design 5: ride-hailing matching, 8 min

Design 5: ride-hailing matching

Goal. By the end of this section you can design the matching core of a ride-hailing service: take driver locations at a high rate, find nearby drivers fast with a grid index, and assign each driver to at most one ride at a time.

Requirements. A driver app sends its position every 4 seconds while online. A rider requests a ride at a point. The system offers the ride to a good nearby available driver, who has 10 seconds to accept; if not, the next one. A driver is never on two rides, and a ride is never matched twice. The rider sees the driver approach. Out of scope, said aloud: pricing and surge, road routing and ETA (a separate service; this design uses straight-line distance as a first filter), payments.

Estimate. Assumptions, invented: 1 million drivers online at peak, one update per 4 seconds, 100 bytes an update, 20 million rides a day. Computed: 250,000 updates a second, 25 MB a second; storing every update would be 2.16 TB a day. The latest position of every driver is 1 million x 100 bytes = 100 MB. Rides are 231 a second on average, 694 at peak, and each match reads about nine cells. So: the write rate is the problem and the match rate is not. Keep the latest positions in memory, and write a sampled trail to cold storage.

The grid. A geohash names a rectangle by interleaving longitude and latitude bits and writing them in base 32. Length 6 is 30 bits, 15 for each axis, so a cell is 360 / 2^15 degrees wide and 180 / 2^15 high: 1.22 km by 0.61 km at the equator (the author's arithmetic from the bit layout; cells get narrower in width away from the equator). Length 5 is 4.89 km by 4.89 km, length 7 is about 153 m square. Two traps. Nearby points can sit in different cells, so a query reads the rider's cell and its eight neighbours, never the cell alone. And fixed cells ignore density: a stadium cell can hold thousands of drivers and a rural cell none. A quadtree, or the S2 library (cells on a space-filling curve in many sizes; the author's reading, its documentation was not opened for this lesson), adapts cell size to density. The code below is a simplified row-major grid with the same cell size, standing in for geohash cells; real geohash neighbour handling differs (neighbours need de-interleaving or a lookup table, and the +1 and -1 arithmetic used here does not apply to geohash strings). Choose the fixed grid when it is enough and say what you would change if one cell becomes too hot.

The next figure shows the query: the rider, a driver just across a border, and the ring read only if too few drivers are found.

Write path. Store the latest location by driver_id, so the 250,000 writes a second spread evenly and no city can make one shard hot. Keep a second, in-memory index from cell to drivers, and change it only when a driver crosses a cell border. At 30 km/h a driver moves 33 m in 4 seconds. A Java random-walk run (2 million trials, java --source 21 Cross.java) found 5.16% of steps change cell, matching the formula 33.3 x (2 / pi) x (1/1,223 + 1/611) = 5.2%. So the cell index takes about 13,000 writes a second, 19 times fewer than the location store. If an index node loses its memory, rebuild it from the next round of driver updates, about four seconds, with matching degraded in that time; nothing in the index needs to be durable.

riderd1d2d3d4d5Cell = geohash length 6: 1.22 km wide, 0.61 km high(at the equator; narrower in width farther north).Query the rider's cell and its 8 neighbours:3 x 3 cells = 3.67 km x 1.83 km. d2 is a few metresaway but in the next cell, so one cell is not enough.Fewer than 5 available drivers? Widen to the5 x 5 ring (dashed), up to a radius cap.d4 is in the ring, d5 far away: neither is readin the first pass.A prefix shared is not a distance: always readthe neighbours, never the prefix alone.
Figure A8.7. A geohash grid. The rider's cell and its eight neighbours are read first because a near driver can sit just across a cell border; the outer ring is read only when too few are found.

Matching and the lock. The service reads the nine cells, widens to the 25-cell ring if it finds fewer than five available drivers (up to a radius cap), keeps the drivers marked available, ranks the nearest ten by straight-line distance, and makes an offer to the best. The claim must be atomic per driver, so it lives with the driver (the key driver_id), not with the cell: a compare-and-set from available to offered, with a lease that expires, so a driver who never answers returns to the pool. When two rides race for one driver, exactly one claim succeeds and the other moves to its next driver. An accept carries the ride id and succeeds only while the hold still names that ride; that check is the fencing token of lesson 8's consistency section. The block below is the cell grid and the hold, with checks: a driver in the next cell is found, a same-cell move writes nothing, eight threads race for one driver and exactly one wins, and the hold expires.

Code, 82 lines
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

class DriverIndex {
  record Hold(String rideId, long expiresAt) {}

  // Simplified grid standing in for geohash cells: 15 bits of x and
  // 15 of y, row-major. Not bit-interleaved, so not a real geohash.
  static long cell(double lat, double lon) {
    long y = (long) ((lat + 90) / 180 * 32768);
    long x = (long) ((lon + 180) / 360 * 32768);
    return (y << 15) | x;
  }

  private final Map<Long, Set<String>> cells = new ConcurrentHashMap<>();
  private final Map<String, Long> where = new ConcurrentHashMap<>();
  private final Map<String, Hold> holds = new ConcurrentHashMap<>();

  // Most updates stay in the same cell and leave the index untouched.
  boolean move(String driver, double lat, double lon) {
    long c = cell(lat, lon);
    Long old = where.put(driver, c);
    if (old != null && old == c) return false;
    if (old != null) cells.get(old).remove(driver);
    cells.computeIfAbsent(c, k -> ConcurrentHashMap.newKeySet()).add(driver);
    return true;
  }

  Set<String> nearby(double lat, double lon) {
    // No wrap at the date line: within one cell of lon 180, also
    // read the cells on the other side.
    long c = cell(lat, lon);
    var found = new HashSet<String>();
    for (int dy = -1; dy <= 1; dy++)
      for (int dx = -1; dx <= 1; dx++)
        found.addAll(cells.getOrDefault(
            c + ((long) dy << 15) + dx, Set.of()));
    return found;
  }

  // One driver, one ride: an atomic claim that expires if never accepted.
  boolean hold(String driver, String rideId, long now, long ttlMs) {
    Hold h = holds.compute(driver, (k, old) ->
        old == null || old.expiresAt() <= now
            ? new Hold(rideId, now + ttlMs) : old);
    return h.rideId().equals(rideId);
  }

  public static void main(String[] args) throws Exception {
    var ix = new DriverIndex();
    double lat = 12.9716, lon = 77.5946;
    check(ix.move("d1", lat, lon), "first move writes the index");
    check(!ix.move("d1", lat + 1e-6, lon), "same-cell move writes nothing");
    ix.move("d2", lat, lon + 0.0125);    // about 1.4 km east: next cell
    var near = ix.nearby(lat, lon);
    check(near.contains("d1") && near.contains("d2"), "next cell found");
    ix.move("d3", lat, lon + 0.5);       // far away
    check(!ix.nearby(lat, lon).contains("d3"), "far driver not found");
    var wins = new java.util.concurrent.atomic.AtomicInteger();
    var ts = new ArrayList<Thread>();
    for (int i = 0; i < 8; i++) {
      String ride = "r" + i;
      Thread t = new Thread(() -> {
        if (ix.hold("d1", ride, 0, 10_000)) wins.incrementAndGet();
      });
      ts.add(t);
      t.start();
    }
    for (Thread t : ts) t.join();
    check(wins.get() == 1, "exactly one ride wins the driver");
    check(!ix.hold("d1", "late", 9_999, 10_000), "hold still valid");
    check(ix.hold("d1", "late", 10_000, 10_000), "hold expired");
    System.out.println("index ok: winners=" + wins.get());
  }

  static void check(boolean ok, String what) {
    if (!ok) throw new AssertionError(what);
  }
}

Components and failures (SDE-2). A stale driver: no update for three intervals (12 seconds) removes them from the index. A rider who taps twice: the request id makes the second a no-op. The matcher crashes mid-offer: the hold expires in 10 seconds and the driver is available again. If the state shard for a driver is down, treat the driver as unavailable (fail closed): an offer you cannot lock is an offer you must not make. GPS jitter can flip a stationary driver between two cells; ignore moves under a few metres.

Driver appLocationgatewayLatest locationby driver_idCell index (memory)cell to driversby cell prefixRider appRider APIMatchingserviceDriver stateAVAILABLE / OFFEREDcompare-and-set, leaseTrip storeevery 4 supsertcell change only9 cellsholdacceptedThe offer is pushed to the driver app; 10 s to accept.
Figure A8.8. Ride-hailing matcher, boxes and arrows. Location writes go to a store keyed by driver; the cell index changes only when a driver crosses a cell border; the one-driver-one-ride claim lives with the driver.
INVENTED example 1 min

An invented candidate is asked what happens after a stadium match, when 8,000 riders and 3,000 drivers are in three cells. They say: "That cell is hot twice over: index writes and match reads. I would split its shard to a finer prefix, cap the candidates read per request at 200 chosen by distance, and widen the offer time rather than the radius, so I do not pull in drivers from far away. The cost is some riders wait longer, which I would show in the app."

Senior depth (adds). Sharding the cell index: a geohash prefix of length 4 is a cell of about 39 km by 20 km, so a whole city is one shard and one hot spot; use length 5 or 6 for big cities and spread the prefixes over nodes with consistent hashing. A request then fans out to up to nine cells on several shards in parallel, and its latency is the slowest of them. Greedy versus batch matching: matching each request at once is simple; collecting requests for about 2 seconds and assigning drivers together lowers total pickup time but adds up to 2 seconds of waiting (the author's reading; state it as a trade).

Operate it · Design 5: ride-hailing matching
  • Roll out and roll back. Matching changes ship to one city first, because a matching bug is local by nature. Compare pickup time and offer-accept rate to a control city of similar size, not to last week. Roll back by flag per city, in seconds. Keep the old greedy matcher as the fallback code path.
  • Blast radius. The geohash cell is the wall: a bad index shard hurts the cells it owns. A matcher bug can offer one driver two rides, which the single driver-state transition (free to offered) prevents. One city down leaves the others.
  • Watch. Page on match-found rate below its normal band for the city and hour, and on location update lag above 10 seconds (a stale index offers a driver who has moved). Ticket on offer-timeout rate, a sign of bad candidate quality.
  • Monthly cost line. Assumed: 25 location gateways for 250,000 updates a second at 10,000 each (Rs 3 lakh); the latest-location store, 250,000 upserts a second over an assumed 50,000 a memory node, so 5 shards with one replica each (10 cache nodes, Rs 1.6 lakh; the positions are only 100 MB, so the write rate sets the count); 6 index nodes (Rs 1.92 lakh), 10 matcher servers (Rs 1.2 lakh), a 1-in-5 trail to cold storage at an assumed Rs 1.6 per GB-month (13 TB after a month, Rs 20,800). About Rs 8 lakh a month (the lines sum to Rs 7.93 lakh); the update stream, gateways and store together, not the matches, is the bill.

Next stop: Design 7: e-commerce checkout and a flash sale, 10 min

Design 7: e-commerce checkout and a flash sale

Goal. By the end of this section you can design checkout for a shop that puts 1,000 units of one phone on sale at noon in front of 2 million people, never sell a unit it does not have, hand payment to the ledger's saga and outbox, and say what a buyer sees when payment fails or arrives late.

Context. Ticket booking holds a numbered seat. This design differs in one way that changes the answer: a unit has no identity, so the guard is a counter, and the counter's row becomes the hot spot.

Requirements. Browse and cart are ordinary: a cached catalogue and a cart per user. The hard path is "Buy now" on a sale item: reserve one unit for 10 minutes, take payment, confirm on success, release on failure or expiry. One unit per buyer for this sale. Never oversell. A buyer whose payment lands after the unit is gone gets an automatic refund. Out of scope, said aloud: search, recommendations, coupons and pricing, delivery slots, and the payment provider's internals; money belongs to the ledger in Design 3 (lesson 8).

Estimate. Assumptions, all invented: 1,000 units; 2 million people on the page at noon, each pressing Buy once in the first 10 seconds; an ordinary day's checkout of 3 orders a second. Computed: 200,000 attempts a second at noon against 1,000 units, so 199,000 of every 200,000 answers are "no", and the design is mostly about saying no cheaply. The stock row is the second number. Its lock is held until commit, and the commit waits for a synchronous standby; at an assumed 2 ms a transaction, one row serves about 500 holds a second. Even 2,000 admitted buyers on one row is 4 seconds of queue, with 2,000 database connections held while they wait. Split into 20 rows of 50 units the same 2,000 clear in 0.2 seconds, with room for ten times the sale. Payments are about 1,000 in 10 minutes, under 2 a second, nothing for the ledger. So: stop the crowd at the edge, admit about twice the stock, and spread the stock over rows.

Where to take the unit. There are three places, and the interviewer wants the trade said. At add-to-cart: carts are abandoned far more often than bought, so window-shoppers would hold the whole sale. At payment success: nothing is held, so all 2,000 admitted buyers pay and 1,000 are refunded; the oversell becomes a refund queue. At Buy now, with a hold that expires: the middle, at the price of a sweeper and a late-payment path. Take the third, and say on the cart page that a sale item is not reserved until Buy now.

Read the figure top to bottom: the crowd stops at the edge, the gate admits a few thousand, one transaction does the work, and every hold is ended by exactly one of two things.

Buyers2,000,000CDN waiting pagesold-out flag, 2 sAdmission gate2,000 tokensthe rest wait at the edgeCheckout serviceidempotency keyOne transaction, inventory databasestock: 20 rows of 50, avail >= 1hold: RESERVED, expires in 10 minorder PENDING and an outbox rowall rows at 0: set flagOutbox relayPayment sagathe ledger, Design 3confirm or release: one winsSweeper, every 10 sreleases expired holdsavail >= 1 is checked again after a lock wait, so no row goes below 0.A payment that lands after its hold is gone is refunded, never sold.
Figure A8.9. Flash-sale checkout. The edge and the gate keep the crowd off the database; one transaction takes a unit from one of 20 stock rows and records the hold, the order and the outbox row; the payment saga or the sweeper ends every hold, and only one of them can.

The guard. One transaction does four writes: the conditional decrement UPDATE stock SET avail = avail - 1 WHERE sku = ? AND bucket = ? AND avail >= 1; the hold row with its expiry; the order as PENDING; and the outbox row that asks for payment, keyed by the order id. Zero rows updated means this bucket is empty; try the next. The decrement is safe without a read first, even at Read Committed. PostgreSQL 18's documentation (section 13.2.1, re-read 2 Nov 2026) says a second updater waits for the first to commit, and then "The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition." So two buyers racing for the last unit cannot both see avail >= 1 pass. A CHECK (avail >= 0) on the column is a second wall (author's practice). One unit per buyer is a row keyed by sale and buyer, inserted in the same transaction and deleted on release.

The listing is an excerpt of a 169-line file and models the guard and the hold's life in memory: a compare-and-set loop stands in for the conditional UPDATE, and the comment above it gives the SQL. Its fields are an array of AtomicInteger stock rows (buckets), the hold lifetime (ttlMs), an AtomicLong id counter (ids), and three ConcurrentHashMaps: idempotency key to hold (byKey), buyer to hold id (buyer) and hold id to state (state).

Code, 47 lines
enum State { RESERVED, CONFIRMED, RELEASED }
record Hold(long id, String user, int bucket, long expiresMs) {}

// UPDATE stock SET avail = avail - 1
//   WHERE sku = ? AND bucket = ? AND avail >= 1
boolean take(int b) {
  while (true) {
    int a = buckets[b].get();
    if (a < 1) return false;
    if (buckets[b].compareAndSet(a, a - 1)) return true;
  }
}

// Same key twice returns the same hold: the retry is free.
Hold reserve(String key, String user, long now) {
  return byKey.computeIfAbsent(key, k -> fresh(user, now));
}

private Hold fresh(String user, long now) {
  long id = ids.incrementAndGet();
  if (buyer.putIfAbsent(user, id) != null) return null; // 1 each
  int n = buckets.length, home = Math.floorMod(user.hashCode(), n);
  for (int i = 0; i < n; i++) {
    int b = (home + i) % n;                 // home row, then next
    if (take(b)) {
      state.put(id, new AtomicReference<>(State.RESERVED));
      return new Hold(id, user, b, now + ttlMs);
    }
  }
  buyer.remove(user, id);                   // sold out
  return null;
}

// Payment callback. False means: refund, the hold is gone.
boolean confirm(Hold h, long now) {
  return now < h.expiresMs() && state.get(h.id())
      .compareAndSet(State.RESERVED, State.CONFIRMED);
}

// Payment failed, or the sweeper found the hold expired.
boolean release(Hold h) {
  if (!state.get(h.id())
      .compareAndSet(State.RESERVED, State.RELEASED)) return false;
  buckets[h.bucket()].incrementAndGet();    // unit goes back
  buyer.remove(h.user(), h.id());
  return true;
}

Run with java --source 21 FlashSale.java on 2 Nov 2026 (OpenJDK 25), three times; each run printed flash ok: sold=1000 released=300 oversold=0, and the line prints only after every check has passed. The full file is 169 lines; the test around the excerpt sends 20,000 buyers on 8 threads at 1,000 units in 20 rows, each buyer retrying its key once and trying a second key; pays 7 holds in 10 and releases the rest; sends a second crowd that must buy exactly the 300 released units; refuses a payment after the sweeper has released its hold, and one on an expired hold the sweeper has not reached yet; and races confirm against release on 2,000 holds, requiring exactly one winner each. A check that fails inside a worker thread is caught there and rethrown on the main thread after the join, so it fails the run. Four mutants were run to check that the test can fail, and each threw its own AssertionError: without the stock guard, "stock left after a 20x crowd"; without the expiry check in confirm, "expired hold confirmed"; with an unconditional release, "confirm and release both won"; without one unit per buyer, "two holds for one user".

Failures (SDE-2). The checkout server dies after the commit and before the reply: the client retries with the same idempotency key and gets the same hold, which is the listing's first check. The payment provider times out: the hold stays RESERVED and the saga asks the provider for the order's status by its idempotency key before deciding; if the hold expires first, the sweeper releases it and a later success is refunded, never fulfilled. The outbox relay stops: holds wait unpaid and start to expire, so page on relay lag long before the 10 minutes run out. The inventory primary dies: a synchronous standby takes over with every committed decrement, the gate pauses admission for the seconds that takes, and no unit is sold twice. The edge flag is stale for up to 2 seconds: buyers in that window reach the gate and hear "sold out" from the database, which is the truth.

Senior depth (adds). Expect "why not a Redis counter?". A Lua script is atomic, so a Redis decrement cannot race. The trouble is failover: the Redis Cluster specification (read 2 Nov 2026) says "asynchronous replication is used" and "Usually there are small windows where acknowledged writes can be lost." A lost decrement is a unit sold twice. So Redis may say no early (the gate's tokens, the sold-out flag) and only the database says yes. Next, the rows: 20 rows of 50 drain unevenly, so the last units sit in scattered rows, and a buyer whose home row is empty scans the others; "sold out" means all 20 rows at zero, checked by summing them at most once a second. At ten times the crowd nothing inside changes: the edge absorbs it and the gate still admits 2,000, because tokens scale with stock, not with people. At ten times the stock, 10,000 units, admit 20,000 over 40 rows; 40 rows at about 500 holds a second clear them in about a second. One number I would not invent: the payment provider's rate limit. 1,000 payments in 10 minutes is far below any limit I would expect, but a 100,000-unit sale is not, so I would ask.

INVENTED example · the cart said in stock 1 min

The interviewer says: "A buyer put the phone in the cart at 11:58, the cart said in stock, and at noon checkout said sold out. They are angry. Fix it." The candidate says: "The cart never reserved, by design, because most carts are never bought and they would hold the whole sale. I would fix the words, not the promise: on a sale item the cart says 'not reserved until Buy now', and the badge says 'selling fast' from the cached count, never 'in stock'. If the business wants carts to reserve, I would offer a 2-minute cart hold for this sale and show what it costs: units held by people who never pay, released late, sold to whoever is still waiting."

Operate it · Design 7: checkout and a flash sale
  • Roll out and roll back. Never first-deploy on a sale day. Ship ahead to a small real sale, then rehearse with a load test at the gate's real admission count. During the sale the levers are config: lower the admission rate, pause admission, or force the sold-out flag. Roll code back only between sales.
  • Blast radius. The gate is the wall: at most 2,000 buyers are inside whatever fails. One sale's stock is its own rows, so a hot SKU cannot lock another. An oversell bug has no wall, which is why the conditional decrement and the CHECK (avail >= 0) both live in the database, where no application release can skip them.
  • Watch. Page on units sold above stock (target: never), on the oldest expired hold above 1 minute (the sweeper is stuck), on outbox relay lag above 30 seconds, and on checkout p99. Ticket on the share of holds that expire unpaid and on late-payment refunds.
  • Monthly cost line. Using the lesson's invented prices: 30 app servers for the whole shop's browse, cart and checkout (assumed), Rs 3.6 lakh; 3 database nodes on local SSD at Rs 64,000, Rs 1.92 lakh; 2 cache nodes at Rs 16,000 for the gate and the flag, Rs 32,000: about Rs 5.84 lakh a month. Each sale adds CDN requests: 200,000 a second for 10 minutes is 120 million, about Rs 9,600 at an invented Rs 80 per million requests. The app tier dominates, and the hot row costs nothing extra; it costs design.
Gave up · revisit when · signal
  • What I gave up. Speed at the counter: every unit goes through a database transaction instead of an in-memory decrement, to keep durability. A cart reservation. Strict fairness for released units: a unit freed 10 minutes after noon goes to whoever is still on the page, not to the next person in the original queue.
  • Revisit when. Stock per sale passes about 50,000 units or the transaction p99 on the stock rows passes 20 ms (add rows, then consider a stock service that holds counters in memory behind a replicated log); or buyers complain of "sold out, then available" (a waitlist that offers released units in queue order).
  • Signal that tells me. Lock-wait time on the stock rows rising across sales, the share of holds that expire unpaid, and support tickets that quote the sold-out page.

Next stop: Leases and fencing tokens, 3 min

Leases and fencing tokens

Context. A lease is a lock that expires, so a holder that dies cannot block everyone for ever. Expiry creates the opposite problem. Martin Kleppmann's essay "How to do distributed locking" (8 Feb 2016, read 1 Nov 2026) lists ways a holder can stop without knowing it: a garbage-collection pause, a page fault, slow disk, a delayed network packet (he cites a GitHub incident with packets delayed for about 90 seconds). The holder wakes after its lease has expired and another client holds the lock, then writes as if it still held it. Checking the clock before writing does not help, because the pause can fall between the check and the write.

The fix. The lock service hands out a fencing token, a number that increases with every grant. The client sends it with every write, and the storage remembers the highest token it has accepted and rejects a lower one. The protection lives in the resource, not in the client's sense of time. The essay notes that a ZooKeeper zxid or znode version can serve as the token, and that Redlock produces none. Figure A8.10 shows the race with tokens 7 and 8.

Worked example. The demonstration uses explicit times in milliseconds instead of sleeping, so every run is the same. Run with java --source 21 FencingDemo.java on 1 Nov 2026: it printed fencing ok: A=7 B=8 and passed every check, and with the token comparison removed the "stale A rejected" check fails.

Client ALock serviceClient BStorageacquiretoken 7, 10 s leaseA paused(GC), 12 s10 s: lease expiresacquiretoken 8write, token 8ok; highest = 8write, token 7 (A still thinks it holds the lock)rejected: 7 < 8Storage keeps the highest token it has accepted and refuses a lower one.
Figure A8.10. A fencing token stops a stale lock holder. A pauses past its lease, B is granted token 8 and writes, and A's late write with token 7 is refused by the storage, not by A.
Code, 51 lines
import java.util.Optional;

public class FencingDemo {
  record Lease(String holder, long token, long expiresAtMs) {}

  static final class LockService {
    private long nextToken = 7;          // six grants happened earlier
    private Lease current;

    Optional<Lease> acquire(String who, long nowMs, long ttlMs) {
      if (current != null && nowMs < current.expiresAtMs()) {
        return Optional.empty();         // a live lease exists
      }
      current = new Lease(who, nextToken++, nowMs + ttlMs);
      return Optional.of(current);
    }
  }

  static final class Storage {
    private long highestToken = 0;
    private String value = "";

    boolean write(long token, String v) {
      if (token < highestToken) return false;   // stale holder
      highestToken = token;
      value = v;
      return true;
    }

    String value() { return value; }
  }

  public static void main(String[] args) {
    LockService locks = new LockService();
    Storage disk = new Storage();
    Lease a = locks.acquire("A", 0, 10_000).orElseThrow();
    check(locks.acquire("B", 5_000, 10_000).isEmpty(), "lease is live");
    // A stops (GC pause) past its TTL; B is granted the next token.
    Lease b = locks.acquire("B", 12_000, 10_000).orElseThrow();
    check(b.token() > a.token(), "tokens increase");
    check(disk.write(b.token(), "B wrote"), "B accepted");
    // A wakes, still believes it holds the lock, and writes.
    check(!disk.write(a.token(), "A wrote"), "stale A rejected");
    check(disk.value().equals("B wrote"), "B's write survives");
    System.out.println("fencing ok: A=" + a.token() + " B=" + b.token());
  }

  static void check(boolean ok, String what) {
    if (!ok) throw new AssertionError(what);
  }
}

Constraints. The token protects only resources that check it; a write path that skips the check is unprotected. Where the storage cannot compare tokens, a conditional write on a version column does the same job. This is the check in Design 4's owner failover and Design 5's driver hold.

Next stop: Leader election and split brain, 2 min

Leader election and split brain

Context. Many stores send every write through one leader so that writes have one order. When the leader goes quiet the others elect a new one, and they can judge "quiet" only by a timeout, which cannot tell a dead leader from a slow or cut-off one. If the old leader is alive on the far side of a partition, two nodes believe they lead. That is split brain: both accept writes and the histories diverge.

How consensus prevents it. The Raft paper (read 31 Oct 2026) numbers time in terms. A candidate needs votes from a majority to lead and a node votes once per term, so there is at most one leader per term. A message carrying an older term is rejected, and a leader that sees a newer term steps down. A leader commits an entry only once a majority has stored it, so an old leader on the minority side keeps its title but commits nothing; for reads, the paper has the leader confirm with a majority before answering. The term is a fencing token built into the protocol. Figure A8.11 shows five nodes split two and three.

network splitN1: old leaderterm 4N2: followerN3: new leaderterm 5N4: followerN5: follower2 of 5: no majority,nothing commits3 of 5: a majority,commits in term 5After the split heals, N1 sees term 5 and steps down.A write stamped term 4 is rejected: the term is the fencing token.
Figure A8.11. Split brain prevented by majority and term. Both sides may have a node that calls itself leader, but only the side with three of five votes can commit.

Constraints. A majority needs an odd number of voters spread over at least three failure domains. With voters in only two data centres, losing the one that holds the majority stops writes. The usual answers are a small witness voter in a third zone, or fencing the old leader through an outside channel, such as cutting its power, before promoting another; practitioners call that STONITH, "shoot the other node in the head" (author's reading of common practice). Storage and clients should still check the term or epoch, because a deposed leader's messages can be in flight. This is the step "fence the old primary" in lesson 8's recovery table. An election also pauses writes for at least one election timeout (author's reading); say how long.

Next stop: An orchestrated saga, run, 4 min

An orchestrated saga, run

Lesson 8's section "Two-phase commit versus a saga" defines the saga and when to use it; this is the sketch that was run.

Worked example. The sketch is an orchestrated saga: one coordinator runs the steps, records a key for each, and on a failure runs the undos in reverse. The credit to B fails because B's account is frozen, so A's debit is refunded; a second saga then succeeds, and its retry under the same saga id moves no money. The step's effect and its key are committed together: once() snapshots the balances, runs the step, records the key, and on any exception restores the snapshot, which is what one local database transaction gives you. The test injects a crash after the debit of saga-3 has changed A but before its key is recorded; the debit is rolled back, the retry runs it once, and A ends at 0 rather than at minus 500. Run with java --source 21 SagaDemo.java on 2 Nov 2026 (OpenJDK 25): it printed saga ok: A=0 B=1000 log=9 and passed every check; with the key check removed the "moved once" check fails, with the rollback removed the "crash rolled the debit back" check fails, and without the compensated set the retry of saga-1 creates 500 in B. A retry of a compensated saga is refused; to try the transfer again, use a new saga id.

Code, 111 lines
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class SagaDemo {
  record Step(String name, Runnable action, Runnable undo) {}

  sealed interface Outcome permits Done, Compensated {}
  record Done() implements Outcome {}
  record Compensated(String failedStep) implements Outcome {}

  static final Map<String, Long> bal = new HashMap<>();
  static final List<String> log = new ArrayList<>();
  static final Set<String> applied = new HashSet<>();
  static final Set<String> compensated = new HashSet<>();
  static boolean bFrozen = true;

  static Outcome run(String sagaId, List<Step> steps) {
    // A compensated saga is finished: a retry must not run it again.
    if (compensated.contains(sagaId)) return new Compensated("retry");
    Deque<Step> done = new ArrayDeque<>();
    for (Step s : steps) {
      try {
        once(sagaId + "/" + s.name(), s.action());
        done.push(s);
        log.add("did " + s.name());
      } catch (IllegalStateException e) {
        log.add("failed " + s.name());
        while (!done.isEmpty()) {          // undo in reverse order
          Step d = done.pop();
          once(sagaId + "/undo " + d.name(), d.undo());
          log.add("undid " + d.name());
        }
        compensated.add(sagaId);
        return new Compensated(s.name());
      }
    }
    return new Done();
  }

  // The effect and its key commit together, as in one local database
  // transaction: a crash between them rolls the effect back.
  static class Crash extends RuntimeException {}
  static String crashAt = null;          // test hook: die before commit

  static void once(String key, Runnable r) {
    if (applied.contains(key)) return;   // a retry: already applied
    var before = new HashMap<>(bal);     // begin
    try {
      r.run();
      if (key.equals(crashAt)) { crashAt = null; throw new Crash(); }
      applied.add(key);                  // commit: effect + key together
    } catch (RuntimeException e) {
      bal.clear();
      bal.putAll(before);                // rollback
      throw e;
    }
  }

  static void add(String acct, long delta) {
    bal.merge(acct, delta, Long::sum);
  }

  static List<Step> transfer(long amt) {
    return List.of(
        new Step("debit A", () -> add("A", -amt), () -> add("A", amt)),
        new Step("credit B", () -> {
          if (bFrozen) throw new IllegalStateException("B frozen");
          add("B", amt);
        }, () -> add("B", -amt)));
  }

  public static void main(String[] args) {
    bal.put("A", 1_000L);
    bal.put("B", 0L);
    Outcome first = run("saga-1", transfer(500));
    check(first instanceof Compensated c
        && c.failedStep().equals("credit B"), "saga-1 compensated");
    check(bal.get("A") == 1_000 && bal.get("B") == 0, "money restored");
    check(log.equals(List.of("did debit A", "failed credit B",
        "undid debit A")), "log order");
    bFrozen = false;
    // Retrying the compensated saga under its id must move no money.
    check(run("saga-1", transfer(500)) instanceof Compensated,
        "compensated retry refused");
    check(bal.get("A") == 1_000 && bal.get("B") == 0, "no money created");
    check(run("saga-2", transfer(500)) instanceof Done, "saga-2 done");
    run("saga-2", transfer(500));        // client retries after timeout
    check(bal.get("A") == 500 && bal.get("B") == 500, "moved once");
    crashAt = "saga-3/debit A";          // die after the debit's effect
    try {
      run("saga-3", transfer(500));
      check(false, "crash expected");
    } catch (Crash e) {
      check(bal.get("A") == 500, "crash rolled the debit back");
    }
    check(run("saga-3", transfer(500)) instanceof Done, "saga-3 done");
    check(bal.get("A") == 0 && bal.get("B") == 1_000, "crash moved once");
    System.out.println("saga ok: A=" + bal.get("A")
        + " B=" + bal.get("B") + " log=" + log.size());
  }

  static void check(boolean ok, String what) {
    if (!ok) throw new AssertionError(what);
  }
}

Constraints. These rules are the author's reading of practice, not the 1987 paper. A compensation must be idempotent and must eventually succeed, so it is retried, never abandoned. A step that cannot be undone, such as money leaving for an outside bank, goes last. The in-between state is shown honestly, which is why the ledger says "pending". In the room: within one database, use a local transaction; across shards of a store that offers distributed transactions, its 2PC is acceptable if you name the blocking risk; across services or companies, use a saga.

Next stop: Idempotency keys and the outbox, 1 min

Idempotency keys and the outbox

Context. Lesson 8's building-block table gives the contract of an idempotency key; the implementation is where candidates slip. The key, a hash of the request and the stored result belong in the same database transaction as the effect, or a crash between them either forgets a completed payment or remembers one that never happened. A unique constraint on the key settles two copies that arrive together: one insert wins, and the other is answered from the stored row, or told to retry while the first is still running (author's reading).

The dual write. A service that updates its database and then publishes an event can crash between the two, so the state changes and nobody hears, or an event announces a change that rolled back. The outbox pattern (a practitioner pattern; author's reading, not from one source) writes the event as a row in an outbox table inside the same transaction as the business rows. A separate relay, polling that table or reading the database's change log (change data capture), publishes each row and marks it sent. The relay can crash after publishing and before marking, so delivery is at least once, and consumers drop repeats by event id, as in the queue row of that table. Design 3 (lesson 8) is this path: transfer, entries and outbox row in one transaction, then the relay to the bank gateway.

The failure to name. Do not promise exactly-once delivery end to end. Say "at least once, with an id the consumer deduplicates on", and say how long the dedupe record lives against the longest retry window.

Use it in step 8: System design