Internals · Distributedpartitioningshardingpartition functionhash partitioningrange partitioning

Partitioning Internals: Key → Partition Function → Node

Splitting data across nodes is a pure function from key to node plus the metadata to find it, and every operational property — balance, hot spots, how much moves when a node joins, which queries fan out — is a property of that function.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    One machine's write throughput, storage or working set is the limit. The data must be split across nodes so that most requests touch one node — and any node must be able to tell which one.

  2. Naive solution

    Node = hash(key) mod N. Uniform spread, trivial to compute anywhere, no metadata.

  3. Why it breaks

    Add a fifth node and mod changes for almost every key: ~80 % of the data must move to grow the cluster by 25 %. Range queries hit every node. And a key that receives 60 % of traffic makes one node carry 60 % of the load no matter how you hash it.

  4. Better idea

    Decouple "which partition" from "which node": map keys to many small partitions with a stable function, then assign partitions to nodes with a table that can change incrementally. Give the routing layer that table. For range queries, partition by ranges of the key instead of by hash.

  5. Internal mechanism

    A partition function (hash of the key onto a ring with virtual nodes, or a sorted table of range boundaries, or an explicit list) plus routing metadata (the ring, the boundary table) held by a coordinator or replicated to every client; rebalancing moves whole partitions; secondary indexes are either local (per partition, scatter to search) or global (partitioned by the indexed value, a second hop).

  6. Trade-offs

    Hash: balance and point lookups, no ranges, rebalancing moves 1/N with a ring. Range: ranges and locality, skew and sequential-key hot spots, split when hot. Every cross-partition operation — join, unique constraint, transaction, count — becomes a fan-out or an application problem.

  7. Real database

    Cassandra and DynamoDB (hash ring with vnodes), HBase and CockroachDB (ranges that split), Citus and Vitess (hash or range with a coordinator), Redis Cluster (16,384 hash slots), PostgreSQL declarative partitioning within one node.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

A function decides where a key lives

Sharding means each node holds a slice of the keys. To read or write a key, someone computes which node: a hash of the key, a lookup of which range it falls in, or a list that says "EU customers go here". That computation is the partition function, and the data it needs — node list, range boundaries — is the routing metadata.

Queries that name the key go to one node. Queries that do not must ask every node and merge (scatter/gather). Adding or removing a node means some keys move, and how many depends on the function.

Key → partition function → node

Strip sharding down and there are three things: a key (whatever identifies the row for routing: a tenant id, a user id, a timestamp), a partition function that maps the key to a partition, and an assignment of partitions to nodes. Everything the practical lesson described — "hash sharding", "range sharding", "hot shard", "resharding" — is a statement about the function or the assignment. The separation matters: a function that maps keys directly to nodes (hash mod N) has nothing to change incrementally when N changes; a function that maps keys to many small, stable partitions, plus an assignment table, can move one partition at a time.

The three functions, with 4 nodes
HASH     node = hash('user:4711') mod 4                       → 2         O(1), no metadata, uniform, no ranges
RING     p = hash('user:4711') / 2^32 = 0.613 on [0,1)         → next vnode clockwise at 0.641 belongs to node 2
RANGE    id 4711 ∈ [4000, 6000)                                 → lookup in sorted boundaries: [0,2000)→n0 [2000,4000)→n1 [4000,6000)→n2 [6000,∞)→n3
LIST     region('user:4711') = 'EU'                             → routing table: EU→n0 US→n1 APAC→n2 LATAM→n3

key → partition (stable, many)  →  partition → node (small table, changes)
      hash slot 0..16383 (Redis Cluster)      slot ranges per node, moved one slot at a time

Hash, range, list

Hash. node = hash(key) mod N, or a hash onto a fixed number of slots (Redis Cluster: CRC16 mod 16384; DynamoDB and Cassandra: a 128-bit token). A good hash makes any key distribution uniform across partitions, so balance is free and a point lookup is one node. Order is destroyed: WHERE ts BETWEEN or WHERE id > 1000 must visit every partition. Composite keys can hash a prefix and keep order within it — Cassandra's partition key vs clustering columns, DynamoDB's partition key vs sort key — so that "all events for device 42 in June" is one partition with a range inside it.

Range. A sorted table of boundaries: keys in [a, b) go to partition 1. Adjacent keys share a partition, so range scans and prefix scans are local, and time-based retention is "drop the oldest range". The key's distribution becomes the partition distribution: names cluster, ids grow, one tenant dominates. A monotonically increasing key — auto-increment, timestamp — means every insert goes to the *last* range: the write hot spot, cured by prefixing a hash or a tenant id ((tenant_id, ts)) at the cost of losing the global time order. Ranges must be split when they grow (HBase regions, CockroachDB ranges at 512 MB) and merged when they shrink.

List / geographic. An explicit mapping from key values to partitions: region = EU → cluster-eu. It is the only option when law dictates where data lives, it gives locality for free, and its partitions are as unequal as the regions are. In practice it is the outer level of a hierarchy: list by region, then hash or range inside.

Partition functions compared
HashRangeList
Point lookup by key1 partition1 partition1 partition
Range scan on keyall partitions (scatter)1–few partitionsdepends on the inner scheme
Balanceuniform by constructionfollows the data: skewas unequal as the categories
Sequential keysfinehot last partitionn/a
Rebalancingmove slots / vnodes (1/N with a ring)split a range, move one halfmove a category
Metadatanode list (mod) or ringsorted boundariesexplicit table
TypicalCassandra, DynamoDB, Redis Cluster, CitusHBase, CockroachDB, Spanner, time-partitioned tablesRegions, tenants, compliance

Consistent hashing and virtual nodes

With hash mod N, changing N changes the result for almost every key: going from 4 to 5 nodes remaps 80 % of keys, which for a terabyte cluster is a terabyte of copying to add 25 % capacity. Consistent hashing maps both nodes and keys onto the same circle of hash values [0, 2³²); a key belongs to the first node clockwise from its position. Adding a node inserts one point and claims only the arc between it and its predecessor — on average 1/N of the keys, taken from exactly one neighbour. Removing a node hands its arc to the next node clockwise. This is the same idea as a Hash Table whose buckets can be added one at a time without rehashing the rest.

One point per node gives uneven arcs (a node might own 40 % of the circle by bad luck) and dumps a departing node's entire load on one neighbour. Virtual nodes fix both: each physical node owns 64–256 points scattered around the ring, so its share is the sum of many small arcs (variance shrinks like 1/√vnodes), a new node takes a sliver from every existing node, and a departing node's arcs are spread across all survivors. Heterogeneous hardware gets more vnodes. Cassandra defaults to 16 vnodes per node (256 historically); DynamoDB and Riak use the same construction. Redis Cluster achieves the same effect differently: 16,384 fixed hash slots assigned to nodes in contiguous ranges, moved one slot at a time.

Ring with 3 nodes × 4 vnodes; adding node D claims four small arcs
            0.00
        C3 ───┬─── A1
      B2 ╱    │    ╲ C1
  A4  ╱       │       ╲  B3          key 'user:4711' at 0.613 → clockwise → next point is B1 (0.641) → node B
0.75 ┤        ●        ├ 0.25
  B1  ╲       │       ╱  A2          add D with 4 vnodes at 0.08, 0.31, 0.57, 0.88:
      C2 ╲    │    ╱ B4                 D takes (C3,0.08] from A1, (A2,0.31] from B3, (B4,0.57] from C2, (A4,0.88] from B1
        A3 ───┴─── C4                   ≈ 1/4 of all keys move, ~1/12 from each of A, B, C — nothing else changes
            0.50

hash mod N, 3 → 4 nodes:  key k moves unless hash(k) mod 3 == hash(k) mod 4 → ~75 % of keys move

Hot partitions

A partition function balances *keys*; it cannot balance *traffic per key*. If one tenant generates 40 % of requests, the partition holding that tenant carries 40 % of the load however the tenants are hashed. If one row — a celebrity's profile, a global counter, today's feature flag — is read a million times a second, its partition is hot and adding nodes does nothing. Under range partitioning, a time-ordered key makes the newest range hot for *every* write. Detecting it is a per-partition metric (requests, bytes, CPU) with the max far above the mean; DynamoDB surfaces it as throttling on one partition while the table's aggregate is idle.

The remedies are all at the key level. Split the key: counter:42 becomes counter:42#0 … #15, writes pick a suffix at random, reads sum sixteen keys. Salt the range key: prefix a time-based key with a small hash so inserts spread over k partitions, and read all k for a time range. Cache the hot key in front of the store. Isolate: give the whale tenant its own partition or node — an explicit exception in the routing table. Adaptive splitting (DynamoDB adaptive capacity, CockroachDB load-based splitting) does the range version automatically by splitting a range that receives disproportionate traffic, but it cannot split a single key.

Rebalancing: moving partitions vs splitting ranges

Two different operations hide under "rebalance". Moving a fixed partition (a hash slot, a vnode's arc): the partition's data is copied to the new owner while the old owner keeps serving; writes during the copy are either forwarded or double-written; at cutover the metadata changes, requests that arrive at the old owner are redirected (Redis MOVED/ASK), and the old copy is deleted. The unit is fixed and the count is large, so moves are small and parallel. Splitting a range: a range that grew beyond a size or load threshold is split at a median key into two ranges, the metadata gains a boundary, and one half may then be moved. Ranges are created by the data, so the count grows with the table and the split decision is a per-range policy.

Both must handle the same hazards: a client with stale metadata (answer with a redirect and the new epoch), a move that fails halfway (the old owner stays authoritative until the cutover record is durable), and rebalancing load competing with production traffic (rate limits; Cassandra streams, CockroachDB's snapshot rate limit). Never rebalance by changing the function itself — mod 4 to mod 5 — on a live system; that is the naive step from the derivation, and it moves everything at once.

Secondary indexes and cross-partition queries

An index on a column that is not the partition key faces a choice. A local index (document-partitioned) lives inside each partition and indexes only that partition's rows: writes stay local — the row and its index entries are on the same node, in the same transaction — but a query WHERE email = ? must be sent to every partition, because any of them might hold a match. Cassandra secondary indexes, DynamoDB local secondary indexes, Citus and PostgreSQL per-partition indexes work this way. A global index (term-partitioned) is itself partitioned by the indexed value: email hashes to one index partition, which stores (email → partition key), so the query is one index lookup plus one row fetch. Writes now touch two partitions — the row's and the index's — which is either a distributed transaction (CockroachDB, Spanner) or asynchronous and eventually consistent (DynamoDB global secondary indexes, Elasticsearch).

Every query that does not name the partition key is a scatter/gather: send to all P partitions, execute locally, merge. Aggregations merge per group; ORDER BY … LIMIT k needs k from every partition and a k-way merge; COUNT(*) sums; a join across partitions is either co-located (both tables partitioned by the same key, so each partition joins locally — the single most important shard-design trick) or a broadcast of the smaller table to every partition, or a full reshuffle. Latency is the slowest partition's; cost grows linearly with P; and a query that was fine at 4 partitions is a problem at 64. This is why the shard key must appear in almost every query, and why designs that cannot achieve that (Partitioning and Sharding) should not shard.

Local vs global index on email, 4 partitions
LOCAL (per partition)
  write user 4711 → partition 2: row + index entry, one node, one transaction
  SELECT … WHERE email='ada@x.io' → ask p0,p1,p2,p3 → p2 answers, the others return empty → 4 requests, latency = slowest

GLOBAL (partitioned by email)
  write user 4711 → partition 2 (row) AND index partition hash('ada@x.io') mod 4 = 0 (entry)  → 2 nodes, 2PC or async
  SELECT … WHERE email='ada@x.io' → index p0 → (ada@x.io → user 4711) → row on p2 → 2 requests, always

SCATTER/GATHER for ORDER BY created_at DESC LIMIT 10:
  every partition: local top-10  →  router: merge 4×10, keep 10   (40 rows moved to return 10; with 64 partitions, 640)

Key points

  • Sharding is key → partition function → node, plus routing metadata. Keep "which partition" (stable, many) separate from "which node" (small table, changes).
  • Hash balances keys and kills ranges; range keeps order and inherits skew (sequential keys → hot last range); list encodes policy.
  • hash mod N remaps ~everything when N changes; a consistent-hash ring moves ~1/N, and virtual nodes spread that evenly across all nodes.
  • Hot partitions come from traffic per key, which no function balances: split the key, salt the range, cache, or isolate.
  • Rebalancing moves fixed partitions or splits ranges — never changes the function on a live system.
  • Local indexes make writes local and reads scatter; global indexes make reads a two-hop and writes distributed. Any query without the key is O(P) partitions.

Key → partition function → node

Key → partition function → node
The partition function is a pure function of the key. Hash spreads, range keeps neighbours together, list follows a routing table — and how many keys move when a node joins is the property that separates modulo from a consistent-hash ring.
fnv1a('user:4711') = 498459675 → 498459675 mod 4 = 3 → node 3
routing metadata
node 0
hash mod 4 = 0
node 1
hash mod 4 = 1
node 2
hash mod 4 = 2
node 3
hash mod 4 = 3
traffic share per node
node 024%
node 125%
node 225%
node 326%
nodes
4
max share
26%
ideal share
25%
Educational simulation — 240 sample keys, FNV-1a hashing, 8 virtual nodes per node; real systems use 64–256 vnodes and a stronger hash.

When to use — and when not

Use it when
  • This mechanism fits when a single node's write throughput or data size is measurably the limit and the access patterns carry a natural key that appears in almost every query.
  • Range partitioning fits time-series and prefix scans; hash fits key-value access with uniform keys; list fits residency and tenancy boundaries.
Avoid it when
  • This mechanism fits poorly when queries are ad hoc across the whole dataset, when joins cross the key, or when uniqueness must be global — every one becomes a scatter or an application protocol.
  • Before vertical scaling, replicas, caching and single-node partitioning are exhausted.

Failure modes

  • A monotonic range key: every insert lands on the last partition; the cluster has 15 idle nodes and one on fire.
  • Resharding by changing hash mod N: nearly every key moves at once under production load.
  • A single hot key (celebrity row, global counter) that no partition function can spread.
  • A global secondary index updated asynchronously: reads by that column return rows that no longer match.
  • A query pattern that scatters to all partitions, fine at 4, unusable at 64.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.