Adetayo Akinsanya unkletayo.dev

Database Sharding & Partitioning Strategies: Range, Hash, and Dynamic Rebalancing

Deconstructing horizontal sharding keys, range vs hash partitioning, cross-shard joins, and dynamic split rebalancing

Part 5 in Series — Catch up on the previous article: Consistent Hashing & Virtual Nodes: Distributing Keys Without Mass Resharding (Part 4) before diving into this post.

Why You Need This in Real Life

When a single relational database table reaches 500 million rows (250GB in index and page data), database query performance degrades rapidly:

  1. Buffer pool cache hit ratios drop from 99%99\% to 40%40\% because index B+ Trees exceed available server RAM.
  2. ALTER TABLE schema migration commands take 14 hours and lock write operations.
  3. Disk I/O bottlenecks choke concurrent transactions.

To scale beyond the hardware limits of a single machine, database architectures must shard (horizontally partition) tables across multiple database instances.

However, choosing the wrong sharding key (e.g., sharding a multi-tenant SaaS application by created_at timestamp) creates severe production operational traps: all incoming writes hit a single “hot” shard containing today’s date, while older shards sit idle.

To design scalable database architectures, you must master Range Partitioning, Hash Sharding, Cross-Shard Join Mitigation, and Dynamic Shard Rebalancing.


Part 1: Horizontal Sharding vs Vertical Partitioning

  • Vertical Partitioning: Splitting a wide table by columns into smaller tables (e.g., moving large text or blob columns to a separate user_profiles_blob table).
  • Horizontal Sharding: Splitting a table by rows across independent database servers. Every shard shares the identical schema, but holds a distinct subset of rows.
                              [ Single 500M Row Table ]
                                          |
        +---------------------------------+---------------------------------+
        |                                 |                                 |
        v                                 v                                 v
[ Shard 1 (Node 1) ]             [ Shard 2 (Node 2) ]             [ Shard 3 (Node 3) ]
Rows 1 to 166M                   Rows 166M to 333M                Rows 333M to 500M

Part 2: Sharding Strategies Breakdown

1. Range-Based Partitioning

Rows are assigned to shards based on contiguous value ranges of the sharding key (e.g., user_id 1–100,000 on Shard A, 100,001–200,000 on Shard B).

  • Advantages: Range queries (SELECT * WHERE user_id BETWEEN 50 AND 150) hit a single shard.
  • Drawbacks: Sequential primary keys (AUTO_INCREMENT) or timestamp keys create monotonic write hotspots—all new writes target the newest range shard.

2. Hash-Based Partitioning

Rows are assigned to shards using a hash function over the sharding key:

Shard ID=hash(sharding_key)(modTotal Shards)\text{Shard ID} = \text{hash}(\text{sharding\_key}) \pmod{\text{Total Shards}}

  • Advantages: Uniform write and read distribution across all shard nodes (±2%\pm 2\% load variance).
  • Drawbacks: Range queries (WHERE created_at BETWEEN X AND Y) cannot be routed to a single shard; they must be broadcast to all shards (Scatter-Gather query pattern).

3. Entity/Tenant-Based Sharding

In multi-tenant SaaS platforms, all records belonging to a specific tenant (tenant_id = 42) are co-located on the same physical shard.

  • Advantages: Eliminates cross-shard transactions and joins for single-tenant queries.
  • Drawbacks: The “Celebrity / Large Tenant Problem”—a giant enterprise tenant can overload its assigned shard.
+-----------------------------------------------------------------------------+
|                      Sharding Strategy Matrix                               |
|                                                                             |
|  Strategy        | Primary Strengths            | Critical Vulnerability    |
|  ----------------+------------------------------+---------------------------|
|  Range           | Efficient range scans        | Monotonic write hotspots  |
|  Hash            | Uniform load distribution    | Scatter-Gather range scans|
|  Entity/Tenant   | Fast single-tenant joins     | Large tenant hotspots     |
+-----------------------------------------------------------------------------+

Part 3: The Cross-Shard Operations Traps

Sharding solves storage scale, but introduces three major distributed architecture complexities:

1. Cross-Shard Joins

Executing SELECT * FROM orders JOIN users ON orders.user_id = users.id across shards located on different database servers requires fetching raw tables over the network and executing a Hash Join in the application routing tier, introducing high latency.

  • Mitigation: Denormalize data, use DTO projections, or co-locate related tables on the same shard using a shared user_id composite sharding key.

2. Global Unique Primary Key Generation

Standard database AUTO_INCREMENT or SERIAL sequences fail across shards because independent shards will generate conflicting primary key IDs (id = 1 on Shard 1 and id = 1 on Shard 2).

  • Mitigation: Use 64-bit Snowflake IDs (Timestamp + Datacenter ID + Worker ID + Sequence Counter) or UUIDv7.

3. Distributed Transactions

Updating rows across two separate shards requires Two-Phase Commit (2PC) or Saga patterns, increasing transaction latency by 10x.


Part 4: Dynamic Shard Splitting & Rebalancing

As data volume grows, fixed shard counts become insufficient. Systems like Google Spanner, CockroachDB, and MongoDB use Dynamic Shard Splitting:

1. Shard A reaches capacity limit (100GB threshold)
   [ Shard A: Keys 1 to 1000 ]

2. Router initiates Split at Key 500
   [ Shard A1: Keys 1 to 500 ]  <--->  [ Shard A2: Keys 501 to 1000 ]

3. Router updates Shard Map Metadata in background

Steps in Dynamic Rebalancing

  1. Threshold Trigger: A monitoring agent detects a shard exceeding disk capacity (>100GB> 100\text{GB}) or IOPS limits.
  2. Split Point Selection: The engine selects the median key to divide the range into two equal halves.
  3. Background Copy: The new target node copies data pages in the background while the parent shard accepts live writes using a Change Data Capture (CDC) write log.
  4. Metadata Swap: An atomic metadata update in the routing service (e.g., via etcd or ZooKeeper) redirects client traffic to the new shard boundaries.

Part 5: Java Snowflake ID Generator for Sharded Systems

package com.example.sharding;

public class SnowflakeIdGenerator {

    private static final long START_EPOCH = 1704067200000L; // 2024-01-01 UTC
    private static final long WORKER_ID_BITS = 5L;
    private static final long DATACENTER_ID_BITS = 5L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
    private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS);

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
    private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
    private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);

    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("Worker ID out of range");
        }
        if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
            throw new IllegalArgumentException("Datacenter ID out of range");
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long timestamp = timeGen();

        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backward! Refusing to generate ID");
        }

        if (lastTimestamp == timestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }

        lastTimestamp = timestamp;

        return ((timestamp - START_EPOCH) << TIMESTAMP_LEFT_SHIFT) |
               (datacenterId << DATACENTER_ID_SHIFT) |
               (workerId << WORKER_ID_SHIFT) |
               sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }

    private long timeGen() {
        return System.currentTimeMillis();
    }
}

Next Steps

Now that we understand database sharding and dynamic rebalancing, we will explore Gossip Protocols and cluster membership in Part 6.

References & Further Reading

  1. Kleppmann, M. (2017). Designing Data-Intensive Applications (Chapter 6: Partitioning). O’Reilly Media.
  2. MongoDB Inc. MongoDB Sharded Cluster Architecture and Balancer Mechanics. MongoDB Docs.
  3. Apache Software Foundation. Apache Cassandra Architecture: Data Partitioning Ring. Cassandra Docs.

Up Next in Series →

Part 6: Gossip Protocols & Cluster Membership: How Decentralized Nodes Maintain Topology

Continue to Part 6 →