背景与问题
做集群系统时,很多问题并不是“服务挂了”这么简单,而是“看起来还活着,但已经不该继续接流量了”。
我第一次在生产上处理这类故障时,应用进程没退出、端口还能连通、容器也显示 Running,但数据库连接池早就耗尽了。结果上游通过“端口探活”判断它健康,持续把请求打进去,最终把整条链路拖慢。这类事故本质上都绕不开两个词:
- 服务发现:调用方怎么知道“应该把请求发给谁”
- 故障转移:当前目标不可靠时,怎么自动切换到别的节点
如果这两块设计得粗糙,就会出现一串很典型的问题:
- 节点明明异常,却迟迟不摘除
- 节点已经恢复,却很久不重新接流量
- 短时网络抖动导致频繁摘除/恢复,产生流量震荡
- 所有客户端同时切到同一台节点,引发“雪崩式补刀”
- 控制面判断健康,数据面却已经超时,状态不一致
这篇文章我会按排障思路来讲,不只谈概念,而是从“出了问题怎么看、怎么止血、如何重构设计”这个角度,把服务发现和故障转移串起来。
背景中的典型故障现象
在中型集群里,下面几种现象最常见:
现象 1:实例明明在线,但请求持续超时
常见原因:
- 健康检查只看进程或 TCP 端口
- 应用线程池打满、GC 抖动严重
- 下游依赖不可用,但服务本身未退出
这时服务注册中心里实例是“健康”的,但业务角度它已经不可服务。
现象 2:节点频繁上下线,流量抖动明显
常见原因:
- 健康检查阈值过于敏感
- 心跳周期和超时设置不合理
- 网络偶发丢包导致误判
- 多级探测结果没有做平滑处理
现象 3:故障切换后整体更慢了
常见原因:
- 切换后请求全部堆到剩余少量节点
- 客户端本地缓存未及时刷新
- 连接池、DNS、LB 权重存在更新延迟
- 故障转移只考虑“可用”,没考虑“容量”
现象 4:注册中心正常,但调用方拿到的是旧地址
常见原因:
- 客户端本地缓存 TTL 太长
- watch 机制断连后没有补拉
- SDK 本地状态机处理不完整
- DNS 解析结果被系统缓存
核心原理
服务发现与故障转移看起来是两个模块,实际上它们必须联合设计。最稳妥的理解方式是把它拆成四层:
- 注册:实例把自己的地址和元数据上报
- 健康判断:系统决定这个实例是否应继续接流量
- 分发:可用实例列表被同步给客户端或代理层
- 切换:请求失败时从当前目标迁移到新目标
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端口能通- 监控显示实例在线
- 业务请求大量超时
定位路径
- 看健康检查逻辑是不是只做了端口检测
- 看 readiness 是否包含关键依赖
- 对比
/ready成功率和真实接口成功率 - 查线程池、连接池、GC、锁等待
止血方案
- 先在网关或客户端侧对该节点做临时摘除
- 缩短超时,减少请求堆积
- 对慢节点降权,而不是等完全故障才处理
坑 2:客户端缓存过久,地址更新不及时
表现
- 注册中心里节点已摘除
- 客户端仍继续请求已下线地址
- 重启客户端后恢复
定位路径
- 查客户端本地缓存 TTL
- 查 watch 是否断连
- 查更新失败后有没有 fallback 机制
- 查 DNS 是否被操作系统或运行时缓存
止血方案
- 缩短 TTL
- watch 失败后强制全量拉取
- 给缓存加版本号和过期时间
- 紧急情况下手动清理本地连接池/缓存
坑 3:故障切换太激进,导致抖动
表现
- 节点一会儿 healthy,一会儿 unhealthy
- 流量在多台节点之间来回跳
- 错误率波动非常大
定位路径
- 查失败阈值是否太小
- 查探测周期是否过短
- 查网络抖动时是否有去抖动策略
- 查恢复后是否立刻满流量回切
止血方案
- 提高连续失败阈值
- 改成滑动窗口统计错误率
- 恢复时采用半开 + 权重渐进恢复
- 区分“偶发错误”和“持续不可用”
坑 4:摘除后所有流量涌向少数节点
表现
- 一个节点坏掉后,其余节点 CPU 突然拉满
- 故障切换引发二次故障
- 系统不是立即挂,而是逐台被压垮
定位路径
- 查剩余节点容量是否足够
- 查重试次数是否过多
- 查是否存在“同步重试风暴”
- 查客户端是否都采用同一负载策略
止血方案
- 立刻降低重试次数
- 启用限流和排队保护
- 按机房/可用区做局部故障隔离
- 必要时快速扩容,而不是只靠切换
坑 5:控制面显示健康,数据面已经坏了
表现
- 注册中心心跳正常
- 但核心接口 5xx 或超时飙升
定位路径
- 分开看控制面指标和业务指标
- 看节点是否只是“还能汇报心跳”
- 看请求超时是否都集中在某些下游依赖
- 看是否存在资源耗尽但进程未退出
止血方案
- 引入被动健康检查:根据真实请求结果降权
- 不把心跳结果作为唯一准入条件
- 让 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
- 失败原因
- 错误类型
- 连续失败次数
- 摘除开始/结束时间
- 恢复方式
后续排障效率会提升非常明显。
总结
服务发现和故障转移,真正难的地方不在“把地址拿到手”,而在于如何正确判断一个节点是否还值得信任,以及如何在切换时不制造新的故障。
如果你想把系统先做稳,我建议优先落这几件事:
- 健康检查分层:至少拆分
live和ready - 客户端或代理层支持短期摘除:不要反复打坏节点
- 结合真实请求结果做被动健康判断
- 恢复走半开和渐进放量:别一恢复就打满
- 把切换原因做成可观测事件:否则出了事很难复盘
边界条件也要讲清楚:
- 如果你的系统规模很小、实例固定,简单的静态配置可能已经够用,不必过度设计
- 如果是跨机房、跨可用区、大量弹性实例的集群,就必须把控制面和数据面的健康分开治理
- 如果业务对一致性和低延迟都很敏感,单靠注册中心心跳绝对不够,必须引入请求级别的失败感知
一句话收尾:
高可用切换不是“失败后换个地址”这么简单,而是一套关于健康判断、流量控制、恢复节奏和观测能力的组合设计。
把这套组合设计好,集群系统才会在故障来临时表现得像“自动修复”,而不是“自动扩大事故”。