Lesson 7 solved the read tier very cheaply. But it does nothing for two things: write throughput β every write still funnels into one primary β and capacity β every replica holds the complete dataset. When either of those exceeds what one machine can take, there is no option left but to split the data: sharding.
This is the most expensive step in the whole series, and the price is not money but permanent complexity: no cross-shard transactions, no JOINs, pagination becomes hard, and every query that does not carry the shard key turns into a full sweep.
Two measurements in this lesson stand out. First, the share of data that must migrate when you add a shard: modulo hashing going from 4 to 5 shards moves 79.97% of the data, while consistent hashing moves only 18.78% β and both match the theoretical formula almost perfectly. Second, a skewed shard key makes p99 10.3 times worse while p50 actually gets better β the hotspot lives entirely in the tail.
node running sysdesign-hashring.js over 100,000 keys, with no Docker
needed.The 1-CPU-per-shard limit is not a decorative detail β see section 8.2, where its absence made the lab measure the exact opposite result.
8.1 When you are forced to shard β and why it should be the last step
The correct order of priority, cheapest to most expensive. Take each step only once the previous one has run out:
| Step | What it solves | Complexity cost |
|---|---|---|
| 1 Β· Optimise queries and add indexes | Most real-world "the database is slow" problems | Practically none |
| 2 Β· Caching (Lesson 5) | Read load; each extra nine of hit ratio divides database load by ten | Low, but it adds the invalidation problem |
| 3 Β· Read replicas (Lesson 7) | Read throughput | Moderate: lag and read-your-writes |
| 4 Β· Scale the primary vertically | Write throughput, up to the hardware ceiling | Low, but there is a ceiling and it is expensive |
| 5 Β· Sharding | Write throughput and capacity, with no ceiling | High and PERMANENT β there is no easy way back |
Three signs that you genuinely need to shard, rather than just feeling that you do:
| Sign | Why replicas and caching cannot help |
|---|---|
| The primary is saturating write I/O, not CPU | Replicas multiply read load rather than dividing write load; a cache does not sit in front of the write path |
| The data exceeds the largest disk you can buy | Every replica holds the complete dataset, so adding replicas adds no capacity |
| Backup and restore times have become unacceptable | This is the most overlooked sign of the three. A 5 TB database means your RTO (Recovery Time Objective β the maximum time you commit to for recovering after an incident) is measured in hours, even while the system runs perfectly |
JOIN, no cross-shard transactions, pagination becomes a project of its own, every query
without the shard key turns into a scatter-gather (it must ask every shard and
then merge the results, so latency equals the slowest shard and load is multiplied by the shard count) β
in exchange for a benefit you may never need.The test question is very simple: have you measured, and do you know exactly what is saturating? If the answer is "no, but it's probably the database", then the next step is to go and measure, not to go and shard. Very often the thing saturating is one query missing an index, and a single
CREATE INDEX solves
the problem that sharding would charge you for over years.
8.2 Choosing a shard key β and a measurement that nearly inverted the conclusion
A good shard key has to satisfy all three of these, and the third is the one most often forgotten:
| Requirement | What happens without it |
|---|---|
| High cardinality (the number of distinct values that column can take) |
Few distinct values β you cannot split into many shards, however perfect the hash function. For
example: country has around 200 values and plan has 3 β no hashing will
ever produce 50 balanced shards from them; user_id has as many values as you have users
|
| Even distribution | Hotspot: one shard receives nearly all the load while the others sit idle |
| Matching how you query | Every query without the shard key must ask every shard and merge the results (scatter-gather) β latency equals the slowest shard, and load is multiplied by the shard count |
created_at or by an auto-incrementing ID sounds very natural and is very easy
to understand. The consequence: every new write funnels into one shard β today's shard
β while the older shards receive only thin read traffic. You have paid the full complexity cost of
sharding without receiving the thing you bought: split write load.Sharding by tenant has the same problem in another form: if one tenant is a hundred times larger than the rest, the shard holding it is a separate, overloaded system. In that case you usually have to handle the giant tenant specially β give it its own shard β because no shard key solves it by hashing.
Measured for real: a good shard key versus a skewed one
The lab stands up two completely independent PostgreSQL shards β no replication, no knowledge of
each other β and a router inside the app decides which key goes to which shard. The skew
mode reproduces the "one giant tenant" situation exactly: 95% of requests use the same shard key.
24 connections, 12 seconds, 20,000 distinct keys, each shard limited to 1 CPU, two repetitions:
| Metric | Good shard key | Skewed shard key |
|---|---|---|
| Load distribution | 49.3% / 50.7% | 97.0% / 3.0% |
| Spread | 2.7% / 3.0% | 188.0% |
| Throughput | 12,024 / 11,982 rps | 9,099 / 9,078 rps |
| p50 | 1.96 / 1.98 ms | 1.73 / 1.73 ms (better!) |
| p95 | 3.03 / 3.01 ms | 2.84 / 2.82 ms (still better) |
| p99 | 3.59 / 3.59 ms | 37.08 / 36.59 ms |
| Maximum latency | 12.19 / 14.45 ms | 48.47 / 50.62 ms |
The good shard key gives +32% throughput. But read the three percentile rows carefully: the skewed key has a better p50 and a better p95, and then p99 suddenly gets 10.3 times worse. A hotspot does not slow most requests down β it concentrates the entire damage in the tail.
This is the third time the same pattern has appeared in this series: in Lesson 3 round robin produced a balanced chart but a p95 340 times worse; in Lesson 5 single-flight made p50 worse but cut the tail 11-fold; here a skewed shard key wins on p50 and p95 and then loses 10-fold on p99. If your dashboard only has the mean and p95, you would draw the wrong conclusion all three times.
The cause: both shards sit on the same machine and share its CPU. The evenly distributed configuration keeps two PostgreSQL containers busy, adding CPU contention with the three apps and the load generator; the skewed configuration only keeps one container busy. On a single machine, "evenly distributed" is a disadvantage.
But that is precisely what sharding exists to overcome: the limits of one machine. A lab on one laptop cannot measure that benefit unless we impose a resource limit per shard to simulate "each shard is its own machine". Adding
cpus: '1.0' per shard β for exactly the same reason
each app has been limited to 1 CPU since Lesson 2 β flipped
the result back to what theory predicts.The general lesson: when a measurement contradicts theory, the most likely explanation is that the measurement environment has cancelled out the very phenomenon being measured. The question to ask is "does this setup even give that phenomenon a chance to appear", before concluding the theory is wrong.
The lab's shard router, exactly as the running code has it:
// FNV-1a, then mixed with MurmurHash3's fmix32.
// Why the mixing step is needed: FNV-1a has POOR avalanche for short, similar strings
// ('user:1', 'user:2'...), so consecutive keys land on the same shard in clumps.
// The fmix32 step is cheap and removes that effect entirely.
function shardHash(key) {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
h ^= h >>> 16;
h = Math.imul(h, 2246822507);
h ^= h >>> 13;
h = Math.imul(h, 3266489909);
h ^= h >>> 16;
return h >>> 0;
}
// WARNING: this is modulo hashing. Simple, but read section 8.3 before using it for
// real β adding one shard means migrating nearly all of the data.
function pickShard(shardKey) {
return shardHash(String(shardKey)) % pgShards.length;
}
8.3 Modulo hashing and the resharding disaster
The simplest way to divide is hash(key) % N, where $N$ is the shard count. It is short, easy
to understand, evenly distributed, and it is exactly what the lab's router does. The problem shows up on
the day you add shard number $N+1$.
A key stays where it is only if $h \bmod N = h \bmod (N+1)$. By the Chinese remainder theorem, when $N$ and $N+1$ are coprime β and two consecutive integers always are β the pair $(h \bmod N,\ h \bmod (N+1))$ is uniformly distributed over $N(N+1)$ possibilities. The number of pairs satisfying the stay condition is $N$, so:
$$P(\text{stay}) = \frac{N}{N(N+1)} = \frac{1}{N+1} \quad\Rightarrow\quad P(\text{move}) = \frac{N}{N+1}$$
Note that this figure is $N/(N+1)$, not $(N-1)/N$ as it is often quoted. Run for real over 100,000 keys, comparing both algorithms, with consistent hashing using 150 virtual nodes:
| Adding a shard | Modulo β measured | Modulo β theory $\frac{N}{N+1}$ | Consistent β measured | Consistent β theory $\frac{1}{N+1}$ |
|---|---|---|---|---|
| 2 β 3 | 66.81% | 66.67% | 37.19% | 33.33% |
| 3 β 4 | 74.89% | 75.00% | 29.46% | 25.00% |
| 4 β 5 | 79.97% | 80.00% | 18.78% | 20.00% |
| 8 β 9 | 88.74% | 88.89% | 12.76% | 11.11% |
| 16 β 17 | 94.03% | 94.12% | 5.74% | 5.88% |
Modulo matches theory to two decimal places on every row. And the number worth remembering is the 4 β 5 row: adding one shard to a four-shard system forces 80% of the data to move. With 16 shards the figure is 94% β the more shards you have, the worse adding one more becomes.
Consistent hashing tracks $1/(N+1)$ closely but deviates more visibly at small $N$ (37.19% against a theoretical 33.33% at 2 β 3). The reason: with few nodes, the positions of the hash points on the ring have high variance, so the actual ratio fluctuates around the expected value. The more nodes and the more virtual nodes, the closer the match.
One: every cache layer using the same hashing misses completely too β if you were running a 99% hit ratio, database load goes up 100-fold at exactly the moment it is also migrating data (this connects back to Lesson 5 section 5.1).
Two: during the migration a key may live on the old shard, the new shard, or both β so the application layer has to know how to read from both and which one is authoritative.
Three: if you have to stop halfway you are in a state that is neither the old one nor the new one. That is why consistent hashing is not merely "better on the numbers" β it is the precondition for adding a shard being a routine operation rather than a project.
8.4 Consistent hashing and virtual nodes
The idea: instead of hashing the key into a shard index, hash both the key and the shard name into the same space β a 32-bit ring. Each key belongs to the first shard it meets going clockwise. Adding a shard only takes over the arc immediately before it, so only the keys inside that arc have to migrate.
node running sysdesign-hashring.js, with no
Docker needed β you can repeat this in seconds.
The full table, with the same 8 nodes and 100,000 keys (the ideal is 12,500 keys per node):
| Virtual nodes / shard | Smallest node | Largest node | Spread |
|---|---|---|---|
| 1 | 66 | 25,465 | 203.2% |
| 2 | 1,002 | 28,766 | 222.1% |
| 5 | 5,621 | 18,705 | 104.7% |
| 10 | 8,428 | 20,368 | 95.5% |
| 50 | 10,599 | 14,722 | 33.0% |
| 150 | 11,514 | 14,688 | 25.4% |
| 500 | 10,887 | 13,483 | 20.8% |
The first row is the reason virtual nodes exist: with one hash point per node, one shard receives 66 keys while another receives 25,465 β a 386-fold difference, for an algorithm still routinely introduced as "evenly distributed".
That has a practical consequence: do not pick a small vnode count and raise it until it looks acceptable, because the curve is not monotonic and you may stop at a lucky point. Pick a sufficiently large value outright (100β200 is the usual range in practice) and verify by measuring with your own key set. The cost is memory and ring lookup time β both grow with the vnode count, so 500 is not always better than 150 (measured 20.8% against 25.4%, in exchange for more than three times as many points to hold).
Both tables above can be reproduced in seconds, with no Docker. One small note so it does not alarm you:
Node will first print a MODULE_TYPELESS_PACKAGE_JSON warning β that is only because the
repo's package.json does not declare "type": "module"; the results below it are
still correct.
# No Docker needed β just node
cd blog/sysdesign
node --input-type=module -e "
import { compareAddNode, makeKeys, HashRing } from './sysdesign-hashring.js';
const keys = makeKeys(100000);
for (const n of [2,3,4,8,16]) {
const nodes = Array.from({length:n},(_,i)=>'s'+(i+1));
const r = compareAddNode(keys, nodes, 's'+(n+1), 150);
console.log(n+'->'+(n+1),
'modulo', (r.modulo.ratio*100).toFixed(2)+'%',
'(theory', ((n/(n+1))*100).toFixed(2)+'%)',
'| consistent', (r.consistent.ratio*100).toFixed(2)+'%',
'(theory', ((1/(n+1))*100).toFixed(2)+'%)');
}
for (const v of [1,2,10,150,500]) {
const ring = new HashRing({ vnodes: v });
for (const n of ['s1','s2','s3','s4','s5','s6','s7','s8']) ring.addNode(n);
const d = ring.loadDistribution(keys);
console.log(v+' vnode: min', d.min, 'max', d.max,
'spread', (d.spread*100).toFixed(1)+'%');
}
"
# And the self-test suite of 42 assertions, which covers the hash ring too
node sysdesign-engine-selftest.mjs
8.5 The consequences at the application layer β the genuinely expensive part
Choosing a good shard key and using consistent hashing only solves the infrastructure part. The expensive part is at the application layer, because four things you have always taken for granted are gone.
| What you lose | Why you lose it | What has to replace it |
|---|---|---|
JOIN across two tables on different shards |
Two shards are two databases that know nothing about each other | Replicate the small table to every shard, or give both tables the same shard key so they always land together |
| Transactions spanning several shards | There is no transaction coordinator between independent databases | Redesign so each transaction fits inside one shard; if that is impossible, use a saga (Lesson 14) |
| A globally auto-incrementing key | Each shard has its own sequence β two shards produce the same ID | UUIDs, or IDs with the shard embedded, or Snowflake-style IDs (Lesson 10) |
| Queries on a column that is not the shard key | You do not know which shard the data is on | A global secondary index (a lookup table mapping value β shard), or accepting scatter-gather |
JOIN is gone, the natural reflex is to pull the data from both shards into the app and
stitch it together in memory. For a few hundred rows that is fine. For a few hundred thousand you have
just turned a distributed database into a network bottleneck β and worse, the bottleneck is in the app
rather than the database, so every database dashboard stays green while the system is slow.This is the same fan-out problem as Lesson 4 section 4.4, with exactly the same two rules: call the shards in parallel (latency equals the slowest shard, not the sum), and give each shard its own timeout so one slow shard cannot drag the whole query down.
But the best answer is usually not to optimise the stitching, it is to not need it: choose a shard key such that data queried together lives together. That is the third requirement from section 8.2 β "matching how you query" β and it matters more than the first two.
There is one more thing that no advice makes easy: pagination across shards. To get "the 20 most recent rows" across 8 shards you have to fetch the 20 most recent from each shard (160 rows), merge, sort, then cut to 20. The second page needs 40 per shard, and page 100 needs each shard to return 2,000 rows so that 20 can be used. The cost grows with the page number, not with the number of rows you want.
The usual escape is to drop page-number pagination in favour of cursor (keyset) pagination: "give me 20 rows after this marker". That removes the per-page cost growth, but in exchange the user can no longer jump to page 100 β a product change, not just a technical one. This is a very characteristic sharding trade-off: an infrastructure limit travels back up and shapes the feature set.
Reproduce the shard measurements yourself
cd blog/sysdesign/sysdesign-lab
docker compose --profile shard up -d # two INDEPENDENT PostgreSQL shards + the app router
# Check that the router really does distribute
for k in a b c d e f; do curl -s "http://localhost:3001/shard?key=$k&mode=good"; echo; done
# --- GOOD shard key ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/shard?mode=good" \
-c 24 -d 12 -w 2 --json --key-space 20000
./tools/cache-stats.sh # look at the SHARD line + the spread
# --- SKEWED shard key (95% of requests share one shard key) ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/shard?mode=skew" \
-c 24 -d 12 -w 2 --json --key-space 20000
./tools/cache-stats.sh
# IMPORTANT: if you drop the `cpus: '1.0'` limit on the two shards in docker-compose.yml
# the result INVERTS β the skewed key will look faster. The reason is in section 8.2.
In summary
Sharding is the last step because it is the only one with a permanent cost: no JOINs, no cross-shard transactions, pagination as a project of its own. Four cheaper steps come before it, and the test question is always "have you measured, and do you know exactly what is saturating".
Modulo hashing is simple, but adding one shard forces $N/(N+1)$ of the data to migrate β measured at 79.97% going from 4 to 5 shards, matching theory to two decimal places. Consistent hashing brings that down to 18.78%, but only with virtual nodes: without them, one shard receives 66 keys while another receives 25,465.
And the measurement lesson repeats for the third time in this series: a skewed shard key has a better p50 and a better p95 than a good one, and then a p99 10.3 times worse. Plus a new lesson: the first run of section 8.2 produced the exact opposite result, because both shards shared one machine's CPU β the measurement environment had cancelled out the very phenomenon being measured.
Lesson 9 formalises the question section 7.4 left open: when the network is cut, and neither node knows whether the other has died or has merely lost contact, what do you choose? That is CAP β and the part that matters more than CAP itself, which fewer people talk about: the choice you face every single day, while the network is perfectly healthy.
π References
- Karger et al. (1997) β Consistent Hashing and Random Trees (free PDF at MIT): the original paper introducing consistent hashing, the foundation for all of section 8.4
- Amazon (2007) β Dynamo: Amazon's Highly Available Key-value Store: section 4.2 introduces virtual nodes for exactly the reason measured in the table in 8.4
- Wikipedia β the FNV-1a hash function, the algorithm the lab's shard router uses as its first step
-
MurmurHash3 β the source of the
fmix32mixing step, and of the avalanche property that step fixes - Wikipedia β the Chinese remainder theorem, the basis of the $N/(N+1)$ proof in section 8.3
-
MDN β
Math.imul(): why it is needed instead of*when multiplying 32-bit integers inside a hash function - PostgreSQL β Table Partitioning: splitting a table within one database, the step often confused with sharding and worth trying first
Download the lab source
The hash ring and the modulo-versus-consistent comparison that produced every number in sections 8.3 and
8.4. No dependencies, and it runs straight away with node β no Docker required:
Comments