跳转到内容
123xiao | 无名键客

《分布式架构中基于一致性哈希与服务发现的高可用流量调度实战》

字数: 0 阅读时长: 1 分钟

背景与问题

在分布式系统里,“把请求发到哪台机器”看起来只是一个路由问题,实际上常常决定了系统的稳定性上限。

很多团队在服务规模还小时,会直接用以下几种方式做流量分发:

  • Nginx 轮询
  • 随机挑一台实例
  • 按权重做负载均衡
  • 客户端拿到实例列表后随便选

这些办法在实例数量稳定、请求无状态时通常够用。但一旦场景变成下面这样,问题就开始集中爆发:

  • 服务实例会频繁扩缩容
  • 某些请求希望尽量命中同一实例的本地缓存
  • 某些租户、用户、会话需要“相对固定”地落到同一批节点
  • 服务注册中心会有短暂抖动
  • 部分节点性能退化,但还没完全宕机
  • 调度层需要高可用,不能因为一个中心节点挂了就失效

我第一次在生产环境遇到这个问题时,最明显的症状是:一扩容,缓存命中率直接掉下去;一缩容,热点节点瞬间被打穿。
根因并不复杂:流量调度策略不稳定,实例集合一变化,请求映射关系就大面积重排。

这类问题通常要同时解决两个维度:

  1. 服务发现:我如何知道当前有哪些健康实例可用?
  2. 流量调度:我如何把请求稳定且均衡地映射到这些实例?

本文就围绕这个组合拳展开:用服务发现维护可用实例集合,用一致性哈希做稳定分配,再补上高可用与排障能力。


方案目标与适用边界

先说结论,这套方案特别适合以下场景:

  • 网关到下游服务的客户端路由
  • 多租户请求按租户 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#0
  • 10.0.0.1:8080#1
  • 10.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 写一个可运行示例,模拟:

  1. 服务发现返回实例列表
  2. 客户端基于一致性哈希构建环
  3. 按 key 选择目标实例
  4. 节点下线后观察 key 迁移比例
  5. 支持简单的失败回退

这个示例偏演示,但结构上已经接近真实工程。

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() 时不是直接在原环上增删节点,而是:

  1. 拉取最新节点列表
  2. 构建新 ring
  3. 用锁原子替换

这比边改边用安全得多。

本地不健康列表

即使注册中心还没来得及摘除故障节点,客户端也可以根据短时失败做本地隔离。
这能缩短故障扩散窗口。

权重通过虚拟节点数量表达

replicas = virtual_nodes_base * weight // 100
这是一种简单但实用的工程写法。


容量估算与设计建议

做高可用调度时,除了算法本身,还要考虑容量。

1. 哈希环内存开销

假设:

  • 200 个实例
  • 每个实例 200 个虚拟节点

则总虚拟节点数约为:

200 * 200 = 40000

对于大多数客户端进程,这个规模是完全可接受的。
但如果:

  • 实例数上千
  • 每实例虚拟节点数也很大
  • 客户端进程数量非常多

就要评估:

  • ring 构建时间
  • 本地内存占用
  • 节点变更广播频率

2. 注册中心压力

不要让每个请求都访问注册中心。
理想模式应是:

  • 启动全量拉取一次
  • 之后只接收增量事件
  • 本地保留最近成功快照

否则注册中心会被误用成“每次转发前的查询数据库”。

3. 失败重试放大效应

如果单请求超时 500ms,失败时重试 3 次,那么一个坏节点可能带来:

  • 延迟飙升
  • 下游额外压力
  • 上游线程池堆积

所以建议:

  • 请求超时要短
  • 总重试次数要小
  • 回退只在幂等请求使用
  • 配合熔断和限流

常见坑与排查

这一部分我尽量写得接地气一些,都是比较典型、也真的容易踩的坑。

坑 1:节点列表一致,但路由结果不一致

现象

同一个业务 key,在不同客户端实例上,路由到的目标节点不一样。

常见原因

  • 节点排序规则不一致
  • 哈希函数实现不一致
  • 节点唯一标识不稳定,比如有时用 IP,有时用 hostname
  • 虚拟节点生成规则不同
  • 客户端本地节点列表版本不一致

排查建议

  1. 打印同一时刻的节点快照
  2. 比较每个节点的 vnode 数量
  3. 校验哈希输入串是否完全一致
  4. 抽样比对几个 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_iduser_idsession_id
  • 引入虚拟节点
  • 先观察扩缩容时迁移比例与缓存命中率变化

第三步:补上故障回退能力

  • 本地失败计数
  • 临时摘除
  • 有边界的后继节点重试
  • 幂等请求与非幂等请求区别处理

第四步:增加可观测性

至少打通这些指标:

  • 哈希环版本号
  • 当前实例数/虚拟节点数
  • key 命中分布
  • 节点失败率
  • 本地摘除节点数
  • 后继回退次数
  • 请求重试率

没有观测,很多问题只能靠猜。


总结

服务发现一致性哈希结合起来,本质上是在解决两个核心矛盾:

  • 服务实例会变,但路由希望尽量稳定
  • 单个节点会故障,但整体流量调度不能失控

一个实用的高可用流量调度方案,通常至少要包含:

  • 注册中心维护健康实例集合
  • 客户端订阅并缓存实例快照
  • 一致性哈希做稳定路由
  • 虚拟节点提升均衡性
  • 本地健康检查与临时摘除
  • 后继节点回退与有边界重试
  • 版本化哈希环与可观测性

如果你正在落地这类方案,我的建议很明确:

  1. 先把“稳定实例视图”做对,再谈复杂调度
  2. 先解决扩缩容抖动,再优化热点和权重
  3. 不要迷信注册中心健康状态,客户端一定要有本地容错
  4. 把日志和指标补齐,否则线上问题会非常难定位

最后补一句经验话:
一致性哈希本身不难,难的是把它放进一个会抖、会变、会故障的真实分布式环境里,还能保持稳定。这也是架构设计最有意思的地方。


分享到:

上一篇
《从源码到实践:基于 Kubernetes 开源项目构建可观测的微服务部署与故障排查方案》
下一篇
《中级实战:用 RAG 构建企业知识库问答系统的架构设计与性能优化》