雪崩前的那一秒:当我们谈论高并发时,我们究竟在谈什么
第 1 章:雪崩前的那一秒:当我们谈论高并发时,我们究竟在谈什么
凌晨 00:00:47,陈默的咖啡杯底还残留着十分钟前的余温。
00:00:00,他按下大促开闸的确认键。流量像闸门后的洪水一样冲进来——网关层 QPS 从平峰期的 8 万一跃而至 53 万,比去年峰值还高出 47%。他在心里默念了一句:"还好,扩容了三倍。"
00:00:12,第一条红色告警浮上屏幕:商品详情接口 P99 延迟从 80ms 攀升至 1.2 秒。他的瞳孔轻微收缩了一下,但根据去年经验,这只是开闸初期的"热身"。
00:00:23,购物车服务的数据库连接池等待队列第一次出现在仪表盘上。他记得这个信号——去年也是这个时刻开始的,但他当时没太在意。
00:00:31,订单服务的线程池活跃线程数触顶。他在群里发了一句:"订单服务接近饱和,准备弹性扩缩。"
00:00:47,订单服务的错误率从 0.03% 跳到了 100%。
不是 30%、不是 50%,是 100%。
所有的请求,像被一只看不见的手齐齐按下了暂停键。陈默后来回忆,那一秒他其实什么都没感觉到。不是因为害怕,是因为他的大脑短暂地拒绝处理这种数据。然后,5xx 告警像焰火一样密集地在屏幕上炸开。购物车、库存、营销、支付——每一个跟订单链路相关的服务,都开始出现超时。
00:01:30,网关层触发熔断,全站返回"系统繁忙,请稍后再试"。
00:03:00,市场部负责人在群里发出第一句询问:"活动还在进行吗?"
00:07:00,陈默终于决定不依赖任何"自动恢复"的承诺,亲自登上了线上机器。
那七分钟里,他经历了什么、失去了什么、后来又学到了什么——是这一章我们要展开的故事。但在这之前,我想先放下事故本身,问一个更基础的问题:当我们说"高并发"的时候,我们究竟在说什么?
这是一个看起来稀松平常、实则充满陷阱的问题。我见过太多团队在"百万 QPS""亿级流量""秒杀架构"这些标签下,把完全不同的系统问题当成同一类问题来处理,最后走向完全不同的结局。要回答它,必须先拆掉几块常见的认知绊脚石。
1.1 雪崩事故的七分钟时间线复盘:每一秒都不是孤立的
回到陈默的那个凌晨。事后的事故复盘会上,他们拉出了一条完整的时间线,把每一秒都钉在了墙上:
```mermaid flowchart LR T0["T+0:00<br/>开闸<br/>QPS 53万"] --> T1["T+0:12<br/>详情 P99 1.2s<br/>缓存命中 88% -> 72%"] T1 --> T2["T+0:23<br/>订单 DB 连接池<br/>等待队列 240"] T2 --> T3["T+0:31<br/>订单线程池<br/>饱和度 95%"] T3 --> T4["T+0:47<br/>订单错误率<br/>0.03% -> 100%"] T4 --> T5["T+1:30<br/>网关熔断<br/>5xx 占比 87%"] T5 --> T6["T+3:00<br/>市场询问<br/>客服电话 1.2 万"] T6 --> T7["T+7:00<br/>运维介入<br/>手动降级"] ```
如果你只看这张图,会以为这是一个"水位线不断上升直到漫堤"的故事——线性、可预测。但真实的事故远比这复杂。事故复盘里最有价值的部分,往往不是这条时间线本身,而是为什么 00:00:31 到 00:00:47 之间那 16 秒,会从"接近饱和"突然跳到"完全不可用"。
答案是请求堆积(backlog)。
订单服务的线程池容量是 800。当活跃线程数触顶时,理论上应该开始排队等待。但问题在于:订单服务依赖库存服务,库存服务依赖 Redis 集群,而 Redis 集群里的某个 Shard 在 00:00:15 就开始出现响应时间抖动——从 2ms 上升到 80ms。这点抖动本身不致命,但它通过同步调用链被放大了。
假设平均一个订单请求需要调用库存 3 次,每次调用 2ms,那么原本订单服务处理一个请求的平均时间是:
$$T_{normal} = T_{self} + 3 \times 2\text{ms} = 50\text{ms} + 6\text{ms} = 56\text{ms}$$
当 Redis 抖动让单次调用变成 80ms:
$$T_{degraded} = 50\text{ms} + 3 \times 80\text{ms} = 290\text{ms}$$
吞吐能力理论上降为原来的 $\frac{56}{290} \approx 19\%$。问题在于线程池已经满了,新请求仍在涌入,于是它们开始在线程池的队列里堆积。队列里的请求等待超时(默认 1s)后会被回收,但线程池中正在执行的请求却因为 Redis 抖动被卡住,无法释放线程。这就形成了一个死锁的变体——线程被卡死,新请求进不来,但对外的 TCP 连接依然在累积。
到 00:00:47 的时候,订单服务的线程池队列里堆积了 12 万个请求,而活跃线程数仍然只有 800,但其中超过 600 个线程已经"卡住"超过 1 秒。整个服务进入了"假死"状态——它没有宕机,进程在跑,但什么也不做。
这就是所谓"雪崩前的那一秒"——在那一秒之前,所有监控指标看起来都还在"可控范围"内;在那一秒之后,系统从"可用"直接跳到了"完全不可用",没有中间地带。
陈默后来在复盘文档里写了一段话,大意是:高并发系统的失败从来不是渐进的,它是阶跃的。水位线上升是渐进的,但水位线漫过堤坝的那一刻是阶跃的。这种阶跃性是所有高并发事故的共同特征,也是它最危险的地方——它不会给你"渐进式告警"的机会。
事故的最后损失是:大促前 7 分钟所有订单无法下单,预估损失 GMV 约 3800 万元。但更深的损失是:在事故发生后的两周内,技术团队陷入了对"该不该引入 Service Mesh""要不要全链路异步化""该不该自研流量调度"等议题的反复争论,每一次争论都让团队的精力更分散,最终错过了双十二的整改窗口。
陈默后来对我说:"雪崩之所以可怕,不是因为它发生了,而是因为它改变了我们对系统的理解。 之前我们以为系统在 53 万 QPS 下还能撑住,事故之后我们再也没法这么想了。"
事故复盘报告的最后一行是这样写的:"我们没能回答'系统在哪里会崩溃'这个问题,所以系统在我们不知道的地方崩溃了。" 这句话会成为这一章贯穿始终的追问。
1.2 并发与并行的语义边界:阿姆达尔定律不是性能公式,而是哲学
回到本章的核心问题:高并发到底是什么?在我们继续讨论"系统健康指标"和"四象限模型"之前,必须先把两个最容易混淆的术语理清楚——并发(Concurrency)与并行(Parallelism),以及它们的近亲:吞吐量(Throughput)与延迟(Latency)。
如果你跟不同的人聊"高并发",你会发现一个有趣的现象:做业务的同学倾向于把"高并发"等同于"高 QPS";做基础架构的同学倾向于把它等同于"分布式系统";做性能优化的同学倾向于把它等同于"并行计算"。这三个理解都不算错,但每一个都只触及了问题的一个侧面。
让我从一个具体的例子开始。
假设你是一个餐厅的服务员。餐厅里有 8 张桌子,每张桌子每 10 分钟点一次单。如果只有你一个人服务,你必须按顺序处理:先处理 1 号桌的点单,然后 2 号桌,然后 3 号桌……即使你走得很勤快,单个顾客从点单到上菜的延迟,也是这 8 张桌子顺序叠加的结果。这就是串行。
如果你学会了"并发",你不必等 1 号桌点完单才去 2 号桌——你可以走到 1 号桌记下点单,然后立刻转向 2 号桌记下点单,再转向 3 号桌……你不断地在不同的桌子之间切换。对每张桌子来说,你都在"为它服务",但实际上你同一时刻只服务一张桌子。这种"看似同时处理多件事"的能力,叫并发。并发的核心是任务切换,不是真正的物理并行。
如果餐厅雇了两个服务员,那么 8 张桌子可以同时被两个人服务,每张桌子被服务的频率翻倍。这就是并行。并行的核心是物理上同时处理多件事。
把这个例子映射到软件系统:
- **串行**:单线程,CPU 一次只执行一个任务。
- **并发**:单核 CPU 通过时间片轮转调度多个线程/协程,看起来像同时处理,实际上是快速切换。
- **并行**:多核 CPU 上,多个线程真正同时运行在不同的核上。
高并发系统不一定是并行系统。一个用 Go 写的、跑在 8 核机器上、用 goroutine 处理请求的服务,是并发 + 并行的混合体。一个用 Node.js 单线程 + 事件循环跑的服务,是"高并发但不是并行"的典型——它通过异步 I/O 在一个线程上同时维护数万个连接,但所有 CPU 密集型任务依然是顺序执行的。
这个区分为什么重要?因为它直接决定了你系统的瓶颈在哪。
如果你的系统是异步 I/O 密集型(典型如网关、API 代理),那它的并发能力主要受限于 I/O 多路复用的能力和文件描述符数量,而不是 CPU 核心数。在这种系统上,盲目增加 CPU 核心数是浪费。
如果你的系统是CPU 密集型(典型如推荐算法、图像处理、加密计算),那它的并发能力主要受限于 CPU 核心数。在这种系统上,事件循环救不了你,必须上并行计算。
这就是阿姆达尔定律(Amdahl's Law)真正想告诉我们的事情。它并不是一个简单的"性能公式",而是一个关于系统可扩展性边界的哲学判断:
$$S = \frac{1}{(1-p) + \frac{p}{n}}$$
其中 $S$ 是理论加速比,$p$ 是可并行部分的比例,$n$ 是并行单元数(核心数)。这个公式告诉我们一个反直觉的事实:当 $p$ 接近 1 时,增加 $n$ 可以接近线性加速;但当 $p$ 只有 0.5 时,无论你把 $n$ 增加到多少,$S$ 的极限都不会超过 2。
换句话说:一个系统里有多少代码必须串行执行,决定了你加再多机器也救不了的部分。在高并发语境里,这意味着——如果你的请求处理路径里有一个必须串行的环节(例如一次同步的分布式锁、一笔必须串行执行的数据库事务、一次必须顺序完成的业务校验),那么无论你横向扩展多少个实例,这个环节都会成为你的瓶颈。
陈默团队的事故复盘里就发现了这样一个环节。订单服务里有一个"幂等性检查"——所有订单请求在写入数据库前,必须先查询一次分布式锁来确保同一笔订单不会被重复处理。这个环节每秒只能处理 1.2 万次——这就是整个订单服务的吞吐量天花板,无论他们把订单服务实例扩到 100 个还是 1000 个。事故复盘时有人问:"为什么当初没人发现这个瓶颈?"陈默的回答是:"因为压测的时候这个环节没有被打满。压测工具不会重复点同一笔订单。"
真实生产环境的流量特征,往往和压测环境的流量特征有根本性的差异。压测告诉你的是"系统能承受多少",但压测永远无法告诉你"系统在某种特殊的请求模式下会不会崩"。
一个更有用的扩展是 Little's Law(利特尔法则):
$$L = \lambda \times W$$
其中 $L$ 是系统中平均在飞请求数(in-flight requests),$\lambda$ 是到达速率(throughput),$W$ 是平均响应时间(latency)。
这个公式看起来简单,但它是高并发系统容量规划的基石。它告诉我们:如果你希望系统支持 100 万 QPS,平均响应时间是 10ms,那么系统中平均必须有 10000 个在飞请求。这意味着你的线程池、连接池、内存 buffer 至少要能容纳 10000 个并发上下文。如果你的内存按每个上下文占用 1MB 计算,你就需要 10GB 的堆外内存——很多团队在做容量规划时根本没算过这笔账。
Little's Law 还有一个推论:如果你的平均响应时间因为某个抖动从 10ms 涨到了 100ms,但到达速率仍然是 100 万 QPS,那么在飞请求数会从 10000 涨到 100000。这就是为什么一次 Redis 抖动能引发雪崩——不是因为 Redis 抖动能直接拖垮系统,而是它把 $W$ 拉高了 10 倍,让在飞请求数爆炸,最终耗尽所有缓冲资源。
回到吞吐量与延迟的区分。吞吐量度量的是系统"完成多少"的能力,延迟度量的是系统"完成得多快"的能力。这两个指标在大多数时候是同向的——延迟越低,吞吐越高——但在高负载区域,它们会反向变化:当你试图提高吞吐量时,延迟会先上升;当吞吐量达到极限时,延迟会陡峭上升;继续增加负载,吞吐量会下降,延迟会崩溃。这种"吞吐量-延迟曲线"的拐点,就是系统的实际容量极限。
很多团队的压测报告里都会写"系统支持 100 万 QPS",但这个数字本身是没有意义的——你必须同时说明"在什么延迟下支持 100 万 QPS"。如果"100 万 QPS"对应的是 P99 延迟 10 秒,那这个数字没有任何生产价值。脱离了延迟谈吞吐量,是高并发领域最大的谎言之一。
1.3 衡量系统健康的 7 个核心指标:一个被严重低估的工程交付物
在我参与的几乎所有高并发系统事故复盘里,都能看到同一个现象:团队在事故发生前对自己的系统健康状况一无所知,而在事故发生后却能脱口而出十几个指标。
这种"事后诸葛亮"是有结构原因的——大多数团队的高并发监控是"运维视角"而不是"架构视角"的。运维视角关心的是"机器在不在跑、CPU 是不是 100%",而架构视角关心的是"系统在当前负载下是否还能保持承诺给用户的体验"。两者之间隔着巨大的鸿沟。
我把这些年在 expert 团队里被反复验证的 7 个核心指标列出来,作为这一章的"仪表盘"。这 7 个指标不是从教科书里抄来的,而是从一次次事故的复盘里"长出来"的——每一个指标都对应过一种特定的失败模式。
指标一:P99 延迟,而不是平均延迟。
平均延迟是统计学的谎言。一个接口的 P99 延迟是 100ms,意味着 100 个请求里有 99 个延迟小于 100ms,而第 100 个请求的延迟可能高达数秒。在高并发系统里,那"1%"的慢请求往往就是用户感受到的"页面卡顿"的来源——他们不知道自己是那 1%,但他们会用脚投票。
更严格地说,你应该追踪 P99.9(千分之一分位)和 P99.99(万分之一分位)。在每秒百万级 QPS 的系统里,P99 和 P99.99 之间可能相差一个数量级。陈默团队的事故复盘里有一个令人震惊的发现:订单服务在大促当天的 P99 是 1.2 秒,但 P99.99 是 38 秒——也就是每一万个请求里有一个要等 38 秒才能得到响应。这种"长尾请求"在压测里完全看不出来,因为压测不会持续足够长的时间来采样到那一万个里的那一个。
指标二:Apdex(应用性能指数)。
Apdex 是一个把延迟转换成"用户满意度分数"的指标,范围从 0 到 1。它的公式是:
$$\text{Apdex} = \frac{S + \frac{T}{2}}{N}$$
其中 $N$ 是总请求数,$S$ 是满意请求数(延迟 ≤ 阈值 $T$),剩下的容忍请求数(延迟在 $T$ 到 $4T$ 之间)记为 $T$(注意:这里 $T$ 既代表阈值又代表容忍数,在原始公式里写作 $\frac{T_tolerating}{2}$)。Apdex = 1 表示所有用户都满意,Apdex = 0 表示所有用户都不满意。
Apdex 的价值在于它把技术指标(延迟)和业务指标(满意度)连接起来。一个 P99 = 100ms 的接口,如果用户能接受的延迟是 200ms,那它的 Apdex 可能还是 0.99;如果用户能接受的延迟是 50ms,那同样的 P99 意味着 Apdex 只有 0.5。脱离了用户预期谈延迟,永远是空谈。
很多团队第一次接 Apdex 时会问:"阈值 $T$ 该怎么定?"答案是:去问产品和运营。$T$ 不是技术决策,是业务承诺。你承诺用户"3 秒内下单成功",那 $T$ 就是 3000ms。你承诺"页面 500ms 内首屏",那 $T$ 就是 500ms。一旦你定了 $T$,就要把 Apdex 写到 SLA 里——这是把"用户满意度"变成可问责指标的关键步骤。
指标三:QPS 与 TPS 的区分。
QPS(Queries Per Second)是"每秒查询数",TPS(Transactions Per Second)是"每秒事务数"。在大多数场景下,一个事务包含多个查询,但很多人混用这两个术语。
更精确地说:QPS 度量的是"系统被访问的频率",TPS 度量的是"系统完成业务目标的频率"。一个 API 网关的 QPS 可能很高(因为每个请求都经过它),但它的 TPS 很低(因为它不完成业务逻辑)。一个订单服务的 QPS 和 TPS 之比,应该接近于"一个订单包含的子操作数"。如果你看到一个订单服务的 QPS = 100 万、TPS = 50 万,那多半意味着有一半的请求失败了或者被去重了——这是一个重要的健康信号。
更隐蔽的陷阱是"成功 TPS"和"尝试 TPS"的差异。一个订单服务的尝试 TPS 是 100 万,但其中 30 万因为限流被拒、20 万因为超时失败,真正成功的只有 50 万。如果只看尝试 TPS,你以为系统支撑住了 100 万 QPS;但如果看成功 TPS,系统实际上只完成了设计能力的一半。所有"高并发大厂案例"在宣传 QPS 时,几乎从不告诉你他们的"成功 TPS"是多少。
指标四:错误率与错误类型分布。
错误率不应该只用一个数字表示。你至少要追踪四类错误:
1. 4xx 错误率:通常是客户端问题,突增可能意味着客户端 SDK 有 bug 或者恶意流量。 2. 5xx 错误率:服务器问题,是真正的健康指标。 3. 超时错误率:当系统变慢但还没崩溃时,超时错误率会先上升。 4. 业务错误率:HTTP 状态码是 200,但业务逻辑返回"失败"的请求。这种错误最难发现,但往往最能反映系统的真实健康状况。
一个常见的反模式是只追踪 5xx 错误率。但事故复盘时你会发现,真正的导火索往往是超时报错,因为超时错误首先出现,而 5xx 错误是最后的崩溃信号。陈默团队的事故复盘里就发现,如果他们在 00:00:23 就对"超时报错率上升"做出反应——比如手动扩容库存服务实例——那么后续的雪崩可能完全避免。但当时的告警规则只对 5xx 错误率设置了阈值,对超时报错率只做了"记录",没有触发告警。
指标五:饱和度(Saturation)。
饱和度度量的是"系统离它的工作极限还有多远"。一个订单服务的线程池容量是 800