🔥 0
0
Lesson 4 of 6 20 min +225 XP

Rebalancing & Consistent Hashing

Your database is growing. You need more shards. But how do you add them without taking the system offline? This is the rebalancing problem.

The Challenge

When you change the number of shards, data that was on one shard might now belong on a different shard. Moving data while serving traffic is like changing tires on a moving car.

The Naive Approach: Modulo Hashing

The simplest sharding uses modulo:

def get_shard(key, num_shards):
    return hash(key) % num_shards

This works great... until you need to add a shard.

The Problem: Massive Data Movement

Before: 4 Shards

key_100: hash % 4 = 0 -> Shard 0
key_101: hash % 4 = 1 -> Shard 1
key_102: hash % 4 = 2 -> Shard 2
key_103: hash % 4 = 3 -> Shard 3

After: 5 Shards

key_100: hash % 5 = 2 -> Shard 2 (moved!)
key_101: hash % 5 = 3 -> Shard 3 (moved!)
key_102: hash % 5 = 4 -> Shard 4 (moved!)
key_103: hash % 5 = 0 -> Shard 0 (moved!)
Result: ~75% of all keys need to move when going from 4 to 5 shards!
Formula: Keys that move = (N-1) / N
4 -> 5 shards: 75% move
10 -> 11 shards: ~91% move
100 -> 101 shards: ~99% move

This is unacceptable for a production system with billions of keys.

The Solution: Consistent Hashing

Consistent hashing was invented to solve exactly this problem. Instead of hash % N, we map both keys and servers onto a hash ring.

How It Works

  • Create a ring from 0 to 2^32 (or similar large range)
  • Place shards on the ring at positions = hash(shard_id)
  • For each key, walk clockwise from hash(key) to find the first shard
                    0 (= 2^32)
                       │
            Shard A ───┼─── Shard B
                      ╱│╲
                     ╱ │ ╲
                    ╱  │  ╲
                   ╱   │   ╲
          Shard D ─────┼───── Shard C
                       │

Key Assignment

def get_shard_consistent(key, shard_positions):
    key_hash = hash(key)

    # Walk clockwise to find first shard
    for shard, position in sorted(shard_positions.items(), key=lambda x: x[1]):
        if position >= key_hash:
            return shard

    # Wrap around to first shard
    return sorted(shard_positions.items(), key=lambda x: x[1])[0][0]

Adding a Shard

When we add Shard E to the ring:

Before:                          After:
     A ─── B                          A ─── B
    ╱       ╲                        ╱       ╲
   ╱         ╲                      ╱    E    ╲
  D ───────── C                    D ───────── C

Keys in region AB → Shard B       Keys in region AE → Shard E (new)
                                  Keys in region EB → Shard B
Only ~1/N Keys Move!

With consistent hashing, adding one shard to N shards only moves approximately 1/N of the keys - just the keys in the arc between the new shard and its predecessor. Going from 4 to 5 shards: only ~20% move instead of 75%!

Virtual Nodes

Basic consistent hashing has a problem: if shards are unevenly distributed on the ring, some shards get more keys than others.

Solution: Give each physical shard multiple positions on the ring.
# Instead of 1 position per shard:
shard_positions = {
    "shard_A": hash("shard_A")
}

# Use many virtual nodes per shard:
shard_positions = {
    "shard_A_v0": hash("shard_A_v0"),  # -> shard_A
    "shard_A_v1": hash("shard_A_v1"),  # -> shard_A
    "shard_A_v2": hash("shard_A_v2"),  # -> shard_A
    # ... 100+ virtual nodes per shard
}

Without Virtual Nodes

Each shard has 1 position. Uneven distribution.

Shard A: 40% of keys
Shard B: 15% of keys
Shard C: 35% of keys
Shard D: 10% of keys

With Virtual Nodes (100 each)

Each shard has 100 positions. Even distribution.

Shard A: ~25% of keys
Shard B: ~25% of keys
Shard C: ~25% of keys
Shard D: ~25% of keys

Virtual Node Benefits

Even Distribution

More points = smoother distribution

Gradual Rebalancing

Add vnodes incrementally

Heterogeneous Hardware

More vnodes for bigger servers

Rebalancing Strategies

Strategy 1: Live Migration

Move data while the system is running.

1. Add new shard to routing layer (receives no traffic yet)
2. Start copying data from source shards
3. Track new writes with double-write or change log
4. Once caught up, update routing to include new shard
5. Clean up old data from source shards
Real Example: MongoDB Chunk Migration

MongoDB divides data into chunks (default 64MB). The balancer automatically moves chunks between shards to maintain even distribution. During migration, the source shard handles reads while streaming data to the destination.

Strategy 2: Double-Write During Migration

Ensure no data is lost during the transition:

def write_during_migration(key, value):
    old_shard = get_shard_v1(key)  # Old mapping
    new_shard = get_shard_v2(key)  # New mapping

    # Write to both during migration
    old_shard.write(key, value)
    if old_shard != new_shard:
        new_shard.write(key, value)

def read_during_migration(key):
    new_shard = get_shard_v2(key)
    value = new_shard.read(key)

    if value is None:
        # Fallback to old shard if not migrated yet
        old_shard = get_shard_v1(key)
        value = old_shard.read(key)
        if value:
            # Backfill to new shard
            new_shard.write(key, value)

    return value

Strategy 3: Ghost Tables (Percona)

For MySQL, Percona's pt-online-schema-change approach:

1. Create "ghost" table with new structure on target shard
2. Copy existing rows in small batches
3. Capture changes via triggers or binlog
4. Apply changes to ghost table
5. Atomic swap when synchronized

Adding Shards Safely: A Checklist

Step Action
1. Prepare Provision new shard, test connectivity
2. Update Routing Add shard to consistent hash ring (no traffic yet)
3. Enable Double-Write New writes go to both old and new locations
4. Backfill Copy existing data to new shard in batches
5. Verify Compare counts, checksums between old and new
6. Cut Over Update routing to read from new shard
7. Clean Up Remove migrated data from old shards

Removing Shards

Removing shards follows the reverse process:

1. Stop accepting new keys for the shard being removed
2. Migrate data to remaining shards
3. Verify all data is migrated
4. Update routing to remove the shard
5. Decommission the server
Warning: Removing is Harder

Removing a shard requires moving ALL its data to other shards. With consistent hashing, each key goes to the next shard on the ring. This temporarily doubles the load on receiving shards.

Real-World: Cassandra's Approach

Apache Cassandra uses consistent hashing with virtual nodes (called "vnodes"):

# cassandra.yaml
num_tokens: 256  # 256 virtual nodes per physical node

When adding a node:

  • New node joins the ring with 256 token positions
  • Each existing node streams a portion of its data
  • Load is distributed across all existing nodes
  • No single node bears the full migration burden
256
Default vnodes
~1/N
Data moved per add
Zero
Downtime

Key Takeaways

  • Modulo hashing causes massive data movement when changing shard count
  • Consistent hashing minimizes movement to ~1/N keys
  • Virtual nodes improve distribution and enable gradual rebalancing
  • Live migration requires double-writes and careful coordination
  • Always verify data integrity before cutting over

Next up: Cross-Shard Queries - Handling JOINs and aggregations across shards.

🧠 Quick Quiz

Test your understanding of this lesson.

1

With naive modulo hashing (key % N shards), what happens when you add one shard to a 4-shard cluster?

2

What is the main benefit of consistent hashing?

3

What are virtual nodes in consistent hashing?

Choosing the Right Shard Key