13 隔离与降级:熔断、限流、舱壁、优雅降级
开篇提问
先给你一个真实的事故场景,你来当消防员:
你的系统有 3 个服务:订单服务 → 支付服务 → 风控服务。某天风控服务因为一个慢查询变慢了,响应时间从 10ms 涨到 5 秒。
请推演接下来会发生什么:
- 支付服务调用风控,每个请求都要等 5 秒
- 支付服务的线程池很快被占满(所有线程都在等风控)
- 订单服务调用支付,也开始变慢
- 订单服务线程池也被占满
- 整个系统全部不可用
注意最后的结果:一个看似不起眼的风控服务变慢,拖垮了整个系统。 而且——风控服务可能根本没崩,它只是”慢”而已。
这就是级联故障(Cascading Failure),分布式系统最可怕的故障模式之一。
请想一个问题:为什么”一个服务慢”会导致”整个系统挂”?中间那个”放大”的环节到底是什么?
答案藏在一个关键概念里:资源耗尽。理解了它,你就理解了这一章所有机制存在的理由。
子主题一:级联故障的机理——资源是怎么被耗尽的
级联故障的核心链条,是一句话:
一个依赖变慢 → 调用方资源被占满 → 调用方自己变慢 → 它的调用方资源被占满 → ……
关键是”资源被占满”这一步。哪些资源会被占满?
资源一:线程池 / 连接池。 每个请求占一个线程,请求不返回线程就不释放。慢依赖 → 线程全在等待 → 新请求拿不到线程 → 排队或拒绝。
资源二:内存 / 队列。 请求堆积在队列里,队列占内存,最终 OOM。
资源三:数据库连接。 慢查询占着连接不放,连接池耗尽,所有需要数据库的操作都失败。
资源四(最隐蔽):调用方自己的”好意”。 重试。慢依赖 → 超时 → 重试 → 请求量翻倍 → 更慢 → 更多重试。这是正反馈循环,是级联故障的加速器。
所以级联故障的”放大器”有两个:
- 同步等待导致的资源占用(线程、连接、内存)
- 重试导致的流量放大
理解了机理,对策就清楚了:
- 针对资源占用 → 隔离(舱壁)+ 快速失败(熔断 + 超时)
- 针对流量放大 → 限流 + 重试预算
- 针对”必须保证基本可用” → 降级
这一整章的四个机制,就是对着这三个放大器设计的。
子主题二:熔断(Circuit Breaker)—— 快速失败的艺术
熔断器的思路直接来自电路里的保险丝:当检测到下游持续故障时,主动”断开”,不再调用下游,直接返回错误(或走降级逻辑)。
熔断器的三个状态:
- Closed(关闭/正常):正常调用。同时统计失败率。
- Open(打开/熔断):失败率超过阈值 → 熔断。所有请求直接快速失败,不调用下游。持续一段时间(熔断窗口)。
- Half-Open(半开):熔断窗口过后,放少量请求去试探下游。如果成功 → 回到 Closed;如果还失败 → 回到 Open。
为什么需要”半开”状态? 因为你需要知道下游什么时候恢复了。但不能一下子放全部流量过去(下游可能还没完全恢复,会被再次打死)。半开是”小心翼翼地试探”。
熔断的核心价值:把”等待超时”变成”立即失败”。
- 没有熔断:调用下游要等 5 秒超时,线程占用 5 秒
- 有熔断:立即返回错误,线程占用 1 毫秒
这一下子把资源占用降了 5000 倍。 这就是为什么熔断能防止级联故障——它让失败变得”便宜”。
熔断的关键参数:
- 失败率阈值:连续失败多少次 / 失败率超过多少才熔断
- 熔断窗口:熔断持续多久(要给下游恢复时间)
- 半开的试探量:放多少请求去试探
一个重要的权衡:熔断太敏感 → 偶发抖动就熔断,白白损失可用性;熔断太迟钝 → 起不到保护作用。通常要基于”错误率”而不是”单次失败”来判断,并且要区分错误类型(5xx 该熔断,4xx 不该)。
子主题三:限流(Rate Limiting)—— 保护自己
熔断是”下游坏了就别调了”,限流是”不管下游坏没坏,我都要控制自己的请求速率”。
限流的目的:
- 保护自己不被打垮(防止突发流量超出容量)
- 保护下游不被打垮
- 公平分配资源(多租户场景)
四大经典限流算法:
1. 固定窗口计数(Fixed Window)
- 把时间切成固定窗口(比如每分钟),每个窗口内计数,超过阈值就拒绝
- 优点:简单
- 致命缺陷:窗口边界突刺。在 00:59 和 01:00 各放 100 个请求,实际 2 秒内放了 200 个——限流形同虚设
2. 滑动窗口计数(Sliding Window)
- 记录每个请求的时间戳,统计”最近 1 分钟内”的请求数
- 优点:解决了边界突刺
- 缺点:要存储时间戳,内存开销稍大(可以用滑动窗口分片的折中方案)
3. 漏桶(Leaky Bucket)
- 请求进桶,桶以恒定速率出水(处理),桶满了就拒绝
- 特点:输出速率恒定——无论输入多猛,输出都是平滑的
- 适合:需要”严格控制输出速率”的场景(比如调用第三方 API 有速率限制)
4. 令牌桶(Token Bucket)
- 桶里以恒定速率生成令牌,请求来了取一个令牌,没令牌就拒绝
- 特点:允许一定程度的突发(桶里的令牌可以攒着,一次性用来处理突发请求)
- 适合:既要限制平均速率、又想允许合理突发的场景(这是最常用的一种)
漏桶 vs 令牌桶的关键区别:漏桶控制”输出速率”(强制平滑),令牌桶控制”平均速率”但允许突发。绝大多数互联网场景选令牌桶,因为用户访问本来就是突发的,强制平滑会伤害体验。
分布式限流的问题: 单机限流简单,但如果你有 100 台机器、要限制”全局每秒 1 万次”,怎么办?
- 方案一:中心化限流(Redis 计数)——准确,但有网络开销和 Redis 单点风险
- 方案二:本地配额(每台 100 次/秒)——简单,但负载不均时不准(某台机器流量特别大)
- 方案三:混合(本地令牌 + 中心协调)——工业级方案
子主题四:舱壁隔离(Bulkhead)—— 把故障关进笼子
舱壁这个名字来自造船:船体被隔成多个独立的密封舱,一个舱进水不会沉船。
在软件里,舱壁就是资源隔离:把资源(线程池、连接池、内存)按功能/调用方分开,一个地方耗尽不影响其他地方。
具体做法:
做法一:为每个依赖分配独立的线程池/连接池。
- 调用 A 服务用 A 的线程池(10 个线程),调用 B 服务用 B 的线程池(10 个线程)
- A 慢了,只耗尽 A 的 10 个线程,B 的线程完好,B 的调用不受影响
- 对比:共用一个 20 线程的池 → A 耗尽全部 20 个 → B 也挂了
做法二:信号量隔离。
- 不用线程池,用信号量限制”同时调用某个依赖的并发数”
- 更轻量(无线程切换开销),但不支持超时和异步
做法三:按租户/用户隔离。
- 多租户系统里,限制每个租户能用的资源上限
- 防止”一个大客户打爆整个系统”
做法四:容器/进程级别的隔离。
- 最彻底的隔离——不同服务跑在不同容器,资源(CPU、内存)硬隔离
- 代价:部署和通信成本
舱壁的权衡:
- 隔离越细,故障影响面越小,但资源利用率越低(某个池空闲时,别的池不能用它的资源)
- 这是典型的”资源效率 vs 故障隔离”的权衡
子主题五:优雅降级(Graceful Degradation)—— 保证基本可用
前面三个机制都是”防止系统崩溃”。降级解决的是另一个问题:当系统确实扛不住时,怎么”少给一点,但别全给不了”。
降级的核心思想:识别”核心功能”和”非核心功能”,在压力大时牺牲非核心,保住核心。
典型的降级策略:
策略一:功能降级。 关掉非核心功能。
- 电商:关闭”推荐”、“评价”、“个性化排序”,保住”浏览-加购-下单-支付”主链路
- 搜索:关闭”聚合统计”、“高亮”,保住基本检索
策略二:数据降级。 用更粗糙但更快的数据。
- 个性化推荐挂了 → 返回热门榜单
- 实时数据算不出来 → 返回 5 分钟前的缓存
- 精确计数算不出来 → 返回近似值(这就是第 5 章的近似思想在容错里的应用)
策略三:体验降级。 牺牲体验保功能。
- 图片加载不出来 → 显示占位图
- 评论加载失败 → 隐藏评论区(用户甚至不知道少了什么)
策略四:限流式降级(有损服务)。 直接拒绝一部分请求。
- 随机丢弃 30% 的请求,保住 70%
- 或者按用户等级丢弃:付费用户优先保证
降级设计的关键前置工作:给功能分级。 你必须在平时(而不是故障发生时)就想清楚:
- 哪些功能是”没有就不能叫服务”的(P0)
- 哪些是”没了很难受但能用”的(P1)
- 哪些是”没了无所谓”的(P2)
没有这个分级,故障时你就只能”全有或全无”——而那往往意味着全无。
降级必须可配置、可快速切换。 最好有开关中心,能在几分钟内关掉任何非核心功能。而且——降级开关本身要有演练。
子主题六:四种限流算法的实现细节——从”边界突刺”到”突发额度”
子主题三给了你四张名片,但名片不是人。现在把每张算法的实现拆开看,你会发现:所谓”算法选择”,本质上是在回答三个问题:精度要多高、内存愿不愿意花、允不允许突发。
先问一个挑衅的问题:如果固定窗口有那么明显的缺陷,为什么它至今还是最常见的限流实现?
因为它只用了一个计数器。而”一个计数器”这个特性,在分布式场景里价值连城(子主题七会看到)。先别急着嘲笑它。
1. 固定窗口:一个计数器能捅多大的篓子
实现只需要两样东西:一个按窗口对齐的 key,一个计数。
key = "rl:" + userId + ":" + (currentTime / windowSize) // 窗口序号
count = INCR(key); if count == 1: EXPIRE(key, windowSize)
if count > limit: REJECT else PASS
现在上时序例子。设限流规则是 1000 次/分钟:
00:57:00— 窗口 W57 开始,零零散散来了几十个请求,无事发生。00:59:58.000— 有人在两秒内灌进 1000 个请求。W59 的计数到 1000,刚好卡在阈值,全部放行。01:00:00.000— 窗口翻页,key 变成 W60,计数从 0 重新开始。01:00:01.000— 又是 1000 个请求灌进来,W60 的计数也才 1000,再次全部放行。
结果:在 00:59:58 到 01:00:01 这 3 秒里,系统实际承受了 2000 个请求,瞬时 QPS 接近 670,而限流器自认为一切正常。 更要命的是,如果攻击者把流量压到 00:59:59.900 ~ 01:00:00.100 这 200 毫秒内,那瞬时 QPS 就是 10000——是你”每分钟 1000 次”(平均 16.7 QPS)的 600 倍。
这就是边界突刺(boundary spike):固定窗口的最坏情况可以通过 2 倍配额,而且这 2 倍可以压缩到极短的时间窗口内。
再想一层:2 倍这个上界是从哪来的? 因为任意长度为 W 的时间区间,最多横跨两个对齐窗口,每个窗口最多放 limit 个,所以上界是 2×limit。这个结论意味着一件重要的事——你无法通过”把窗口调小”来根治它。窗口调成 1 秒,最坏情况仍然是”1 秒内 2 倍 QPS”;你只缩短了突刺的持续时间,没消除突刺本身。
那它该不该用?该用。当你的目的是”防止某个租户把账单打爆”或者”防止爬虫把后台刷挂”这种粗粒度保护时,2 倍误差完全可接受,换来的是 O(1) 内存和单行 Redis 操作。工程里没有最优解,只有”误差成本 vs 实现成本”的交换。
2. 滑动窗口日志:精确,但要记账
最直白的修法:别按窗口对齐,按”最近 W 毫秒”算。 给每个请求记一条时间戳:
onRequest():
now = nowMillis()
log.removeOlderThan(now - W) // 清掉窗口外的
if log.size() >= limit: REJECT
log.append(now); PASS
精度是完美的——任意时刻,log.size() 就是”最近 W 毫秒内真真切切发生过的请求数”,绝无突刺。
代价是什么?每个请求都要存一条记录。 限流 100 万次/分钟,你就得在内存里躺 100 万个时间戳。Redis 里用 ZSET 实现(ZREMRANGEBYSCORE 清窗口 + ZCARD 计数 + ZADD 记账),每次限流是 O(log N) 且 member 不能重复(要用 uuid 或 timestamp+seq 做 member)。一个百万 QPS 的网关用这个方案,Redis 会先于你的业务挂掉。
所以:滑动窗口日志只适合低 QPS、高精度、短窗口的场景——比如”同一手机号 60 秒内只能发 1 条短信”。
3. 滑动窗口分片:工业界最常用的折中
既然”记每个请求”太贵,“一个计数器”太糙,那就折中:把窗口切成 k 个小格,每格一个计数器,然后按时间比例加权估算。
设窗口 W=60 秒,切成 k=6 格,每格 10 秒。当前时间在本格内已经走了 30%(即 3 秒):
estimate = 当前格计数 + 之前各格计数 × (1 - 0.3)
凭什么这么算? 因为”之前各格”里已经有一部分时间滑出了窗口,滑出的比例正好是本格已经走过的比例。我们假设格内请求均匀分布(这是个谎言,但够用),于是按比例打个折。
内存从 O(请求数) 降到 O(k),且 k 是可调的精度旋钮:k 越大越精确,最坏通过量越接近 limit(k 片时最坏误差量级约为 limit/(k-1),即 k=10 时约 1.11 倍,k=2 时退化回 2 倍)。这就是 Redis 里常见的”限流用 ZSET 还是 Hash”之争的答案——Hash 分桶 + 加权估算,是绝大多数生产系统的选择。
4. 漏桶:一个”反向”的直觉
漏桶的模型是:水(请求)从上面倒进桶,桶底有个洞,以恒定速率 r 滴水。水在桶里排队;桶满了,新来的水直接溢出(拒绝)。
关键性质只有一句:输出速率恒定。 无论你一瞬间倒进 1000 升还是 1 升,出水口永远是每秒 r 滴。
实现要点(注意这里的一个常见误解):
// 漏桶作为"队列"(业务侧排队等待)
interval = 1 / rate // 相邻两个请求的最小放行间隔
lastLeakTime // 上一次放行的时间戳
onRequest():
now = nowNanos()
water = max(0, water - (now - lastCheck) * rate) // 先漏水
lastCheck = now
if water > capacity: REJECT // 装不下了
water += 1
waitTime = water * interval // 排队多久轮到我
还有另一种等价实现,叫 GCRA(Generic Cell Rate Algorithm,通用信元速率算法),它不存”水量”,只存一个 TAT(Theoretical Arrival Time,理论到达时间):
T = 1 / rate // 每个请求应占的时间片
τ = capacity * T // 突发容忍度(能"预支"多久)
if now < TAT - τ: REJECT // 来得太早,已经预支到极限了
else: TAT = max(TAT, now) + T; PASS
GCRA 的好处是状态只有一个时间戳,内存 O(1),且天然支持”返回还要等多久”(TAT - τ - now)。Redis 的 redis-cell 模块(CL.THROTTLE)就是它的实现。
漏桶适合什么? 需要严格控制对下游的输出速率的场景:调用第三方 API(对方明确说”每秒 10 次”)、发短信、批量任务调度、网络流量整形。漏桶是对下游的承诺——我保证不会突然给你 1000 个请求。
5. 令牌桶:为”突发”留的后门
令牌桶换个方向:桶里装的是令牌,系统以速率 r 往里放令牌,桶容量 b 是上限;请求来了拿一个令牌,拿到就过,没有就拒绝(或等待)。
实现要点,注意完全没有定时任务:
tokens, lastRefill // 惰性填充,只有两个变量
onRequest(n=1):
now = nowNanos()
tokens = min(b, tokens + (now - lastRefill) * r / 1e9) // 按时间差补令牌
lastRefill = now
if tokens >= n: tokens -= n; return PASS
return REJECT // 或 return WAIT((n - tokens)/r)
这个”惰性填充”是整个实现的精髓:你不需要每秒跑个定时任务加令牌,只需要在有人来的时候,根据”距上次过去多久”一次性补上。这就是为什么令牌桶可以是 O(1) 内存、O(1) 时间的无锁操作。
令牌桶的两个可调参数含义非常清晰:
- r(生成速率)= 长期平均速率。这是你对系统的长期承诺。
- b(桶容量)= 允许的最大突发量。桶攒满了 b 个令牌,可以一口气处理 b 个请求。
于是有个必须记住的结论:令牌桶的”最大瞬时 QPS”是 b(不是 r),“长期平均 QPS”是 r。 如果你配了 r=100, b=1000,那你的系统必须能扛住瞬间 1000 QPS 的冲击——很多线上事故就是配错了 b:以为限的是 100,实际瞬时能放 1000 进来。
还有个反直觉的细节(Guava RateLimiter 的 SmoothBursty):令牌可以”预支”。 当桶里只有 0.5 个令牌而请求要 1 个时,Guava 会放行,但把”欠的 0.5 个”记到 nextFreeTicketMicros 上,让下一个请求等待。也就是说,突发流量不是免费的,它让后面的请求替它还债。这个设计让”限流”变成”整形”——不是拒绝,而是让你慢下来。
6. 四张表的对照
| 算法 | 内存 | 输出特性 | 允许突发 | 最坏通过量 | 典型用途 |
|---|---|---|---|---|---|
| 固定窗口 | O(1) | 不平滑 | 边界处允许 | 2×limit | 粗粒度配额、计费保护 |
| 滑动窗口日志 | O(请求数) | 较平滑 | 不允许 | = limit | 低 QPS 精确控制(短信) |
| 滑动窗口分片 | O(k) | 较平滑 | 轻微 | ≈ limit×(1+1/(k-1)) | 网关通用限流 |
| 漏桶/GCRA | O(1) | 恒速 | 不允许 | 恒速 r | 保护下游、流量整形 |
| 令牌桶 | O(1) | 可突发 | 允许(上限 b) | 瞬时 b | 入口限流、允许合理突发 |
回到一开始那个挑衅的问题——四个算法里,你日常写业务接口限流该选哪个? 令牌桶,几乎无悬念。因为用户访问天然是突发的,你要限的是”长期平均”而不是”削平每一个毛刺”。漏桶那是给”你求着别人限你”的场景准备的。
子主题七:分布式限流——当计数器不住在同一台机器上
单机限流是个纯内存问题,一点都不难。难的是这句话:“我要限制全集群对微信支付接口的调用,每秒不超过 5000 次”,而你有 200 个实例。
先把方案摆出来,再逐个拆:
方案一:中心化限流(Redis + Lua)
思路直白:把计数器搬到所有实例都能看到的地方,用 Redis 的原子操作来保证一致性。
为什么必须用 Lua 而不是”先 GET 再 SET”?因为这两步之间有时间差:
// 错误示范:非原子
n = GET key
if n < limit: SET key n+1 // 这中间可能插进来 100 个并发请求
在高并发下,这个 check-then-act 的间隙会让限流失效到什么程度? 取决于 QPS 和 RTT——QPS 5000、RTT 1ms,那么每秒有约 5 个请求能”同时看到”同一个值并全部放行。看起来不多,但这是个永远存在的漏洞,而且负载越高漏洞越大。
正确做法是 Lua 脚本(Redis 保证脚本原子执行):
-- 固定窗口分布式限流
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- 毫秒
local cur = redis.call('INCR', key)
if cur == 1 then
redis.call('PEXPIRE', key, window)
end
if cur > limit then return 0 end
return 1滑动窗口日志版(ZSET):
local key = KEYS[1]
local now, window, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
if redis.call('ZCARD', key) < limit then
redis.call('ZADD', key, now, ARGV[4]) -- member 必须全局唯一
redis.call('PEXPIRE', key, math.ceil(window/1000))
return 1
end
return 0这里藏了一个真实的事故级 bug,你发现了吗? 第一个脚本里 if cur == 1 then PEXPIRE —— 如果 INCR 成功了但 PEXPIRE 之前 Redis 进程被杀(或者主从切换、或者脚本执行到一半连接断开),这个 key 就永不过期。结果:这个用户被永久限流,谁也解不开,只能人工删 key。修法是每次都无条件 PEXPIRE,或者用 SET key 1 EX window NX 再 INCR 的组合。在限流这种”基础设施”代码里,任何一次”只在首次执行”的副作用,都要问一句:它失败了会怎样?
中心化方案的三个真实成本:
- 每次请求多一次 Redis 往返。 你的接口 RT 里凭空多 0.5~2ms。对 RT 预算只有 10ms 的接口,这是 10%~20%。
- Redis 成了关键路径上的新依赖。 它挂了怎么办?两个选择:fail-open(放行,假设 Redis 挂了就不过限) 或 fail-close(全部拒绝)。前者有被打爆的风险,后者等于把 Redis 变成单点故障。绝大多数业务选 fail-open,但会配一个保守的本地兜底阈值(比如”Redis 不可用则本地按单机 200 QPS 限流”)。
- 热 key。 所有实例的流量都打到一个 key 上,单分片 Redis 扛不住。可以做 key 分片(
rl:{userId}:{shard},配额也除以分片数),代价是精度下降。
方案二:本地配额(Local Quota)
思路更简单:总配额 ÷ 实例数,每个实例自己限自己的。 5000 / 200 = 每秒 25 次。
它的问题只有一个,但很致命:这个算法假设流量在所有实例间均匀分布,而这个假设在真实世界里几乎从不为真。
具体会歪到什么程度?
- 负载不均:网关的负载均衡算法(哪怕是一致性哈希)总有倾斜。假设 200 个实例里有一个实例分到了 10% 的流量(而不是 0.5%),它每秒收到 500 个请求,但本地配额只有 25/s —— 98% 的请求被误杀,而其余 199 个实例还闲着。用户看到的现象是”偶尔点一下就失败”,极难排查。
- 实例数变化:弹性伸缩时,实例数从 200 变到 210,如果配额是启动时算好的,你的总配额就悄悄变成了 5250。反之,有实例宕机,总配额掉到 4750,系统变得比预期保守。
- 实例数很少时误差爆炸:只有 2 个实例,一个挂了,总配额直接腰斩。
所以本地配额适合什么? 适合”宁可少放不可多放”的保护性限流(保护下游不被打死),不适合”要精确控制总量”的场景。
方案三:混合方案——工业界的答案
能不能又准又快?能。思路是:本地做执行,中心做协调,二者异步解耦。
┌──────────────┐ 每 T 秒上报本地消耗量 ┌──────────────┐
│ 实例 A/B/C │ ──────────────────────→ │ 配额协调器 │
│ 本地令牌桶 │ ←────────────────────── │ (Redis/独立服务)│
└──────────────┘ 下发新配额 quota_i(T) └──────────────┘
工作方式是:
- 本地:每个实例持有一个令牌桶,限流判定完全在本地内存完成,零网络开销,所以热路径上没有 Redis。
- 异步:每 T 秒(典型值 1~10 秒),实例把”这一周期内我消耗了多少”上报给协调器(或者直接由协调器从监控系统拉取每个实例的 QPS)。
- 重新分配:协调器按上一周期各实例的实际流量占比,重新分配下一周期的配额。谁流量多谁多拿,谁闲谁少拿。
- 边界保护:新配额下发前,本地用旧配额继续跑;同时给每个实例设一个”最小保底配额”,防止新实例刚启动(还没有历史流量)被分配 0 配额而永远起不来——这是个真实的冷启动坑。
这其实就是 Envoy 的两级限流模型:本地限流(local rate limit,token bucket,零开销、粗粒度)+ 全局限流(global rate limit,通过 RLS 服务,精确、有网络开销)。本地层拦掉绝大部分超额流量,全局层负责总量正确性。两层不是二选一,是”先粗后精”的漏斗。
还有一个更激进但很优雅的做法:概率式限流。中心维护一个全局”通过概率 p”(比如 0.7),广播给所有实例;每个实例本地随机丢弃 1-p 的请求。零协调成本,且在流量均匀分布时总量误差极小(大数定律)。代价是”同一个用户的两个请求可能被区别对待”——所以要么按用户哈希决定(同用户结果一致),要么接受这种随机性。随机丢弃听起来粗暴,但它天然避免了”所有实例在同一毫秒同时拒绝”的同步振荡。
子主题八:自适应过载保护——为什么”固定阈值限流”在真实系统里不够好
前面所有讨论,都建立在一个未经审视的前提上:系统的容量是一个常数,我们可以提前测出来,然后写进配置。
这个前提是错的。而且不是”有点误差”的错,是根本性的错。
一、容量为什么是动态的
问你一个问题:你上个月压测出”这个服务能扛 3000 QPS”,这个数字今天还成立吗?
看看它依赖于什么:
- 机器数与机器健康度:一台机器宕机,容量 -1/N;一台机器在做 GC 或被打到 CPU throttling,容量立刻打折。
- 下游延迟:你的服务 QPS 上限 = 并发数 / 平均延迟(Little’s Law)。下游慢一倍,你的容量直接腰斩,而你自己的代码一行没改。
- 请求构成:一个带 5 个商品的下单请求和一个带 200 个商品的下单请求,成本能差 40 倍。你的限流按”次数”算,但系统消耗的是 CPU 时间。
- 机器异构:容器混布、CPU 型号不同、别人跟你抢宿主机(noisy neighbor)。
- 缓存命中率:缓存一崩,所有请求打到 DB,容量掉一个数量级。
所以静态阈值不是”不准”,是”在你需要它的那一刻最不准”——因为系统状态偏离常态的时刻(故障、发布、流量高峰),恰恰是容量变化最大的时刻。你用常态下标定的阈值去保护异常态,就像用夏天的身高买冬天的衣服。
还有个更难堪的事实:静态阈值需要压测标定,而压测本身昂贵且很快过期。 一个季度前的压测数据,经过几十次发布后还剩多少参考价值?
二、过载时会发生什么:拥塞崩溃
先建立一个心智模型。排队论告诉我们,一个系统的延迟大致随利用率 ρ 呈 1/(1-ρ) 增长:
- ρ = 0.5 → 延迟是空闲时的 2 倍
- ρ = 0.8 → 5 倍
- ρ = 0.9 → 10 倍
- ρ = 0.95 → 20 倍
- ρ → 1 → 发散
而真正致命的是 ρ > 1 之后:吞吐不再随负载上升,反而下降。 为什么?因为系统里多出来的资源全被”管理开销”吃掉了——线程上下文切换、锁竞争、GC 频率上升、缓存局部性被破坏、队头阻塞。这就是拥塞崩溃(congestion collapse):你越努力处理,处理得越少。
所以过载保护的第一原则是:宁可拒绝一部分请求,也不要让系统进入 ρ > 1 的区域。 拒绝一个请求的成本是”这个用户失败”,进入拥塞崩溃的成本是”所有用户失败”。
三、CoDel:别看队列长度,看排队时间
CoDel(Controlled Delay, controlled delay AQM)本来是给网络路由器设计的,但它的思想直接可以搬到服务过载保护上。它的核心洞察极其漂亮:
队列长度会骗人,排队延迟不会。
什么意思?一个突发流量瞬间填满队列,但很快被排空——这是无害的,不该触发限流。而一个持续过载的系统,队列永远是满的,但如果你只看”队列长度”,你分不清”刚刚突发了一下”和”已经过载 10 秒了”。长度是瞬时快照,延迟是积分结果。
CoDel 的算法:
target = 5ms // 可接受的排队延迟
interval = 100ms // 观察窗口
每次出队时记录 sojourn_time(这个请求在队列里待了多久)
if sojourn_time > target:
if 在最近一个 interval 内,最小的 sojourn_time 都 > target:
丢弃队首的包(drop)
count++
next_drop = now + interval / sqrt(count) // 越持续,丢得越频繁
else:
count = 0 // 突发只是突发,重置
三个设计细节值得记住:
- 用”窗口内最小值”而不是”平均值”或”当前值”——最小值能穿透噪声,稳定地反映”系统是否持续排队”。
- 丢队首(drop from head)而不是丢队尾。丢队尾是丢刚来的,丢队首是丢等最久的。等最久的那个请求,大概率已经注定超时了,它的等待毫无意义;而刚来的请求还有可能被及时处理并成功。这就是LIFO(后进先出)优于 FIFO 的过载场景:优先服务”还有救的”。
- 丢包频率按
1/√count加速。持续过载时逐步加大力度,避免一开始就打死,也避免力度不够。
四、Google SRE 的做法:客户端侧自适应节流
《SRE:Google 运维解密》里有一套非常朴素但极其有效的算法,思路是:后端最知道自己扛不扛得住,那就让后端用”拒绝”把真实情况告诉客户端,客户端据此自适应。
客户端为每个后端维护两个滑动窗口计数(覆盖最近 2 分钟):
requests:我发出去的请求总数accepts:后端接受的请求数(即”没有被后端以过载为由拒绝”的响应数;业务错误也算 accepts,因为那不代表后端过载)
然后:
if requests > K * accepts: // K 通常取 2
p = (requests - K * accepts) / (requests + 1)
以概率 p 在本地直接拒绝请求(不发出去)
精妙在哪?
- 它是自适应的:后端健康时,
accepts ≈ requests,p = 0,完全不限流。后端开始过载并拒绝时,accepts下降,p自动爬升。 - 它天然偏向”多后端”场景:客户端对每个后端独立维护计数,所以一个后端挂了,只有发给它的流量被节流,其他后端不受影响。
- 它解决了重试放大的问题:被本地节流掉的请求不会消耗网络和后端的资源。
- K=2 的含义:允许”发出 2 个只成功 1 个”这种程度的浪费,超过才开始砍。K 就是你对浪费的容忍度。
服务端侧还有一版基于 CPU 的:每台服务器记录 CPU 利用率,超过阈值时按上述公式拒绝,并把”我拒绝了”这件事告诉客户端,客户端更新 accepts。这两者结合,就形成了”服务端自己保护的 + 客户端主动退让”的双层反馈。
这背后是一个普适的架构原则,值得单独记住:过载保护的最优信号源,是”被保护者自己”的反馈,而不是外部观测者的猜测。
五、Netflix 的做法:基于延迟的自适应并发限制
另一条路线是 Netflix 的 concurrency-limits 库(Envoy 的 adaptive concurrency filter 也基于此)。它不限制 QPS,而限制并发数(同时在处理多少个请求),并根据延迟动态调整这个上限。
为什么改用并发而不是 QPS?因为 Little’s Law:L = λ × W,并发 = 吞吐 × 延迟。并发是”系统里同时有多少活儿”,它比 QPS 更接近”容量”这个物理量的本质,而且不受延迟变化的影响——下游慢了,QPS 会掉但并发会涨,后者才是过载的真实信号。
算法核心:
gradient = min(RTT_noload / RTT_actual, 1.0) // 延迟梯度
new_limit = limit * gradient + queueSize // 按梯度缩放
// 再用 AIMD 平滑:
// 加性增:一段时间延迟正常 → limit += α(比如 1~3)
// 乘性减:延迟超阈值 → limit *= β(比如 0.7~0.9)
RTT_noload是系统空闲时的基准延迟(持续追踪最小值得到)。- 延迟上升 →
gradient < 1→limit下降 → 排队减少 → 延迟回落。这是一个负反馈环。 - AIMD(加性增乘性减) 是 TCP 拥塞控制的经典手段:慢慢探、快快退。因为”探过头”的代价(拥塞崩溃)远大于”探得慢”的代价(少赚一点吞吐)。
注意这个和”排队延迟”的关系:CoDel 看的是队列里的等待时间,Netflix 看的是端到端延迟的梯度。两者都遵循同一原则——用”系统自己的响应时间”作为过载信号,而不是用请求数。
六、工程上怎么落地自适应
给你一个务实的组合,从粗到细:
- 兜底硬阈值:CPU > 80% 或并发线程数 > N,无条件开始丢流量。这一层不需要聪明,只需要确定性。
- 基于排队延迟的自适应(CoDel 思路):入口队列按延迟丢弃,优先丢”等太久的”。
- per-依赖的自适应并发限制(Netflix 思路):每个下游独立限流,避免”一个慢依赖吃光所有并发额度”。
- 客户端侧节流(SRE 思路):跨服务的退让机制。
- 重要性分级(LIFO/优先级队列):实在要丢,先丢不重要的。 同样是丢 20%,丢掉的是”推荐位刷新”还是”支付回调”,天壤之别。
最后一问:自适应限流能完全替代静态限流吗? 不能。自适应解决的是”我不知道容量是多少”的问题,但它解决不了”我要保证租户 A 最多用 30%“这种配额/公平性问题。限流有两个完全不同的目标——保护容量(adaptive 擅长)和分配资源(静态配额擅长)——别指望一个机制同时干好两件事。
子主题九:熔断器的工程实现——窗口怎么记、什么时候跳、怎么试探
熔断的概念你在子主题二已经会了。现在问几个只有真正实现过才会遇到的问题。
一、统计窗口:滚动窗口 vs 滑动窗口
问题:怎么统计”最近 10 秒的失败率”?
幼稚做法:一个变量存总数、一个变量存失败数,每 10 秒清零。这会有什么问题? 清零的那一刻,你的统计值瞬间归零,如果刚好此刻来一个失败请求,失败率就是 100% —— 误熔断。更糟的是,清零前 1 秒的失败被”遗忘”得干干净净,统计有周期性的盲区。
Hystrix 的做法:滚动窗口(Rolling Window)。 把 10 秒切成 10 个 1 秒的桶,每个桶存这一秒的(成功数、失败数、超时数、拒绝数),窗口随时间滚动,每次统计时把最近的 10 个桶加起来。
为什么这样就没盲区了? 因为任意时刻,你统计的都是”最近 10 个桶”这个滑动集合,而不是”当前这个对齐周期”。旧数据是被逐秒淘汰的,不是一次性清零的。
Resilience4j 的做法:环形缓冲区(Ring Buffer)。 用一个固定大小的数组(比如 100 个槽位)循环覆盖,每个槽位存一个结果(成功/失败)。它提供两种模式:
- COUNT_BASED:最近 N 次调用(N=100)。好处是样本量恒定,判定稳定;坏处是”最近 N 次”在低流量时可能跨越很久。
- TIME_BASED:最近 N 秒,每秒一个聚合桶。更贴合直觉。
实现上还有个精巧处:Resilience4j 用一个 BitSet 存 N 个布尔结果(1=失败),统计失败数就是 bitSet.cardinality(),一条 CPU 指令级别的操作,且内存只有 N 位。这就是为什么它能宣称”零开销”。
二、判定条件:三个必须同时回答的问题
只说”失败率 > 50% 就熔断”是远远不够的。 一个合格的判定需要三个条件同时成立:
条件一:最小请求数门槛(minimumNumberOfCalls)。 问:如果最近只有 2 个请求,1 个失败了,失败率 50%,要不要熔断? 答:绝对不要。 样本太小,统计意义为零。所以必须有 “窗口内请求数 ≥ N(如 20 或 100)” 这个前置条件。没有这个条件的熔断器,在低流量期会疯狂误熔断。
条件二:什么算”失败”。 这是最容易被做错的地方。以下三类要严格区分:
- 该熔断的:连接被拒、超时、5xx、依赖返回”我挂了”。这些是依赖的健康问题。
- 不该熔断的:400 参数错误、401 未授权、404。这些是你的调用方的问题,不是依赖的问题。 熔断它们等于”因为用户输错了密码,所以判定支付服务挂了”。
- 最容易被忽略的:慢调用也算失败。 一个请求没报错但花了 8 秒(正常是 50ms),和报错一样危险——它同样占着你的线程。Resilience4j 支持独立的
slowCallRateThreshold和slowCallDurationThreshold,慢调用率超阈值也熔断。这一点非常重要:在高并发系统里,慢比错更可怕,因为错会释放资源,慢不会。
条件三:用哪种聚合口径。 除了错误率,还可以用”连续失败 N 次”。两者该怎么选? 连续失败适合那种”失败是强相关”的故障(实例宕机),响应快;错误率适合”偶发抖动”,更稳健但反应慢。生产上用错误率为主,因为连续失败对偶发毛刺过敏。
三、半开状态:试探策略的设计
熔断窗口到期后,不能一直关着(下游好了你不用),也不能全量放(下游没好你打死它)。所以要有 Half-Open,试探。但”试探”这两个字里全是设计决策:
决策一:放多少?
- 放 1 个(Hystrix 的做法):最保守。风险是单个请求的随机性太大——这个请求恰好遇到网络抖动就失败了,你又多等一个完整熔断窗口。
- 放 N 个(如 Resilience4j 的
permittedNumberOfCallsInHalfOpenState=10):用一批请求算成功率,统计更靠谱。这是更好的选择。
决策二:怎么算成功?
- 方案 A(逐次判定):第一个成功就立即恢复。快,但被单个样本绑架。
- 方案 B(批量判定):放 N 个,统计这 N 个的失败率,超过阈值继续熔断,否则恢复。推荐这个,它和 Closed 状态的判定逻辑一致,代码也统一。
决策三:半开期间超额的请求怎么处理? 被拒绝(快速失败 + fallback),不能排队等待——排队会让”试探”变成”积压”,一旦试探失败,积压的请求全部超时。
决策四:反复失败要不要延长熔断窗口? 要。这是指数退避思想:第一次熔断 5 秒,第二次 10 秒,第三次 20 秒…… 上限 60 秒。理由:如果下游反复失败,说明它在挣扎;你越频繁地试探,越是给它添乱。 这也是对下游的一种”体面”。
四、最关键的一条:熔断必须按依赖粒度
问一个致命问题:如果整个服务只有一个熔断器,会发生什么?
你的服务依赖 5 个下游:用户、商品、库存、推荐、风控。推荐服务挂了 → 全局失败率飙升 → 整体熔断 → 所有请求全部快速失败,包括那些根本不需要推荐服务的下单请求。
一个非核心依赖的故障,把整个服务变成了不可用。 这比不熔断还糟——至少不熔断时,不依赖推荐的请求还能成功。
所以规则是硬的:
每个(依赖 × 接口)一个熔断器实例。
实现上就是一个 Map<String, CircuitBreaker>,key 通常是 下游服务名 + 接口/方法名,甚至加上分库分表的分片维度。粒度越细,故障的”爆炸半径”越小。 代价是熔断器实例变多(内存、监控指标基数变大),但每个实例只有几十字节,完全不是问题。
同理,线程池隔离、连接池、限流器,全部都得按依赖粒度。这条规律可以概括成一句:
凡是用来”限制故障影响范围”的机制,它的粒度必须等于你想要的最小爆炸半径。
五、熔断器的两个副作用(必须提前知道)
副作用一:熔断会掩盖问题。 熔断一开,下游零流量,从监控上看下游”恢复了”(CPU 归零、错误率归零)——其实是没人调用了。如果没有”熔断器状态”这个指标进监控告警,你会发现故障被静悄悄地吞掉了。所以熔断器状态必须是指标、必须有告警、必须在 dashboard 上。
副作用二:熔断和重试会打架。 客户端重试 3 次,这 3 次如果分别计入熔断统计,失败率会被放大 3 倍,导致过早熔断。正确做法:一次逻辑调用的多次重试,在熔断器里只记一次结果(重试全失败才算一次失败,任一成功就算成功)。反过来,重试次数必须受到重试预算的硬约束(后面子主题十四谈)。
子主题十:隔离的层次——从线程到容器,以及”吵闹的邻居”
舱壁的原理子主题四讲过了,这里回答三个工程上真正难的问题:隔离到哪一层才够?线程池和信号量到底怎么选?多租户怎么隔?
一、线程池隔离 vs 信号量隔离:一个被误解多年的选择
先把机制摆清楚:
| 线程池隔离 | 信号量隔离 | |
|---|---|---|
| 执行位置 | 业务线程提交,池内线程执行 | 调用线程自己执行 |
| 开销 | 有队列 + 线程切换 | 一个计数器,几乎为零 |
| 能否超时中断 | 能(可以 cancel/future.get(timeout)) | 不能(在调用线程上阻塞,无法打断) |
| 能否排队 | 能(有等待队列) | 不能(拿不到信号量立即拒绝) |
| 能否异步 | 能 | 不能 |
| 内存 | 每线程 ~1MB 栈 | 几十字节 |
关键点在于第二行到第四行。 线程池隔离的价值,表面看是”限制并发数”,但真正不可替代的价值是”超时后能真正释放资源”:
- 线程池方案:请求提交给池,主线程
future.get(1s)超时就走 fallback,那个卡在慢依赖上的工作线程可以通过 future.cancel(true) 被中断(尽管中断不一定能真正杀死一个阻塞在 socket read 上的线程,但至少线程可以标记为可复用/被超时逻辑接管)。 - 信号量方案:请求在你的 Tomcat 业务线程上直接调下游。下游慢 5 秒,那这个业务线程就被占 5 秒,你没有任何办法把它抢回来——它就在那阻塞着。信号量只保证了”不会有第 11 个请求进来”,但那 10 个已经进去的,会一直占着 10 个宝贵的业务线程。
所以选择标准不是”开销大小”,而是这一问:
这个依赖会”不响应”(而不只是”慢”)吗?它会忽略你的超时设置吗?
- 如果依赖是可靠的、超时语义明确的(比如一个现代 HTTP client,你设了 1s 超时它一定 1s 内返回)→ 信号量足够,因为超时由客户端自己保证,不需要线程池来”兜底中断”。
- 如果依赖可能无限期挂起(老式的同步客户端、没设超时的 socket、第三方 SDK 内部自己封装了连接池且不可配)→ 必须用线程池,你需要一个”外部的、可以被放弃的执行容器”。
Hystrix 官方自己给出的结论很有意思:线程池的开销在 Netflix 的量级下实测约 1~3ms 的额外延迟(队列 + 上下文切换),官方建议绝大多数场景(他们原话是 99%)用信号量就够了,因为现代的 HTTP 客户端本身就有可靠的超时。线程池隔离是给”不可控依赖”准备的,不是默认选项。 很多人把 Hystrix 的线程池隔离当成”标准做法”抄过来,结果引入了不必要的延迟——这是典型的”拿着锤子找钉子”。
二、连接隔离:最容易被漏掉的一层
线程池隔住了,连接池呢?连接耗尽是级联故障里最常见的死法之一。
几个必须注意的点:
- 连接池大小和并发数是两回事,但必须匹配。 池子 10 个连接、并发 100,那 90 个请求在等连接——等待连接的时间算不算你的超时预算里? 如果不算,你的实际延迟会是”连接等待 + 请求处理”,远超预期。
- “最大连接数”必须小于下游能承受的并发数。20 个实例 × 每个 50 个连接 = 1000 个并发打到下游,下游扛得住吗?这是容量规划里最经典的算术错误:每个人都觉得”我这边才 50 个连接”,没人乘上实例数。
- 慢查询会同时耗尽连接池和线程池,而且是”占着不放”——这就是为什么数据库慢查询必须被语句级超时(
SET statement_timeout)和熔断双重保护。 - 单独指定”关键接口”的池。不要让报表类的慢查询和管理员后台的操作共用业务主连接池。
三、进程 / 容器隔离:硬边界的代价
cgroup / K8s 的 requests + limits 给了你真正的硬隔离:CPU、内存、网络带宽都是物理隔离的。
但这里有一个几乎每个人都踩过的坑:CPU limit 导致的 throttling。
K8s 的 CPU limit 是用 CFS 配额实现的:默认周期 100ms,你配 limit: 2 就意味着每 100ms 周期内最多用 200ms CPU 时间。用完了,进程被强制挂起直到下一个周期。
后果是什么?
- 一个 Java 服务,平时 CPU 使用率 30%(远低于 limit),看起来很安全。
- 突然来一波流量,30ms 内 CPU 打满,触顶被 throttle,进程被冻结 70ms。
- GC 线程也被冻结 → STW 时间被拉长到超过实际的 GC 时间 → RT 出现几百毫秒的尖刺。
- 更糟的是,这种尖刺在 CPU 使用率监控上看不出来(平均使用率才 30%),你只能看到
container_cpu_cfs_throttled_seconds_total这个指标在涨。
所以:对延迟敏感的在线服务,慎设 CPU limit(或者设得宽松些,只设 requests 保证调度,不设 limit 防止 throttle);对离线/批处理任务,反而要严格设 limit。 这是”隔离”和”延迟”之间一个非常具体的权衡。
四、多租户隔离:吵闹的邻居
SaaS 系统的经典问题:一个大客户的一次批量导出,让所有其他客户都变慢。
这不是”故障”,是”资源被合法地抢走了”,所以熔断和降级都管不了它,只能靠配额 + 隔离 + 优先级。三层缺一不可:
第一层:配额(Quota)——限制”能用多少” 给每个租户一个独立的令牌桶(key = tenantId),桶的容量 b 决定”允许的突发”,速率 r 决定”长期平均”。配额是按”请求数”还是按”成本”? 高明的做法是加权成本:一个跑全表扫描的报表请求算 100 个单位的成本,一个查单条记录算 1 个单位。否则按次数限流,租户只要发”重的”请求就能把你打死。
第二层:隔离(Isolation)——限制”最多能占多少” 配额管的是”长期平均”,但还需要一个”瞬时上限”。给每个租户一个并发信号量(比如最多同时 5 个请求),防止一个租户在同一瞬间把线程池占满。配额管总量,信号量管瞬时,两者是互补的。
第三层:优先级 / 公平调度(Fairness)——决定”先给谁” 光有前两层,租户之间还是会互相影响(都在排队)。真正的隔离需要加权公平队列(Weighted Fair Queueing):按租户权重分配服务时间,而不是简单的 FIFO。为什么 FIFO 不够? 因为 FIFO 下一个发 10 万个请求的租户,会把排在它后面的小租户的请求全部堵住——这就是队头阻塞(Head-of-Line Blocking),也是”吵闹的邻居”最直接的成因。
还有个工程细节:配额的 key 可以是复合的(租户 + 接口 + 用户等级),并且支持”热点参数限流”(Sentinel 的能力):比如限制”同一商品 ID 的查询每秒 1000 次”,防止某个爆款商品的请求打爆缓存。
子主题十一:降级的设计方法——分级、开关、演练
降级是这一章里唯一一个无法被框架代劳的机制。熔断、限流、隔离都有成熟库,但”降级返回什么”这个问题,只有你自己知道答案。
一、功能分级:怎么把 P0/P1/P2 定出来
P0/P1/P2 的说法人人会讲,但判定标准是什么? 给你一个可操作的提问法,对每个功能问三句话:
- 这个功能挂了,用户还能完成主任务吗? 不能 → P0。
- 这个功能挂了,用户会立刻察觉并抱怨吗? 会 → P1。
- 这个功能挂了,用户大概不会注意到吗? 是 → P2。
具体到电商的”浏览商品详情页”,拆开看:
| 能力 | 级别 | 挂了之后 |
|---|---|---|
| 商品标题/价格/库存 | P0 | 无法下单,页面等于白屏 |
| 立即购买 / 加入购物车 | P0 | 主链路断 |
| 商品主图 | P1 | 很别扭但能买 |
| 用户评价 | P1 | 影响转化但不阻断 |
| 个性化推荐、“猜你喜欢” | P2 | 无所谓,甚至能提升加载速度 |
| 销量精确数字 | P2 | 显示”已售 1万+“即可 |
| 店铺装修特效、3D 展示 | P2 | 关了甚至更快 |
关键认知:分级不是给”功能”分的,是给”一次请求里的每个数据片段”分的。 同一个页面里,标题是 P0、推荐位是 P2,粒度必须细到”字段级”,否则降级就是”整页开或整页关”,没有中间地带。
顺着这个思路,还有第二个同样重要的分级:依赖分级。
- 强依赖:没有它,这次请求无法完成(下单时的库存扣减)。→ 不能降级,只能重试/熔断后报错。
- 弱依赖:没有它,请求仍能完成,只是质量下降(下单时的优惠券推荐)。→ 必须可降级,且必须满足三条:超时要短(比如 200ms)、必须可异步或直接跳过、必须有兜底值。
一条铁律:任何被标记为”弱依赖”的调用,如果在代码里没有降级路径,那它事实上就是强依赖。 这就是为什么”依赖分级”这件事必须由架构评审强制,而不是靠开发自觉。
二、降级开关:怎么管理才能”真的能用”
降级要有开关。但开关的设计本身就是个分布式系统问题,很多团队的降级开关在真正需要时是失效的。
原则一:开关必须能秒级生效,且不能依赖发布。 用配置中心(Nacos / Apollo / etcd)的长轮询或推送,1~3 秒内推到所有实例。绝不能是”改配置文件然后重新发布” —— 故障时你等不起 20 分钟的发布。
原则二:开关本身必须高可用,且必须有”最后的兜底”。 问:配置中心挂了怎么办?三个层次:
- 客户端本地磁盘快照(启动时和每次变更时落盘),配置中心不可用时用快照。
- 环境变量 / 启动参数里的默认值,快照也没有时用它。
- 代码内的硬编码默认值——通常应该是”不降级”(或”安全降级”)。
第三条尤其重要:默认值该设成”开”还是”关”? 我的建议是默认不降级(关闭降级开关),因为你不知道配置中心为何不可用;但如果你有明确证据表明正处在故障中(比如本地熔断率飙升),应该有本地自愈逻辑自动切到保守模式。
原则三:开关要有多维度,且能灰度。 不要只有一个”全局降级开关”。至少要支持:
全局开关 > 应用 > 接口/功能 > 租户 > 用户百分比灰度
为什么必须有灰度? 因为降级逻辑本身可能有问题(见下条)。你要能先对 1% 的用户打开降级开关,观察无误后再全量。一个从未执行过的降级分支,第一次执行就全量,是在赌博。
原则四:开关要有元数据(谁、何时、为什么)。 开关变更必须记录操作人和时间,且能一键回滚。故障时会有多个人同时改开关,没有审计你根本不知道现在的状态是谁设的。
三、降级演练:那个最容易被跳过、代价最大的环节
讲个真实的经典事故:某大促,监控系统检测到推荐服务超时,值班同学淡定地打开”关闭推荐”的降级开关。
然后整个详情页 500 了。
为什么?因为那段降级代码从上线那天起就从未被执行过一次。它有 bug——fallback 方法里少判了一个 null,直接在渲染层抛了 NPE。降级代码成了系统里唯一没有测试覆盖、却又在关键时刻必须正确的代码。
这个问题的根源是:降级路径是”冷代码”。它的执行概率接近 0,所以:
- 单元测试可能没覆盖(或者测了 mock,没测真实链路)
- 监控可能没覆盖(降级后的指标没打点)
- 代码腐化:上游接口改了,fallback 的返回类型对不上了,编译期可能还好(泛型/JSON),运行时才炸
所以降级演练不是”可选项”,它是降级方案的验收标准。 具体怎么做:
- 纳入混沌工程:定期(比如每月)在生产环境的低峰期,用少量流量真实触发降级。Netflix 的 Chaos Monkey 就是这个思路——主动制造故障,验证容错真的有效。
- 开关的”双路验证”:打开降级开关后,在代码里同时执行降级逻辑和原逻辑(影子执行),对比两者结果,降级逻辑的异常被捕获并告警,但返回降级结果。这样能在不影响用户的情况下持续验证降级路径的正确性。
- 压测中验证:每次大促前的全链路压测,必须包含”降级开关全开”这一场景,验证降级后系统容量确实提升了(这是降级的意义所在——如果降级后容量没提升,说明你降错了地方)。
- 定期演练清单化:把每个降级开关、演练步骤、预期效果写成 runbook,让不同值班的人都能操作。故障时没人有耐心读代码。
四、自动降级 vs 手动降级
自动降级:基于指标自动触发(失败率 > X、平均 RT > Y、CPU > Z、熔断器打开)。
自动降级的两个必备设计:
- 防抖动(磁滞 / hysteresis):触发阈值和恢复阈值必须不同。 比如 CPU > 80% 触发降级,CPU < 60% 才恢复。如果都是 80%,系统会在阈值附近疯狂开关,产生振荡——降级本身成了新的故障源。
- 冷却时间:恢复后至少观察 N 分钟才能再次自动降级/恢复。
手动降级:人来判断,人工执行。慢,但可靠且可控。
结论:自动降级负责”秒级止损”,手动降级负责”复杂判断”。 不要指望自动降级能处理所有情况——机器不知道”现在是双十一零点,推荐位可以多扛一会儿”,但值班的人知道。反过来说,只有手动没有自动,你的止损时间就是人的响应时间(可能 5 分钟),而级联故障在系统里的传播时间往往是 30 秒。
子主题十二:业界对照——Hystrix、Resilience4j/Sentinel、与服务网格
这三个方案代表了三种不同的哲学,把它们放在一起看,你能看到容错机制”下沉”的完整路径。
一、Hystrix(2012,Netflix):把容错做成”一套世界观”
Hystrix 不只是一个熔断库,它是一个完整的容错框架,把这一章讲的所有东西打包成了一件事:
- 熔断:滚动窗口 + 三态机
- 隔离:线程池隔离(默认)/ 信号量隔离
- 降级:
getFallback(),这是 API 的强制部分——你必须写 - 超时:命令级超时配置
- 请求缓存:同一请求上下文内的结果复用
- 请求合并(Collapser):把多个单条查询合并成批量查询
- 监控:Hystrix Dashboard + Turbine 聚合
Hystrix 的核心抽象是 Command 模式:所有对外部依赖的调用都包装成一个 HystrixCommand,由框架统一调度。
它的历史贡献是什么? 是把”容错”从若干个零散技巧(超时、重试、限流)变成了一个标准化的、有名字的模式体系,并且第一次让”每个依赖一个线程池 + 一个熔断器 + 一个 fallback”成为行业默认心智模型。
它为什么在 2018 年进入维护模式并最终停更? 三个原因:
- 太重。每个依赖一个线程池,几十个依赖就是几十个线程池、上千个线程,上下文切换和内存开销可观。前面说过 Netflix 自己承认线程池开销约 1~3ms。
- 侵入性太强。Command 模式要求你把业务逻辑包进
run()方法里,和 Spring 的声明式编程风格、和后来的响应式编程(Project Reactor / RxJava)都不协调。 - 容错的需求变了。随着服务网格兴起,超时、重试、熔断这些”通用”能力被认为应该下沉到基础设施,而不是塞在应用代码里。
二、Resilience4j(2019+):轻量、函数式、可组合
Resilience4j 是作为 Hystrix 的替代品出现的,设计取向完全相反:
| Hystrix | Resilience4j | |
|---|---|---|
| 依赖 | 重(Archaius、RxJava) | 极轻(只依赖 Vavr) |
| 隔离默认值 | 线程池 | 信号量 |
| 编程模型 | Command 继承 | 装饰器 / 函数式(decorateSupplier) |
| 模块化 | 大而全 | 按需组合(CircuitBreaker / RateLimiter / Bulkhead / TimeLimiter / Retry 各自独立) |
| 熔断统计 | 滚动窗口(10×1s) | 环形缓冲区(count-based / time-based),BitSet 实现 |
| 新增能力 | — | 慢调用率熔断、限流器(令牌桶,带预热) |
最能体现设计取向差异的是”装饰器 + 组合”:
Supplier<String> decorated = Decorators.ofSupplier(() -> callRemote())
.withCircuitBreaker(circuitBreaker)
.withRateLimiter(rateLimiter)
.withBulkhead(bulkhead)
.withRetry(retry)
.withFallback(throwable -> "兜底值")
.decorate();为什么这个更好? 因为容错策略不是业务逻辑,它是横切关注点(cross-cutting concern)。装饰器让你可以”把策略贴到任何函数上”,包括 lambda、包括已有的第三方调用。而 Hystrix 强制你改写代码结构。
它还补上了 Hystrix 缺失的两块:慢调用熔断(子主题九说过,慢比错更危险)和开箱即用的限流器(AtomicRateLimiter / SemaphoreBasedRateLimiter)。
阿里的 Sentinel 代表了另一条路:以流量为视角。它的核心是”资源 + 规则”模型——围绕资源(URL 或方法)配置流控规则(QPS/线程数/关联限流/链路限流)、熔断规则、热点参数限流、系统自适应保护(CPU/负载/RT/并发)。Sentinel 的特色是控制台实时下发规则和系统级自适应保护,这在阿里双十一的场景下非常实用。
三、服务网格(Istio / Envoy):把容错下沉到基础设施
然后出现了第三种思路,最激进的一种:
既然超时、重试、熔断、限流是所有服务都需要的通用能力,为什么要让每个团队在各自的代码里各写一遍(还可能写错、还可能用不同的语言写错)?
服务网格的回答:把 sidecar 代理(Envoy)放在每个 Pod 旁边,所有进出流量都经过它,容错策略由 sidecar 执行,通过控制面(Istio)统一下发。
Envoy 提供的能力,和这一章的对应关系:
- 超时、重试:
VirtualService里的timeout/retries(带 retry budget 和 backoff) - 熔断:两层——
ConnectionPoolSettings:maxConnections、maxPendingRequests、maxRequests、maxRetries(这是”舱壁”,限制到某个上游的连接和并发)OutlierDetection:,连续 5xx / 网关失败达到阈值就把该实例踢出负载均衡池一段时间(这是”实例级熔断”,比服务级熔断更细)
- 限流:本地限流(Envoy 内 token bucket)+ 全局限流(Rate Limit Service,即子主题七的混合模型)
- 故障注入:
fault injection直接注入延迟和 5xx,这是混沌工程的基础设施化
这个方案赢在哪里?
- 语言无关。Java、Go、Python、Node 全都一样,不用每个语言找一个 Hystrix。
- 策略统一、集中治理。一次配置,全网格生效;可以强制全公司统一超时/重试策略。
- 零侵入。业务代码不用改一行,老系统也能享受。
- 策略可以热更新,不用发布应用。
代价是什么?
- sidecar 成为新的故障点和延迟源。 每个请求多两跳代理(出 + 入),通常增加 1~5ms 延迟。对 RT 极敏感的服务,这是不能接受的。
- 运维复杂度陡增。 你得养一个懂 Envoy xDS 的团队。控制面挂了会怎样?sidecar 挂了会怎样?(Envoy 挂了 Pod 就完全失联——这是比应用内熔断更严重的单点。)
- 可观测性变难。 一个请求的延迟,可能是业务代码、可能是 sidecar、可能是网络,排查链路变长。
- 最关键的一点:它看不见业务语义。 这是服务网格方案的根本局限。
第 4 点值得展开。Envoy 能知道”这个请求超时了”,但它不可能知道”这个请求是详情页的推荐位还是下单的库存查询”。 因此:
超时、重试、熔断、限流、实例剔除,可以且应该下沉到基础设施。而”降级”——返回什么兜底值、牺牲哪个功能、保住哪个用户——永远不能下沉,因为它需要业务语义。
这就是这一章最值得记住的一条边界:基础设施能解决”怎么做快速失败”,但只有业务代码能回答”失败之后给什么”。
四、演进的脉络
应用内自己写 try-catch + 超时
↓
Hystrix:把熔断+隔离+降级打包成框架(侵入式、重)
↓
Resilience4j / Sentinel:轻量、可组合、按依赖粒度(仍是应用内)
↓
Istio / Envoy:通用容错能力下沉到 sidecar(无侵入,但丢失业务语义)
↓
自适应过载保护:从"人工设定阈值"到"系统自己感知容量"
注意这条线不是”后者淘汰前者”,而是职责的重新划分:通用的下沉,业务的留在应用里。今天的标准实践是:基础设施层(网关/网格)做限流、超时、重试、实例剔除;应用层保留熔断、隔离、降级,因为只有应用知道业务语义。
子主题十三:实操——一个完整的过载保护方案怎么设计
把前面所有内容收拢,回答最后一个问题:如果让你从零给一个中等规模的分布式系统(比如 20 个微服务)设计过载保护,你会怎么分层?
四层防线
流量
│
┌─────▼──────────────────────────────────────────┐
│ 第 1 层:限流(入口 + 出口) │ 解决:总量超过容量
│ 入口:网关按用户/IP/租户做令牌桶 │
│ 出口:per-下游依赖的并发限制 + 速率限制 │
├─────────────────────────────────────────────────┤
│ 第 2 层:熔断(per 依赖 × 接口) │ 解决:依赖故障时的快速失败
│ 滑动窗口统计错误率 + 慢调用率 │
│ 三态机 + 指数退避的半开试探 │
├─────────────────────────────────────────────────┤
│ 第 3 层:隔离(资源切分) │ 解决:故障横向扩散
│ per-依赖信号量 / 连接池 / 容器资源限额 │
│ 多租户配额 + 并发上限 + 公平队列 │
├─────────────────────────────────────────────────┤
│ 第 4 层:降级(牺牲非核心) │ 解决:资源不够时保什么
│ P0/P1/P2 分级 + 配置中心开关 + 演练 │
└─────────────────────────────────────────────────┘
│
┌─────▼──────────────────────────────────────────┐
│ 底座:可观测性(指标/告警/dashboard)+ 混沌演练 │
└─────────────────────────────────────────────────┘
为什么是这个顺序? 因为从外到内,每一层的成本递减、保护粒度递增:
- 限流在最外层,因为它最便宜(拒绝一个还没进系统的请求,零成本)。
- 熔断在第二层,因为它需要”知道下游的状态”,但成本仍然极低(快速失败)。
- 隔离是结构性的(平时就在那,故障时自动生效),不需要”触发”。
- 降级是最后手段,因为它有业务代价(牺牲功能/体验)。
每层各解决什么,缺了会怎样
第 1 层:限流
- 解决:“总量超过我能处理的”。包括外部流量超预期,和”我打给下游的量超了下游的承诺”。
- 缺了它:突发流量 / 重试风暴直接把系统推入拥塞崩溃;或者你把下游打死,被下游拉黑。
- 配置要点:入口按成本加权而非请求数;出口按下游实际容量而非你的意愿;配上自适应(子主题八)。
第 2 层:熔断
- 解决:“依赖已经不行了,别再等它了”——把 5 秒的等待变成 1 毫秒的失败。
- 缺了它:慢依赖吃光线程池,级联故障(开篇那个事故)。
- 配置要点:per 依赖 × 接口粒度;最小请求数门槛;区分 4xx/5xx;慢调用率也算;状态要进监控。
第 3 层:隔离
- 解决:“故障不应该横向扩散”——A 依赖耗尽了资源,B 依赖不该受影响;租户 A 的批量任务不该影响租户 B。
- 缺了它:所有依赖共用一个池,任何一个出问题就是全局问题;一个大客户能打爆全平台。
- 配置要点:默认信号量(轻量);对”可能不响应”的依赖用线程池;连接池大小 × 实例数 < 下游容量;多租户三层(配额 + 并发 + 公平队列)。
第 4 层:降级
- 解决:“资源不够时,我选择保什么”——这是唯一一个主动的、有业务判断的机制。
- 缺了它:故障时只有”全有或全无”,而那通常意味着全无。
- 配置要点:字段级分级;开关秒级生效 + 本地快照兜底;必须演练;自动降级要有磁滞。
四个不能忘的配套
配套一:超时预算(Timeout Budget)。 链路 A → B → C,如果每层都设 5 秒,用户最多等 15 秒,而前端 3 秒就超时了——前 3 层的等待全部是浪费。正确做法是自顶向下分配:
用户可接受总时间 3s
→ A 超时 3s(A 自己留 600ms 处理时间)
→ B 超时 2.4s
→ C 超时 1.5s(C 本身 300ms,留重试余量)
规则:外层超时 > 内层超时 + 本层处理时间;如果内层要重试 N 次,必须把 N 倍的单次超时算进外层预算。 现在更进一步,用 deadline 传递(gRPC 的 deadline):把”还剩多少时间”随请求一起传下去,下游拿着剩余的 budget 自己决定还值不值得干。这比逐层配置超时更本质,因为它处理的是”剩余时间”而非”固定时间”。
配套二:重试预算(Retry Budget)。 重试是级联故障的加速器(子主题一)。三条硬规则:
- 重试次数必须有上限,且只在”可能成功”的错误上重试(超时、503、连接被拒可以重试;400、401 重试一万次也一样)。
- 全局重试预算:重试请求数不超过总请求数的 10%(Google SRE 的建议值)。用令牌桶实现:每次重试消耗令牌,令牌按成功请求数的 10% 速率补充。
- 必须加 jitter(抖动)。1000 个客户端同时超时同时重试,就是 1000 个请求的同步冲击;加随机抖动后它们会被摊开。这是”避免同步”这个通用思想在重试上的应用。
配套三:可观测性。 你必须能回答这些问题,最好在一个 dashboard 上:
- 现在有哪些熔断器处于 Open?
- 各接口当前的限流拒绝率是多少?
- 各依赖的 P99 延迟和慢调用率?
- 哪些降级开关是打开的?
- 每个租户的资源消耗 Top 10?
没有这些指标,你的容错机制就是”瞎保护”——你不知道它在保护还是在误杀。特别是熔断器状态和限流拒绝率,这两个指标必须告警。
配套四:混沌演练。 定期注入故障(杀实例、加延迟、打满 CPU、踢掉网络),验证四层防线真的生效。不演练的容错方案,价值等于零(子主题十一那个推荐位 NPE 的故事)。
最后一个反问
有了这四层防线,你的系统就”高可用”了吗?
没有。因为这四层都在做同一件事:在资源不足时,选择让谁失败。 它们不创造容量,只分配痛苦。
真正的高可用,第一顺位永远是容量与架构:水平扩容能力、无状态设计、消除单点、异步化削峰、缓存、读写分离。容错机制是”当容量不够时的止损手段”,不是容量的替代品。
一个只能靠熔断降级续命的系统,本质上是容量不足的系统。先问”为什么容量不够”,再问”容量不够时先牺牲谁” —— 顺序反了,你会把降级开关当成架构设计,那是一条通往长期技术债的路。
子主题十四:组合陷阱与自检清单
最后这一节是”踩过的坑”。这些机制单独看都很清楚,组合起来会出现单个机制没有的问题。
陷阱一:限流 + 重试 = 放大
你在客户端加了重试(3 次),又在服务端加了限流。看起来很完善。结果:服务端开始限流 → 客户端收到 429/503 → 触发重试 → 请求量变成 3 倍 → 服务端限流更狠 → 更多重试。正反馈。
修法:服务端在限流响应里明确区分”可重试”和”不可重试”(用 Retry-After 头和专门的响应码),客户端绝不对”限流拒绝”做立即重试(那只会雪上加霜),只能对”连接失败/超时”重试,且必须受重试预算约束。
陷阱二:熔断 + 重试 = 永远打不开
熔断器半开放了 1 个试探请求,这个请求重试了 3 次,如果这 3 次都算进统计,失败率会被放大。更糟的是:重试期间的多次失败可能让熔断器立刻又跳回 Open,于是下游永远没有机会被证明已恢复。
修法:一次逻辑调用只记一次结果;并且重试必须在熔断器的”外层”(熔断判定看到的是最终结果),而不是反过来。
陷阱三:线程池隔离配了 200 个线程
“既然要隔离,那多给点线程”——这是最危险的想法。线程池大小不是”越大越好”,它是”限流”的一种形式。 200 个线程意味着允许 200 个并发打到下游,20 个实例就是 4000 并发。你的”隔离”变成了”放大”。
合理值怎么定?用 Little’s Law:线程数 = 目标QPS × 目标延迟。若你要支撑该依赖 100 QPS、延迟 50ms,那 100 × 0.05 = 5 个线程就够了,留点余量给 10 个。超出这个数的线程,不会提升吞吐,只会在故障来临时排队更久。
陷阱四:超时设得比降级收益还长
弱依赖设了 3 秒超时,但整个页面的预算只有 800ms。这个 3 秒的超时配置等于没有——用户早就走了,你还在等。
铁律:弱依赖的超时必须显著小于主链路预算,且远小于强依赖的超时。 弱依赖 200ms,强依赖 1s,这是一个常见的合理配比。
陷阱五:只有入口限流,没有出口限流
你在网关限了 1 万 QPS,看起来安全。但一个请求可能放大成下游 100 次调用(N+1 查询、fan-out 扇出)。网关的 1 万 QPS,到依赖那一层就是 100 万 QPS。
所以出口限流(per-依赖的并发/速率限制)不是可选项。 子主题十三的四层图里,“限流”横跨入口和出口,就是这个原因。
陷阱六:把降级当常态
降级开关打开了,忘了关,半年后没人敢关(不知道依赖了什么)。这是真实发生的事情。
修法:开关必须有超时自动关闭或定期审计(“这个开关开了 30 天,请确认”);开关状态进 dashboard;开关的”预期正常状态”要写进元数据。
自检清单
给你一份可以直接拿去评审的清单:
熔断
- 是否 per 依赖 × 接口粒度?(不是一个全局熔断器)
- 是否设置了最小请求数门槛?
- 是否区分了 4xx(不熔断)和 5xx/超时(熔断)?
- 是否配置了慢调用率熔断?
- 熔断器状态是否是指标 + 有告警?
- 半开是否用批量判定 + 指数退避?
限流
- 令牌桶的 b(桶容量) 是否是你真正能承受的瞬时 QPS?
- 是否区分了入口限流和出口限流?
- 分布式限流是否有 Redis 故障时的 fail-open 策略 + 本地兜底?
- 是否按”成本”而非”次数”限流?
- 是否有自适应层(CPU/延迟/排队)?
隔离
- 是否默认用信号量(而非无脑线程池)?
- 连接池大小 × 实例数 是否 < 下游容量?
- 多租户是否有配额 + 并发 + 公平队列三层?
- 是否设置了 K8s CPU limit?如果是,是否监控了 throttling 指标?
降级
- 功能分级是否细到字段级?
- 依赖是否区分强弱?弱依赖是否真的有降级路径?
- 开关是否秒级生效 + 有本地快照兜底?
- 降级路径是否被执行过(演练/影子执行)?
- 自动降级是否有磁滞(触发/恢复阈值不同)+ 冷却?
配套
- 链路超时是否自顶向下分配(外层 > 内层)?
- 重试是否有预算(≤10%)+ jitter + 只对可重试错误?
- 是否有 dashboard 能看到熔断状态/拒绝率/降级开关?
- 是否定期做混沌演练?
演进脉络
- 早期:无系统性容错,靠人工干预和重启。
- 2000s:超时和简单重试成为标配,但重试风暴问题频发。
- 2010s:Netflix 的 Hystrix 把熔断器模式带入主流;限流算法(令牌桶等)标准化;Netflix 提出”混沌工程”,用故障演练验证容错。
- 2010s 后期:Google SRE 提出”重试预算”和”自适应限流”。
- 现代:服务网格(Istio/Envoy)把熔断、限流、重试下沉到基础设施层,应用代码不再需要关心;自适应过载保护(根据系统负载而非固定阈值来限流)成为新趋势。
主线:容错机制的演进,是从”被动反应”(超时重试)走向”主动防御”(熔断、限流、隔离、降级),再走向”主动验证”(混沌工程)和”自适应”(根据实时负载动态调整)。核心认知的升级在于:故障不是意外,而是常态——系统必须为”部分失败”而设计,而不是假装它不会发生。
本章思考题
【思考题】
-
级联故障的”放大器”是什么?为什么一个服务”慢”能拖垮整个系统?
-
熔断为什么能防止级联故障?“半开”状态解决什么问题?
-
令牌桶和漏桶的区别是什么?为什么互联网场景更常用令牌桶?
-
什么是舱壁隔离?它带来了什么代价?优雅降级的前置工作是什么?
-
固定窗口限流的”边界突刺”具体是怎么产生的?为什么说”把窗口调小”治不了它?请用一个具体时序说明。
-
令牌桶只有两个参数 r(生成速率)和 b(桶容量),请说明它们各自决定了什么,以及一个常见的配置陷阱。
-
漏桶和令牌桶在实现思想上有一个关键差异:漏桶常用”队列等待”,令牌桶常用”惰性填充的计数器”。请说明”惰性填充”为什么不需要定时任务,以及 Guava RateLimiter 的”预支令牌”会带来什么后果。
-
分布式限流有”中心化”和”本地配额”两种思路,各自的不准确/不适用之处是什么?工业界的混合方案是怎么工作的?它必须处理哪个冷启动坑?
-
为什么”固定阈值限流”在真实系统里不够好?请至少说出三个导致容量动态变化的原因。
-
CoDel 的核心主张是”队列长度会骗人,排队延迟不会”。为什么?它丢弃队首而不是队尾,理由是什么?
-
Google SRE 的客户端侧自适应节流用了 requests 和 accepts 两个计数,请说明 K 的含义,以及这个算法为什么能天然适应多后端场景。
-
一个合格的熔断判定需要同时满足哪些条件?为什么”慢调用”必须和”错误”一样被计入?
-
熔断为什么必须按依赖粒度(而不是全局)?如果整个服务只有一个熔断器,会发生什么糟糕的事?
-
线程池隔离和信号量隔离,真正的取舍标准是什么?Hystrix 官方给出的建议是什么,为什么?
-
多租户资源隔离为什么需要”配额 + 隔离 + 优先级”三层?只做配额层够不够,缺了公平队列会怎样?
-
降级开关的设计里,“默认值该设成开还是关”、“配置中心挂了怎么办”、“为什么必须能灰度”——请分别回答。
-
为什么”降级路径”是最容易出事的代码?有哪几种手段可以在故障前验证降级真的有效?
-
服务网格(Envoy/Istio)把容错下沉到基础设施,它赢在哪里、代价是什么?哪一类容错能力永远不能下沉?为什么?
-
设计过载保护的四层防线(限流→熔断→隔离→降级),请说明每层各自解决什么问题,以及”缺了这一层”会导致什么。
-
重试和限流、重试和熔断组合使用时,会出现什么反直觉的问题?分别怎么修?
【参考答案】
-
两个放大器:一是同步等待导致的资源占用(线程/连接/内存被慢依赖占满,导致调用方自己也变慢,层层向上传染);二是重试导致的流量放大(超时→重试→请求翻倍→更慢→更多重试,形成正反馈)。所以一个服务”慢”会通过”占用调用方资源”向上传染,最终让整条链路上的服务都耗尽资源,即使它自己根本没崩溃。
-
熔断把”等待超时”(线程占用 5 秒)变成”立即失败”(线程占用 1 毫秒),资源占用降低几个数量级,从而切断了”资源被慢依赖占满”这条传染链。半开状态解决”如何知道下游何时恢复”的问题:熔断窗口过后放少量请求试探,成功则恢复、失败则继续熔断——既避免一直熔断(下游已恢复却不用),又避免一下子放全量流量把未完全恢复的下游再次打死。
-
漏桶:请求进桶,以恒定速率出水,桶满拒绝——强制输出平滑,无论输入多猛。令牌桶:以恒定速率生成令牌,请求取令牌,没有则拒绝——限制平均速率,但允许桶内积攒的令牌应对突发。互联网场景更常用令牌桶,因为用户访问天然是突发的,强制平滑会伤害体验;令牌桶在”限制平均速率”和”允许合理突发”之间取得了平衡。
-
舱壁隔离:把资源(线程池/连接池/内存)按功能或调用方分开,一个地方耗尽不影响其他地方(如为每个下游依赖分配独立线程池)。代价是资源利用率降低——某个池空闲时别的池不能用它的配额,这是”资源效率 vs 故障隔离”的权衡。优雅降级的前置工作:平时就给功能分级(P0 核心必须有 / P1 没了难受但能用 / P2 没了无所谓),并做成可快速切换的开关;没有这个分级,故障时只能”全有或全无”,而那往往意味着全无。
-
固定窗口按”对齐的时间窗口”计数(如 key = 分钟序号),窗口翻页时计数清零。若限流 1000 次/分钟,攻击者可在 00:59:58~00:59:59.999 打满 1000 次,01:00:00.000 窗口翻页后再打满 1000 次——3 秒内实际通过 2000 次,瞬时 QPS 是”平均 16.7”的几十到几百倍。上界 2×limit 的来源是:任意长度 W 的区间最多横跨两个对齐窗口,每个最多放 limit 个,所以是 2×limit。把窗口调小(如 1 秒)只是把突刺压缩到更短时间内,最坏情况仍是”1 秒内 2 倍”,误差倍率不变,所以调小窗口治不了根,只能改用滑动窗口(分片或日志)。
-
r 决定长期平均速率(系统对下游/自身的长期承诺),b 决定允许的最大突发量(桶攒满后可一口气放行 b 个请求)。常见陷阱:把 b 配得很大(如 r=100、b=1000),以为”限的是 100 QPS”,实际瞬时能放进 1000 QPS——系统必须能扛住 b 这个瞬时冲击,很多事故就出在只盯着 r 而忽略了 b。
-
惰性填充:只需保存
tokens和lastRefill两个变量,每次请求到来时用tokens = min(b, tokens + (now - lastRefill) × r)一次性补上这段时间的令牌,因此不需要任何定时任务或后台线程,是 O(1) 时间 O(1) 内存的操作。Guava RateLimiter 的 SmoothBursty 允许”预支”:桶里令牌不足时仍放行,但把欠账记到nextFreeTicketMicros,由下一个请求等待偿还。后果是:突发不是免费的,它会让后续请求变慢——限流变成了”整形”(让你慢下来)而不是”拒绝”,好处是削峰、不丢请求,坏处是延迟被转嫁给后续调用,使用方需要知道这个语义。 -
中心化(Redis + Lua):准确,但每次限流多一次网络往返(增加 RT)、Redis 成为关键路径上的新依赖(需决定 fail-open 还是 fail-close)、存在热 key 问题;且要注意”只在首次 INCR 时设过期”会因进程崩溃导致 key 永不过期(永久限流)。本地配额(总配额 ÷ 实例数):零开销,但假设流量均匀分布,而该假设基本不为真——负载倾斜时某实例会大量误杀(如某实例分到 10% 流量却只有 0.5% 的配额,误杀率 98%);弹性伸缩、实例宕机也会让总配额漂移。混合方案:本地持令牌桶做判定(热路径零网络开销),每 T 秒(1~10s)向协调器上报消耗量,协调器按上一周期实际流量占比重新分配配额并下发;必须处理的冷启动坑是新实例没有历史流量会被分到 0 配额而永远起不来,所以要给每个实例一个”最小保底配额”。
-
因为容量不是一个常数,而是随环境持续变化的量:①机器数与机器健康度(宕机、GC、CPU throttling 都会瞬间打折);②下游延迟——由 Little’s Law,QPS 上限 = 并发数/延迟,下游慢一倍容量直接腰斩,而你的代码一行没改;③请求构成差异(一个带 200 个商品的下单请求成本可能是带 5 个的几十倍,按”次数”限流不等于按”成本”限流);④机器异构与混布(noisy neighbor);⑤缓存命中率(缓存一崩,容量掉一个数量级)。更根本的是:静态阈值需要压测标定且很快过期,而系统偏离常态的时刻(故障、发布、高峰)恰恰是容量变化最大的时刻——你用常态下的阈值去保护异常态。
-
队列长度是瞬时快照:突发流量瞬间填满队列但很快排空(无害),持续过载也是”队列满”——两者在长度上无法区分;而排队延迟是积分结果,能稳定反映”系统是否持续排队”。CoDel 因此用”观察窗口内最小的 sojourn time(停留时间)“作判据(最小值能穿透噪声),持续超过 target(5ms)达 interval(100ms)才丢弃,并按
interval/√count加速丢包频率。丢队首(drop from head)而非队尾的理由:等最久的请求大概率已经注定超时,继续等毫无意义;而刚到的请求还有可能被及时处理并成功——这是过载场景下 LIFO 优于 FIFO 的原因,优先服务”还有救的”。 -
客户端为每个后端维护最近 2 分钟的
requests(发出总数)和accepts(后端非”因过载拒绝”的响应数,业务错误也算 accepts)。当requests > K × accepts时,以概率p = (requests - K×accepts)/(requests+1)在本地直接拒绝。K 的含义是对浪费的容忍度:K=2 表示”允许发出 2 个只成功 1 个”这种程度的浪费,超过才开始砍;K 越大越激进、越小越保守。它天然适应多后端,因为客户端对每个后端独立维护一组计数,一个后端过载只节流发给它的流量,其他后端不受影响;同时被本地节流掉的请求不消耗网络与后端资源,因此也抑制了重试放大。核心思想是:过载信号的最优来源是”被保护者自己的反馈”,而非外部观测者的猜测。 -
合格判定需要同时满足:①最小请求数门槛(窗口内请求数 ≥ N,如 20~100),否则 2 个请求错 1 个就误熔断——低流量期没有这个门槛会疯狂误熔断;②错误类型区分:5xx/超时/连接拒绝该熔断(依赖的健康问题),4xx 不该(那是调用方的问题,把 400 当依赖故障等于”用户输错密码就判定支付服务挂了”);③聚合口径:推荐用滑动窗口内的错误率(对偶发抖动稳健)而非”连续失败 N 次”(对毛刺过敏)。慢调用必须计入,因为在高并发下慢比错更危险:错误会立刻释放线程,慢调用会一直占着线程直到超时——它和错误一样消耗资源,却不会触发”失败”统计。所以 Resilience4j 提供独立的 slowCallDurationThreshold 和 slowCallRateThreshold,慢调用率超阈值也熔断。
-
因为一个全局熔断器会把”某个非核心依赖的故障”放大成”整个服务不可用”:推荐服务挂了 → 全局失败率飙升 → 整体熔断 → 连根本不需要推荐服务的下单请求也被快速失败,这比不熔断还糟(不熔断时至少不依赖推荐的请求还能成功)。所以规则是每个(依赖 × 接口)一个熔断器实例,用 Map 维护,粒度越细爆炸半径越小。推广成普遍规律:凡是用来限制故障影响范围的机制(熔断、线程池、连接池、限流器),其粒度必须等于你想要的最小爆炸半径。
-
真正的取舍标准不是开销大小,而是这一问:这个依赖会不会”不响应”、会不会忽略你设的超时? 线程池隔离不可替代的价值是”超时后能真正释放资源”(可 cancel、可中断、可排队、可异步),代价是队列 + 上下文切换开销(Netflix 实测约 1~3ms)和每线程约 1MB 栈;信号量隔离只是个计数器,几乎零开销,但在调用线程上直接执行,无法中断一个阻塞在慢依赖上的线程,也没法排队或异步。所以:依赖本身有可靠超时语义(现代 HTTP client)→ 信号量足够;依赖可能无限期挂起(老式同步客户端、不可配超时的第三方 SDK)→ 必须用线程池。Hystrix 官方的建议是绝大多数场景(原话约 99%)用信号量就够了,因为现代客户端自带可靠超时;线程池是给”不可控依赖”准备的,不是默认选项。
-
三层各管一件事,缺一不可:配额层(每租户独立令牌桶)管”长期平均能用多少”,宜按加权成本而非请求数计(否则租户只要发重请求就能打死你);隔离层(每租户并发信号量)管”瞬时最多能占多少”,防止一个租户瞬间占满线程池——配额管总量、信号量管瞬时,互补;优先级/公平调度层(加权公平队列)决定”排队时先服务谁”。只做配额不够,因为缺了公平队列就仍是 FIFO,一个发 10 万请求的租户会把排在后面的小租户请求全部堵住——这正是队头阻塞(Head-of-Line Blocking),也是”吵闹的邻居”最直接的成因。
-
①默认值建议设为”不降级(关闭降级开关)“,因为你不知道配置中心为何不可用,贸然降级可能引入未经验证的冷代码路径;更稳妥的是保留本地自愈逻辑(如本地熔断率飙升时自动切保守模式)。②配置中心挂了要有三级兜底:客户端本地磁盘快照 → 启动参数/环境变量 → 代码内硬编码默认值;开关绝不能是”改配置重新发布”(故障时等不起发布)。③必须能灰度,因为降级路径是几乎没有执行过的冷代码,第一次执行就全量是在赌博;要能按 全局 > 应用 > 接口 > 租户 > 用户百分比 逐级放开,先 1% 验证再全量。
-
因为降级路径是”冷代码”:执行概率接近 0,导致单元测试可能只测了 mock 没测真实链路、监控没打点、代码腐化(上游返回结构变了、fallback 少判 null 直到运行时才炸)。经典事故就是”打开降级开关后整个页面 500,因为 fallback 代码从上线起从未执行过”。验证手段:①纳入混沌工程,在生产低峰期用少量流量真实触发降级;②影子执行——打开开关后同时跑降级逻辑和原逻辑,降级结果返回给用户,原逻辑的异常被捕获告警,从而在不影响用户的前提下持续验证正确性;③压测中包含”降级开关全开”场景,验证降级后容量确实提升(若没提升说明降错了地方);④把开关、步骤、预期效果写成 runbook 并定期演练(故障时没人有耐心读代码)。
-
赢在:①语言无关(不用每个语言找一个 Hystrix);②策略集中统一下发,全网格一致;③零侵入,老系统也能享受;④策略热更新无需发布;⑤Envoy 还提供实例级熔断(OutlierDetection,把连续 5xx 的实例踢出负载均衡池)和故障注入(混沌工程基础设施化)。代价:①sidecar 成为新的故障点和延迟源(每请求多两跳,通常 +1~5ms,且 sidecar 挂掉 Pod 就完全失联);②运维复杂度陡增,需要懂 xDS 的团队;③排查链路变长。永远不能下沉的是”降级”:Envoy 能知道”这个请求超时了”,但不可能知道”这是详情页的推荐位还是下单的库存查询”,而降级要回答的恰恰是”失败之后给什么、牺牲哪个功能、保住哪个用户”——这需要业务语义。通用容错(超时、重试、熔断、限流、实例剔除)可以下沉,业务降级必须留在应用里。
-
四层从外到内,成本递减、粒度递增:①限流(入口按用户/租户/成本做令牌桶,出口 per-依赖限并发与速率)解决”总量超过容量”——缺了它,突发流量与重试风暴直接把系统推入拥塞崩溃,或把下游打死;②熔断(per 依赖 × 接口,三态 + 指数退避半开)解决”依赖不行了别再等它”——缺了它,慢依赖吃光线程池,就是开篇的级联故障;③隔离(per-依赖信号量/连接池、容器资源限额、多租户配额)解决”故障不该横向扩散”——缺了它,一个依赖或一个租户出问题就是全局问题;④降级(P0/P1/P2 分级 + 秒级开关 + 演练)解决”资源不够时保什么”——缺了它,故障时只有”全有或全无”,而那通常意味着全无。还要记得:这四层都不创造容量,只分配痛苦;第一顺位永远是容量与架构(水平扩容、无状态、消除单点、异步削峰、缓存),容错是止损手段而非容量替代品。
-
①限流 + 重试 = 放大:服务端限流 → 客户端收到拒绝 → 触发重试 → 请求量翻倍 → 限流更狠 → 更多重试,正反馈。修法:服务端明确区分”可重试”与”不可重试”(Retry-After 头 + 专门响应码),客户端绝不对限流拒绝做立即重试,只对连接失败/超时重试,且受重试预算(≤10%)约束 + jitter 抖动(避免同步冲击)。②熔断 + 重试 = 永远打不开:一次逻辑调用的多次重试若分别计入统计,失败率被放大 3 倍,可能导致半开试探刚失败一次就立刻跳回 Open,下游永远没机会被证明已恢复。修法:一次逻辑调用只记一次结果(重试全失败才算一次失败,任一成功即成功),且重试在熔断器的外层,让熔断看到的是最终结果。
-
两个放大器:一是同步等待导致的资源占用(线程/连接/内存被慢依赖占满,导致调用方自己也变慢,层层向上传染);二是重试导致的流量放大(超时→重试→请求翻倍→更慢→更多重试,形成正反馈)。所以一个服务”慢”会通过”占用调用方资源”向上传染,最终让整条链路上的服务都耗尽资源,即使它自己根本没崩溃。
-
熔断把”等待超时”(线程占用 5 秒)变成”立即失败”(线程占用 1 毫秒),资源占用降低几个数量级,从而切断了”资源被慢依赖占满”这条传染链。半开状态解决”如何知道下游何时恢复”的问题:熔断窗口过后放少量请求试探,成功则恢复、失败则继续熔断——既避免一直熔断(下游已恢复却不用),又避免一下子放全量流量把未完全恢复的下游再次打死。
-
漏桶:请求进桶,以恒定速率出水,桶满拒绝——强制输出平滑,无论输入多猛。令牌桶:以恒定速率生成令牌,请求取令牌,没有则拒绝——限制平均速率,但允许桶内积攒的令牌应对突发。互联网场景更常用令牌桶,因为用户访问天然是突发的,强制平滑会伤害体验;令牌桶在”限制平均速率”和”允许合理突发”之间取得了平衡。
-
舱壁隔离:把资源(线程池/连接池/内存)按功能或调用方分开,一个地方耗尽不影响其他地方(如为每个下游依赖分配独立线程池)。代价是资源利用率降低——某个池空闲时别的池不能用它的配额,这是”资源效率 vs 故障隔离”的权衡。优雅降级的前置工作:平时就给功能分级(P0 核心必须有 / P1 没了难受但能用 / P2 没了无所谓),并做成可快速切换的开关;没有这个分级,故障时只能”全有或全无”,而那往往意味着全无。