《Spring Boot 实战》17.2 压测与容量评估

压测做错比不做更危险:它会给出一个被信任的错误容量结论。本节讲清压测目标、场景与数据设计、工具选型、压测机与被测机分离、预热与稳定期、指标采集口径,拆解「连接池与并发不匹配」等假瓶颈,并给出用实测 QPS 乘安全系数倒推实例数的完整方法与示例。

本节目标:把压测从「跑个工具看数字」变成可信的工程方法——明确目标、设计贴近真实的场景与数据、选对工具、分离压测机与被测机、正确采集指标,避开假瓶颈,最后用实测 QPS 与安全系数倒推出有依据的实例数。
适用版本:Spring Boot 4.1.x(Java 21)

17.2 压测与容量评估

17.1 教了怎么定位瓶颈,但瓶颈定位有个前提:你得先有一个能复现问题的负载。压测就是制造这个负载的手段。问题在于,压测也是最容易被做歪的一环——一台笔记本上跑个 ab,得出「单机 5000 QPS」,然后据此规划生产容量,结果上线后连 500 都扛不住。

本节的核心观点:压测做错比不做更危险,因为它会给你一个错误的、被信任的容量数字。要得到可信的数字,必须在目标、场景、工具、环境、口径五个环节都做对。

17.2.1 先明确压测目标

没有目标的压测就是浪费时间。目标不同,场景设计和工具选型完全不同。常见三类目标:

目标想回答的问题输出物
找拐点系统在多少并发下开始劣化?并发 → QPS/P99 曲线、最大可持续 QPS
验证容量目标负载下能否满足 SLO?目标 QPS 下的 P99、错误率是否达标
回归对比这次改动有没有让性能退化?改动前后的同场景指标对比

「找拐点」要的是逐渐加压直到系统劣化;「验证容量」是直接上目标负载并观察是否稳定;「回归对比」要的是固定负载、固定环境、可重复,重点在对比而非绝对值。

明确目标还会顺带定下成功判据。比如「在 3000 QPS 下 P99 < 200ms 且错误率 < 0.1%」——有了这条线,压测结果才有「通过 / 不通过」的结论,而不是一堆看不懂的数字。

17.2.2 场景设计:读多写少、混合比例、真实数据

第一个决策是读写比例。 图书借阅服务的真实流量里,查书、查借阅记录这类读操作占绝对多数,借书、还书这类写操作少得多。如果压测只压「查一本书」,得到的 QPS 会严重高估容量;如果只压「借书」,又会低估。要按真实比例混合。

第二个决策是接口组合。 单一接口压测(如只压 GET /books/{id})只能测出那个接口的极限,不代表整体。真实流量是多个接口按比例混合,缓存命中率、连接池占用、锁竞争都会因为组合而不同。

第三个决策是数据量与数据分布。 这是最容易被忽视、也最容易导致「压测很快、生产很慢」的地方:

  • 数据量:在 1 万行的表上压测,和在生产 1000 万行的表上,SQL 的执行计划可能完全不同(小表可能全表扫也很快)。压测库的数据量要接近生产。
  • 数据分布:如果所有请求都命中同一本书(热点数据),缓存永远命中,得到的 QPS 虚高。要按真实分布打散,让一部分请求命中冷数据、一部分走到数据库。

一句话:压测场景的可信度,取决于它离真实流量有多近。 场景失真,数字再精确也没用。

用 k6 把上面的三点落成一个可进版本库的脚本(读多写少、接口混合、阶梯加压找拐点):

import http from 'k6/http';
import { check } from 'k6';

// 阶梯加压:每 30 秒升一档并发,观察 QPS 与 P99 的拐点
export const options = {
  scenarios: {
    ramp: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 100 }, // 预热
        { duration: '2m', target: 400 }, // 逐步加压
        { duration: '2m', target: 800 },
        { duration: '2m', target: 800 }, // 稳定期
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(99)<200'],  // SLO:P99 < 200ms
    http_req_failed: ['rate<0.001'],   // 错误率 < 0.1%
  },
};

const BASE = __ENV.BASE_URL || 'http://10.0.0.20:8080';

export default function () {
  // 读多:查询图书列表(按真实分布打散 category,避免缓存被刷热)
  const categories = ['NOVEL', 'TECH', 'HISTORY', 'ART', 'SCIENCE'];
  const c = categories[Math.floor(Math.random() * categories.length)];
  const r = http.get(`${BASE}/api/books?category=${c}&page=0&size=20`);
  check(r, { 'list ok': (res) => res.status === 200 });

  // 写少:约 1/10 的迭代触发一次借阅
  if (Math.random() < 0.1) {
    const body = JSON.stringify({ bookId: 1000 + Math.floor(Math.random() * 500) });
    http.post(`${BASE}/api/loans`, body, { headers: { 'Content-Type': 'application/json' } });
  }
}

thresholds 把 SLO 写进了脚本,k6 结束时直接给「通过 / 不通过」的结论——这就是 17.2.1 说的「成功判据」。ramping-vus 的阶梯让拐点自然浮现:当并发从 400 升到 800 而 QPS 不再增长、P99 陡增时,拐点就在那里。

17.2.3 工具选型:定位不同,别只看 QPS 数字

工具定位适合不适合
abApache 自带的最简压测单 URL 冒烟、快速看量级多接口、复杂场景、动态参数
wrk多线程 + Lua 脚本单接口高并发、可脚本化复杂业务流程、图形化报告
JMeter功能最全的 GUI 压测平台多接口混合、参数化、断言、分布式追求极致单机 QPS(本身较重)
Gatling基于 Scala DSL,代码化场景复杂场景、CI 集成、报告美观快速上手(要写 DSL)
k6基于 JavaScript,面向开发代码化、CI 集成、云压测传统企业 GUI 习惯

选型建议:验证容量与回归对比用 k6 或 Gatling(脚本可进版本库,天然可重复);快速定位单接口极限用 wrk;复杂业务流程、需要参数化和断言的用 JMeter。 工具本身不决定压测质量,场景设计才决定。

一个 wrk 的例子(压借阅查询接口):

wrk -t4 -c128 -d60s --latency \
    "http://10.0.0.20:8080/api/books?category=NOVEL&page=0&size=20"

-t4 是 4 个压测线程,-c128 是 128 个并发连接,-d60s 压 60 秒,--latency 打印延迟分布。示例输出:

Running 1m test @ http://10.0.0.20:8080/api/books?category=NOVEL&page=0&size=20
  4 threads and 128 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    12.34ms    8.91ms 220.10ms   88.20%
    Req/Sec   890.12    120.44     1.02k    70.10%
  Latency Distribution
     50%   10.20ms
     75%   15.30ms
     90%   22.80ms
     99%   88.40ms
  213420 requests in 60.02s, 512.33MB read
Requests/sec:   3556.12

判读:Requests/sec 是吞吐,Latency Distribution 里的 99% 是 P99。只报 Requests/sec 不报 P99 的压测报告没有意义——高 QPS 可能靠错误或超时堆出来的。

17.2.4 压测机与被测机必须分离

这是最硬的一条纪律:压测客户端和服务端不要在同一台机器上。

原因很直接:压测客户端本身要消耗 CPU、内存、网络带宽和文件描述符。当它和服务端抢同一份资源时,你测的到底是服务端的能力,还是「客户端和服务端加起来的能力」,完全说不清。更糟的是,客户端可能在服务端还没饱和时就先饱和了,于是你得出「服务端 QPS 只有这么点」的错误结论——其实瓶颈是压测机。

正确做法:

  • 压测机与被测机物理分离,最好跨网段但同机房(避免公网抖动)。
  • 压测机要预留足够资源,其 CPU 使用率不应接近饱和(压测期间盯一下压测机的 top)。
  • 需要更大压力时用多台压测机分布式发起,而不是把客户端线程数调到离谱。

同理,被测的 Spring Boot 应用也要独占机器:别在压测机上同时跑数据库、消息队列或监控 agent 的重活。

17.2.5 预热与稳定期

JVM 应用有一个「冷启动」阶段:类加载、JIT 编译、连接池填充、缓存预热。冷启动阶段的数字不代表稳态性能。

  • 预热(warmup):正式采集前,用中等负载跑一段时间(几十秒到几分钟),让 JIT 把热点方法编译到 C2、让连接池和缓存填充起来。没预热就采样,会把 JIT 的编译开销算进延迟里。
  • 稳定期:预热之后进入正式采集,此时才记录指标。稳定期要足够长(通常至少几分钟),以覆盖 GC 周期、定时任务、缓存过期等周期性行为。
  • 看趋势而非单点:稳定期里 QPS 和 P99 应该是平稳的;如果持续下降或抖动剧烈,说明系统还没进入稳态,或者已经出现资源泄漏、队列堆积。

wrk 没有内置预热,需要自己「先压一段不计入、再压一段计入」;k6/Gatling/JMeter 都有 warmup 阶段或可脚本化实现。

17.2.6 指标采集:不要在压测客户端测服务端延迟

最容易犯的测量错误:用压测客户端记录的延迟,当作服务端的处理延迟。

客户端测到的是「端到端往返时间」,里面包含:网络往返、客户端排队、服务端处理、响应序列化。当并发很高时,客户端自身的排队会显著放大这个数字。用它在服务端侧做决策,会把「客户端排队」误判成「服务端慢」。

正确的采集口径:

指标应在哪里采集为什么
服务端处理延迟服务端(Micrometer / Actuator)排除网络与客户端排队
端到端延迟客户端反映用户真实感知,作为补充
吞吐 QPS客户端客户端统计最完整
错误率服务端 + 客户端服务端看 5xx,客户端看超时/连接失败
资源使用服务端(CPU/GC/连接池)定位瓶颈资源

也就是说:吞吐和错误率看客户端,处理延迟和资源看服务端。 两者对不上时(客户端 P99 远高于服务端 P99),差额就是网络与客户端排队的开销,这本身就是有用的信息。

服务端侧用 Micrometer + Actuator 采集(16.1 已搭好),可以按 URI 维度看 P99 与请求计数;压测期间同时抓 JFR(17.1),把「服务端慢」和「GC/锁/IO」关联起来。

17.2.7 常见陷阱:假瓶颈与假容量

陷阱现象后果对策
连接池与压测线程数不匹配连接池成瓶颈,服务端线程在等连接误判为「应用慢」让压测并发与连接池、线程池一起放大验证
缓存被压测「刷热」重复请求同一数据,缓存永远命中QPS 虚高,生产冷数据打穿用接近真实分布的数据打散
单机压测下容量结论只压一台实例容量结论不可迁移多实例压测,或明确按单实例折算
压测机先饱和客户端 CPU 打满误判服务端上限分离压测机并监控其资源
短压测抓瞬时峰值只压 10 秒错过 GC 周期与长尾稳定期拉长到覆盖完整周期
只看平均值平均延迟很低掩盖 P99 长尾永远报 P99/P99.9

重点说第一条「连接池与压测线程数不匹配」:如果 HikariCP 连接池只有 20 个连接(11.1 讲过怎么定),而压测用 200 并发,那么 180 个请求会排在连接池外等待。此时压测报告的「服务端慢」其实是连接池不够,不是应用逻辑慢。对策是:压测时同步观察连接池活跃数、等待时间(hikaricp.connections.pending 等指标),如果连接池长期打满,说明瓶颈在池大小而不是代码——要么调池,要么调并发,两者要一起验证。

17.2.8 容量评估:用实测 QPS 乘安全系数倒推

容量评估不是「拍脑袋定 4 个实例」,而是从一个可信的实测 QPS 出发做算术。步骤:

  1. 测出单实例的最大可持续 QPS。 「可持续」指在满足 SLO(如 P99 < 200ms、错误率 < 0.1%)前提下的 QPS,而不是压到崩溃前的瞬时峰值。这个数从「找拐点」的曲线里读。
  2. 按峰值流量算实例数。 假设业务峰值是 P QPS,单实例可持续 C QPS,则理论实例数 N = ceil(P / C)。
  3. 乘安全系数。 生产环境要留冗余应对突发、实例故障、发布滚动重启。安全系数通常取 1.3~2.0(可用性要求越高、突发越猛,系数越大)。最终实例数 N = ceil(P / C × factor)。
  4. 按最坏情况验证。 用「N-1 个实例」验证容量(模拟一个实例故障),确认剩余实例仍能满足 SLO。

举例(数字仅为演算,非实测):若单实例可持续 800 QPS,业务峰值 2400 QPS,安全系数取 1.5,则 N = ceil(2400 / 800 × 1.5) = ceil(4.5) = 5 个实例;再用 4 个实例跑一次「N-1」验证,确认还能扛住 2400 QPS。

把这个算术固化成脚本,避免每次手算:

import math

def instances(peak_qps, per_instance_qps, factor):
    return math.ceil(peak_qps / per_instance_qps * factor)

print(instances(peak_qps=2400, per_instance_qps=800, factor=1.5))  # 5

为什么不用「总 QPS ÷ 单机 QPS」直接算? 因为没有安全系数就没有冗余,任何一次实例重启或突发流量都会击穿。容量评估的目标不是「刚好够用」,而是「在合理波动下仍然满足 SLO」。

容量结论必须连同前提一起写下来:单实例 QPS 是在什么 JVM 参数、什么数据量、什么场景下测的。换了大版本、改了 GC(17.3)、调了连接池,结论都要重新验证。

17.2.9 压测报告:没有前提的数字等于零

压测的价值在于「可复现、可对比」。一份合格的报告必须让人照着能重跑出同样的结论。至少包含:

字段内容为什么重要
目标找拐点 / 验证容量 / 回归对比决定怎么解读数字
场景接口、读写比例、数据量与分布场景不同,数字不可比
环境实例数、规格、JVM 参数、JDK 版本环境变了结论作废
工具与负载工具、并发、时长、压测机规格排除压测机成为瓶颈
结果QPS、P50/P95/P99、错误率、资源曲线完整指标,不只看平均值
结论单实例可持续 QPS、安全系数、实例数可执行的决策依据
对比与上次基线的差异回归的判据

把报告和脚本一起提交到版本库,每次改配置或发版都重跑一遍。性能回归往往比功能回归更隐蔽——功能测试会红,性能退化不会报警,只会慢慢把 P99 推高。

小结

  • 压测做错比不做更危险,因为它给出一个被信任的错误容量数字;目标、场景、工具、环境、口径五个环节都要做对。
  • 先明确目标(找拐点 / 验证容量 / 回归对比)并定下成功判据,再设计场景。
  • 场景要贴近真实:读写比例、接口组合、数据量与数据分布都要对齐生产,否则数字虚高。
  • 工具按定位选:wrk/ab 快速看量级,k6/Gatling 做可重复的容量与回归,JMeter 做复杂业务流程;工具不决定质量,场景才决定。
  • 压测机与被测机必须分离,压测机不能先饱和;被测应用要独占资源。
  • 预热让 JIT、连接池、缓存进入稳态,稳定期要足够长并看趋势;吞吐与错误率看客户端,处理延迟与资源看服务端。
  • 避开假瓶颈:连接池与并发不匹配、缓存被刷热、单机压测下结论、短压测抓峰值、只看平均值。
  • 容量评估用「单实例可持续 QPS × 安全系数」倒推实例数,并用 N-1 验证;结论要连同前提一起记录,改了参数就重新验证。

阅读导航:上一节:17.1 性能剖析方法 · 下一节:17.3 JVM 参数与运行时调优 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计