KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 并发的量化:拐点是算出来的 — keel 龙骨

这一章回答:并发从 50 涨到 500,吞吐不涨反降——拐点在哪,怎么提前算出来。

这一章回答:并发从 50 涨到 500,吞吐不涨反降——拐点在哪,怎么提前算出来。

先修课讲了连接池参数怎么配。但"配多大"这个问题,本质上是并发度与系统容量的匹配问题。凭感觉设 20 或 200,都会在某个时刻出事。这一章给出可计算的方法。

一、Little's Law:三个量的关系

并发度(N)= 吞吐量(X)× 响应时间(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。 连接池 + 限流闸门都应该按这个数设,而不是按"机器能扛多少"。

⚠️ 两个常见错误:

  1. 只报平均耗时。P50 正常而 P99 已经爆炸的情况极其常见,平均值会完全掩盖掉排队。
  2. 压测客户端本身成为瓶颈。客户端并发度不够、或客户端 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,并发就能无限大"。

真相是:

错误做法: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 的恶化

自测题

  1. 目标 800 QPS、平均耗时 25ms,所需并发度是多少?如果耗时劣化到 100ms,同样的并发度下吞吐变成多少?
  2. 为什么压测必须同时看吞吐和延迟分位?只看 QPS 会漏掉什么?
  3. 拐点的物理本质是什么?为什么说"排队"是根因?
  4. 异步化之后连接池可以调大吗?为什么?
  5. 并发超过拐点后会形成什么正反馈?为什么限流必须是第一道防线?

进入 keel 阅读