固定 10 个并发压测,服务卡了 2 秒,报告里只有 10 个慢请求
压测 12 秒,中间服务整体卡住了 2 秒。报告显示:近 1.6 万个请求,p99 是 11 毫秒,只有 10 个请求超过 100 毫秒。换一种发压方式测同一个服务、同样的卡顿:1.2 万个请求里有 3000 多个超过 100 毫秒,p99 接近 2 秒。
两份报告都没有算错。差别在于发压的一方在服务变慢的时候做了什么:前一种停下来等,后一种继续按原来的节奏发。真实的用户属于哪一种,决定了哪份报告更接近上线后的情况。
本文用一个进程内模拟的服务把这件事量出来,再说明读一份压测报告时先核对什么。实验在 JDK 25.0.4 上运行,见文末。
一、同一个服务,四份报告
被测服务:同时最多处理 16 个请求,每个请求处理 5 毫秒;测试进行到第 5 秒时整体卡住,第 7 秒恢复。四种发压方式各测 12 秒,「慢样本」指延迟超过 100 毫秒的请求。
| 发压方式 | 样本数 | 卡顿 2 秒内发出 | 慢样本 | p50 | p99 | 最大 |
|---|---|---|---|---|---|---|
| 闭合:10 个虚拟用户,收到响应立刻发下一个 | 15986 | 10 | 10 | 5 ms | 11 ms | 2010 ms |
| 开放:每秒 1000 个,按时间表到达 | 12000 | 2000 | 3032 | 6 ms | 1930 ms | 2006 ms |
| 单线程按时间表每秒 100 个,从实际发送时刻计时 | 1200 | 1 | 1 | 6 ms | 11 ms | 2010 ms |
| 同一次运行,从预定发送时刻计时 | 1200 | 1 | 669 | 313 ms | 1979 ms | 2010 ms |
四行的最大值都是 2 秒左右:每种方式都「看到」了卡顿。但只有最大值看到了。p99 这样的分位数取决于慢样本占多大比例,而这个比例由发压方式决定。
二、闭合模型在服务变慢时自动减压
**闭合模型(closed model)**里,系统中的请求数是固定的:N 个虚拟用户,每人发一个请求,等响应,(可选地停顿一下,)再发下一个。新请求的到来以旧请求的完成为前提。常见压测工具里「设定并发数(线程数、连接数、虚拟用户数)」的用法都是这一种。
服务卡住时,10 个用户手里各有一个请求挂着,于是没有人再发新请求。2 秒之后服务恢复,这 10 个请求各记一个 2 秒的样本,然后一切如常。整场测试里慢样本的数量等于并发数,与卡了多久、本该有多少请求到达都没有关系。把卡顿从 2 秒延长到 20 秒,慢样本还是 10 个。
闭合模型的吞吐也不是输入,而是结果:吞吐 = 并发数 ÷(响应时间 + 思考时间)。服务越慢,发压越少。它测到的是「这 N 个调用方在这个服务面前能跑多快」。
**开放模型(open model)**里,请求按一个与服务状态无关的速率到达:每秒 1000 个,不管前面的有没有处理完。卡顿的 2 秒里照常到达 2000 个请求,全部在排队;恢复之后服务以高于到达速率的速度处理积压,大约 1 秒才追平,这期间新到的请求也要排在后面。受影响的请求有 3000 多个,占 25%。
哪一种更接近真实,要看调用方是谁:
- 面向公网用户的入口、被大量独立客户端调用的接口、消息的生产速率,都是开放的。每个用户自己决定什么时候来,服务变慢不会让还没到的用户晚点来。
- 调用方数量固定而且同步等待的场景是闭合的:固定大小的批处理工作线程、连接数受限的内部同步调用。这时用闭合模型是对的,并发数就取真实的工作线程数。
- 介于两者之间的情况(用户会重试、会放弃、客户端有超时)更接近开放,而且重试会让到达速率在服务变慢时不降反升。
用闭合模型去回答「每秒 1000 个请求时延迟是多少」,得到的数字会系统性地偏好看。
三、延迟从什么时候开始算
有些发压方式看起来是按速率发的,实际上仍然被响应挡着。表里第三、四行是同一次运行:一个发送线程,按每秒 100 个的时间表发请求,但发送是同步的,上一个没回来,下一个就发不出去。
卡顿的 2 秒里,时间表上有 200 个请求该发而没发。恢复之后,发送线程以最快速度把它们补发出去。这时有两种计时方式:
从「实际发出去的时刻」计时,那 200 个被推迟的请求,以及补发期间继续被推迟的请求,每个都只花了 6 毫秒,报告里只有 1 个慢样本。从「时间表上本该发出去的时刻」计时,它们分别等了 2 秒、1.99 秒、1.98 秒……慢样本有 669 个。
后一种才是用户感受到的等待:用户在预定的时刻点了按钮,不关心发压工具的线程当时在忙什么。前一种计时方式把发压方自己的排队时间从延迟里漏掉了,这个问题通常叫协调遗漏(coordinated omission):发压方无意中配合了被测服务,在它最慢的时候恰好没有去测它。
判断一个工具或一份脚本有没有这个问题,看两点:
- 发送是不是会被未完成的请求挡住。每个请求由独立的线程、协程或异步回调发出,才不会被挡。
- 延迟的起点是不是预定的发送时刻。如果工具只能从实际发送时刻计时,就需要它在落后于时间表时把缺失的样本补进统计。
四、发压方自己成了瓶颈
目标是每秒 2000 个请求,发压方只有 4 个同步发送线程,被测服务没有任何卡顿:
| 计时起点 | 实际速率 | p50 | p99 |
|---|---|---|---|
| 实际发送时刻 | 569/s | 6 ms | 12 ms |
| 预定发送时刻 | 569/s | 4264 ms | 8515 ms |
4 个线程、每个请求约 7 毫秒,最多只能发出每秒 570 个左右。按第一种计时方式,报告是「延迟很低、非常平稳」,但测的根本不是 2000/s 下的服务,而是 569/s 下的服务。被测服务的 CPU 利用率很低、延迟很好、吞吐上不去,这三个现象同时出现时,先怀疑发压方。
所以压测报告里必须同时给出目标速率和实际达到的速率,两者不一致时延迟数字不能用。发压机本身的 CPU、网络带宽、连接数、文件描述符也要监控;一台不够就用多台,并且确认各台的统计是合并原始样本而不是把各自的分位数再取平均(分位数不能平均)。
五、目标速率从哪里来
开放模型需要一个到达速率作为输入。常见的错误是用日请求量除以 86400:一天 864 万次请求,平均每秒 100 个,但流量在一天里不是均匀的。
- 从真实流量里取峰值:按秒或按分钟统计历史请求数,取高峰时段的值,而不是日均值。没有历史数据的新业务,用相近业务的峰均比估算,并把它写成假设。
- 目标是「峰值的若干倍」而不是峰值本身:留出增长和突发的余量,倍数由业务决定。
- 速率之外还有形状:请求在各接口之间的比例、参数分布(热点键还是均匀)、读写比例。全部打同一个缓存命中的键,测到的是缓存。
- 负载曲线按要回答的问题选:阶梯加压找拐点;在目标速率上长时间保持,看有没有随时间恶化的问题(内存增长、连接泄漏、磁盘写满);突增看限流和扩容的反应。
拐点之前用开放模型加压的另一个好处是它会暴露排队:到达速率一旦超过服务能力,延迟不是略微上升而是持续增长,这正是线上过载时的样子。闭合模型在同样的位置只会表现为吞吐不再上升。
六、读一份压测报告先核对什么
- 负载模型:设定的是并发数还是到达速率?和真实调用方的行为是否一致?
- 实际速率:有没有达到目标?发压方的资源是否有余量?
- 计时起点:延迟从预定时刻还是实际发送时刻算起?发送会不会被慢响应挡住?
- 分位数怎么来的:是全部样本的分位数,还是每个时间窗口分位数的平均?后者没有意义。
- 错误怎么算:超时和失败的请求是否计入延迟统计?只统计成功请求会让结果偏好看。
- 持续时间:是否覆盖了周期性的事件(垃圾回收、缓存过期、定时任务)?预热阶段是否剔除?
- 最大值与长尾:只报平均值和 p99 的报告,问一下最大值和超过阈值的请求数。
常见工具对两种模型都有支持,但默认值不同:例如 k6 用不同的执行器区分固定虚拟用户数与固定到达速率,Gatling 区分开放与闭合两类注入方式。选工具之前,先看它的文档里这一节。
延迟分布在上线之后怎么看,见 可观测性;压测要找的线程池与下游容量拐点,见 线程池参数怎么定。
配套实验
- codesphere-labs/testing/open-vs-closed-load-model:JDK 25.0.4 上同一个带 2 秒卡顿的模拟服务,闭合模型、开放模型、单线程按时间表发送(两种计时起点)与发压能力不足四种情况的样本数、实际速率与延迟分位数(验证记录)
参考资料