05. 一致性哈希与数据分片

一致性哈希算法原理与实现、Range 分片与 Hash 分片对比、虚拟节点、水平扩展与数据迁移策略

分布式系统处理海量数据时,必须通过分片 (Sharding) 将数据分散到多个节点。一致性哈希解决了传统 Hash 取模在节点增减时的大量数据迁移问题。

1. 传统 Hash 分片的问题

传统方式: node = hash(key) % N

N=3 时:
  hash(key) % 3 = 0 → Node0
  hash(key) % 3 = 1 → Node1
  hash(key) % 3 = 2 → Node2

新增 Node3 (N=4):
  原来 75% 的数据需要重新映射!
  hash(key) % 4 和 hash(key) % 3 结果不同的概率 = 3/4

问题:节点数量变化时,几乎所有数据都需要迁移,成本极高。

2. 一致性哈希 (Consistent Hashing)

1997 年由 MIT 的 David Karger 等人提出,核心思想是将节点和数据映射到同一个哈希环上。

2.1 算法原理

哈希环 [0, 2^32-1]:

      Node A (hash=10)
           ●
          / \
   ●----/   \----●
Node D         Node B
(hash=95)      (hash=50)
   \----\   /----/
          \ /
           ●
      Node C (hash=80)

数据定位: 从数据 hash 值顺时针找到第一个节点
  hash(key1)=25 → Node B
  hash(key2)=60 → Node C
  hash(key3)=90 → Node D

2.2 节点增减的影响

新增 Node E (hash=30):
  只有 Node A→Node E 之间的数据从 Node B 迁移到 Node E
  迁移比例 ≈ 1/N(均匀分布假设)

删除 Node B:
  Node B 上的数据顺时针迁移到 Node C
  仅影响 Node B 的数据

2.3 虚拟节点 (Virtual Nodes)

解决数据倾斜问题:一个物理节点对应多个虚拟节点。

物理节点 A → 虚拟节点 A1(10), A2(110), A3(210)
物理节点 B → 虚拟节点 B1(50), B2(150), B3(250)

数据更均匀分布,物理节点负载趋于平衡

2.4 Python 实现

import hashlib
import bisect

class ConsistentHashRing:
    def __init__(self, replicas=150):
        self.replicas = replicas  # 每个物理节点的虚拟节点数
        self.ring = {}            # {hash: node}
        self.sorted_keys = []     # 排序的哈希值
        self.nodes = set()
    
    def _hash(self, key):
        return int(hashlib.md5(key.encode('utf-8')).hexdigest(), 16)
    
    def add_node(self, node):
        self.nodes.add(node)
        for i in range(self.replicas):
            key = self._hash(f"{node}:{i}")
            self.ring[key] = node
            bisect.insort(self.sorted_keys, key)
    
    def remove_node(self, node):
        self.nodes.discard(node)
        for i in range(self.replicas):
            key = self._hash(f"{node}:{i}")
            del self.ring[key]
            idx = bisect.bisect_left(self.sorted_keys, key)
            self.sorted_keys.pop(idx)
    
    def get_node(self, key):
        if not self.ring:
            return None
        h = self._hash(key)
        idx = bisect.bisect_right(self.sorted_keys, h) % len(self.sorted_keys)
        return self.ring[self.sorted_keys[idx]]
    
    def get_nodes(self, key, n=3):
        """获取顺时针的 n 个节点(用于多副本)"""
        if not self.ring or n > len(self.nodes):
            return list(self.nodes)
        h = self._hash(key)
        idx = bisect.bisect_right(self.sorted_keys, h)
        result = []
        seen = set()
        while len(result) < n:
            node = self.ring[self.sorted_keys[idx % len(self.sorted_keys)]]
            if node not in seen:
                seen.add(node)
                result.append(node)
            idx += 1
        return result

# 使用
ch = ConsistentHashRing(replicas=150)
ch.add_node("192.168.1.1")
ch.add_node("192.168.1.2")
ch.add_node("192.168.1.3")

node = ch.get_node("user:12345")
replicas = ch.get_nodes("order:67890", n=3)  # 3 个副本节点

3. 分片策略对比

3.1 Hash 分片

Shard = hash(key) % N
优点缺点
数据分布均匀范围查询需扫描所有分片
写入分散,无热点新增分片需大量迁移
适合点查无法按时间序分区

3.2 Range 分片

Shard 0: user_id [1, 1000000)
Shard 1: user_id [1000000, 2000000)
Shard 2: user_id [2000000, 3000000)

或按时间:
Shard 0: 2024-01 ~ 2024-03
Shard 1: 2024-04 ~ 2024-06
优点缺点
范围查询高效可能产生热点(新数据集中在最新分片)
扩展简单(追加新范围)数据分布可能不均匀
适合时间序列数据需预分片或动态分裂

3.3 混合分片 (Hash + Range)

先按时间 Range 分表,再按 UserID Hash 分库

2024_01_order_0 (user_id % 4 = 0)
2024_01_order_1 (user_id % 4 = 1)
...

3.4 目录分片 (Directory Based)

维护一个元数据服务记录 key→shard 的映射,灵活性最高。

# 元数据表
shard_map = {
    'user:1': 'shard_0',
    'user:2': 'shard_1',
    # ...
}

# 查找
shard = shard_map.get('user:123') or compute_shard('user:123')

4. 水平扩展与数据迁移

4.1 双倍扩展法

N=2 → N=4:
  Shard_0 → Shard_0, Shard_2
  Shard_1 → Shard_1, Shard_3
  
  只需将原来 shard 的数据 COPY 拆分,无需重新 hash

4.2 一致性哈希的渐进迁移

def migrate_data(consistent_hash, new_node):
    """迁移应落在 new_node 上的数据"""
    # 1. 将 new_node 加入环(双写阶段)
    consistent_hash.add_node(new_node)
    
    # 2. 遍历数据,将归属变为 new_node 的数据复制过去
    for key, value in source_db.scan():
        assigned = consistent_hash.get_node(key)
        if assigned == new_node:
            new_node.store(key, value)
    
    # 3. 验证一致性后,确认新节点服务读请求
    # 4. 删除旧节点上的冗余数据

4.3 在线扩缩容流程

1. 新增节点加入集群(不分配流量)
2. 后台迁移数据到新节点(双写:旧节点仍写入)
3. 数据迁移完成,新节点加入读流量(逐步放量)
4. 监控无误后,旧节点停止写入,清理冗余数据
5. 缩容则反向执行

5. 实战:Redis Cluster 的分片

Redis Cluster 使用 哈希槽 (Hash Slot):16384 个槽均匀分配到各节点。

slot = CRC16(key) % 16384

Node A: slots 0-5460
Node B: slots 5461-10922
Node C: slots 10923-16383

# 多 key 操作需在同一个 slot:用 hash tag
{user:123}:profile → 只 hash "user:123"
{user:123}:orders  → 同一 slot
# 查看集群槽分配
redis-cli -c -p 7000 cluster slots

# 迁移槽
redis-cli --cluster reshard 127.0.0.1:7000 \
  --cluster-from node-id-a \
  --cluster-to node-id-b \
  --cluster-slots 1000

6. 实战:Cassandra 一致性哈希

Cassandra 使用 一致性哈希 + 虚拟节点 (vnodes)

默认 256 tokens/节点
Token = hash(partition_key)
数据属于顺时针方向的 N 个 replica 节点

复制因子 RF=3:
  Primary: 负责该 token 区间的节点
  Replica1: 顺时针下一个节点
  Replica2: 顺时针再下一个节点
-- 创建 keyspace 指定复制策略
CREATE KEYSPACE mykeyspace
WITH replication = {
  'class': 'NetworkTopologyStrategy',
  'datacenter1': 3
};

总结

场景推荐分片策略
用户数据(点查为主)Hash 分片 + 一致性哈希
日志/时序数据Range 分片(时间)
需要灵活调度目录分片
缓存层一致性哈希(Redis/Memcached)
数据库中间件范围分片 + 自动分裂

水平扩展关键:

  1. 预分片:提前分成足够多的虚拟分片,减少后续迁移
  2. 渐进迁移:避免全量迁移,逐步切换流量
  3. 双写机制:迁移期间同时写入新旧节点
  4. 元数据服务:记录分片映射,支持灵活调度

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「distributed-systems」更多文章

  1. 分布式高可用架构模式:多活、容灾、降级与 K8s 编排高可用
  2. 分布式链路追踪实战:OpenTelemetry、Jaeger 与 W3C Trace Context
  3. 分布式缓存深度策略:Redis Cluster、一致性哈希与多级缓存架构