Sharding Strategies
Sharding splits data across machines so no single database limits storage or write throughput. The shard key you pick is the most important decision.
- 80% vs 20%keys moved 4 to 5 nodes: modulo vs ring
- 16,384hash slots in Redis Cluster
- 4,096/msSnowflake ids per worker per millisecond
Show what to say
“I shard last, only when writes or data size outgrow one primary after indexes, caching and replicas. The shard key matters most: high cardinality and matching the main query, usually user_id. I'd use consistent hashing with virtual nodes, so adding a node moves about 1 over N of the keys instead of nearly all of them.”
Trade-offs
- Hash vs range sharding: hash spreads evenly but loses range scans; range keeps scans cheap but timestamps and auto-increment ids make a hot shard.
- Directory vs hash: a lookup table lets you move one huge tenant alone, at the cost of an extra, cached lookup.
- Salting vs a plain key: salting spreads a hot key over N shards, but reads cost N times more, so salt only measured hot keys.
Bring it up before they ask
A query without the shard key becomes scatter-gather, where the slowest shard sets the latency, so keep those off hot paths.
New to the words? Read the one-minute primer
only when one database can't hold the data or the writes. The matters more than the scheme; moves only about 1/N of keys when a node joins.
When to shard
One database is one checkout till.
Read more · 340 words
opens more tills: it splits the data across several databases (), each holding a slice.
It is the last resort, because joins, and changes get harder forever. Try the cheap fixes first:
- A bigger machine.
- Indexes. Most "slow databases" are missing one.
- and for reads.
- Archiving cold rows.
Shard when writes or data size outgrow one , rarely for reads alone. Do the sum first. 500 million users at 5 KB each is 2.5 TB. That fits on one big machine, so size alone is a weak reason. Writes are the usual one.
| Your system | Shard? | Why |
|---|---|---|
| 200 GB, 2K writes a second | No | One tuned primary copes |
| 2.5 TB, 3K writes a second | Not yet | Try a bigger machine, replicas, a cache |
| 5 TB, 50K writes a second | Yes | One primary cannot take the writes |
The numbers are rough. A tuned primary often handles a few thousand writes a second; tens of thousands is where sharding starts to pay for its pain. Walk the tree with your own numbers. Most systems stop at the first or second question.
Have you tried indexes, a bigger machine, a cache, read replicas and archiving cold rows?
Choosing the shard key
The decides which queries stay on one and whether the load spreads, so it matters more than the scheme you hash it with.
Read more · 420 words
A good key:
- Has many values.
countryhas about 200, so 200 shards at most. - Spreads evenly. No value owns a big share of the traffic.
- Matches your main query. Mostly "this user's orders"? Use
user_id. - Avoids hot ranges. Timestamps and auto-increment ids pile new writes on one shard.
Run a candidate through the tree. Every "no" is a reason to pick a different column.
Try some keys for an orders table:
| Key | Verdict | Why |
|---|---|---|
user_id | Good | Many values, spread evenly, one user's orders stay together |
country | Weak | About 200 values, and one country can hold most users |
is_premium | Bad | Two values means two shards, ever |
created_at | Bad | Every new write lands on the newest shard |
The usual answer is user_id (tenant_id in SaaS). It is the default: start there unless the main query says otherwise.
Skew is how much more the busiest shard carries than its fair share, and it sets your real capacity. 10,000 writes a second over 4 shards is 2,500 each when the key spreads evenly. Take some made-up but plausible workloads. If one country holds 60% of users, its shard takes 6,000 writes a second, 2.4 times the fair 2,500. If one tenant is 40%, that shard takes 4,000. With created_at ranges the newest shard takes all 10,000.
If one shard tops out at 5,000 writes a second, the country key already fails at 6,000, and the timestamp key fails at twice its limit, while three machines sit idle. Adding shards does not fix skew: the hot value still lands on one of them.
Store the data twice. When one key cannot serve two big access patterns, keep two copies with two keys. Buyers ask "my orders" and sellers ask "orders for my shop", and no single column serves both. Write the order once to the store by buyer_id, then copy it by event into a second store sharded by seller_id. Use it when both queries are on hot paths. The price is double storage and a lag of milliseconds to seconds on the second copy. If only one pattern is hot, serve the other with instead.
Does your most common query filter on this column?
Show data table
| % | |
|---|---|
| user_id, hashed | 25% |
| tenant, one at 40% | 40% |
| country, one at 60% | 60% |
| created_at ranges | 100% |
Tap any box to see what it is and what it talks to.
- Service
- Database
- Queue
Text description
- A buyer places an order. It is written to the store sharded by buyer_id, the source of truth.
- The service publishes an order-created event.
- A consumer inserts the same order into the store sharded by seller_id, a few milliseconds to seconds later.
Strategies and consistent hashing
A router (a library or a proxy) turns the into a shard, like a mail sorter reading the postcode.
Read more · 765 words
Play the four scenarios.
There are five ways to turn a shard key into a shard, and hash on user_id is the default answer. Pick another only when a specific need names it.
| Strategy | How it works | Use it when | Costs |
|---|---|---|---|
| Hash mod N (the default) | hash(key) mod N picks the shard. Load spreads evenly because hashes look random, and a lookup by key touches one shard | You read and write by key and do not scan ranges: users, orders by user, sessions. MongoDB hashed shard keys work this way; Cassandra and DynamoDB hash partition keys too, though they use a ring or partitions rather than a plain mod | Range scans (every shard answers). Growth: plain mod moves N/(N+1) of keys when N changes |
| Range | Each shard holds a contiguous slice of the key space, so neighbours live together and a range scan stays on one shard | You scan by key interval and new keys do not arrive in order: (sensor_id, time), alphabetical listings, a leaderboard cut into score bands. Bigtable, HBase and Spanner split ranges this way, automatically when a range grows | Hot last shard on timestamps or auto-increment ids, where every new write hits the last range |
| Directory | A table says which shard each key lives on, so you can place any key anywhere and move it later | A few keys are huge or special: a SaaS with 1 million small tenants and a handful 50 times the median, or a tenant that pays for isolation. 1 million tenants at about 50 bytes each is 50 MB, so the directory fits in memory | An extra lookup on every request (cache it, about 1 ms); a directory that must be replicated (a single point of failure); stale entries after a move. The shard must refuse a tenant that is no longer its own, so the app refreshes and retries |
| Geo | Each user's data lives in the region where they live, chosen once at signup. It is a directory whose key is a region | Law or latency demands it: data residency rules that keep EU data in the EU, or a round trip across the Atlantic (about 90 ms) against a local one (under 10 ms) felt on every click. Balance is not the goal, so a busy region simply gets more shards | Global queries that fan out across oceans, and users whose home region is wrong |
| Fixed slots (Redis Cluster) | Hash every key into a fixed number of slots (16,384 in Redis Cluster), then assign slots to machines. Adding a node moves whole slots, so keys are never rehashed | You want growth without consistent hashing: about the same 25% moved going from 3 to 4 nodes. Pick many more logical shards than machines, as the resharding section shows | Slots must be assigned and tracked |
Play Add a shard to see the catch of plain mod.
. Picture a clock face. Nodes and keys both land on it, and a key belongs to the next node clockwise. Add a node and only its neighbours' keys move. Drag (extra points per node) down to 1 and watch the load drift uneven.
With hash % N, N/(N+1) of keys move when you add a node. A ring moves only 1/(N+1).
At 4 to 5 nodes, that is 80% versus 20%: the size of a 's miss storm. With 1 million keys, hash mod N moves 800,000 and the ring moves 200,000. Even doubling from 4 to 8 nodes moves half the keys with mod, because a key stays only when hash mod 4 equals hash mod 8.
gets the same 1/N result without a ring: every key scores each node with hash(node + key) and picks the highest. Removing a node moves only its own keys. It is simpler to code, and it costs one hash per node per lookup, fine for tens of nodes.
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
Text description
- The app saves an order for userId 42 through the shard router.
- hash(42) mod 3 = 2, so the row goes to shard 2. Any query with the user id touches exactly one shard.
- Shard 2 replicates to its own replica: every shard is a small primary/replica cluster.
- Shard 2 confirms the commit.
- Done. Later reads for user 42 run the same function and land on the same shard.
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
Text description
- Fetch user 42. Say hash(42) is 7,000,013.
- 7,000,013 mod 3 is 2, so the router asks shard 2 and nobody else.
- One shard, one lookup. Even load is the strength of hashing.
- hash % N
- Consistent hashing
Show data table
| Nodes before adding one (N) | hash % N (%) | Consistent hashing (%) |
|---|---|---|
| 2 | 66.7 | 33.3 |
| 3 | 75 | 25 |
| 4 | 80 | 20 |
| 5 | 83.3 | 16.7 |
| 6 | 85.7 | 14.3 |
| 7 | 87.5 | 12.5 |
| 8 | 88.9 | 11.1 |
| 9 | 90 | 10 |
| 10 | 90.9 | 9.1 |
| 11 | 91.7 | 8.3 |
| 12 | 92.3 | 7.7 |
| 13 | 92.9 | 7.1 |
| 14 | 93.3 | 6.7 |
| 15 | 93.8 | 6.3 |
| 16 | 94.1 | 5.9 |
| 17 | 94.4 | 5.6 |
| 18 | 94.7 | 5.3 |
| 19 | 95 | 5 |
| 20 | 95.2 | 4.8 |
Cross-shard queries, transactions and ids
Any query without the must ask every , and any write across two shards loses the database's .
Read more · 675 words
"Top 10 posts today" on 64 shards is 64 calls for one question.
sends the query to every shard in parallel and merges the answers, and the slowest shard sets the . Say each shard is slow 1% of the time. With 64 shards, the chance that at least one is slow is 1 minus 0.99 to the 64th power, about 47%. Your per-shard becomes nearly the median. Use it for admin pages, reports and rare searches. For a hot path, precompute: a background job keeps the answer in a small table. Play , One slow shard and Precomputed fix.
Global . A second table, by the column you want to search, that maps it to the : for a login by email, one lookup in the index, then one lookup on the right user shard. Use it for the few lookups on other columns that happen on every request: email, phone, username. Compare a local index, which sits inside each shard and still needs a fan-out. The index costs a second write, and the two writes are not one transaction, so it can lag or miss. Write it through an and retry a not-found once. Unique columns need care: two users signing up with the same email on different shards need the index to be the arbiter.
Cross-shard transactions. locks rows on every shard until a coordinator says commit, so it is slow, and if the coordinator dies the locks hang. The better answer is to design it away: keep data that changes together on one shard (an order and its lines both keyed by user_id). When a workflow must span shards, use a : a chain of local transactions, each with a written undo. Play payment fails.
A saga is not isolated: other readers can see the reserved item before the charge settles. Make every step and every undo safe to retry, and keep the orchestrator's progress in a table so a crash resumes instead of forgetting.
Ids across shards. Two auto-increment primaries hand out the same id. Options:
- UUIDs: time-ordered UUIDv7 is fine; random v4 fragments indexes.
- A : simple, but one more service.
- : timestamp, worker id and sequence in 64 bits. No coordination per id.
Tap any box to see what it is and what it talks to.
- Service
- API gateway
- Database
- Cache
Text description
- Top 10 products by sales. No shard key in the query, so every shard may hold part of the answer.
- The coordinator asks each shard for its own top 10, in parallel.
- Three lists of 10 come back. It merges 30 rows and keeps the best 10.
- Service
- Database
Text description
- Orchestrator → Stock shard: reserve 1 item
- Stock shard replies to Orchestrator: reserved
- Orchestrator → Payments shard: charge 20 dollars
- Payments shard replies to Orchestrator: charged
- Orchestrator → Orders shard: create order
- Orders shard replies to Orchestrator: created
Hot keys and resharding
A good can still have one scorching value, and then one machine melts while the others idle.
Read more · 720 words
There are three shapes, and each has its own fix.
- One . A celebrity with 100 million followers sends every like to one key: 160,000 likes a second hit a node that tops out near 100,000. Salt the key into N sub-keys (
likes:42:0tolikes:42:15): write to a random one, sum them on read. With 16 salts that is about 10,000 writes a second per sub-key. Reads cost 16 times more, so salt only measured hot keys, and the summed count for a second. Another way out is to count in the app server's memory and flush a batch every second, if losing a second on a crash is acceptable. - One . A carries far more than its share because of how keys are assigned, not because one key is huge. The classic cause is a range or timestamp key. Fix it at the key: hash it, and keep time as the sort key inside the shard. Watch each shard's writes per second and its disk growth; a shard at 2 times the average is the early warning.
- One giant tenant. One customer holds a big share of rows, such as a single business with 40% of a SaaS's data. Hashing keeps all of it together, on one shard. Give that tenant a dedicated shard through the directory, and leave the rest hashed. Do the same for a tenant who pays for isolation.
Play The problem, Salted writes and Summed reads.
means moving data to new shards while the system keeps serving traffic, and it is a step sequence, not an event: six steps, and only the last cannot be undone.
Add the new shards
Empty, before any traffic moves.
Dual-write
Moving keys write to both. The old shard stays the truth.
Backfill
Copy old rows in throttled batches.
Verify
Compare counts and checksums.
Flip reads
A few percent at a time, behind a flag.
Stop old writes and clean up
Delete the moved rows. Only this step is irreversible.
The sum for a slow, safe move: 2 TB copied at a throttled 50 MB per second is 40,000 seconds, about 11 hours. That is fine, because the old shard keeps serving. Throttle the copy so it does not starve live traffic, and do it at the quiet hours.
What goes wrong: the backfill race. A backfill copying row 42 as version 1 can land after a live dual-write put version 2 there, and the new shard ends up with stale data that checksums on counts will not notice. Make the copy an upsert that only writes if its version is newer, so it is safe to replay. Verify with per-range checksums of the row contents, not only counts, before flipping any reads. Keep the old data until the new shards have served reads cleanly for a day.
Plan ahead with logical shards. The cheapest reshard is the one that rehashes nothing. Create many logical shards (say 256) on a few machines from day one, and map logical shards to machines in a small table. Play Add machine D.
Growing from 3 to 4 machines moves 64 of the 256 logical shards, 25% of the data, each as a small job with the steps above. The step from key to logical shard never changes. This is the Cluster idea, and the default way to avoid a painful reshard. The limit: you can never have more machines than logical shards, so choose generously.
Tap any box to see what it is and what it talks to.
- Client
- Service
- Cache
Text description
- A celebrity posts. 160,000 likes a second arrive for one post.
- All of them increment the same key, so the same node. If one node tops out near 100,000 operations a second, it melts while the cluster idles.
Tap any box to see what it is and what it talks to.
- Client
- API gateway
- Database
Text description
- hash(42) mod 256 is 106, so user 42 is in logical shard 106. That number never changes for this user.
- A table says logical shards 85 to 170 live on machine B, so that is where the row is.
- Done.
Code
Hash sharding
import { createHash } from 'node:crypto';
const hash = s => createHash('md5').update(s).digest().readUInt32BE(0);
// Hash the key and take it modulo the shard count. Even spread, but changing the count moves almost every key.
export const shardFor = (id, shardCount) => hash(String(id)) % shardCount;
// const db = pools[shardFor(userId, pools.length)];Range sharding
// Each shard owns a contiguous range of ids. Range scans stay on one shard,
// but sequential keys (auto-increment ids, timestamps) send every new write to the last shard.
const ranges = [{ max: 1e6, db: 'db0' }, { max: 2e6, db: 'db1' }, { max: Infinity, db: 'db2' }];
export const shardFor = id => ranges.find(r => id < r.max).db;Directory (lookup) sharding
// A lookup table maps each tenant to a shard. The hot copy lives in Redis; the directory DB is the source of truth.
export async function shardForTenant(tenantId) {
const shard = await redis.hget('shard-map', tenantId)
?? await directory.lookup(tenantId);
return shard;
}
// Moving one huge tenant is a data copy plus one row update: directory.assign('acme', 'shard-7')Consistent hashing
import { createHash } from 'node:crypto';
const hash = s => createHash('md5').update(s).digest().readUInt32BE(0);
export class HashRing {
constructor(nodes = [], vnodes = 100) { this.vnodes = vnodes; this.ring = []; nodes.forEach(n => this.add(n)); }
add(node) {
for (let i = 0; i < this.vnodes; i++) this.ring.push({ pos: hash(`${node}#${i}`), node });
this.ring.sort((a, b) => a.pos - b.pos);
}
remove(node) { this.ring = this.ring.filter(p => p.node !== node); }
get(key) { // binary search for the first point at or after hash(key)
const h = hash(key);
let lo = 0, hi = this.ring.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.ring[mid].pos < h) lo = mid + 1; else hi = mid;
}
return this.ring[lo % this.ring.length].node; // wrap around the ring
}
}
// 4 → 5 nodes moves ~20% of keys (all of them to the new node); hash % N would move ~80%Rendezvous hashing
import { createHash } from 'node:crypto';
const hash = s => createHash('md5').update(s).digest().readUInt32BE(0);
// Rendezvous (highest random weight) hashing: each key picks the node with the largest hash(node + key).
// No ring to maintain, and removing a node only moves that node's keys.
export const rendezvous = (key, nodes) => nodes.reduce((best, n) => (hash(n + key) > hash(best + key) ? n : best));Online resharding
// Online resharding: never stop the world. The old shard stays the source of truth until reads flip.
export async function save(row) {
await Promise.all([oldShard.write(row), newShard.write(row)]); // dual-write
}
// backfill in background, compare checksums, flip reads, delete old dataScatter-gather
// Scatter-gather: a query that can't use the shard key asks every shard, then merges, re-sorts and cuts
export async function topProducts(limit = 10) {
const perShard = await Promise.all(shards.map(db =>
db.query('SELECT id, name, sales FROM products ORDER BY sales DESC LIMIT $1', [limit])));
return perShard.flatMap(r => r.rows).sort((a, b) => b.sales - a.sales).slice(0, limit);
}
// Latency = the slowest shard. If this query is frequent, maintain a precomputed global table instead.Hot-key salting
// Hot-key salting: spread one celebrity counter over 16 sub-keys; reads add them up.
// (Separate GETs rather than MGET, because in Redis Cluster the sub-keys live in different slots.)
const SALTS = 16;
export const like = postId => redis.incr(`likes:${postId}:${Math.floor(Math.random() * SALTS)}`);
export async function likes(postId) {
const parts = await Promise.all(Array.from({ length: SALTS }, (_, i) => redis.get(`likes:${postId}:${i}`)));
return parts.reduce((sum, v) => sum + Number(v ?? 0), 0);
}Snowflake ids
// Snowflake-style 64-bit ids: unique across shards with no coordination, roughly time-ordered.
// 41 bits ms since a custom epoch (~69 years) | 10 bits worker | 12 bits sequence (4,096 ids/ms/worker)
const EPOCH = 1_704_067_200_000n; // 2024-01-01T00:00:00Z
export function snowflake(workerId) {
if (!Number.isInteger(workerId) || workerId < 0 || workerId > 1023) throw new RangeError('workerId must be 0–1023');
let lastMs = -1n, seq = 0n;
return function next() {
let now = BigInt(Date.now());
if (now < lastMs) throw new Error('clock moved backwards');
if (now === lastMs) {
seq = (seq + 1n) & 4095n;
if (seq === 0n) while ((now = BigInt(Date.now())) <= lastMs) {} // sequence exhausted: wait for the next ms
} else seq = 0n;
lastMs = now;
return ((now - EPOCH) << 22n) | (BigInt(workerId) << 12n) | seq;
};
}
// Send ids to browsers as strings: 64-bit values don't fit in a JS number.Sharding in the interview
Bring up only after you have shown that one cannot take the writes, then walk through five decisions in order.
Read more · 430 words
The order is what shows you have done this before.
Prove one database is not enough
Do the sum out loud: "50,000 writes a second against a primary that handles a few thousand, and 5 TB of data."
Choose the shard key from the main query
Name the query, then the column:
user_id, ortenant_idfor SaaS. Say what you rejected: timestamps and low-cardinality columns.Pick the strategy
Hash on the key by default. Range only for scans, a directory for giant tenants, geo for residency.
Handle the fallout
Cross-shard queries go to or a global index, off the hot path. Name the hot-key fix: salting, or a dedicated .
Say how you grow
Many logical shards on few machines, or , and the online steps: dual-write, backfill, verify, flip reads.
| If the interviewer says | You raise |
|---|---|
| "Writes outgrow the primary" | Shard by user_id, hash, after replicas and caching |
| "We need to scan by date" | Hash by id, sort by time inside the shard; or range with a hash prefix |
| "One customer is 40% of the data" | A directory and a dedicated shard |
| "Users must stay in their country" | Geo sharding with a home region |
| "How do you add a shard?" | Logical shards, then dual-write and backfill |
Quick checkWhich decision comes right after proving one primary is not enough?
Interview questions
What's the shard key for a multi-tenant SaaS?
tenant_id, with a directory so huge tenants get their own .
How do you run queries that span shards?
Keep them off hot paths: for admin, else a global index.
How do you reshard without downtime?
Add shards, dual-write, backfill, verify, flip reads, stop old writes.
Why do you need virtual nodes?
One point per node can leave a node with twice its share. Many points even it out and spread a dead node's keys over many survivors.
Hash or range sharding for time-series data?
Hash by series and sort by time inside each shard. Pure range-by-time sends every write to the newest shard.
Check yourself
Question 1 of 5Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.
- Hash Maps and SetsHash sharding is one hash and one modulo to pick a shard: the O(1) lookup idea, spread across machines.
- Binary SearchA consistent-hash ring finds the next node by binary searching sorted ring positions.
- Bit ManipulationSnowflake IDs pack a timestamp, machine id and sequence number into 64 bits with shifts and masks.