ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

gatling3.0.0负载测试实战:线程模型、注入模型与断言避坑

gatling3.0.0负载测试实战:线程模型、注入模型与断言避坑

简介:Gatling 3.0.0 是面向性能测试工程师、开发人员及自动化测试学习者的压测工具包,主要用于构建高并发负载场景,验证 Web 应用在压力下的响应时间、吞吐量与资源利用率。压缩包共 114 个文件,约 57.17MB,其中 97 个 jar 依赖库构成运行主体,另含 6 个 Scala 脚本示例、3 个 conf 配置、2 个 bat 与 2 个 sh 启动脚本,以及 csv 参数文件和 xml 说明文件,便于在本地搭建并运行压测任务。已有 238 人学习,适合希望从脚本编写、场景配置到报告分析完整实践 Gatling 的读者。包内保留 recorder 录制入口、gatling 启动脚本与 Highcharts 报告组件,可用于理解 DSL 场景定义、执行速率设置、检查点与会话变量配置,并通过图表观察 RPS、平均响应时间和百分位分布,对排查性能瓶颈、将压测接入持续集成流程也有参考价值。

1. 从一次压不上去的压测说起:gatling3.0.0 到底是哪一层工具

上周帮一组人排查压测环境:脚本在自己机器上跑到 200 并发就卡住,TPS 停在 300 左右再也上不去,服务端 CPU 才 30%。换成 gatling3.0.0 重跑同一套接口,同样的机器,到达率稳定在 800 req/s 以上,响应时间曲线反而更平。这不是工具玄学,是线程模型差异——每用户一线程的压测端,客户端自己先成了瓶颈。

gatling3.0.0 是一个基于 Scala DSL 的负载测试工具发行包,底层用 Netty 做异步非阻塞 IO,把「虚拟用户」做成消息而不是线程,所以单机压测的并发上限明显更高。它适合两类人:需要把性能测试写进代码仓库、跟着 CI 一起跑的团队;以及被图形化脚本的维护成本折磨过、想让压测脚本像业务代码一样可 review 的从业者。代价是得接受 Scala 语法和命令行操作。

2. 拆开 gatling3.0.0 的发行包:四个目录和三条版本对齐线

拿到的是一个压缩包,解压后没有任何安装步骤,这点对常年处理各种环境依赖的人来说很友好。但「免安装」不等于「随便放」,包里每个目录的职责边界很清楚,动错了地方排查成本会成倍上升。

2.1 bin / conf / lib / user-files 各自管什么

目录职责什么时候需要动它
bin/启动脚本gatling.sh、gatling.bat,以及录制器脚本改 JVM 参数、看命令行参数说明
conf/gatling.conf(全局行为)、logback.xml(日志级别)调超时、连接池、报告输出路径
lib/全部依赖 jar,含 scala-library、netty、akka排查 jar 冲突、确认 Scala 版本
user-files/simulations/放.scala压测脚本写业务场景
user-files/resources/放 CSV、JSON 等参数化数据做数据驱动
user-files/binaries/放上传用的二进制文件测文件上传接口
results/每次运行的报告目录看报告、取原始日志

第一次用最容易犯的错是把脚本扔在bin/旁边就执行bin/gatling.sh -s xxx,然后收不到类。脚本默认只从user-files/simulations编译源码,路径不对就直接「simulation not found」。自定义目录要用-sf显式指过去,参数后面会讲。

2.2 JDK 与 Scala 的两条对齐线

3.0 这条产品线同时存在 Scala 2.11 和 2.12 两个构建。判断手上这份包是哪个,不用猜,看lib/下scala-library-*.jar的版本号就行。这条信息不是摆设:如果你的工程里另有一套 Scala 依赖,或者要往lib/里补第三方 jar,编译目标必须和它一致,否则运行期抛NoSuchMethodError,报错位置还跟你写的代码毫无关系,属于典型的需要提前对齐的坑。

JDK 侧建议固定在 8。3.0.0 发布时主流的运行环境就是 JDK 8,用 9 及以上版本时,Akka 和反射相关代码会撞上模块访问限制,表现是启动阶段直接抛IllegalAccessError或警告刷屏。真要跑在高版本 JDK 上,得补--add-opens参数,这笔账不如直接装个 JDK 8 划算。

unzip gatling-charts-highcharts-bundle-3.0.0.zip cd gatling-charts-highcharts-bundle-3.0.0 java -version # 确认是 1.8.x ls lib | grep scala-library # 确认 Scala 构建版本 bin/gatling.sh # 不带参数会列出可用的 simulation

bin/gatling.sh不带任何参数执行时,会扫描并列出当前可用的 simulation 类名,这是个很实用的自检动作:能列出来,说明 JDK、Scala、脚本目录三件事都对了;列不出来,问题一定在这三者之一,不用往业务代码上想。包里自带的示例指向一个公共演示站点,内网环境跑不通,直接换成自己的地址即可,别在这个示例上耗时间。

2.3 用 sbt 把脚本挪进代码仓库

命令行模式适合验证,长期维护还是得进工程。3.0 提供了标准的 sbt 接入方式,依赖坐标和主版本严格对应:

// project/plugins.sbt addSbtPlugin("io.gatling" % "gatling-sbt" % "3.0.0") // build.sbt enablePlugins(GatlingPlugin) scalaVersion := "2.12.8" // 必须与 gatling 3.0.0 的构建目标一致 libraryDependencies ++= Seq( "io.gatling.highcharts" % "gatling-charts-highcharts" % "3.0.0" % Test, "io.gatling" % "gatling-test-framework" % "3.0.0" % Test )

两个依赖必须同版本,gatling-charts-highcharts管报告渲染,gatling-test-framework管 DSL 和运行时,版本错开会出现报告页面空白或者 DSL 方法找不到。% Test不能省,压测代码不该进生产制品,这条在代码评审时经常被漏掉。scalaVersion写错是最隐蔽的一类问题:编译能过,运行时报错信息指向框架内部,看半天看不出是自己版本没对齐。

工程化之后,执行入口变成sbt gatling:test,报告落在target/gatling/。好处是脚本能进版本管理、能在流水线里跑、能被同事 review,坏处是首次拉依赖比较慢,内网环境记得配好镜像。

3. 写出第一个能跑的 Simulation:协议层、场景层、校验层三件套

Scala DSL 乍看陌生,结构其实就三层:协议层定义「打到哪、怎么打」,场景层定义「按什么顺序打」,注入层定义「打多少」。三层分开写,改任何一层都不牵动另外两层,这是它比图形化脚本更适合长期维护的根本原因。

3.1 协议层:baseURL 与连接复用

package simu import io.gatling.core.Predef._ import io.gatling.http.Predef._ import scala.concurrent.duration._ class OrderSimulation extends Simulation { // 协议层:所有请求的公共部分 val httpProtocol = http .baseURL("http://127.0.0.1:8080") // 3.0 的写法是大写 URL .acceptHeader("application/json, text/plain, */*") .contentTypeHeader("application/json") .userAgentHeader("gatling-3.0-loadtest") .shareConnections // 同一虚拟用户复用连接 }

baseURL在 3.0 里确实是大写URL,后续版本改成了baseUrl,从旧脚本迁移过来时这是第一个编译错误来源,报错信息是「value baseUrl is not a member」,看着像依赖没引进来,其实是 API 改名。

shareConnections值得展开说。默认情况下每个虚拟用户各自持有连接池,用户数一多,客户端侧的连接数会成倍上涨,服务端和中间件都可能先扛不住。打开共享之后,同一个 Gatling 实例内的连接会被复用,客户端连接数被压到一个可控范围,压测端自身的资源占用明显下降。这条在压高并发短请求时效果最明显,长连接场景要结合pooledConnectionIdleTimeout一起看。

3.2 场景层:exec / pause / feed 三件套

// 参数化数据:user-files/resources/order_ids.csv,首行是表头 orderId val orderIds = csv("order_ids.csv").circular val scn = scenario("order-query") .feed(orderIds) // 每轮取一行 .exec( http("query_order") .get("/api/order/${orderId}") // EL 表达式取字段 .check(status.is(200)) ) .pause(1, 3) // 1~3 秒随机思考时间 }

feed的默认策略是queue,数据用尽就抛异常,很多人第一次跑长稳压测时被这个坑到——短跑没事,跑到第 N 分钟突然大面积失败。.circular循环读、.random随机读,长稳压测基本都要显式指定。

pause(1, 3)是区间随机,不要写成固定值。固定思考时间会让所有虚拟用户形成节拍同步,请求像潮水一样一波一波打过去,测出来的响应时间曲线周期性抖动,看着像服务端有问题,其实是压测端自己造出来的假象。随机区间是消除这种同步的最简单手段。

路径里的${orderId}是 EL 表达式,从 session 里取值。字段名必须和 CSV 表头完全一致,大小写敏感,写错了不会编译报错,运行期取到空串,请求打到/api/order/上,返回 404,然后你以为是路由配错了。

3.3 校验层:check 写不写,结论差一个数量级

.exec( http("query_order") .get("/api/order/${orderId}") .check(status.is(200)) .check(jsonPath("$.code").is("0")) // 业务码校验 .check(jsonPath("$.data.id").saveAs("lastOrderId"))// 提取供后续请求用 .check(responseTimeInMillis.lt(1000)) // 单请求超时门槛 )

只看 HTTP 状态码是不够的。很多接口在业务失败时照样返回 200,body 里code是非 0。不写业务码校验,报告里成功率 100%,实际上全是失败响应,这份报告拿去汇报就是事故。

saveAs是把响应里的字段写回 session,供后续请求串联使用。注意它和执行顺序绑定:saveAs之后的exec才能取到这个值,写在前面取到的是空。这类关联逻辑在 3.0 里没有图形化提示,全靠自己理清执行链。

responseTimeInMillis做单请求级别的门槛校验,好处是能把「慢请求」直接标成 KO,而不是淹没在平均值里。缺点是它会放大 KO 率,需要配合断言一起看,别单独拿它下结论。

3.4 跑起来:命令行参数逐条对

bin/gatling.sh \ -s simu.OrderSimulation \ -rd "baseline-20rps" \ -rf results \ -nr

-s指定 simulation 全限定类名,包名不能省;-rd是这次运行的描述,会写进报告首页,多轮对比时靠它区分;-rf指定报告输出根目录,CI 里通常指到一个固定挂载点;-nr表示不生成 HTML 报告,只出原始数据,批量跑回归时能省不少时间和磁盘。日常排错别加-nr,报告页是主要的信息来源。

执行完results/下会多一个以 simulation 名和实例号命名的目录,里面有index.html和simulation.log。后者是制表符分隔的原始记录,每一行是一次请求的开始、结束、状态,报告页上任何对不上的数字,最后都要回到这个文件里核对。我习惯把它叫做压测的黑匣子,出了问题先看它,比盯着 HTML 图表猜要快得多。

4. 注入模型与断言:把「多少并发」翻译成会红的门槛

大部分人卡在这一步:脚本能跑了,但不知道该填多少用户、跑多久、看哪个指标。Gatling 把这件事拆成两个独立的部分——注入模型负责「怎么产生负载」,断言负责「什么算不达标」。两者都写在setUp里,改起来互不影响。

4.1 inject 的五个常用积木与它们真实的负载形状

积木负载形状典型用途
atOnceUsers(n)n 个用户瞬间启动测冷启动、连接池打满的瞬时表现
rampUsers(n) over (d)线性爬坡到 n找系统拐点,看响应时间从哪开始翘
constantUsersPerSec(r) during (d)恒定到达率最接近线上真实流量,做容量基线
rampUsersPerSec(a) to (b) during (d)到达率线性上升压饱和点,看服务端在哪个 RPS 崩
stressPeakUsers(n) over (d)尖峰脉冲模拟秒杀、整点推送
setUp( scn.inject( rampUsers(50) over (30 seconds), // 预热 constantUsersPerSec(20) during (2 minutes) randomized, // 稳态 rampUsersPerSec(20) to (60) during (1 minute) // 加压找拐点 ) ).protocols(httpProtocol)

randomized这个后缀容易被忽略,作用是把到达间隔打散。不加它,constantUsersPerSec会按固定节拍发请求,虽然均匀但过于理想,测不出排队效应;加上之后更接近真实用户的到达分布。这条差别在小流量下看不出来,到达率一高,响应时间分位数能差出 20% 以上。

还有一个容易混淆的概念:Gatling 的默认模型是「闭环」,也就是固定虚拟用户数,服务端变慢,这些用户发请求的速率自然下降。所以看rampUsers的报告,TPS 上不去不一定是服务端没能力,可能是响应变慢把用户卡住了。要测服务端容量上限,用constantUsersPerSec这种开环模型,或者干脆用throttle强制指定到达率:

setUp(...) .protocols(httpProtocol) .throttle( reachRps(100) in (10 seconds), // 10 秒内爬到 100 req/s holdFor(2 minutes) // 维持 2 分钟 )

throttle是硬性限速,达到上限后多余请求会被阻塞,报告里的吞吐量曲线会是一条平线。用它做容量基线比较干净,代价是它掩盖了压测端真实的调度压力。

4.2 assertions:把 SLA 写成会红的门槛

).assertions( global.responseTime.percentile3.lt(800), // p95 < 800ms global.responseTime.percentile4.lt(2000), // p99 < 2000ms global.successfulRequests.percent.gt(99.0), // 成功率 > 99% details("query_order").failedRequests.count.lt(10) )

百分位编号是最容易记错的地方,这里必须说清楚:percentile1是 p50,percentile2是 p75,percentile3是 p95,percentile4是 p99。把percentile3当成 p99 写进断言,门槛等于被自己放宽了一个数量级,压测跑绿了但线上照样雪崩,这种后悔药没地方买。

断言不通过时,命令行退出码是非 0,这点对接流水线很关键。把它和构建脚本串起来,性能不达标直接卡住发布,比人工看报告靠谱得多。details("query_order")是按请求名做细粒度断言,请求名就是http("query_order")里那个字符串,改名字记得同步改断言,否则会静默失效——断言里写了不存在的请求名,3.0 不会报错,只是这条规则不生效。

4.3 报告里该看哪三张图

第一张是 Requests per second,看吞吐是否稳定,有没有周期性掉坑;第二张是 Response Time Percentiles over Time,重点看 p95 和 p99 的走势,平均值在这里没有意义;第三张是 Active Users,这条线和 TPS 的对应关系能直接判断出是不是压测端先饱和了。

判断客户端瓶颈有个很直接的经验:Active Users 上不去、响应时间却始终平稳、机器 CPU 也不高,三个现象同时出现,基本就是注入端被限制住了,跟服务端能力无关。这时候该查的是文件句柄数、临时端口范围、GC 日志,而不是继续调服务端参数。

5. 避坑与排查:gatling 3.0 上最常见的五类翻车

下面这些都是在真实压测现场反复出现的,按「现象 → 原因 → 解决」整理,遇到类似症状可以直接对照。

5.1 现象:TPS 卡在某个值不动,服务端 CPU 和带宽都没满

先看压测机自己的资源,十有八九是客户端饱和。常见触发点是文件句柄数和临时端口范围:默认ulimit -n只有 1024,几千个并发连接就把描述符吃光了;短连接场景下 TIME_WAIT 堆积,可用的本地端口很快耗尽,日志里会出现Too many open files或连接被重置。

解决办法分两步。系统侧放开限制:

ulimit -n 65535 sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535" sudo sysctl -w net.ipv4.tcp_tw_reuse=1

应用侧把连接复用打开,也就是前面提到的shareConnections,再配合gatling.conf里的连接池参数,把maxConnectionPoolSize调到和并发量匹配的量级。这一步做完还没起色,再回去看服务端。

5.2 现象:前几分钟一切正常,之后失败率突然飙到 100%

日志里出现 feeder 相关的异常,典型信息是迭代器取空的NoSuchElementException。原因是csv()默认用queue策略,数据读完之后不再循环,feed直接抛异常,整个场景中断。

解决就是显式指定读取策略:需要循环用.circular,需要随机用.random。另外注意数据行数和压测总请求量的关系,constantUsersPerSec(20) during (10 minutes)是 12000 次请求,CSV 至少要有这个量级的行数,否则循环读会反复打同一批 ID,缓存命中率虚高,测出来的性能偏乐观。这种数据构造上的偏差比代码 bug 更难发现。

5.3 现象:报告里的 P95 很漂亮,线上监控的 QPS 对不上

多数是模型理解偏差。用rampUsers(200)跑出来的报告,不代表 200 并发常驻,用户启动后各自带思考时间循环执行,实际到达率可能只有几十。加上pause拉长了单次迭代周期,两者一叠加,报告上的吞吐量和业务方理解的「并发」完全不是一回事。

解决方式是把口径写进报告描述:用-rd标明这轮是「恒定到达率 50 req/s」还是「200 闭环用户」,别只写「压测第一轮」。做容量结论时优先看constantUsersPerSec或throttle的结果,闭环用户数只作为参考。

5.4 现象:编译或启动阶段报 Scala 相关的类找不到

报错类似「object gatling is not a member of package io」或者运行期NoSuchMethodError,位置指向框架内部。原因是 Scala 版本没对齐:手上的包是 2.11 构建,工程里配的却是 2.12,或者往lib/里塞了不同 Scala 版本编译的第三方 jar。

解决分两步确认:先ls lib | grep scala-library看包本身的目标版本,再把工程里的scalaVersion改成一致。这一步没对齐之前,任何业务代码层面的调试都是白费功夫。JDK 侧同理,固定 8 最省事,非要用高版本就补--add-opens。

5.5 现象:拿别人的脚本直接跑,报baseUrl不存在或注入语法不认识

脚本是从更高版本的项目里拷过来的,API 已经变了。baseURL改成baseUrl、部分 check 的方法签名调整、注入 DSL 的写法演进,都会导致 3.0 编译不过。

解决办法是建立版本意识:拿到一份脚本,先看它的依赖坐标里的 Gatling 版本,再做迁移。3.0 的语法本身是稳定的,问题都出在跨版本搬运。我一般的做法是新项目直接用包自带的版本号锁定依赖,不在一个工程里混用多个 Gatling 版本,避免出现「同一个类两个来源」这种排查起来极其痛苦的局面。

6. 固化一轮基线压测:参数表、验证口径和我的固定动作

压测结果不可复现,比压测跑失败更麻烦。跑过几轮之后我形成了一个固定套路:每轮基线压测都填同一张参数表,跑完先核对表里的五项,再看报告。

项目我这边的固定取值说明
注入模型constantUsersPerSec或throttle开环,容量结论才站得住
预热时长30 秒避开 JIT 和连接池冷启动
稳态时长2 分钟以上短于这个数看不出内存和 GC 问题
断言门槛p95 / p99 / 成功率三项缺一项结论就不完整
客户端检查句柄数、端口、GC每轮必看,不看等于白测

跑完先看的不是 HTML,是simulation.log里的 KO 记录,确认失败是集中在某段时间还是均匀分布。集中在一段,通常是压测端的资源抖动或者 feeder 用尽;均匀分布,才轮到怀疑服务端。这个顺序能省掉大量无谓的沟通成本。

# 固化的一轮基线,命令和描述都写死,方便多轮对比 bin/gatling.sh \ -s simu.OrderSimulation \ -rd "baseline-open-50rps-2min" \ -rf results

描述里带上模型、到达率、时长三个要素,报告目录一多也能一眼认出哪份是哪份。这个习惯是被坑出来的:曾经攒了十几份报告,文件名全是默认生成的一串数字,最后靠时间戳倒推才找回要对比的那一轮,从那以后我每次跑基线都强制把这三个要素写进-rd,一次都没再翻过车。

进阶一点的做法是把断言接进流水线,性能门槛和功能用例用同一套退出码机制,不达标就卡发布。先在预发环境用 1/10 的流量跑一遍,确认断言门槛不会误报,再放大到目标流量,这样门槛本身也是被验证过的。至于工具版本,3.0 这条线用顺手了就不急着升,升级前先把脚本里的baseURL、注入 DSL、check 写法过一遍,改动量心里有数再动手。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进