背景与问题
在微服务系统里,流量路由看起来像个“基础能力”,但真正落地时,往往会牵一发而动全身。
很多团队一开始会用最简单的几种方式:
- Nginx 轮询
- 客户端随机选实例
- 按权重做负载均衡
- 网关层统一转发
这些方式在“实例数稳定、请求无状态”的时候没什么问题。但一旦业务里出现下面几类诉求,问题就开始冒出来了:
-
会话粘性需求
比如用户请求希望尽量打到同一批实例,减少缓存失效。 -
本地缓存命中率要求高
某些服务在内存里做热点数据缓存,如果同一类请求总是被打散,本地缓存基本形同虚设。 -
节点频繁扩缩容
K8s 环境中 Pod 上下线很常见,如果每次扩容都导致大量 key 被重新分配,缓存雪崩和抖动会非常明显。 -
多机房/多分区路由控制
希望在“优先同机房,再降级跨机房”的前提下做稳定路由。
这时,一致性哈希就很适合登场了。
但只讲一致性哈希还不够,真实系统中的实例列表不是写死的,而是来自 服务发现。因此,真正可用的方案其实是:
服务发现动态提供实例集合,一致性哈希在这个实例集合上做稳定映射。
这篇文章我会从架构设计和可运行代码两方面,带你把这件事走通。
方案概览与取舍分析
先给一个整体视角:我们要解决的不是“怎么挑一个实例”,而是“怎么在动态实例集合上,尽量稳定地挑到同一个实例”。
一个典型调用链
flowchart LR
A[客户端/网关] --> B[服务发现模块]
B --> C[获取健康实例列表]
A --> D[一致性哈希路由器]
C --> D
D --> E[选出目标实例]
E --> F[发起服务调用]
这里有两个关键模块:
- 服务发现:维护当前可用实例列表
- 一致性哈希路由器:根据路由 key 选择实例
为什么不用普通哈希
最直观的办法是:
instance = nodes[hash(key) % N]
问题在于,当实例数量从 N 变成 N+1 时,几乎所有 key 的映射都会变化。
这在缓存、会话、热点路由场景里代价很高。
一致性哈希的优势在于:
- 节点增减时,只影响环上相邻的一小部分 key
- 映射更稳定
- 更适合动态扩缩容环境
与其他策略的取舍
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 随机/轮询 | 简单、易实现 | 无法保证稳定映射 | 无状态接口 |
| 最少连接 | 对长连接友好 | 实现复杂,粘性弱 | 长连接服务 |
| 普通哈希取模 | 计算快 | 扩缩容时大面积迁移 | 节点固定场景 |
| 一致性哈希 | 扩缩容迁移少 | 需处理虚拟节点、实例变化 | 缓存、粘性路由、热点稳定分配 |
一句话总结:
如果你的路由 key 有业务意义,并且你希望“同一个 key 尽量落到同一实例”,一致性哈希通常比轮询更对路。
核心原理
1. 一致性哈希的基本思想
把哈希空间想象成一个环:
- 对每个实例做哈希,放到环上
- 对请求 key 做哈希,也映射到环上
- 顺时针找到第一个实例,就是目标节点
flowchart TB
K1[Key: user_1001] --> H1[Hash到环上位置]
H1 --> N2[顺时针找到实例 B]
K2[Key: order_88] --> H2[Hash到环上位置]
H2 --> N3[顺时针找到实例 C]
这样做的好处是:
新增或删除某个实例时,只会影响它附近的一部分 key,而不是全量重排。
2. 为什么要加虚拟节点
如果直接把真实节点放到环上,可能出现分布不均:
- 某些实例负责范围很大
- 某些实例几乎没流量
所以通常会给每个真实实例创建多个虚拟节点:
10.0.0.1:8080#0
10.0.0.1:8080#1
10.0.0.1:8080#2
...
这样哈希环更均匀,流量分布更平滑。
3. 服务发现解决什么问题
一致性哈希本身只负责“选节点”,但它不知道“节点是谁”。
服务发现系统负责:
- 实例注册
- 健康检查
- 实例上下线通知
- 提供当前可用实例列表
典型组件包括:
- Consul
- etcd
- ZooKeeper
- Nacos
- Kubernetes Service + EndpointSlice
在工程上,路由器通常不会每次请求都实时拉注册中心,而是:
- 先从本地缓存拿实例列表
- 由后台 watcher/watch stream 持续更新
- 用最新实例列表重建哈希环
4. 关键设计点:路由 key 到底选什么
这是实战里最容易被忽视的点。常见选择:
- 用户维度:
userId - 租户维度:
tenantId - 会话维度:
sessionId - 订单维度:
orderId - 组合维度:
tenantId:userId
经验上我建议:
- 想要用户粘性:用
userId - 想要租户隔离感更强:用
tenantId - 想提升本地缓存命中:用与缓存 key 一致的主维度
如果 key 选错了,一致性哈希做得再好,收益也会很有限。
架构设计:服务发现 + 一致性哈希如何协同
下面给一个更完整的时序图。
sequenceDiagram
participant Client as 调用方
participant Router as 路由器
participant Registry as 服务注册中心
participant ServiceA as 实例A
participant ServiceB as 实例B
Registry-->>Router: 推送/拉取健康实例列表
Router->>Router: 重建一致性哈希环
Client->>Router: 请求(userId=1001)
Router->>Router: hash(userId)并选择实例
Router->>ServiceA: 转发请求
ServiceA-->>Router: 响应结果
Router-->>Client: 返回结果
Registry-->>Router: 实例B下线
Router->>Router: 更新哈希环
Client->>Router: 再次请求(userId=1001)
Router->>ServiceB: 若落点受影响则迁移,否则仍命中原实例
推荐的模块拆分
在代码层面,建议至少拆成三层:
-
DiscoveryProvider
负责返回健康实例列表 -
ConsistentHashRouter
负责构建哈希环和选实例 -
ServiceClient
负责实际发请求、超时控制、重试和熔断
这样做的好处是职责清晰,后续切换注册中心也更容易。
实战代码(可运行)
下面用 Python 做一个可运行示例。
这个例子不会直接接入真实注册中心,但会模拟:
- 服务实例注册与下线
- 一致性哈希选路
- 节点变更前后的 key 迁移效果
你可以直接保存为 router_demo.py 运行。
import hashlib
import bisect
import threading
from dataclasses import dataclass
from typing import List, Dict, Optional
@dataclass(frozen=True)
class ServiceInstance:
service_name: str
host: str
port: int
weight: int = 1
@property
def instance_id(self) -> str:
return f"{self.service_name}:{self.host}:{self.port}"
class InMemoryDiscoveryProvider:
"""
模拟服务发现:
- register: 注册实例
- deregister: 下线实例
- get_instances: 获取当前健康实例
"""
def __init__(self):
self._services: Dict[str, List[ServiceInstance]] = {}
self._lock = threading.Lock()
def register(self, instance: ServiceInstance):
with self._lock:
self._services.setdefault(instance.service_name, [])
if instance not in self._services[instance.service_name]:
self._services[instance.service_name].append(instance)
def deregister(self, instance: ServiceInstance):
with self._lock:
instances = self._services.get(instance.service_name, [])
self._services[instance.service_name] = [i for i in instances if i != instance]
def get_instances(self, service_name: str) -> List[ServiceInstance]:
with self._lock:
return list(self._services.get(service_name, []))
class ConsistentHashRouter:
def __init__(self, virtual_nodes: int = 100):
self.virtual_nodes = virtual_nodes
self._ring = []
self._node_map = {}
self._lock = threading.Lock()
@staticmethod
def _hash(value: str) -> int:
return int(hashlib.md5(value.encode("utf-8")).hexdigest(), 16)
def rebuild(self, instances: List[ServiceInstance]):
ring = []
node_map = {}
for instance in instances:
replicas = self.virtual_nodes * max(1, instance.weight)
for i in range(replicas):
vnode_key = f"{instance.instance_id}#{i}"
h = self._hash(vnode_key)
ring.append(h)
node_map[h] = instance
ring.sort()
with self._lock:
self._ring = ring
self._node_map = node_map
def route(self, key: str) -> Optional[ServiceInstance]:
with self._lock:
if not self._ring:
return None
h = self._hash(key)
idx = bisect.bisect_left(self._ring, h)
if idx == len(self._ring):
idx = 0
return self._node_map[self._ring[idx]]
class RoutedServiceClient:
def __init__(self, discovery: InMemoryDiscoveryProvider, virtual_nodes: int = 100):
self.discovery = discovery
self.router = ConsistentHashRouter(virtual_nodes=virtual_nodes)
self._service_snapshots: Dict[str, List[ServiceInstance]] = {}
def refresh(self, service_name: str):
instances = self.discovery.get_instances(service_name)
self._service_snapshots[service_name] = instances
self.router.rebuild(instances)
def call(self, service_name: str, route_key: str, payload: dict):
if service_name not in self._service_snapshots:
self.refresh(service_name)
instance = self.router.route(route_key)
if not instance:
raise RuntimeError(f"没有可用实例: {service_name}")
# 这里只做模拟,真实场景应发起 HTTP/gRPC 调用
return {
"service": service_name,
"route_key": route_key,
"target_instance": instance.instance_id,
"payload": payload
}
def print_distribution(client: RoutedServiceClient, service_name: str, keys: List[str], title: str):
print(f"\n=== {title} ===")
stats = {}
for key in keys:
result = client.call(service_name, key, {"demo": True})
instance = result["target_instance"]
stats[instance] = stats.get(instance, 0) + 1
print(f"{key} -> {instance}")
print("\n分布统计:")
for instance, count in sorted(stats.items()):
print(f"{instance}: {count}")
if __name__ == "__main__":
discovery = InMemoryDiscoveryProvider()
s1 = ServiceInstance("user-service", "10.0.0.1", 8080)
s2 = ServiceInstance("user-service", "10.0.0.2", 8080)
s3 = ServiceInstance("user-service", "10.0.0.3", 8080)
discovery.register(s1)
discovery.register(s2)
discovery.register(s3)
client = RoutedServiceClient(discovery, virtual_nodes=200)
client.refresh("user-service")
route_keys = [f"user:{i}" for i in range(1, 21)]
print_distribution(client, "user-service", route_keys, "初始路由")
# 模拟扩容
s4 = ServiceInstance("user-service", "10.0.0.4", 8080)
discovery.register(s4)
client.refresh("user-service")
print_distribution(client, "user-service", route_keys, "扩容后路由")
# 模拟缩容
discovery.deregister(s2)
client.refresh("user-service")
print_distribution(client, "user-service", route_keys, "实例下线后路由")
运行效果你应该关注什么
不是只看“能不能选到实例”,而是看下面两个指标:
- 分布是否大致均匀
- 扩缩容后是否只有部分 key 迁移
这才是一致性哈希真正的价值。
进一步落地:接真实服务发现时怎么做
上面是内存版模拟。到了生产环境,常见做法一般是这样:
方案 A:客户端直连注册中心
- 每个调用方都订阅目标服务实例列表
- 本地维护哈希环
- 直接路由到目标实例
优点:
- 路由灵活
- 少一跳网络开销
- 可按业务自定义路由 key
缺点:
- 每个客户端都要实现服务发现和缓存更新
- SDK 复杂度会提高
方案 B:在网关或 Sidecar 上统一实现
- 业务代码不关心实例选择
- 网关/Sidecar 维护服务发现与哈希环
- 统一做路由、重试、熔断、观测
优点:
- 逻辑收敛,治理方便
- 便于统一升级和审计
缺点:
- 某些业务细粒度路由诉求不容易下沉
- 可能增加中间层复杂度
我的经验是:
- 业务路由 key 很强、差异化明显:更适合客户端实现
- 团队更看重统一治理和可控性:更适合网关或 Sidecar 实现
容量估算与参数建议
一致性哈希不是“写完就行”,参数会直接影响效果。
1. 虚拟节点数怎么选
通常建议从以下范围开始试:
- 小规模集群:
50 ~ 100 - 中等规模集群:
100 ~ 300 - 对均衡性要求高:
300+
但虚拟节点不是越多越好,因为它会带来:
- 更大的内存占用
- 更慢的环构建速度
- 更高的更新时间成本
经验建议:
- 先用
100或200 - 用真实流量 key 样本压测分布
- 观察偏斜率再调优
2. 环更新时间控制
如果实例变化频繁,不要每收到一次事件就立刻全量重建。
可以做:
- 短时间事件合并
- 增量更新
- 双缓冲切换
例如:
- 100ms 内收到多个实例变更事件,只重建一次
- 新环构建完后原子替换旧环
3. 请求量与实例数关系
如果 key 数量本身就很少,比如只有几十个租户,而实例有上百个,那么一致性哈希再均匀也没法让每台实例都有流量。
这是业务 key 分布决定的,不是算法失效。
常见坑与排查
这一节我尽量写得接地气一点,因为这些坑真的是很常见。
坑 1:实例列表不一致,导致不同客户端路由结果不同
现象:
- 同一个
userId,A 客户端路由到实例 1 - B 客户端却路由到实例 3
原因:
- 各客户端本地缓存的实例列表版本不一致
- 某些实例健康状态传播延迟
- 排序规则不一致
排查建议:
- 打印实例列表版本号
- 打印哈希环摘要值(如 ring checksum)
- 比较不同客户端的实例排序是否一致
一个很实用的办法是,给当前环生成一个签名:
def ring_checksum(instances):
raw = ",".join(sorted(i.instance_id for i in instances))
return hashlib.md5(raw.encode("utf-8")).hexdigest()
如果 checksum 不一样,路由结果不一致几乎是必然的。
坑 2:哈希 key 选得太随机,根本达不到粘性效果
现象:
- 明明上了一致性哈希,但缓存命中率没提升
- 同一个用户请求还是被打散
原因:
- 路由 key 用了 requestId、traceId 这种每次都变的值
- 业务维度选错了
排查建议:
- 先明确你到底想稳定什么:用户、租户、会话,还是商品
- 检查日志里 route_key 是否真的是稳定值
坑 3:虚拟节点太少,流量严重倾斜
现象:
- 某台实例 CPU 很高
- 某些实例几乎没流量
原因:
- 环分布不均
- 节点数太少,虚拟节点也少
- key 本身就有热点
排查建议:
- 提高虚拟节点数量
- 对热点 key 做识别
- 必要时引入“热点拆分 key”策略
例如把极热的单个租户从:
tenant_001
拆成:
tenant_001:bucket_0
tenant_001:bucket_1
...
当然,这么做会牺牲一部分强粘性,需要按业务权衡。
坑 4:实例刚下线,请求还在打过去
现象:
- 注册中心已经摘除实例
- 但客户端仍有少量请求命中旧实例
原因:
- 本地缓存更新有延迟
- 正在执行中的连接池还保留旧连接
- 路由层和连接层状态不同步
排查建议:
- 检查服务发现事件传播延迟
- 检查连接池是否及时清理失效连接
- 结合熔断器/失败重试做兜底
坑 5:重试机制把粘性路由打散了
这个坑特别隐蔽。
现象:
- 首次请求路由很稳定
- 一旦失败重试,请求跑到别的实例去了
原因:
- 重试策略重新随机选节点
- 或者重试时忽略了原始 route key
建议:
- 同一请求链路内,优先保持相同 route key
- 若业务要求强粘性,第一次失败后可先在原节点做短重试
- 若原节点明确不可用,再切换备选节点
安全/性能最佳实践
安全实践
虽然路由本身像个“内部能力”,但也有安全边界要守。
1. 不要直接信任外部传入的路由 key
如果客户端能任意构造 route key,可能导致:
- 热点打穿某个实例
- 恶意构造碰撞 key
- 绕过某些隔离策略
建议:
- 在服务端从认证后的上下文提取 key
- 比如从 JWT 中读取
userId、tenantId - 不要直接使用用户随便传入的 header 作为最终路由依据
2. 注册中心数据要做鉴权与完整性保护
实例列表一旦被污染,路由就可能被劫持。
至少要保证:
- 注册中心访问有认证授权
- 服务注册有身份校验
- 通信链路启用 TLS
3. 路由日志注意脱敏
如果 route key 含有用户标识、租户信息,打印日志时要注意脱敏或做摘要化:
def mask_key(key: str) -> str:
if len(key) <= 6:
return "***"
return key[:3] + "***" + key[-2:]
性能实践
1. 路由查询要 O(log N) 或更优
像本文示例一样,用有序数组 + 二分查找是比较稳妥的:
- 构建环:
O(V log V),V 为虚拟节点总数 - 单次路由:
O(log V)
对于绝大多数业务完全够用。
2. 环更新与请求读路径分离
不要在请求线程里边更新边读。
推荐:
- 后台线程构建新环
- 构建完成后原子替换
- 请求线程始终只读当前快照
3. 给服务发现加本地缓存和过期保护
如果注册中心短时抖动,不要立刻让路由器“无实例可用”。
建议:
- 使用最近一次成功快照
- 设置短期容忍窗口
- 配合熔断和告警
4. 做好观测指标
至少要有这些指标:
- 路由命中实例分布
- 实例变更次数
- 哈希环重建耗时
- key 迁移比例
- 调用失败率 / 重试率
- 注册中心同步延迟
这些指标在问题发生时非常关键。很多时候不是算法错了,而是数据面和控制面不同步。
一套更稳妥的生产落地建议
如果你准备在线上系统使用,我建议按下面的顺序推进:
-
先明确路由目标
- 是为了会话粘性?
- 还是为了缓存命中?
- 还是为了租户级稳定分配?
-
选对路由 key
- 优先选稳定、业务有意义的主键
- 不要选每次变化的随机值
-
接入服务发现快照
- 优先基于健康实例构建哈希环
- 统一实例排序和签名方式
-
设置合理虚拟节点数
- 先从 100/200 起步
- 用真实 key 样本校验均衡性
-
补齐容错机制
- 下线延迟容忍
- 重试保持粘性
- 连接池失效清理
- 熔断与降级
-
观察迁移比例
- 每次扩缩容后,统计 key 迁移率
- 如果迁移异常高,优先检查实例列表一致性和实现细节
总结
把一致性哈希和服务发现放在一起看,事情就清楚了:
- 服务发现解决“当前有哪些可用实例”
- 一致性哈希解决“同一个业务 key 应该稳定打到哪台实例”
这套方案特别适合下面几类场景:
- 需要用户/租户粘性路由
- 想提高本地缓存命中率
- 实例会频繁扩缩容
- 希望减少节点变更带来的流量抖动
但它也不是银弹。边界条件要记住:
- 如果业务完全无状态,轮询/随机可能更简单
- 如果 key 本身分布极端不均,单靠一致性哈希无法消除热点
- 如果实例列表在各客户端不一致,再好的哈希算法也会失效
如果你准备真正落地,我的可执行建议是:
- 先选一个最能代表业务稳定性的 route key
- 用真实流量样本验证分布和迁移率
- 把实例列表一致性、重试粘性、观测指标一起补上
- 再逐步扩大到核心链路
很多架构方案的问题不在“原理不对”,而在“工程细节没收住”。
一致性哈希 + 服务发现就是一个典型例子:原理不复杂,但做扎实了,收益非常实在。