S074 · Sharding & Partitioning Strategies
Split your data before it splits you. Hash vs range vs directory sharding, the hot-partition problem, and why 'just add a shard key' is the wrong answer for 90% of teams.
🎯 Choose the right partitioning strategy for a given workload, avoid the hot-partition trap, and reshard a live system without downtime.
Why this session exists
The first time a database gets too big for one machine, every team invents the same fix: "let's split it in half by user_id". Six months later they discover 5% of users generate 60% of traffic, the sharding key is baked into 200 queries, and rebalancing means downtime. Sharding is one of those decisions where the wrong choice on day one is a two-year migration on day 400. This session gives you the vocabulary and the mental model to make the right choice — or at least to recognise when you're about to make the wrong one.
- Name the three canonical partitioning strategies (hash, range, directory) and pick the right one for a given workload.
- Explain consistent hashing and why it lets you add a shard without moving all the data.
- Diagnose a 'hot partition' from monitoring graphs and propose three concrete fixes.
- Plan a live resharding — dual-write, backfill, cutover — without downtime.
- Predict which queries will be fast, slow, or impossible under a given shard key.
Prerequisites
- S068 · Replication basics — sharding is orthogonal to replication; you almost always do both.
- S072 · Consistency models — cross-shard operations force you to make consistency trade-offs.
- S040 · Database indexes — a shard key is essentially a mandatory-primary index.
(a) Intuition · 5 min
Your city library has grown too big for one building. You need to split its 3 million books across three buildings. Three natural strategies:
By author's last name: A–H → Building 1, I–P → Building 2, Q–Z → Building 3. Easy to know where a book is, but Building 2 gets crushed because half the authors' names start with M.
By a hash of the ISBN: Each book is randomly assigned. Buildings stay balanced, but "give me every book by Rowling" requires visiting all three.
By an explicit lookup table: "The DBA decided these books go here, those books go there." Flexible but somebody has to maintain the table.
These are the three canonical sharding strategies. Range (by-author), hash (by-ISBN), directory (by-lookup-table). Databases have argued about which is best for 50 years and there is still no winner — because the answer depends on your access pattern.
The bad news: you can't easily switch. Once you've sharded by ISBN, your "all books by Rowling" queries are slow forever unless you also maintain a secondary index across all shards.
The three sharding strategies
- Hash sharding — hash(shard_key) mod N. Balanced by default, but destroys locality (range scans and 'give me everything for X' become slow).
- Range sharding — contiguous ranges of the key. Great for range scans (time-series, alphabetical) but prone to hot spots (latest hour, latest user).
- Directory sharding — an explicit key → shard mapping stored in a lookup service. Most flexible, but the directory itself is a scalability + reliability problem.
- Consistent hashing — a special hash-sharding variant that lets you add a shard while only moving 1/N of the data (not all of it). Used by DynamoDB, Cassandra, memcached clusters.
- Composite / hierarchical — hash on tenant_id, then range within. Multi-tenant SaaS lives here.
A history of "we shard now"
- 2004Google BigTableRange-partitioned tablets, auto-split when they get too big. The template every wide-column store copies.
- 2007Amazon Dynamo paperConsistent hashing hits mainstream. DynamoDB, Cassandra, Riak all trace their partitioning to this.
- 2010MongoDB sharding GAAdds a mongos router + config servers. Popularises 'shard key' as an app concern.
- 2012Vitess (YouTube)Sharding layer on top of MySQL. Later runs Slack, GitHub metadata, Etsy.
- 2015Citus (Postgres extension)Shards Postgres tables by a distribution column while keeping SQL semantics. Acquired by Microsoft in 2019.
- 2019Discord Cassandra hot-partition crisis'Trending' channels created skewed load; single partitions hit 100 GB. Migration to ScyllaDB + resharding.
- 2022Serverless / auto-sharding databasesAurora Serverless v2, DynamoDB adaptive capacity, Spanner splits — the industry moving from 'you pick shards' to 'we manage them'.
(b) Visual walkthrough · 15 min
The three strategies side by side
Consistent hashing — the trick that makes adding a shard cheap
The lifecycle of a shard
Everything fits on one node. No sharding logic needed. Almost every system starts here.
Move to a bigger box until you hit the ceiling (~64 vCPU, ~1 TB RAM, ~10 TB SSD). Costs are exponential above that.
Buys you time on read-heavy workloads. Writes still on one node.
The big decision. Split into N shards using hash / range / directory.
Traffic skewed? Add shards. This is the moment you regret not picking consistent hashing.
You surrender operational control to Dynamo/Spanner/Aurora Serverless.
Hot partitions — the failure mode you'll hit at scale
Where 'skew' comes from
When to use which strategy
Default. Use unless you have a reason not to.
- ✅ Balanced load if key has high cardinality
- ✅ Simple: just hash(key) % N
- ❌ Range queries impossible without scatter-gather
- ❌ Naive hash % N means resharding moves everything
- Fix: consistent hashing (Cassandra, Dynamo)
Time-series, alphabetical, IoT — anything you scan in order
- ✅ Range scans are one-shard operations
- ✅ Cache locality: adjacent keys on same node
- ❌ Hot 'latest' shard for time-based workloads
- ❌ Manual rebalancing when uneven
- Fix: hash-then-range (composite), or auto-splitting (BigTable, Cockroach)
When routing depends on business rules (region, tier, tenant)
- ✅ Total flexibility — 'this customer goes to shard 7'
- ✅ Great for multi-tenant SaaS with per-tenant SLAs
- ❌ Directory service becomes SPOF; must be replicated
- ❌ Adds a network hop per query
- Fix: cache the directory client-side with a TTL
The mental model to hold
"Hash the key to pick a shard. It distributes evenly, so the load is balanced."
Hashing distributes keys evenly. It says nothing about distributing traffic, and traffic is almost never uniform across keys. One celebrity user, one popular product, one tenant fifty times larger than the rest — each becomes a hot shard that hashing cannot fix, because every request for that key must go to the same place by definition. Even distribution of keys and even distribution of load are different properties, and only the second one matters.
The myth is sticky because it is verifiably true at the level it describes: you can count rows per shard and see a beautifully flat distribution. That measurement is real, it is just measuring the wrong thing. It is also true for workloads with genuinely uniform access, which is what synthetic benchmarks generate — so the design passes every test you run and fails in production where access follows a power law, as almost all human-generated traffic does.
Measure request rate per shard, not row count per shard:
-- keys per shard: looks perfect, tells you nothing
select shard_id, count(*) from data group by 1;
-- requests per shard: this is the real distribution
sum(rate(requests_total[5m])) by (shard)
-- and the top keys by traffic, which is where hotspots hide
topk(10, sum(rate(requests_by_key[5m])) by (key))If the top ten keys account for a large share of traffic, no hash function will save you — the fix must be at the access pattern, not the placement function.
Why does consistent hashing exist? Modulo hashing is simpler — what breaks badly enough to justify a ring?
- 1With
shard = hash(key) mod N, the shard for a key depends on N, the number of shards.forced by · the modulus is part of the function, so changing it changes the function's output for essentially every input - 2Therefore adding one shard changes the mapping for almost every key — going from 10 to 11 shards leaves only about one key in eleven where it was.forced by · modulo by a different number produces an essentially unrelated result for each key
- 3That means scaling out requires moving nearly all the data, during which the system is either unavailable or serving from an inconsistent mixture of old and new placements.forced by · a key's data must be at its new shard before requests are routed there, and both cannot be true simultaneously without dual-reads or downtime
- 4So scaling becomes an operation so expensive and risky that teams postpone it, and capacity planning turns into a series of doublings rather than incremental additions.forced by · if the cost is the same whether you add one node or double the cluster, you may as well double
- 5Avoiding this requires a mapping where adding a shard affects only the keys that move to it — so place both shards and keys on a fixed ring, and assign each key to the next shard clockwise.forced by · a new shard on the ring intercepts only the arc between itself and its predecessor, leaving every other assignment untouched
Therefore consistent hashing exists to make the amount of data movement proportional to the capacity change rather than to the total dataset — roughly K/N keys move when adding one shard to N, instead of nearly all of them.
And note what this predicts: a small number of shards placed randomly on the ring will produce uneven arc sizes and therefore uneven load, which is exactly why implementations use virtual nodes — placing each physical shard at many ring positions so the arcs average out. It also predicts why some systems use a fixed large number of logical partitions mapped onto a smaller number of physical nodes: rebalancing then means reassigning whole partitions, which is a metadata change rather than a rehash. Both are the same insight applied twice.
Choosing a shard key decides three things at once: how data is distributed, which queries are cheap, and which transactions are possible. Queries that include the shard key hit one shard; queries that do not must fan out to all of them. Transactions spanning shards need distributed coordination or must be avoided entirely.
Changing the shard key later means rewriting all the data and usually the application too. Treat it with the seriousness of a schema decision that cannot be migrated.
- Choose a key with high cardinality, even access distribution, and presence in your dominant query. Missing any one of the three produces a specific failure: low cardinality caps your shard count, uneven access produces hotspots, and absence from queries makes every read a scatter-gather.
- Scatter-gather latency is governed by the slowest shard, so p99 latency degrades as shard count grows even when every shard is individually healthy. A query touching all shards is a query whose tail latency you cannot improve by adding capacity.
- Cross-shard transactions require two-phase commit or a saga. Both are substantially more complex and slower than a local transaction, so the correct move is almost always to choose a shard key that keeps transactional data co-located rather than to build the coordination.
- Range partitioning gives efficient range scans and creates hotspots on sequential keys — timestamps and auto-increment IDs send all new writes to one shard. Hash partitioning fixes the hotspot and destroys range scans. This is a genuine choice determined by your query shape, not a matter of which is better.
Fire this model when you see: one shard at 90% CPU while others idle · a query that must contact every shard · a tenant large enough to fill a shard alone · all writes landing on the newest partition · a proposal to change the shard key of a live system.
A multi-tenant system where tenants vary enormously in size. Shard by tenant, or by a synthetic key that spreads each tenant across shards?
Shard by tenant until a tenant is large enough to be a problem, then move it to its own shard. This requires a lookup-based mapping rather than a pure hash from the very beginning — a small amount of indirection added on day one that costs almost nothing and preserves every option later.
That indirection is the point. A pure hash function is a mapping you cannot override, so when one key becomes a problem you have no move available except reshuffling everything. A lookup table lets you special-case exactly the keys that need it, and the cost is one cached lookup per request. Build the indirection before you need it, because retrofitting it means the migration you were trying to avoid.
(c) Hands-on · 25 min
Let's implement consistent hashing and prove that adding a node moves only ~1/N of the keys.
#!/usr/bin/env python3
"""consistent_hash.py — a working consistent hash ring in one file.
Run: python consistent_hash.py
Compares naive `hash % N` to consistent hashing with virtual nodes.
Shows: 'when you add a shard, how many keys move?'
"""
from __future__ import annotations
import bisect
import hashlib
from collections import Counter
def h(x: str) -> int:
"""Stable 128-bit hash truncated to 32 bits for readability."""
return int(hashlib.md5(x.encode()).hexdigest(), 16) & 0xFFFF_FFFF
class NaiveHashRouter:
"""The `hash(key) % N` approach. Simple, terrible at resharding."""
def __init__(self, nodes: list[str]):
self.nodes = list(nodes)
def route(self, key: str) -> str:
return self.nodes[h(key) % len(self.nodes)]
def add_node(self, node: str) -> None:
self.nodes.append(node)
class ConsistentHashRouter:
"""Consistent hashing with virtual nodes for balance."""
def __init__(self, nodes: list[str], virtual_per_node: int = 150):
self.vpn = virtual_per_node
self.ring: list[tuple[int, str]] = [] # (hash, node)
for n in nodes:
self._add(n)
def _add(self, node: str) -> None:
for v in range(self.vpn):
self.ring.append((h(f"{node}#{v}"), node))
self.ring.sort(key=lambda x: x[0])
def route(self, key: str) -> str:
if not self.ring:
raise RuntimeError("empty ring")
hv = h(key)
# Find the first virtual node whose hash >= key's hash; wrap around.
idx = bisect.bisect_left(self.ring, (hv, ""))
if idx == len(self.ring):
idx = 0
return self.ring[idx][1]
def add_node(self, node: str) -> None:
self._add(node)
def remove_node(self, node: str) -> None:
self.ring = [(hv, n) for hv, n in self.ring if n != node]
def demo(router_class, name: str, N_INITIAL: int, N_KEYS: int) -> None:
print(f"\n=== {name} ===")
nodes = [f"node-{i}" for i in range(N_INITIAL)]
router = router_class(nodes) if router_class is NaiveHashRouter \
else router_class(nodes, virtual_per_node=150)
keys = [f"user-{i}" for i in range(N_KEYS)]
# Distribution before
before = Counter(router.route(k) for k in keys)
print(f"Before ({N_INITIAL} nodes):")
for n, c in sorted(before.items()):
pct = 100 * c / N_KEYS
bar = "#" * int(pct)
print(f" {n:<10} {c:>6} {bar}")
# Snapshot mapping
old_map = {k: router.route(k) for k in keys}
# Add a node
router.add_node(f"node-{N_INITIAL}")
new_map = {k: router.route(k) for k in keys}
moved = sum(1 for k in keys if old_map[k] != new_map[k])
pct_moved = 100 * moved / N_KEYS
print(f"After adding one node ({N_INITIAL + 1} nodes total):")
after = Counter(new_map.values())
for n, c in sorted(after.items()):
pct = 100 * c / N_KEYS
bar = "#" * int(pct)
print(f" {n:<10} {c:>6} {bar}")
print(f"Keys moved: {moved}/{N_KEYS} = {pct_moved:.1f}%")
print(f"Ideal (1 / (N+1)): {100 / (N_INITIAL + 1):.1f}%")
def hot_partition_demo() -> None:
"""Simulate the 'celebrity user' effect."""
print("\n=== Hot-partition simulation ===")
router = ConsistentHashRouter([f"node-{i}" for i in range(4)])
# 10 000 normal users, 1 celebrity
events: list[str] = []
for i in range(10_000):
events.append(f"user-{i}")
# Celebrity generates 100 000 events (all with same key)
events.extend(["user-42"] * 100_000)
load = Counter(router.route(k) for k in events)
total = sum(load.values())
print("Traffic per node (celebrity concentrates load):")
for n, c in sorted(load.items()):
pct = 100 * c / total
bar = "#" * int(pct / 2)
print(f" {n:<10} {c:>7} {pct:5.1f}% {bar}")
print("Fix: shard by (user_id, event_id % 16) so one hot user")
print(" spreads across 16 sub-shards.")
if __name__ == "__main__":
demo(NaiveHashRouter, "Naive hash % N", 4, 100_000)
demo(ConsistentHashRouter, "Consistent hashing (150 vnodes/node)",
4, 100_000)
hot_partition_demo()What each block does
Anatomy of the script
Modify the hot_partition_demo to do:
events.extend(f"user-42#{i % 16}" for i in range(100_000))Re-run and watch the load flatten across the 4 nodes. The tradeoff: retrieving all of user-42's data now requires 16 lookups instead of 1. For celebrities that's a win. For normal users, you'd degrade performance — so you apply this trick ONLY to detected hot keys (adaptive sharding).
(d) Production reality · 15 min
Discord's message store on Cassandra hit a limit: 'trending' channels (a viral server, a big streamer's chat) all wrote to the same partition (the channel_id). Some partitions grew to 100 GB, causing full-partition compactions that stalled the node for minutes.
Latency for reading messages spiked from 5 ms to 30 seconds. Users saw 'loading messages...' spinners forever during peak traffic.
channel_id to (channel_id, bucket_id) where bucket_id is a time-derived value so a busy channel's data is naturally split across many partitions over time. Rewrote every read path to fan out across buckets.photo_id. Everything worked until user @justinbieber's photos got 1 M likes each. The shard hosting Bieber's photos was overloaded while other shards sat 10% utilised.user_id AND move to a system where they could add virtual shards on demand. Later moved to a directory-based sharding layer so specific super-users (celebrity accounts) could be moved to dedicated shards with more capacity.id % N, doubling N means every row on shards 0,1,2,3 must potentially move to 4,5,6,7. They dual-write to old and new, backfill 10 TB of data over weeks. Backfill lags — new writes outpace it. They add more workers. Workers overload production. They pause.id % N is the wrong algorithm if you ever plan to add nodes. Consistent hashing or range-based auto-splits are the right ones.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- The three sharding strategies and one example of each.
- Why consistent hashing is a big deal (adding a node moves 1/N keys, not all keys).
- What is a hot partition and how do you fix it without buying more hardware?
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.