Byte-Range Shard Partitioning

Distributed Systems intermediate Building with AI 15 min read

Overview

Byte-range shard partitioning divides a keyspace into contiguous byte ranges, each assigned to a specific node or replica group. Unlike hash-based partitioning, range partitioning preserves key ordering, enabling efficient range scans, prefix queries, and sequential access patterns that are critical for search indexes and ordered storage engines.

This approach is used by systems like Google Bigtable, HBase, CockroachDB, and TiKV. In a typical distributed search system, byte-range partitioning distributes search index segments across nodes, allowing queries to target only the shards that contain relevant key ranges rather than broadcasting to every node.

The tradeoff is that range partitioning requires active management of split and merge operations to handle hotspots. When write traffic concentrates on a narrow key range, that shard becomes a bottleneck. Effective implementations monitor shard sizes and access patterns to trigger automatic rebalancing.

Core Concepts

Key Concept

Range partitioning preserves key ordering across shards, making range scans and prefix queries efficient because they only touch contiguous shards rather than requiring scatter-gather across all nodes.

Key Concept

The shard mapping table is the single source of truth for data placement. Its consistency and availability are critical—an inconsistent mapping leads to misrouted requests and data loss. This is why it is typically replicated via Raft or stored in a strongly consistent coordination service.

Key Concept

Hotspot mitigation in range partitioning requires monitoring both shard size and request rate. A shard may be small but receive disproportionate traffic (e.g., a popular key prefix), requiring a split based on load rather than size alone.

How It Works

Keyspace Division

The entire keyspace is treated as a continuous byte sequence from 0x00...00 to 0xFF...FF. Initially, the system starts with a single shard covering the entire range. As data is ingested, shards are split when they exceed a configured size threshold (e.g., 256 MB). Each split produces two shards with roughly equal data volume.

The split point is chosen by sampling keys in the shard and selecting the median. Some implementations use approximate techniques—for example, scanning the shard’s index to find the key closest to the midpoint by byte count rather than key count, which better handles variable-length values.

Routing and Lookup

When a client needs to read or write a key, it consults the shard mapping table. The table is a sorted list of (startKey, shardID, nodeAddr) tuples. A binary search over the start keys finds the shard that owns the target key. Clients typically cache this table and refresh it when they receive a “not my shard” error from a node, indicating the mapping has changed due to a split or rebalance.

Rebalancing

When a new node joins the cluster, the system identifies overloaded nodes and transfers some of their shards to the newcomer. Unlike hash-based schemes that rebalance by rehashing, range-based rebalancing transfers entire shards intact. The shard mapping table is updated atomically (via a Raft proposal or distributed transaction) to redirect traffic to the new owner.

Practical Applications

In a distributed search index, byte-range partitioning maps document ID ranges to index shards. When a search query includes a document ID filter or range predicate, the query router uses the shard mapping to fan out only to the relevant shards, significantly reducing query latency compared to a broadcast approach.

In a distributed storage layer, range partitioning enables efficient compaction. Because each shard owns a contiguous key range, compaction within a shard is a local operation that does not interfere with adjacent shards. This allows the system to compact shards independently and in parallel.

Range partitioning also simplifies backup and restore operations. Each shard can be snapshot independently, and restoring a specific key range only requires loading the corresponding shard snapshots rather than reconstructing the full dataset.

Implementation Patterns

Two-Level Routing

Production systems often use a two-level routing scheme. A lightweight routing layer (or client library) caches the shard mapping table and routes requests directly to the owning node. If the mapping is stale, the node returns a redirect error with the updated mapping, and the client refreshes its cache. This avoids making the metadata service a bottleneck on every request.

Pre-Splitting for Known Access Patterns

If you know the key distribution in advance (e.g., keys are UUIDs with uniform distribution, or timestamps that will grow monotonically), you can pre-split the keyspace at initialization time. This avoids the cascading split storms that occur when all writes initially land on a single shard.

Shard Size Monitoring

Each node periodically reports shard statistics (size in bytes, key count, read/write QPS) to the metadata service. The rebalancing controller uses these statistics to decide when to split, merge, or transfer shards. Implementing hysteresis (e.g., only split when a shard exceeds 256 MB and only merge when both neighbors are below 64 MB) prevents oscillation.

Code Examples

Shard Router in Go

package sharding

import (
    "bytes"
    "fmt"
    "sort"
    "sync"
)

type Shard struct {
    ID       string
    StartKey []byte // inclusive
    EndKey   []byte // exclusive; nil means +infinity
    NodeAddr string
}

type ShardRouter struct {
    mu     sync.RWMutex
    shards []Shard // sorted by StartKey
}

func NewShardRouter(shards []Shard) *ShardRouter {
    sorted := make([]Shard, len(shards))
    copy(sorted, shards)
    sort.Slice(sorted, func(i, j int) bool {
        return bytes.Compare(sorted[i].StartKey, sorted[j].StartKey) < 0
    })
    return &ShardRouter{shards: sorted}
}

// Route finds the shard that owns the given key using binary search.
func (r *ShardRouter) Route(key []byte) (*Shard, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    idx := sort.Search(len(r.shards), func(i int) bool {
        return r.shards[i].StartKey != nil &&
            bytes.Compare(r.shards[i].StartKey, key) > 0
    }) - 1

    if idx < 0 {
        idx = 0
    }

    shard := &r.shards[idx]
    if shard.EndKey != nil && bytes.Compare(key, shard.EndKey) >= 0 {
        return nil, fmt.Errorf("key %x not covered by any shard", key)
    }
    return shard, nil
}

// RouteRange returns all shards that overlap with [startKey, endKey).
func (r *ShardRouter) RouteRange(startKey, endKey []byte) []Shard {
    r.mu.RLock()
    defer r.mu.RUnlock()

    var result []Shard
    for _, s := range r.shards {
        // Skip shards entirely before the range
        if s.EndKey != nil && bytes.Compare(s.EndKey, startKey) <= 0 {
            continue
        }
        // Stop at shards entirely after the range
        if endKey != nil && bytes.Compare(s.StartKey, endKey) >= 0 {
            break
        }
        result = append(result, s)
    }
    return result
}

Split Decision Logic

package sharding

import "bytes"

type ShardStats struct {
    ShardID   string
    SizeBytes uint64
    KeyCount  uint64
    ReadQPS   float64
    WriteQPS  float64
}

const (
    MaxShardSize   = 256 << 20 // 256 MB
    MinShardSize   = 64 << 20  // 64 MB
    HotShardQPS    = 10000      // QPS threshold for load-based split
)

type SplitDecision struct {
    ShardID  string
    SplitKey []byte
    Reason   string
}

func EvaluateSplits(stats []ShardStats, getSplitKey func(string) []byte) []SplitDecision {
    var decisions []SplitDecision
    for _, s := range stats {
        var reason string
        switch {
        case s.SizeBytes > MaxShardSize:
            reason = "size_exceeded"
        case s.ReadQPS+s.WriteQPS > HotShardQPS && s.KeyCount > 1000:
            reason = "hot_shard"
        default:
            continue
        }
        splitKey := getSplitKey(s.ShardID)
        if splitKey != nil {
            decisions = append(decisions, SplitDecision{
                ShardID:  s.ShardID,
                SplitKey: splitKey,
                Reason:   reason,
            })
        }
    }
    return decisions
}

type MergeDecision struct {
    ShardA string
    ShardB string
}

func EvaluateMerges(shards []Shard, stats map[string]ShardStats) []MergeDecision {
    var decisions []MergeDecision
    for i := 0; i < len(shards)-1; i++ {
        a, b := shards[i], shards[i+1]
        sa, sb := stats[a.ID], stats[b.ID]
        // Only merge adjacent shards that are both small
        if sa.SizeBytes < MinShardSize && sb.SizeBytes < MinShardSize {
            combined := sa.SizeBytes + sb.SizeBytes
            if combined < MaxShardSize {
                if bytes.Equal(a.EndKey, b.StartKey) {
                    decisions = append(decisions, MergeDecision{
                        ShardA: a.ID, ShardB: b.ID,
                    })
                    i++ // skip b in next iteration
                }
            }
        }
    }
    return decisions
}

Common Pitfalls

Further Reading