系统是被打出来的 · 系统设计实战手册
PART 1 · 单机时代 · 第 2 章

第一次宕机
连接池耗尽之夜

本地媒体的一篇报道把三千用户同时推到门口。数据库没有坏,连接也没有丢;系统只是把每个位置都坐满,然后让后面的人排到超时。

02 · 1 值班现场23:10,第一篇报道带来第一场洪峰

创业第 2 个月,「秒抢」累计用户约 3 千。公司 6 人,后端只有 2 人。你刚合上电脑,本地媒体那篇《年轻人做的抢票网站》被推上首页;23:10,手机先震了一下,随后像卡住一样连续震动。

PagerDuty · P0 · 触发于 23:10:18
[CRITICAL] api-gateway 502 比例 61.8%(阈值 2%)
[CRITICAL] app-db-pool active 50/50,waiters 1,842
[WARNING]  order-api P99 8.7s(平时 140ms)
──────────────────────────────────────
Nginx error log:
upstream timed out while waiting for application response

你连上监控。最扎眼的是「池满」,但另外三张卡片更重要:它们告诉你数据库还有余力,真正把用户挡在门外的是应用侧的等待。

入口请求
486 QPS
▲ 平时约 72 QPS
连接池占用
50 / 50
▲ 已持续打满 68s
DB 查询 P99
41ms
● 平时约 38ms
DB CPU
43%
● 尚有余量

连接池只有 50 个槽(第 1 章上线时是 20,随机器扩容一路调到 50),入口却在一分多钟里涨到平时约 6.8 倍。拿到连接的请求仍能在几十毫秒内完成 SQL;拿不到连接的 1,842 个请求则在应用里排队。它们等满 8 秒,被网关切断,最终被翻译成 502。

老陈在电话里问:「加连接,还是砍流量?」你看着 DB CPU 43% 和连接池等待队列,知道这不是“SQL 全慢了”。此刻要先决定的是:让多少请求进入有限的服务槽,而不是给等待队列换一个更深的坑。

A的问题是没有先问下游账单。 500 条并发连接可能把目前只有 43% CPU 的数据库瞬间推满;即便 DB 扛住,应用线程、内存和锁竞争也会把等待搬到下一层。池大小是并发上限,不是凭空出现的处理能力。

B是教科书答案:先让到达量低于服务能力。 把入口并发压到连接池能稳定承受的范围,超出的请求在 200ms 内明确失败,连接槽便能开始周转。部分用户要重试,但存活请求不再和 1,842 个等待者争同一口气。

C会让事情更糟。 重启只会让已等待的请求失败一次,入口 486 QPS 会立刻把空池重新灌满;两台应用同时重连,还可能在数据库上制造连接风暴。队列被清空,不等于到达率变小。

D方向没错,但救不了现在。 慢查询值得查,可眼前 DB 查询 P99 只有 41ms,与平时接近。索引评估和发布按分钟到小时计,502 正按秒增加;先止血,再确认哪段服务时间值得优化。

02 · 2 原理利用率越靠近 1,等待越不像线性

一个连接槽在处理请求时,后来的请求只能等它归还。设到达速率为 λ,每个连接每秒能完成 μ 个请求,连接数为 c;容量直觉是 c × μ,利用率则是 ρ = λ ÷ (c × μ)。当 ρ 接近 1,任何一次稍慢的查询都会把队列推长。

平均排队延迟 ∝ ρ / ( 1 − ρ )  (ρ = 利用率)

把平均服务时间记作 1 倍。代入指定的四个利用率,风险不是平滑增长,而是在容量边缘竖起一堵墙:

ρ = 0.50
1 倍
0.5 ÷ 0.5
ρ = 0.80
4 倍
0.8 ÷ 0.2
ρ = 0.95
19 倍
0.95 ÷ 0.05
ρ = 0.99
99 倍
0.99 ÷ 0.01

若一次请求平均占用连接 100ms,那么这组比例可读成约 100ms、400ms、1.9s、9.9s 的平均排队量级。从 80% 提到 95%,只多吃了 15 个百分点的容量,等待倍数却从 4 涨到 19,约 4.75 倍;从 95% 到 99% 又放大约 5.2 倍。

「利用率不是效率指标,是风险指标。」把连接池长期跑在 99% 看起来“物尽其用”,实际等于把 1% 的波动余量写进故障合同。流量有抖动,查询耗时有长尾,垃圾回收和锁等待也会偷走槽位;没有余量的池只需一个小毛刺,就会从忙碌跨到排队。

你调的旋钮 短期收益 隐藏账单 正确观察
加大连接池 允许更多请求同时进入 DB 下游 CPU、锁与 I/O 竞争上升;排队可能只是搬家 吞吐是否真增加,而不只看 active 连接数
缩短超时 更快释放线程和等待位 错误率上升,过短会误杀本可完成的请求 成功率、存活请求 P99、端到端预算
降低到达量 直接把 ρ 拉回安全区 需要决定先拒绝谁,并承受业务损失 通过量是否贴近已验证容量
连接池不是水库,是有固定座位的诊室。把走廊塞满病人不会让医生看得更快;真正的护栏是让到达量、服务时间与并发槽位保持可验证的余量。

这一夜你先手工给 ρ 设了上限。到第 6 章,这个动作会被正式写成限流:系统主动决定放行量,而不是等连接池替你拒绝。到第 7 章,超时还会和重试绑在一起;超时越短并不天然越安全,因为上游若立即重试,失败流量会重新排队。

02 · 3 病历卡池满只是表象,排队才是伤口

连接池耗尽

病历卡 No.01
Connection Pool Exhaustion · 有限槽位被占满,等待者持续堆积
机理
请求到达速度接近或超过连接归还速度,活跃连接占满池上限;新请求停在获取连接的等待队列里,等待又占住应用线程与内存,最终由超时把失败向上游传播。
症状
▁▂▅███ 阶跃打满型。active 连接快速顶到上限后贴线不回,waiters 单调增加;应用 P99 和 502 飙升,但 DB 单条查询仍可能很快。
易发场景
突发流量;慢 SQL 或锁等待拉长连接持有时间;事务开启后夹杂远程调用;连接泄漏;应用实例扩容后总连接数超过数据库承载边界。
处方
① 限并发与快速失败:把到达量压回可验证容量;② 设置获取连接超时:不让等待队列无界增长;③ 缩短持有时间:优化慢查询,事务内不做远程等待;④ 按下游容量定总池大小:实例数增加时同步核算总连接。
案例
「秒抢」第 2 个月被本地媒体报道,入口从约 72 QPS 涨到 486 QPS。50 个连接槽打满、等待者升到 1,842,全站 502 达 61.8%;入口限并发后,DB 无需重启便恢复周转。

看到 ▁▂▅███,不要只问「池能不能再大」。先同时看三件事:连接持有多久、单位时间归还多少、下游还有多少余量。池满可能是流量变多,也可能是一笔慢事务把服务时间拉长;处方要落在 ρ 的分子或分母上。

02 · 4 模拟器ρ 不是效率,是悬崖边的距离

调节到达、服务耗时、连接池和超时,观察利用率接近 100% 时尾延迟如何失控,以及超时怎样用快速失败换回存活请求的延迟。

QUEUEING SIMULATOR · 秒抢接口监控 02:47:00
P50 P99 利用率 ρ 超时错误率
P50
—
P99
—
利用率 ρ
—
超时错误率
—
处理吞吐
—

🧪 3 个实验(按顺序做)

  1. 走近悬崖。保持 100ms、10 槽,把到达 QPS 依次调到 50、80、95,记录 P99 → ρ 按 50% / 80% / 95% 线性上涨,P99 单调上涨,且 95% 时超过 50% 时的 4 倍。ρ 是风险指标,不是效率勋章。
  2. 连接池幻觉。保持 95 QPS,把并发从 10 翻到 20 → 下游有效并行度没变,处理吞吐变化不到 10%,排队更深,P99 不降。槽位不是服务能力;无限加大连接池只会把等待藏进去。
  3. 用超时止损。保持 95 QPS,把超时从 3000ms 收到 300ms → 错误率上升,但存活请求 P99 下降超过 50%。系统从全员慢死变成部分快速失败。

⚖️ 这个模型简化了什么

这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。 实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证, 但保证的是这个模型里的现象。

02 · 5 权衡超时不是耐心,是资源预算

事故后,你要为订单查询重新定超时。短超时能尽快释放槽位,长超时能给慢请求更多完成机会;端到端预算则把耐心按调用阶段拆开。三种方案都能成立,区别是你愿意把失败、等待和实现复杂度放在哪里。

决策点 D2

平时 P99 约 180ms、峰值偶尔到 700ms,订单查询超时怎么设?

A · 短超时:过载时迅速释放线程和连接等待位,故障边界清晰。 账单:会误杀一部分本可在 300–700ms 完成的请求,错误率更敏感;客户端若无退避地重试,短超时会变成更高到达率。适合可安全重试、失败页面明确、保护下游优先的查询。

B · 长超时:给长尾请求更多完成时间,低流量时单次成功率较高。 账单:过载时请求长时间占住线程和等待位,队列更深,用户最终仍可能等到失败。适合低并发、任务价值高、下游确实会在预算内恢复的操作。

C · 端到端 deadline:让每层知道还剩多少时间,避免上游已经放弃、下游仍继续做无用功。 账单:需要跨层传播 deadline、统一取消语义并补齐观测,工程复杂度最高。适合调用链超过一层、需要精确分配延迟预算的核心链路。

「秒抢」的选择:当前单体先选 A,订单查询 300ms 快速失败,并禁止前端立即连点;同时把 C 写进下一版接口契约。理由是此刻最贵的不是少量慢请求失败,而是它们占住连接让整站排队;等调用链增长后,再用统一 deadline 避免每层各等一遍。

02 · 6 复盘那一夜,先让队列缩短

回放 23:10 的正确处置——每一步都在先减小 ρ,再验证服务能力,最后才恢复流量:

23:11

入口止血。并发上限设为 40,获取连接等待超过 200ms 快速失败。连接池仍忙,但 waiters 从 1,842 降到 620,存活请求 P99 从 8.7s 回到 1.6s。

23:14

缩短持有。临时关闭订单列表里的非必要统计查询,把单请求连接占用从约 100ms 压到 62ms;处理吞吐上升,等待队列降到 90。

23:19

核对下游。确认 DB CPU 峰值 57%、查询 P99 55ms,没有锁等待;于是把入口并发从 40 调到 45,而不是盲目把池扩成十倍。502 降到 3.1%。

23:27

逐级放量。每三分钟增加约 10% 放行量,观察连接池占用和 P99;流量回落后 502 低于 0.5%,池占用稳定在 60%–72%。

壹
排队要有边界。无界等待把一次过载变成内存、线程和用户耐心的联合透支;队列满时快速失败,是对资源的明确结算。
貳
连接数不等于吞吐。吞吐由下游服务能力决定。调大池之前先看 DB CPU、锁、I/O 和完成速率,否则只是把更多并发送进争用。
叁
给利用率留可测余量。稳态 70% 左右不是浪费,而是给长尾和突发预留刹车距离;阈值要靠压测与生产指标校准。

02 · 7 自测你离排队悬崖还有多远

Q1 · 看形状诊断

连接池 active 曲线呈 ▁▂▅███,顶到上限后贴线;waiters 持续增加,但 DB 查询 P99 仍接近平时。最可能是?

A。 「active 贴上限 + waiters 增长」是 No.01 的阶跃打满型。C 很诱人,因为击穿也会让 DB 压力骤升,但题中 DB 查询仍快,瓶颈发生在获取有限连接之前;复制延迟则更像与写高峰同步的旧读毛刺。

Q2 · 排队算术

按 ρ / (1 − ρ),利用率从 0.8 升到 0.95,平均排队延迟的比例从多少变到多少?

B。 0.8/0.2=4,0.95/0.05=19,只多 15 个百分点,等待约放大 4.75 倍。A 是最诱人的线性直觉,但分母 1−ρ 正在快速缩小,容量边缘不能按百分点直接外推。

Q3 · 连接池幻觉

连接池 50/50,DB CPU 92%、锁等待上升。此时把池调到 500 最可能发生什么?

B。 DB 已接近饱和,放入更多并发通常只会增加锁、CPU 和 I/O 争用。A 很诱人,因为池大小确实扩大十倍,但槽位只代表许可,不代表服务能力;到达量和单次查询成本都没有因此变小。

Q4 · 超时边界

把超时从 3s 缩到 300ms 后,错误率上升、存活请求 P99 大幅下降。最准确的解释是?

A。 超时没有加快 DB,只是让超预算请求尽早退出,释放线程与队列位置。B 把资源回收误当成服务时间优化;D 忽略了误杀和重试账单,若客户端立即重试,短超时反而可能提高到达率。

🎯 面试映射 · 本章直接覆盖的高频追问

「为什么不能无限加大连接池?」 答题骨架:池是并发许可 → 下游容量才是吞吐上限 → 大池把等待搬到 DB → 实例数 × 单池大小核算总连接 → 用压测找安全水位。
「为什么 P99 比平均值重要?」 答题骨架:平均值会稀释长尾 → 排队由慢请求占槽传播 → 对比 P50/P99 与超时比例 → 用分位数定位少量请求如何拖住资源。
「超时应该怎么定?」 答题骨架:从端到端用户预算倒推 → 参考正常 P99 而非拍脑袋 → 给各层分配 deadline → 明确取消与重试 → 用错误率和资源释放验证。反直觉加分点:超时首先是资源预算。