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)

三个读数,三条结论:

  1. 只占 10% 流量的下游,在共享池里杀掉了 2/3 的健康请求:故障的 3 秒窗口内,健康接口成功率 33.2%(1791/5400),P99 从 10ms 涨到 2054ms。最大延迟 3241ms = 3s 的服务时间 + 241ms 排队,说明“排队”实打实叠在服务时间上。
  2. 分池用 380 次拒绝换掉了 3699 次陪葬:故障接口自己的队列先满,被快速拒绝 380 次;健康接口 0 次超时、0 次拒绝。整体成功率 64.2% → 95.0%,差的那 5% 全是故障接口自己。
  3. 隔离不修下游:故障接口的超时次数两栏都很大(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 倍。超时不只是“失败得快”,它是池子的周转率参数:调超时比加池子便宜,而且加池子会把压力转给已经变慢的下游。

关联