Search Tech Journey

Find topics, journeys and posts

6-month learning plan74 / 130
back to blog
systemsintermediate 55m read

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.

⚙️SystemsM08 · Distributed Systems· Session 074 of 130 90 min

🎯 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.

You will be able to
  • 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

Splitting a library across three buildings
🌍 Real world

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.

💻 Code world

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

Pick one and live with it
  • 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"

  1. 2004
    Google BigTable
    Range-partitioned tablets, auto-split when they get too big. The template every wide-column store copies.
  2. 2007
    Amazon Dynamo paper
    Consistent hashing hits mainstream. DynamoDB, Cassandra, Riak all trace their partitioning to this.
  3. 2010
    MongoDB sharding GA
    Adds a mongos router + config servers. Popularises 'shard key' as an app concern.
  4. 2012
    Vitess (YouTube)
    Sharding layer on top of MySQL. Later runs Slack, GitHub metadata, Etsy.
  5. 2015
    Citus (Postgres extension)
    Shards Postgres tables by a distribution column while keeping SQL semantics. Acquired by Microsoft in 2019.
  6. 2019
    Discord Cassandra hot-partition crisis
    'Trending' channels created skewed load; single partitions hit 100 GB. Migration to ScyllaDB + resharding.
  7. 2022
    Serverless / auto-sharding databases
    Aurora 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

1day 1
1 · Small · single shard

Everything fits on one node. No sharding logic needed. Almost every system starts here.

2day 100
2 · Vertical scaling exhausted

Move to a bigger box until you hit the ceiling (~64 vCPU, ~1 TB RAM, ~10 TB SSD). Costs are exponential above that.

3day 300
3 · Read replicas added

Buys you time on read-heavy workloads. Writes still on one node.

4day 500
4 · Choose shard key + shard

The big decision. Split into N shards using hash / range / directory.

5day 900
5 · Resharding pain

Traffic skewed? Add shards. This is the moment you regret not picking consistent hashing.

6day 1500
6 · Auto-managed / serverless

You surrender operational control to Dynamo/Spanner/Aurora Serverless.

Hot partitions — the failure mode you'll hit at scale

Where 'skew' comes from

Application layer
One tenant is 100x the size of others. One celebrity user has 10M followers. Trending event drives all traffic to one row.
workload
Key choice
Sharding by timestamp: all writes today go to the same shard. Sharding by country: US shard has 40% of traffic. Sharding by low-cardinality key: only N possible values.
key
Hash function
A weak or biased hash can pile keys onto some shards. Use MurmurHash / xxHash / CityHash, not `%` alone on non-uniform inputs.
algorithm
Range boundaries
Fixed ranges get lopsided as data grows. BigTable + CockroachDB auto-split ranges when they exceed a size threshold — solving this at the storage layer.
topology
Coordinator / router
If routing is centralised and one shard is slow, the coordinator gets backed up. Circuit-breakers and per-shard timeouts help.
operational

When to use which strategy

Hash sharding

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)
Range sharding

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)
Directory sharding

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


Common misconception
✗ What most people think

"Hash the key to pick a shard. It distributes evenly, so the load is balanced."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.

From first principles
Start with the question

Why does consistent hashing exist? Modulo hashing is simpler — what breaks badly enough to justify a ring?

  1. 1
    With 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
  2. 2
    Therefore 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
  3. 3
    That 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
  4. 4
    So 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
  5. 5
    Avoiding 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

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.

Mental modelThe shard key is the decision you cannot undo

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.
🔔 Fires when you see

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.

The tradeoff

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 ID
+ you gain all of a tenant's data is co-located, so every query and transaction is single-shard and fast. Isolation is natural, per-tenant backup and restore is trivial, and a noisy tenant's impact is contained to its own shard.
− you pay tenant sizes follow a power law, so distribution is inherently uneven. Your largest tenant may exceed a single shard's capacity entirely, at which point the scheme has no answer at all and requires a special case.
pick when when the largest tenant comfortably fits one shard with headroom for growth, and tenant-scoped queries dominate the workload
Shard by a composite key spreading each tenant
+ you gain even distribution regardless of tenant size, and no single tenant can exhaust a shard. Capacity planning becomes a function of total volume rather than of your biggest customer's growth.
− you pay every tenant-scoped query becomes scatter-gather, so the common case is now the expensive one, and cross-shard transactions become routine rather than exceptional. You have optimised for the rare large tenant at every small tenant's expense.
pick when when tenant-scoped queries are rare and most access is by a finer-grained key anyway
Hybrid: small tenants share shards, large tenants get dedicated ones
+ you gain small tenants keep the co-location benefit while large tenants get isolation and capacity. Distribution can be actively managed by moving tenants, and the largest customers can be given predictable performance.
− you pay you now need a tenant-to-shard mapping service, live tenant migration, and a policy for promotion when a tenant outgrows a shared shard. That is real infrastructure to build and operate, and the migration path must work without downtime.
pick when when tenant sizes span orders of magnitude, which is the normal state of any successful B2B product
What a senior engineer actually does

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

h(x) via md5 -> 32-bit int
Stable, uniform hash. Real systems use MurmurHash3 or xxHash — md5 is fine for demos but slower.
hash
NaiveHashRouter
The straw-man. Add a node and every key remaps because N changes. Baseline to prove consistent hashing's value.
baseline
ConsistentHashRouter with vnodes
Each physical node maps to 150 positions on the ring. Vnodes smooth out imbalance from hash randomness — without them a 4-node cluster easily has 40/60 splits.
core
bisect.bisect_left
Binary search on the sorted ring. O(log(N*vnodes)) per lookup — sub-microsecond even for 10 000 vnodes.
lookup
wrap-around at idx == len(ring)
The 'ring' — the last key wraps to the first vnode. Without this the topmost keys have no home.
correctness
demo() moved-key count
The killer metric: consistent hashing moves ~1/(N+1) of keys. Naive moves nearly ALL keys. This is why every large cache/DB uses it.
proof
hot_partition_demo
Shows that hashing alone doesn't fix skew — if one KEY gets 90% of traffic, one NODE gets 90% of traffic. Fix: 'shard the hot key' (see TryIt).
gotcha
Try itFix the hot partition by sub-sharding the celebrity's key

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).

💡 Hint · For the celebrity's 100 000 events, instead of routing on `user-42`, route on `user-42#{event_id % 16}`. This spreads the celebrity across 16 sub-shards. On the read path you must query all 16 and merge — the classic 'write fan-out, read fan-in' trade. This is exactly how Instagram, Twitter, and TikTok handle celebrity accounts.

(d) Production reality · 15 min

War story Discord· 2022Migrating trillions of messages from Cassandra to ScyllaDB
🔥 What broke

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.

🧯 The fix
Move to ScyllaDB (C++ Cassandra rewrite, faster compactions) AND change the partition key from 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.
🎓 Lesson to steal
Choosing a shard key with skewed cardinality (some channels are 100 000x busier than others) will always create hot partitions eventually. The fix is not 'more nodes' — it's adding a second dimension to the key so any single value doesn't dominate.
Post-mortem
War story Instagram· 2012~1 billion likes
🔥 What broke
Instagram's early photo table was sharded by 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.
🧯 The fix
Rewrite the photo storage to shard by 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.
🎓 Lesson to steal
User-generated content platforms have a "power law" traffic distribution — 0.1% of users generate 30%+ of traffic. Any sharding scheme built for the median user will collapse on the tail. Design for celebrities from day one.
Post-mortem
War story Common failure mode — 'the resharding that never ends'documented across dozens of engineering blogs
🔥 What broke
Team decides to split a 4-shard MySQL cluster into 8 shards. Because they used 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.
🧯 The fix
Restart with consistent hashing so only 1/2 of the keys need to move (not all of them). Use a bulk-copy tool (gh-ost, pt-online-schema-change, Vitess VReplication) that throttles based on production load. Cutover in one atomic switch of the routing config, not per-key.
🎓 Lesson to steal
The algorithm you pick determines whether resharding takes a weekend or a year. 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

Sharding touches every distributed data topic
S068 · Replication
Every shard is typically replicated 3x. Sharding × replication = the shape of every OLTP cluster.
S073 · Consensus
Each shard usually has its own Raft group. 1000 shards = 1000 Raft groups.
S099 · Caching
Consistent hashing is what memcached / Redis clusters use for client-side sharding.
S105 · Kafka
Kafka partitions are the same idea — hash on key OR round-robin OR custom partitioner.
S110 · Search (Elasticsearch)
Elasticsearch shards documents by hash of _id by default. Same tradeoffs, same hot-shard risk.
S128 · Design Twitter (interview)
The 'celebrity user' problem is literally an interview trope. This session is your answer.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, no notes:

  1. The three sharding strategies and one example of each.
  2. Why consistent hashing is a big deal (adding a node moves 1/N keys, not all keys).
  3. 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.