JMeter 压测 TPS 上不去?先别改代码,我踩了 7 个坑,最后发现瓶颈是压测机自己

🔑 关键词:性能测试,JMeter压测,k6对比,压测机瓶颈,协调遗漏

📖 摘要:做压测最怕的不是系统扛不住,是你根本没压到系统。这篇记录了我把 3200 TPS 折腾到 9200 TPS 的全过程:压测机自身瓶颈、端口耗尽、工具选型参数对比、协调遗漏(coordinated omission)、阶梯加压找拐点,以及全链路压测里数据倾斜这种阴间坑。有具体数字和参数,没有鸡汤。

先说个丢人的事

图片

去年双十一前两周,我们给一个订单查询接口做压测,目标是 8000 TPS。JMeter 开了 2000 线程,跑出来 3200 TPS,p99 从平时的 80ms 飙到 1.8s。开发同学连夜改缓存、加联合索引、干掉了三条慢 SQL。第二天再压,3400 TPS,动了 6%。

我一开始也以为是代码问题,盯着 Grafana 看了一晚上,突然发现一件特别尴尬的事:压测机自己的 CPU 跑到了 780%。那是个 8C16G 的容器,JMeter 进程几乎把核吃干了,而被测服务那边 CPU 才 35%,GC 日志干干净净,连接池最大才用到 12 个。也就是说我们花了两个晚上,在优化一个根本没被压到的系统。

后来把压测机换成 16C32G 的物理机,同一份 jmx、同样的 2000 线程、同样的数据,直接跑到 9200 TPS,p99 210ms。代码一行没改。

从那以后我改了流程:任何一次压测,第一个要看的指标不是被测服务的,是压测机自己的 CPU、内存、网络带宽和端口占用。顺序错了,后面全是白干。

坑一:压测机本身就是个 Java 应用

JMeter 是纯 Java 写的,默认堆内存 1GB,脚本里的 HEAP 变量很多人从来不改。一个线程大概占 1MB 栈空间加上下文开销,2000 线程光线程栈就要 2GB 左右,再加上采样结果、响应体、监听器缓存,1GB 堆必崩。我一般直接改成 -Xms4g -Xmx4g -XX:+UseG1GC,8C 机器开 3000 线程比较稳,再往上就要考虑分布式了。

图片

还有个新手必踩的:千万、千万不要用 GUI 模式压测。View Results Tree 这个监听器会把每一个请求和响应体都塞进内存,压 5 分钟就能 OOM。正确姿势是命令行:jmeter -n -t order.jmx -l result.jtl -e -o ./report,跑完再看 HTML 报告。GUI 只用来调脚本,调完立刻关掉。

另外 JMeter 从 5.0 开始支持 Backend Listener 直接写 InfluxDB,配 Grafana 看实时曲线,比跑完再看 jtl 强一百倍。压测过程中发现不对可以立刻停,不用等十分钟。

坑二:端口耗尽,这个特别容易被忽略

短连接压测的时候,Linux 默认 net.ipv4.ip_local_port_range 是 32768-60999,大概 28000 个可用端口。TIME_WAIT 默认 60 秒。你要压到 8000 TPS,60 秒内就要建立 48 万个连接,端口池根本转不过来。表现就是压着压着开始报 Cannot assign requested address,或者 TPS 突然掉一半。

三个解法,按性价比排序。第一,用长连接,JMeter 的 HTTP Request 里勾上 Use KeepAlive,改 jmeter.properties 里的 httpclient4.time_to_live=60000,让它复用连接。第二,net.ipv4.tcp_tw_reuse=1 加上扩大端口范围到 1024-65535。第三,实在不行就上分布式,多台机器分摊。

我见过最离谱的一次,有人把所有连接池的 maxActive 设成 1,说是为了模拟真实用户。真实用户是会用浏览器连接复用的,不这么干。

图片

工具选型:别拿 JMeter 去干 wrk 的活

我整理了一张自己常用的对比表,参数都是实测或者官方文档里来的,不是拍脑袋:

工具 语言 单机能力(参考) 适合场景 主要痛点
JMeter Java 2000-3000 线程 / 约 5000-8000 TPS 复杂业务链路、JDBC/FTP/JMS/gRPC 吃资源,报告难用,无协调遗漏保护
k6 Go 单机 3 万 VU 量级 HTTP/WebSocket、CI 里跑冒烟压测 复杂协议要写 Go 扩展
Locust Python 单机 5000-10000 并发 脚本逻辑复杂、要写业务判断 GIL 卡脖子,必须多 worker
wrk2 C+LuaJIT 单机几十万 RPS 纯 HTTP 接口找性能上限 只能压 HTTP,Lua 写起来难受
Gatling Scala 比 JMeter 同配置高一截 Java 团队、要求好看报告 Scala 学习曲线
vegeta Go 中等 命令行快速验证 功能太素

结论很简单:只是想知道一个 HTTP 接口能扛多少,用 wrk2 或者 k6,五分钟出结果。要跑完整链路(登录→加购→下单→支付),用 JMeter 或者 Locust。如果团队本来就是 Java 技术栈,Gatling 是真的香,基于 Netty 不是线程一对一模型,同样机器能压出更高的量。

我现在的习惯是两层:k6 写在 CI 里,每次发版跑一遍基线(固定 500 并发 3 分钟),偏差超过 15% 就拦下来;正式压测用 JMeter 跑完整链路。两个工具不冲突。

坑三:你的 p99 可能是假的(协调遗漏)

图片

这个东西是 Gil Tene 2015 年在 How NOT to Measure Latency 那场演讲里讲的,名字叫 coordinated omission,中文一般叫协调遗漏。

传统压测工具是闭环的:一个线程发完请求,等响应回来,再发下一个。如果服务卡了整整 1 秒,这个线程这 1 秒里只发出了 1 个请求。但在真实世界里,用户不会因为你慢了就不来,那 1 秒里本该有 100 个请求,它们只是排在门外,或者直接超时走了。结果就是:延迟数据被系统性地美化了,你看到的 p99 是 200ms,实际用户体验到的是 1.2 秒。

wrk2 的 --rate 参数就是解这个的:不管服务返回快慢,每秒就是固定发 5000 个请求,发不出去就排队,排队时间算进延迟。k6 里的 constant-arrival-rate executor、JMeter 5.0 之后的 Arrivals Thread Group,都是同一个思路。JMeter 默认那个 Thread Group 是闭环的,很多人压了三年都不知道。

同一个接口我实测过两种模式:JMeter 闭环 2000 线程跑出来 p50 12ms、p99 180ms、平均 35ms;wrk2 固定 4000 req/s 跑出来 p50 15ms、p99 1.2s、平均 90ms。p99 差了将近 7 倍。你说哪个更接近用户真实感受?我觉得是后者。

找拐点,不是找最高 TPS

这是我个人最想强调的一个观点:TPS 这个数字本身没有上限,你不设限,工具有多少线程就发多少线程,最后要么把服务打死,要么把压测机打死。你拿到一个 20000 TPS 的数字,然后呢?没有任何意义。

图片

真正有价值的是拐点——响应时间开始非线性上升的那个点。做法是阶梯加压:500 → 1000 → 2000 → 4000 → 8000 → 12000 TPS,每档跑 5 分钟(3 分钟爬坡 + 2 分钟稳态),把每档的 p99 记下来画曲线。一开始是平的,某个点之后突然翘起来,那个位置就是你要找的拐点。JMeter 用 Stepping Thread Group 插件,k6 用 ramping-arrival-rate,都能做。

找到拐点之后我一般乘 0.6 到 0.7 作为线上容量水位。比如拐点是 8000 TPS,那告警阈值设 5000,扩容阈值设 5500,留出缓冲。

还有两个细节容易被忽略。第一,第一次压测的前 1 到 2 分钟数据要直接扔掉,因为 JVM 的 C1 编译阈值是 1500 次方法调用、C2 是 10000 次,JIT 预热需要一两万次请求才能稳定,HikariCP 默认 maximumPoolSize=10、minimumIdle=10,连接的建立也在这两分钟内完成。第二,稳态时间不能太短,有的系统内存泄漏或者连接泄漏要跑十分钟才暴露,跑 60 秒什么都看不出来。

全链路压测里最阴的一个坑:数据倾斜

做全链路压测,影子表、流量染色(HTTP header 加个 x-shadow: true,网关识别后路由到影子库)这些流程网上一搜一大把,我就不重复了。我想说的是数据脱敏带来的数据倾斜——这个坑吃了我整整一个下午。

生产数据要脱敏,我们把手机号统一脱敏成了 13800000000 这种格式。听起来没问题对吧?问题是分库分表是按手机号哈希路由的,脱敏之后所有数据的哈希值全一样,整个压测流量全部打到 8 个分片里的第 1 个。压测时那个分片 CPU 100%,另外 7 个分片闲得发慌,我还以为是 SQL 写的有问题,查了两个小时。

图片

正确做法是:脱敏的时候保持数据的分布特征。手机号可以保留后 4 位随机,或者用一个固定的盐做不可逆哈希,总之脱敏之后哈希路由的结果必须还是均匀的。同理,订单号、user_id 这些参与路由的字段都要小心,别脱着脱着把数据变成一条线。

监控方面,服务端看 RED(Rate、Errors、Duration),资源看 USE(Utilization、Saturation、Errors),链路用 trace。还有一个判断技巧:如果 p99 的尖峰是规律的、间隔几秒或者几十秒出现一次,八成是 GC 停顿而不是代码问题,加个 -Xlog:gc* 把 GC 日志打出来,跟尖峰时间对一下就知道。要定位到具体哪行代码,用 async-profiler 或者 arthas 的 profiler start 抓火焰图。

最后说点真心话

性能测试这件事最反直觉的地方在于:它测的其实不是系统,是你的模型。你以为你在测这个接口能扛多少,实际上你测的是我这套压测脚本 + 这个环境 + 这批数据能扛多少。换一套数据分布、换一种流量模式、换个网络延迟,结论可能完全反过来。

所以别迷信任何一个 TPS 数字,包括我给你列的这些。要迷信的是另一件事:你清楚地知道瓶颈在哪,并且知道干掉它要花多少钱。这才叫性能测试,不然只是跑了个数。

我到现在还留着那张 3200 TPS 的截图,用来提醒自己别着急改代码。

🏷️ 标签: