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

《分布式架构中基于一致性哈希与服务发现的微服务流量路由实战》

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

背景与问题

在微服务系统里,流量路由看起来像个“基础能力”,但真正落地时,往往会牵一发而动全身。

很多团队一开始会用最简单的几种方式:

  • Nginx 轮询
  • 客户端随机选实例
  • 按权重做负载均衡
  • 网关层统一转发

这些方式在“实例数稳定、请求无状态”的时候没什么问题。但一旦业务里出现下面几类诉求,问题就开始冒出来了:

  1. 会话粘性需求
    比如用户请求希望尽量打到同一批实例,减少缓存失效。

  2. 本地缓存命中率要求高
    某些服务在内存里做热点数据缓存,如果同一类请求总是被打散,本地缓存基本形同虚设。

  3. 节点频繁扩缩容
    K8s 环境中 Pod 上下线很常见,如果每次扩容都导致大量 key 被重新分配,缓存雪崩和抖动会非常明显。

  4. 多机房/多分区路由控制
    希望在“优先同机房,再降级跨机房”的前提下做稳定路由。

这时,一致性哈希就很适合登场了。
但只讲一致性哈希还不够,真实系统中的实例列表不是写死的,而是来自 服务发现。因此,真正可用的方案其实是:

服务发现动态提供实例集合,一致性哈希在这个实例集合上做稳定映射。

这篇文章我会从架构设计和可运行代码两方面,带你把这件事走通。


方案概览与取舍分析

先给一个整体视角:我们要解决的不是“怎么挑一个实例”,而是“怎么在动态实例集合上,尽量稳定地挑到同一个实例”。

一个典型调用链

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

在工程上,路由器通常不会每次请求都实时拉注册中心,而是:

  1. 先从本地缓存拿实例列表
  2. 由后台 watcher/watch stream 持续更新
  3. 用最新实例列表重建哈希环

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: 若落点受影响则迁移,否则仍命中原实例

推荐的模块拆分

在代码层面,建议至少拆成三层:

  1. DiscoveryProvider
    负责返回健康实例列表

  2. ConsistentHashRouter
    负责构建哈希环和选实例

  3. 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, "实例下线后路由")

运行效果你应该关注什么

不是只看“能不能选到实例”,而是看下面两个指标:

  1. 分布是否大致均匀
  2. 扩缩容后是否只有部分 key 迁移

这才是一致性哈希真正的价值。


进一步落地:接真实服务发现时怎么做

上面是内存版模拟。到了生产环境,常见做法一般是这样:

方案 A:客户端直连注册中心

  • 每个调用方都订阅目标服务实例列表
  • 本地维护哈希环
  • 直接路由到目标实例

优点:

  • 路由灵活
  • 少一跳网络开销
  • 可按业务自定义路由 key

缺点:

  • 每个客户端都要实现服务发现和缓存更新
  • SDK 复杂度会提高

方案 B:在网关或 Sidecar 上统一实现

  • 业务代码不关心实例选择
  • 网关/Sidecar 维护服务发现与哈希环
  • 统一做路由、重试、熔断、观测

优点:

  • 逻辑收敛,治理方便
  • 便于统一升级和审计

缺点:

  • 某些业务细粒度路由诉求不容易下沉
  • 可能增加中间层复杂度

我的经验是:

  • 业务路由 key 很强、差异化明显:更适合客户端实现
  • 团队更看重统一治理和可控性:更适合网关或 Sidecar 实现

容量估算与参数建议

一致性哈希不是“写完就行”,参数会直接影响效果。

1. 虚拟节点数怎么选

通常建议从以下范围开始试:

  • 小规模集群:50 ~ 100
  • 中等规模集群:100 ~ 300
  • 对均衡性要求高:300+

但虚拟节点不是越多越好,因为它会带来:

  • 更大的内存占用
  • 更慢的环构建速度
  • 更高的更新时间成本

经验建议:

  • 先用 100200
  • 用真实流量 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 中读取 userIdtenantId
  • 不要直接使用用户随便传入的 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 迁移比例
  • 调用失败率 / 重试率
  • 注册中心同步延迟

这些指标在问题发生时非常关键。很多时候不是算法错了,而是数据面和控制面不同步。


一套更稳妥的生产落地建议

如果你准备在线上系统使用,我建议按下面的顺序推进:

  1. 先明确路由目标

    • 是为了会话粘性?
    • 还是为了缓存命中?
    • 还是为了租户级稳定分配?
  2. 选对路由 key

    • 优先选稳定、业务有意义的主键
    • 不要选每次变化的随机值
  3. 接入服务发现快照

    • 优先基于健康实例构建哈希环
    • 统一实例排序和签名方式
  4. 设置合理虚拟节点数

    • 先从 100/200 起步
    • 用真实 key 样本校验均衡性
  5. 补齐容错机制

    • 下线延迟容忍
    • 重试保持粘性
    • 连接池失效清理
    • 熔断与降级
  6. 观察迁移比例

    • 每次扩缩容后,统计 key 迁移率
    • 如果迁移异常高,优先检查实例列表一致性和实现细节

总结

把一致性哈希和服务发现放在一起看,事情就清楚了:

  • 服务发现解决“当前有哪些可用实例”
  • 一致性哈希解决“同一个业务 key 应该稳定打到哪台实例”

这套方案特别适合下面几类场景:

  • 需要用户/租户粘性路由
  • 想提高本地缓存命中率
  • 实例会频繁扩缩容
  • 希望减少节点变更带来的流量抖动

但它也不是银弹。边界条件要记住:

  • 如果业务完全无状态,轮询/随机可能更简单
  • 如果 key 本身分布极端不均,单靠一致性哈希无法消除热点
  • 如果实例列表在各客户端不一致,再好的哈希算法也会失效

如果你准备真正落地,我的可执行建议是:

  1. 先选一个最能代表业务稳定性的 route key
  2. 用真实流量样本验证分布和迁移率
  3. 把实例列表一致性、重试粘性、观测指标一起补上
  4. 再逐步扩大到核心链路

很多架构方案的问题不在“原理不对”,而在“工程细节没收住”。
一致性哈希 + 服务发现就是一个典型例子:原理不复杂,但做扎实了,收益非常实在。


分享到:

上一篇
《Web3 中级实战:从零搭建基于钱包登录与链上签名验证的去中心化身份认证系统》
下一篇
《Java 开发踩坑实录:排查 ThreadLocal 内存泄漏与线程池复用导致数据串脏的实战指南》