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

《集群架构中服务发现与故障转移的实战设计:从健康检查到高可用切换》

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

背景与问题

做集群系统时,很多问题并不是“服务挂了”这么简单,而是“看起来还活着,但已经不该继续接流量了”。

我第一次在生产上处理这类故障时,应用进程没退出、端口还能连通、容器也显示 Running,但数据库连接池早就耗尽了。结果上游通过“端口探活”判断它健康,持续把请求打进去,最终把整条链路拖慢。这类事故本质上都绕不开两个词:

  • 服务发现:调用方怎么知道“应该把请求发给谁”
  • 故障转移:当前目标不可靠时,怎么自动切换到别的节点

如果这两块设计得粗糙,就会出现一串很典型的问题:

  • 节点明明异常,却迟迟不摘除
  • 节点已经恢复,却很久不重新接流量
  • 短时网络抖动导致频繁摘除/恢复,产生流量震荡
  • 所有客户端同时切到同一台节点,引发“雪崩式补刀”
  • 控制面判断健康,数据面却已经超时,状态不一致

这篇文章我会按排障思路来讲,不只谈概念,而是从“出了问题怎么看、怎么止血、如何重构设计”这个角度,把服务发现和故障转移串起来。


背景中的典型故障现象

在中型集群里,下面几种现象最常见:

现象 1:实例明明在线,但请求持续超时

常见原因:

  • 健康检查只看进程或 TCP 端口
  • 应用线程池打满、GC 抖动严重
  • 下游依赖不可用,但服务本身未退出

这时服务注册中心里实例是“健康”的,但业务角度它已经不可服务。

现象 2:节点频繁上下线,流量抖动明显

常见原因:

  • 健康检查阈值过于敏感
  • 心跳周期和超时设置不合理
  • 网络偶发丢包导致误判
  • 多级探测结果没有做平滑处理

现象 3:故障切换后整体更慢了

常见原因:

  • 切换后请求全部堆到剩余少量节点
  • 客户端本地缓存未及时刷新
  • 连接池、DNS、LB 权重存在更新延迟
  • 故障转移只考虑“可用”,没考虑“容量”

现象 4:注册中心正常,但调用方拿到的是旧地址

常见原因:

  • 客户端本地缓存 TTL 太长
  • watch 机制断连后没有补拉
  • SDK 本地状态机处理不完整
  • DNS 解析结果被系统缓存

核心原理

服务发现与故障转移看起来是两个模块,实际上它们必须联合设计。最稳妥的理解方式是把它拆成四层:

  1. 注册:实例把自己的地址和元数据上报
  2. 健康判断:系统决定这个实例是否应继续接流量
  3. 分发:可用实例列表被同步给客户端或代理层
  4. 切换:请求失败时从当前目标迁移到新目标

1. 服务发现的两种主流模式

客户端发现

客户端直接向注册中心拉取实例列表,再自己做负载均衡。

优点:

  • 少一跳,性能更好
  • 路由策略更灵活
  • 适合微服务内部调用

缺点:

  • SDK 逻辑更复杂
  • 多语言栈维护成本高
  • 本地缓存和更新一致性要处理好

服务端发现

客户端只请求一个统一入口,由代理或负载均衡器完成目标选择。

优点:

  • 客户端简单
  • 路由逻辑集中治理
  • 运维管控方便

缺点:

  • 增加一层代理
  • 入口本身要高可用
  • 某些细粒度路由能力不如客户端灵活
flowchart LR
    A[服务实例] --> B[注册中心]
    C[客户端] --> B
    C --> D[本地负载均衡]
    D --> E[实例1]
    D --> F[实例2]
    D --> G[实例3]

2. 健康检查不是“能连通”这么简单

健康检查通常分三层:

  • Liveness:进程是不是还活着
  • Readiness:当前能不能接新流量
  • Dependency Health:核心依赖是否正常

很多系统只做第一层,于是误把“活着但很慢”当成健康。

更实用的做法是:

  • /live:只检查进程主循环和关键线程
  • /ready:检查线程池、连接池、关键依赖、延迟阈值
  • 业务级指标:错误率、P99 延迟、超时比例

我一般建议摘流量用 readiness,不要直接用 liveness
因为 liveness 更适合“是否需要重启”,而 readiness 更适合“是否继续接单”。

3. 故障转移的核心不只是切换,而是“判定 + 限流 + 恢复”

完整的故障转移流程应该包含:

  • 失败判定:什么情况下算这个节点不可靠
  • 临时摘除:在一定时间内不再选它
  • 重试切换:转发到其他节点
  • 半开探测:周期性给它少量流量试探恢复
  • 平滑恢复:恢复后不要一口气打满

这和熔断器的思路很像,本质上是一个状态机:

stateDiagram-v2
    [*] --> Healthy
    Healthy --> Suspect: 连续失败/超时超阈值
    Suspect --> Unavailable: 达到摘除条件
    Unavailable --> HalfOpen: 冷却时间到
    HalfOpen --> Healthy: 探测成功
    HalfOpen --> Unavailable: 探测失败

4. 控制面健康与数据面健康要分开看

这是排障里特别容易忽略的一点。

  • 控制面健康:注册中心里节点状态正常、心跳存在
  • 数据面健康:真实请求能否在 SLA 内完成

一个节点完全可能:

  • 心跳正常
  • 监控在线
  • 但真实业务请求持续超时

所以在生产设计里,最好把两类信息合并决策:

  • 注册中心提供“基础可见性”
  • 客户端或代理层根据真实请求结果做“动态降权/摘除”

一套可落地的设计思路

这里给出一套中级团队比较容易落地的方案,兼顾可维护性和可排障性。

方案分层

第 1 层:注册中心维护实例列表

存储:

  • 实例 ID
  • 地址
  • 版本
  • 可用区
  • 权重
  • 最近心跳时间
  • 静态元数据

第 2 层:实例暴露双健康接口

  • /live:进程可存活
  • /ready:业务可接流量

第 3 层:客户端本地缓存实例列表

  • 定时拉取或 watch 更新
  • 带版本号
  • 支持过期回退
  • 更新失败时保留最后一份可用副本

第 4 层:客户端做失败摘除与短期屏蔽

  • 连续失败 3 次摘除 30 秒
  • 超时比超过阈值时降权
  • 半开阶段仅放少量探测请求

第 5 层:观测系统记录切换原因

必须能回答这些问题:

  • 为什么这个节点被摘了?
  • 是控制面摘除还是数据面摘除?
  • 摘除持续了多久?
  • 恢复是自动还是人工?
sequenceDiagram
    participant S as 服务实例
    participant R as 注册中心
    participant C as 客户端
    participant T as 目标实例

    S->>R: 注册 + 心跳
    C->>R: 拉取/订阅实例列表
    R-->>C: 返回健康实例
    C->>T: 发起请求
    T-->>C: 超时/失败
    C->>C: 连续失败计数 + 临时摘除
    C->>R: 下一次更新实例列表
    Note over C: 冷却后半开探测
    C->>T: 少量试探请求
    T-->>C: 成功
    C->>C: 恢复节点权重

现象复现:一个最小可运行示例

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

  • 两个服务实例
  • 一个注册中心数据源
  • 客户端本地服务发现
  • 健康检查
  • 连续失败后的故障转移
  • 半开恢复

这不是生产级框架,但足够把原理跑通。

目录结构

.
├── registry.json
├── server.py
└── client.py

1)注册信息

registry.json

{
  "payment-service": [
    {
      "id": "node-a",
      "host": "127.0.0.1",
      "port": 8001,
      "weight": 1
    },
    {
      "id": "node-b",
      "host": "127.0.0.1",
      "port": 8002,
      "weight": 1
    }
  ]
}

2)服务实例代码

server.py

from flask import Flask, jsonify
import argparse
import time
import random

app = Flask(__name__)

START_TIME = time.time()
FAIL_MODE = False
SLOW_MODE = False
NODE_ID = "node"

@app.route("/live")
def live():
    return jsonify({"status": "alive", "node": NODE_ID})

@app.route("/ready")
def ready():
    # 模拟业务可服务状态
    if FAIL_MODE:
        return jsonify({"status": "not-ready", "node": NODE_ID}), 503
    return jsonify({"status": "ready", "node": NODE_ID})

@app.route("/pay")
def pay():
    global FAIL_MODE, SLOW_MODE

    if FAIL_MODE:
        return jsonify({"error": "dependency unavailable", "node": NODE_ID}), 503

    if SLOW_MODE:
        time.sleep(2.5)

    return jsonify({
        "result": "ok",
        "node": NODE_ID,
        "uptime_sec": int(time.time() - START_TIME)
    })

@app.route("/toggle/fail")
def toggle_fail():
    global FAIL_MODE
    FAIL_MODE = not FAIL_MODE
    return jsonify({"fail_mode": FAIL_MODE, "node": NODE_ID})

@app.route("/toggle/slow")
def toggle_slow():
    global SLOW_MODE
    SLOW_MODE = not SLOW_MODE
    return jsonify({"slow_mode": SLOW_MODE, "node": NODE_ID})

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, required=True)
    parser.add_argument("--node", type=str, required=True)
    args = parser.parse_args()

    NODE_ID = args.node
    app.run(host="0.0.0.0", port=args.port)

3)客户端服务发现与故障转移代码

client.py

import json
import time
import random
import requests
from collections import defaultdict

REGISTRY_FILE = "registry.json"
SERVICE_NAME = "payment-service"
REQUEST_TIMEOUT = 1.0
FAIL_THRESHOLD = 3
EJECT_SECONDS = 10
HALF_OPEN_AFTER = 10

class NodeState:
    def __init__(self):
        self.fail_count = 0
        self.ejected_until = 0
        self.half_open = False

class ServiceClient:
    def __init__(self):
        self.nodes = []
        self.states = defaultdict(NodeState)
        self.refresh_registry()

    def refresh_registry(self):
        with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        self.nodes = data[SERVICE_NAME]

    def base_url(self, node):
        return f"http://{node['host']}:{node['port']}"

    def is_node_available(self, node):
        state = self.states[node["id"]]
        now = time.time()

        if state.ejected_until == 0:
            return True

        if now >= state.ejected_until:
            state.half_open = True
            return True

        return False

    def choose_node(self):
        candidates = [n for n in self.nodes if self.is_node_available(n)]
        if not candidates:
            raise RuntimeError("没有可用节点,请稍后重试")

        random.shuffle(candidates)

        # 优先挑非 half-open 节点
        stable = [n for n in candidates if not self.states[n["id"]].half_open]
        if stable:
            return stable[0]
        return candidates[0]

    def mark_success(self, node_id):
        state = self.states[node_id]
        state.fail_count = 0
        state.ejected_until = 0
        state.half_open = False

    def mark_failure(self, node_id):
        state = self.states[node_id]
        state.fail_count += 1
        if state.fail_count >= FAIL_THRESHOLD:
            state.ejected_until = time.time() + EJECT_SECONDS
            state.half_open = False
            print(f"[EJECT] 节点 {node_id} 被摘除 {EJECT_SECONDS} 秒")

    def call_pay(self):
        tried = set()

        for _ in range(len(self.nodes)):
            node = self.choose_node()
            if node["id"] in tried:
                continue
            tried.add(node["id"])

            try:
                # readiness 检查
                r = requests.get(self.base_url(node) + "/ready", timeout=0.5)
                if r.status_code != 200:
                    raise RuntimeError("readiness failed")

                resp = requests.get(self.base_url(node) + "/pay", timeout=REQUEST_TIMEOUT)
                resp.raise_for_status()

                self.mark_success(node["id"])
                print(f"[OK] 命中节点 {node['id']} -> {resp.json()}")
                return resp.json()

            except Exception as e:
                print(f"[FAIL] 节点 {node['id']} 请求失败: {e}")
                self.mark_failure(node["id"])

        raise RuntimeError("所有节点调用失败")

if __name__ == "__main__":
    client = ServiceClient()

    while True:
        try:
            client.refresh_registry()
            client.call_pay()
        except Exception as e:
            print(f"[ERROR] {e}")
        time.sleep(1)

4)运行方式

先安装依赖:

pip install flask requests

启动两个节点:

python server.py --port 8001 --node node-a
python server.py --port 8002 --node node-b

启动客户端:

python client.py

5)模拟故障

node-a 进入失败模式:

curl http://127.0.0.1:8001/toggle/fail

你会看到客户端先尝试它,连续失败到阈值后将其摘除,然后自动切到 node-b

再恢复:

curl http://127.0.0.1:8001/toggle/fail

过了冷却时间后,客户端会重新尝试它,成功后恢复使用。

6)模拟“假活着,真不可用”

node-a 进入慢响应模式:

curl http://127.0.0.1:8001/toggle/slow

这时:

  • /live 仍然正常
  • /ready 也可能正常
  • /pay 请求会超时

这正好说明:健康检查必须和真实业务数据面结果结合,不能只看探活接口。


常见坑与排查

下面这部分我尽量按“遇到现象后怎么查”的顺序写。

坑 1:只做 TCP 探活,摘不掉业务坏节点

表现

  • telnet 端口能通
  • 监控显示实例在线
  • 业务请求大量超时

定位路径

  1. 看健康检查逻辑是不是只做了端口检测
  2. 看 readiness 是否包含关键依赖
  3. 对比 /ready 成功率和真实接口成功率
  4. 查线程池、连接池、GC、锁等待

止血方案

  • 先在网关或客户端侧对该节点做临时摘除
  • 缩短超时,减少请求堆积
  • 对慢节点降权,而不是等完全故障才处理

坑 2:客户端缓存过久,地址更新不及时

表现

  • 注册中心里节点已摘除
  • 客户端仍继续请求已下线地址
  • 重启客户端后恢复

定位路径

  1. 查客户端本地缓存 TTL
  2. 查 watch 是否断连
  3. 查更新失败后有没有 fallback 机制
  4. 查 DNS 是否被操作系统或运行时缓存

止血方案

  • 缩短 TTL
  • watch 失败后强制全量拉取
  • 给缓存加版本号和过期时间
  • 紧急情况下手动清理本地连接池/缓存

坑 3:故障切换太激进,导致抖动

表现

  • 节点一会儿 healthy,一会儿 unhealthy
  • 流量在多台节点之间来回跳
  • 错误率波动非常大

定位路径

  1. 查失败阈值是否太小
  2. 查探测周期是否过短
  3. 查网络抖动时是否有去抖动策略
  4. 查恢复后是否立刻满流量回切

止血方案

  • 提高连续失败阈值
  • 改成滑动窗口统计错误率
  • 恢复时采用半开 + 权重渐进恢复
  • 区分“偶发错误”和“持续不可用”

坑 4:摘除后所有流量涌向少数节点

表现

  • 一个节点坏掉后,其余节点 CPU 突然拉满
  • 故障切换引发二次故障
  • 系统不是立即挂,而是逐台被压垮

定位路径

  1. 查剩余节点容量是否足够
  2. 查重试次数是否过多
  3. 查是否存在“同步重试风暴”
  4. 查客户端是否都采用同一负载策略

止血方案

  • 立刻降低重试次数
  • 启用限流和排队保护
  • 按机房/可用区做局部故障隔离
  • 必要时快速扩容,而不是只靠切换

坑 5:控制面显示健康,数据面已经坏了

表现

  • 注册中心心跳正常
  • 但核心接口 5xx 或超时飙升

定位路径

  1. 分开看控制面指标和业务指标
  2. 看节点是否只是“还能汇报心跳”
  3. 看请求超时是否都集中在某些下游依赖
  4. 看是否存在资源耗尽但进程未退出

止血方案

  • 引入被动健康检查:根据真实请求结果降权
  • 不把心跳结果作为唯一准入条件
  • 让 readiness 纳入关键依赖状态

更系统的排查流程

如果你线上真的遇到了“服务发现/故障切换异常”,我建议按这个顺序看,效率最高。

flowchart TD
    A[请求失败或延迟升高] --> B{实例列表是否正确}
    B -- 否 --> C[查注册中心/缓存/watch/DNS]
    B -- 是 --> D{健康判断是否正确}
    D -- 否 --> E[查live/ready/依赖检查逻辑]
    D -- 是 --> F{切换是否生效}
    F -- 否 --> G[查客户端重试/摘除/连接池]
    F -- 是 --> H{切换后是否过载}
    H -- 是 --> I[查容量/限流/重试风暴]
    H -- 否 --> J[查下游依赖或局部网络问题]

安全/性能最佳实践

这部分容易被忽略,但在集群环境里非常关键。

安全最佳实践

1. 注册中心访问要鉴权

不要把注册、摘除、权重调整这些接口裸奔暴露。否则最坏情况下,恶意请求可以:

  • 注入伪造实例
  • 摘除真实节点
  • 篡改权重造成流量偏斜

建议:

  • 使用 mTLS 或 token 鉴权
  • 注册与查询权限分离
  • 关键操作保留审计日志

2. 健康检查接口要避免泄露敏感信息

/ready 不应该直接把:

  • 数据库账号状态
  • 内网 IP 拓扑
  • 详细异常栈
  • 第三方依赖密钥信息

暴露给外部。
更稳妥的方式是:

  • 对外只返回 ready/not-ready
  • 详细原因只打到内部日志和监控

3. 防止探测接口被滥用

健康检查通常频率高、路径固定,很容易被误用甚至放大攻击。

建议:

  • 仅内网开放
  • 限制来源
  • 对探活接口做轻量实现
  • 避免探活接口再访问过重依赖

性能最佳实践

1. 健康检查要轻,但不能失真

这是个平衡问题。

  • 太轻:只能看见“活着”
  • 太重:探活本身把系统压垮

比较实用的经验是:

  • /live 尽量轻量
  • /ready 检查关键依赖,但设置小超时
  • 不要在每次探活里做大查询或全链路自检

2. 使用增量更新,减少实例列表全量拉取

实例数一多,全量拉取会带来:

  • 带宽浪费
  • 客户端 CPU 反序列化开销
  • 更新延迟抖动

更推荐:

  • watch + 版本号
  • 定时全量校准
  • 增量失败时回退全量拉取

3. 客户端重试必须有边界

重试是故障转移的一部分,但无边界重试会把系统压死。

建议:

  • 最多重试 1~2 次
  • 只对幂等请求重试
  • 配合退避和抖动
  • 单节点失败后短期摘除,而不是每次都再试它

4. 用“降权”替代“二元健康”

现实里实例状态通常不是非黑即白。
某节点可能只是:

  • 延迟变高
  • 某些依赖变慢
  • 容量不足

这种情况下,直接摘除未必最好。可以先:

  • 降低权重
  • 只给少量流量
  • 观察是否恢复

这样比“一刀切上下线”更平滑。

5. 设计时要考虑可用区和机房拓扑

故障转移如果不考虑拓扑,经常会把同城问题扩大成跨区问题。

建议:

  • 优先同可用区选择
  • 可用区失效再跨区
  • 跨区切换要配额限制
  • 指标上单独看“跨区流量比例”

一些实战设计建议

这里给几条我自己比较认可、也比较容易执行的建议。

建议 1:健康检查至少分 live 和 ready

如果只能做一个接口,团队后面迟早会踩坑。
最起码做到:

  • live 决定是否重启
  • ready 决定是否接流量

建议 2:用“主动检查 + 被动观测”双轨制

  • 主动检查:心跳、探活接口
  • 被动观测:真实请求成功率、延迟、超时率

双轨结合,误判会少很多。

建议 3:切换策略不要只看可用性,还要看容量

一个实例坏了,不代表其他实例能无痛接住全部流量。
所以设计切换时要同时定义:

  • 每个节点最大承载量
  • 摘除后的扩容策略
  • 限流和降级策略

建议 4:恢复比摘除更需要小心

摘除通常容易做,恢复才是事故高发点。
如果节点刚恢复就立刻吃满流量,往往又被打回去。

所以恢复阶段最好做:

  • 半开探测
  • 小流量灰度恢复
  • 权重渐进提升

建议 5:把“为什么切换”记录下来

很多团队只能看到“切了”,看不到“为啥切”。
建议至少记录:

  • 节点 ID
  • 失败原因
  • 错误类型
  • 连续失败次数
  • 摘除开始/结束时间
  • 恢复方式

后续排障效率会提升非常明显。


总结

服务发现和故障转移,真正难的地方不在“把地址拿到手”,而在于如何正确判断一个节点是否还值得信任,以及如何在切换时不制造新的故障

如果你想把系统先做稳,我建议优先落这几件事:

  1. 健康检查分层:至少拆分 liveready
  2. 客户端或代理层支持短期摘除:不要反复打坏节点
  3. 结合真实请求结果做被动健康判断
  4. 恢复走半开和渐进放量:别一恢复就打满
  5. 把切换原因做成可观测事件:否则出了事很难复盘

边界条件也要讲清楚:

  • 如果你的系统规模很小、实例固定,简单的静态配置可能已经够用,不必过度设计
  • 如果是跨机房、跨可用区、大量弹性实例的集群,就必须把控制面和数据面的健康分开治理
  • 如果业务对一致性和低延迟都很敏感,单靠注册中心心跳绝对不够,必须引入请求级别的失败感知

一句话收尾:
高可用切换不是“失败后换个地址”这么简单,而是一套关于健康判断、流量控制、恢复节奏和观测能力的组合设计。
把这套组合设计好,集群系统才会在故障来临时表现得像“自动修复”,而不是“自动扩大事故”。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》