system-design · intermediate

Database Sharding — Split Data Across Many Machines

The Central Question

Consider an online SaaS platform running on the DataLab platform (datalab.com) processing 100,000,000 queries per day across 10,000,000 active users and 500,000,000 orders, growing by 5 TB of new transactional data every month:

1. Vertical Scaling (Scale-Up): The database runs on the largest available cloud server VM (128 vCPUs, 1 TB RAM, 64,000 IOPS NVMe storage), but CPU utilization reaches 100% during peak hours.
2. Indexing & Query Optimization: All SQL queries use composite B-Tree indexes, and unused secondary indexes have been purged.
3. Read Replicas: Read queries are offloaded across 10 read replicas, but the single Primary node is saturated by write throughput (INSERT and UPDATE statements).

When a single Primary database node reaches its physical hardware ceiling for writes and storage, the database must be partitioned horizontally.

Database Sharding (Horizontal Partitioning) is the architectural pattern of splitting a large database table into smaller, independent chunks called Shards, with each shard residing on an independent database server instance holding its own dedicated CPU, RAM, and storage.

This lesson answers one central question: How does horizontal database sharding partition massive datasets across independent database nodes using shard keys, and how do engineers design hashing/range routing algorithms while managing cross-shard query penalties and resharding re-balance operations?


Topology: Vertical Scaling Ceiling vs. Horizontal Sharding

Understanding when to transition from vertical scale-up to horizontal sharding defines database infrastructure growth:

flowchart TB
  subgraph Single Primary Infrastructure (Scale-Up Ceiling)
    App1[Application Tier] -->|100% of Write Traffic| SingleDB[(Single Primary Node<br/>128 vCPUs / 1 TB RAM)]
    SingleDB --> R1[(Read Replica 1)]
    SingleDB --> R2[(Read Replica 2)]
    Note1["Write Bottleneck! Single Primary cannot handle write QPS."]
  end
  subgraph Sharded Infrastructure (Scale-Out Architecture)
    App2[Application Tier] --> Router[Database Proxy / Router]
    Router -->|user_id: 1-1,000,000| S1[(Shard Server 1)]
    Router -->|user_id: 1,000,001-2,000,000| S2[(Shard Server 2)]
    Router -->|user_id: 2,000,001-3,000,000| S3[(Shard Server 3)]
    Note2["Each Shard is an independent Primary handling a subset of writes."]
  end

Figure 1: Structural comparison between a saturated single Primary node and a horizontally sharded database cluster.


The Shard Key: Choosing the Partitioning Attribute

The most critical architectural decision in database sharding is selecting the Shard Key (Partition Key).

The Shard Key is a specific column (such as user_id, tenant_id, or account_id) present in database tables used by the database router to compute which shard server stores a given row:

flowchart TD
  Req[Incoming Write: INSERT INTO orders user_id = 89041] --> Router{Evaluate Shard Key: user_id}
  Router -->|Hash Partitioning: hash 89041 % 3 = Shard 2| S2[(Shard Node 2)]
  S2 --> Write[Execute Local SQL Insert]

Figure 2: Routing decision flowchart mapping a shard key value to a target database node.

Partitioning Strategies: Hash vs. Range Partitioning

Partitioning StrategyAlgorithm FormulaAdvantagesDisadvantages / Vulnerabilities
Hash Partitioning$\text{Shard ID} = \text{hash}(\text{Shard Key}) \pmod N$Distributes read/write traffic evenly across nodes; eliminates hot spots.Range queries (WHERE date BETWEEN ...) must scan all shards (Scatter-Gather).
Range Partitioning$\text{Shard ID} = \text{range\_lookup}(\text{Shard Key})$Excellent for range queries (user_id 1..1000 on Shard 1).Risk of Hot Shards if sequential keys (e.g. auto-increment IDs) hit the latest node.

Directory-Based Sharding (Lookup Mapping Table)

In **Directory-Based Sharding**, the database proxy maintains a centralized lookup mapping table (stored in a high-speed cache like Redis) mapping each specific shard key to a target physical server (`user_1001 -> Shard_A`, `user_1002 -> Shard_C`). While directory lookups add an initial cache query hop, they provide complete flexibility to move individual tenant records between shards dynamically during live rebalancing without altering key hashing algorithms.

Virtual Nodes in Hash Sharding Rings

To prevent non-uniform row distribution caused by hash key clustering in basic modulo hashing ($\text{hash}(k) \pmod N$), sharded systems map physical database servers to 100+ **Virtual Nodes** (e.g. `ShardA_1` through `ShardA_100`) on a Consistent Hashing Ring. Virtual nodes smooth out mathematical hash collisions, guaranteeing equal row distribution across physical servers.

The Cross-Shard Query Penalty: Scatter-Gather Fan-Out

When an application issues a query that includes the Shard Key (WHERE user_id = 89041), the router forwards the query directly to a Single Shard ($O(1)$ routing latency).

However, if a query omits the Shard Key (WHERE status = 'PENDING'), the router must execute a Scatter-Gather Query:

flowchart TD
  Router[Database Shard Router] -->|1. Parallel Scatter| S1[(Shard Server 1)]
  Router -->|1. Parallel Scatter| S2[(Shard Server 2)]
  Router -->|1. Parallel Scatter| S3[(Shard Server 3)]
  
  S1 -->|2. Return Partial Rows| Merge[Router Aggregation & Sort]
  S2 -->|2. Return Partial Rows| Merge
  S3 -->|2. Return Partial Rows| Merge
  
  Merge --> Final[Return Final Merged Result to Client]

Figure 3: Scatter-Gather fan-out pattern querying all shards in parallel.

Scatter-Gather Cost Formula

If a sharded cluster contains $S$ shards and average single-shard query execution time is $T_{\text{shard}}$, the total latency and network overhead $L_{\text{scatter}}$ scales as:

$$L_{\text{scatter}} = \max(T_{\text{shard1}}, T_{\text{shard2}}, \dots, T_{\text{shardS}}) + T_{\text{merge}}$$

Scatter-gather queries suffer from the Slowest Shard Bottleneck: a single degraded or slow shard node delays the entire user query response.

Global Secondary Indexes (GSI)

To avoid scatter-gather queries when searching by non-shard keys (e.g. searching orders by `email` when the database is sharded by `user_id`), systems build **Global Secondary Indexes (GSI)**:

Distributed Transactions Across Shards: Two-Phase Commit (2PC)

When a business transaction must update records residing on two different physical shards (e.g. transferring money from Account A on Shard 1 to Account B on Shard 2), standard local database ACID transactions cannot guarantee atomicity. The system must execute a **Two-Phase Commit (2PC)** protocol:
  1. Prepare Phase: The Transaction Coordinator sends a PREPARE request to both Shard 1 and Shard 2. Each shard acquires local locks, writes the WAL log, and responds VOTE_COMMIT.
  2. Commit Phase: If both shards vote YES, the Coordinator sends COMMIT to both shards. If any shard votes NO (or times out), the Coordinator sends ABORT to rollback both shards.
**Trade-off**: 2PC guarantees distributed ACID atomicity, but introduces blocking locks and network roundtrips that reduce transaction throughput by up to **$90\%$**. High-scale systems avoid 2PC by using **Eventual Consistency and Saga Patterns**.

Complete Worked Example: Go Shard Router (Hash & Range Partitioning)

Let's inspect a complete Go implementation of a Shard Router supporting Hash and Range partitioning for the DataLab platform (datalab.com).

package main

import (
"context"
"fmt"
"hash/fnv"
"sync"
)

type ShardNode struct {
ID string
ConnString string
}

type ShardRouter struct {
mu sync.RWMutex
shards map[int]*ShardNode
}

func NewShardRouter(shards map[int]ShardNode) ShardRouter {
return &ShardRouter{shards: shards}
}

func (r ShardRouter) HashRoute(shardKey string) ShardNode {
r.mu.RLock()
defer r.mu.RUnlock()

hasher := fnv.New32a()
hasher.Write([]byte(shardKey))
hashVal := hasher.Sum32()

shardIdx := int(hashVal) % len(r.shards)
fmt.Printf("[HASH ROUTER] Key '%s' (Hash: %d) -> Assigned to Shard Node %d (%s)\n",
shardKey, hashVal, shardIdx, r.shards[shardIdx].ID)

return r.shards[shardIdx]
}

func (r ShardRouter) RangeRoute(userID int64) ShardNode {
r.mu.RLock()
defer r.mu.RUnlock()

var shardIdx int
switch {
case userID <= 1000000:
shardIdx = 0
case userID <= 2000000:
shardIdx = 1
default:
shardIdx = 2
}

fmt.Printf("[RANGE ROUTER] User ID %d -> Assigned to Shard Node %d (%s)\n",
userID, shardIdx, r.shards[shardIdx].ID)

return r.shards[shardIdx]
}


Failure Modes and Engineering Mitigations

Failure ScenarioRoot CauseSystem SymptomDetection SignalMitigation Strategy
1. Poor Shard Key SelectionSharding by country_code causes 80% of data to land on 1 shard (US), leaving others empty.One shard server suffers 100% CPU and disk saturation while other shards remain idle.Asymmetric storage and QPS metrics across database shard nodes.Choose high-cardinality shard keys (user_id, account_id) or Salt Shard Keys.
2. Scatter-Gather OverloadApplication queries without shard key (WHERE status = 'ACTIVE') continuously fan out to all 32 shards.Database router CPU spikes to 100%; high cross-shard network bandwidth usage.High volume of multi-shard query fan-out metrics on gateway proxies.Create Global Secondary Indexes (GSI) or duplicate lookups via Redis.
3. Cross-Shard Transaction DeadlockDistributed 2-Phase Commit (2PC) spans 3 shards; network partition causes locks to hang.Database transaction locks hang for seconds; API write latency surges.Surge in 2PC lock wait duration metrics and thread pool saturation.Avoid cross-shard transactions; structure application data using Saga Patterns.
4. Resharding Downtime OutageCluster attempts to add 4 new shards using naive Modulo Hashing, requiring 100% data copy.Platform suffers complete 12-hour offline outage during data migration.Mass database lock events during cluster expansion scripts.Use Consistent Hashing or Fixed Hash Slots (16,384) for seamless node additions.

What You Should Remember

  1. Sharding provides horizontal write scale: Partition rows across independent database servers to scale write throughput and storage infinitely.
  2. Select high-cardinality shard keys: Use unique keys like user_id or uuid to distribute reads and writes uniformly across shard nodes.
  3. Avoid Scatter-Gather queries: Ensure critical high-frequency queries include the Shard Key to route to a single target node ($O(1)$ routing).
  4. Use Consistent Hashing or Hash Slots: Eliminate 100% data reshuffling when adding shards by using fixed virtual slot assignments.
  5. Avoid Cross-Shard Transactions (2PC): Design application boundaries to keep related transaction records inside the same shard or use Sagas.

Glossary of Terms

TermDefinition
Database ShardingThe horizontal partitioning of database rows across multiple independent physical server instances.
Shard KeyThe specific table column evaluated by database routers to determine which shard stores a given row.
Hash PartitioningA sharding strategy calculating target shard nodes via $\text{hash}(\text{key}) \pmod N$.
Range PartitioningA sharding strategy allocating contiguous key ranges (1..1,000,000) to specific shard nodes.
Scatter-Gather QueryA query that omits the shard key, requiring the router to query all shards in parallel and merge results.
Global Secondary Index (GSI)A secondary lookup table maintained to map non-shard keys to primary shard keys.

Practice Scenario and Self-Assessment

Architecture Scenario

You are designing the sharded database tier for a multi-tenant SaaS platform (`saas.datalab.com`): **Questions**:
  1. Evaluate whether to shard by tenant_id or user_id.
  2. Design the Hot Tenant isolation architecture to prevent Tenant 1 from overwhelming shared shards.

Interactive Self-Assessment

It forces a Scatter-Gather query across all shards in parallel, binding total latency to the slowest shard node.

It automatically formats persistent NVMe SSD disk drives across all shard nodes.

It revokes client HTTPS TLS encryption certificates on edge load balancers.

It doubles the physical hardware clock speed of primary database CPUs.

Hash Partitioning distributes data and write traffic uniformly across all nodes, preventing hot-shard bottlenecks.

Hash Partitioning converts relational SQL schemas into un-indexed CSV files.

Hash Partitioning replaces public DNS nameservers with local hosts entries.

Hash Partitioning reboots operating system hypervisors across all shard nodes.


What to Learn Next

Track: Data, Storage and Messaging

Previous: Database Indexes — Find Rows Without Scanning Everything

Next: Hot Partition / Hot Key — When One Shard Takes All the Heat

By Shubham Jain

All articles · Study paths

Shubham Jain · Learning Lab