Hashing User IDs Across 64 Shards: Why Your Range Queries Stop Working
The orders table has 400 million rows on one 2 TB node, and writes are saturating its disk. The obvious fix is to split it: pick a shard key, hash it, and spread rows over 64 shards. Writes parallelize immediately. Two weeks later support reports that the "recent orders for customer 88213" page — which used to return in 40 ms — now times out, and a WHERE customer_id = 88213 ORDER BY created_at DESC scan that once hit one index now fans out to 64 nodes and merges 64 result sets.
That is the trade of hash partitioning in one sentence: you trade spatial locality for balance. The function is simple:
shard = H(key) mod N # naive version — avoid this for growing clusters
shard = lookup(H(key)) # consistent hash ring: H(key) → ring position → owning node
example (64 shards):
key user_88213 → sha256 → 0x4f2a... → ring position 0x4f2a → shard 31
every query for that user goes to shard 31, and only shard 31
Hashing makes distribution a probability question instead of a design question: if the hash is uniform and the key has high cardinality, load evens out by the law of large numbers — no shard holds 10% of rows just because it was created first. That is why Cassandra, DynamoDB, Cosmos DB, and Vitess all hash by default.
What breaks:
| Operation | Single node, ordered key | Hash-partitioned |
|---|---|---|
WHERE id = ? |
one index lookup | one shard lookup — unchanged ✓ |
WHERE id IN (...) |
one lookup | up to N shard queries, merged |
WHERE id BETWEEN a AND b ORDER BY id |
index range scan | scatter-gather across every shard ✗ |
ORDER BY created_at LIMIT 20 |
index walk | fetch 20 from each shard, then merge ✗ |
GROUP BY country |
stream over index | every shard aggregates, coordinator combines |
The scatter-gather rows are the ones to internalize: the shard key must appear in almost every query, because any predicate without it becomes a full-fleet scan. This is the classic sharding interview question — "you shard by user_id, how do you produce a global leaderboard?" The honest answers are: keep the leaderboard in a separate store designed for it, maintain a per-shard partial aggregate and merge top-K, or accept an eventually-updated precomputed table.
The second failure is cross-shard transactions. UPDATE accounts SET balance = balance - 100 WHERE id = A on shard 3 and ... + 100 WHERE id = B on shard 19 cannot share a local ACID transaction; you need a two-phase commit, a saga (The Saga Pattern), or a redesign that colocates both rows on one shard. Sharding an existing schema also runs into constraints that assumed a global table: unique indexes become per-shard unless the key includes the shard key, foreign keys are unenforceable across shards, and AUTO_INCREMENT collides — which is why sharded systems push you toward UUID/ULID keys or per-shard ID ranges.
Finally, mod N deserves its own warning. With 64 shards, H(id) mod 64 sends nearly every key to a different shard when you go to 65 — a full data reshuffle billed as "adding one node". Consistent hashing fixes this: keys are placed by ring position, and adding a node takes over only the arc of the ring it now owns — roughly 1/N of the keys move, not everything. Virtual nodes (each physical node owns many ring positions) smooth out the imbalance that a sparse ring produces.
Pick hash partitioning when access is point-lookup shaped and you need even load. If your queries are ordered, range partitioning preserves exactly what hashing throws away — the next chapter covers when that locality is worth the hotspot risk it invites.
Range Partitioning Keeps Order — Until One Tenant Becomes the Whole Table
A multi-tenant analytics product stores events with tenant_id as the primary dimension. Hashing tenant_id would balance rows nicely, but every query starts with WHERE tenant_id = ? AND ts BETWEEN ... ORDER BY ts — a shape hash partitioning punishes. So the team ranges on ts: partition 2026-01, 2026-02, ... each a contiguous, index-ordered block. Queries touching a time window hit only the partitions that overlap it — partition pruning — and ordered scans are served by local indexes. For a while this is the correct answer.
Then one customer backfills two years of history in a weekend, and the 2024-03 partition grows to ten times its siblings. Every write for that period lands on one shard, its WAL fsync queue backs up, and p99 write latency for all tenants sharing the node degrades. This is the hotspot: range partitioning gives locality by accepting that locality is unevenly distributed.
The two axes you can range on lead to two different failure modes:
| Range key | Locality benefit | Hotspot failure |
|---|---|---|
Time (ts) |
recent-window scans touch few partitions; TTL drop = partition delete | ingest-time writes all target "now"; backfills target one old partition |
| Tenant / customer ID | one customer's data colocated | one giant customer or a sequential-ID prefix concentrates writes |
| Auto-increment ID | ordered scans cheap | the "latest row" partition is always the write target |
An increasing-ID range is the sneakiest: the last partition is a write hotspot by construction, because every insert goes to the right edge. Remedies, in increasing order of invasiveness:
- Salt the range — prepend a random shard prefix (
bucket = random(0,7), keybucket:ts) so writes scatter across 8 pre-splits per window, then merge on read. You have bought balance by giving up single-partition ordered reads — the hash trade again, scoped to the query. - Pre-split — create empty partitions ahead of time so growth lands in several places rather than one auto-splitting node.
- Isolate the heavy tenant — dedicate a node per large tenant (shard-by-tenant, range-within-shard: composite key
(tenant_id, ts)gives per-tenant balance and per-tenant ordered scans). - Backpressure and batching — throttle bulk loads to off-peak or route them to a staging keyspace merged later.
Composite keys deserve emphasis because they combine both worlds: PARTITION BY (tenant_id) RANGE (ts) — common in Cassandra with CLUSTERING ORDER BY ts DESC and in Postgres declarative partitioning — balances across tenants while preserving order inside each. The rule of thumb interviewers listen for: the first column of the key decides distribution; later columns decide locality within a shard.
There is also a maintenance cost specific to ranges: partitions must exist for incoming data. If new ranges are not pre-created, inserts fail with errors like no partition of relation 'events' found for row (Postgres) rather than silently creating one. Scheduling partition creation, archiving old partitions to object storage, and dropping expired ones is operational work you own.
Range partitioning is right when queries carry a bounded, ordered predicate and data has a natural temporal edge. The remaining problem — partitions drifting apart as usage changes — is the rebalance problem, covered in the final chapter.
Rebalancing a Live Cluster: Moving Shards While Traffic Keeps Flowing
Your cluster has 16 shards and the traffic forecast says 24 by Q3. Adding nodes is the easy part — the hard part is that the old mapping H(id) mod 16 now disagrees with mod 24, which means every key must move. The team schedules a maintenance window, pauses producers, copies, remaps, restarts: 40 minutes of write downtime and a rollback plan nobody wants to use. The alternative is a rebalance that never stops serving, and it comes down to one invariant: routing must change exactly once, and every write during the copy must be captured either way it could be read.
First, why naive approaches fail:
mod Nremapping — with N changing, virtually every key changes shard. Moving 97% of the data is not a rebalance, it is a migration. Consistent hashing (or Vitess's shard-routing cells, or DynamoDB's partition metadata) limits movement to1/Nof the data per node addition.Copy-then-switch without a log — while you copy shard 31's rows to the new owner, writes keep landing on shard 31. At cut-over, those rows are stale on the new owner unless you capture them.
The two production-proven patterns:
Dual-write / routing epoch:
1. epoch 1: router sends ALL traffic to old owners; background copy runs
2. capture every change since the copy started (CDC tail or write log)
3. epoch 2: router flips to new owners in ONE atomic metadata update
→ any write that raced the flip lands on the new owner too (dual-written)
4. verify checksums, drain old copy, epoch 3: stop dual-write
Chunk move (MongoDB, Vitess, Kafka):
1. split range into chunks; mark chunk migrating (owner → donor)
2. copy chunk; while copying, incoming writes queue on the donor
3. hand over ownership metadata, replay queued writes, chunk now owned
4. repeat per chunk — each chunk is individually small, so the flip is fast
Both satisfy the same rule: the metadata/routing change is atomic and instantaneous; the data copy is slow and invisible. You never have two masters for a key after the flip, because writes during the copy were replayed or dual-written. Kafka's kafka-reassign-partitions with --throttle and MongoDB's chunk balancer both follow it, and both throttle replication bandwidth so the rebalance does not starve foreground reads.
Practical details that decide success:
| Concern | Decision |
|---|---|
| Splitting load | move ranges, not whole nodes; move the smallest shards first |
| Protecting p99 | throttle copy bandwidth; schedule cross-AZ copy off-peak |
| Concurrency | one or two chunks in flight — unbounded parallelism is a self-inflicted DoS |
| Verification | checksum/row-count compare before the old copy is dropped |
| Rollback | flip the routing metadata back — the data copy itself needs no undo |
| Capacity math | with N nodes, a 1/N move; plan for 80% disk so a rebalance has room |
One interview follow-up to be ready for: "what if a node dies mid-rebalance?" The answer is that ownership metadata, not the copy process, defines truth — an in-flight chunk move aborts and re-allocates, and replicas already satisfy the replication factor independently of the copy. This is why sharded stores separate the control plane (who owns what, changed by consensus) from the data plane (reads and writes) — the same split Raft-backed systems use (Consensus Overview with Raft).
With data now spread across nodes, the next question is what promise each read makes. Continue to Consistency Models.
Quiz
1. orders is hash-sharded by user_id across 64 shards. Which query becomes a 64-shard scatter-gather?
2. A 16-shard cluster uses shard = H(id) mod 16 and needs to grow to 24 shards. What happens, and what avoids it?
3. Events are range-partitioned by timestamp. Write p99 spikes every morning at 09:00 while other partitions sit idle. What is the cause and the cleanest first fix?
4. You are copying a shard's data to a new node while production keeps writing. What makes the cut-over safe?
Flashcards
Question
Hash partitioning vs range partitioning — what does each optimize, and what does each break?
Click to reveal answer
Answer
Hash optimizes for even load distribution across shards at the cost of locality: ordered range scans and prefix queries become scatter-gathers. Range optimizes for locality: ordered predicates prune to a few partitions, but distribution is whatever the key's shape is — increasing keys and big tenants create hotspots. Rule: hash key = balance, range key = locality.
Question
Why does H(id) mod N force a near-full data move when N changes, and what is the fix?
Click to reveal answer
Answer
Changing N changes the modulus for essentially every key, so almost every row relocates — an O(data) resize per node addition. Consistent hashing places keys at ring positions and assigns ownership by position, so adding a node transfers only the arcs it now owns (~1/N of keys); virtual nodes keep those arcs balanced across physical machines.
Question
What makes a shard key wrong, and what does a missing shard key in a predicate cost?
Click to reveal answer
Answer
A shard key is wrong when it is low-cardinality, non-uniform, or absent from most queries. Any predicate lacking it forces a scatter-gather across every shard (or a full-fleet scan), and cross-shard joins/transactions stop being local. The operational test: if a typical query cannot name the shard key, that query will scale inversely with shard count.
Question
Name three ways to relieve a hotspot and the trade each one makes.
Click to reveal answer
Answer
Salt the range with a random bucket prefix (balance, but ordered reads must merge buckets); pre-split partitions so growth lands in several places (needs capacity forecasting); isolate large tenants onto their own shard with composite key (tenant, ts) (per-tenant balance plus local order, but requires routing metadata); throttle/backfill off-peak (protects p99, delays the load).
Question
What is the invariant that makes a live rebalance safe?
Click to reveal answer
Answer
Routing metadata changes exactly once and atomically (microseconds), while the data copy runs invisibly; every write racing the copy is captured via CDC, a write log, or dual-writes and replayed before/after the flip. Data movement is slow and restartable — truth is ownership metadata, not the copy job, so an aborted chunk move just re-allocates.
Revision Notes
Key Takeaways
- 1. Hash partitioning buys balance and sells locality; every ordered or shard-key-less query becomes a scatter-gather across the whole fleet
- 2. The shard key appears in almost every query or the cluster does not scale — cross-shard joins, transactions, and unique constraints are the downstream costs
- 3. mod-N resizing moves nearly all data; consistent hashing with virtual nodes confines any resize to about 1/N per added node
- 4. Range partitioning hotspots are structural: the newest partition and the biggest tenant receive their share of writes by construction, so salt, split, or isolate
- 5. A safe rebalance is an atomic metadata flip plus a captured change tail — the copy can be slow, but the ownership switch cannot be
Interview Tips
- • "You shard by user_id — how do you build a global top-N?" — say plainly it becomes a scatter-gather, then offer per-shard partial aggregates with a merged top-K or a separate precomputed store; naming the cost earns more than dodging it
- • "Add a node to a mod-1024 cluster — what moves?" — essentially everything; contrast with consistent hashing's 1/N arc transfer and mention virtual nodes for balance
- • "How do you rebalance without downtime?" — one atomic routing flip, CDC/dual-write to cover the copy window, checksum verification, throttled bandwidth; then the rollback answer (flip back)
- • "Design a shard key for this table" — walk the access patterns, name the cross-shard transactions it forces, and check cardinality/uniformity; flagging an incoming unique constraint shows real experience
Cheat Sheet
Partitioning and Sharding
Pick the axis by query shape:
point lookups / even load → HASH (breaks ordered scans ⇒ scatter-gather)
ordered / time-bounded scans → RANGE (breaks balance ⇒ hotspots)
per-tenant balance + local order → composite: partition by tenant, range by ts
| Query | Hash-sharded cost |
|---|---|
WHERE key = ? |
1 shard ✓ |
WHERE key IN (...) |
k shards, merged |
WHERE key BETWEEN ... ORDER BY |
all N shards ✗ |
GROUP BY (no shard key) |
all N shards aggregate, coordinator merges |
Growth: H(id) mod N → changing N moves ~everything. Consistent hash ring + virtual nodes → a new node takes ~1/N of keys. Shard-map metadata gives the same containment.
Hotspots (range/time): live-edge partition takes all current writes · one giant tenant · auto-increment edge. Fixes: random bucket salt · pre-split · isolate tenant on (tenant, ts) composite · off-peak backfills.
Shard-key consequences: unique indexes become per-shard (include shard key) · foreign keys unenforceable across shards · auto-increment replaced by UUID/ULID or per-shard ranges · cross-shard txn needs 2PC or a saga.
Live rebalance recipe:
1. start copy; tail changes via CDC/write-log (or dual-write both owners)
2. throttle copy bandwidth; move smallest ranges first; 1–2 chunks in flight
3. flip routing metadata ATOMICALLY (microseconds) → replay tail → verify checksums
4. drop old copy; rollback = flip metadata back (data copy needs no undo)
Control plane (ownership, consensus-backed) is separate from data plane — a node dying mid-move aborts the chunk, replicas already hold the RF.