背景与问题
在单机时代,服务调用通常写死一个 IP 或域名就能跑起来;一旦进入集群架构,事情立刻复杂起来:
- 服务实例会动态扩缩容
- 节点会重启、漂移、临时失联
- 流量高峰时,某些实例会被打爆
- 注册中心短暂抖动,就可能引发整片调用超时
- 故障切换策略不合理,反而会把小故障放大成雪崩
很多团队一开始以为“上个注册中心 + 配个负载均衡”就够了,真正上线后才发现,问题大多不在“能不能发现服务”,而在“发现之后是否稳定、切换是否及时、失败后是否可控”。
我自己踩过一个很典型的坑:服务实例明明已经挂了,但客户端本地缓存还没刷新,结果 20% 的请求持续打到死节点;同时重试策略又过于激进,失败请求瞬间放大 3 倍,最后把正常实例也拖慢了。这类问题,本质上不是单点 bug,而是服务发现、负载均衡、健康检查、故障切换几块设计没有闭环。
这篇文章会从排障视角来讲这套链路,重点回答几个实际问题:
- 注册中心到底解决了什么,没解决什么?
- 客户端负载均衡和服务端负载均衡该怎么选?
- 节点故障时,为什么“自动切换”经常不自动?
- 出现调用超时、流量倾斜、节点摘除不及时,应该怎么定位?
- 一套最小可运行方案怎么搭起来?
现象复现:线上最常见的几类异常
先别急着讲原理,先看故障长什么样。下面这些现象,在集群环境里非常高频。
1. 明明有实例存活,请求却大量超时
常见原因:
- 注册中心里还有脏实例
- 客户端本地缓存未及时更新
- 健康检查只检查进程活着,没检查业务可用
- 连接池还持有已失效连接
2. 负载均衡不均,少数节点 CPU 飙高
常见原因:
- 轮询策略忽略了实例性能差异
- 长连接/会话粘滞导致流量倾斜
- 热点请求被 hash 到固定节点
- 慢实例没有被及时降权
3. 故障切换后恢复很慢
常见原因:
- 重试次数过多,形成请求风暴
- 熔断未开启,持续探测已故障节点
- 摘除策略太保守
- 恢复后没有预热,刚回来的实例被瞬间打满
4. 注册中心看起来正常,调用还是失败
常见原因:
- 服务注册名、分组、版本不一致
- 客户端订阅的是旧命名空间
- DNS / Sidecar / 代理层缓存不一致
- 业务超时小于发现链路更新时间,误判为“服务不可用”
核心原理
服务发现与负载均衡,其实是一个完整调用链,不是几个独立组件的拼装。
1. 服务发现的基本链路
服务发现一般包含几个角色:
- 服务提供者:启动后向注册中心注册自己
- 注册中心:保存实例列表、健康状态、元数据
- 服务消费者:订阅服务列表,维护本地缓存
- 负载均衡器:从可用实例中选一个目标实例
- 健康检查机制:决定实例是否应该继续对外服务
flowchart LR
A[服务提供者实例A] --> R[注册中心]
B[服务提供者实例B] --> R
C[服务提供者实例C] --> R
R --> D[消费者订阅实例列表]
D --> L[本地负载均衡]
L --> A
L --> B
L --> C
要点是:注册中心只负责“告诉你有哪些实例”,不负责保证你“每次都打到健康实例”。
后者还依赖客户端缓存刷新、负载均衡算法、超时控制、失败重试、熔断摘除。
2. 客户端负载均衡 vs 服务端负载均衡
这是设计时经常纠结的一点。
客户端负载均衡
调用方从注册中心拉取实例列表,在本地挑选目标实例。
优点:
- 少一跳,性能更好
- 可结合调用方视角做熔断、降权、就近访问
- 实例变更感知更直接
缺点:
- 逻辑分散在每个客户端
- 多语言统一治理更难
- 客户端实现不一致时,行为会漂移
服务端负载均衡
调用方只访问一个统一入口,比如 Nginx、LVS、Envoy、网关。
优点:
- 策略集中管理
- 多语言接入方便
- 便于统一观测与治理
缺点:
- 多一跳
- 负载均衡层本身要高可用
- 如果上游只看“TCP 活着”,可能误把坏实例当好实例
flowchart TD
subgraph ClientLB[客户端负载均衡]
C1[消费者] --> L1[本地实例选择]
L1 --> S1[服务实例]
L1 --> S2[服务实例]
end
subgraph ServerLB[服务端负载均衡]
C2[消费者] --> G[网关/代理/LB]
G --> S3[服务实例]
G --> S4[服务实例]
end
实战建议
大多数中大型系统里,常见的是混合模式:
- 南北流量:网关/反向代理做服务端负载均衡
- 东西流量:微服务 SDK 或 Sidecar 做客户端负载均衡
这样既保留治理能力,也兼顾内部调用效率。
3. 健康检查不是“活着就算健康”
健康检查至少分三层:
- 进程存活:进程是否还在
- 网络可达:端口是否连通
- 业务可用:依赖是否正常、线程池是否耗尽、数据库是否超时
真正线上最容易误判的,是只做了前两层。
比如 Java 进程还活着,但线程池打满、GC 卡顿严重、数据库连接池耗尽,这时它“在线”,但已经不适合接流量了。
4. 故障切换的关键不是“切”,而是“有序地切”
一个好的故障切换策略,至少要回答:
- 多久判定一个实例故障?
- 故障后立即摘除,还是降权?
- 是否允许重试?重试几次?
- 重试是否换实例?
- 恢复后如何半开探测?
- 实例恢复是否需要预热?
stateDiagram-v2
[*] --> Healthy
Healthy --> Suspect: 连续失败阈值触发
Suspect --> Unhealthy: 健康检查失败
Suspect --> Healthy: 探测成功
Unhealthy --> HalfOpen: 冷却时间到
HalfOpen --> Healthy: 少量探测成功
HalfOpen --> Unhealthy: 探测再次失败
定位路径:排障时不要一上来就抓包
服务发现和负载均衡问题,建议按调用链分层排查。这样效率最高。
第 1 层:注册信息是否正确
先确认:
- 服务是否真的注册成功
- 服务名、分组、版本是否一致
- 元数据是否符合路由条件
- 注册中心中实例状态是否健康
如果这层不对,后面都白查。
第 2 层:消费者是否拿到最新实例列表
重点看:
- 本地缓存更新时间
- 订阅事件是否丢失
- 长连接 watch 是否断开
- 是否有过期缓存兜底逻辑
很多问题都出在这里:注册中心已经摘除了实例,但客户端还在用旧列表。
第 3 层:负载均衡算法是否合理
重点看:
- 是轮询、随机、加权、最少连接还是一致性哈希
- 权重是否动态调整
- 慢节点是否被持续选中
- 是否存在热点 key 导致 hash 倾斜
第 4 层:连接与重试策略是否放大了故障
重点看:
- 连接池是否复用失效连接
- 请求超时是否过长
- 重试是否仍命中同一实例
- 总超时时间是否被重试放大
第 5 层:故障摘除与恢复机制是否生效
重点看:
- 连续失败阈值是否太高
- 熔断窗口是否过长或过短
- 半开探测是否配置合理
- 恢复后是否瞬时全量放流
实战代码(可运行)
下面用 Python 写一个简化版服务注册中心 + 客户端负载均衡 + 故障切换示例。
它不是生产级框架,但足够演示核心机制,且可以直接运行。
1. 注册中心与服务实例
保存为 registry_demo.py:
from flask import Flask, request, jsonify
from threading import Lock
import time
app = Flask(__name__)
services = {}
lock = Lock()
TTL_SECONDS = 10
@app.route("/register", methods=["POST"])
def register():
data = request.json
service_name = data["service_name"]
instance_id = data["instance_id"]
host = data["host"]
port = data["port"]
weight = data.get("weight", 1)
with lock:
services.setdefault(service_name, {})
services[service_name][instance_id] = {
"instance_id": instance_id,
"host": host,
"port": port,
"weight": weight,
"last_heartbeat": time.time(),
"status": "UP"
}
return jsonify({"message": "registered"})
@app.route("/heartbeat", methods=["POST"])
def heartbeat():
data = request.json
service_name = data["service_name"]
instance_id = data["instance_id"]
with lock:
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({"message": "heartbeat received"})
return jsonify({"message": "instance not found"}), 404
@app.route("/instances/<service_name>", methods=["GET"])
def get_instances(service_name):
now = time.time()
alive = []
with lock:
for instance in services.get(service_name, {}).values():
if now - instance["last_heartbeat"] <= TTL_SECONDS:
alive.append(instance)
else:
instance["status"] = "DOWN"
return jsonify(alive)
if __name__ == "__main__":
app.run(port=5000)
安装依赖:
pip install flask requests
启动注册中心:
python registry_demo.py
2. 启动两个服务实例
保存为 service_instance.py:
from flask import Flask, jsonify
import requests
import threading
import time
import sys
import random
app = Flask(__name__)
REGISTRY = "http://127.0.0.1:5000"
SERVICE_NAME = "order-service"
PORT = int(sys.argv[1])
INSTANCE_ID = f"order-{PORT}"
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "UP", "instance_id": INSTANCE_ID})
@app.route("/work", methods=["GET"])
def work():
delay = random.choice([0.05, 0.1, 0.2, 1.5]) # 偶尔模拟慢请求
time.sleep(delay)
return jsonify({
"instance_id": INSTANCE_ID,
"port": PORT,
"delay": delay
})
def register():
requests.post(f"{REGISTRY}/register", json={
"service_name": SERVICE_NAME,
"instance_id": INSTANCE_ID,
"host": "127.0.0.1",
"port": PORT,
"weight": 1
})
def heartbeat_loop():
while True:
try:
requests.post(f"{REGISTRY}/heartbeat", json={
"service_name": SERVICE_NAME,
"instance_id": INSTANCE_ID
}, timeout=2)
except Exception:
pass
time.sleep(3)
if __name__ == "__main__":
register()
t = threading.Thread(target=heartbeat_loop, daemon=True)
t.start()
app.run(port=PORT)
分别启动两个实例:
python service_instance.py 6001
python service_instance.py 6002
3. 客户端负载均衡 + 故障切换
保存为 client_lb.py:
import requests
import random
import time
REGISTRY = "http://127.0.0.1:5000"
SERVICE_NAME = "order-service"
class InstanceState:
def __init__(self, host, port, weight=1):
self.host = host
self.port = port
self.weight = weight
self.fail_count = 0
self.open_until = 0
@property
def address(self):
return f"http://{self.host}:{self.port}"
def available(self):
return time.time() >= self.open_until
class ClientLB:
def __init__(self):
self.instances = {}
def refresh_instances(self):
resp = requests.get(f"{REGISTRY}/instances/{SERVICE_NAME}", timeout=2)
data = resp.json()
new_instances = {}
for item in data:
key = f"{item['host']}:{item['port']}"
old = self.instances.get(key)
if old:
new_instances[key] = old
else:
new_instances[key] = InstanceState(
item["host"], item["port"], item.get("weight", 1)
)
self.instances = new_instances
def choose_instance(self):
candidates = [ins for ins in self.instances.values() if ins.available()]
if not candidates:
return None
weighted = []
for ins in candidates:
weighted.extend([ins] * ins.weight)
return random.choice(weighted)
def call(self):
self.refresh_instances()
tried = set()
for _ in range(len(self.instances)):
ins = self.choose_instance()
if not ins:
break
key = f"{ins.host}:{ins.port}"
if key in tried:
continue
tried.add(key)
try:
resp = requests.get(f"{ins.address}/work", timeout=0.8)
resp.raise_for_status()
ins.fail_count = 0
return resp.json()
except Exception as e:
ins.fail_count += 1
if ins.fail_count >= 2:
ins.open_until = time.time() + 5
print(f"[WARN] call failed: {key}, fail_count={ins.fail_count}, err={e}")
raise RuntimeError("all instances unavailable")
if __name__ == "__main__":
client = ClientLB()
for i in range(20):
try:
result = client.call()
print(f"[OK] {result}")
except Exception as e:
print(f"[ERROR] {e}")
time.sleep(1)
运行:
python client_lb.py
这个示例演示了什么?
- 服务实例会注册到注册中心
- 注册中心通过心跳维护可用实例
- 客户端定期拉取实例列表
- 客户端本地做简单加权随机负载均衡
- 请求失败达到阈值后,临时熔断该实例 5 秒
- 调用失败时会自动尝试其他实例
这已经是一个最小闭环了。
常见坑与排查
下面这部分是 troubleshooting 文章的核心。很多“设计问题”最后都是通过排查暴露出来的。
坑 1:注册中心摘除了实例,但客户端还在访问
现象
- 注册中心页面上实例已下线
- 客户端日志仍持续访问该节点
- 错误率呈周期性波动
原因
- 客户端实例列表有本地缓存
- watch 事件断开后没有及时重连
- 刷新周期过长
- 连接池未清理旧连接
排查方法
- 打印消费者当前实例列表
- 比对注册中心返回列表与本地缓存
- 检查订阅链路是否断开
- 查看连接池中是否仍保留旧地址连接
止血方案
- 手动缩短缓存 TTL
- 强制刷新实例列表
- 清理连接池
- 临时通过网关/防火墙摘流
坑 2:重试机制把故障放大了
现象
- 某实例异常后,整体 QPS 不降反升
- 下游 CPU、线程池迅速打满
- 上游看似“做了容错”,实际错误更多
原因
- 每次失败都立即重试
- 重试仍命中同一实例
- 超时时间过长,导致线程阻塞
- 上游多个中间件叠加重试
我见过最夸张的一次是:SDK 重试 2 次、网关重试 2 次、业务代码再 catch 后重试 1 次。一个原始请求,最后打成了 6 次下游调用。
排查方法
- 统计单个请求链路上的总尝试次数
- 查看重试是否跨实例
- 检查是否存在多层重试叠加
- 对比错误率与请求量变化曲线
止血方案
- 只允许一层负责重试
- 重试必须换实例
- 失败快速返回,不做无意义等待
- 总超时时间要小于调用链预算
坑 3:健康检查接口“太健康”
现象
/health一直返回 200- 实际业务接口却大量超时
- 负载均衡器一直不摘除实例
原因
- 健康检查只看进程活着
- 未检测数据库、缓存、线程池、磁盘、依赖超时
- 健康接口与真实流量路径差异太大
排查方法
- 检查健康检查逻辑到底做了什么
- 比对健康接口耗时与真实业务耗时
- 验证依赖异常时健康状态是否变化
- 看是否有“假健康”缓存
止血方案
- 增加业务就绪检查
- 区分 liveness 与 readiness
- 线程池/连接池耗尽时主动降级为不可接流量
坑 4:负载算法选对了,场景却没选对
现象
- 轮询明明很公平,业务还是慢
- 一致性哈希明明稳定,却总有热点
- 最少连接看起来聪明,实际抖动大
原因
不同算法适用条件不同:
- 轮询:实例能力接近
- 加权轮询/随机:实例性能不同
- 最少连接:请求耗时差异大
- 一致性哈希:需要会话保持或缓存亲和
如果你把热点 key 场景交给一致性哈希,单节点热点会非常明显。
止血方案
- 热点场景增加 key 打散
- 慢实例动态降权
- 混合策略:先按权重过滤,再局部最少连接
安全/性能最佳实践
这部分不只是“锦上添花”,很多稳定性问题最后都和安全、性能边界有关。
1. 注册中心不要裸奔
至少做到:
- 注册与发现接口做认证鉴权
- 只允许可信服务注册
- 敏感元数据不要明文暴露
- 管理接口与业务接口分离
- 限制非法实例伪造注册
如果注册中心被恶意注册一个“假实例”,流量会被直接引到错误节点,后果比单纯宕机更严重。
2. 服务实例要有明确的上下线流程
推荐顺序:
- 实例先进入“停止接新流量”状态
- 从注册中心摘除或降权
- 等待连接排空
- 再真正退出进程
这样可以避免进程刚退出,调用方还没感知到,导致大量请求撞死。
sequenceDiagram
participant Admin as 发布系统
participant Instance as 服务实例
participant Registry as 注册中心
participant Client as 调用方
Admin->>Instance: 标记下线/只读
Instance->>Registry: 注销或降权
Client->>Registry: 拉取最新实例列表
Client->>Instance: 停止新请求
Instance-->>Client: 处理存量连接
Admin->>Instance: 终止进程
3. 超时、重试、熔断必须成套设计
建议原则:
- 超时优先于重试
- 重试次数尽量少,通常 1 次足够
- 重试必须切换实例
- 熔断窗口要短,恢复时半开探测
- 总超时要小于上游 SLA 预算
一个简单参考值,不是绝对标准:
- 单次 RPC 超时:200ms ~ 1000ms
- 重试次数:0 ~ 1
- 连续失败熔断阈值:3 ~ 5
- 熔断冷却时间:5s ~ 30s
边界条件是:
如果下游请求本来就是长耗时任务,就不能机械套用短超时,否则会把正常请求误伤。
4. 避免“惊群式恢复”
实例恢复后,不要立刻恢复满量流量。建议:
- 先半开探测
- 恢复初期低权重引流
- 观察成功率、RT、CPU、连接数
- 稳定后再逐步恢复权重
5. 观测指标要覆盖整个链路
至少采集这些指标:
注册中心维度
- 实例注册数
- 实例变更频率
- 心跳超时数
- watch 连接数
- 配置/注册请求延迟
客户端维度
- 本地缓存实例数
- 实例选择次数
- 按实例维度成功率/错误率
- 熔断次数、半开次数
- 重试次数与重试成功率
服务实例维度
- QPS、RT、P99
- CPU、内存、GC
- 线程池队列长度
- 连接池使用率
- 下游依赖错误率
没有这些指标时,很多排障最后只能靠猜。
方案落地建议:怎么从“能用”走向“稳定”
如果你现在手里有一个中等规模集群,我建议按下面顺序建设,而不是一上来追求“全家桶”。
第一阶段:先保证基本可发现
做到:
- 服务实例自动注册
- 客户端自动拉取列表
- 健康检查能摘除死亡实例
这解决的是“能连上”。
第二阶段:补齐调用稳定性
做到:
- 本地负载均衡
- 请求超时
- 有边界的重试
- 熔断与半开恢复
这解决的是“故障不会快速蔓延”。
第三阶段:做动态治理
做到:
- 动态权重
- 慢节点降权
- 就近路由 / 同机房优先
- 灰度发布与分组路由
这解决的是“高峰期也能稳住”。
第四阶段:完善观测与应急
做到:
- 调用链追踪
- 按实例维度的监控与告警
- 下线排空机制
- 一键摘流、强制降权、只读止血
这解决的是“出问题时能快速止血”。
总结
服务发现、负载均衡、故障切换,本质上是一条连续的稳定性链路:
- 注册中心告诉你“谁在线”
- 负载均衡决定你“打给谁”
- 健康检查决定“谁不该接流量”
- 熔断与重试决定“失败后怎么办”
- 上下线流程决定“变更时会不会抖”
真正的难点,不是选 Nacos、Consul、Eureka 还是 ZooKeeper,也不是轮询、随机、一致性哈希这些算法名词本身;难点在于这些机制是否形成闭环。
如果你想把方案落地得更稳,我给三个可执行建议:
- 先把重试收紧:宁可少重试,也别多层叠加。
- 把健康检查做真实:至少区分存活检查和就绪检查。
- 让实例恢复有节奏:半开探测 + 低权重预热,别一恢复就全量流量灌进去。
最后给一个判断边界:
如果你的系统规模还小、实例数不多,没必要一开始把治理做得特别复杂;但只要进入多实例、频繁扩缩容、跨机房或高并发阶段,服务发现与负载均衡就不能只停留在“能跑”,一定要按稳定性工程来设计。