技术思考问题24:QPS冲上十万,P99爆炸也算赢?

临界点与安全边际:压测如何定义可用容量

峰值QPS不等于可用容量,拐点才是。

text 复制代码
[读者] 后端工程师、架构师、容量负责人
[痛点] 压测只报峰值QPS,上线后P99先塌
[现在读] 大促或扩容前必须给出可承诺容量
[读完] 能用SLO和阶梯压测找到拐点并留安全边际
text 复制代码
[旧方案] 一次打满,记录最高QPS
    |
    v
[新需求] 延迟、错误率、超时都必须达标
    |
    v
[冲突] 瓶颈之后吞吐还可能上扬,队列与P99已失控
    |
    v
[后果] 把拐点当容量,上线即过载

我是老李,在一个订单系统里负责容量与稳定性。过去我们做压测,最常问的一句话是"峰值能到多少",报告里最醒目的也是一个最高QPS数字。

后来大促前的容量评审变了。业务给的不是"越高越好",而是一组SLO:P95、P99、错误率和超时门槛。技术团队要回答的也不再是峰值,而是"在什么负载下,用户还能正常完成任务"。

于是我遇到一个很尴尬的局面:同一条链路,压测报告说能力充足,线上却在更低负载下出现尾延迟飙升。指标没有撒谎,撒谎的是我们对容量的理解。

业务到底能承受多高的尾延迟?

峰值QPS能代表可用容量吗?

容量应该定在曲线的哪个点?

01、故事

订单链路的核心路径不长:网关进来,经过订单服务,读写缓存与数据库,再调用支付和库存的下游API。系统里有一组线程池、连接池和HTTP客户端连接池,下游接口也有自己的限流阈值。

过去的容量方案很朴素:找一台机器,用单次峰值压测把请求压到极限,记下仪表盘里最高的QPS,然后按这个数字乘实例数,作为上线容量。

变化来自一次大促容量评审。业务方提出,下单接口在大促期间必须满足明确的P99和错误率目标,超时请求要单独统计,不能混在成功里。任务是给出一个可承诺的容量区间,而不是一个最高数字。

我们照旧跑了一次峰值压测。报告里QPS曲线很好,但有一位同事问:这个峰值对应的P99是多少?队列有多长?下游超时有没有被算进错误率?会议室安静了几秒。

那一刻我意识到,旧方案和新的SLO要求之间,已经出现了一道裂缝。

报告只看峰值

线上只看超时

任务不是冲高

而是定住边界

02、问题

旧方案失效的地方在于,它默认"系统能接收的最大请求量"就是"系统能正常服务的最大请求量"。这两件事并不等价。

业务影响很直接:容量被高估,大促流量一进来,尾延迟先恶化,超时和重试进一步放大负载,最后才表现为错误率上升和链路不可用。

技术表现通常是成组的:线程池活跃数打满,连接池等待队列变长,下游API触发限流,P95和P99分叉,超时请求被计入失败或重试。这个阶段吞吐曲线可能还在高位,但用户已经无法正常完成任务。

可验证的完成标准需要提前写清:给定业务SLO,找到满足延迟、错误率和超时目标的最大稳定负载;在曲线上标出拐点;给出安全边际;分别记录稳态、峰值、突发和降载恢复的行为。压测报告必须能回答"哪个负载区间可以上线",而不是只回答"最高跑到了多少"。

旧法失效在先

尾延迟现原形

标准必须可验

容量才有意义

03、原理

临界点思维说的是,系统接近资源饱和点后,延迟和失败率会非线性恶化。线程池、连接池或下游API都有自己的容量边界,一旦越过边界,请求不会排队排得优雅,而是开始超时、重试和堆积。

这里有一个反直觉判断:吞吐峰值可能出现在延迟已经不可接受的区间之后。因为队列本身是缓冲,系统可以一边让请求等待,一边把吞吐维持在高位。仪表盘看到的是QPS还在上扬,用户看到的是P99已经飙升。

另一个反直觉判断:过载时全部接收请求,往往比主动拒绝一部分请求更糟。Google SRE 书在《Handling Overload》中强调,服务端需要限流、降载和关键请求优先,客户端也需要退避与自适应限流。拒绝不是失败,而是保住已接收请求的服务质量。

安全边际的含义是,容量规划不以恰好撑住的极限作为正常工作区间。SLO和错误预算给出的是一条可运营边界:在边界内,系统有抖动空间;越靠近临界点,抖动越可能把系统推过去。

所以压测要找的不是最高点,而是拐点,以及在拐点之前那个满足SLO的稳定区间。

饱和之后非线性

队列拿延迟换量

极限不是工作点

安全边际留余地

04、架构

text 复制代码
[输入] 阶梯并发请求
    |
    v
[模块] 压测执行器 + 指标采集器
    |
    v
[数据/状态] SLO阈值、队列、P95、P99、错误率、资源
    |
    v
[处理] 稳态、峰值、突发、降载恢复分段判定
    |
    v
[输出] 拐点报告 + 安全容量区间

这个结构把压测从"打一发"变成"按阶段观察"。输入是阶梯并发,模块分成执行与采集,状态必须同时包含延迟分位、错误率、队列和资源,处理阶段按稳态、峰值、突发、降载恢复分开,输出是可解释的拐点与容量区间。

边界是:它测的是被测链路和被测依赖,不能替代全链路容量模型。收益是让容量结论可复现、可验证、可解释。代价是需要更多采集指标、更长压测时间和更明确的分析规则。

适用条件是:链路有明确SLO,压测环境与生产在关键依赖上尽量一致,且团队愿意接受"容量是一个区间"而不是一个单点。

阶梯压测输入

指标同步采集

拐点标在曲线

容量锁进区间

05、实战一次

先做一个最小但完整的V1:按并发阶梯发请求,采集QPS、P95、P99和错误率。环境使用Python 3.11、requests和标准库concurrent.futures。

配置只有一个目标URL和一组并发阶梯:

bash 复制代码
TARGET_URL='http://127.0.0.1:8000/order'
LEVELS='10 50 100'
DURATION=15

核心实现如下:

python 复制代码
import concurrent.futures
import time
import requests

TARGET = 'http://127.0.0.1:8000/order'
LEVELS = [10, 50, 100]
DURATION = 15


def one_request(_):
    start = time.time()
    try:
        r = requests.get(TARGET, timeout=2)
        return r.status_code, time.time() - start
    except Exception:
        return 599, time.time() - start


def percentile(values, p):
    values = sorted(values)
    idx = int(len(values) * p) - 1
    return values[max(idx, 0)]


for c in LEVELS:
    results = []
    started = time.time()
    with concurrent.futures.ThreadPoolExecutor(max_workers=c) as ex:
        futures = [ex.submit(one_request, i) for i in range(c * DURATION)]
        for f in concurrent.futures.as_completed(futures):
            results.append(f.result())
    codes = [x[0] for x in results]
    lat = [x[1] * 1000 for x in results]
    qps = len(results) / (time.time() - started)
    err = sum(1 for x in codes if x >= 500) / len(codes)
    print('concurrency=%d qps=%.2f p95=%.2fms p99=%.2fms err=%.4f' % (c, qps, percentile(lat, 0.95), percentile(lat, 0.99), err))

启动方式:

bash 复制代码
python3 load_ladder.py

请求发出后,观察每个并发档位下的QPS、P95、P99和错误率。示例输出:

console 复制代码
$ python3 load_ladder.py
concurrency=10 qps=<...> p95=<...>ms p99=<...>ms err=<...>
concurrency=50 qps=<...> p95=<...>ms p99=<...>ms err=<...>
concurrency=100 qps=<...> p95=<...>ms p99=<...>ms err=<...>

未在当前环境实测,以下为预期结果:随着并发升高,某一档之后P99会明显分叉,错误率可能随后上升。V1先跑通,再留下问题:它只采集了客户端视角,还没有把线程池队列、连接池等待和下游限流纳入证据。

先跑通再质疑

脚本要能复现

输出不只看QPS

还看尾延迟错率

06、排查

现象一:压测报告说峰值很高,线上在更低QPS下出现超时。怀疑是下游慢。检查线程池活跃数、连接池等待队列、下游超时计数和P99。证据是线程池活跃数达到上限,等待队列持续变长,P99与P95分叉,下游开始出现超时。根因不是下游单独变慢,而是压测只记录了峰值吞吐,没有记录对应延迟和队列状态。修复是把队列、P99、错误率和超时纳入压测报告,并把最大稳定负载作为结论。

错误尝试: 直接给实例扩容,认为实例多了容量就上去了。这个尝试错在两点:第一,瓶颈可能在单实例线程池或连接池,扩容只是稀释压力;第二,它掩盖了拐点位置,容量模型仍然建立在峰值QPS上,下一次扩容还会重演。

现象二:压测时长很短,缓存全热,报告里QPS曲线很好看。怀疑是业务流量不够。检查压测时长、缓存命中率、请求比例和第三方限流。证据是压测只持续很短时间,冷启动和长尾请求未被覆盖,报告里没有恢复阶段。根因是把一次短时峰值当成了稳态容量。修复是把稳态、峰值、突发和降载恢复分开测试,并固定请求比例、缓存状态和第三方限流条件。

现象指向队列

怀疑需要证据

错误尝试要记

根因才可修复

07、优化

根因来自第06章的证据:V1只采集客户端结果,缺少服务端队列和资源指标;只做单次峰值,缺少稳态与降载恢复;结论只报QPS,缺少SLO判定。

修改一:先定义业务SLO,标记P95、P99、错误率和超时门槛。原因是容量必须由用户可感知的目标定义,而不是由仪表盘最高数字定义。新行为是每个并发档位都要给出SLO是否达标。验证方式是对照压测输出逐档判定。

修改二:按并发阶梯加压,并同步采集QPS、队列、资源和尾延迟。原因是拐点通常先出现在队列和P99上,而不是先出现在QPS上。新行为是输出"并发---QPS---P99---错误率---队列"的联合视图。验证方式是检查同一档位下各指标是否相互解释。

修改三:单独做稳态、峰值、突发和降载恢复测试。原因是峰值能力不等于稳态能力,降载恢复能力也不等于承压能力。新行为是报告拐点、安全容量区间和恢复时长。验证方式是重复同一组阶梯,观察结论是否稳定。

根因决定改法

修改要有原因

新行为可验证

优化不靠猜测

08、演进

text 复制代码
[同一输入]
    |
    +--[V1] 单次峰值 / 代价:只有QPS,P99与队列不可见
    |
    +--[V2] 阶梯+SLO / 代价:耗时更长,指标与判定更多
    |
[Trade-off]
得到可承诺容量 / 失去快速结论 / 适用边界:核心链路与大促前容量评审

正确性上,V1只能证明"某一刻打到过某个吞吐",V2能证明"在哪个负载区间内满足SLO"。稳定性上,V1的结论容易被缓存、时长和依赖状态影响,V2通过分段和固定条件降低偶然性。复杂度上,V2需要更多采集、更长执行时间和更明确的分析规则。成本上,V2的压测资源与人力投入更高,但换来的是容量承诺的可解释性。适用范围上,V2更适合核心链路和关键依赖;探索性压测仍可先用V1快速摸底。遗留问题是:生产依赖的限流、冷启动和长尾场景仍需分组验证,不能靠单次报告一锤定音。

同一输入两版

旧版只看峰值

新版守住区间

取舍写在边界

09、洞见

9.1 峰值吞吐不是容量

反直觉判断:仪表盘最高的QPS,可能来自系统最不健康的状态。队列在替系统争取时间,延迟在替吞吐买单。容量必须要能解释"用户是否还能正常完成任务",而不是"机器还能接多少请求"。

9.2 拐点才是容量边界

拐点不是一个点,而是一段曲线上的判定。它由SLO阈值、队列、尾延迟和错误率共同标出。把拐点标出来,容量讨论才有共同语言。

9.3 安全边际不是保守,是工程责任

反直觉判断:留安全边际看起来浪费容量,实际上是在为抖动、重试、冷启动和突发流量买保险。以极限作为工作区间,等于把系统长期放在非线性恶化的边缘。

9.4 压测报告必须能解释

只报告QPS峰值却不报告对应延迟、错误率和队列的压测结论,基本没有参考价值。报告的价值在于暴露拐点,并说明上线容量为什么定在那个区间。

峰值不是容量

拐点是条红线

边际不是保守

是工程的责任

10、系统落地

原来有的是一份峰值压测报告,里面只有一个最高QPS和几张资源曲线。本篇新增的是SLO判定、阶梯并发、队列与尾延迟采集、分段测试和拐点标注。现在能做的,是给出满足SLO的最大稳定负载,并把上线容量限制在安全区间内。

还缺的是生产与压测环境在第三方限流、缓存状态和请求比例上的一致性验证。下一步演进可以从单链路扩展到核心依赖组合,把冷启动、长尾请求和降载恢复做成固定报表,让每次容量评审都能复用同一套判定方法。

原来只有峰值

现在多了拐点

能承诺稳定区

还缺长尾验证

11、小结

text 复制代码
Q1 → 先定义业务SLO,再谈压测
Q2 → 峰值QPS不能代表可用容量
Q3 → 容量定在满足SLO的稳定区间
状态 → 能输出拐点、安全边际与恢复时长

先定业务目标

再找最大稳定

最后留下边际

容量才算落地

12、作业

12.1 理解题:为什么吞吐峰值可能出现在延迟已经不可接受的区间之后?

参考答案:因为队列和缓冲会用等待时间换取吞吐维持。系统在瓶颈之后仍可能保持高位吞吐,但请求排队的尾延迟已经升高,用户可感知的服务质量先恶化。

12.2 实战题:把一个只报峰值QPS的压测脚本改成按并发阶梯采集P95、P99和错误率。

参考答案:在脚本中定义并发阶梯,每一档执行固定时长,分别统计QPS、P95、P99和错误率;执行时同步记录服务端队列与资源指标;最后输出联合视图并标注拐点。

12.3 排障题:压测报告峰值很好,线上低负载就超时,最先检查什么?

参考答案:先检查线程池活跃数、连接池等待队列、下游超时计数和P99。重点确认压测是否只记录了峰值,是否遗漏了队列、尾延迟和超时,然后判断瓶颈是在单实例资源还是下游依赖。

12.4 架构判断题:容量是否应该定在压测得到的最大稳定负载上?

参考答案:不应该直接把最大稳定负载当作上线容量。最大稳定负载是拐点附近的边界值,上线容量应保留安全边际,并考虑抖动、重试、冷启动和突发流量,把正常工作区间限制在满足SLO的稳定区间内。

理解要能说清

实战要能复现

排障要能闭环

判断要能取舍

13、思考

临界点思维和安全边际共同回答了一个问题:可用容量是用户还能正常完成任务的负载,不是仪表盘最高数字。压测的价值在于暴露系统的拐点,不在于跑出一个好看的峰值。只报告QPS峰值却不报告对应延迟的压测结论,基本没有参考价值。把SLO定义清楚,把阶梯压测跑完整,把队列、尾延迟和错误率放在同一张图上,容量才会从口号变成可承诺的工程边界。

峰值易迷人眼

拐点才见真章

边界定在心中

容量落在纸上

相关推荐
李福春2 天前
技术思考问题18:AI的超时和回答不准怎么作系统设计和处理?
java技术 技术管理
李福春3 天前
技术思考问题15:技术选型说不清禁区还敢选型吗?
java技术 技术管理
李福春4 天前
技术思考问题7:AI应用越多,人均产出为何不涨?
java技术 技术管理
李福春5 天前
AgentScope第11式·定式输出
java技术 技术管理