背景与问题
在分布式系统里,“把请求发到哪台机器”看起来只是一个路由问题,实际上常常决定了系统的稳定性上限。
很多团队在服务规模还小时,会直接用以下几种方式做流量分发:
- Nginx 轮询
- 随机挑一台实例
- 按权重做负载均衡
- 客户端拿到实例列表后随便选
这些办法在实例数量稳定、请求无状态时通常够用。但一旦场景变成下面这样,问题就开始集中爆发:
- 服务实例会频繁扩缩容
- 某些请求希望尽量命中同一实例的本地缓存
- 某些租户、用户、会话需要“相对固定”地落到同一批节点
- 服务注册中心会有短暂抖动
- 部分节点性能退化,但还没完全宕机
- 调度层需要高可用,不能因为一个中心节点挂了就失效
我第一次在生产环境遇到这个问题时,最明显的症状是:一扩容,缓存命中率直接掉下去;一缩容,热点节点瞬间被打穿。
根因并不复杂:流量调度策略不稳定,实例集合一变化,请求映射关系就大面积重排。
这类问题通常要同时解决两个维度:
- 服务发现:我如何知道当前有哪些健康实例可用?
- 流量调度:我如何把请求稳定且均衡地映射到这些实例?
本文就围绕这个组合拳展开:用服务发现维护可用实例集合,用一致性哈希做稳定分配,再补上高可用与排障能力。
方案目标与适用边界
先说结论,这套方案特别适合以下场景:
- 网关到下游服务的客户端路由
- 多租户请求按租户 ID 稳定分发
- 会话、购物车、推荐画像等带有局部状态/缓存的数据访问
- 分布式缓存代理层、任务分片调度层
- 需要在扩缩容时降低流量抖动的服务
但它也不是万能钥匙。边界要讲清楚:
- 如果业务完全无状态,且已经有成熟的 L4/L7 负载均衡,未必需要在客户端再加一致性哈希
- 如果请求分布极度不均,单纯一致性哈希可能仍会出现热点,需要配合虚拟节点、权重、热点隔离
- 如果实例频繁上下线到秒级抖动,服务发现链路本身不稳,再好的哈希策略也救不了全局稳定性
核心原理
1. 服务发现负责“看见谁可用”
服务发现系统的职责,不是帮你做复杂调度,而是维护一个尽量实时、可信的实例视图。
常见模式有两种:
- 服务端发现:请求先到负载均衡器,由它查注册中心后转发
- 客户端发现:客户端直接从注册中心拉取/订阅实例列表,自行路由
本文更偏向客户端发现 + 本地一致性哈希,原因是:
- 减少中心转发层压力
- 每个客户端可独立决策,避免单点瓶颈
- 可针对不同业务键做精细路由
一个典型的流程如下:
flowchart LR
A[服务实例启动] --> B[向注册中心注册]
B --> C[注册中心维护健康实例列表]
D[客户端订阅实例变更] --> C
C --> E[客户端更新本地节点视图]
E --> F[基于一致性哈希选择目标实例]
F --> G[发起请求]
这里最关键的一点是:
客户端不应该每次请求都去问注册中心。
正确做法通常是:
- 启动时全量拉取
- 运行中通过 watch/长轮询/流式订阅增量更新
- 本地缓存实例列表和版本号
- 注册中心不可达时短时间内使用最后一次成功快照
这就是高可用的第一层。
2. 一致性哈希负责“稳定地把请求映射出去”
为什么不用普通取模?
如果有 4 台实例,按 hash(key) % 4 分配,请求映射很简单。
问题在于实例数从 4 变成 5 时,几乎所有 key 的落点都会变化。
这在带缓存、会话粘性、租户隔离的场景里是灾难。
一致性哈希的核心思想
一致性哈希把节点和请求 key 都映射到一个首尾相连的哈希环上:
- 节点按哈希值放在环上
- 请求 key 也映射到环上某一点
- 从这个点顺时针找到第一个节点,就是目标实例
这样一来,当新增或删除一个节点时,只有该节点附近的一小部分 key 会迁移,而不是全量重排。
flowchart TD
K1[KeyA] --> H1[哈希环位置]
K2[KeyB] --> H2[哈希环位置]
N1[Node-1]
N2[Node-2]
N3[Node-3]
H1 --> N2
H2 --> N3
为什么需要虚拟节点?
真实机器数量往往不多,直接映射到环上可能分布不均。
解决方法是给每个真实节点创建多个虚拟节点:
10.0.0.1:8080#010.0.0.1:8080#110.0.0.1:8080#2
这样环上的点会更均匀,请求分布更平滑。
如果节点性能不同,还可以通过不同数量的虚拟节点表达权重。
3. 高可用调度不是“选出来就完了”
很多实现到“一致性哈希选中某节点”就结束了,但生产环境里还有几个必须补上的机制:
失败重试与后继节点回退
如果选中的实例超时或连接失败,可以按哈希环继续找后继节点。
但这里要控制边界:
- 不能无限重试
- 不能把单个慢请求变成多次风暴
- 对幂等和非幂等请求策略要分开
健康检查与熔断
实例还在注册中心里,不代表它真的健康。
所以客户端通常需要叠加本地判断:
- 连续失败阈值
- 半开恢复
- 临时摘除本地不健康节点
版本化实例视图
客户端更新节点列表时,不能边更新边读导致环结构不一致。
更稳妥的做法是:
- 用不可变快照构建新哈希环
- 原子替换旧环
- 请求始终读取某一个完整版本
这一点我很建议认真做,不然并发高时会出现偶发路由错误,特别难查。
方案对比与取舍分析
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮询/随机 | 简单、实现成本低 | 扩缩容抖动大,无法稳定命中缓存 | 纯无状态服务 |
| 最少连接 | 能感知短时负载 | 需要实时状态,客户端实现更复杂 | 长连接、连接数差异明显 |
| 普通哈希取模 | 实现简单 | 节点变更时大量重映射 | 节点集合长期稳定 |
| 一致性哈希 | 扩缩容迁移少,适合缓存粘性 | 需要处理虚拟节点、热点与健康状态 | 中大型分布式服务 |
| Rendezvous Hash | 无环结构、实现优雅 | 节点多时计算量上升 | 节点数中等、追求实现简洁 |
如果你问我在工程上怎么选:
- 节点数几十到几百,且需要稳定路由:优先一致性哈希
- 节点变化频繁,客户端实现要简洁:可以评估 Rendezvous Hash
- 完全无状态 API:别过度设计,普通负载均衡就够了
架构设计示意
下面是一种比较常见的落地架构:
sequenceDiagram
participant S as 服务实例
participant R as 注册中心
participant C as 客户端调度器
participant T as 目标实例
S->>R: 注册/续约
R-->>C: 推送实例变更
C->>C: 重建哈希环快照
C->>C: 基于业务Key选择节点
C->>T: 发起请求
alt 请求失败
C->>C: 标记失败次数+后继节点回退
C->>T: 重试或降级
else 请求成功
C->>C: 更新成功状态
end
实战代码(可运行)
下面我用 Python 写一个可运行示例,模拟:
- 服务发现返回实例列表
- 客户端基于一致性哈希构建环
- 按 key 选择目标实例
- 节点下线后观察 key 迁移比例
- 支持简单的失败回退
这个示例偏演示,但结构上已经接近真实工程。
1. 完整代码
import hashlib
import bisect
import threading
import random
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple
def md5_hash(value: str) -> int:
return int(hashlib.md5(value.encode("utf-8")).hexdigest(), 16)
@dataclass(frozen=True)
class ServiceNode:
node_id: str
host: str
port: int
weight: int = 100
healthy: bool = True
@property
def address(self) -> str:
return f"{self.host}:{self.port}"
class ConsistentHashRing:
def __init__(self, virtual_nodes_base: int = 50):
self.virtual_nodes_base = virtual_nodes_base
self._ring: List[int] = []
self._hash_to_node: Dict[int, ServiceNode] = {}
self._unique_nodes: Dict[str, ServiceNode] = {}
def build(self, nodes: List[ServiceNode]) -> None:
ring = []
hash_to_node = {}
unique_nodes = {}
for node in nodes:
if not node.healthy:
continue
unique_nodes[node.node_id] = node
replicas = max(1, self.virtual_nodes_base * node.weight // 100)
for i in range(replicas):
vnode_key = f"{node.node_id}#{i}"
h = md5_hash(vnode_key)
ring.append(h)
hash_to_node[h] = node
ring.sort()
self._ring = ring
self._hash_to_node = hash_to_node
self._unique_nodes = unique_nodes
def get_node(self, key: str, excluded_node_ids: Optional[set] = None) -> Optional[ServiceNode]:
if not self._ring:
return None
excluded_node_ids = excluded_node_ids or set()
key_hash = md5_hash(key)
idx = bisect.bisect(self._ring, key_hash)
for offset in range(len(self._ring)):
pos = (idx + offset) % len(self._ring)
node = self._hash_to_node[self._ring[pos]]
if node.node_id not in excluded_node_ids:
return node
return None
def all_nodes(self) -> List[ServiceNode]:
return list(self._unique_nodes.values())
class MockServiceRegistry:
def __init__(self):
self._nodes: Dict[str, ServiceNode] = {}
self._lock = threading.Lock()
def register(self, node: ServiceNode) -> None:
with self._lock:
self._nodes[node.node_id] = node
def unregister(self, node_id: str) -> None:
with self._lock:
self._nodes.pop(node_id, None)
def list_nodes(self) -> List[ServiceNode]:
with self._lock:
return list(self._nodes.values())
class TrafficScheduler:
def __init__(self, registry: MockServiceRegistry):
self.registry = registry
self._ring = ConsistentHashRing(virtual_nodes_base=100)
self._ring_lock = threading.Lock()
self._failure_count: Dict[str, int] = {}
self._local_unhealthy: set = set()
self.refresh()
def refresh(self) -> None:
nodes = self.registry.list_nodes()
new_ring = ConsistentHashRing(virtual_nodes_base=100)
new_ring.build(nodes)
with self._ring_lock:
self._ring = new_ring
def choose_node(self, business_key: str) -> Optional[ServiceNode]:
with self._ring_lock:
return self._ring.get_node(business_key, excluded_node_ids=self._local_unhealthy)
def call(self, business_key: str) -> Tuple[bool, str]:
tried = set()
for _ in range(3):
with self._ring_lock:
node = self._ring.get_node(business_key, excluded_node_ids=tried | self._local_unhealthy)
if not node:
return False, "没有可用节点"
tried.add(node.node_id)
success = self._mock_send_request(node, business_key)
if success:
self._failure_count[node.node_id] = 0
return True, f"请求 key={business_key} 命中节点 {node.address}"
else:
self._failure_count[node.node_id] = self._failure_count.get(node.node_id, 0) + 1
if self._failure_count[node.node_id] >= 2:
self._local_unhealthy.add(node.node_id)
return False, f"请求 key={business_key} 重试后仍失败"
def _mock_send_request(self, node: ServiceNode, business_key: str) -> bool:
# 模拟某个节点偶发不稳定
if node.node_id == "node-2":
return random.random() > 0.5
return True
def distribution_demo():
registry = MockServiceRegistry()
registry.register(ServiceNode("node-1", "10.0.0.1", 8080, weight=100))
registry.register(ServiceNode("node-2", "10.0.0.2", 8080, weight=100))
registry.register(ServiceNode("node-3", "10.0.0.3", 8080, weight=100))
scheduler = TrafficScheduler(registry)
keys = [f"user-{i}" for i in range(1000)]
before = {}
for k in keys:
node = scheduler.choose_node(k)
before[k] = node.node_id if node else None
registry.unregister("node-3")
scheduler.refresh()
after = {}
moved = 0
for k in keys:
node = scheduler.choose_node(k)
after[k] = node.node_id if node else None
if before[k] != after[k]:
moved += 1
print("=== 一致性哈希迁移演示 ===")
print(f"总 key 数: {len(keys)}")
print(f"迁移 key 数: {moved}")
print(f"迁移比例: {moved / len(keys):.2%}")
print("\n=== 调用演示 ===")
for key in ["tenant-A", "tenant-B", "tenant-C", "tenant-D"]:
ok, message = scheduler.call(key)
print(ok, message)
if __name__ == "__main__":
distribution_demo()
2. 运行方式
python3 scheduler_demo.py
你会看到两类输出:
- 节点下线后,只有部分 key 迁移,而不是全量重排
- 某个节点偶发失败时,会尝试回退到后继节点
3. 这段代码对应的工程要点
这份示例里有几个值得注意的实现点:
不可变重建,而不是原地修改
refresh() 时不是直接在原环上增删节点,而是:
- 拉取最新节点列表
- 构建新 ring
- 用锁原子替换
这比边改边用安全得多。
本地不健康列表
即使注册中心还没来得及摘除故障节点,客户端也可以根据短时失败做本地隔离。
这能缩短故障扩散窗口。
权重通过虚拟节点数量表达
replicas = virtual_nodes_base * weight // 100
这是一种简单但实用的工程写法。
容量估算与设计建议
做高可用调度时,除了算法本身,还要考虑容量。
1. 哈希环内存开销
假设:
- 200 个实例
- 每个实例 200 个虚拟节点
则总虚拟节点数约为:
200 * 200 = 40000
对于大多数客户端进程,这个规模是完全可接受的。
但如果:
- 实例数上千
- 每实例虚拟节点数也很大
- 客户端进程数量非常多
就要评估:
- ring 构建时间
- 本地内存占用
- 节点变更广播频率
2. 注册中心压力
不要让每个请求都访问注册中心。
理想模式应是:
- 启动全量拉取一次
- 之后只接收增量事件
- 本地保留最近成功快照
否则注册中心会被误用成“每次转发前的查询数据库”。
3. 失败重试放大效应
如果单请求超时 500ms,失败时重试 3 次,那么一个坏节点可能带来:
- 延迟飙升
- 下游额外压力
- 上游线程池堆积
所以建议:
- 请求超时要短
- 总重试次数要小
- 回退只在幂等请求使用
- 配合熔断和限流
常见坑与排查
这一部分我尽量写得接地气一些,都是比较典型、也真的容易踩的坑。
坑 1:节点列表一致,但路由结果不一致
现象
同一个业务 key,在不同客户端实例上,路由到的目标节点不一样。
常见原因
- 节点排序规则不一致
- 哈希函数实现不一致
- 节点唯一标识不稳定,比如有时用 IP,有时用 hostname
- 虚拟节点生成规则不同
- 客户端本地节点列表版本不一致
排查建议
- 打印同一时刻的节点快照
- 比较每个节点的 vnode 数量
- 校验哈希输入串是否完全一致
- 抽样比对几个 key 的 hash 值与命中节点
我建议直接输出这样的诊断日志:
def debug_route(ring, key: str):
node = ring.get_node(key)
print({
"key": key,
"key_hash": md5_hash(key),
"selected_node": node.node_id if node else None
})
坑 2:扩容后流量还是不均
现象
明明已经加了机器,但新节点没吃到多少流量,老节点还是很忙。
常见原因
- 虚拟节点数太少
- 权重设置未生效
- 业务 key 分布不均,天然有热点
- 只是“连接数均衡”,但不是“请求成本均衡”
排查建议
- 统计 key 分布是否长尾
- 看实例 CPU、QPS、P99,而不只看请求数
- 增加虚拟节点数后重新压测
- 对超级热点 key 做单独治理
坑 3:注册中心抖动导致频繁重建哈希环
现象
服务实例并没真正变化,但客户端不断收到变更事件,频繁刷新路由表。
风险
- CPU 空转
- 路由抖动
- 调度层日志风暴
- 高并发下偶发锁竞争
解决建议
- 对实例变更做去重
- 用版本号/修订号判断是否真的变化
- 做短时间 debounce 合并更新
- 重建动作放到单线程事件循环
坑 4:故障节点没有及时摘除,导致雪崩重试
现象
某实例半死不活,注册中心还显示健康,但大量请求持续命中它。
解决办法
客户端必须有自己的“最后一道防线”:
- 本地失败计数
- 临时摘除
- 半开探测恢复
- 快速失败,不要长时间阻塞
下面是一个简单状态流转图:
stateDiagram-v2
[*] --> Healthy
Healthy --> Suspect: 连续失败
Suspect --> Unhealthy: 达到阈值
Unhealthy --> HalfOpen: 冷却时间到
HalfOpen --> Healthy: 探测成功
HalfOpen --> Unhealthy: 再次失败
安全/性能最佳实践
高可用调度不仅是可用性问题,也会碰到安全和性能边界。
安全最佳实践
1. 不信任注册数据的全部内容
从注册中心拿到的实例信息,至少要校验:
- 节点 ID 格式
- IP/端口是否合法
- 元数据字段长度和内容
- 权重取值范围
否则异常数据可能导致:
- 哈希环构建失败
- 内存膨胀
- 路由到非法地址
2. 服务发现链路要鉴权
如果注册中心支持 ACL、Token、mTLS,一定要开。
因为一旦有人能伪造注册信息,流量就可能被引到错误节点。
3. 避免把敏感业务键直接作为日志输出
例如把手机号、用户真实 ID、订单号原文打印在调度日志里。
更好的做法是:
- 记录脱敏 key
- 或记录业务 key 的哈希摘要
性能最佳实践
1. 哈希函数选型要平衡稳定性与开销
演示代码用 MD5 是为了方便和稳定。
在真实业务中可以考虑:
- MurmurHash
- xxHash
- FNV
前提是:所有语言、所有客户端实现要一致。
2. 读多写少场景优先快照 + 无锁读
如果节点更新不频繁,可以把 ring 做成不可变对象,通过原子引用切换。
这样请求路径几乎无需重锁。
3. 虚拟节点数量不要盲目拉满
虚拟节点太少会不均,太多会增加:
- 构建开销
- 排序开销
- 内存占用
工程上通常先从每节点 100~300 个虚拟节点试起,再用压测校准。
4. 区分“不可用”和“慢”
- 不可用:快速摘除
- 慢:可能需要降权,而不是立刻摘除
如果你把所有慢节点都当宕机处理,可能会让剩余节点过载,反而更差。
一套更稳妥的落地建议
如果你准备在线上系统中实践,我建议按下面的优先级推进:
第一步:先把实例视图稳定下来
- 客户端订阅注册中心
- 本地缓存实例快照
- 更新采用版本化替换
- 注册中心故障时允许使用旧快照短暂续命
第二步:用一致性哈希替代随机分发
- 选定稳定业务 key,例如
tenant_id、user_id、session_id - 引入虚拟节点
- 先观察扩缩容时迁移比例与缓存命中率变化
第三步:补上故障回退能力
- 本地失败计数
- 临时摘除
- 有边界的后继节点重试
- 幂等请求与非幂等请求区别处理
第四步:增加可观测性
至少打通这些指标:
- 哈希环版本号
- 当前实例数/虚拟节点数
- key 命中分布
- 节点失败率
- 本地摘除节点数
- 后继回退次数
- 请求重试率
没有观测,很多问题只能靠猜。
总结
把服务发现和一致性哈希结合起来,本质上是在解决两个核心矛盾:
- 服务实例会变,但路由希望尽量稳定
- 单个节点会故障,但整体流量调度不能失控
一个实用的高可用流量调度方案,通常至少要包含:
- 注册中心维护健康实例集合
- 客户端订阅并缓存实例快照
- 一致性哈希做稳定路由
- 虚拟节点提升均衡性
- 本地健康检查与临时摘除
- 后继节点回退与有边界重试
- 版本化哈希环与可观测性
如果你正在落地这类方案,我的建议很明确:
- 先把“稳定实例视图”做对,再谈复杂调度
- 先解决扩缩容抖动,再优化热点和权重
- 不要迷信注册中心健康状态,客户端一定要有本地容错
- 把日志和指标补齐,否则线上问题会非常难定位
最后补一句经验话:
一致性哈希本身不难,难的是把它放进一个会抖、会变、会故障的真实分布式环境里,还能保持稳定。这也是架构设计最有意思的地方。