QuickCart Stage 4: Scaling Writes With Replication and Sharding
Split QuickCart's order data across shards with consistent hashing, choose replication and consistency per data type, and generate order ids that never collide.
What you'll learn: By the end of this lesson you will be able to explain leader-follower, multi-leader and leaderless replication and choose one per data type, pick a shard key and route with a consistent-hash ring, state CAP and PACELC without the usual myths, assign a consistency model to each kind of QuickCart data, handle "my order just placed" with read replicas, and design order ids that stay unique across shards.
A corner shop has one till. When the queue reaches the door, a faster till only helps so far. The better fix is more tills and a rule that tells each customer which one to use, plus a rule for what happens when two tills disagree about the stock.
QuickCart reaches that point now. Stage 2 made reads cheap, but every write still goes to one place. This stage splits writes across several databases, decides how fresh each copy of the data must be, and makes sure two databases never hand out the same order number. It follows Stage 3, the data model; the order pipeline builds on it.
Where QuickCart is now
Reads are cheap: stateless app servers, a cache and read replicas absorb them. Orders, payments and users still live in one relational primary. At the end-state targets (all figures illustrative assumptions derived from the shared QuickCart numbers):
Be honest about what this says. About 580 row writes per second at peak may not, by itself, force sharding. The pressure is the combination: a data set heading for several terabytes, slow restores, every failure touching 100 percent of orders, and replicas that cannot help writes because each replays every write.
Figure 1. The Stage 3 design from the point of view of writes.How to read this: the dashed arrow is replication. Reads fan out to the cache and the replica, but every write, and every failure, still passes through the one highlighted node.
The core idea
Keep several copies of data so a machine can fail, split data into pieces so no machine holds or writes all of it, and say per kind of data how fresh each copy must be.
Replication copies the same data to more machines; sharding divides different data across machines; each shard is itself a small replicated group. Wherever copies can differ you must promise which reads may see an old one, which is what CAP, PACELC and consistency models are about. Unique ids fall out of sharding: a counter starting at 1 in each database is no longer an identity.
Copies of the data: replication
Leader-follower replication
In leader-follower replication (primary-replica) one node, the leader, accepts all writes and records each change in an ordered log. Followers apply that log in the same order. In PostgreSQL the log is the write-ahead log (WAL), and a follower that answers queries is a hot standby. Stage 2's read replicas are followers.
The key choice is when the leader says "committed": synchronous versus asynchronous replication. PostgreSQL's documentation (the current manual at the time of writing) describes synchronous_commit as how much WAL processing must finish before success is returned. With synchronous standbys configured:
Setting
Commit waits for
Leader lost right after "committed"
Cost
off
Nothing, not even the local flush
Recent commits can be lost
Lowest latency
local
Local WAL flush only
May not have reached any follower
Low
remote_write
Standby received and wrote it
Safe from a standby process crash, not a standby OS crash
Round trip
on (default)
Standby received and flushed it to durable storage
Survives unless the leader and the acknowledging standby lose storage
Round trip plus flush
remote_apply
Standby also applied it, so queries there see it
Same as on; a read on that standby sees the write
Largest delay
With plain asynchronous streaming, the documentation says committed transactions may not have reached the standby when the primary crashes, in proportion to the replication delay. Synchronous replication costs availability: commits can wait indefinitely if no synchronous standby is reachable, so list several candidates.
# postgresql.conf on one shard's primary (illustrative)synchronous_standby_names = 'ANY 1 (standby_az2, standby_az3)'synchronous_commit = on
ANY 1 (...) needs acknowledgement from any one listed standby: losing one does not block orders, losing both does.
Replication lag is the delay between a commit on the leader and its visibility on a follower. On the primary, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) against pg_stat_replication gives, per standby, the bytes of WAL still to replay. Lag is usually small but unbounded. It causes a bug every marketplace meets: a shopper places an order and is sent to "My orders", which reads a replica.
sequenceDiagram
accTitle: Replication lag hides a just-placed order
accDescr: A shopper places an order on the primary, then reads the order list from a replica that has not yet replayed the write, and sees nothing.
participant S as Shopper
participant A as Order service
participant P as Shard primary
participant R as Read replica
S->>A: POST /orders
A->>P: INSERT order, COMMIT
P-->>A: committed
A-->>S: 201 created
S->>A: GET /my-orders
A->>R: SELECT orders for shopper
R-->>A: no rows
A-->>S: empty list
P-->>R: WAL arrives, order replayed
Note over R: the order exists here a moment later
Figure 2. The lag bug on QuickCart's own order flow.How to read this: the order is correct on the primary the whole time; the wrong answer comes from reading a copy that is a moment behind. The fix is below, under read-your-writes.
Failover and split-brain protection are covered in Replication and Consensus and leader election. In QuickCart each shard is one leader, a synchronous standby in another zone and asynchronous read replicas.
Multi-leader replication and conflict resolution
In multi-leader replication several nodes accept writes and replicate them to each other, typically one per region or device, so a write is accepted locally even when the link to the other leader is down. The price is conflicts. Suppose the store dashboard sets the price of 1-litre milk to 1.20 while the store's tablet, syncing through another leader, sets 1.25. Both were valid where they happened. Something must decide:
Avoid it. Route all writes for a record to one leader.
Last write wins (LWW). Keep the larger timestamp. It converges but silently discards the other write and trusts clocks that can disagree (see IDs, clocks and ordering).
Keep both and ask. Detect concurrent versions with version vectors; the application or a person merges them.
Use a mergeable type. A counter, or a set merged by union, loses nothing (conflict-free replicated data types, CRDTs).
QuickCart does not use multi-leader for orders: merging two divergent orders is a business problem, not a technical one. Each shopper's orders have one leader.
Leaderless replication and quorums
In leaderless (Dynamo-style) replication there is no leader. A coordinator sends each write to several replicas and reads from several. The design comes from Amazon's Dynamo paper (2007) and lives on in stores such as Cassandra.
With N replicas per key, a write succeeds when W acknowledge and a read when R answer. If W + R > N, every read set overlaps every write set in at least one replica, so a read includes the latest successful write. Cassandra's documentation states the same condition. Suppose, only as an illustration, that carts lived in such a store (Stage 3 keeps them relational) with N = 3, W = 2, R = 2: 2 + 2 > 3, and one node can be down while reads and writes still succeed. W = 1, R = 1 would trade the guarantee for speed.
Two mechanisms heal replicas that missed a write:
Hinted handoff. If a replica is unreachable when a write arrives, another node keeps a note (a hint) and replays it when the replica returns. In Dynamo's sloppy quorum the stand-in is a healthy node outside the key's usual replicas. Cassandra's coordinator stores hints on local disk; its documentation says hints are best effort, do not guarantee eventual consistency the way anti-entropy repair does, and are generated for a failed node only within a window (3 hours by default).
Read repair. When a read finds one contacted replica behind, the coordinator writes the newest value back to it. Replicas never read stay stale, so systems also run background anti-entropy repair (Dynamo compares Merkle trees).
sequenceDiagram
accTitle: Leaderless write with a hint, then a read that repairs a replica
accDescr: A coordinator writes add-milk to three replicas with one down, stores a hint, later reads from two replicas, sees one is stale, and repairs it.
participant C as Coordinator
participant N1 as Node 1
participant N2 as Node 2 (down)
participant N3 as Node 3
C->>N1: write cart v2 (add milk)
C->>N2: write cart v2
N2--xC: no response
C->>N3: write cart v2
N1-->>C: ack
N3-->>C: ack
Note over C: W = 2 reached, client told ok, hint stored for Node 2
Note over N2: Node 2 returns, still holds cart v1
C->>N1: read cart (R = 2)
C->>N2: read cart
N1-->>C: v2
N2-->>C: v1
Note over C: newest wins, read repair writes v2 to Node 2
Figure 3. One write and one read in a leaderless store (N = 3, W = 2, R = 2).How to read this: the write succeeded with a node down; a replayed hint or a later read repair closes the gap; the read set (nodes 1 and 2) overlaps the write set (nodes 1 and 3) at node 1.
W + R > N is weaker than it looks. It fails with a sloppy quorum (the stand-in is not in the read set), with concurrent writes that need a conflict rule, after a write that reached only some replicas, or when clock-based last-write-wins goes wrong. Leaderless suits data where staying writable matters more than agreeing at once. For orders and money it is the wrong model, so QuickCart keeps it as an option, not a component.
Splitting the data: sharding
Sharding and partitioning
Partitioning divides a data set into non-overlapping pieces. Sharding is partitioning where the pieces live on different machines, each serving reads and writes for its subset; a shard is usually a replicated group. Sharding goes deeper. Three ways to decide where a row goes:
Scheme
How it decides
Good at
Weak at
Range
Contiguous key ranges
Range scans
Hot spots when new keys all land in the newest range
Hash
Hash of the key picks the shard
Even spread
Range queries scatter; changing the shard count moves keys
Directory
A lookup table maps keys to shards
Total flexibility; move one tenant at will
The directory must be fast, correct and available
QuickCart hashes the shopper id to a point on a ring, and a small ring map says which shard owns each arc: hash plus directory.
Choosing the shard key for orders
The shard key decides which queries stay on one shard. Choose it from the queries you must serve cheaply, not from the column that looks unique.
Candidate key
Stays on one shard
Becomes scatter-gather
Verdict
order_id
One order by id
"My orders"; a store's orders
Even spread, but the commonest query touches every shard
store_id
A store's order list
"My orders"
Skewed: store sizes differ a lot
placed_at
Recent-order reports
Almost all else
All new writes hit the newest range
shopper_id
Checkout, "my orders", history, status
A store's or courier's list
Even spread, matches dominant queries
QuickCart shards orders by shopper id. The price is that a store asking "my open orders" would hit every shard. Stores and couriers instead read a read model fed from order events (see the order pipeline and CQRS).
CREATE TABLE orders (
order_id bigintPRIMARY KEY, -- unique across all shards (see the ids section)
shopper_id bigintNOT NULL, -- the shard key
store_id bigintNOT NULL,
status text NOT NULL,
placed_at timestamptz NOT NULL
);
CREATE INDEX orders_by_shopper ON orders (shopper_id, placed_at DESC);
"My 20 latest orders" is WHERE shopper_id = $1 ORDER BY placed_at DESC LIMIT 20, answered by one shard from one index.
Hot shards, resharding and cross-shard queries
A hot shard gets far more traffic than its siblings. Hashing spreads ordinary shoppers evenly, so the risks are special cases: a business account ordering at a rate no household would, or a bot. Responses, in order: rate-limit abusive keys, give a heavy account its own shard (a directory entry makes this cheap), and only then split one key across shards.
Resharding changes the layout while the system runs. Consistent hashing limits what moves, but moving an arc still means copying rows, catching up with changes made meanwhile, and switching ownership at one instant (see the rollout below).
Cross-shard queries run on every shard and merge results, so latency is the slowest shard's, and a cross-shard transaction cannot use a plain local one. Checkout meets this at once: it touches the order (shopper's shard) and stock (the inventory side). QuickCart avoids two-phase commit by splitting the work into steps with compensations, a saga (Stage 5; see distributed transactions).
Shard router sends a shopper's orders to one shardOrder service to Shard router (shopper 4417); Shard router to Shard 3 (owner); Shard router to Ring map (lookup)Order service: x N podsOrder servicex N podsShard router: hash of shopper idShard routerhash of shopperidRing map: versioned, cachedRing mapversioned, cachedShard 1: primary, standby, replicasShard 1primary, standby,replicasShard 2: primary, standby, replicasShard 2primary, standby,replicasShard 3: primary, standby, replicasShard 3primary, standby,replicasShard 4: primary, standby, replicasShard 4primary, standby,replicasshopper 4417ownerlookup
Figure 4. Routing one order write.How to read this: the router hashes the shopper id, finds the owning arc in the cached ring map, and sends the write to that shard only. The others see nothing, which is the point of matching the shard key to the query.
Consistent hashing
Consistent hashing assigns keys to nodes so adding or removing a node moves only a small share of keys. With hash(key) mod N, going from 3 shards to 4 keeps a key in place only when its hash gives the same remainder for both, which happens for 3 of every 12 hash values, so 75 percent of keys move, at the moment you needed capacity.
Arrange hash values on a circle, the ring. Here positions run 0 to 99; a real ring uses something like 64 bits.
Hash each node (shard) to one or more points on the ring.
Hash each key, here shopper_id, to a point.
The key belongs to the first node point at or after it, walking clockwise and wrapping at the end.
Worked example with shards at A = 20, B = 50, C = 80:
Shopper
Hash position
First point clockwise
Owner
1001
14
20
A
1002
47
50
B
1003
63
80
C
1004
91
wraps to 20
A
1005
35
50
B
A owns 81 to 99 and 0 to 20 (40 positions), B owns 21 to 50 (30), C owns 51 to 80 (30). Add shard D at 65. D takes only the arc 51 to 65, which C owned. Shopper 1003 moves from C to D and everyone else stays: 15 of 100 positions moved, not 75.
flowchart LR
accTitle: Adding a node to a tiny ring moves one shopper
accDescr: Before adding node D, shopper 1003 at position 63 belongs to C. After D joins at position 65, only shopper 1003 moves to D; the other shoppers keep their owners.
subgraph B["Before: A=20, B=50, C=80"]
b1["1001 at 14"] --> bA["A"]
b2["1002 at 47"] --> bB["B"]
b3["1003 at 63"] --> bC["C"]
b4["1004 at 91"] --> bA
b5["1005 at 35"] --> bB
end
subgraph F["After: D joins at 65"]
a1["1001 at 14"] --> aA["A"]
a2["1002 at 47"] --> aB["B"]
a3["1003 at 63"] --> aD["D (new)"]
a4["1004 at 91"] --> aA
a5["1005 at 35"] --> aB
end
Figure 5. The tiny ring before and after adding a node.How to read this: arrows go from a shopper to the owning shard. Compare the two groups: exactly one arrow changed, the key inside D's new arc.
Removal is the mirror. If B leaves, its arc 21 to 50 goes to the next point clockwise, C, which then owns 21 to 80 (60 percent of the ring). One neighbour doubles, which virtual nodes (vnodes) fix: give each physical shard many points on the ring. Arcs become small, shares even out, a departing shard's load scatters over many neighbours, and an arriving shard takes a little from many. Cassandra's documentation describes the same idea (tokens) and notes more tokens slow cluster-wide maintenance. A lookup is a binary search:
typePoint = { position: bigint; shard: string };
// points must be sorted by position, ascendingfunctionownerOf(points: Point[], keyHash: bigint): string {
let lo = 0;
let hi = points.length; // find the first point with position >= keyHashwhile (lo < hi) {
const mid = (lo + hi) >> 1;
if (points[mid].position < keyHash) lo = mid + 1;
else hi = mid;
}
return points[lo === points.length ? 0 : lo].shard; // wrap around the ring
}
For hash 91 on the ring above, no point is at or after 91, so the lookup wraps to the first point, A. Use a hash that is stable across processes and languages (Python's built-in string hash is randomised per process), and version the ring map so every router can tell whether it is behind. QuickCart's choice: 4 shards with 64 virtual points each (a teaching example). A simpler alternative is fixed partitions: many more logical partitions than nodes, moved whole between nodes.
What you promise: CAP, PACELC and consistency models
CAP and PACELC
The CAP theorem is a precise result, not a menu. Gilbert and Lynch (2002) proved Brewer's conjecture for an asynchronous network: a read/write object cannot be guaranteed both consistent (atomic, that is linearizable) and available (every request to a non-failing node gets a response) in all executions where messages can be lost, that is, when the network may partition.
What it does not say:
It does not say "pick any two of three". Partitions are a fact of networks, so the real choice is C versus A during a partition.
"Consistency" means linearizability, not ACID consistency. "Availability" means every non-failing node responds, not "99.9 percent uptime".
It says nothing about normal operation or latency, which is what you feel daily.
PACELC (Daniel Abadi, "Consistency Tradeoffs in Modern Distributed Database System Design", IEEE Computer, 2012) adds the other half: if there is a Partition, choose A or C; Else, choose Latency or Consistency. Waiting for a synchronous standby buys C at the cost of L; answering from an asynchronous replica buys L at the cost of C.
flowchart TD
accTitle: What each QuickCart operation does during a partition
accDescr: A network partition separates a shard primary from its standby. Placing an order waits or refuses, an order status read is served by a replica that may be slightly old, and browsing serves a cached copy.
P["Partition: primary cannot reach its standby"] --> Q{"Which operation?"}
Q --> O["Place an order"]
Q --> C["Read an order status"]
Q --> B["Browse the catalogue"]
O --> O2["Choose C: commit waits or is refused, shopper retries"]
C --> C2["Choose A: serve the replica's copy, may be a moment old"]
B --> B2["Choose A: serve the cached copy, may be stale"]
Figure 6. CAP is decided per operation, not per system.How to read this: one partition, three answers. Money and identity choose consistency and accept waits or failures; a status read and a browse page choose availability and accept slightly old data.
In PACELC terms QuickCart's orders are PC/EC; the catalogue and browse pages are PA/EL. More in CAP and consistency.
Consistency models
A consistency model is a promise about what a read may return after writes. The strongest is only the most expensive, so use it where wrong data costs real money:
Linearizable ("strong"): each operation appears to happen atomically at one instant between its start and end, respecting real time; after a write completes, every later read sees it. A single primary gives this.
Sequential: everyone sees one agreed order that respects each client's own order, but not necessarily real time.
Causal: causally related operations (a reply, a write that depended on a read) are seen in that order by everyone; unrelated ones may differ.
Eventual: if writes stop, copies converge. Meanwhile reads can be old and readers can disagree.
Read-your-writes (a session guarantee): a client always sees its own earlier writes. Monotonic reads adds that it never sees data go backwards.
Data
Model needed
Why
How
Placing an order
Linearizable
Two orders must not claim one payment
Shard primary, synchronous standby
Stock decrement
Linearizable
Overselling costs money
Conditional update on the stock row's owner
Payment ledger
Linearizable
Money must be exact
Primary only (Stage 6)
"My order" after placing
Read-your-writes
The shopper must see what they did
Token or primary read (below)
Order status while tracking
Monotonic reads, else eventual
Status must not go backwards
Replica pinned per shopper
Price on the browse page
Eventual (seconds to minutes)
Cache TTL already allows it
Re-read at checkout
Courier location, search, analytics
Eventual
Derived or latest-wins data
Time-series store, events, batch loads
Cart contents
Read-your-writes
The shopper sees their own adds
Relational row (Stage 3), read as below
Read-your-writes for "my order just placed"
This is the bug from Figure 2, and it needs an explicit decision.
Option
How it works
Weakness
Read the primary for a window after a write
Fixed time, say 10 seconds (illustrative)
Primary load; lag longer than the window still shows the bug
remote_apply
Commit waits until a synchronous standby applied the write
Slower commits; covers only synchronous standbys, not every replica
Return data in the write response
Confirmation page renders from the 201 body
Covers only the first screen
Consistency token
Response carries the leader's WAL position; a replica serves the read only if it replayed at least that
A router component and a primary fallback
QuickCart combines the last two. POST /orders returns the full order, so the confirmation page needs no read. The response also carries an opaque consistency token (QuickCart's own design, not a standard) holding the primary's WAL position just after the commit. A later read passes it back, and the router uses a replica only if that replica has replayed at least that position; otherwise it reads the primary.
-- on the shard primary, right after COMMIT: the token to hand to the clientSELECT pg_current_wal_lsn(); -- for example '0/3000148'-- on a replica: may this replica serve the read?SELECT pg_last_wal_replay_lsn() >='0/3000148'::pg_lsn;
The router can keep each replica's last reported replay position from health checks. WAL positions only advance, so a stale value is conservative: extra primary reads, never a stale answer. Each shopper is also pinned to one replica (by hash of the shopper id) for monotonic reads.
flowchart TD
accTitle: Where to read an order after the shopper wrote it
accDescr: A decision flow. The confirmation page uses the write response. Later reads carry a token; if the chosen replica has replayed past the token it serves the read, otherwise the primary does.
S["Shopper opens an order"] --> F{"First screen after placing?"}
F -->|yes| R1["Render from the POST response, no read"]
F -->|no| T{"Request carries a consistency token?"}
T -->|no| R2["Read from the pinned replica"]
T -->|yes| L{"Replica replay position at or past token?"}
L -->|yes| R3["Read from the pinned replica"]
L -->|no| R4["Read from the shard primary"]
Figure 7. The read-your-writes decision.How to read this: most reads end at a replica, sparing the primary. Only a read that could expose lag goes to the primary.
Names that never collide: unique ids
Unique ID generation
With one database, an auto-increment column is perfect: compact, ordered, one source. With shards it breaks: shard 1 and shard 3 each count 1, 2, 3, so "order 1,000,017" can exist twice. Order ids travel far beyond the shard, into event streams, the courier app and support tools, where all orders mix. Ids must be unique across all shards without a central call per order.
Scheme
Layout
Strengths
Costs
UUID v4
122 random bits (RFC 9562)
No coordination
16 bytes; random inserts scatter across B-tree indexes (RFC 9562 notes poor index locality)
UUID v7
48-bit Unix millisecond timestamp, then random bits
No coordination; time-ordered, good index locality
16 bytes; reveals creation time
ULID
48-bit millisecond timestamp plus 80 random bits, 26 characters of Crockford Base32
Sortable text, easy in URLs
UUID-sized; order within one millisecond needs monotonic generation
Snowflake-style
64-bit integer: time, worker, sequence
8 bytes; time-ordered; no per-id call
Needs unique worker ids and a trustworthy clock
Time order matters because B-tree indexes like inserts near the end; v7 and ULID give it, v4 does not.
A Snowflake-style id is a design, not a library. The original Snowflake service split 64 bits like this; variants change the widths:
| 1 bit unused | 41 bits: milliseconds since a custom epoch | 10 bits: worker id | 12 bits: sequence |
2^41 milliseconds is about 69.7 years from the epoch; 10 worker bits allow 1,024 generators; 12 sequence bits allow 4,096 ids per millisecond per worker. QuickCart needs a sliver of that (58 checkouts per second at peak). Decoding one id:
Field
Example
Contribution
Milliseconds since epoch
86,400,000 (one day after the epoch)
86,400,000 x 2^22 = 362,387,865,600,000
Worker id
5
5 x 2^12 = 20,480
Sequence
3
3
Order id
362,387,865,620,483
QuickCart's epoch is 2026-01-01T00:00:00Z (1,767,225,600,000 Unix milliseconds). The generator refuses to issue an id when the clock moves backwards, because that is how duplicates are born.
constEPOCH_MS = 1_767_225_600_000n; // 2026-01-01T00:00:00Z; fixed foreverconstWORKER_BITS = 10n;
constSEQ_BITS = 12n;
constMAX_SEQ = (1n << SEQ_BITS) - 1n; // 4095exportclassOrderIdGenerator {
private lastMs = -1n;
private seq = 0n;
constructor(privatereadonlyworkerId: bigint) {
if (workerId < 0n || workerId >= 1n << WORKER_BITS) thrownewError("worker id out of range");
}
next(): bigint {
let ms = BigInt(Date.now());
if (ms < this.lastMs) thrownewError("clock moved backwards; refusing to issue ids");
if (ms === this.lastMs) {
this.seq = (this.seq + 1n) & MAX_SEQ;
if (this.seq === 0n) {
// sequence used up in this millisecond: wait for the next onewhile (ms <= this.lastMs) ms = BigInt(Date.now());
}
} else {
this.seq = 0n;
}
this.lastMs = ms;
return ((ms - EPOCH_MS) << (WORKER_BITS + SEQ_BITS)) | (this.workerId << SEQ_BITS) | this.seq;
}
}
Two duties follow. Each running generator needs a worker id nobody else holds: assign it from a coordination service, a stable instance number, or an expiring lease so a crashed worker's id is not reused while it might still be alive. And the clock must be disciplined (IDs, clocks and ordering covers both). Ids are ordered only to about a millisecond, so they are not proof of event order.
QuickCart's choice is Snowflake-style 64-bit ids from the order service: half a UUID's size in every key and index, time-ordered, unique across shards because the worker field differs, no network call. UUID v7 would also be sound for a team that prefers to run no worker leases. Shoppers see a separate random order code, since these ids are guessable in principle.
Decisions and trade-offs
Option
Gain
Cost
Wrong when
Synchronous standby (on, quorum of 1)
Acknowledged order survives primary loss
Commit latency; waits if none reachable
Data is cheap to lose
Multi-leader
Local writes, survives a link loss
Conflicts to resolve
Records have rules that cannot merge, like orders
Leaderless quorums
Always writable, no failover
Weak under sloppy quorums and concurrent writes
You need strict uniqueness or money
Hash on shopper id
Even spread; one-shard checkout
Stores need a read model
The dominant query is by store
Consistent hashing with vnodes
Small moves on growth
A ring map to version
You will never grow past a few fixed shards
Snowflake-style ids
Compact, time-ordered, no central call
Worker ids and a trusted clock
You cannot run leases and clocks safely; use UUID v7
In practice: QuickCart at this stage
This is a realistic scenario, not a case history. It is a composite built from common production patterns, it describes no real company, and every number in it is an illustrative assumption.
The situation
QuickCart is heading from 5,000,000 toward 10,000,000 monthly active users. Orders sit in one PostgreSQL primary with a read replica. The orders table is by far the largest, the restore test takes longer each month, and the forecast shows several terabytes inside the planning horizon. Even a clean failover stops checkout for everyone.
Starting architecture
Figure 1, with an auto-increment order_id and an index on shopper_id. No code is shard-aware.
Options considered
Option
What it fixes
What it costs
Risk
Bigger primary, monthly partitions, archive old orders
Table size, vacuum, index size
Larger instance
Keeps one failure domain and the write ceiling
Shard by shopper id on a hash ring
Size, write ceiling, blast radius
Router, ring map, new ids, read models
Medium: resharding and cross-shard queries
Leaderless store for orders
Always writable
Weak guarantees for money
Higher: wrong model
The decision
Field
Decision record
Context
One orders primary is one failure domain on a path to multiple terabytes; replicas cannot help writes.
Decision
4 shards (a teaching figure) by hash of shopper_id, 64 virtual points each, a versioned ring map. Each shard: a primary, a synchronous standby in another zone (ANY 1 over two standbys) and asynchronous replicas. Snowflake-style ids. Reads follow Figure 7. Stores and couriers read an event-fed model.
Consequences, good
A shard failure affects about a quarter of shoppers; restores and maintenance are per shard; capacity grows by adding shards.
Consequences, bad
Router, ring map, id generator and worker leases to run; cross-shard queries are slow by design; every order query needs the shopper id.
Revisit when
One shard exceeds its capacity budget while others idle (skew), or the read model lags beyond its limit.
Rollout and verification
Ids first. New orders get Snowflake-style ids; old small ids stay valid and cannot overlap them.
Router with a one-shard ring. The ring map assigns every arc to the existing database, so behaviour is unchanged; only the code path is new. Run it behind a flag.
Provision shards and copy one arc at a time. Bulk copy, then catch up with changes (logical replication or change data capture). Verify row counts and checksums per arc.
Cut over an arc. Pause that arc's writes for seconds, apply the last changes, publish the next ring-map version, resume. Every shard rejects a write for an arc it does not own, so a router with an old map cannot misplace data.
Verify. Shadow-read old and new owners and compare; watch per-shard write rate, skew, replica lag, rejected writes and primary read share.
Rollout: an arc moves from the old primary to a new shardOrder service to Shard router; Shard router to Old primary (before); Shard router to New shard (after); Old primary to New shard (copy, catch up)Order serviceOrder serviceShard router: ring map v7 to v8Shard routerring map v7 to v8Old primary: owns the arc until cutoverOld primaryowns the arcuntil cutoverNew shard: owns it after cutoverNew shardowns it aftercutoverbeforeaftercopy, catch up
Figure 8. One arc moving during the rollout.How to read this: data flows from the old owner to the new one (dashed) while writes still go to the old owner. At cutover the map version changes and writes go to the new shard.
The 2 a.m. failure
At 02:00, during an arc cutover, one order-service pod misses the ring-map update because its watch connection dropped. For nine minutes (illustrative) it keeps sending writes for shoppers in the moved arc to the old shard. Next morning support sees "my order vanished" tickets: the orders exist, but on a shard that no longer owns the arc, so "my orders" on the new owner does not show them.
The alert says "rejected writes by non-owner shard above 0" or "ring-map versions differ across pods". First three checks: the map version each pod reports; rejected-write counts on the old shard (the step 4 ownership check makes this loud; without it orders land silently in the wrong place); orders on the old shard with placed_at after the cutover. Mitigation: force-refresh the stale pod, then replay the misplaced rows to their owner. Because order ids are unique across shards, they copy over with no collision. Follow-ups: pods refuse to serve with a map older than the control plane's, and the runbook resumes writes only after every pod confirms the new version.
Architecture after this stage
QuickCart architecture after Stage 4: sharded ordersShoppers to App servers; App servers to Id generator (next id); App servers to Redis cache (hot reads); App servers to Shard router (order ops); App servers to Stage 3 stores (unchanged); Shard router to Ring map (lookup); Shard router to Orders shard 2 (one shard)SHARDED ORDERS DATABASEShoppersShoppersApp servers: behind the load balancerApp serversbehind the loadbalancerRedis cacheRedis cacheStage 3 stores: catalogue, searchStage 3 storescatalogue, searchId generator: in order serviceId generatorin order serviceShard router: hash of shopper idShard routerhash of shopperidRing map: versionedRing mapversionedOrders shard 1: primary, standby, replicasOrders shard 1primary, standby,replicasOrders shard 2: primary, standby, replicasOrders shard 2primary, standby,replicasOrders shard 3: primary, standby, replicasOrders shard 3primary, standby,replicasOrders shard 4: primary, standby, replicasOrders shard 4primary, standby,replicasnext idhot readsorder opsunchangedlookupone shard
Figure 9. QuickCart after Stage 4.How to read this: highlighted boxes are new in this stage; muted ones are unchanged. Order traffic passes through the router to one of four shards; everything else is as in Stage 3.
What QuickCart looks like now
Component
Why it exists
What it costs
Orders shards (4, each primary, sync standby, replicas)
Split writes and size; limit one failure
Four databases to run, back up and upgrade
Shard router and ring map
Send each shopper to the owning shard
A critical component; map versions must agree
Order id generator
Ids unique across shards, time-ordered
Worker leases and a disciplined clock
Synchronous standby per shard
Acknowledged orders survive a primary loss
Commit latency; waits if none is reachable
Consistency token
"My order just placed" shows the order
Router logic, primary fallback reads
Event-fed read model
Store and courier queries without the shopper id
Lag and one more projection
Architect's notes
Shard last, but design for it early. Make every order query name its shard key long before you split; adding it later touches every query.
Version everything that routes. The ring map and each shard's ownership check make resharding safe.
Name the id owner. Worker leases and clock policy need an owner.
Common mistakes
Sharding for the write rate alone. At these numbers one primary may cope.
Choosing the key from the table, not the queries.
hash(key) mod N. It works until you add a shard, then moves most of the data.
Trusting W + R > N in every case. Sloppy quorums, concurrent writes and clock-based last-write-wins break it.
Per-shard auto-increment ids. They collide once data from two shards is combined.
Trusting the clock in an id generator. Refuse on a backwards step.
Key takeaways
Replication copies data for availability and read scale; sharding splits it for write scale and size, and each shard is a replicated group.
Synchronous commit trades latency and availability for durability; asynchronous replication trades possible loss and stale reads for speed. Multi-leader and leaderless designs buy write availability at the cost of conflicts.
Choose the shard key from the dominant query: shopper_id for QuickCart orders. Consistent hashing with virtual nodes moves a small share of keys when a shard joins.
CAP concerns linearizability and availability during a partition; PACELC adds latency versus consistency in normal operation.
Assign a consistency model per data type: money and orders strong, browsing eventual, "my order" read-your-writes.
Order ids must be unique across shards; Snowflake-style or time-ordered UUID ids do that without a central call.
Practice exercises
Exercise 1 - Starter (30 min): On the tiny ring (A = 20, B = 50, C = 80), place shoppers at hash positions 5, 22, 50, 51, 80 and 99. Add shard D at 35 and list who moves and what share of the ring moved.
Exercise 2 - Intermediate (2 hours): Add three QuickCart data types to the consistency table (for example delivery slots or coupon redemptions). Choose a model for each and say what breaks under a weaker one.
Exercise 3 - Intermediate (1 hour): Decode the id 362,387,865,620,483 into its three fields, then compute the id 100 ms later from worker 5 with sequence 0.
Exercise 4 - Advanced (half day): Extend ownerOf to return the owner plus the next two distinct shards clockwise (a replica set for N = 3), skipping repeated physical shards when virtual nodes are used.
Lesson 4 of 11 · Backend System Design Project: Build QuickCart
Split QuickCart's order data across shards with consistent hashing, choose replication and consistency per data type, and generate order ids that never collide.
In the rest of this lesson
Splitting the data: sharding
What you promise: CAP, PACELC and consistency models
Names that never collide: unique ids
Decisions and trade-offs
In practice: QuickCart at this stage
Architecture after this stage
Enrolling is free. It unlocks every lesson in Backend System Design Project: Build QuickCart, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.