背景与问题
很多团队在把单体系统拆成服务之后,第一波问题往往不是业务逻辑,而是“服务找不到服务”和“故障切不过去”。
典型现场我见过不少:
- 某个实例明明活着,但调用方频繁报超时
- 注册中心列表里还有节点,实际上节点已经假死
- 故障转移触发太慢,业务可用性下降
- 故障转移触发太快,正常节点被误杀,形成“抖动”
- 服务端、注册中心、客户端三方对“健康”的理解不一致
这类问题有个共同点:不是某个组件单点失效,而是集群中的状态传播、健康判断、路由决策三件事没配合好。
如果你是中级工程师,通常已经会用 Nginx、Consul、Eureka、etcd、Kubernetes Service 之类的东西。但真正到线上排障时,难点不在“会不会用”,而在于:
- 出问题时先看哪里
- 如何判断是注册、发现、探活、网络还是客户端缓存的问题
- 如何设计一个更稳的故障转移策略,而不是“挂了就切”这么简单
这篇文章我会从排障视角来讲高可用服务发现与故障转移,尽量把原理和一套可运行的小实验串起来。
背景系统模型
先统一一下本文讨论的对象。一个典型的高可用服务发现链路,通常长这样:
flowchart LR
U[用户请求] --> G[网关 / 调用方]
G --> D[服务发现客户端]
D --> R[注册中心集群]
D --> S1[服务实例 A]
D --> S2[服务实例 B]
D --> S3[服务实例 C]
R --> H[健康检查机制]
H --> S1
H --> S2
H --> S3
真正影响可用性的关键路径有三段:
- 注册链路:实例是否正确注册、续约、下线
- 发现链路:调用方是否拿到最新可用实例列表
- 转移链路:一个实例故障后,请求是否快速且平稳地切换
很多线上事故,都是这三段里某一段看似正常,组合起来却不正常。
核心原理
1. 服务发现不是“查个地址”这么简单
服务发现至少涉及四个状态:
- 实例启动状态:进程是否活着
- 业务健康状态:依赖是否正常,例如数据库、缓存、磁盘、线程池
- 注册状态:注册中心是否认为它在线
- 可路由状态:客户端是否仍把流量打给它
这四个状态可能不一致。比如:
- 进程活着,但线程池满了,业务已不可用
- 注册中心还认为它健康,但客户端本地缓存未刷新
- 健康检查刚失败,摘流量还没传播到所有客户端
所以服务发现的本质是:在一个延迟和不一致存在的系统里,尽可能快而稳地找到可用节点。
2. 故障转移的核心是“检测、判定、切换、恢复”
可以把故障转移拆成一个状态机:
stateDiagram-v2
[*] --> Healthy
Healthy --> Suspect: 连续失败阈值触发
Suspect --> Unhealthy: 健康检查失败 / 熔断打开
Unhealthy --> Recovering: 探测成功
Recovering --> Healthy: 连续成功阈值满足
Recovering --> Unhealthy: 再次失败
这里最容易踩坑的是两个阈值:
- 失败阈值太低:网络抖动一下就切走,误判多
- 恢复阈值太低:实例刚恢复一点就进流量,马上又崩
所以高可用设计里通常会配:
- 连续失败次数阈值
- 连续成功次数阈值
- 熔断时间窗口
- 半开探测流量比例
3. 注册中心高可用,不等于业务高可用
不少人会把注意力全放在注册中心集群上,比如 3 节点、5 节点、Raft、副本同步。但即便注册中心本身高可用,也不意味着业务调用链高可用。
因为真正决定请求落点的是客户端:
- 客户端有没有本地缓存
- 缓存多久刷新一次
- 失败后是否重试其他节点
- 是否支持熔断与摘除
- 是否有连接池和 DNS 缓存问题
一句话总结:注册中心解决“数据源可靠”,客户端策略决定“请求是否真的可靠”。
现象复现
下面我们用一个最小可运行实验复现常见问题:
两个服务实例 + 一个简化注册中心 + 一个带故障转移逻辑的客户端。
目标:
- 正常情况下轮询调用
- 某个实例故障后自动摘除
- 故障恢复后再加入
- 观察探活、缓存、切换的行为
目录结构
ha-demo/
├── registry.py
├── service_a.py
├── service_b.py
└── client.py
实战代码(可运行)
运行环境:Python 3.9+
依赖安装:pip install flask requests
1)简化注册中心
# registry.py
from flask import Flask, request, jsonify
import time
import threading
app = Flask(__name__)
services = {}
TTL = 10 # 超过 10 秒未续约视为过期
def cleanup_expired():
while True:
now = time.time()
for service_name in list(services.keys()):
alive_instances = []
for instance in services[service_name]:
if now - instance["last_heartbeat"] <= TTL:
alive_instances.append(instance)
services[service_name] = alive_instances
time.sleep(2)
@app.route("/register", methods=["POST"])
def register():
data = request.json
service_name = data["service_name"]
address = data["address"]
if service_name not in services:
services[service_name] = []
found = False
for instance in services[service_name]:
if instance["address"] == address:
instance["last_heartbeat"] = time.time()
found = True
break
if not found:
services[service_name].append({
"address": address,
"last_heartbeat": time.time()
})
return jsonify({"status": "ok"})
@app.route("/discover/<service_name>", methods=["GET"])
def discover(service_name):
return jsonify({
"instances": [i["address"] for i in services.get(service_name, [])]
})
if __name__ == "__main__":
t = threading.Thread(target=cleanup_expired, daemon=True)
t.start()
app.run(port=8500)
2)服务实例 A
# service_a.py
from flask import Flask, jsonify
import requests
import threading
import time
import os
app = Flask(__name__)
SERVICE_NAME = "demo-service"
PORT = int(os.getenv("PORT", "5001"))
REGISTRY = "http://127.0.0.1:8500"
ADDRESS = f"http://127.0.0.1:{PORT}"
fail_mode = {"enabled": False}
def heartbeat():
while True:
try:
requests.post(f"{REGISTRY}/register", json={
"service_name": SERVICE_NAME,
"address": ADDRESS
}, timeout=1)
except Exception:
pass
time.sleep(3)
@app.route("/health")
def health():
if fail_mode["enabled"]:
return jsonify({"status": "down"}), 500
return jsonify({"status": "up"})
@app.route("/work")
def work():
if fail_mode["enabled"]:
return jsonify({"instance": ADDRESS, "result": "error"}), 500
return jsonify({"instance": ADDRESS, "result": "ok"})
@app.route("/toggle_fail")
def toggle_fail():
fail_mode["enabled"] = not fail_mode["enabled"]
return jsonify({"fail_mode": fail_mode["enabled"]})
if __name__ == "__main__":
t = threading.Thread(target=heartbeat, daemon=True)
t.start()
app.run(port=PORT)
3)服务实例 B
# service_b.py
from flask import Flask, jsonify
import requests
import threading
import time
import os
app = Flask(__name__)
SERVICE_NAME = "demo-service"
PORT = int(os.getenv("PORT", "5002"))
REGISTRY = "http://127.0.0.1:8500"
ADDRESS = f"http://127.0.0.1:{PORT}"
fail_mode = {"enabled": False}
def heartbeat():
while True:
try:
requests.post(f"{REGISTRY}/register", json={
"service_name": SERVICE_NAME,
"address": ADDRESS
}, timeout=1)
except Exception:
pass
time.sleep(3)
@app.route("/health")
def health():
if fail_mode["enabled"]:
return jsonify({"status": "down"}), 500
return jsonify({"status": "up"})
@app.route("/work")
def work():
if fail_mode["enabled"]:
return jsonify({"instance": ADDRESS, "result": "error"}), 500
return jsonify({"instance": ADDRESS, "result": "ok"})
@app.route("/toggle_fail")
def toggle_fail():
fail_mode["enabled"] = not fail_mode["enabled"]
return jsonify({"fail_mode": fail_mode["enabled"]})
if __name__ == "__main__":
t = threading.Thread(target=heartbeat, daemon=True)
t.start()
app.run(port=PORT)
4)带故障转移的客户端
# client.py
import requests
import time
REGISTRY = "http://127.0.0.1:8500"
SERVICE_NAME = "demo-service"
CACHE_TTL = 5
FAIL_THRESHOLD = 2
RECOVER_THRESHOLD = 2
class HAClient:
def __init__(self):
self.instances = []
self.last_fetch = 0
self.fail_count = {}
self.recover_count = {}
self.unhealthy = set()
self.index = 0
def refresh_instances(self):
now = time.time()
if now - self.last_fetch < CACHE_TTL:
return
resp = requests.get(f"{REGISTRY}/discover/{SERVICE_NAME}", timeout=1)
data = resp.json()
self.instances = data["instances"]
self.last_fetch = now
def choose_instance(self):
healthy = [i for i in self.instances if i not in self.unhealthy]
if not healthy:
healthy = self.instances[:]
if not healthy:
return None
instance = healthy[self.index % len(healthy)]
self.index += 1
return instance
def mark_failure(self, instance):
self.fail_count[instance] = self.fail_count.get(instance, 0) + 1
self.recover_count[instance] = 0
if self.fail_count[instance] >= FAIL_THRESHOLD:
self.unhealthy.add(instance)
def mark_success(self, instance):
self.fail_count[instance] = 0
if instance in self.unhealthy:
self.recover_count[instance] = self.recover_count.get(instance, 0) + 1
if self.recover_count[instance] >= RECOVER_THRESHOLD:
self.unhealthy.remove(instance)
self.recover_count[instance] = 0
def call(self):
self.refresh_instances()
tried = set()
for _ in range(len(self.instances)):
instance = self.choose_instance()
if not instance or instance in tried:
break
tried.add(instance)
try:
resp = requests.get(f"{instance}/work", timeout=1)
if resp.status_code == 200:
self.mark_success(instance)
return resp.json()
else:
self.mark_failure(instance)
except Exception:
self.mark_failure(instance)
return {"error": "all instances failed", "unhealthy": list(self.unhealthy)}
if __name__ == "__main__":
client = HAClient()
while True:
result = client.call()
print(result)
time.sleep(1)
运行与验证
启动步骤
分别在 4 个终端执行:
python registry.py
python service_a.py
python service_b.py
python client.py
观察正常行为
你会看到客户端输出类似:
{'instance': 'http://127.0.0.1:5001', 'result': 'ok'}
{'instance': 'http://127.0.0.1:5002', 'result': 'ok'}
说明客户端在两个实例间轮询。
模拟实例故障
把 A 切成失败模式:
curl http://127.0.0.1:5001/toggle_fail
此时客户端可能先看到几次失败,然后把 5001 标记为不健康,只打 5002。
模拟恢复
再执行一次:
curl http://127.0.0.1:5001/toggle_fail
客户端在达到恢复阈值后,会重新把 5001 加入。
请求与故障转移时序
下面这个时序图可以帮助你理解,为什么“故障发生了,但不是瞬间全部切走”。
sequenceDiagram
participant C as 客户端
participant R as 注册中心
participant A as 实例A
participant B as 实例B
C->>R: 拉取实例列表
R-->>C: A, B
C->>A: /work
A-->>C: 500 / 超时
C->>A: /work 重试一次
A-->>C: 500 / 超时
C->>C: 标记 A 为 unhealthy
C->>B: /work
B-->>C: 200 OK
A->>R: 心跳仍存在
Note over C,R: 注册状态与可路由状态短时不一致
这也是为什么线上排障时,不要只看注册中心页面上实例在不在。
实例“在列表里”并不等于“还会被请求打到”。
定位路径
排障时我建议按这条链路走,效率最高:
第一步:确认故障发生在哪一层
先问自己三个问题:
- 注册中心里还有实例吗?
- 客户端本地缓存里还有实例吗?
- 实例能响应健康检查吗?
可以用下面这个定位矩阵:
| 现象 | 可能原因 | 优先检查 |
|---|---|---|
| 注册中心无实例 | 注册失败、心跳中断、TTL过短 | 注册日志、网络、时钟 |
| 注册中心有实例但调用失败 | 假健康、客户端缓存、连接池残留 | 健康检查、客户端摘除逻辑 |
| 故障切换很慢 | 探测周期太长、缓存 TTL 太大 | 心跳周期、拉取频率 |
| 切换后抖动严重 | 阈值太敏感、恢复过快 | 熔断和恢复参数 |
第二步:看“健康定义”是否统一
一个常见坑是:
/health只检查进程活着- 但
/work依赖数据库、Redis、线程池
于是健康检查通过,业务请求却失败。
更合理的做法是分层:
- liveness:进程是否存活
- readiness:是否可接流量
- deep health:关键依赖是否正常
如果你的平台支持,最好把它们拆开,不要一个接口包打天下。
第三步:区分“超时”与“错误”
超时和 500 的处理策略不该完全一样。
- 超时:更像容量、网络、阻塞问题,适合快速摘流量
- 500:可能是业务逻辑异常,不一定代表整个实例不可用
我早期就踩过一个坑:把所有非 200 都计入同等失败,结果某个接口因为参数异常返回 400,也把实例熔断了,最后越熔越少。
常见坑与排查
1. TTL、心跳周期、拉取周期配得不合理
比如:
- 心跳每 10 秒一次
- TTL 设 12 秒
- 客户端缓存 30 秒
这就很危险。网络抖一下,实例可能被误删;即便恢复了,客户端也要很久才能感知。
建议经验值
- 心跳周期:3~5 秒
- TTL:心跳周期的 3 倍左右
- 客户端缓存:3~10 秒,视注册中心压力而定
边界条件是:
如果实例规模很大,拉取太频繁会加大注册中心压力,这时要引入推送、增量订阅或本地代理。
2. 只靠注册中心摘除,不做客户端熔断
这是非常常见的误区。
注册中心的摘除有传播延迟;客户端如果不做本地失败摘除,故障窗口内仍会持续打到坏节点。
排查方法
看客户端日志里有没有:
- 连续失败计数
- 本地摘除实例记录
- 恢复探测日志
如果没有,说明流量控制完全依赖注册中心,稳态还行,故障态通常不够快。
3. 连接池没更新,导致“已经切换了但还在连旧节点”
服务发现更新了,不代表网络连接立刻更新。
常见场景:
- Java HTTP client 长连接仍指向老节点
- gRPC channel 长时间复用
- DNS 缓存太久
排查方法
- 看实际出站连接
- 看连接池空闲连接数量
- 比对实例下线时间和连接关闭时间
止血方案
- 缩短空闲连接存活时间
- 节点摘除时主动驱逐连接
- 降低 DNS TTL,并确认客户端是否遵守
4. 误把“假死”当“宕机”
实例进程活着,但实际上:
- Full GC 卡顿
- 线程池耗尽
- 下游数据库阻塞
- CPU 被打满
这时注册和心跳可能都还正常,但业务请求已经超时。
排查重点
- GC 日志
- 线程池活跃数与队列长度
- 下游连接池等待时间
- p99 延迟是否突增
5. 故障恢复后瞬间流量打满
节点恢复后,立刻恢复 100% 流量,很容易二次故障。
更稳的做法
- 先半开探测
- 放 5% 流量
- 逐步提升到 25%、50%、100%
如果你用服务网格或成熟网关,这类能力通常可以配置;如果是 SDK 自己做,也建议保留灰度恢复逻辑。
止血方案
当线上已经在抖,不要先追求“最优设计”,先保业务。
场景 1:单个实例频繁超时
可优先做:
- 手动摘除实例
- 扩容健康实例
- 缩短客户端超时时间
- 暂时关闭激进重试,避免放大流量
这里有个反直觉点:
重试不是永远越多越好。
下游已经半死不活时,重试会把它直接压垮。
场景 2:注册中心不稳定
先做:
- 客户端启用本地缓存兜底
- 拉长缓存 TTL,避免雪崩式拉取
- 降低注册中心非核心请求
- 必要时启用静态节点列表保底
场景 3:故障转移太慢
先调:
- 客户端超时
- 失败阈值
- 健康检查间隔
- 本地摘除时间窗口
但注意不要一口气全调太激进,否则从“切不动”变成“乱切”。
安全/性能最佳实践
安全方面
1. 注册接口不要裸奔
示例代码里为了演示简单,注册中心接口没有认证。线上至少要做:
- 服务身份认证,例如 mTLS、Token、SPIFFE
- 注册来源白名单
- 心跳频率限制
- 注册数据校验
否则很容易被伪造实例污染服务列表。
2. 健康检查接口避免泄露过多内部信息
不要把数据库地址、账号异常栈、磁盘路径等直接返回给外部。
对外暴露的健康接口可以只返回简化状态,对内保留详细诊断接口。
性能方面
1. 控制发现流量
如果每个客户端都每秒全量拉取一次注册表,注册中心迟早扛不住。
建议按规模分层:
- 小规模:短周期拉取可接受
- 中规模:增量拉取
- 大规模:长连接推送 / Sidecar / 本地 Agent
2. 超时要小而明确
高可用系统里,超时比错误更危险,因为它占资源更久。
建议:
- 连接超时:短
- 读超时:按 SLA 设定
- 总请求超时:必须有上限
不要出现“下游卡 30 秒,上游线程全堵住”的情况。
3. 重试要带边界
推荐策略:
- 只对幂等请求重试
- 限制最大重试次数,通常 1~2 次
- 避免对同一实例重复重试
- 配合退避和抖动
4. 做好可观测性
至少埋这些指标:
- 实例注册数
- 心跳成功率
- 服务发现延迟
- 客户端本地摘除数
- 熔断打开次数
- 故障转移耗时
- 每实例成功率 / 超时率 / p95 / p99
很多时候你不是不会修,而是没有证据判断故障到底从哪开始。
一套更稳的设计建议
如果让我给中级工程师一个实用的落地方案,我会建议按下面组合:
flowchart TD
A[服务实例] --> B[注册中心集群]
A --> C[Readiness健康检查]
D[客户端SDK / 网关] --> B
D --> E[本地缓存]
D --> F[失败摘除]
D --> G[熔断与半开恢复]
D --> H[限次重试]
D --> I[连接池驱逐]
关键原则就五条:
- 注册中心做全局视图,客户端做快速本地决策
- 健康检查区分存活与可接流量
- 故障摘除和恢复都要有阈值
- 重试、超时、连接池联动设计
- 观测指标先行,不要盲调参数
总结
高可用服务发现与故障转移,真正难的不是“有没有注册中心”,而是以下三件事能不能配合好:
- 状态传播足够快
- 健康判断足够准
- 切换策略足够稳
如果你想把线上问题明显减少,我建议从最可执行的三步开始:
- 给客户端加本地失败摘除与恢复探测
- 把健康检查拆成 liveness / readiness
- 统一超时、重试、连接池、缓存 TTL 的参数策略
最后给一个边界判断:
- 如果你的系统规模还不大,先把客户端熔断、本地缓存、健康检查做好,收益最高
- 如果实例数和调用链很复杂,再考虑推送式发现、服务网格、区域容灾等更重的方案
很多高可用问题,不是架构不高级,而是基础动作没做扎实。把这些细节理顺,故障转移会比你想象中稳很多。