30.4 服务治理 (注册、发现、熔断、限流)
一句话
东西向治理只有三件事——找到谁、限制打多少、失败时快速放弃;算法本身各有主场,治理真正的难点是“策略放在哪一层、参数怎么统一”——配置散落在 8 个服务里各自为政,治理就等于没有。
三件事,各自的算法主场
| 能力 | 解决的问题 | 算法细节的主场 |
|---|---|---|
| 注册与发现 | 实例在哪、还能不能用 | 48.2 服务发现 |
| 限流与并发 | 打进来多少、允许多少在飞 | 35.7 限流 (漏桶 / 令牌桶 / 滑动窗口 / 自适应) |
| 超时 / 重试 | 等多久、失败后要不要再试 | 35.5 超时、重试与退避 |
| 熔断 / 降级 | 下游挂了怎么快速失败、返回什么 | 35.6 熔断与降级 |
本节不重复这些算法,只回答它们该装在哪、参数怎么定、怎么防止跑偏——这三件事决定了治理能不能在 20 个服务上成立。
策略该放在哪一层
| 策略 | 客户端 SDK | Sidecar | 服务端 | 网关 |
|---|---|---|---|---|
| 服务发现 + 负载均衡 | ✅ 主流 | ✅ 主流(Mesh) | ✅(服务端发现) | ⚠️ 只适合南北向 |
| 超时 / 重试 | ✅ | ✅ | ❌ 服务端无法替调用方放弃 | ❌ |
| 熔断 | ✅(按被调方维度) | ✅ | ⚠️ 只能拒绝,不能熔断 | ❌ |
| 限流(保护自己) | ❌ | ❌ | ✅ 唯一正确位置 | ✅(入口限流) |
| 并发限制(背压) | ✅(限制对下游的并发) | ✅ | ✅(限制自己接受的并发) | ⚠️ |
| 鉴权 | ⚠️ | ✅(mTLS) | ✅(业务授权) | ✅(入口认证) |
| 灰度 / 流量染色 | ⚠️ | ✅ | ❌ | ✅ |
🔑 两条铁律:超时和重试只能由调用方决定(服务端不知道调用方还能等多久);限流必须在被保护的那一侧(在调用方限流保护不了自己)。
最小配置集:五条基线
| # | 参数 | 基线值 | 为什么必须有基线 |
|---|---|---|---|
| 1 | 超时 | 下游 P99 × 2,且全链路总预算 ≤ 上游超时 | 没有基线时,默认值往往是“无限等” |
| 2 | 重试 | ≤ 2 次,仅对幂等操作 + 指数退避 | 重试是放大器,三层各 3 次就是 27 倍(35.5) |
| 3 | 熔断 | 失败率 50% / 10 秒窗口 / 半开放 1 个探针 | 没有熔断时,故障下游会被持续打满 |
| 4 | 并发限制 | 每依赖 ≤ 连接池大小,且 ≤ 下游容量 ÷ 调用方实例数 | 没有并发限制时,慢依赖会吃光线程/连接 |
| 5 | 连接池 | 与并发上限同量级,禁止“无上限” | 池太小排队、池太大把下游打挂 |
配置漂移:治理失效的第一原因
治理参数天然会漂移:新服务抄了旧模板、紧急变更改了一个值没改回来、不同团队各有一套默认值。下面这段程序把 8 个服务的治理配置与基线比对,找出偏离项与影响:
package main
import "fmt"
type Cfg struct {
Service string
TimeoutMs int
Retries int
BreakerFailPct int // 0 = 未配置熔断
MaxConcurrent int // 0 = 不限制
}
var cfgs = []Cfg{
{"order", 1000, 2, 50, 200},
{"inventory", 30000, 2, 50, 200}, // 紧急变更改大了没改回来
{"payment", 1000, 5, 50, 200}, // 抄了一个“重试保成功”的模板
{"pricing", 1000, 2, 0, 200}, // 忘了配熔断
{"notify", 1000, 2, 50, 0}, // 并发不限制
{"user", 300, 0, 50, 100}, // 超时压得比下游 P99 还低
{"search", 1000, 2, 50, 200},
{"report", 1000, 0, 50, 50},
}
var base = Cfg{TimeoutMs: 1000, Retries: 2, BreakerFailPct: 50, MaxConcurrent: 200}
func main() {
fmt.Printf("=== 治理配置漂移(基线 timeout=%dms, retries=%d, breaker=%d%%, maxConcurrent=%d)===\n",
base.TimeoutMs, base.Retries, base.BreakerFailPct, base.MaxConcurrent)
fmt.Printf("%-10s %-12s %-10s %-10s %-4s %s\n", "服务", "参数", "实际", "基线", "级别", "影响")
drift, high := 0, 0
report := func(svc, param, actual, baseV, level, impact string) {
drift++
if level == "高" {
high++
}
fmt.Printf("%-10s %-12s %-10s %-10s %-4s %s\n", svc, param, actual, baseV, level, impact)
}
for _, c := range cfgs {
if c.TimeoutMs != base.TimeoutMs {
switch {
case c.TimeoutMs > base.TimeoutMs*5:
report(c.Service, "timeout", fmt.Sprintf("%dms", c.TimeoutMs), fmt.Sprintf("%dms", base.TimeoutMs),
"高", fmt.Sprintf("慢依赖会占住调用方连接 %d 秒", c.TimeoutMs/1000))
case c.TimeoutMs < base.TimeoutMs/2:
report(c.Service, "timeout", fmt.Sprintf("%dms", c.TimeoutMs), fmt.Sprintf("%dms", base.TimeoutMs),
"中", "低于下游 P99,正常抖动会被判成失败并触发重试")
}
}
if c.Retries > base.Retries {
level := "中"
if c.Retries >= 5 {
level = "高"
}
report(c.Service, "retries", fmt.Sprintf("%d", c.Retries), fmt.Sprintf("%d", base.Retries),
level, "重试是乘法,会与下游叠加放大")
}
if c.Retries < base.Retries {
report(c.Service, "retries", fmt.Sprintf("%d", c.Retries), fmt.Sprintf("%d", base.Retries),
"低", "偶发抖动直接暴露给用户(可接受,需确认)")
}
if c.BreakerFailPct == 0 {
report(c.Service, "breaker", "未配置", fmt.Sprintf("%d%%", base.BreakerFailPct),
"高", "下游故障时会被持续打满,故障不收敛")
}
if c.MaxConcurrent == 0 {
report(c.Service, "maxConcurrent", "不限制", fmt.Sprintf("%d", base.MaxConcurrent),
"高", "没有背压,慢依赖会吃光线程与连接")
}
}
// 重试放大:沿调用链把 (1+retries) 连乘
fmt.Println("\n=== 重试放大(沿 order -> payment -> user)===")
m := 1
for _, name := range []string{"order", "payment", "user"} {
for _, c := range cfgs {
if c.Service == name {
m *= 1 + c.Retries
fmt.Printf("%-9s (1+%d)=%d\n", name, c.Retries, 1+c.Retries)
}
}
}
fmt.Printf("一次用户请求 → 最坏 %d 次下游调用\n", m)
fmt.Printf("\n共 %d 处偏离基线,其中 %d 处为高优先级\n", drift, high)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 治理配置漂移(基线 timeout=1000ms, retries=2, breaker=50%, maxConcurrent=200)===
服务 参数 实际 基线 级别 影响
inventory timeout 30000ms 1000ms 高 慢依赖会占住调用方连接 30 秒
payment retries 5 2 高 重试是乘法,会与下游叠加放大
pricing breaker 未配置 50% 高 下游故障时会被持续打满,故障不收敛
notify maxConcurrent 不限制 200 高 没有背压,慢依赖会吃光线程与连接
user timeout 300ms 1000ms 中 低于下游 P99,正常抖动会被判成失败并触发重试
user retries 0 2 低 偶发抖动直接暴露给用户(可接受,需确认)
report retries 0 2 低 偶发抖动直接暴露给用户(可接受,需确认)
=== 重试放大(沿 order -> payment -> user)===
order (1+2)=3
payment (1+5)=6
user (1+0)=1
一次用户请求 → 最坏 18 次下游调用
共 7 处偏离基线,其中 4 处为高优先级
读数:
| 读数 | 含义 | 动作 |
|---|---|---|
inventory.timeout = 30000ms | 一个慢依赖可以让调用方线程/连接被占 30 秒 | 超时按下游 P99×2 定,不按“感觉够用”定 |
payment.retries = 5 | 单层 6 次,叠上 order 的 3 次后,一次用户请求最坏变成 18 次下游调用 | 重试 ≤ 2 且只在幂等操作上开(35.5) |
pricing 未配熔断 | 定价服务抖动时,调用方会一直重试打到它彻底挂掉 | 熔断是“给对方留恢复时间”,不是可选优化 |
notify.maxConcurrent = 0 | 没有背压,突增流量直接压向下游 | 并发上限 = 下游容量 ÷ 调用方实例数 |
user.timeout = 300ms | 比下游 P99 还低,正常请求被判失败 → 触发重试 → 放大流量 | 超时太短和太长一样有害:过短会制造重试风暴 |
治理失效的三种模式
| 失效模式 | 现象 | 根因 | 解法 |
|---|---|---|---|
| 配置漂移 | 同样的下游,有的服务超时 1s,有的 30s | 参数分散在各服务配置里,没有基线与审计 | 治理参数集中管理 + 本节的漂移检测进 CI |
| 治理绕过 | 熔断、限流全配了,故障时还是挂了 | 有人直连 IP 调试、本地改了 DNS、SDK 未注入 | 网络策略禁止绕过 sidecar/网关;直连需审批 |
| 治理组件自身成单点 | 注册中心挂了,全站不可用 | 客户端强依赖注册中心做每次调用 | 本地缓存实例列表 + 兜底直连(48.2) |
三层责任:谁配什么
| 层 | 负责 | 不该负责 |
|---|---|---|
| 平台(注册中心 / Mesh / 网关) | 发现、mTLS、基础指标、默认熔断与重试模板 | 业务降级返回什么 |
| 服务(应用配置) | 每个依赖的超时 / 重试 / 并发上限 | 全局限流阈值(应由容量规划给出) |
| 业务代码 | 幂等键、降级兜底值、业务错误码 | 网络超时、连接池大小 |
🔑 这条分界的意义:治理参数是“容量与契约”的产物,不是“开发习惯”的产物。超时应该由下游 P99 与全链路预算算出来,重试应该由幂等能力决定,并发上限应该由下游容量除以实例数算出来——凡是“拍脑袋定的值”,都会在某次故障里暴露。
反模式
| 反模式 | 后果 |
|---|---|
| 每个服务自己定超时/重试,没有基线 | 漂移必然发生,故障时行为不可预测 |
| 用“重试”代替“把下游修好” | 流量被放大,故障从下游扩散到上游 |
| 在调用方做限流来“保护下游” | 调用方有 N 个实例,限的是 N × 阈值,且无法阻止别人打 |
| 熔断阈值设得极高(如失败率 90%) | 熔断几乎不触发,等于没配 |
| 只配熔断不配降级 | 熔断后直接报错,用户体验比慢更差 |
| 注册中心客户端强依赖,不做本地缓存 | 注册中心抖动 → 全站调用失败 |
| 把网关当成万能治理层 | 东西向流量根本不过网关,治理覆盖不到 |
自检
Q1 一个团队给所有下游统一配了 timeout=3s, retries=3,理由是“统一好管理”。这为什么是错的?
▸ 答案:三个问题。① 超时应该由下游的延迟分布决定:P99 是 50ms 的服务配 3s,故障时要等 3 秒才发现,期间连接一直占着;P99 是 2s 的服务配 3s,正常抖动就会超时。② 重试应该由幂等能力决定:查询可以重试,下单不能无条件重试。③ 叠加:3 次重试在 3 层链路上是 4^3 = 64 倍放大,会把一个局部故障放大成全站雪崩。统一管理的正确形态是“统一的基线 + 按依赖覆盖”,不是“统一的值”。
Q2 为什么“限流”必须放在被保护的一侧,而“超时”必须放在调用方?
▸ 答案:限流保护的是自己的容量,只有自己知道当前有多少并发、队列有多长;放在调用方只能限制“我这一个实例发多少”,N 个调用方实例合起来仍然能把下游打挂(35.7 里“单机 × N 的陷阱”)。超时保护的是调用方的等待预算,只有调用方知道这次请求还能等多久(用户已经等了 500ms,剩余预算可能只有 200ms);服务端无法替调用方决定“你该放弃”。
Q3 漂移检测发现 8 个服务里有 7 处偏离基线(4 处高优先级)。团队决定“先全部改成基线值再上线”。这样安全吗?
▸ 答案:不安全。基线是“默认值”,不是“每个服务的最优值”:例如 report 服务的下游 P99 可能就是 2s,强行改成 1s 会制造大量超时;user.timeout=300ms 看起来偏离,但如果它的下游是本地缓存(P99 20ms),300ms 恰恰是合理的。正确做法是把偏离分成三类:① 明显错误(未配熔断、并发不限、重试 ≥5)→ 直接改;② 有业务理由的偏离(下游 P99 高)→ 在配置里写注释 + 加入白名单;③ 说不清理由的偏离(抄模板抄错的)→ 改回基线并观察。检测器的价值是逼出解释,不是强制统一。
Q4 注册中心故障 30 秒,服务间调用全部失败。哪个设计缺陷最可能导致这种结果?
▸ 答案:客户端把“每次调用都查注册中心”当成默认行为,没有本地实例列表缓存与兜底。正确的客户端发现是“订阅 + 本地缓存 + 变更推送”,注册中心不可用时继续用最后一次的实例列表(可能包含已下线实例,但至少能服务大部分请求)。这个问题在 48.2 服务发现 的“一致性:CP 还是 AP”里有完整讨论;治理层面的结论是:任何治理组件都不能是调用链上的同步依赖。
调用去哪一组节点,是另一件事:路由规则怎么写、规则放在哪、怎么一键清空,见 30.8 服务路由 (条件 / 标签 / 脚本);这些策略由谁下发、怎么审计、怎么回滚,见 30.9 服务治理平台。