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 服务治理平台。

关联