JMeter 压测 TPS 上不去?同一接口我用 k6、Gatling 重跑了一遍,差出 50%

🔑 关键词:性能测试,JMeter 压测,TPS 上不去,k6 压测,压测机瓶颈

📖 摘要:同一个商品详情接口,用 JMeter 5.6.3、k6 v0.49、Gatling 3.10 在同一台 4C8G 压测机上各跑 200 并发 300 秒,稳态 TPS 分别是 6100、8400、9200。本文记录这组数据背后的真实原因:线程模型差异、压测机自身的内核参数、P99 统计口径的三套算法,以及那次让我们上线翻车的『缓存全命中』假数据。

一、先说那个凌晨两点的事

图片

去年 11 月,商品详情接口的压测报告上写着:P99 78ms,TPS 12000,验收通过。上线第三天晚上 9 点,接口 P99 直接飙到 1.4 秒,监控图上是一根竖线,差点触发熔断。我和两个同事从十点排查到凌晨两点,最后结论很蠢:压测时用的数据是假的。

脚本里的 URL 是 /api/item/10086,压了 30 万次,全是同一个商品 ID。Redis 里这个 key 热得发烫,缓存命中率 100%。而真实流量里长尾商品占了 68%,全是缓存穿透直接打 MySQL。后来我把 ID 池改成 5 万个,按线上热度分布加权(头部 20% 的商品吃掉 70% 流量),同一套服务重新压,TPS 从 12000 掉到 3400,P99 从 78ms 变成 610ms。

这才是应该出现在报告里的数字。所以我现在对性能测试的理解很朴素:工具是次要的,你压的到底是不是线上那个东西,才是主要的。下面几段全是踩过的坑,不保证通用,但都是我自己跑出来的。

二、同一接口,三种工具,一组数据

环境先说清楚,不然数字没意义。压测机 4C8G,同可用区,千兆网卡;被测服务 8C16G,接口走 Redis 读,返回 1.2KB JSON,不落库。统一 200 并发,预热 60 秒不计入,稳态跑 300 秒。

图片

指标 JMeter 5.6.3 k6 v0.49 Gatling 3.10
并发模型 1 请求占 1 线程 goroutine Akka Actor
稳态 TPS 6100 8400 9200
平均响应 31ms 23ms 21ms
P99(各自算法) 96ms 71ms 68ms
压测机 CPU 峰值 92% 55% 61%
压测机内存占用 3.4G 210M 780M
脚本语言 Groovy / BeanShell JS Scala / Java DSL

JMeter 那 6100 TPS 我还抢救过一次。默认配置下它会把每个响应写成 .jtl 记录,SampleResult 对象创建加写盘本身就吃掉一截。把结果文件改到 /dev/shm(内存盘),同时在 user.properties 里关掉 jmeter.save.saveservice.autoflush,TPS 从 6100 涨到 7400。启动参数用的是 -Xms4g -Xmx4g -XX:+UseG1GC,Java 17 下还得补一串 --add-opens java.base/java.lang=ALL-UNNAMED,否则 BeanShell 会报警告。

k6 的模型和 JMeter 完全不是一回事。它一个 VU 就是一个 goroutine,跑的是 while(true) { http.get(...); sleep(0); } 这种循环,不会像 JMeter 那样开 200 个线程互相抢锁。所以同样 200 并发,k6 的压测机 CPU 只有 55%,剩下的余量足够我往上加到 500 VU 再压一轮。Gatling 用 Akka 做事件驱动,性能比 k6 还好一点,但代价是 Scala DSL 的门槛——团队里没人愿意维护那份脚本,最后就是我们三个人里只有我看得懂。

选型别纠结性能参数,先看团队谁来维护。这点我是吃了亏才认的。

图片

三、压不上去之前,先看压测机自己

很多人第一反应是被测服务扛不住,其实一半以上的『TPS 上不去』是压测机自己先趴了。我踩过的几个点,按发现频率从高到低排:

1. 文件描述符。 ulimit -n 默认 1024,200 并发加长连接池,几千个 socket 直接把额度顶穿,报 Too many open files。改法:/etc/security/limits.conf 里写 * soft nofile 65535、* hard nofile 65535,再 sysctl -w fs.file-max=1000000。改完要重新登录才生效,我第一次改完没退 session,白折腾半小时。

2. 端口耗尽。 net.ipv4.ip_local_port_range 默认是 32768 60999,可用端口两万八。如果连接不复用(比如每个请求都新建 TCP),60 秒就能耗光,现象是客户端出现大量 Cannot assign requested address。改成 1024 65535,同时 net.ipv4.tcp_tw_reuse=1(这个参数只对客户端出方向有效,服务端设了没用)。

3. 网卡带宽。 这条最容易被忽略。1.2KB 的响应体,8400 TPS 大约是 10MB/s,千兆网卡毫无压力。但我另一个项目响应体是 96KB 的商品图列表,同样的 TPS 就是 800MB/s,直接把网卡打满,sar -n DEV 1 显示 %ifutil 冲到 100,而服务端 CPU 才 30%。那次我查了三个小时服务端代码,最后发现是压测机网卡的事。

图片

4. 看指标的方式。 压测过程中我会同时开三个窗口:sar -n DEV 1 看网卡,ss -s 看 socket 总数和 TIME_WAIT 数量,vmstat 1 看 r 队列。r 队列持续大于核数,说明压测机 CPU 已经在排队了,这时候测出来的延迟里有一半是压测机自己贡献的。

四、P99 的统计口径,能差出 15%

这个事挺多人不知道。同一批原始数据,JMeter、k6、Gatling 算出来的 P99 可能不一样,因为它们用的算法不同。JMeter 底下是 Apache Commons Math 的 Percentile,默认 LEGACY 估计法,做的是位置插值;k6 用的是 nearest-rank,取的是最近的实样本;Gatling 也有自己的一套。样本量大到 300 万的时候差异可以忽略,但样本只有 1000 个的时候,同一个 P99 能差 15% 上下。所以跨工具横向比数据之前,先把口径对齐,不然就是自欺欺人。

比口径更容易骗人的是平均值。我见过一份报告写着平均响应 80ms,看起来挺健康,实际拆开是 60% 的请求 20ms、40% 的请求 170ms——典型双峰,40% 那批全卡在 HikariCP 连接池排队上(当时 maximumPoolSize 设了 20)。平均值把这事儿糊住了。

图片

现在的习惯是每次压测至少导出这几个数:avg、p50、p90、p95、p99、p99.9、max。k6 里一行就够:

export const options = {
  vus: 200,
  duration: '300s',
  summaryTrendStats: ['avg', 'p(50)', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)', 'max'],
}

p50 和 avg 差得越多,说明分布越歪,这时候再拿平均值汇报就是耍流氓。

五、我现在把性能预算写进流水线

最后一件事。压测报告最大的问题不是数据不准,是没人看——发出去群里一个『收到』,然后存档,三个月后翻出来发现早就不适用了。

图片

所以从去年年底开始,我们把核心接口的性能预算直接写进 CI,用 k6 跑,阈值不达标流水线就红:

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<200', 'p(99)<500'],
    http_req_failed: ['rate<0.01'],
  },
}

每次发版前跑 5 分钟,数据量和线上比只是一小部分,但能挡住绝大多数『顺手改了个 SQL 导致慢 3 倍』这类回归。比每季度做一次大压测、写一份没人读的 Word 文档,实在得多。

当然这套也不是万能的。CI 机器配置低,压不出高并发下的排队和 GC 问题,那些还是得上专门的压测环境。我的做法是分两层:CI 挡回归,季度压测定容量。前者保不出事,后者保不浪费机器。这两件事目标不一样,别指望一套脚本全包了。

🏷️ 标签: