Hirestack
Hirestack
Chapter 3

Partitioning (sharding)

📖 10 min read · 9 sections · May 2026

Why this matters

Replication makes one machine of data fault-tolerant. Partitioning lets you have more data than fits on one machine. They're complementary: in real systems you partition first (split data across machines), then replicate each partition (for HA).

Partitioning is also where you'll feel the most operational pain at scale. Bad partition choices are extraordinarily expensive to undo because they encode themselves into the API, the schema, the business logic.

From first principles: what problem are we solving?

One server can hold maybe 100 TB of disk and handle maybe 100K writes/sec. If your dataset is larger or your traffic is higher, you need to split it across multiple servers. Partitioning is how.

The hard questions:

The four partitioning strategies

Hash partitioning

flowchart LR
    subgraph Ring["Consistent hash ring (positions 0–360°)"]
        N1["Node A — pos 45"]
        N2["Node B — pos 130"]
        N3["Node C — pos 220"]
        N4["Node D — pos 310"]
        N1 -->|next CW| N2
        N2 -->|next CW| N3
        N3 -->|next CW| N4
        N4 -->|wraps| N1
    end
    K1["key foo
hash=80"] -.assigned to.-> N2 K2["key bar
hash=15"] -.assigned to.-> N1 K3["key baz
hash=200"] -.assigned to.-> N3 K4["key qux
hash=350"] -.assigned to.-> N1

Rule: hash the key onto the ring; assign it to the next node clockwise. Adding or removing a node only re-maps ~1/N of keys.

Compute a hash of the partition key and take it modulo the number of partitions:

partition = hash(key) % N

Hashes are uniform: keys spread evenly across partitions, no hot spots (assuming the key itself isn't skewed).

The naive problem: when you add a partition (N changes from 8 to 9), almost every key's modulo changes. ~89% of keys would move. Catastrophic during a resharding.

Fix: consistent hashing. Imagine a ring from 0 to 2^32. Each node owns a range on the ring. To find a key's partition, hash the key onto the ring, then walk clockwise to the next node.

Ring (simplified):
    0 ────────── 1
    │           │
   Node A      Node B
    │           │
   4 ───────── 2
    │
   Node C
    │
    3

Key with hash 0.5 → walks to Node B (first node clockwise).
Key with hash 1.5 → walks to Node C.

Adding Node D between A and B: only the keys between A's position and D's position move (from Node B to Node D). Just ~1/N of keys move per node addition.

Virtual nodes: instead of one ring position per physical node, give each physical node 100-200 virtual positions. Distributes load more evenly; rebalancing is finer-grained. Used by Cassandra (256 vnodes per node default), DynamoDB, Riak.

Used by: Cassandra (default), DynamoDB, Memcached with consistent hashing, Riak.

Range partitioning

Each partition owns a contiguous key range.

Partition 1: keys A-F
Partition 2: keys G-M
Partition 3: keys N-T
Partition 4: keys U-Z

Pros: efficient range scans. "Give me all keys from K to M" hits one or two partitions.

Cons: hot spots if keys cluster. The classic disaster: range-partition by created_at. All new writes go to the latest partition. One partition is white-hot; others are idle.

Fix for the hotspot: prepend a salt or hash to the key before range-partitioning, or use composite partition keys (more on this below).

Used by: HBase, Bigtable, Spanner, CockroachDB (which also uses range partitioning to provide ordered scans for SQL).

Directory-based (lookup) partitioning

An explicit table maps keys to partitions:

key   | partition
------|----------
foo   | shard-3
bar   | shard-1
baz   | shard-3
qux   | shard-7

Maximum flexibility: you can rebalance per-key without re-hashing. But the directory itself is a bottleneck and a SPOF.

Used by: Slack (channel→shard mapping in a "topology table"), Vitess (vschema lookup tables), many internal multi-tenant systems where one tenant might be 1000× bigger than another.

Composite partitioning

Most production systems combine strategies. Common pattern:

Example for a chat app: (channel_id, timestamp). channel_id is hashed to pick a shard; timestamp orders messages within a channel. Queries for "recent messages in channel X" hit one shard and read in sorted order.

Used by: Cassandra (composite primary keys), DynamoDB (partition key + sort key), most modern wide-column stores.

The secondary index problem

Your primary key shards your data. Great. Now what about queries by a different attribute? "Find all users with email = X" — but email isn't the partition key.

Two approaches, each with painful tradeoffs.

Local secondary indexes

Each shard maintains its own secondary index on its own data.

Shard 1: index on email → rows in shard 1 only
Shard 2: index on email → rows in shard 2 only
...

Writes: cheap (one shard write, one local index update). Reads by email: scatter-gather — query every shard, aggregate results.

Used by: Cassandra secondary indexes, DynamoDB local secondary indexes (LSIs).

Global secondary indexes

The index is itself sharded by the indexed attribute.

Index shard 1: emails a-m → original-row pointers
Index shard 2: emails n-z → original-row pointers

Reads by email: cheap (one index shard lookup, one data shard lookup). Writes: distributed transaction across data shard and index shard.

Used by: DynamoDB global secondary indexes (GSIs), Vitess vindex tables, Spanner.

Mental model: secondary indexes in a sharded system are themselves sharded data. You're picking between "write in two places" (global) and "read from many" (local). There's no free lunch. Most production systems use local indexes + scatter-gather, optimised by routing scatter-gather reads to replicas.

Hot keys and the celebrity problem

The single most common production failure of a sharded system. Symptoms: one shard's CPU/IOPS at 100% while siblings are idle.

Where hot keys come from

Mitigations

Append-the-key. Instead of partitioning on user_id, partition on user_id_bucket = (user_id, random 0..N-1). The same user's data is now spread across N shards. Reads must scatter-gather across the N buckets — accept the cost.

L1 cache. Put a local cache in front. 99% of hot-key reads never hit the data tier. The cache is your shock absorber.

Read replicas. Add more replicas for the hot shard, spread reads across them. Doesn't help writes.

Dedicated shard for the hot tenant. The 1% biggest tenant gets its own physical shard. Operational complexity; sometimes worth it.

Application-level rate limiting. Cap the request rate for the hot key. User-visible.

War story — Discord's hot channels
Discord originally sharded messages by channel_id. When a channel got popular (a server's #general with thousands of members), that one shard's IOPS pinned. Their fix: partition by (channel_id, time_bucket) where time_bucket is a monthly bucket. A hot channel splits into 12 buckets per year, distributing the load. Cross-bucket queries (e.g., "give me a year of messages") are rarer than recent-message queries. The fix required schema migration, not configuration change. Read: discord.com/blog/how-discord-stores-billions-of-messages.

Online resharding — adding and removing nodes without downtime

This is the operation that decides whether a sharded system is operable in production.

The slot-based approach (Redis Cluster, Vitess)

Pre-tokenise the keyspace into many small, fixed-size slots. Redis Cluster has 16,384 slots. Each slot is independently movable.

To add a node:

  1. Decide which slots to move to the new node (typically 1/N of total).
  2. For each slot:
    • Mark it MIGRATING on source, IMPORTING on destination.
    • Stream keys from source to destination, in batches, throttled.
    • Client requests for those slots: if source has the key, serve it; if not, return an ASK redirect to the destination.
  3. When migration of a slot finishes, flip ownership atomically in cluster topology.
  4. Broadcast new topology; clients refresh their slot map on next MOVED error.

Throttling matters: migration competes with serving traffic for I/O. Cap migration rate (e.g., 1000 keys/sec) to keep production p99 stable. A 1 TB shard at 10 MB/sec moves in 28 hours. Plan resharding in days, not hours.

Real-world online resharding examples

Notion: sharded Postgres from 1 → 32 → 96 logical shards over multiple years. Each transition involved logical replication + dual-write + cutover. Their writeup: notion.so/blog/sharding-postgres-at-notion. Key insight: shard early, even if only on one physical box. Going from 1 logical shard to 32 is the painful migration; going from 32 on one box to 32 on 32 boxes is straightforward.

Figma: online Postgres sharding by table groups. Each group is independently shardable. They wrote about it: figma.com/blog/how-figmas-databases-team-lived-to-tell-the-scale. Used a custom routing layer in front of pgbouncer.

Vitess (YouTube → GitHub → Slack): a whole framework for sharded MySQL with online resharding as a first-class operation. vitess.io/docs.

Cross-partition transactions — the hardest problem

Updating two rows on different shards atomically is hard.

ApproachHowTradeoff
Two-Phase Commit (2PC)Coordinator asks all participants to prepare; on all-prepare, commitsStrong consistency but blocking; if coordinator dies between prepare and commit, participants are stuck
Saga patternSequence of local transactions, each with a compensating actionEventual consistency; intermediate states visible; complex error handling
AvoidanceDesign schema so cross-shard updates are rare/impossible (co-locate related data)Schema constraints; works for 90% of cases

Most production systems pick avoidance. Design the schema so the partition key co-locates the rows that need atomic updates (e.g., all of a user's data on the same shard). Cross-shard transactions become rare exceptions handled by saga or eventual consistency.

Further reading — partitioning

Interview drills

Q: Design sharding for a chat product with 1M concurrent users and 1B messages.
Partition messages by (channel_id, time_bucket) using hash partitioning. Each shard holds messages for a subset of (channel, month) combinations. Within a shard, messages clustered by (channel_id, time_bucket, message_timestamp) for efficient "recent messages" queries. For users: separate "user inbox" tables sharded by user_id — each message generates an inbox entry per recipient (fan-out-on-write). Hot channels are bucketed monthly, distributing load. For 1M concurrent: 10-20 shards each handling ~50-100K connections. 3× replication factor for HA.
Cross-Q: A user is in 500 channels. How do you build their unread count?
A: Per-user-per-channel "last-read marker" stored in user-sharded table. To compute unread for all channels: query the user's shard for all markers, then for each channel, query the message shard for "messages since marker." Can be ~500 cross-shard queries. Mitigate with caching the unread count itself, updated on message write (incrementing the recipient's per-channel counter).
Cross-Q: What if a celebrity user has 100M followers and posts a message?
A: Fan-out-on-write is 100M inbox inserts. Fan-out-on-read flips the model: don't insert into followers' inboxes; instead, each follower's inbox query pulls from the celebrity's "sent" timeline. Hybrid model: fan-out for ordinary users (most), pull for celebrities (few). Twitter does this.
Q: How would you reshard from 16 to 32 shards without downtime?
Pre-condition: 16 logical shards on 16 physical boxes. Spin up 16 new physical boxes. Configure them as logical replicas (subscribers) of the existing 16 shards. Wait for catch-up. For each pair (old shard, new box), decide which slots will move. Briefly stop writes to that subset of slots, promote the new box as owner, resume writes pointed at the new box. Per-slot downtime is seconds; total system downtime is zero. Vitess automates this entire flow.
Cross-Q: What if you started with one logical shard?
A: Painful. You must introduce sharding into the application code first — every query needs a partition key. Then introduce logical shards (perhaps via Postgres native partitioning) on the same physical box. Only after that do you start moving partitions across boxes. Lesson: always introduce sharding logic to your app even if you have one shard.
Q: A query needs to do scatter-gather across 50 shards. p99 is bad. What do you do?
Scatter-gather p99 is determined by the slowest shard. If each shard has p99 of 10ms, the combined p99 is much higher because you're rolling 50 dice. Mitigations: (1) Hedged requests — fire to both primary and replica, take first. (2) Cap-and-paginate: instead of "give me all matching" return "top K matching" so each shard can short-circuit. (3) Add a denormalised lookup table sharded on the query attribute so it's no longer scatter-gather. (4) Cache the result if it's stable.
Cross-Q: Why is scatter-gather p99 worse than any single shard's p99?
A: "Tail at scale" — Jeff Dean paper. If each shard has 1% probability of being slow, with 50 shards the probability that at least one is slow is 1-(0.99)^50 ≈ 40%. So the median request waits for at least one slow shard, dominating end-to-end latency.

Red flags