Sharding
Splitting one dataset across several databases: range, hash and directory mappings, picking the key, and the cost of crossing shards.
shard = route(shardKey) // range: ranges.find(key) | hash: hash(key) % N | directory: lookup(key)Sharding (horizontal partitioning) splits one logical dataset across several databases so each holds a disjoint subset. It exists because replication scales reads but not writes or storage: every replica still receives every write and still stores every row. Sharding is the only way to put more write throughput and more disk behind one table.
Three ways to map a key to a shard
- Range — contiguous ranges of the shard key. Range scans and ordered pagination stay on one shard, but a monotonic key such as a timestamp or auto-increment id sends every new write to the last shard.
- Hash — hash the key, then take a bucket. Even distribution, but any range query becomes a scatter-gather across all shards. MongoDB spells the choice out:
{ userId: 1 }is ranged,{ userId: "hashed" }is hashed. - Directory — a lookup table from key to shard, consulted on every request. Moving one tenant is a single row update, which is why multi-tenant systems favour it. The directory is now on every read path, so it needs caching.
Choosing the key
Pick the column your common queries already filter on. Figma sharded on ids their code already carried — user, file and organisation — and hashed them before routing to avoid hot spots. A key with too few distinct values cannot be split further: a DynamoDB partition is capped at 3,000 read units and 1,000 write units however much capacity the table has, so a partition key value that cannot be split across partitions inherits that ceiling. The 10 GB figure often quoted alongside it is the item-collection limit, and it only binds when the table has a local secondary index — without one, the items sharing a partition key value can be split across partitions by sort key and are not capped at 10 GB.
What trips people up
Everything that crosses shards gets worse. A join becomes an application-side merge, ordered pagination has to over-fetch from every shard, a count is approximate unless you fan out, and a transaction needs two-phase commit or an idempotent saga. Unique constraints and generated ids stop being global.
Example
// A shard router with all three mappings behind one interface.
import { createHash } from 'node:crypto';
const SHARDS = ['shard-0', 'shard-1', 'shard-2', 'shard-3'];
// Hash: even spread, no useful ordering. Take 32 bits so the number is exact
// in a JS integer, and hash the key rather than using it raw -- sequential
// ids would otherwise still land in sequential buckets.
function hashShard(key) {
const digest = createHash('sha256').update(String(key)).digest();
return SHARDS[digest.readUInt32BE(0) % SHARDS.length];
}
// Range: ordered, so BETWEEN and ORDER BY stay on one shard. The boundaries
// are stored rather than computed, so one hot range can be split in two
// without moving any other key.
const RANGES = [
{ upto: 1_000_000, shard: 'shard-0' },
{ upto: 2_000_000, shard: 'shard-1' },
{ upto: 3_000_000, shard: 'shard-2' },
{ upto: Infinity, shard: 'shard-3' },
];
const rangeShard = (id) => RANGES.find((r) => id < r.upto).shard;
// Directory: one lookup per request, cached, and the only mapping where
// moving a single tenant is a row update rather than a migration.
async function directoryShard(orgId, cache, catalog) {
const hit = cache.get(orgId);
if (hit) return hit;
const { rows } = await catalog.query(
'SELECT shard FROM org_shards WHERE org_id = $1', [orgId],
);
// Fail loudly: a missing placement must not silently fall back to shard 0.
if (!rows.length) throw new Error(`org ${orgId} has no shard placement`);
cache.set(orgId, rows[0].shard);
return rows[0].shard;
}When to use it
- A single orders table has outgrown the largest instance available and writes are the bottleneck, not reads.
- Choosing between user_id and organisation_id as the shard key for a B2B product with a few very large customers.
- A multi-tenant SaaS that needs to move one noisy tenant onto its own database without touching the others.
- Explaining what happens to a JOIN, a COUNT and an OFFSET query once the table is split across eight nodes.
More examples
A query with no shard key: ordered pagination across all shards
The pattern for any read that does not carry the shard key, and the arithmetic that shows why you should avoid needing it. Worth having ready because interviewers ask it directly: the answer is over-fetch and merge, the follow-up is that offset pagination does not survive sharding, and the fix is a keyset cursor.
// "Newest 20 posts" with no shard key in the predicate. Each shard must
// return 20 rows, because all 20 winners can live on one shard; the merge
// then discards most of what it fetched. Cost grows with the shard count,
// which is why a feed usually gets its own index rather than this query.
async function newestPosts(pools, limit = 20) {
const sql = `SELECT id, author_id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1`;
const results = await Promise.all(pools.map((p) => p.query(sql, [limit])));
return results
.flatMap((r) => r.rows)
// Same tie-break as the SQL, or the merged order is not deterministic.
.sort((a, b) => b.created_at - a.created_at || (a.id < b.id ? 1 : -1))
.slice(0, limit);
}
// Page 50 is far worse: OFFSET cannot be pushed down, so every shard would
// have to return offset + limit rows. Use a keyset cursor instead -- pass the
// last (created_at, id) you showed and add
// WHERE (created_at, id) < ($2, $3)
// so each shard still returns at most `limit` rows however deep you go.Ranged and hashed, on the same collection
A concrete reading of the range-versus-hash tradeoff in a system where both are one argument apart. Reach for the hashed prefix when the natural key is an id handed out in increasing order, because ranged sharding on such a key produces a single write-hot shard no matter how many shards you add.
// mongosh. The same collection, sharded two different ways.
// Ranged: documents with nearby userId values sit together, so a scan over a
// range of users touches one shard. A monotonically increasing shard key
// sends every insert to whichever shard holds the top range.
sh.shardCollection("app.events", { userId: 1 });
// Hashed: MongoDB hashes the key before placing the document, so inserts
// spread evenly and range scans become scatter-gather across every shard.
sh.shardCollection("app.events", { userId: "hashed" });
// Compound with a hashed prefix: spread across shards by user, ordered by
// time within one user. Usually the shape you actually want.
sh.shardCollection("app.events", { userId: "hashed", ts: 1 });
// The default range (chunk) size is 128 MB, settable from 1 to 1024 MB.
// From MongoDB 6.0 a cluster only splits a range when it has to migrate one,
// so an on-disk range can be far larger than the configured size and that
// alone is not a symptom of anything.What a single database gave you for free
Two constraints that quietly stop holding the day you shard, and both tend to be discovered in production. The id generator is worth memorising because it comes up as its own interview question; the email table is the general pattern for any global uniqueness requirement, and it is also why claiming and releasing such a row needs to be idempotent.
import { createHash } from 'node:crypto';
// 1. SERIAL and AUTO_INCREMENT are per shard, so ids collide across shards.
// A snowflake id is generated locally, fits in 64 bits and still sorts by
// time. This is Discord's layout: 42 bits of millisecond timestamp, then
// 5 bits of worker id, 5 of process id, 12 of increment.
const DISCORD_EPOCH = 1420070400000n; // 2015-01-01T00:00:00Z
let increment = 0n;
function nextId(workerId, processId) {
const ms = BigInt(Date.now()) - DISCORD_EPOCH;
increment = (increment + 1n) & 0xfffn; // 12 bits, wraps within a millisecond
return (ms << 22n)
| ((BigInt(workerId) & 0x1fn) << 17n)
| ((BigInt(processId) & 0x1fn) << 12n)
| increment;
}
// 2. A UNIQUE index is unique within one shard only. To keep emails globally
// unique while the data is sharded by user_id, give the email its own
// table sharded by email, and claim it before writing the user row.
async function claimEmail(emailPool, email, userId) {
const { rowCount } = await emailPool.query(
`INSERT INTO email_owners (email, user_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[email.toLowerCase(), userId],
);
if (rowCount === 0) throw new Error('email already taken');
}
Discussion