本地媒体的一篇报道把三千用户同时推到门口。数据库没有坏,连接也没有丢;系统只是把每个位置都坐满,然后让后面的人排到超时。
创业第 2 个月,「秒抢」累计用户约 3 千。公司 6 人,后端只有 2 人。你刚合上电脑,本地媒体那篇《年轻人做的抢票网站》被推上首页;23:10,手机先震了一下,随后像卡住一样连续震动。
你连上监控。最扎眼的是「池满」,但另外三张卡片更重要:它们告诉你数据库还有余力,真正把用户挡在门外的是应用侧的等待。
连接池只有 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 正按秒增加;先止血,再确认哪段服务时间值得优化。
一个连接槽在处理请求时,后来的请求只能等它归还。设到达速率为 λ,每个连接每秒能完成 μ 个请求,连接数为 c;容量直觉是 c × μ,利用率则是 ρ = λ ÷ (c × μ)。当 ρ 接近 1,任何一次稍慢的查询都会把队列推长。
把平均服务时间记作 1 倍。代入指定的四个利用率,风险不是平滑增长,而是在容量边缘竖起一堵墙:
若一次请求平均占用连接 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 章,超时还会和重试绑在一起;超时越短并不天然越安全,因为上游若立即重试,失败流量会重新排队。
看到 ▁▂▅███,不要只问「池能不能再大」。先同时看三件事:连接持有多久、单位时间归还多少、下游还有多少余量。池满可能是流量变多,也可能是一笔慢事务把服务时间拉长;处方要落在 ρ 的分子或分母上。
调节到达、服务耗时、连接池和超时,观察利用率接近 100% 时尾延迟如何失控,以及超时怎样用快速失败换回存活请求的延迟。
usefulConcurrency=10 后有效下游并行度封顶,超出部分只让服务时间线性变慢 → 真实系统里下游能扛多少并发取决于它自己的 CPU、连接数和依赖容量,不是一个全局固定的 10 → 因此不要得出「本系统的连接池扩容都没用」的结论,先去测你自己下游的真实并行上限。
这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。
实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证,
但保证的是这个模型里的现象。
事故后,你要为订单查询重新定超时。短超时能尽快释放槽位,长超时能给慢请求更多完成机会;端到端预算则把耐心按调用阶段拆开。三种方案都能成立,区别是你愿意把失败、等待和实现复杂度放在哪里。
A · 短超时:过载时迅速释放线程和连接等待位,故障边界清晰。 账单:会误杀一部分本可在 300–700ms 完成的请求,错误率更敏感;客户端若无退避地重试,短超时会变成更高到达率。适合可安全重试、失败页面明确、保护下游优先的查询。
B · 长超时:给长尾请求更多完成时间,低流量时单次成功率较高。 账单:过载时请求长时间占住线程和等待位,队列更深,用户最终仍可能等到失败。适合低并发、任务价值高、下游确实会在预算内恢复的操作。
C · 端到端 deadline:让每层知道还剩多少时间,避免上游已经放弃、下游仍继续做无用功。 账单:需要跨层传播 deadline、统一取消语义并补齐观测,工程复杂度最高。适合调用链超过一层、需要精确分配延迟预算的核心链路。
「秒抢」的选择:当前单体先选 A,订单查询 300ms 快速失败,并禁止前端立即连点;同时把 C 写进下一版接口契约。理由是此刻最贵的不是少量慢请求失败,而是它们占住连接让整站排队;等调用链增长后,再用统一 deadline 避免每层各等一遍。
回放 23:10 的正确处置——每一步都在先减小 ρ,再验证服务能力,最后才恢复流量:
入口止血。并发上限设为 40,获取连接等待超过 200ms 快速失败。连接池仍忙,但 waiters 从 1,842 降到 620,存活请求 P99 从 8.7s 回到 1.6s。
缩短持有。临时关闭订单列表里的非必要统计查询,把单请求连接占用从约 100ms 压到 62ms;处理吞吐上升,等待队列降到 90。
核对下游。确认 DB CPU 峰值 57%、查询 P99 55ms,没有锁等待;于是把入口并发从 40 调到 45,而不是盲目把池扩成十倍。502 降到 3.1%。
逐级放量。每三分钟增加约 10% 放行量,观察连接池占用和 P99;流量回落后 502 低于 0.5%,池占用稳定在 60%–72%。
连接池 active 曲线呈 ▁▂▅███,顶到上限后贴线;waiters 持续增加,但 DB 查询 P99 仍接近平时。最可能是?
A。 「active 贴上限 + waiters 增长」是 No.01 的阶跃打满型。C 很诱人,因为击穿也会让 DB 压力骤升,但题中 DB 查询仍快,瓶颈发生在获取有限连接之前;复制延迟则更像与写高峰同步的旧读毛刺。
按 ρ / (1 − ρ),利用率从 0.8 升到 0.95,平均排队延迟的比例从多少变到多少?
B。
0.8/0.2=4,0.95/0.05=19,只多 15 个百分点,等待约放大 4.75 倍。A 是最诱人的线性直觉,但分母 1−ρ 正在快速缩小,容量边缘不能按百分点直接外推。
连接池 50/50,DB CPU 92%、锁等待上升。此时把池调到 500 最可能发生什么?
B。 DB 已接近饱和,放入更多并发通常只会增加锁、CPU 和 I/O 争用。A 很诱人,因为池大小确实扩大十倍,但槽位只代表许可,不代表服务能力;到达量和单次查询成本都没有因此变小。
把超时从 3s 缩到 300ms 后,错误率上升、存活请求 P99 大幅下降。最准确的解释是?
A。 超时没有加快 DB,只是让超预算请求尽早退出,释放线程与队列位置。B 把资源回收误当成服务时间优化;D 忽略了误杀和重试账单,若客户端立即重试,短超时反而可能提高到达率。