35.8 故障隔离 (线程 / 进程 / 资源 / 集群)
一句话
隔离不是“多部署一套”,而是把故障关进盒子里;盒子的大小就是爆炸半径,而盒子的代价是容量碎片。
共享资源是故障传播的唯一通道
故障不会自己长腿。它从 A 跑到 B,一定是因为 A 和 B 共享了某个东西。所以排查爆炸半径不需要看架构图,只需要列一张“谁和谁共享了什么”的清单。
| 共享什么 | 故障怎么传过去 | 怎么切断 |
|---|---|---|
| 同一个线程 / 协程池 | 慢下游把线程占满,所有接口一起排队,排队时间随积压线性上涨 | 按依赖分池 + 有界队列 + 快速失败 |
| 同一个连接池 / HTTP 客户端 | 一个下游变慢,连接被占住,调用其他下游时拿不到连接 | 下游级独立连接池,超时跟着池走(35.5 超时、重试与退避) |
| 同一进程的堆与 GC | 一个接口的大对象或泄漏把堆打满,整个进程被 GC 拖住甚至 OOM | 进程 / 容器隔离 + 内存 limit |
| 同一节点的磁盘与 IO | 日志回刷、报表导出打满 IO,所有请求的 fsync 一起变慢 | cgroup blkio 限额,导出任务独立节点 |
| 同一个 DB 实例 | 一条慢 SQL 或一个大事务把 CPU 打满,所有业务一起慢 | 逻辑分库、读写分离、语句超时 |
| 同一个 Redis | hgetall 打在大 key 上占满单核,所有依赖缓存的服务一起抖动 | 大 key 拆分、独立实例、把缓存故障当降级处理 |
| 同一个 NAT 出口 / LB | 源端口或 conntrack 打满,所有出网调用一起失败 | 多出口 IP、连接复用、按目标分出口 |
| 同一个 DNS / 注册中心 | 解析失败或注册中心过载,新实例上不了线、老实例找不到地址 | 客户端本地缓存 + 多套 DNS |
| 同一个集群控制面 | apiserver / etcd 高负载,所有节点探针超时、无法调度 | 多集群、单元化(36.4 两地三中心与多活) |
九行里只有第一行是“代码里改一改就能做到”的,其余全是部署与资源层面的共享。这是“只在代码里做线程池隔离,上线发现没用”的根本原因:故障换了一条通道继续传播。
四级隔离光谱
| 维度 | 线程 / 协程池 | 进程 / 容器 | 资源配额(cgroup / 连接池 / 队列上限) | 集群 / 单元 / Region |
|---|---|---|---|---|
| 隔离强度 | 弱:共享堆、共享 GC、共享 fd | 中:共享内核、共享宿主机 | 中:只约束用量,不隔离故障 | 强:共享的只剩 DNS 和光缆 |
| 故障传播概率 | 高(一个慢下游就能穿透) | 低(OOM、死循环被关在容器里) | 中(防“吃太多”,不防“变慢”) | 极低 |
| 资源碎片率 | 10%–20%(切细后线程不能互借) | 30%–50%(每容器一份 runtime,JVM 至少 512MB 堆) | 看限额之和,常超卖 1.5–3 倍 | 50% 以上(每单元一套全量部署) |
| 跨隔离调用代价 | 纳秒–微秒(同进程函数调用) | 0.1–1ms(本机 socket) | 不变 | 1–50ms(跨机房 RTT) |
| 切换粒度 | 秒级(改配置、热更新) | 秒级(重启容器) | 分钟级(改配额要滚动发布) | 分钟–小时(切流 + 数据校验) |
| 典型手段 | 按依赖分池、worker pool | 容器、sidecar、独立进程 | cgroup limit、池上限、队列长度 | 单元化、多活、两地三中心 |
结论一句话:强度每升一级,碎片率和运维成本也各升一级。多数服务停在“分池 + 资源配额”,只有 P0 链路值得上进程和集群级隔离。
舱壁的数学:200 线程 vs 10 个 20
共享池 200 线程,10 个接口各 200 QPS,健康 RT 10ms:
单个接口:L = λ × W = 200 QPS × 0.01s = 2 个线程
全部接口:2000 QPS × 0.01s = 20 个线程,池利用率 20 / 200 = 10%
一个下游 RT 从 10ms 涨到 3s,它自己的在途需求变成 200 × 3s = 600,是池子的 3 倍。共享池在 1 秒内被这一个下游吃干净,其余 9 个接口的请求排在后面,排队时间从 10ms 涨到 2s 以上。分池后同样的故障只吃掉 1 个 20 线程的池子,影响面从 100% 降到 10%。
代价在另一边。池子按 1/10 切开,每池只剩 20 线程,能吸收的突发也按比例缩水。用 Erlang-C 算排队概率(a = λ×W 是负载的 erlang 数,c 是线程数):
C(c, a) = [a^c/c! × c/(c-a)] / [ Σ(k=0..c-1) a^k/k! + a^c/c! × c/(c-a) ]
| 单接口流量 | 每池负载 a | 分池 c=20 的排队概率 | 共享 c=200 的排队概率 |
|---|---|---|---|
| 200 QPS(正常) | 2 | 约 0(<1e-4) | 约 0 |
| 800 QPS(4 倍) | 8 | 0.03% | 约 0 |
| 1600 QPS(8 倍) | 16 | 25.6%,平均排队 0.64ms | 约 0(5e-84) |
| 2000 QPS(10 倍) | 20 | ρ=1,队列无界增长 | 约 0 |
| 20000 QPS(100 倍) | 200 | 早已崩溃 | ρ≈1,开始排队 |
读法:共享池能吃下单接口约 90 倍突发(200 线程减去其他接口的 18 erlangs ≈ 182 erlangs)才满,分池 10 倍就满。这就是容量碎片的真实价格——不是“浪费了线程”,而是“失去了跨接口借容量的能力”。
池大小只有一条算式,和 CPU 核数无关:
池大小 = QPS × P99 × 冗余系数
500 QPS × 20ms × 2 = 20 线程
冗余系数取 1.5–2。低于 1.5 时,一次 GC 停顿或一批重试就能把池打满;高到 4 以上,等于没隔离(排队模型与瓶颈定位见 34.2 性能建模)。
线程池隔离的三个参数
| 参数 | 怎么定 | 定错的后果 |
|---|---|---|
| 池大小 | QPS × P99 × 1.5~2 | 按“核数 × 2”拍脑袋:IO 型服务池太小,常态排队 |
| 队列长度 | 可容忍排队时间 × 到达速度,如 200 QPS × 100ms = 20 | 无界队列:排队时间无上限,内存持续上涨 |
| 拒绝策略 | 快速失败 + 计数 + 降级(35.6 熔断与降级) | 用 CallerRuns 阻塞提交:压力反弹到调用方线程,故障面积扩大 |
无界队列就是内存泄漏,只是漏得慢。200 QPS 超出服务能力、每个请求占 10KB:
积压速度 = 200 × 10KB = 2MB/s = 120MB/min
6 分钟吃掉 720MB,然后 OOM。OOM 会把同一台机器上的其他服务一起带走,隔离在这里彻底失效。所以“队列有界 + 满了就拒”不是优化,是隔离能不能成立的前提(入口侧的兜底见 35.7 限流)。
信号量隔离 vs 线程池隔离
| 信号量隔离 | 线程池隔离 | |
|---|---|---|
| 做法 | 一个计数器,拿到许可才发请求 | 请求交给独立线程池执行,调用线程不阻塞 |
| 线程切换 | 无 | 有,一次 2–5µs |
| 给调用方硬超时 | 能(自己控制等待) | 能(Future.get(timeout) 直接放弃) |
| 支持异步 | 不能,调用线程一直被占着 | 能 |
| 内存开销 | 一个 int | 每线程 1MB 栈(Go 协程 2KB 起) |
| 适合 | 下游 RT 短、无阻塞的异步链路 | 下游会阻塞、RT 长、必须硬超时 |
| 典型实现 | Hystrix SEMAPHORE、Go 的带缓冲 channel | Hystrix THREAD、Java 线程池 |
我的判断:Go 服务默认用信号量 + 独立连接池 + 独立超时,不要给每个下游开一个线程池。goroutine 便宜不等于线程池隔离没有成本——它带来调度延迟、panic 跨池传播、context 传不过去这三类问题。Java 那种同步阻塞栈才值得上线程池隔离。
隔离的失效模式
隔离做完了还是雪崩,通常是下面六条之一:
| 失效模式 | 表现 | 修法 |
|---|---|---|
| 隔离了应用,共享 DB | 10 个池全打同一个 MySQL,慢 SQL 一堵全堵 | 逻辑分库 + 独立连接池 + 语句超时 |
| 共享 Redis | 一个大 key 打满单核,所有池一起超时 | 大 key 拆分,缓存故障走降级 |
| 共享 NAT 出口 | 出网调用全部 connection refused,池子再细也没用 | 多出口 IP、连接复用 |
| 隔离了但超时没隔离 | 池分了,所有下游仍共用 3s 超时 + 3 次重试,队列照样堆满 | 超时和重试跟着池走 |
| 共享宿主机 | CPU 争抢、page cache 被大任务冲掉 | 容器 CPU limit + 独立节点 |
| 共享客户端 SDK | 所有下游共用一个全局连接池 | 每个下游一个 client 实例 |
“隔离了但超时没隔离”最隐蔽:池子的吞吐 = 池大小 ÷ 平均占用时间。20 线程、超时 3s,每秒只能处理 6.7 个请求;超时降到 500ms,同样的池子每秒能处理 40 个。不调超时只加池子,等于加内存。
实测:共享池 vs 分池
场景:2000 QPS,10 个接口各 200 QPS,线程总数 200,队列总容量 2000;第 0 个接口的下游在 2s–5s 之间 RT 从 10ms 涨到 3000ms,调用方 1s 超时。
package main
import (
"container/heap"
"fmt"
"sort"
)
// 单位统一:微秒(1ms = 1000µs),避免浮点误差。
const (
us = int64(1)
ms = 1000 * us
totalQPS = 2000 // 入口总 QPS
endpoints = 10 // 10 个接口,各调一个不同的下游,各 200 QPS
poolTotal = 200 // 线程总数固定 200:共享=一个 200 池,隔离=10 个 20 池
queueTotal = 2000 // 排队总容量固定 2000
healthyRT = 10 * ms // 健康下游 RT 10ms
degradedRT = 3000 * ms // 退化下游 RT 3s
clientTO = 1000 * ms // 调用方超时 1s
simEnd = 6000 * ms // 仿真 6s
faultFrom = 2000 * ms // 2s 时第 0 个下游退化
faultTo = 5000 * ms // 5s 恢复
badEP = 0 // 故障接口编号
arrival = 1000000 * us / totalQPS // 2000 QPS = 每 500µs 一个请求
)
type req struct {
ep int
arrive int64
done int64
ok bool
}
type pool struct {
cap, maxQ int
busy int
q []*req // 有界 FIFO 队列
}
type ev struct {
at int64 // 完成时刻
p *pool
job *req
}
type evHeap []*ev
func (h evHeap) Len() int { return len(h) }
func (h evHeap) Less(i, j int) bool { return h[i].at < h[j].at }
func (h evHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *evHeap) Push(x interface{}) { *h = append(*h, x.(*ev)) }
func (h *evHeap) Pop() interface{} {
old := *h
n := len(old)
e := old[n-1]
*h = old[:n-1]
return e
}
// rtFor:一次调用的 RT 由“它开始执行的那一刻”下游是否故障决定。
func rtFor(ep int, start int64) int64 {
if ep == badEP && start >= faultFrom && start < faultTo {
return degradedRT
}
return healthyRT
}
type metrics struct {
accepted, rejected int
success int
healthyServed []int64 // 健康接口里真正拿到线程的请求的端到端延迟
healthyReject, healthyTO int
fwTotal, fwOK int // 故障窗口(2s-5s)内到达的健康接口请求数与成功数
badTO int
maxLat int64
}
func complete(e *ev, h *evHeap, m *metrics) {
p, r := e.p, e.job
r.done = e.at
r.ok = e.at-r.arrive <= clientTO // 客户端 1s 就放弃,服务端线程仍然被占着
if r.ep != badEP {
m.healthyServed = append(m.healthyServed, e.at-r.arrive)
if !r.ok {
m.healthyTO++
}
if r.arrive >= faultFrom && r.arrive < faultTo {
m.fwTotal++
if r.ok {
m.fwOK++
}
}
} else if !r.ok {
m.badTO++
}
if e.at-r.arrive > m.maxLat {
m.maxLat = e.at - r.arrive
}
// 线程归还:队列里有下一个就用同一个线程接着跑
if len(p.q) > 0 {
nx := p.q[0]
p.q = p.q[1:]
heap.Push(h, &ev{at: e.at + rtFor(nx.ep, e.at), p: p, job: nx})
} else {
p.busy--
}
}
func run(isolated bool) metrics {
pools := make([]*pool, endpoints)
if isolated {
for i := range pools {
pools[i] = &pool{cap: poolTotal / endpoints, maxQ: queueTotal / endpoints}
}
} else {
one := &pool{cap: poolTotal, maxQ: queueTotal} // 10 个接口共用同一个池、同一个队列
for i := range pools {
pools[i] = one
}
}
var (
h evHeap
m metrics
seq int
)
for t := int64(0); t < simEnd; t += arrival {
// 结算所有已经完成的调用,线程还回池子
for len(h) > 0 && h[0].at <= t {
complete(heap.Pop(&h).(*ev), &h, &m)
}
ep := seq % endpoints
seq++
p := pools[ep]
r := &req{ep: ep, arrive: t}
switch {
case p.busy < p.cap:
p.busy++
heap.Push(&h, &ev{at: t + rtFor(ep, t), p: p, job: r})
case len(p.q) < p.maxQ:
p.q = append(p.q, r) // 排队等线程
default:
// 队列满了:快速失败(拒绝),不占任何资源
m.rejected++
if ep != badEP {
m.healthyReject++
if t >= faultFrom && t < faultTo {
m.fwTotal++
}
}
r.done = t
continue
}
m.accepted++
}
for len(h) > 0 {
complete(heap.Pop(&h).(*ev), &h, &m)
}
m.success = m.accepted - m.healthyTO - m.badTO
return m
}
func pct(xs []int64, p int) string {
if len(xs) == 0 {
return "-"
}
s := append([]int64(nil), xs...)
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
return fmt.Sprintf("%dms", s[len(s)*p/100]/ms)
}
func main() {
fmt.Printf("场景:%d 接口 × %d QPS = %d QPS,线程总数 %d,队列总容量 %d\n",
endpoints, totalQPS/endpoints, totalQPS, poolTotal, queueTotal)
fmt.Printf(" 第 %d 个接口的下游在 2s–5s 之间 RT 从 10ms 涨到 3000ms,调用方超时 %dms\n\n",
badEP, clientTO/ms)
shared, iso := run(false), run(true)
fmt.Printf("%-18s %8s %8s %8s %10s %10s\n",
"方案", "受理", "拒绝", "成功率", "健康P99", "最大延迟")
for _, row := range []struct {
name string
m metrics
}{{"共享池 (1×200)", shared}, {"分池 (10×20)", iso}} {
rate := float64(row.m.success) / float64(totalQPS*6) * 100
fmt.Printf("%-18s %8d %8d %7.1f%% %10s %10s\n",
row.name, row.m.accepted, row.m.rejected, rate,
pct(row.m.healthyServed, 99), pct([]int64{row.m.maxLat}, 99))
}
fmt.Println("\n健康接口(9 个,占流量 90%)的表现:")
fmt.Printf("%-18s %10s %10s %12s %10s\n", "方案", "健康P50", "健康P99", "排队超时失败", "队列拒绝")
fmt.Printf("%-18s %10s %10s %12d %10d\n", "共享池 (1×200)",
pct(shared.healthyServed, 50), pct(shared.healthyServed, 99), shared.healthyTO, shared.healthyReject)
fmt.Printf("%-18s %10s %10s %12d %10d\n", "分池 (10×20)",
pct(iso.healthyServed, 50), pct(iso.healthyServed, 99), iso.healthyTO, iso.healthyReject)
fmt.Printf("\n故障接口(流量 10%%)自身:共享池超时 %d 次,分池超时 %d 次\n",
shared.badTO, iso.badTO)
fmt.Printf("健康接口在故障期被挤掉的成功请求:共享池 %d 次,分池 %d 次\n",
shared.healthyTO+shared.healthyReject, iso.healthyTO+iso.healthyReject)
fmt.Printf("故障 3s 窗口内健康接口成功率:共享池 %.1f%%(%d/%d),分池 %.1f%%(%d/%d)\n",
100*float64(shared.fwOK)/float64(shared.fwTotal), shared.fwOK, shared.fwTotal,
100*float64(iso.fwOK)/float64(iso.fwTotal), iso.fwOK, iso.fwTotal)
}
GO111MODULE=off go run main.go 的真实输出:
场景:10 接口 × 200 QPS = 2000 QPS,线程总数 200,队列总容量 2000
第 0 个接口的下游在 2s–5s 之间 RT 从 10ms 涨到 3000ms,调用方超时 1000ms
方案 受理 拒绝 成功率 健康P99 最大延迟
共享池 (1×200) 9901 2099 64.2% 2054ms 3241ms
分池 (10×20) 11620 380 95.0% 10ms 3000ms
健康接口(9 个,占流量 90%)的表现:
方案 健康P50 健康P99 排队超时失败 队列拒绝
共享池 (1×200) 10ms 2054ms 1800 1899
分池 (10×20) 10ms 10ms 0 0
故障接口(流量 10%)自身:共享池超时 400 次,分池超时 220 次
健康接口在故障期被挤掉的成功请求:共享池 3699 次,分池 0 次
故障 3s 窗口内健康接口成功率:共享池 33.2%(1791/5400),分池 100.0%(5400/5400)
三个读数,三条结论:
- 只占 10% 流量的下游,在共享池里杀掉了 2/3 的健康请求:故障的 3 秒窗口内,健康接口成功率 33.2%(1791/5400),P99 从 10ms 涨到 2054ms。最大延迟 3241ms = 3s 的服务时间 + 241ms 排队,说明“排队”实打实叠在服务时间上。
- 分池用 380 次拒绝换掉了 3699 次陪葬:故障接口自己的队列先满,被快速拒绝 380 次;健康接口 0 次超时、0 次拒绝。整体成功率 64.2% → 95.0%,差的那 5% 全是故障接口自己。
- 隔离不修下游:故障接口的超时次数两栏都很大(400 与 220),甚至共享池里更多——因为它的请求也在长队列里被调度,白占了 3s 线程却照样超时。隔离只保证“别人不被拖下水”,修下游要靠熔断与容量。
🔑 判断隔离有没有做到位,不看池子数量,只看一个问题:把任意一个下游打成 RT 3s,其他接口的成功率会不会变。会变,就是还有共享资源没切断。
什么时候别隔离
| 你的情况 | 选择 |
|---|---|
| 只有一个下游、QPS < 50 | 别分池,超时 + 熔断够用(35.6 熔断与降级) |
| 下游 RT 差异大(10ms 与 2s 混跑) | 按 RT 分池,慢调用单独一个池 |
| P0 交易链路 | 进程 / 集群级隔离 + 单元化(36.4 两地三中心与多活) |
| 纯计算、无 IO 的内部调用 | 共享池,分池只会让每池更小 |
| 单机 < 8 核 / 2GB | 别切太细,每池 3–5 线程时排队概率已经很高 |
| 多租户 SaaS | 按租户隔离,防一个大客户拖全站 |
⚠️ 隔离是有上限的手段:它把故障关进盒子,盒子本身仍然会坏。验证盒子边界是否真的成立,只能靠主动注入故障(56.1 混沌工程)。
反模式
| 反模式 | 后果 |
|---|---|
| 全局一个线程池 | 一个下游变慢,全站一起排队,隔离等于零 |
| 每接口一个池 | 池数量爆炸,每池太小:单接口 8 倍突发就有 25.6% 的请求排队 |
| 无界队列 | 排队时间无上限,内存按 120MB/min 涨,最后 OOM 带走整台机器 |
| 隔离了应用但共享连接池 | 故障下游的连接仍被复用,池子分了也没用 |
| 池大小按 CPU 核数拍脑袋 | IO 型服务的池和核数无关,按“核数 × 2”配出来常态排队 |
| 只隔应用不隔数据 | 10 个池打同一个 DB / Redis,慢 SQL 一堵全堵 |
| 分了池但共用超时和重试 | 队列照样被 3s × 3 次的请求堆满,池子扩容只是加内存 |
| 隔离后不看池利用率 | 利用率长期 95% 说明池太小,长期 1% 说明切太碎,两边都是白花钱 |
| 只有隔离没有降级 | 被隔离的流量直接 500,用户体验和没隔离一样 |
自检
Q1 团队给每个下游都开了独立线程池(各 20 线程),结果一次 MySQL 慢查询还是让全站超时。隔离白做了吗?哪里漏了?
▸ 答案:做了一半。池子只隔离了“调用方线程”这一种共享资源,而 20 个池的请求仍然打同一个 MySQL 实例——连接数、DB 工作线程、buffer pool 全是共享的。按共享资源清单逐项切:数据层要分库或给 DB 加语句超时与连接数上限,应用层的池只解决排队蔓延,不解决下游本身被打满。判断标准还是那一问:把 DB 打成 3s,其他接口成功率会不会变。
Q2 分池后单接口 8 倍突发,排队概率从约 0 跳到 25.6%,共享池却几乎还是 0。为什么这个代价通常值得付?
▸ 答案:Erlang-C 里 c=20、a=16 时 ρ=0.8,P(排队)=25.6%;共享池 c=200、总 a 只有 34,ρ=0.17,几乎不排队。碎片的本质是“不能再跨接口借容量”。值得付的理由是:8 倍突发几乎总是那个接口自己的问题(热点、大户),让它自己排队、自己拒绝,好过让 9 个无辜接口陪它排队;而且 25.6% 只是排队概率,不是错误率。
Q3 为什么说“无界队列 = 内存泄漏”?给一个能算给人听的量级。
▸ 答案:到达率超过服务率时,队列只增不减,积压速度 =(到达率 − 服务率)× 单个请求占用内存。200 QPS 超出、每请求 10KB,就是 2MB/s、120MB/min,6 分钟 720MB,随后 OOM。OOM 会带走同机其他进程,比“慢慢变慢”严重得多——所以队列必须有界,满了就快速失败。
Q4 你把下游超时从 3s 调到 500ms,池子大小没变,吞吐涨了 6 倍。为什么?
▸ 答案:固定线程数下,吞吐 = 池大小 ÷ 平均占用时间。20 线程、3s 超时 → 6.7 QPS;20 线程、0.5s 超时 → 40 QPS,正好 6 倍。超时不只是“失败得快”,它是池子的周转率参数:调超时比加池子便宜,而且加池子会把压力转给已经变慢的下游。