背景与问题
在单机时代,服务地址通常写死在配置里:db.prod.local:3306、api.internal:8080。这种方式简单直接,但一旦进入集群架构,问题会立刻冒出来:
- 实例数量动态变化,IP 不再稳定
- 节点可能随时故障,客户端不能继续打到坏节点
- 新版本发布时,需要平滑切换流量,而不是“全量一刀切”
- 多机房、多可用区下,流量最好优先走近端,减少延迟和跨区成本
所以,集群中的“访问一个服务”实际上分成了三件事:
- 服务发现:我怎么知道现在有哪些节点可用?
- 健康检查:我怎么确认这些节点真的还能接请求?
- 负载均衡:我应该把请求分配给哪个节点?
这三个环节是连在一起的。很多线上故障并不是某一个组件挂了,而是“注册还在、健康已坏、流量还在打”,最后把局部故障放大成全局故障。我自己做过几次线上切换后,最大的感受是:服务发现不是一个表,负载均衡也不是一个算法,它们本质上是一套状态传播和流量控制系统。
本文从架构视角,把这套链路串起来,并给一个可运行的实战代码示例。
先建立整体认知:请求是怎么走的
下面先看一个典型链路:
flowchart LR
A[服务实例启动] --> B[向注册中心注册]
B --> C[注册中心维护实例列表]
D[客户端/网关] --> E[从注册中心拉取或订阅服务列表]
E --> F[本地缓存可用节点]
F --> G[负载均衡选一个节点]
G --> H[发起请求]
I[健康检查器] --> C
C --> E
J[节点故障/摘除] --> C
K[新节点上线] --> C
这个过程里最容易被忽略的点有两个:
- 注册信息不是实时真相,它只是“最近一次已知状态”
- 健康状态必须参与流量决策,否则负载均衡只是“均匀地打错目标”
核心原理
1. 节点注册:谁来告诉系统“我还活着”
服务实例启动后,通常会把自己的信息注册到注册中心,典型字段包括:
- 服务名:
order-service - 实例 ID:
order-10.0.1.23-8080 - 地址:
10.0.1.23:8080 - 元数据:版本、机房、权重、协议
- TTL 或心跳时间
注册方式常见有两类:
客户端注册
由服务实例自己在启动时向注册中心上报。
优点:
- 逻辑直接
- 注册信息完整,业务元数据更容易带上
缺点:
- 业务 SDK 耦合注册中心
- 应用异常时可能还来得及“活着注册,死前没注销”
平台侧注册
例如 K8s 中由控制平面、Sidecar 或代理感知实例状态并注册。
优点:
- 应用无感知
- 统一治理能力更强
缺点:
- 与平台绑定更紧
- 业务元数据透传有时不够灵活
注册不等于可用
一个节点注册成功,只能说明“它曾经上线过”,不能说明“它现在能处理请求”。这也是为什么服务发现系统一定要和健康检查联动。
2. 健康检查:活着,不等于能服务
健康检查常见分三层:
存活检查(Liveness)
判断进程是否还活着。
例如:进程没死、端口还能连上。
适合回答的问题:这个实例是不是已经彻底挂了?
就绪检查(Readiness)
判断实例是否已经准备好接收流量。
例如:应用启动完成、缓存预热结束、数据库连接正常。
适合回答的问题:这个实例现在能不能接流量?
深度健康检查(Dependency Health)
检查关键依赖是否正常。
例如:数据库超时、消息队列堆积、线程池耗尽。
适合回答的问题:这个实例虽然还活着,但继续给它流量会不会出事?
很多团队一开始只做“HTTP 200 就算健康”,上线后才发现根本不够。比如:
- Web 线程还能返回
/health,但业务线程池已经满了 - 进程没死,但数据库连接池已经耗尽
- 节点 GC 抖动严重,请求 RT 已经失控
所以,健康检查的设计目标不是证明“我没死”,而是证明“我值得继续接流量”。
3. 服务发现:推模式、拉模式与本地缓存
客户端获取服务列表,通常有两种方式:
拉模式
客户端定期从注册中心拉取最新节点列表。
优点:
- 实现简单
- 对注册中心压力可控
缺点:
- 状态传播有延迟
- 故障节点可能在一段时间内仍被访问
推模式
注册中心在节点变化时主动通知客户端。
优点:
- 变更更快传播
- 故障摘除更及时
缺点:
- 客户端连接管理复杂
- 大规模场景下推送风暴要仔细设计
本地缓存是必须的
不管推还是拉,客户端几乎都会保留一份本地缓存。否则一旦注册中心抖动,业务请求也会跟着抖。
但缓存也引出一个经典问题:缓存过期和状态漂移。
所以常见做法是:
- 注册中心提供变更版本号
- 客户端只增量更新
- 节点失败后做本地熔断/临时摘除
- 缓存失效时保留“最后一份可用列表”,避免直接雪崩
4. 负载均衡:不是平均分配,而是按目标分配
常见算法:
轮询(Round Robin)
按顺序依次选节点。
适合:
- 节点能力相近
- 请求耗时比较均匀
加权轮询(Weighted Round Robin)
按节点权重分流,适合不同规格实例混部。
最少连接(Least Connections)
优先选择当前连接数少的节点。
适合:
- 长连接场景
- 请求执行时间差异较大
一致性哈希(Consistent Hashing)
同一个 key 尽量路由到同一节点。
适合:
- 会话保持
- 缓存命中优化
负载均衡一定要结合健康状态
一个最常见的误区是:
“我已经有轮询算法了,所以流量很均匀。”
但如果实例列表里混入了半死不活的节点,轮询只会把错误均匀扩散。
因此生产环境里常见的选择顺序其实是:
- 先过滤不健康节点
- 再按机房/版本/标签过滤
- 最后再执行负载均衡算法
方案对比与取舍分析
方案一:客户端负载均衡
由调用方自己拿服务列表,并在本地选目标节点。
优点
- 少一跳,性能好
- 客户端可按自身需求做路由策略
- 容易实现同机房优先、灰度优先
缺点
- 各语言 SDK 都要维护
- 服务治理能力分散
- 客户端版本不统一时,策略难收敛
适用场景
- 内部微服务调用
- 团队有较强基础设施能力
- 多语言生态可控
方案二:服务端负载均衡
客户端统一打到代理层,例如 Nginx、Envoy、LVS、网关。
优点
- 治理策略集中
- 客户端简单
- 统一接入认证、限流、熔断、观测
缺点
- 多一跳
- 代理层容量和高可用要求更高
- 某些业务级路由上下文不易透传
适用场景
- 北南向流量
- 对接外部调用方
- 希望集中治理
实际落地建议
大多数中大型系统,最终会是混合模式:
- 内部服务调用:客户端服务发现 + 本地负载均衡
- 外部入口流量:网关/代理统一负载均衡
- 跨地域或多活调度:DNS、全局流量调度、地域权重控制
一个实战架构:从注册到流量切换
这里给一个比较稳妥、也容易落地的设计:
- 服务实例启动后注册到注册中心
- 注册中心维护实例 TTL 和元数据
- 客户端每 5 秒拉取节点列表,并本地缓存
- 客户端对请求失败的节点做临时摘除
- 负载均衡采用“健康优先 + 加权随机”
- 发布时通过权重逐步放量,做到平滑切换
下面这张时序图会更直观:
sequenceDiagram
participant S as 服务实例
participant R as 注册中心
participant C as 客户端
participant LB as 本地负载均衡器
S->>R: 注册实例(地址、版本、权重、TTL)
S->>R: 定时心跳续租
C->>R: 拉取实例列表
R-->>C: 返回健康实例列表
C->>LB: 更新本地缓存
C->>LB: 发起一次请求
LB-->>C: 选择一个节点
C->>S: 请求服务
alt 请求失败
C->>LB: 标记节点短暂失效
else 请求成功
C->>LB: 更新成功统计
end
实战代码(可运行)
下面我用 Python 写一个简化版示例,模拟:
- 两个服务节点启动并注册
- 注册中心维护实例与心跳过期
- 客户端周期性拉取服务列表
- 本地负载均衡根据健康状态和权重选节点
- 节点失败后自动流量切换
代码可以直接运行,便于理解整条链路。
import time
import random
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import urlopen
from urllib.error import URLError
import json
class Registry:
def __init__(self):
self.instances = {}
self.lock = threading.Lock()
def register(self, service_name, instance_id, host, port, weight=1, ttl=10):
with self.lock:
self.instances[instance_id] = {
"service_name": service_name,
"instance_id": instance_id,
"host": host,
"port": port,
"weight": weight,
"ttl": ttl,
"last_heartbeat": time.time(),
"status": "UP"
}
def heartbeat(self, instance_id):
with self.lock:
if instance_id in self.instances:
self.instances[instance_id]["last_heartbeat"] = time.time()
self.instances[instance_id]["status"] = "UP"
def mark_down_expired(self):
while True:
now = time.time()
with self.lock:
for instance in self.instances.values():
if now - instance["last_heartbeat"] > instance["ttl"]:
instance["status"] = "DOWN"
time.sleep(1)
def discover(self, service_name):
with self.lock:
return [
v.copy()
for v in self.instances.values()
if v["service_name"] == service_name and v["status"] == "UP"
]
class ServiceHandler(BaseHTTPRequestHandler):
instance_name = "unknown"
fail_ratio = 0.0
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
return
if self.path == "/work":
if random.random() < self.fail_ratio:
self.send_response(500)
self.end_headers()
self.wfile.write(f"{self.instance_name} failed".encode())
else:
self.send_response(200)
self.end_headers()
self.wfile.write(f"response from {self.instance_name}".encode())
return
self.send_response(404)
self.end_headers()
def run_service(port, instance_name, fail_ratio):
class CustomHandler(ServiceHandler):
pass
CustomHandler.instance_name = instance_name
CustomHandler.fail_ratio = fail_ratio
server = HTTPServer(("127.0.0.1", port), CustomHandler)
print(f"{instance_name} started at {port}")
server.serve_forever()
class DiscoveryClient:
def __init__(self, registry, service_name):
self.registry = registry
self.service_name = service_name
self.local_cache = []
self.temp_down = {}
self.lock = threading.Lock()
def refresh(self):
while True:
instances = self.registry.discover(self.service_name)
with self.lock:
now = time.time()
self.local_cache = [
i for i in instances
if i["instance_id"] not in self.temp_down or self.temp_down[i["instance_id"]] < now
]
time.sleep(5)
def mark_temp_down(self, instance_id, cooldown=5):
with self.lock:
self.temp_down[instance_id] = time.time() + cooldown
def choose_instance(self):
with self.lock:
if not self.local_cache:
return None
weighted = []
for inst in self.local_cache:
weighted.extend([inst] * inst["weight"])
return random.choice(weighted)
def heartbeat_loop(registry, instance_id, stop_event):
while not stop_event.is_set():
registry.heartbeat(instance_id)
time.sleep(2)
def invoke(client):
inst = client.choose_instance()
if not inst:
print("no available instance")
return
url = f"http://{inst['host']}:{inst['port']}/work"
try:
with urlopen(url, timeout=1) as resp:
body = resp.read().decode()
print(f"[OK] hit {inst['instance_id']} -> {body}")
except Exception as e:
print(f"[FAIL] hit {inst['instance_id']} -> {e}")
client.mark_temp_down(inst["instance_id"])
def main():
registry = Registry()
# 启动服务实例
threading.Thread(target=run_service, args=(8001, "node-1", 0.0), daemon=True).start()
threading.Thread(target=run_service, args=(8002, "node-2", 0.5), daemon=True).start()
# 注册到注册中心
registry.register("demo-service", "node-1", "127.0.0.1", 8001, weight=3, ttl=6)
registry.register("demo-service", "node-2", "127.0.0.1", 8002, weight=1, ttl=6)
# 注册中心后台清理过期实例
threading.Thread(target=registry.mark_down_expired, daemon=True).start()
# 心跳线程
stop_event_1 = threading.Event()
stop_event_2 = threading.Event()
threading.Thread(target=heartbeat_loop, args=(registry, "node-1", stop_event_1), daemon=True).start()
threading.Thread(target=heartbeat_loop, args=(registry, "node-2", stop_event_2), daemon=True).start()
# 客户端服务发现
client = DiscoveryClient(registry, "demo-service")
threading.Thread(target=client.refresh, daemon=True).start()
# 模拟请求
for i in range(20):
if i == 10:
print("\n--- simulate node-2 heartbeat lost ---\n")
stop_event_2.set()
invoke(client)
time.sleep(1)
if __name__ == "__main__":
main()
运行后你会看到什么
这个示例里:
node-1权重更高,正常情况下会接到更多流量node-2有 50% 概率返回失败,客户端会把它临时摘除- 第 10 次请求后,
node-2停止发送心跳,TTL 过期后注册中心会将其标记为DOWN - 客户端下一轮刷新后,就不会再选中它
这其实就是一条最小可用链路:
注册 -> 续约 -> 发现 -> 负载均衡 -> 失败摘除 -> 流量切换
流量切换设计:别只想着“切过去”,要想着“怎么退回来”
服务上线、灰度、迁移机房时,很多问题出在流量切换策略太粗暴。比较稳妥的做法是:
1. 新节点先注册,但不立即接全量流量
可以通过元数据控制:
weight=0:先注册但不接流量- 预热完成后逐步加权:1 -> 5 -> 20 -> 100
2. 把“注册成功”和“可接流量”分开
一个实例启动后可能需要:
- JVM 预热
- 连接池建立
- 缓存加载
- 路由规则同步
所以不要“进程起来就立刻接流量”。
我更推荐把状态拆成:
STARTINGREADYDRAININGDOWN
如下图:
stateDiagram-v2
[*] --> STARTING
STARTING --> READY: 预热完成/就绪检查通过
READY --> DRAINING: 发布下线/人工摘流
DRAINING --> DOWN: 连接耗尽/超时退出
READY --> DOWN: 健康检查失败
DOWN --> STARTING: 实例重启
3. 下线时要先摘流,再停机
正确顺序一般是:
- 节点进入
DRAINING - 注册中心或负载均衡层停止分配新请求
- 等待存量连接/请求执行完成
- 再真正停进程
如果反过来先停机,客户端就只能通过超时和重试来“感知下线”,这会直接放大延迟。
4. 重试要有限制,否则切换会变成风暴
请求失败后重试是合理的,但要注意:
- 只对幂等请求重试
- 限制最大重试次数
- 避免重试到同一实例
- 给重试加退避和抖动
- 配合超时设置,否则整体 RT 会被拉长
容量估算:别让注册中心变成隐形单点
很多团队把业务服务做成集群了,却忘了注册中心、网关、配置中心本身也是基础设施服务,也会成为瓶颈。
一个简单估算思路
假设:
- 2000 个实例
- 每个实例每 5 秒发送一次心跳
- 每个客户端每 10 秒拉取一次服务列表
- 500 个客户端进程
那么注册中心每秒大致要处理:
心跳写入
2000 / 5 = 400 QPS
服务发现读取
500 / 10 = 50 QPS
如果再考虑:
- 多个服务
- 多个环境
- 推送通知
- 健康检查写状态
- 控制台查询
- 灰度元数据变更
实际压力会更高。
所以注册中心至少要具备:
- 多副本高可用
- 数据一致性策略
- 限流与隔离
- 本地缓存兜底
- 变更风暴保护
边界条件也要讲清楚:
如果你的系统规模还很小,十几个服务实例,用一层 Nginx + 静态配置都未必不行。不要为了“像微服务”而过度设计。
常见坑与排查
下面这些坑我基本都见过,排查时非常有共性。
坑 1:节点明明挂了,但流量还在打
常见原因
- TTL 过长,摘除太慢
- 客户端缓存刷新周期过长
- 只看注册状态,不看探活结果
- 长连接池里还保留着旧连接
排查方法
- 看注册中心中的实例最后心跳时间
- 看客户端本地实例列表是否已更新
- 看是否存在连接复用导致的“假摘除”
- 抓请求日志,确认失败请求集中在哪些节点
坑 2:健康检查通过,但业务请求大量超时
常见原因
/health只检查进程,不检查关键依赖- 健康检查接口太轻量,无法反映真实负载
- 检查周期过长,状态滞后
排查方法
- 对比健康检查 RT 与真实业务 RT
- 查看线程池、连接池、队列积压
- 检查数据库、Redis、MQ 等依赖状态
- 看是否发生 Full GC 或 CPU 抢占
坑 3:重试机制把故障放大
现象
一个节点超时,客户端全都在重试,结果把剩余健康节点也打满。
排查方法
- 看请求链路中的总超时是否合理
- 看重试次数是否过多
- 看是否存在“代理重试 + SDK 重试 + 业务重试”叠加
- 看重试是否带随机退避
我当时踩过一个坑:网关重试 2 次,SDK 重试 2 次,业务方又自己循环调了 3 次。理论上一条失败请求,最后变成了十几次下游访问,系统直接雪崩。
坑 4:发布时抖动严重
常见原因
- 新节点未预热就接流量
- 一次性全量切换
- 下线节点未做连接排空
- 客户端缓存更新不一致
排查方法
- 查看实例状态转换日志
- 查看新节点启动后前几分钟 RT、错误率
- 检查是否有
drain流程 - 对比不同调用方拿到的实例版本号
安全/性能最佳实践
安全方面
1. 注册接口要鉴权
不要让任意进程都能往注册中心写实例。否则轻则污染服务列表,重则形成流量劫持。
建议:
- 实例注册使用双向 TLS 或签名认证
- 限制服务名与命名空间
- 审计注册、摘除、权重变更操作
2. 防止伪造健康状态
如果健康上报来自客户端自身,要防止实例“假装健康”。
建议:
- 平台侧探活和实例侧心跳结合
- 对关键依赖做独立观测
- 不把单一信号当成绝对真相
3. 灰度元数据变更要可审计
版本、权重、标签一旦改错,影响的是流量分配。
所以必须保留:
- 谁改的
- 何时改的
- 改了什么
- 是否支持一键回滚
性能方面
1. 客户端优先使用本地缓存
不要每次请求都查注册中心。
服务发现是控制面数据,不应出现在数据面高频路径上。
2. 健康检查要轻重分离
建议拆成:
- 高频轻量探活:端口、基础接口
- 低频深度检查:依赖、资源、慢查询
否则健康检查本身会成为负担。
3. 做好失败摘除与恢复探测
节点失败后临时摘除能减少错误流量,但也不能永久拉黑。
应该设计:
- 失败阈值
- 冷却时间
- 半开探测
- 成功后恢复
4. 发布时渐进式放量
推荐方式:
- 先 1%
- 观察错误率、RT、资源使用
- 再逐步提升到 5%、20%、50%、100%
不要一上来把新节点权重打满。
一份落地检查清单
如果你正在设计或改造服务发现与负载均衡链路,我建议至少确认下面这些点:
- 是否区分了注册、存活、就绪三种状态
- 客户端是否有本地缓存和兜底策略
- 节点失败后是否支持本地临时摘除
- 下线流程是否支持
drain - 重试、超时、熔断是否成体系设计
- 负载均衡是否支持权重、标签、机房优先
- 注册中心本身是否高可用
- 权重和路由变更是否可审计、可回滚
- 是否有观测指标:实例数、摘除数、错误率、切换耗时、缓存版本
总结
服务发现、健康检查、负载均衡,表面上是三个模块,实际上是一条完整的流量控制链路。
真正稳定的集群架构,核心不在于“能找到节点”,而在于:
- 能尽快识别坏节点
- 能把坏节点及时移出流量
- 能让新节点平滑接入
- 能在状态传播有延迟时保持系统韧性
如果你要从今天开始动手优化,我建议按这个优先级推进:
- 先补齐就绪检查和下线摘流
- 再补客户端本地缓存和失败临时摘除
- 然后引入权重流量切换和灰度放量
- 最后再优化多机房、全局调度、一致性哈希等高级能力
边界条件也别忘了:
如果规模不大,简单方案往往更可靠;如果规模已经上来,就不要再用“静态地址 + 人工切换”硬扛了。因为到了那一步,问题已经不只是麻烦,而是故障迟早会发生。
把这条链路设计对,很多看似复杂的线上流量问题,其实都会变得可预期、可观测、可回滚。