KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 并发的量化:拐点是算出来的 — keel 龙骨
这一章回答:并发从 50 涨到 500,吞吐不涨反降——拐点在哪,怎么提前算出来。
这一章回答:并发从 50 涨到 500,吞吐不涨反降——拐点在哪,怎么提前算出来。
先修课讲了连接池参数怎么配。但"配多大"这个问题,本质上是并发度与系统容量的匹配问题。凭感觉设 20 或 200,都会在某个时刻出事。这一章给出可计算的方法。
一、Little's Law:三个量的关系
并发度(N)= 吞吐量(X)× 响应时间(R)
这个恒等式说明三件事:
- 给定目标吞吐 X 和单请求耗时 R,所需并发度是算出来的:
N = X × R
(例:目标 1000 QPS,平均耗时 20ms → N = 1000 × 0.02 = 20) - 反过来,并发度固定时,耗时上升必然导致吞吐下降:
X = N / R - 耗时变长是吞吐下降的原因,而不是结果——这解释了很多"数据库变慢 → 吞吐暴跌"的现象
用途:把"连接池应该设多大"从玄学变成算术。20 个连接够不够,取决于每个 SQL 花多久,而不是取决于机器有多少核。
二、为什么会有拐点:排队
系统里的每个资源(连接、CPU、磁盘、锁)都是一个队列。排队论的结论是:
当资源利用率 ρ 接近 1 时,等待时间呈双曲线上升(而非线性)
ρ = 0.5 → 等待约 1 倍服务时间
ρ = 0.8 → 等待约 4 倍
ρ = 0.95 → 等待约 19 倍
所以拐点的本质是:某个资源的利用率接近饱和,之后新增的并发全部变成排队时间。
表现:吞吐曲线变平(甚至下降),而延迟曲线陡升。这就是压测时必须同时画三条曲线的原因——只看 QPS 会让你误以为"还能撑"。
QPS
│ ┌─────────── 平台(吞吐饱和)
│ ╱
│ ╱
│╱
└─────────────────────► 并发度
P99 延迟
│ ╱
│ ╱
│ ╱ ← 拐点:这里开始排队
│──────╱
└─────────────────────► 并发度
拐点处的并发度,就是你应该允许的并发上限。
三、怎么测出拐点
压测的正确做法:固定其它条件,逐步提高并发度,记录每个档位的吞吐与延迟分位。
并发度: 10 25 50 100 200 400 800
QPS: 480 1150 2100 3400 4100 4200 3900 ← 400 之后不涨,800 反降
P50 ms: 12 14 18 28 55 120 310
P99 ms: 30 38 55 95 210 520 1500 ← 200 之后陡升
判读:
| 档位 | 结论 |
|---|---|
| 并发 100:QPS 3400、P99 95ms | 健康区 |
| 并发 200:QPS 4100、P99 210ms | 接近拐点,吞吐边际收益已经很小 |
| 并发 400:QPS 4200、P99 520ms | 拐点之后,多出来的并发全是排队 |
| 并发 800:QPS 反而下降 | 过载区(可能是锁竞争、上下文切换、内存压力) |
结论:这个系统的合理并发上限是 100~150。 连接池 + 限流闸门都应该按这个数设,而不是按"机器能扛多少"。
⚠️ 两个常见错误:
- 只报平均耗时。P50 正常而 P99 已经爆炸的情况极其常见,平均值会完全掩盖掉排队。
- 压测客户端本身成为瓶颈。客户端并发度不够、或客户端 GC/网络打满,会让你测出一个虚假的平台期。压测时要确认客户端资源未饱和。
四、把并发上限变成配置:背压闸门
算出上限之后,必须有一个机制阻止并发超过它,否则排队会在系统内部堆积(连接池队列、线程池队列、数据库锁队列)。
三层闸门(从外到内):
# ① 入口限流:超过阈值直接拒绝,快速失败而不是排队
from asyncio import Semaphore
db_gate = Semaphore(120) # 允许同时进入数据库路径的请求数
async def handle(req):
async with db_gate: # 拿不到就在这里等待/超时
return await query(req)
| 闸门 | 位置 | 作用 |
|---|---|---|
| 入口限流/熔断 | 网关或服务入口 | 挡住超出容量的请求 |
| 信号量/连接池上限 | 应用内数据库路径 | 保证并发不超过拐点 |
数据库侧 max_connections |
数据库 | 最后一道保护 |
关键原则:拒绝要快,排队要短。 无限排队会把"慢"变成"不可用"——请求堆在队列里,用户看到的是转圈,而你看到的连接数还是满的。
# 排队也要有上限与超时
try:
async with asyncio.timeout(0.5): # 排队超过 500ms 就放弃
async with db_gate:
...
except TimeoutError:
return {"error": "系统繁忙,请稍后重试"} # 明确的降级响应
五、异步协程与并发度的关系
一个高频误解:"用了 async,并发就能无限大"。
真相是:
- 协程让等待连接不再占用线程(线程成本低了);
- 但数据库的连接数、锁、CPU 没有变多;
- 所以
pool_size的容量计算方式与同步代码完全一样,仍然受 Little's Law 与拐点约束。
错误做法:async 化之后把 pool_size 从 20 加到 200
→ 数据库并发 200 > 拐点 150 → 锁竞争加剧、延迟陡升、吞吐下降
正确做法:pool_size 仍按 N = X × R 计算
async 的收益体现在"同样的并发下线程/内存开销更小"
六、并发度与锁竞争:为什么加起来更糟
超过拐点后,还有一个正反馈:
并发上升 → SQL 执行时间变长(排队)
→ 事务持锁时间变长
→ 锁冲突概率上升(冲突 ∝ 并发² 量级)
→ 更多事务等待 → 执行时间更长
这就是为什么过载时系统不是"慢一点",而是断崖式崩溃。也是为什么限流必须是第一道防线:一旦进入这个正反馈,除了降并发没有别的解法。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一次完整压测 | 并发度 7 档 × (QPS、P50、P99、错误率),画出三条曲线 |
| 拐点结论 | 明确指出拐点并发度,以及支撑该结论的数据(吞吐边际收益 + 延迟斜率) |
| 容量计算 | 用 N = X × R 算出目标 QPS 所需的并发度,与压测结果交叉验证 |
| 背压闸门实现 | 一个信号量 + 排队超时的实现,压到过载时表现为快速失败而非雪崩 |
| 过载验证 | 并发超过拐点 2 倍时,观察吞吐下降与 P99 陡升(确认正反馈存在) |
完成标志:给定目标 QPS 与 SLA(P99 < 200ms),你能给出连接池大小、信号量上限、限流阈值三个数字,并说明它们是怎么算出来的。
故障注入
| 注入方式 | 观察 |
|---|---|
| 并发从拐点以下逐步加压到 3 倍 | 吞吐是否达到平台并下降、P99 是否陡升 |
| 去掉信号量闸门直接压到 1000 并发 | 是否出现大量超时、错误率曲线 |
把 pool_size 设成远大于数据库上限 |
数据库是否拒绝连接、是否影响其它服务 |
| 慢 SQL(1s)占比 10% 时压测 | 持锁时间变长后拐点是否前移 |
| 压测客户端 CPU 打满 | 是否测出虚假平台期(验证压测客户端不是瓶颈) |
| 只记录平均耗时不看分位 | 用数据说明平均值如何掩盖 P99 的恶化 |
自测题
- 目标 800 QPS、平均耗时 25ms,所需并发度是多少?如果耗时劣化到 100ms,同样的并发度下吞吐变成多少?
- 为什么压测必须同时看吞吐和延迟分位?只看 QPS 会漏掉什么?
- 拐点的物理本质是什么?为什么说"排队"是根因?
- 异步化之后连接池可以调大吗?为什么?
- 并发超过拐点后会形成什么正反馈?为什么限流必须是第一道防线?