第 8 章:一致性哈希
第 8 章:一致性哈希
8.1 为什么需要一致性哈希?
Memcached 是"无中心"架构——服务端节点之间互不通信。客户端通过哈希算法决定将 Key 路由到哪个节点。
朴素哈希的问题
# 朴素哈希:hash(key) % N
def get_node(key, num_nodes):
return hash(key) % num_nodes
# 3 个节点时:
# key1 → hash(key1) % 3 = 1 → Node 2
# key2 → hash(key2) % 3 = 0 → Node 1
# key3 → hash(key3) % 3 = 2 → Node 3
# 问题:增加 1 个节点(3 → 4)后:
# key1 → hash(key1) % 4 = 3 → Node 4 ← 命中节点改变!
# key2 → hash(key2) % 4 = 0 → Node 1
# key3 → hash(key3) % 4 = 2 → Node 3
朴素哈希取模的问题:
节点数 N 变化时,几乎所有 Key 的路由都会改变
→ 大量缓存失效 → 数据库压力激增 → 可能导致雪崩
影响比例: 约 (N-1)/N 的 Key 会受影响
例: 从 3 节点 → 4 节点,约 75% 的 Key 路由改变
一致性哈希的目标
| 目标 | 说明 |
|---|
| 单调性 | 新增节点只影响部分 Key 的路由 |
| 平衡性 | 各节点负载大致均匀 |
| 分散性 | 同一 Key 不应被映射到不同节点 |
| 负载均衡 | 节点变更时影响最小化 |
8.2 一致性哈希原理
哈希环
一致性哈希将整个哈希空间组织成一个虚拟的环(0 ~ 2^32-1):
0
│
┌─────────┼─────────┐
│ │ │
│ Node A│ │
│ · │ · │
│ │ Node B │
│ │ │
│ · │ │
│ Node C │ │
│ │ · │
│ │ Node D │
└─────────┼─────────┘
│
2^32-1
节点映射: hash(node_id) → 环上位置
Key 路由: hash(key) → 环上位置 → 顺时针找到的第一个节点
路由示例
环上节点位置(顺时针):
0° ─── Node A (100°) ─── Node B (200°) ─── Node C (300°) ─── 360°
Key X → hash(X) = 150° → 顺时针找到 Node B
Key Y → hash(Y) = 250° → 顺时针找到 Node C
Key Z → hash(Z) = 50° → 顺时针找到 Node A
节点增减的影响
移除 Node B:
0° ─── Node A (100°) ─── Node C (300°) ─── 360°
Key X (150°) → 顺时针找到 Node C(改变!)
Key Y (250°) → 顺时针找到 Node C(不变)
Key Z (50°) → 顺时针找到 Node A(不变)
影响范围: 只有 Node B 负责的 Key 需要重新路由
影响比例: 约 1/N(而非朴素哈希的 (N-1)/N)
8.3 虚拟节点
问题:节点分布不均匀
真实场景中,少量节点在环上可能分布不均,导致负载倾斜。
虚拟节点解决方案
每个物理节点映射多个虚拟节点到环上:
物理节点 → 虚拟节点映射:
Node A → VA-1 (50°), VA-2 (150°), VA-3 (250°)
Node B → VB-1 (70°), VB-2 (170°), VB-3 (270°)
Node C → VC-1 (90°), VC-2 (190°), VC-3 (290°)
环上: 50° 70° 90° 150° 170° 190° 250° 270° 290°
A B C A B C A B C
负载更均匀!
虚拟节点数量选择
| 虚拟节点数 | 负载均衡性 | 内存开销 | 推荐 |
|---|
| 1 | 差 | 最小 | 不推荐 |
| 50 | 较好 | 小 | 小规模 |
| 150 | 好 | 适中 | 通用推荐 |
| 256 | 很好 | 适中 | 大规模 |
| 1000+ | 极好 | 较大 | 超大规模 |
8.4 哈希算法选择
常用算法对比
| 算法 | 速度 | 分布均匀性 | 推荐 |
|---|
| CRC32 | ★★★★★ | ★★★ | 一般 |
| FNV1a_32 | ★★★★★ | ★★★★ | 推荐 |
| MurmurHash3 | ★★★★ | ★★★★★ | 强烈推荐 |
| MD5 | ★★ | ★★★★★ | 不推荐(太慢) |
| xxHash | ★★★★★ | ★★★★★ | 推荐 |
8.5 客户端实现
Python 实现
import hashlib
import bisect
class ConsistentHash:
"""一致性哈希实现(带虚拟节点)"""
def __init__(self, nodes=None, vnodes=150):
self.vnodes = vnodes
self.ring = [] # 排序的哈希值列表
self.ring_map = {} # 哈希值 → 节点
self.nodes = set()
if nodes:
for node in nodes:
self.add_node(node)
def _hash(self, key):
"""计算哈希值"""
h = hashlib.md5(key.encode('utf-8')).hexdigest()
return int(h, 16)
def add_node(self, node):
"""添加节点"""
self.nodes.add(node)
for i in range(self.vnodes):
vnode_key = f"{node}:vnode{i}"
h = self._hash(vnode_key)
bisect.insort(self.ring, h)
self.ring_map[h] = node
def remove_node(self, node):
"""移除节点"""
self.nodes.discard(node)
for i in range(self.vnodes):
vnode_key = f"{node}:vnode{i}"
h = self._hash(vnode_key)
self.ring.remove(h)
del self.ring_map[h]
def get_node(self, key):
"""获取 Key 对应的节点"""
if not self.ring:
return None
h = self._hash(key)
idx = bisect.bisect_right(self.ring, h)
if idx == len(self.ring):
idx = 0
return self.ring_map[self.ring[idx]]
# 使用示例
ch = ConsistentHash(
nodes=["mc1:11211", "mc2:11211", "mc3:11211"],
vnodes=150
)
# 路由 Key
print(ch.get_node("user:1001")) # mc2:11211
print(ch.get_node("user:1002")) # mc1:11211
print(ch.get_node("session:abc")) # mc3:11211
# 添加新节点
ch.add_node("mc4:11211")
print(ch.get_node("user:1001")) # 大概率不变
# 验证分布均匀性
from collections import Counter
dist = Counter(ch.get_node(f"key:{i}") for i in range(10000))
for node, count in sorted(dist.items()):
print(f"{node}: {count} ({count/100:.1f}%)")
Go 实现
package consistent
import (
"hash/crc32"
"sort"
"strconv"
"sync"
)
type ConsistentHash struct {
mu sync.RWMutex
ring []uint32
ringMap map[uint32]string
nodes map[string]bool
vnodes int
}
func New(vnodes int) *ConsistentHash {
return &ConsistentHash{
ring: make([]uint32, 0),
ringMap: make(map[uint32]string),
nodes: make(map[string]bool),
vnodes: vnodes,
}
}
func (ch *ConsistentHash) hash(key string) uint32 {
return crc32.ChecksumIEEE([]byte(key))
}
func (ch *ConsistentHash) AddNode(node string) {
ch.mu.Lock()
defer ch.mu.Unlock()
ch.nodes[node] = true
for i := 0; i < ch.vnodes; i++ {
h := ch.hash(node + ":vnode" + strconv.Itoa(i))
ch.ring = append(ch.ring, h)
ch.ringMap[h] = node
}
sort.Slice(ch.ring, func(i, j int) bool {
return ch.ring[i] < ch.ring[j]
})
}
func (ch *ConsistentHash) RemoveNode(node string) {
ch.mu.Lock()
defer ch.mu.Unlock()
delete(ch.nodes, node)
newRing := make([]uint32, 0)
for _, h := range ch.ring {
if ch.ringMap[h] != node {
newRing = append(newRing, h)
} else {
delete(ch.ringMap, h)
}
}
ch.ring = newRing
}
func (ch *ConsistentHash) GetNode(key string) string {
ch.mu.RLock()
defer ch.mu.RUnlock()
if len(ch.ring) == 0 {
return ""
}
h := ch.hash(key)
idx := sort.Search(len(ch.ring), func(i int) bool {
return ch.ring[i] >= h
})
if idx >= len(ch.ring) {
idx = 0
}
return ch.ringMap[ch.ring[idx]]
}
8.6 各语言客户端的一致性哈希
libmemcached(C/C++ 库)
# libmemcached 使用 Ketama 一致性哈希算法
# 配置字符串中指定:
# --SERVER=mc1:11211 --SERVER=mc2:11211 --SERVER=mc3:11211 --HASH=WHEEL
PHP Memcached 扩展
<?php
$mc = new Memcached();
// 设置一致性哈希算法
$mc->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true);
// 添加服务器
$mc->addServers([
['mc1', 11211, 100], // [host, port, weight]
['mc2', 11211, 100],
['mc3', 11211, 100],
]);
// 自动使用一致性哈希路由
$mc->set('user:1001', $userData, 3600);
$mc->get('user:1001'); // 自动路由到正确的节点
Java Spymemcached
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.ConnectionFactoryBuilder;
// 使用 Ketama 一致性哈希
ConnectionFactoryBuilder builder = new ConnectionFactoryBuilder();
builder.setHashAlg(DefaultHashAlgorithm.KETAMA_HASH);
builder.setLocatorType(ConnectionFactoryBuilder.LocatorType.CONSISTENT);
MemcachedClient mc = new MemcachedClient(
builder.build(),
AddrUtil.getAddresses("mc1:11211 mc2:11211 mc3:11211")
);
Go gomemcache
import "github.com/bradfitz/gomemcache/memcache"
// gomemcache 默认使用一致性哈希
mc := memcache.New("mc1:11211", "mc2:11211", "mc3:11211")
// 自动使用 fnv32a 一致性哈希路由
8.7 Ketama 算法
Ketama 是最广泛使用的一致性哈希算法,最初由 Last.fm 开发。
特点
| 特性 | 说明 |
|---|
| 虚拟节点 | 每个物理节点 160 个虚拟节点 |
| 哈希算法 | MD5 生成 4 个 32 位哈希值(一次 MD5 = 4 个 vnode) |
| 权重支持 | 可通过 weight 参数控制节点的虚拟节点数 |
| 兼容性 | 几乎所有 Memcached 客户端都支持 |
Ketama 算法伪代码
for each node:
for i in 0..39: # 40 次循环 × 4 个哈希 = 160 个虚拟节点
digest = md5(f"{node}:{i}")
for j in 0..3:
hash = digest[j*4..j*4+4] as uint32
ring.append(hash, node)
sort ring by hash
8.8 节点增减的缓存失效分析
数据迁移量
假设 N 个节点,总 Key 数为 K
新增 1 个节点:
需要迁移的 Key ≈ K / (N + 1)
缓存命中率下降 ≈ 1 / (N + 1)
移除 1 个节点:
需要回源的 Key ≈ K / N
缓存命中率下降 ≈ 1 / N
实际影响
| 场景 | 节点变化 | 缓存失效比例 |
|---|
| 3 → 4 节点 | 新增 1 台 | ~25% |
| 10 → 11 节点 | 新增 1 台 | ~9% |
| 50 → 51 节点 | 新增 1 台 | ~2% |
| 3 → 2 节点 | 故障 1 台 | ~33% |
| 10 → 9 节点 | 故障 1 台 | ~10% |
8.9 最佳实践
节点扩容策略
推荐:预分配虚拟节点
1. 启动时配置比实际更多的节点地址
2. 先部署空节点
3. 逐步将流量切换到新节点
4. 观察命中率和 DB 压力
Key 设计与哈希
# 好的做法:Key 有明确前缀
"user:1001" # 可控的哈希分布
"session:abc123" # 不同前缀分散到不同节点
# 不好的做法:Key 有相同前缀
"cache_1", "cache_2", "cache_3" # 可能集中在同一节点
节点故障处理
class ResilientMemcached:
def __init__(self, servers):
self.healthy = set(servers)
self.all_servers = servers
self.mc = memcache.Client(servers)
self.fail_counts = {}
def get(self, key):
try:
value = self.mc.get(key)
self.fail_counts[key] = 0
return value
except Exception as e:
self.fail_counts[key] = self.fail_counts.get(key, 0) + 1
if self.fail_counts[key] >= 3:
# 标记节点不健康,移除
self._mark_unhealthy(key)
return None
def _mark_unhealthy(self, key):
# 更新一致性哈希环,临时移除故障节点
pass
扩展阅读
小结
| 要点 | 内容 |
|---|
| 核心原理 | 哈希环 + 顺时针查找,节点增减只影响部分 Key |
| 虚拟节点 | 每个物理节点映射 150+ 虚拟节点到环上,保证负载均衡 |
| 推荐算法 | Ketama(业界标准)或 MurmurHash3 |
| 扩容影响 | 增减 1 个节点影响约 1/N 的 Key |
| 客户端 | 大多数 Memcached 客户端内置一致性哈希支持 |