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

《集群架构中的服务发现与负载均衡实战:从节点注册、健康检查到流量切换设计》

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

背景与问题

在单机时代,服务地址通常写死在配置里:db.prod.local:3306api.internal:8080。这种方式简单直接,但一旦进入集群架构,问题会立刻冒出来:

  • 实例数量动态变化,IP 不再稳定
  • 节点可能随时故障,客户端不能继续打到坏节点
  • 新版本发布时,需要平滑切换流量,而不是“全量一刀切”
  • 多机房、多可用区下,流量最好优先走近端,减少延迟和跨区成本

所以,集群中的“访问一个服务”实际上分成了三件事:

  1. 服务发现:我怎么知道现在有哪些节点可用?
  2. 健康检查:我怎么确认这些节点真的还能接请求?
  3. 负载均衡:我应该把请求分配给哪个节点?

这三个环节是连在一起的。很多线上故障并不是某一个组件挂了,而是“注册还在、健康已坏、流量还在打”,最后把局部故障放大成全局故障。我自己做过几次线上切换后,最大的感受是:服务发现不是一个表,负载均衡也不是一个算法,它们本质上是一套状态传播和流量控制系统。

本文从架构视角,把这套链路串起来,并给一个可运行的实战代码示例。


先建立整体认知:请求是怎么走的

下面先看一个典型链路:

flowchart LR
    A[服务实例启动] --> B[向注册中心注册]
    B --> C[注册中心维护实例列表]
    D[客户端/网关] --> E[从注册中心拉取或订阅服务列表]
    E --> F[本地缓存可用节点]
    F --> G[负载均衡选一个节点]
    G --> H[发起请求]
    I[健康检查器] --> C
    C --> E
    J[节点故障/摘除] --> C
    K[新节点上线] --> C

这个过程里最容易被忽略的点有两个:

  • 注册信息不是实时真相,它只是“最近一次已知状态”
  • 健康状态必须参与流量决策,否则负载均衡只是“均匀地打错目标”

核心原理

1. 节点注册:谁来告诉系统“我还活着”

服务实例启动后,通常会把自己的信息注册到注册中心,典型字段包括:

  • 服务名:order-service
  • 实例 ID:order-10.0.1.23-8080
  • 地址:10.0.1.23:8080
  • 元数据:版本、机房、权重、协议
  • TTL 或心跳时间

注册方式常见有两类:

客户端注册

由服务实例自己在启动时向注册中心上报。

优点:

  • 逻辑直接
  • 注册信息完整,业务元数据更容易带上

缺点:

  • 业务 SDK 耦合注册中心
  • 应用异常时可能还来得及“活着注册,死前没注销”

平台侧注册

例如 K8s 中由控制平面、Sidecar 或代理感知实例状态并注册。

优点:

  • 应用无感知
  • 统一治理能力更强

缺点:

  • 与平台绑定更紧
  • 业务元数据透传有时不够灵活

注册不等于可用

一个节点注册成功,只能说明“它曾经上线过”,不能说明“它现在能处理请求”。这也是为什么服务发现系统一定要和健康检查联动。


2. 健康检查:活着,不等于能服务

健康检查常见分三层:

存活检查(Liveness)

判断进程是否还活着。
例如:进程没死、端口还能连上。

适合回答的问题:这个实例是不是已经彻底挂了?

就绪检查(Readiness)

判断实例是否已经准备好接收流量。
例如:应用启动完成、缓存预热结束、数据库连接正常。

适合回答的问题:这个实例现在能不能接流量?

深度健康检查(Dependency Health)

检查关键依赖是否正常。
例如:数据库超时、消息队列堆积、线程池耗尽。

适合回答的问题:这个实例虽然还活着,但继续给它流量会不会出事?

很多团队一开始只做“HTTP 200 就算健康”,上线后才发现根本不够。比如:

  • Web 线程还能返回 /health,但业务线程池已经满了
  • 进程没死,但数据库连接池已经耗尽
  • 节点 GC 抖动严重,请求 RT 已经失控

所以,健康检查的设计目标不是证明“我没死”,而是证明“我值得继续接流量”。


3. 服务发现:推模式、拉模式与本地缓存

客户端获取服务列表,通常有两种方式:

拉模式

客户端定期从注册中心拉取最新节点列表。

优点:

  • 实现简单
  • 对注册中心压力可控

缺点:

  • 状态传播有延迟
  • 故障节点可能在一段时间内仍被访问

推模式

注册中心在节点变化时主动通知客户端。

优点:

  • 变更更快传播
  • 故障摘除更及时

缺点:

  • 客户端连接管理复杂
  • 大规模场景下推送风暴要仔细设计

本地缓存是必须的

不管推还是拉,客户端几乎都会保留一份本地缓存。否则一旦注册中心抖动,业务请求也会跟着抖。

但缓存也引出一个经典问题:缓存过期和状态漂移
所以常见做法是:

  • 注册中心提供变更版本号
  • 客户端只增量更新
  • 节点失败后做本地熔断/临时摘除
  • 缓存失效时保留“最后一份可用列表”,避免直接雪崩

4. 负载均衡:不是平均分配,而是按目标分配

常见算法:

轮询(Round Robin)

按顺序依次选节点。

适合:

  • 节点能力相近
  • 请求耗时比较均匀

加权轮询(Weighted Round Robin)

按节点权重分流,适合不同规格实例混部。

最少连接(Least Connections)

优先选择当前连接数少的节点。

适合:

  • 长连接场景
  • 请求执行时间差异较大

一致性哈希(Consistent Hashing)

同一个 key 尽量路由到同一节点。

适合:

  • 会话保持
  • 缓存命中优化

负载均衡一定要结合健康状态

一个最常见的误区是:
“我已经有轮询算法了,所以流量很均匀。”
但如果实例列表里混入了半死不活的节点,轮询只会把错误均匀扩散。

因此生产环境里常见的选择顺序其实是:

  1. 先过滤不健康节点
  2. 再按机房/版本/标签过滤
  3. 最后再执行负载均衡算法

方案对比与取舍分析

方案一:客户端负载均衡

由调用方自己拿服务列表,并在本地选目标节点。

优点

  • 少一跳,性能好
  • 客户端可按自身需求做路由策略
  • 容易实现同机房优先、灰度优先

缺点

  • 各语言 SDK 都要维护
  • 服务治理能力分散
  • 客户端版本不统一时,策略难收敛

适用场景

  • 内部微服务调用
  • 团队有较强基础设施能力
  • 多语言生态可控

方案二:服务端负载均衡

客户端统一打到代理层,例如 Nginx、Envoy、LVS、网关。

优点

  • 治理策略集中
  • 客户端简单
  • 统一接入认证、限流、熔断、观测

缺点

  • 多一跳
  • 代理层容量和高可用要求更高
  • 某些业务级路由上下文不易透传

适用场景

  • 北南向流量
  • 对接外部调用方
  • 希望集中治理

实际落地建议

大多数中大型系统,最终会是混合模式

  • 内部服务调用:客户端服务发现 + 本地负载均衡
  • 外部入口流量:网关/代理统一负载均衡
  • 跨地域或多活调度:DNS、全局流量调度、地域权重控制

一个实战架构:从注册到流量切换

这里给一个比较稳妥、也容易落地的设计:

  • 服务实例启动后注册到注册中心
  • 注册中心维护实例 TTL 和元数据
  • 客户端每 5 秒拉取节点列表,并本地缓存
  • 客户端对请求失败的节点做临时摘除
  • 负载均衡采用“健康优先 + 加权随机”
  • 发布时通过权重逐步放量,做到平滑切换

下面这张时序图会更直观:

sequenceDiagram
    participant S as 服务实例
    participant R as 注册中心
    participant C as 客户端
    participant LB as 本地负载均衡器

    S->>R: 注册实例(地址、版本、权重、TTL)
    S->>R: 定时心跳续租
    C->>R: 拉取实例列表
    R-->>C: 返回健康实例列表
    C->>LB: 更新本地缓存
    C->>LB: 发起一次请求
    LB-->>C: 选择一个节点
    C->>S: 请求服务
    alt 请求失败
        C->>LB: 标记节点短暂失效
    else 请求成功
        C->>LB: 更新成功统计
    end

实战代码(可运行)

下面我用 Python 写一个简化版示例,模拟:

  • 两个服务节点启动并注册
  • 注册中心维护实例与心跳过期
  • 客户端周期性拉取服务列表
  • 本地负载均衡根据健康状态和权重选节点
  • 节点失败后自动流量切换

代码可以直接运行,便于理解整条链路。

import time
import random
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import urlopen
from urllib.error import URLError
import json


class Registry:
    def __init__(self):
        self.instances = {}
        self.lock = threading.Lock()

    def register(self, service_name, instance_id, host, port, weight=1, ttl=10):
        with self.lock:
            self.instances[instance_id] = {
                "service_name": service_name,
                "instance_id": instance_id,
                "host": host,
                "port": port,
                "weight": weight,
                "ttl": ttl,
                "last_heartbeat": time.time(),
                "status": "UP"
            }

    def heartbeat(self, instance_id):
        with self.lock:
            if instance_id in self.instances:
                self.instances[instance_id]["last_heartbeat"] = time.time()
                self.instances[instance_id]["status"] = "UP"

    def mark_down_expired(self):
        while True:
            now = time.time()
            with self.lock:
                for instance in self.instances.values():
                    if now - instance["last_heartbeat"] > instance["ttl"]:
                        instance["status"] = "DOWN"
            time.sleep(1)

    def discover(self, service_name):
        with self.lock:
            return [
                v.copy()
                for v in self.instances.values()
                if v["service_name"] == service_name and v["status"] == "UP"
            ]


class ServiceHandler(BaseHTTPRequestHandler):
    instance_name = "unknown"
    fail_ratio = 0.0

    def do_GET(self):
        if self.path == "/health":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"OK")
            return

        if self.path == "/work":
            if random.random() < self.fail_ratio:
                self.send_response(500)
                self.end_headers()
                self.wfile.write(f"{self.instance_name} failed".encode())
            else:
                self.send_response(200)
                self.end_headers()
                self.wfile.write(f"response from {self.instance_name}".encode())
            return

        self.send_response(404)
        self.end_headers()


def run_service(port, instance_name, fail_ratio):
    class CustomHandler(ServiceHandler):
        pass

    CustomHandler.instance_name = instance_name
    CustomHandler.fail_ratio = fail_ratio

    server = HTTPServer(("127.0.0.1", port), CustomHandler)
    print(f"{instance_name} started at {port}")
    server.serve_forever()


class DiscoveryClient:
    def __init__(self, registry, service_name):
        self.registry = registry
        self.service_name = service_name
        self.local_cache = []
        self.temp_down = {}
        self.lock = threading.Lock()

    def refresh(self):
        while True:
            instances = self.registry.discover(self.service_name)
            with self.lock:
                now = time.time()
                self.local_cache = [
                    i for i in instances
                    if i["instance_id"] not in self.temp_down or self.temp_down[i["instance_id"]] < now
                ]
            time.sleep(5)

    def mark_temp_down(self, instance_id, cooldown=5):
        with self.lock:
            self.temp_down[instance_id] = time.time() + cooldown

    def choose_instance(self):
        with self.lock:
            if not self.local_cache:
                return None
            weighted = []
            for inst in self.local_cache:
                weighted.extend([inst] * inst["weight"])
            return random.choice(weighted)


def heartbeat_loop(registry, instance_id, stop_event):
    while not stop_event.is_set():
        registry.heartbeat(instance_id)
        time.sleep(2)


def invoke(client):
    inst = client.choose_instance()
    if not inst:
        print("no available instance")
        return

    url = f"http://{inst['host']}:{inst['port']}/work"
    try:
        with urlopen(url, timeout=1) as resp:
            body = resp.read().decode()
            print(f"[OK] hit {inst['instance_id']} -> {body}")
    except Exception as e:
        print(f"[FAIL] hit {inst['instance_id']} -> {e}")
        client.mark_temp_down(inst["instance_id"])


def main():
    registry = Registry()

    # 启动服务实例
    threading.Thread(target=run_service, args=(8001, "node-1", 0.0), daemon=True).start()
    threading.Thread(target=run_service, args=(8002, "node-2", 0.5), daemon=True).start()

    # 注册到注册中心
    registry.register("demo-service", "node-1", "127.0.0.1", 8001, weight=3, ttl=6)
    registry.register("demo-service", "node-2", "127.0.0.1", 8002, weight=1, ttl=6)

    # 注册中心后台清理过期实例
    threading.Thread(target=registry.mark_down_expired, daemon=True).start()

    # 心跳线程
    stop_event_1 = threading.Event()
    stop_event_2 = threading.Event()
    threading.Thread(target=heartbeat_loop, args=(registry, "node-1", stop_event_1), daemon=True).start()
    threading.Thread(target=heartbeat_loop, args=(registry, "node-2", stop_event_2), daemon=True).start()

    # 客户端服务发现
    client = DiscoveryClient(registry, "demo-service")
    threading.Thread(target=client.refresh, daemon=True).start()

    # 模拟请求
    for i in range(20):
        if i == 10:
            print("\n--- simulate node-2 heartbeat lost ---\n")
            stop_event_2.set()

        invoke(client)
        time.sleep(1)


if __name__ == "__main__":
    main()

运行后你会看到什么

这个示例里:

  • node-1 权重更高,正常情况下会接到更多流量
  • node-2 有 50% 概率返回失败,客户端会把它临时摘除
  • 第 10 次请求后,node-2 停止发送心跳,TTL 过期后注册中心会将其标记为 DOWN
  • 客户端下一轮刷新后,就不会再选中它

这其实就是一条最小可用链路:

注册 -> 续约 -> 发现 -> 负载均衡 -> 失败摘除 -> 流量切换


流量切换设计:别只想着“切过去”,要想着“怎么退回来”

服务上线、灰度、迁移机房时,很多问题出在流量切换策略太粗暴。比较稳妥的做法是:

1. 新节点先注册,但不立即接全量流量

可以通过元数据控制:

  • weight=0:先注册但不接流量
  • 预热完成后逐步加权:1 -> 5 -> 20 -> 100

2. 把“注册成功”和“可接流量”分开

一个实例启动后可能需要:

  • JVM 预热
  • 连接池建立
  • 缓存加载
  • 路由规则同步

所以不要“进程起来就立刻接流量”。
我更推荐把状态拆成:

  • STARTING
  • READY
  • DRAINING
  • DOWN

如下图:

stateDiagram-v2
    [*] --> STARTING
    STARTING --> READY: 预热完成/就绪检查通过
    READY --> DRAINING: 发布下线/人工摘流
    DRAINING --> DOWN: 连接耗尽/超时退出
    READY --> DOWN: 健康检查失败
    DOWN --> STARTING: 实例重启

3. 下线时要先摘流,再停机

正确顺序一般是:

  1. 节点进入 DRAINING
  2. 注册中心或负载均衡层停止分配新请求
  3. 等待存量连接/请求执行完成
  4. 再真正停进程

如果反过来先停机,客户端就只能通过超时和重试来“感知下线”,这会直接放大延迟。

4. 重试要有限制,否则切换会变成风暴

请求失败后重试是合理的,但要注意:

  • 只对幂等请求重试
  • 限制最大重试次数
  • 避免重试到同一实例
  • 给重试加退避和抖动
  • 配合超时设置,否则整体 RT 会被拉长

容量估算:别让注册中心变成隐形单点

很多团队把业务服务做成集群了,却忘了注册中心、网关、配置中心本身也是基础设施服务,也会成为瓶颈。

一个简单估算思路

假设:

  • 2000 个实例
  • 每个实例每 5 秒发送一次心跳
  • 每个客户端每 10 秒拉取一次服务列表
  • 500 个客户端进程

那么注册中心每秒大致要处理:

心跳写入

2000 / 5 = 400 QPS

服务发现读取

500 / 10 = 50 QPS

如果再考虑:

  • 多个服务
  • 多个环境
  • 推送通知
  • 健康检查写状态
  • 控制台查询
  • 灰度元数据变更

实际压力会更高。
所以注册中心至少要具备:

  • 多副本高可用
  • 数据一致性策略
  • 限流与隔离
  • 本地缓存兜底
  • 变更风暴保护

边界条件也要讲清楚:
如果你的系统规模还很小,十几个服务实例,用一层 Nginx + 静态配置都未必不行。不要为了“像微服务”而过度设计。


常见坑与排查

下面这些坑我基本都见过,排查时非常有共性。

坑 1:节点明明挂了,但流量还在打

常见原因

  • TTL 过长,摘除太慢
  • 客户端缓存刷新周期过长
  • 只看注册状态,不看探活结果
  • 长连接池里还保留着旧连接

排查方法

  • 看注册中心中的实例最后心跳时间
  • 看客户端本地实例列表是否已更新
  • 看是否存在连接复用导致的“假摘除”
  • 抓请求日志,确认失败请求集中在哪些节点

坑 2:健康检查通过,但业务请求大量超时

常见原因

  • /health 只检查进程,不检查关键依赖
  • 健康检查接口太轻量,无法反映真实负载
  • 检查周期过长,状态滞后

排查方法

  • 对比健康检查 RT 与真实业务 RT
  • 查看线程池、连接池、队列积压
  • 检查数据库、Redis、MQ 等依赖状态
  • 看是否发生 Full GC 或 CPU 抢占

坑 3:重试机制把故障放大

现象

一个节点超时,客户端全都在重试,结果把剩余健康节点也打满。

排查方法

  • 看请求链路中的总超时是否合理
  • 看重试次数是否过多
  • 看是否存在“代理重试 + SDK 重试 + 业务重试”叠加
  • 看重试是否带随机退避

我当时踩过一个坑:网关重试 2 次,SDK 重试 2 次,业务方又自己循环调了 3 次。理论上一条失败请求,最后变成了十几次下游访问,系统直接雪崩。


坑 4:发布时抖动严重

常见原因

  • 新节点未预热就接流量
  • 一次性全量切换
  • 下线节点未做连接排空
  • 客户端缓存更新不一致

排查方法

  • 查看实例状态转换日志
  • 查看新节点启动后前几分钟 RT、错误率
  • 检查是否有 drain 流程
  • 对比不同调用方拿到的实例版本号

安全/性能最佳实践

安全方面

1. 注册接口要鉴权

不要让任意进程都能往注册中心写实例。否则轻则污染服务列表,重则形成流量劫持。

建议:

  • 实例注册使用双向 TLS 或签名认证
  • 限制服务名与命名空间
  • 审计注册、摘除、权重变更操作

2. 防止伪造健康状态

如果健康上报来自客户端自身,要防止实例“假装健康”。

建议:

  • 平台侧探活和实例侧心跳结合
  • 对关键依赖做独立观测
  • 不把单一信号当成绝对真相

3. 灰度元数据变更要可审计

版本、权重、标签一旦改错,影响的是流量分配。
所以必须保留:

  • 谁改的
  • 何时改的
  • 改了什么
  • 是否支持一键回滚

性能方面

1. 客户端优先使用本地缓存

不要每次请求都查注册中心。
服务发现是控制面数据,不应出现在数据面高频路径上。

2. 健康检查要轻重分离

建议拆成:

  • 高频轻量探活:端口、基础接口
  • 低频深度检查:依赖、资源、慢查询

否则健康检查本身会成为负担。

3. 做好失败摘除与恢复探测

节点失败后临时摘除能减少错误流量,但也不能永久拉黑。
应该设计:

  • 失败阈值
  • 冷却时间
  • 半开探测
  • 成功后恢复

4. 发布时渐进式放量

推荐方式:

  • 先 1%
  • 观察错误率、RT、资源使用
  • 再逐步提升到 5%、20%、50%、100%

不要一上来把新节点权重打满。


一份落地检查清单

如果你正在设计或改造服务发现与负载均衡链路,我建议至少确认下面这些点:

  • 是否区分了注册、存活、就绪三种状态
  • 客户端是否有本地缓存和兜底策略
  • 节点失败后是否支持本地临时摘除
  • 下线流程是否支持 drain
  • 重试、超时、熔断是否成体系设计
  • 负载均衡是否支持权重、标签、机房优先
  • 注册中心本身是否高可用
  • 权重和路由变更是否可审计、可回滚
  • 是否有观测指标:实例数、摘除数、错误率、切换耗时、缓存版本

总结

服务发现、健康检查、负载均衡,表面上是三个模块,实际上是一条完整的流量控制链路。

真正稳定的集群架构,核心不在于“能找到节点”,而在于:

  • 能尽快识别坏节点
  • 能把坏节点及时移出流量
  • 能让新节点平滑接入
  • 能在状态传播有延迟时保持系统韧性

如果你要从今天开始动手优化,我建议按这个优先级推进:

  1. 先补齐就绪检查下线摘流
  2. 再补客户端本地缓存失败临时摘除
  3. 然后引入权重流量切换灰度放量
  4. 最后再优化多机房、全局调度、一致性哈希等高级能力

边界条件也别忘了:
如果规模不大,简单方案往往更可靠;如果规模已经上来,就不要再用“静态地址 + 人工切换”硬扛了。因为到了那一步,问题已经不只是麻烦,而是故障迟早会发生。

把这条链路设计对,很多看似复杂的线上流量问题,其实都会变得可预期、可观测、可回滚。


分享到:

上一篇
《AI 智能体实战:基于大模型构建企业知识库问答系统的架构设计与落地指南》
下一篇
《Spring Boot 中基于 Spring Cache + Redis 实现多级缓存与缓存一致性的实战指南》