做性能测试的时间越长,我越觉得"生成html测试报告、参数化、jvm监控"这三件事必须当成一套组合拳来打。早些年我只关心压测脚本能不能把服务打满,压完把聚合报告界面截图丢到群里就算交差,结果每次被追问都很难看:请求参数全是固定写死的,压出来的曲线代表不了真实用户;服务CPU看着不高,线上却大量超时,根本说不清问题卡在JVM的哪一层;报告里一堆英文指标,业务和开发各看各的,最后还得我再口播一遍结论才能收场。
后来我把流程固定下来:先做参数化,让每一笔请求都更接近真实流量;再用jstat、GC日志、JMX这些手段盯住目标服务的JVM内部状态;最后统一输出一份HTML测试报告,把过程和结论一次性交付出去。这套内容适合刚接触性能测试的QA、需要自己搭环境跑压测的后端工程师,也适合想把压测沉淀成可复用流水线的人。下面每一节都会讲清楚做什么、为什么这么做,以及哪里最容易翻车。
1. 这三件事为什么必须放在一条链上
1.1 不做参数化,压测结果根本不敢信
很多人压测时候图省事,直接在脚本里写死一个用户ID、一个商品编码,然后并发往上怼。表面看TPS跑得很高,实际这个数字在线上没有任何参考价值。原因很直接:现在服务端基本都有缓存,一百万线程都查同一个用户,第一次查完后面全命中了缓存,你测出来的是"缓存命中率100%"的理想值,根本不是真实服务的表现。换个方向看,如果压的是下单、扣库存这种写接口,固定用一个订单号,后面几十个请求全在撞同一把锁,要么报重复主键,要么互相等待,错误率高得离谱,你会误以为系统扛不住。
所以参数化的第一作用,是让测试数据有足够的宽度和真实性。第二作用是让你的压测可以重复。用脚本批量生成10万条数据文件,这轮压完下轮还能用同样的数据集复现问题,而不是靠运气碰。这里的关键不是会不会用CSV参数化,而是压测之前先想明白:哪些字段要变化、变化范围多大、需不需要唯一、数据该从文件来还是动态生成。这些问题没想清楚,CSV配置配得再熟练也是白搭。
1.2 只看外部指标,永远定位不了瓶颈
性能测试最气人的场景是:TPS突然掉了一半,响应时间从50ms涨到2秒,但你打开系统监控看CPU只有30%,内存也没满。这时候如果手里没有JVM视角的数据,就只能靠猜。而JVM监控就是为了回答"问题到底出在哪一层":是不是Young GC太频繁,把停顿时间吃掉了;是不是Old区被占满触发了Full GC,整机STW了几百毫秒;是不是线程池被卡死的请求占满,活跃线程数异常升高。
举个例子,之前给某积分服务做容量摸底,压测中RT每过30秒就规律性地跳一下,从曲线看像是有定时任务在抢资源。当时我们同时抓了GC日志和线程快照,发现每次RT尖峰前都有一次Full GC,Full GC的触发点又和缓存过期批量回源的定时任务重合。没有JVM监控,这个结论根本没法下。JVM数据不是"锦上添花",它是把问题从应用层、系统层、JVM层区分开的唯一依据。
1.3 报告不是给自己看的,是给决策看的
压测报告如果只是导出个聚合表,那叫数据,不叫报告。真正有用的HTML测试报告,要让看的人能在三分钟内回答三个问题:系统当前到底有多大能力、离目标差多少、如果要达到目标该调整什么。开发想知道瓶颈在代码还是资源配置,业务想知道能不能按时上线,领导想知道要不要加机器。这些都需要一份结构化的报告来承接。
我刚入行时习惯把每个接口的min、max、avg全列进去,结果越列越厚,反而没人看。后来我改成只放关键结论加支撑曲线,报告反而开始被人认真读。参数化保证数据可信,JVM监控保证论据充分,HTML报告保证表达清楚,这三件本身就是一条完整性能测试链路上的三个环。
2. 参数化落地:先定数据模型,再选实现工具
2.1 动手写脚本前先回答三个问题
参数化最怕一上来就配CSV组件。我现在的习惯是先算三笔账。
第一笔是数据量。假设目标并发是200线程,每个线程循环50次,那最少要有10000条不重复的数据;如果还分了登录、查询、下单多组接口,每组都要单独估算。一般我会按计算值的两倍生成,给热身阶段和异常消耗留余量。第二笔是唯一性要求。用户ID、订单号这种关键业务标识尽量不重复,否则会引入锁竞争或主键冲突,测出来的数字不能反映真实容量。第三笔是分布要求。压测数据是随机命中、顺序递增,还是按线上比例混合,这里要结合业务场景,比如模拟新老用户混合访问,就要让"注册时间"这个字段也随机分布。
这三笔账算完,参数化的实现方式就很好选了:数据量大且有固定业务的用文件参数化,数据需要动态拼接的用函数参数化,两者结合也很常见。
2.2 CSV Data Set Config 的配置细节
JMeter的CSV Data Set Config是文件参数化最常用的组件,但配置项里全是坑。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| File encoding | UTF-8 | 注意不能带BOM,否则第一个变量名会多一个不可见字符 |
| Variable Names | 用逗号隔开,如userId,itemId | 后面通过${userId}引用 |
| Delimiter | 逗号 | 如果CSV是用Tab分隔,要改成\t |
| Allow quoted data | False | 数据里没有逗号时保持默认即可 |
| Recycle on EOF | True(按需) | 如果每线程需要独立读完整个文件,要结合Sharing mode配置 |
| Stop thread on EOF | False | 如果设为True,数据读尽线程直接结束,会悄悄少跑 |
| Sharing mode | Current thread | 关键项,下面细说 |
最容易翻车的是Sharing mode。默认是All threads,意思是所有线程共享一个游标,A线程读了第1行,B线程接着读第2行,大家轮流拿数据。这种模式适合"数据只要不重复就行"的场景。但如果你的业务要求每个线程全程使用同一个用户上下文,就得改成Current thread,让每个线程各自从头读自己的数据。曾经我把Sharing mode设成默认值压登录接口,结果每个线程每次循环都换一个新用户,服务端的session创建压力被严重高估,整个结果白做。
还有编码问题。Windows下用Excel另存的CSV经常是ANSI编码或者带BOM的UTF-8,JMeter读出来第一个字段名会变成乱码,变量引用全部失效。我现在统一用脚本生成数据文件,生成时直接指定utf-8无BOM,从源头杜绝这个坑。
2.3 函数参数化:适合动态生成而不是查表
有些数据不需要文件,直接用JMeter的内置函数更省事。比如随机手机号可以用${__Random(13000000000,13999999999,phone)},循环计数可以用${__counter(TRUE,index)}。更常用的是拼唯一标识,我经常这么写:
${__P(user_prefix)}_${__threadNum}_${__counter(TRUE,userIdx)}__threadNum取线程号,__counter在该线程内自增,两者组合起来能保证多线程高并发下每个用户ID都不重复。线的场景。
函数参数化的优势是不用准备文件、脚本轻,缺点是数据分布不好控制,也没法保证复杂业务字段之间的关联关系。所以我的判断标准很简单:需要关联多字段的表数据用CSV,单个字段动态生成用函数,复杂混合场景两者一起用。比如压一个订单流程,用户基础信息从CSV读,金额用__Random生成,库存编码再单独准备一份CSV,这样最贴近真实。
2.4 选型对比
| 方式 | 适合场景 | 优点 | 需要注意 |
|---|---|---|---|
| CSV Data Set Config | 批量账号、商品、订单基础数据 | 可控性最强,可复现 | 编码、Sharing mode、EOF配置容易踩坑 |
| __Random函数 | 金额、年龄、随机ID | 免数据文件,灵活 | 无法控制重复率,要关注唯一性 |
| __counter / __threadNum | 并发唯一标识 | 多线程下天然唯一 | 重启线程后计数会重置 |
| __StringFromFile | 从文件逐行读取 | 适合已有日志文件回放 | 不支持随机读取,只能顺序读 |
| JDBC请求做参数源 | 数据需要从库中动态取 | 最接近真实库中分布 | 压测过程中会额外占用数据库连接,慎用 |
2.5 一个小例子:模拟1万用户查积分
用一个实际的参数化组合收尾这个部分。假设目标是模拟1万个用户查积分,每个用户查3次。我会先用Python脚本生成10000行的CSV,结构是userId,userLevel,cityId三列,其中userId按顺序生成,userLevel和cityId按线上分布随机生成。然后CSV组件配成UTF-8、Current thread共享模式、Recycle on EOF设为True。查询请求里引用${userId},再用${userLevel}去控制断言或分支。这样一个脚本跑出来,既有用户维度差异,又重复可复现,比手动造10000个Sampler靠谱得多。
3. JVM监控:把堆内存和GC变成可以对照的曲线
3.1 监控工具矩阵,先知道该用哪把尺子
JVM监控工具不少,但各有各的适用时机,我按使用频率排个序。
| 工具 | 作用 | 副作用 | 压测中的用法 |
|---|---|---|---|
| jstat | 查看GC、堆各区占用 | 几乎无 | 压测全程后台定时记录 |
| jstack | 抓线程快照,看阻塞和死锁 | 会有短暂停顿 | RT异常时手动抓几份 |
| jmap | 堆转储、类统计 | 会触发STW,慎用 | 怀疑内存泄漏时才用 |
| VisualVM / JConsole | 可视化看堆、线程 | 本地连远程要开JMX | 调测阶段单机观察 |
| JMX + 监控平台 | 持续曲线展示 | 需配置暴露端口 | 长时间压测或流水线场景 |
| JMeter PerfMon插件 | 监控进程CPU/内存 | 需要部署Agent | 配合看系统资源 |
压测现场我最常用的是jstat,因为它在命令行一把梭,开销极小。启动命令一般是:
jstat -gcutil 12345 1000 60意思是每1000毫秒采样一次目标进程12345,共采60次,输出GC和堆使用率。压测结束后把输出重定向到文件,就是一条干净的JVM时间序列。jstack则是在响应时间突然恶化的时候用,连抓三次线程快照,看哪些线程长时间停在BLOCKED或WAITING状态,能立刻定位到锁竞争和线程池打满的问题。
3.2 压测时我固定抓的五组数据
jstat输出里那一串字母,S0、S1、E、O、M、YGC、YGCT、FGC、FGCT、GCT,刚开始看会觉得晕,其实每一列都在回答一个问题。
S0、S1是Survivor区的占用比例,E是Eden区,O是Old区,M是元空间。我主要看的是O这一列,也就是老年代的使用趋势。如果Old区在一轮压测里持续上涨,而且每次Young GC之后都不回落,那基本可以往内存泄漏方向查。如果Old区到了80%以上就开始频繁FGC,那是堆内存配置偏小,需要调大或者优化对象分配。
YGC和YGCT是Young GC次数和累计耗时,FGC和FGCT是Full GC次数与耗时,GCT是总GC耗时。Full GC是Stop-The-World的,一次几百毫秒的停顿直接打在响应时间上,所以在压测曲线里看到RT周期性尖峰,第一件事就去看FGC时间点是否吻合。
线程数据不来自jstat,来自jstack和VisualVM。我会重点记录活跃线程数、BLOCKED线程数、WAITING线程数。线程数持续爬升不回落,通常是连接池或任务队列堆积,这个信号比CPU更有说服力。CPU数据则用PerfMon或容器自带监控来看,注意要区分系统CPU和JVM进程CPU,别拿整台机器的指标当服务指标。
3.3 GC日志怎么开、怎么看
jstat是采样,GC日志是全过程记录,两个都要留。开GC日志很简单,JVM启动参数里加一行就行。
JDK 8:
-Xloggc:/opt/app/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStampsJDK 11及以上推荐统一日志格式:
-Xlog:gc*:file=/opt/app/logs/gc.log:time拿到GC日志后先看有没有Full GC记录,例如下面这行:
[Full GC (Allocation Failure) 2.50G->1.80G(3.00G), 0.63s]意思是Full GC把堆从2.50G回收到了1.80G,最大容量3.00G,耗时0.63秒。0.63秒听起来不长,但对一个目标RT 200ms的接口,这一停顿就是三倍超时。所以GC日志要和压测时间线放在一起看,一旦RT曲线出现尖峰,直接在GC日志里搜对应时间点的Full GC,十次有八次能对上。
3.4 监控数据怎么和压测曲线对齐
有了JVM数据,最后要解决对齐问题。JTL文件里JMeter记录的时间戳是epoch毫秒,jstat输出的是系统时间,GC日志带的是程序处理过的时间串,三者格式不统一。我现在固定用epoch毫秒做对齐。
操作上很简单,压测开始前后在脚本里放两个时间标记,或者直接在命令行记录开始时间;结束后把所有CSV、JTL、GC日志统统按时间戳秒级对齐,用脚本合并成一张宽表,再做曲线叠加。没有对齐的监控数据是废数据,因为你看不到GC发生在压测的哪个阶段,也就没法判断它对TPS的影响。对齐这一步花不了十分钟,但能让整个压测结果的可信度上一个台阶。
4. 生成HTML测试报告:我走过两条路,各有适用场景
4.1 最快拿到结果:JMeter Dashboard Report
JMeter 3.0之后自带HTML报告生成能力,不需要装额外插件。我最常用的命令是:
jmeter -n -t test.jmx -l results.jtl -e -o report_dir这条命令的含义是:非GUI模式跑一遍测试计划,把原始结果写到results.jtl,然后直接根据这个JTL生成HTML报告到report_dir目录。如果脚本已经跑完、只留了JTL文件,也可以单独生成:
jmeter -g results.jtl -o report_dir生成成功后,report_dir里有个index.html,打开就是一套完整仪表盘,包含APDEX指数、总TPS、响应时间分布、分位数曲线、活跃线程数曲线、错误率等模块。对于自用或者临时给团队看,这个报告已经完全够用,而且它是官方生成,不会被人质疑数据加工过程。
它的问题是样式固定、结构千篇一律,没法带业务自定义结论,项目组看久了会疲劳。所以我一般把它当作第一道成果,确认真实业务结论后,再决定要不要做定制报告。
4.2 想要能交付给业务的报告:自己写脚本渲染
当报告要面向业务方和决策层时,我选择自己写脚本生成HTML。思路不复杂:用Python读取JTL原始数据,按需聚合,再套一个HTML模板输出。比起手工复制数据进Word,这条路可以反复用,一个项目改改参数就能出下一份。
核心聚合代码大概长这样:
import pandas as pd df = pd.read_csv('results.jtl', usecols=['timeStamp', 'elapsed', 'success', 'label']) rt = df.groupby('label')['elapsed'].agg( count='count', mean='mean', p90=lambda x: x.quantile(0.90), p95=lambda x: x.quantile(0.95), p99=lambda x: x.quantile(0.99) ) df['sec'] = df['timeStamp'] // 1000 tps = df.groupby('sec').size()拿到这张聚合宽表后,再交给Jinja2模板渲染。模板里放上几个"指标卡片":峰值TPS、平均RT、p99、错误率,下面再放TPS曲线和RT分位曲线。图表部分我会把聚合数据直接序列化成JSON,塞给前端图表库去画,这样一个HTML文件没有外部依赖,发出去就能打开。
这条路的成本是前期要写一次模板,收益是从此报告想怎么调整就怎么调整。公司内部如果有压测平台,把这段逻辑接进流水线,每次压测结束自动出报告,效率比人工贴数据高一个量级。
4.3 报告里真正值得放的内容
写报告的原则是"回答决策问题,而不是罗列数据"。一份及格的HTML测试报告,我认为至少要有四块。
第一块是结论卡片区,顶部放一句话结论:系统在多大并发下达到多少TPS、RT是否达标、是否满足上线标准。第二块是核心曲线区,放TPS、RT平均值与p99、活跃线程数随时间变化的图,这一块要和JVM的GC曲线叠在一起看。第三块是接口明细表,把每个接口的采样数、均值、p90、p95、p99、错误率列成表格,供开发定位具体接口。第四块是资源与JVM区,放CPU、内存、Full GC次数、Old区占用趋势,以及明确的瓶颈结论和改进建议。
数据可以多,但结论必须唯一。否则报告发出去,开发看完不知道先改哪里,报告就白做了。
5. 把三块内容一次串完:我的标准操作顺序
5.1 准备阶段:数据和监控先就位
压测执行前我有一张固定检查清单。
数据方面,先用脚本生成参数化数据文件,文件放到项目目录下,同时记录生成脚本和数据条数,方便复现。脚本方面,先在JMeter GUI里用5到10个线程小并发跑通整个链路,确认CSV引用没写错、断言没乱报错。服务端方面,确认目标进程的PID,开启GC日志,必要时重启服务让JVM带上新参数。监控方面,启动后台采样并落盘:
jstat -gcutil <PID> 1000 >> gcutil.log &这个后台进程会在压测期间每秒钟记录一次,别等到压测结束再想起来,那就什么都晚了。
5.2 执行阶段:阶梯加压而不是一把梭
正式压测我基本不会直接冲到目标并发,而是分段加压。先用100并发跑3分钟热身,让JIT编译完成、缓存热起来,观察GC基线;然后分阶段升到目标并发,每个阶段维持5分钟,记录一个起止时间戳。
压测过程中要盯的点就两个:TPS是否随并发上升而上升、RT是否同步变差。如果TPS上去了RT没有明显恶化,说明系统还有余量;如果TPS停在某个值不再涨,RT开始陡增,就赶紧看FGC和BLOCKED线程,确定瓶颈类型。一轮跑完不要急着改参数,先确认JTL文件完整、jstat文件有数据、GC日志落盘了,再跑下一轮。
5.3 收尾与归档:数据全部对齐后再下结论
压测结束不等于工作结束。我会把所有原始材料归档到一个目录,包括压测脚本、数据文件、JTL、jstat输出、GC日志,命名上加上日期和场景名。然后做时间对齐,把JVM数据和JTL合并成一张宽表,最后生成HTML报告。
这一步我坚持"先对齐、后下结论"。因为只要时间轴对不上,任何线性相关的推断都站不住脚。归档目录结构固定以后,每次压测报告的复现成本几乎为零,下个月再跑同样的场景,直接换数据文件就行。
6. 我踩过的坑和现在固定下来的回避动作
6.1 高频坑汇总
| 现场现象 | 根因 | 我现在固定的对策 |
|---|---|---|
| CSV第一个字段变成乱码 | 文件带UTF-8 BOM | 所有参数化数据用脚本生成,强制utf-8无BOM |
| 压测跑到一半线程全停了 | Stop thread on EOF设为True | 数据量估算后按两倍生成,EOF策略统一选用默认False |
| 每个线程拿到同一行数据 | Sharing mode设成All threads | 需要线程独立上下文时改成Current thread |
| 监控抓错进程 | 容器内PID和宿主机PID混淆 | 先在容器内执行ps确认PID,再启动jstat |
| Dashboard报告页面空 | JTL路径或命令行参数顺序有误 | 用-n -t -l -e -o整套参数,并在本地先试生成一次 |
| 时间对不上 | JTL和jstat时间格式不一致 | 统一转epoch毫秒,压测开始前打印一个开始标记 |
| Full GC频繁但没人发现 | 只看系统CPU不看JVM | 把FGC次数接入报告,RT尖峰第一时间查GC日志 |
6.2 现在雷打不动的三个习惯
第一,任何正式压测前,先低并发全链路验证一遍。不是说脚本能跑就行,而是要确认参数化值在真实请求里生效、断言结果正确、GC日志确实在写。第二,每轮压测前手写一张"预期值"小卡片,写上目标TPS、目标RT、预计FGC上限,压完直接对照。这个动作很小,但能逼着我在压测前真的想清楚要验证什么。第三,数据文件永远用脚本生成并留档,绝不手搓CSV,这样出了问题随时可以溯源。
最后再分享一个我坚持很久的小习惯:每次压测报告定稿之前,自己先按"目标是什么、结果是什么、差在哪、下一步改什么"四句话试着讲一遍。如果哪句话讲不清楚,就回头补充对应的曲线和数据。这三件事配合到位之后,性能测试就不再是跑个脚本、截个图、发个表的流水线,而是真正能回答系统容量问题的工程能力。