背景与问题
做集群系统时,很多人最先想到的是“多部署几台机器就高可用了”。但真到线上,问题往往不是“有没有多副本”,而是:
- 服务实例挂了,调用方多久才能发现?
- 注册中心短暂抖动时,业务是否跟着雪崩?
- 故障转移后,新节点是不是已经准备好接流量?
- 缓存的服务列表过期了,会不会把请求继续打到死节点?
- 读写分离、主备切换后,客户端有没有“脑裂式”误判?
我自己早期做服务治理时踩过一个很典型的坑:应用实例已经被 K8s 重启了,但客户端本地缓存还保留着旧地址,导致 30 秒内持续请求失败。从监控上看,服务明明“恢复了”,但业务错误率就是压不下去。最后发现不是服务不可用,而是服务发现与故障转移链路没有闭环。
这篇文章不讲空泛概念,而是从排障角度,带你把这条链路拆开看:
- 服务是怎么被发现的;
- 客户端如何判断“这个节点还能不能用”;
- 故障发生时,流量如何安全切走;
- 出现异常时,应该沿着什么路径定位;
- 代码层面怎样做一个可运行的最小实现。
先看一个典型架构
在中级开发者最常见的场景里,高可用服务发现通常包含这些角色:
- 服务提供者:真正处理业务请求的实例
- 注册中心:保存可用实例列表
- 客户端调用方:从注册中心拉取或订阅实例,并做本地负载均衡
- 健康检查模块:判断实例是否可接流量
- 故障转移策略:节点失败后进行重试、摘除、切换
flowchart LR
A[服务提供者 A1/A2/A3] --> B[注册中心]
C[客户端调用方] --> B
C --> D[本地实例缓存]
C --> E[负载均衡器]
E --> A
F[健康检查] --> B
F --> D
这个图里最容易被忽视的是 本地实例缓存。很多故障都不是注册中心本身挂了,而是:
- 本地缓存没及时更新
- 更新了但还没触发摘除
- 摘除了但连接池还在复用旧连接
- 新节点注册了,但预热未完成就被导入流量
背景与问题:真实故障一般长什么样
我们先把 troubleshooting 视角立起来。线上最常见的故障现象通常有这几类:
1. 部分请求间歇性失败
表现:
- 错误率不是 100%,而是 5%~30%
- 同一个接口,有时成功有时超时
- 多出现在扩缩容、发布、节点重启之后
这往往说明不是整体不可用,而是实例列表中混入了坏节点。
2. 故障切换慢
表现:
- 主节点挂了以后,请求要卡几秒甚至十几秒才恢复
- 调用链超时层层叠加
- 应用线程池、连接池被拖满
这通常意味着:
- 健康检查间隔太长
- 重试策略过于激进
- 故障摘除依赖“自然失败”,没有主动熔断
3. 注册中心看起来正常,业务却不正常
表现:
- 注册中心页面上实例状态健康
- 实际业务请求大量失败
- 应用日志里出现连接拒绝、TLS 失败、读超时
这类问题多数是控制面健康,不代表数据面健康。也就是:注册成功了,不代表真能处理业务。
核心原理
1. 服务发现的两种主流模式
客户端发现(Client-side Discovery)
客户端自己去注册中心拿实例列表,然后选择一个节点调用。
优点:
- 负载均衡策略灵活
- 客户端可以做就近访问、权重路由、连接复用
缺点:
- 客户端逻辑复杂
- 各语言 SDK 一旦实现不一致,问题很多
服务端发现(Server-side Discovery)
客户端请求先到负载均衡器或网关,再由它转发到后端实例。
优点:
- 客户端简单
- 流量治理能力集中
缺点:
- 负载均衡器变成关键组件
- 个性化路由策略不容易下沉到业务侧
flowchart TB
subgraph ClientSide[客户端发现]
C1[客户端] --> R1[注册中心]
C1 --> S1[服务实例]
end
subgraph ServerSide[服务端发现]
C2[客户端] --> LB[网关/负载均衡]
LB --> S2[服务实例]
LB --> R2[注册中心]
end
对于中级开发者,我的建议很实际:
- 内部服务调用、追求灵活性:优先理解客户端发现
- 统一出口、协议治理、跨团队协作多:服务端发现更省心
2. 健康检查不是“能 ping 通”就够了
健康检查至少要分三层:
存活检查(Liveness)
判断进程是不是还活着。
适合回答:要不要重启容器。
就绪检查(Readiness)
判断实例是否准备好接流量。
适合回答:要不要加入服务列表。
业务检查(Business Health)
检查关键依赖是否正常,比如:
- 数据库连接是否可用
- 消息队列是否积压严重
- 本地缓存是否初始化完成
很多团队只做了 liveness,于是出现一个经典问题:
实例刚启动,HTTP 端口能通,但缓存还没预热、连接池还没建好,结果立刻被导流,瞬间打挂。
3. 故障转移的核心不是“重试”,而是“有边界的重试”
故障转移通常包含几步:
- 请求失败
- 判定节点异常
- 从本地可用列表中摘除
- 选择下一个节点
- 按策略重试
- 等待健康恢复后再重新加入
其中最危险的是“盲目重试”。
如果没有边界,重试会把小故障放大成大故障:
- 单次超时 2 秒,重试 3 次,用户一次请求可能卡 6 秒以上
- 下游 50% 实例故障时,上游重试会放大流量
- 重试叠加线程池阻塞,容易触发级联雪崩
更稳妥的做法是:
- 只对幂等请求重试
- 限制最大重试次数
- 失败后短时间熔断节点
- 用指数退避避免瞬时打爆恢复中的节点
4. 本地缓存 + 心跳 + 熔断,是高可用发现的最小闭环
一个可落地的最小闭环通常是这样:
sequenceDiagram
participant P as 服务提供者
participant R as 注册中心
participant C as 客户端
participant H as 健康检查器
P->>R: 注册实例
C->>R: 拉取实例列表
R-->>C: 返回 A/B/C
C->>P: 发起请求
P-->>C: 返回结果
H->>P: 健康探测
P-->>H: 失败
H->>R: 更新实例状态为异常
C->>C: 本地熔断并摘除实例
C->>R: 下一轮刷新实例列表
这条链路里有两个关键事实:
- 注册中心最终一致,不等于客户端实时正确
- 客户端本地失败感知,往往比注册中心广播更快
所以工程上不能只依赖注册中心。客户端必须具备局部自愈能力。
现象复现:一个最小可运行案例
下面用 Python 写一个简化版示例,模拟:
- 两个服务实例
- 一个注册中心
- 一个客户端调用器
- 本地缓存
- 健康状态摘除
- 故障转移
这个例子不是生产级注册中心,但足够帮助你理解排障链路。
目录说明
registry.py:简化注册中心service_a.py:正常实例service_b.py:可模拟故障实例client.py:带本地缓存和故障转移的客户端
1) 注册中心
# registry.py
from flask import Flask, request, jsonify
import time
app = Flask(__name__)
services = {}
@app.route("/register", methods=["POST"])
def register():
data = request.json
service_name = data["service_name"]
instance = {
"id": data["id"],
"host": data["host"],
"port": data["port"],
"last_heartbeat": time.time(),
"status": "UP"
}
services.setdefault(service_name, {})
services[service_name][instance["id"]] = instance
return jsonify({"ok": True})
@app.route("/heartbeat", methods=["POST"])
def heartbeat():
data = request.json
service_name = data["service_name"]
instance_id = data["id"]
if service_name in services and instance_id in services[service_name]:
services[service_name][instance_id]["last_heartbeat"] = time.time()
services[service_name][instance_id]["status"] = "UP"
return jsonify({"ok": True})
return jsonify({"ok": False, "error": "instance not found"}), 404
@app.route("/services/<service_name>", methods=["GET"])
def get_services(service_name):
now = time.time()
result = []
for instance in services.get(service_name, {}).values():
if now - instance["last_heartbeat"] > 10:
instance["status"] = "DOWN"
result.append(instance)
return jsonify(result)
if __name__ == "__main__":
app.run(port=5000)
启动:
pip install flask requests
python registry.py
2) 服务实例 A:正常实例
# service_a.py
from flask import Flask, jsonify
import threading
import time
import requests
app = Flask(__name__)
REGISTRY = "http://127.0.0.1:5000"
SERVICE_NAME = "order-service"
INSTANCE_ID = "order-a"
PORT = 6001
def register():
requests.post(f"{REGISTRY}/register", json={
"service_name": SERVICE_NAME,
"id": INSTANCE_ID,
"host": "127.0.0.1",
"port": PORT
})
def heartbeat_loop():
while True:
try:
requests.post(f"{REGISTRY}/heartbeat", json={
"service_name": SERVICE_NAME,
"id": INSTANCE_ID
}, timeout=1)
except Exception:
pass
time.sleep(3)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "UP"})
@app.route("/query", methods=["GET"])
def query():
return jsonify({"instance": INSTANCE_ID, "result": "ok from A"})
if __name__ == "__main__":
register()
threading.Thread(target=heartbeat_loop, daemon=True).start()
app.run(port=PORT)
3) 服务实例 B:可模拟慢响应/故障
# service_b.py
from flask import Flask, jsonify, request
import threading
import time
import requests
import os
app = Flask(__name__)
REGISTRY = "http://127.0.0.1:5000"
SERVICE_NAME = "order-service"
INSTANCE_ID = "order-b"
PORT = 6002
FAIL_MODE = {"enabled": False}
def register():
requests.post(f"{REGISTRY}/register", json={
"service_name": SERVICE_NAME,
"id": INSTANCE_ID,
"host": "127.0.0.1",
"port": PORT
})
def heartbeat_loop():
while True:
try:
requests.post(f"{REGISTRY}/heartbeat", json={
"service_name": SERVICE_NAME,
"id": INSTANCE_ID
}, timeout=1)
except Exception:
pass
time.sleep(3)
@app.route("/health", methods=["GET"])
def health():
if FAIL_MODE["enabled"]:
return jsonify({"status": "DEGRADED"}), 500
return jsonify({"status": "UP"})
@app.route("/query", methods=["GET"])
def query():
if FAIL_MODE["enabled"]:
time.sleep(2.5)
return jsonify({"instance": INSTANCE_ID, "error": "simulated failure"}), 500
return jsonify({"instance": INSTANCE_ID, "result": "ok from B"})
@app.route("/toggle_fail", methods=["POST"])
def toggle_fail():
FAIL_MODE["enabled"] = not FAIL_MODE["enabled"]
return jsonify({"fail_mode": FAIL_MODE["enabled"]})
if __name__ == "__main__":
register()
threading.Thread(target=heartbeat_loop, daemon=True).start()
app.run(port=PORT)
4) 客户端:本地缓存、轮询、失败摘除、故障转移
# client.py
import requests
import time
from itertools import cycle
REGISTRY = "http://127.0.0.1:5000"
SERVICE_NAME = "order-service"
class ServiceClient:
def __init__(self):
self.instances = []
self.bad_instances = {}
self.index = 0
def refresh_instances(self):
resp = requests.get(f"{REGISTRY}/services/{SERVICE_NAME}", timeout=1)
all_instances = resp.json()
now = time.time()
available = []
for ins in all_instances:
addr = f"http://{ins['host']}:{ins['port']}"
if ins["status"] != "UP":
continue
if addr in self.bad_instances and self.bad_instances[addr] > now:
continue
available.append(addr)
self.instances = available
def pick_instance(self):
if not self.instances:
return None
instance = self.instances[self.index % len(self.instances)]
self.index += 1
return instance
def mark_bad(self, addr, cooldown=10):
self.bad_instances[addr] = time.time() + cooldown
def call(self):
self.refresh_instances()
if not self.instances:
raise Exception("no available instances")
tried = set()
max_retry = len(self.instances)
for _ in range(max_retry):
addr = self.pick_instance()
if not addr or addr in tried:
continue
tried.add(addr)
try:
resp = requests.get(f"{addr}/query", timeout=1)
resp.raise_for_status()
return resp.json()
except Exception as e:
print(f"[WARN] call failed on {addr}: {e}")
self.mark_bad(addr)
raise Exception("all instances failed")
if __name__ == "__main__":
client = ServiceClient()
while True:
try:
result = client.call()
print("[OK]", result)
except Exception as e:
print("[ERROR]", e)
time.sleep(2)
启动顺序:
python registry.py
python service_a.py
python service_b.py
python client.py
故障模拟:
curl -X POST http://127.0.0.1:6002/toggle_fail
你会看到:
service_b开始慢响应/报错client.py一旦探测失败,会把它临时标记为 bad- 后续请求自动切到
service_a - 冷却时间过去后,客户端会再尝试把它纳入可用列表
定位路径:线上排查应该怎么走
遇到“服务发现失效”或者“故障转移不生效”时,我建议按下面路径排查,别一上来就怀疑注册中心。
第一步:看错误分布,是全量失败还是部分失败
先回答几个问题:
- 是所有请求失败,还是只有一部分失败?
- 是固定接口失败,还是随机接口失败?
- 是所有客户端都失败,还是某个机房/某批实例失败?
结论通常很关键:
- 全量失败:更可能是注册中心不可达、DNS 失效、统一网关问题
- 部分失败:更可能是坏节点未摘除、本地缓存不一致、连接池复用旧连接
第二步:看实例视角,而不是只看服务视角
很多监控是按服务聚合的,比如“order-service 成功率 98%”。
这很容易掩盖问题,因为真正坏的可能只有某一台实例。
建议立刻拉出:
- 单实例错误率
- 单实例 P99 延迟
- 单实例连接数
- 单实例 CPU / Load / GC
- 单实例 readiness 状态变化
如果某个实例特别突出,基本就不是“服务整体设计问题”,而是实例剔除机制没跟上。
第三步:看控制面与数据面是否一致
这里是高频误区。
控制面
指注册中心、配置中心、服务治理平台看到的状态。
数据面
指真正的业务请求有没有打通。
经常出现:
- 注册中心显示实例
UP - 但业务接口
/query超时 - 或者 TLS 握手失败
- 或者依赖数据库失败,接口实际不可用
所以排查时一定要做“双看”:
- 查注册中心实例状态
- 从客户端机器上直接 curl 目标实例业务接口
第四步:核对超时、重试、摘除三个时间窗口
这个地方最容易造成“明明做了故障转移,但用户还是慢”。
重点看:
- 单次请求超时:例如 1 秒
- 最大重试次数:例如 2 次
- 实例熔断冷却时间:例如 10 秒
- 注册中心刷新周期:例如 30 秒
如果配置不协调,问题就会出现:
- 超时太长:切换慢
- 重试太多:放大故障
- 冷却太短:坏节点反复被放回
- 刷新太慢:客户端列表长期不一致
常见坑与排查
1. 只依赖注册中心摘除,不做客户端本地失败感知
现象:
- 节点已挂,但客户端仍然持续打过去
- 直到注册中心下一次刷新才恢复
原因:
- 客户端缓存更新滞后
- 没有本地熔断或失败摘除
止血方案:
- 客户端在请求失败后立即本地摘除
- 设置短期冷却时间,比如 10~30 秒
- 后续再通过健康探测或下一轮刷新恢复
2. 健康检查接口过于乐观
现象:
/health返回 200- 实际业务接口大量超时
原因:
- 健康检查只判断进程存活
- 没覆盖数据库、缓存、线程池、磁盘等关键依赖
止血方案:
- 把 readiness 和业务健康拆开
- readiness 失败时应立即停止接流量
- 不要把“服务活着”误当成“服务可用”
3. 发布时流量切入过早
现象:
- 新实例刚启动时错误率陡增
- 几分钟后又恢复正常
原因:
- 应用未完成预热就被注册
- JIT、缓存加载、连接池初始化都还没完成
止血方案:
- 延迟注册
- readiness 成功后再进入流量池
- 对新节点加 warm-up 权重,逐步放量
4. 重试策略没有区分错误类型
现象:
- 下游异常时,上游重试把整个系统打崩
- 日志里出现大量重复请求
原因:
- 对所有错误一律重试
- 连 4xx 业务错误也重试
- 非幂等写请求也重试
止血方案:
- 仅对网络抖动、连接失败、超时等可恢复错误重试
- 非幂等操作必须带幂等键
- 限制重试预算
5. 长连接/连接池没有及时清理坏连接
现象:
- 实例状态恢复后,仍有持续失败
- 或者实例已摘除,旧连接还在发请求
原因:
- HTTP keep-alive 连接复用
- 连接池未按实例状态驱逐连接
止血方案:
- 节点摘除时同步清理连接池
- 为连接池设置合理空闲超时
- 对请求失败的连接做快速失效处理
6. 脑裂式主备切换
这个更偏状态型服务,比如主从数据库、主备任务调度。
现象:
- 两个节点都认为自己是主
- 写流量分裂,数据不一致
原因:
- 故障检测不可靠
- 仲裁机制缺失
- 客户端持有过期主节点信息
止血方案:
- 用有仲裁能力的选主机制
- 切换前确认旧主 fencing
- 客户端不要长期缓存主节点地址不刷新
stateDiagram-v2
[*] --> PrimaryUp
PrimaryUp --> PrimarySuspect: 心跳超时
PrimarySuspect --> Failover: 多次探测失败
Failover --> NewPrimary: 完成选主与 fencing
NewPrimary --> RecoveryObserve: 旧主恢复
RecoveryObserve --> PrimaryUp: 状态一致并回归
安全/性能最佳实践
高可用设计不能只盯着“可用”,还要看安全和性能。否则很容易变成“虽然没挂,但又慢又不安全”。
安全最佳实践
1. 注册中心接口要鉴权
不要让任意实例都能注册自己。否则会出现:
- 恶意注册假实例
- 流量被导向错误节点
- 服务拓扑被污染
建议:
- 注册与心跳接口使用 mTLS 或 token 鉴权
- 对服务名、实例 ID 做白名单校验
- 记录注册来源 IP 和审计日志
2. 实例元数据不要暴露敏感信息
很多注册中心会存储 metadata,比如:
- 版本号
- 机房
- 权重
- 标签
但不要把这些也塞进去:
- 数据库密码
- 内网敏感路径
- 调试 token
3. 健康检查接口要分级开放
/health 适合内部探测,但不一定适合公网暴露。
对外只保留最小信息,避免泄露内部依赖结构。
性能最佳实践
1. 用增量更新优于全量拉取
如果实例很多,客户端频繁全量拉取列表会放大注册中心压力。
建议:
- 优先使用 watch / subscribe
- 做本地缓存版本号比对
- 大规模场景下使用增量变更推送
2. 刷新周期与故障检测周期不要混为一谈
很多人把“每 30 秒刷新一次服务列表”当成故障检测。
这太慢了。
更合理的做法:
- 注册中心列表刷新:秒级到十秒级
- 客户端请求失败感知:毫秒到秒级
- 独立健康探测:按业务特点设定
3. 负载均衡策略要贴合业务
常见策略:
- 轮询:简单,适合均匀实例
- 加权轮询:适合新老机器性能不同
- 最少连接:适合长连接场景
- 一致性哈希:适合会话保持、缓存路由
不要迷信某一种策略。
比如实例性能差异大时,纯轮询可能比随机还差。
4. 设置熔断恢复探针
实例被熔断后,不要等冷却时间结束就直接恢复全量流量。
更稳的方式是:
- 半开状态只放少量探针流量
- 探针成功后再逐步恢复
- 连续失败则继续熔断
一个更贴近生产的设计建议
如果你要在真实业务里落地,我建议按下面分层设计:
控制层
负责:
- 实例注册
- 元数据管理
- 状态同步
- 订阅通知
可选:
- Nacos
- Consul
- Eureka
- Kubernetes Service + EndpointSlice
数据层
负责:
- 本地缓存
- 负载均衡
- 请求超时
- 重试
- 熔断
- 连接池管理
常见做法:
- Java:Spring Cloud LoadBalancer / Dubbo / gRPC
- Go:内置 resolver + balancer
- Service Mesh:交给 Sidecar
保护层
负责:
- 限流
- 降级
- 熔断恢复
- 预热
- 灰度
一个经验判断是:
如果你的服务发现只解决“找到地址”,但没有解决“坏地址别打、恢复地址慢慢放量”,那它离高可用还差一大截。
止血方案:线上已经出问题时怎么办
如果你现在就在处理事故,不想先大改架构,可以按下面顺序止血:
优先级 1:快速摘除坏节点
- 手动下线错误实例
- 从注册中心剔除
- 让网关/客户端刷新实例列表
- 必要时重启连接池或 Sidecar
优先级 2:降低重试风暴
- 暂时把重试次数降到 0 或 1
- 缩短超时时间
- 对下游失败做快速失败
优先级 3:关闭未预热的新节点流量
- 将 readiness 调整为失败
- 暂停自动扩容引流
- 先做缓存/连接池预热
优先级 4:验证控制面与数据面一致
- 注册中心状态是否正确
- 客户端缓存是否刷新
- 实际业务接口是否可达
- 是否还存在旧连接复用
总结
高可用服务发现与故障转移,核心不是“有个注册中心”这么简单,而是要打通这几件事:
- 实例状态要真实
- 客户端缓存要及时
- 失败节点要快速摘除
- 恢复节点要谨慎回流
- 重试要有边界
- 健康检查要反映业务可用性,而不只是进程活着
如果你让我给中级开发者一个最实用的落地建议,我会给这 5 条:
- 客户端必须做本地失败摘除,别只等注册中心更新
- 把 liveness、readiness、business health 分开
- 重试只给幂等请求,并限制次数与总耗时
- 节点恢复时先探针、再放量,不要直接全量回切
- 排障时分开看控制面与数据面,别被“注册中心显示正常”骗了
最后强调一个边界条件:
如果你的系统是强状态型服务,比如主备数据库、分布式锁、任务调度主节点,那么“故障转移”不能只靠客户端重试和实例摘除,还必须引入可靠选主、仲裁、fencing 机制。否则可用性问题很容易演化成一致性事故。
高可用不是靠某一个组件达成的,它更像是一整条链路的协作结果。真正稳的系统,往往不是“永远不出错”,而是一出错就能快速识别、隔离并恢复。这才是服务发现和故障转移设计最值得投入的地方。