1. 站在性能测试的深水区:为什么你总是“压测一时爽,调优火葬场”
做性能测试最怕的不是压测本身跑不起来,而是压力一上来,系统就像个开始漏水的桶——TPS一会儿高一会儿低,内存像坐了滑梯一样往下掉,然后某一次请求直接甩给你一个OutOfMemoryError。你翻日志、查监控、问开发,忙活大半天还是一头雾水。
这几年我大大小小做了几十个项目的压测,从传统单体应用到分布式微服务,从物理机部署到容器化K8s,踩过的内存溢出的坑、分析过的TPS曲线,比看过的技术文档还多。说实话,性能测试这个活儿,很多人以为就是拿Jmeter把线程数调大,看个聚合报告,然后写个“平均响应时间多少、TPS多少、错误率多少”就完事了。但真正的问题恰恰藏在你没看的地方:TPS为什么掉下去了?内存为什么一直涨?GC为什么频繁得像呼吸一样?这些才是压测的本质。
这篇内容我打算一次性把内存溢出问题定位和TPS分析这两件大事讲透。适用的人群很广:刚入行想系统学性能测试的测试工程师、写代码但被线上OOM折磨的后端开发、以及那些被领导要求“压一下看看性能”但完全不知道从哪下手的运维同学。本文会结合我实际用Jmeter、LoadRunner做压测的经验,手把手拆解从发现OOM到定位根因,再到通过TPS曲线反推系统瓶颈的完整思路。看完你至少能明确一件事:下次再遇到内存溢出或者TPS异常,你该按什么顺序去看、去查、去解决,而不是只会重启服务。
2. 先搞清楚内存溢出的常见类型,再谈定位
2.1 堆内存溢出(Java Heap Space)——90%的OOM都是它
在讲定位技巧之前,我们得先把内存溢出这头大象分清楚。服务端应用(尤其是Java技术栈)最常见的就是堆内存溢出,报错大概是这样的:java.lang.OutOfMemoryError: Java heap space。遇到这个错误,意味着你JVM的堆内存已经被对象占满了,新对象分配不出空间了。
堆内存溢出的根因无非两类:
- 对象真的太多,超出了堆的容量。比如压测时并发量太大,每个请求都产生大量对象,堆默认大小扛不住。
- 对象本该被回收但一直被引用,也就是内存泄漏。比如静态集合类不断放入数据、IO流未关闭、ThreadLocal使用不当等,GC回收不了,可用内存一点点被蚕食。
我的建议是,遇到Java heap space先不要慌,也别急着把-Xmx调大。调大堆内存只能暂时缓解症状,如果根因是泄漏,跑一段时间照样爆。正确姿势是,先看GC日志和堆转储文件,分析对象分布,确认是“对象真的多”还是“对象被不该持有的地方持有”。
2.2 元空间溢出(Metaspace)与直接内存溢出(Direct Buffer Memory)
除了堆内存溢出,还有两类间接相关的OOM容易被忽视。
元空间(Metaspace)存放的是类的元数据。如果压测中频繁动态生成类(比如CGLIB代理、反射生成类、热部署加载类),元空间就会持续增长,最终报java.lang.OutOfMemoryError: Metaspace。这属于“永久代/元空间”层面的问题,排查思路是看加载了哪些类、由哪个ClassLoader加载的。
直接内存溢出通常表现为java.lang.OutOfMemoryError: Direct buffer memory。使用NIO或者Netty这类框架时,堆外内存申请过多也会OOM。这类问题比较隐蔽,因为堆内存看起来很正常,但是堆外内存占用飙升,最终进程被系统杀掉。压测时发现程序莫名其妙crash,先看看是不是堆外内存的锅。
2.3 Jmeter性能测试步骤中如何触发与监控OOM
顺便说一个测试侧的现象:如果你用Jmeter压测,自己电脑上先OOM了,多半不是被测系统的问题,而是Jmeter本身内存不够。很多新手压测时会把Jmeter的堆调得很小,结果并发一高,Jmeter自己先挂了,然后得出“被测系统性能差”的错误结论。
正确的Jmeter压测姿势是:
- 修改
jmeter.bat或jmeter.sh里的HEAP参数,一般建议设到4G以上,压测机内存够大的话甚至可以给8G。 - 使用
-n命令行模式跑压测,不要开着GUI压,GUI模式本身就要吃掉大量内存和CPU。 - 监控Jmeter自身的GC情况:压测过程中如果Jmeter的
Available Memory持续下降,或者日志里频繁出现GC,那说明压测机已经是瓶颈了,需要改用分布式压测。
也就是说,做性能测试的第一步,是保证压测工具本身不拖后腿,否则你测出来的数据全是“假数据”。这个细节往往被很多人忽略。
3. TPS分析:曲线背后藏着的秘密
3.1 TPS到底是怎么算出来的
TPS,全称是Transactions Per Second,每秒事务数。对HTTP接口来说,一个事务通常就是一个完整请求(从发起到响应完成)。在Jmeter里,TPS可以在聚合报告或者通过ServerAgent插件直接查看;在LoadRunner里,则是通过Controller的分析图看“Transaction Response Time”和“Hits per Second”等指标。
但很多人对TPS的理解过于表面。它不是一个孤立的数字,而是和并发用户数、响应时间、错误率连在一起的。它们之间的关系可以用一句话概括:TPS是并发数除以平均响应时间。比如说,100个并发用户,平均响应时间0.5秒,那理论TPS就是200。这只是一个粗略估算,真实场景中由于锁竞争、线程上下文切换、网络开销等原因,实际TPS会明显低于这个理论值。
压测过程中看TPS,不能只看“最高值”,更要看TPS曲线的形态。TPS曲线大致有几类:
- 平稳型:随着并发增加,TPS线性上升,然后趋于稳定,这是健康的曲线。
- 先升后降型:TPS在某个并发点达到峰值,之后再增加并发反而下降,说明系统已经有瓶颈了。
- 剧烈波动型:TPS高一下低一下,像锯齿一样,往往是线程阻塞、GC暂停、或者后端依赖不稳定导致的。
3.2 通过TPS曲线反推内存问题
这里想重点强调一个经验:TPS曲线的变化,往往能提前告诉你内存要出事了。
举个例子,压测一个Web应用,200线程跑10分钟,前5分钟TPS稳定在500左右。但从第6分钟开始,TPS开始断崖式下跌,降到200、100,同时响应时间飙涨。这个时候如果你去查JVM的GC日志,大概率能发现GC越来越频繁,每次Full GC的耗时也从几百毫秒涨到了几秒。为什么会这样?因为内存中的垃圾对象累积越来越多,GC线程要花费大量时间回收,导致业务线程被STW(Stop The World)阻塞,请求自然处理不过来了。
这是一个典型的“TPS下降—GC频繁—对象堆积—最终OOM”的链条。所以我的习惯是:压测过程中一旦发现TPS掉头向下,第一反应不是去看代码逻辑,而是立刻拉三组数据:
- 当前JVM堆内存使用率曲线
- GC频率和GC耗时曲线
- 线程池活跃线程数和阻塞线程数
如果这三组数据里有两个以上异常,那基本可以确定问题出在JVM内存管理层面,而不是业务逻辑层面。反之,如果堆内存和GC都正常,TPS却下降,那可能是数据库连接池满了、线程池队列积压、或者是下游接口变慢导致的。
3.3 性能测试指标整体对照:响应时间、并发数与TPS的综合判断
再补充一下性能测试中几大核心指标的联动分析方法。团队里经常有人拿了压测报告问我:“TPS是800,响应时间平均300ms,这个性能算好吗?”我没法直接回答,因为判断性能好坏永远不是看单个指标,而是看它们在不同压力阶梯下的变化趋势。
一个相对完整的判断方法是:把压测分为“性能测试”“负载测试”“压力测试”三个阶段。性能测试阶段,验收系统能否在预期并发下满足SLA(比如“300并发下TPS不低于1000,平均RT不高于500ms”);负载测试阶段,只增加并发数,观察系统何时达到TPS峰值以及曲线如何变化;压力测试阶段,在超过峰值的并发下继续压,看系统降级行为、错误率、以及是否会崩溃。
各指标联动关系我一般用这个表格辅助判断:
| 现象组合 | 可能瓶颈 | 排查方向 |
|---|---|---|
| TPS高、RT低、并发持续增加 | 系统仍有富余能力 | 继续加压,找拐点 |
| TPS低、RT高、CPU高 | 计算密集型瓶颈,代码或算法问题 | 做CPU火焰图,定位热点 |
| TPS低、RT高、CPU低 | 外部依赖阻塞或锁等待 | 查数据库慢SQL、Redis超时、锁竞争 |
| TPS下降、堆内存上涨、GC频繁 | 存在内存泄漏或对象堆积 | 做堆转储,分析大对象 |
| TPS波动剧烈、RT偶尔飙升 | 容器或宿主机资源争抢、线程池队列满 | 查容器监控、线程池配置 |
这张表我建议收藏,排查时直接对照着来,能省掉很多盲目试错的时间。
4. 性能测试实操:从环境准备到完整压测的步骤拆解
4.1 LoadRunner和Jmeter的选型分析
既然标题涉及了LoadRunner和Jmeter两种主流工具,我先聊聊选型。这两者我都深度使用过,各有优劣,不存在谁完全取代谁。
LoadRunner是老牌商业工具,功能全、报表丰富、协议支持广(比如支持SAP、Citrix等老协议),适合大型企业合规化性能测试。缺点是贵,而且本身的学习曲线陡峭,安装配置也比较繁琐。如果你身在重视“正规军”的团队,或者被测系统用的是老旧的客户端/服务器协议,LoadRunner依然是合理选项。
Jmeter则完全开源免费,社区活跃,插件生态丰富(如jpgc性能监控插件集),做HTTP/HTTPS接口压测非常顺手。对绝大多数互联网公司来说,Jmeter是最具性价比的选择。我需要特别提一句:Jmeter的聚合报告虽然常用,但看TPS趋势还是建议配合后端监听器将结果写入InfluxDB,然后用Grafana展示曲线,这样你才能看到完整的趋势变化,而不是只看一个平均值。
4.2 Jmeter性能测试步骤:脚本编写、参数化与场景设计
用Jmeter做压测,我有一套固定的操作流程,这里完整分享出来。
第一步,设计测试计划。先明确要压哪个接口、压多少并发、跑多长时间、验证什么指标。比如“对登录接口做300并发、持续15分钟的稳定性测试,期望TPS不低于500,RT低于300ms,错误率为0%”。没有明确目标的压测,做完了也是白做。
第二步,编写脚本。用HTTP请求取样器,配置好协议、域名、端口、路径和请求体。这里提醒一句:如果需要登录态,要添加HTTP Cookie管理器或者用HTTP头管理器加Token,不然压测时大量请求因为鉴权失败而返回401,TPS全是假的。
第三步,参数化。千万别让所有线程都发一模一样的请求,这不符合真实场景。用CSV数据文件设置把用户数据做成参数化,比如不同的用户名、商品ID等,更贴近真实负载,还能避免服务端命中缓存导致测试失真。
第四步,添加监听器。至少加三个:查看结果树(调试时用,正式压测要关掉,否则IO开销极大)、聚合报告(看整体TPS和RT)、后端监听器(写入InfluxDB,配合Grafana看实时曲线)。
第五步,设定压测场景。用线程组的调度器,设定持续时间。我个人比较推荐“阶梯加压”:先用Jmeter插件Ultimate Thread Group,设置每隔2分钟增加50个并发,直到达到目标并发,然后维持10分钟。这样能观察到系统在压力缓慢增长过程中的性能拐点,而不是一次性灌进去200个并发直接把系统打死。
4.3 LoadRunner性能测试步骤:从脚本录制到场景执行的关键点
再简单说下LoadRunner的操作路径。用Vuser Generator录制脚本或者写脚本,用Controller设计场景(通常是手动场景,设置虚拟用户数、加载策略和持续时间),最后用Analysis分析结果。
LoadRunner里最常用也最实用的是Trees视图和Graphs视图。Transaction Response Time图和Running Vusers图叠加看,如果Vuser还在涨,但RT已经明显变慢,说明饱和度快到了;如果两者都下跌,那系统可能已经过载了。
我做LoadRunner压测的习惯是,除了内置的分析器,一定会把原始数据导出来,用Excel再画一遍趋势图。因为LoadRunner默认的分析图有时太“平滑”,把毛刺都抹掉了,反而看不到那些瞬间的抖动。
4.4 性能测试环境核对清单:压测前必须确认的3件事
在正式开始压测之前,还有一份环境核对清单,写下来给所有做性能测试的同学:
- 被测环境是否和生产环境等同?如果测试环境是缩减版配置,压测结果只能作为相对参考,不能直接拍板生产容量。
- 压测数据是否充分?空库和全量数据的查询性能天差地别,索引是否和生产一致,这直接决定了接口性能。
- 监控体系是否就绪?服务端的基础监控(CPU、内存、磁盘IO、网络)、JVM监控(堆内存、GC、线程数)、中间件监控(Tomcat线程数、连接池使用率)都要在压测启动前就绪。缺失任何一个维度,问题定位都会困难重重。
这三点看似基础,但据我观察,超过一半的团队都会在其中一个环节上翻车。
5. 内存溢出定位实战:从jmap、jstat到MAT的完整流程
5.1 定位OOM的三板斧:日志、jstat、堆转储
假设压测过程中被测系统报了OOM,接下来你该做什么?我的顺序是这样的。
第一板斧,看日志。检查应用日志和-XX:+HeapDumpOnOutOfMemoryError参数配置,这个参数一定要在压测前就加上,否则OOM发生时不会有堆转储文件落下,你就少了一条最重要的线索。同时在启动参数里加上-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log,把GC日志单独输出。
第二板斧,用jstat实时观察JVM。jstat -gcutil <pid> 1000每秒输出一次,观察Eden区、Old区、Full GC次数和耗时。如果发现Old区持续增长且Full GC不断,说明老年代对象只增不减,这是内存泄漏的典型信号。
第三板斧,也是最重要的一步——生成堆转储文件,然后用MAT或者JProfiler分析。堆转储生成有两种方式:一是利用OOM参数自动生成,二是用jmap -dump:format=b,file=heap.hprof <pid>手动生成。如果系统还没OOM但内存居高不下,我倾向于手动Dump一份快照来做离线分析。
使用MAT分析时,核心是看三块内容:
Leak Suspects Report:MAT自动识别最可能的泄漏点,直接告诉我从哪个对象入手。Dominator Tree:查看大对象和持有引用链,定位到具体是哪段业务代码创建了这些对象。Histogram:按类统计对象实例数,通常能看到各个类的对象数量。对比多次Dump的结果,如果某个业务类的实例数随时间只增不减,那基本锁定了泄漏源。
5.2 动态监控利器:Arthas在OOM定位中的实战用法
堆转储文件的分析是事后行为,但很多时候我们希望在压测“现场”就能看到问题。这里我要重点推荐阿里开源的Arthas,用好了它,OOM定位效率能提升一个量级。
Arthas的dashboard命令可以实时展示线程、内存、GC状态,类似jstat的增强版,不用登录服务器输命令就能看到全貌。
至于thread命令,查线程状态和阻塞情况很好用。thread -n 3直接列出CPU占用最高的前3个线程,并给出他们正在执行的栈。如果压测时CPU飙高,用这个命令一瞬间就能看到热点线程。
最有意思的是tt命令,可以记录方法调用时的入参、返回值、异常等。比如怀疑某个接口创建了大对象,用tt跟踪一下,就能看到每次调用的详细情况。
Arthas还支持ognl表达式直接执行代码,可以现场查看某个静态Map里装了多少对象、多大的对象。这种“钻进JVM里看”的能力,对定位某些模棱两可的内存问题帮助非常大。
5.3 东方通内存溢出文件分析的特殊套路
如果你使用的中间件是东方通TongWeb,那OOM分析会有些特殊之处。东方通在OOM时通常会在logs目录下生成heapdump.hprof等文件,但要注意它的JVM参数配置路径和Oracle/OpenJDK默认的不太一样,需要去bin目录下的setenv或类似脚本中查看具体的JVM参数设置。
东方通环境下做OOM定位,我建议重点检查两个方向:
- 部署在TongWeb上的应用是否因为反复热部署导致类加载器泄漏。这种情况在老项目的开发/测试环境尤其常见,每次重部署都产生一批新类,老类一直被某个全局变量引用着,最终Metaspace爆掉。
- 连接池和Session管理。TongWeb的Session存储如果配置不当,高并发下会不断创建Session对象,长期不清理,堆内存被Session占满。
分析东方通的堆转储文件时,思路和应用服务器是一样的——用MAT打开,看Dominator Tree中是否有大量com.tongweb.*包下的对象。如果有,说明是中间件层面的问题;如果没有,再往业务类的引用链上去追。
5.4 POI增量写入Excel导致堆内存溢出的案例复盘
很多业务系统都有导出Excel的功能,压测时导出接口是最容易OOM的重灾区。这里用一个我实际解决过的案例来说明定位思路。
某系统在做接口压测的时候,并发导出Excel的功能一跑就OOM。报错是堆内存溢出,而且只在导出接口上出现。第一反应是导出数据量太大,Workbook对象把内存撑爆了。但开发说他们用的是POI的SXSSFWorkbook(流式写入),按理说不会一次性把所有行都放内存里。
用jmap抓了堆转储,用MAT分析后发现,内存中有大量org.apache.poi.xssf.streaming.SXSSFWorkbook$SheetDataWriter对象,每个对象又引用了大量字符串数组。再深入一看,问题出在代码里:虽然用了SXSSFWorkbook,但开发在循环里手动维护了一个Map缓存了所有行的Cell数据,用于后续的业务计算。这个Map的key和value都是String,几百个线程同时导出,每个线程几万行数据,Map里的字符串对象就把堆吃完了。
这个案例的教训是:内存溢出的表象在POI,根因却在业务代码的缓存逻辑。只分析技术框架是远远不够的,要从引用链往业务代码上追,找到真正持有大量对象的那个类。
5.5 Impala内存溢出的原因分析与应对思路
大数据组件里,Impala的内存溢出是出了名的难排查。Impala使用C++编写,它的内存管理和Java完全不同,通常不会出现Java那种OutOfMemoryError报错,更多是查询失败返回Memory limit exceeded错误。
Impala内存溢出的常见原因:
- 查询的中间结果集过大。比如多表Join时没有合理的过滤条件下推,整个大表的数据都进内存了。
- 分区策略不合理。分区数过多导致每个查询需要打开的扫描线程过多,内存被元数据和句柄吃光。
- 并发查询数量超过可用内存。
应对思路一般是先检查impalad的内存配置,在--mem_limit参数里调整单查询内存上限;再从SQL优化入手,避免全表扫描和大表Join;最后是规划好内存队列,控制同时运行的查询数。这类问题的定位需要结合Impala Web UI和/proc/<pid>/status等系统层面的内存数据去看,因为你没法用Java那套工具链来分析C++进程。
6. 常见问题排查与效率提升技巧
6.1 内存溢出排查的常见误区与避坑指南
做了这么多年的性能测试和OOM排查,我总结出几个常见误区,每个都是真金白银换来的教训。
误区一:一看到OOM就怀疑堆内存设置太小。这是最典型的“头痛医头”思维。堆内存占用高不等于堆内存设置小,可能只是你的代码写得不合理。盲目调大堆内存,只是延迟了OOM爆发时间,治标不治本。
误区二:只看free -m判断内存是否充足。Linux的free命令显示的内存使用情况是包含Page Cache的,它不能直接反映JVM堆内的对象占用情况。判断JVM内存还是要看JVM自己的监控数据。
误区三:压缩压测时间,用“短平快”的方式跑3分钟就算完事。内存泄漏类问题,往往是长时间运行后才暴露的。我见过很多稳定性测试跑8个小时都不爆,但跑24小时就OOM的案例。如果测试目标是验证稳定性,建议至少跑12小时以上,或者通过加大压力让问题提前暴露。
误区四:压测数据过多依赖平均值。TPS和RT的“平均值”往往掩盖了长尾问题。比如平均RT是200ms,但P99可能是3秒。用户体感上的问题,基本都来自尾延迟,分析时必须同时关注P90、P95、P99指标。
6.2 TPS分析中容易遇到的假象与困惑
TPS曲线分析里也有几个常见的坑,这里一并说清楚。
首先,TPS高并不代表系统健康。有时候TPS很高,但错误率也高得离谱——实际上是服务端快速拒绝了大多数请求,返回了错误码。这种“虚假TPS”会严重误导判断。所以分析TPS时一定要搭配错误率看,两者结合才有意义。
其次,压测结果和网络环境强相关。本机压测和跨机房压测的TPS差异可能达到倍数级。如果压测过程中网络出现抖动,TPS曲线会突然掉头。不要一看到曲线异常就去查应用,先确认压测机和被压测机之间的基础网络是否稳定。
第三,Jmeter的“线程数”不等于“并发数”。设置300个线程,不代表系统同时有300个并发请求在跑。如果线程只是处在等待响应的状态,实际并发请求数可能远低于线程数。要评估真正的并发压力,得看服务端的活跃连接数。
6.3 常用排查工具速查表
操作系统层面的内存和CPU排查,我一般用top、vmstat、dstat;JVM层面的排查用jps、jinfo、jstat、jmap、jstack、jcmd;线程和堆分析用Arthas、MAT、VisualVM;压测分析工具用Grafana + InfluxDB来可视化Jmeter监控数据。如果是容器环境,还要看kubectl top和具体Pod的cgroup内存限制,容器内存溢出(Pod OOMKilled)和JVM堆OOM的表现不一样,前者是进程被杀,后者有Java报错栈,不要混淆。
对于接口调用链的问题,建议用Arthas的trace命令跟踪一次完整调用的耗时分布,看看是Controller层耗时多还是DAO层耗时多。如果DAO层耗时多,再用showSQL执行计划来排查慢SQL。
6.4 压测数据记录规范
最后分享一个我个人坚持的记录规范:每次压测都要记录完整的环境信息和参数配置。包括压测工具版本、Jmeter线程组配置、被测应用的JVM启动参数、中间件版本、数据库连接池大小、压测时间等。别小看这个动作,很多问题在当天查不出来,过了一周再回头分析数据时,如果当时没有留下完整的参数记录,就只能两眼一抹黑重新压一遍。规范化的记录不仅是专业性的体现,更是高效排查的基石。
7. 最后再分享一点实操倾向
回头看看这些年做性能测试踩过的坑,最大的体会就是:性能测试不是“测完给报告”就结束了,它是一个对系统深层次认知的过程。内存溢出和TPS分析这两件事,其实是一体两面——TPS掉下去,往往是内存或资源出了问题;内存持续上涨,又一定会体现在TPS曲线上。把两者联动起来分析,才能做到真正的根因定位。
再分享一个小技巧,这是我实际工作中觉得最划算的一个习惯:每次压测前,先打开GC日志和系统监控,然后录一段“初值快照”,包括当前堆内存使用量、Full GC次数、线程数。压测结束后再打一份“终值快照”,两份对比一下,很多问题的答案就已经摆在面前了。不要一上来就翻代码翻日志,先看数据走向,再顺藤摸瓜。这套方法论,无论是用Jmeter还是LoadRunner,无论是测Java服务还是测大数据组件,都适用。