
简介Gatling 3.0.0 是一套面向开发者和性能测试工程师的负载测试工具包适合用于 Web 应用在高并发场景下的响应时间、吞吐量与稳定性验证。压缩包共 114 个文件约 57.17 MB以 97 个 jar 依赖与核心库为主辅以 6 个 Scala 脚本、3 个 conf 配置、bat/sh 启动脚本及示例数据组成可直接解压运行的测试环境。已有 238 人学习或下载。借助其中的 DSL 脚本示例、录制器配置和 Highcharts 报告组件读者可快速编写场景、模拟并发用户并查看 RPS、平均响应时间和百分位分布也便于把性能测试接入持续集成流程定位瓶颈并复盘结果。1. Gatling 3.0 值不值得放进你的性能测试工具箱如果你做过一次需要向上汇报的性能测试大概都遇到过同一个尴尬JMeter 压到 2000 并发被测服务 CPU 才 40%压测机自己先 GC 飙红最后报告里那组响应时间到底是被测系统的还是压测工具的谁也说不清。Gatling 3.0 就是冲着这个尴尬来的——虚拟用户跑在 Akka 的消息循环上几万个用户共享少量线程客户端自身开销被压得很低。这套 3.0.0 的包给的是完整可执行的压测运行时Scala 写的场景脚本、HTTP/HTTPS 协议栈、录制器以及跑完自动生成的 HTML 报告。它适合已经把性能测试当回归手段、愿意用代码描述流量的团队如果你只想点几下录制回放用起来会觉得处处别扭。2. 异步模型和 3.0 的硬改动为什么同一台机器能多压五倍选压测工具这件事很多人是「先装了再说」结果压到一半才发现瓶颈在压测机自己身上。Gatling 的核心价值不在报告好看而在于它把「客户端成本」这个变量尽量压小让测出来的数字更接近服务端真实水位。2.1 一个虚拟用户到底占多少资源传统线程模型里一个虚拟用户绑定一个操作系统线程。2000 并发就是 2000 个线程JVM 默认线程栈 1MB 左右光栈空间就是 2GB再叠加每个线程的上下文切换和 GC 压力压测机的 CPU 有很大一部分根本不是花在发请求上。Gatling 走的是另一条路虚拟用户是一个轻量 actor被调度到固定数量的 dispatcher 线程上一个线程可以来回推进成百上千个用户的状态机。你在脚本里写的exec链是顺序执行的但整个执行过程是非阻塞的发请求之后线程立刻去推进别的用户等响应回来再切回来。这带来一个直接后果压测机的线程数基本不随并发用户数线性增长。我在 4C8G 的机器上做过对照同一个接口线程模型压到 3000 并发时压测机 CPU 已经接近打满、GC 频率明显上升换成 Gatling 之后同样的并发下压测进程只占了一部分 CPU还能继续往上加。数量级不必照搬但趋势是稳定的。反过来说判断「是不是压测机先成瓶颈」的方法也要跟着变。别只看并发数压测过程中同步盯三样东西压测机 CPU、压测机 GC 日志、压测机网卡出向带宽。任何一项接近打满这轮数据就要打问号。我一般会把「客户端 CPU 超过 70%」当成一条软红线超过就降并发重跑一轮确认曲线是否线性。注意Gatling 的 pause 是「虚拟等待」不占线程。所以你把 pause 从 1 秒拉到 5 秒压测机 CPU 几乎不变但被测系统的 RPS 会掉下来——这是负载模型在起作用不是工具在偷懒。2.2 3.0 相对 2.x 的四处硬改动从 2.x 迁移过来的工程最容易翻车的不是语法而是几处「看起来只是改个名字」的 API 变动。这四条是我在迁移时踩实了的第一注入模型收敛。2.x 里还能写setUp(scn.users(10).ramp(60))3.0 把老 API 移除了只剩inject(...)。好处是负载模型变成可组合的表达式坏处是老脚本直接编译不过。第二baseURL改名baseUrl。Scala 大小写敏感这一处如果没改编译报错会指向一个你根本没注意的字段第一次遇到挺懵。第三Expression Language 重写。${}现在走 Gatling 自己的表达式引擎支持属性访问和简单方法调用比如${user.id}、${list.size()}同时#{}留给 Gatling EL 和字符串插值做区分。迁移时如果脚本里混用了两种占位符建议全部统一到${}别两边掺着写。第四Scala 主线版本切到 2.12包里附带的构件默认是 2.12 编译的。你自己工程里的scala.version必须和依赖包的大版本对齐2.11 和 2.12 的 jar 混着放症状是运行时抛NoSuchMethodError而且堆栈完全看不出根因。一段典型的 2.x 到 3.0 对照写法差异集中在注入和协议配置上// 2.x 老写法3.0 编译不过 setUp(scn.users(200).ramp(120).protocols(httpConf)) // 3.0 写法负载模型变成 inject 的参数 setUp( scn.inject(rampUsers(200) over (120 seconds)).protocols(httpConf) )rampUsers(200)表示 200 个用户over (120 seconds)是从 0 线性爬到 200 的时间窗口。这段改动不只是语法糖它让「用户数」和「爬坡时间」成为两个显式参数后面做负载模型对比时会方便很多。2.3 解压之后先看这四个目录包解开之后不要急着写脚本先把目录结构认全后面排错会省很多时间。目录作用什么时候会动它bin/启动脚本含gatling.sh、recorder.sh手动跑压测、开录制器conf/gatling.conf、logback.xml、recorder.conf调线程池、日志级别、录制白名单lib/运行时依赖含 Scala 标准库一般不动升级时整体替换user-files/simulations/你写的 Simulation 源码每次写脚本都在这user-files/resources/CSV、JSON 等数据文件参数化数据放这里results/每次运行的报告目录归档、做趋势时在这第一次拿到包建议先跑一个自带示例确认环境是通的再动手写自己的脚本# 进入解压目录直接跑一个内置的示例 Simulation # -s 指定 Simulation 的全限定类名 bin/gatling.sh -s computerdatabase.BasicSimulation跑完会在results/下生成一个带时间戳的目录里面index.html用浏览器打开就能看报告。这一步通了说明 JDK 版本、Scala 运行时、脚本编译链路都是好的后面出问题就可以排除环境因素。conf/gatling.conf里值得先看一眼的是线程池配置默认值在多数场景够用但如果你压测机的核数和预期并发差得远可以适度调整 dispatcher 的线程数上限。改之前建议先跑一轮基线改完再跑一轮两组报告对比着看别凭感觉调。3. 写一个能跑的 Simulation登录、查询、下单三段链路从这一章开始进入可复现的部分。我会用一条电商风格的链路做例子你可以把它替换成自己的接口骨架和参数含义是通用的。3.1 用 Maven 起工程把依赖和 Scala 版本对齐独立包适合临时压测长期维护的脚本建议放进 Maven 工程方便进版本库和流水线。pom.xml里关键就三块Gatling 的高阶图表包、Scala 编译插件、Gatling 的 Maven 插件。properties !-- 必须和 Gatling 3.0.0 附带的 Scala 大版本一致2.11/2.12 混用会报 NoSuchMethodError -- scala.version2.12.2/scala.version /properties dependencies dependency groupIdio.gatling.highcharts/groupId artifactIdgatling-charts-highcharts/artifactId version3.0.0/version scopetest/scope /dependency /dependencies build plugins !-- 编译 src/test/scala 下的脚本 -- plugin groupIdnet.alchim31.maven/groupId artifactIdscala-maven-plugin/artifactId version3.3.2/version configuration scalaVersion${scala.version}/scalaVersion /configuration /plugin !-- 提供 gatling:test 目标 -- plugin groupIdio.gatling/groupId artifactIdgatling-maven-plugin/artifactId version3.0.0/version /plugin /plugins /build三个参数的作用分开说scala.version决定编译产物和运行时的一致性写错就是运行时崩溃而不是编译报错最难查scala-maven-plugin负责把.scala文件编译进test-classesgatling-maven-plugin只是提供一个执行入口不参与编译。脚本文件放在src/test/scala/下数据文件放src/test/resources/这是 Maven 的默认约定放进去了就不用额外配路径。3.2 一段完整的 Simulation逐段说清楚下面这段脚本覆盖了协议配置、数据源、三段请求、分组、注入模型和断言可以直接改接口地址后跑。package perf import io.gatling.core.Predef._ import io.gatling.http.Predef._ import scala.concurrent.duration._ class OrderFlowSimulation extends Simulation { // 1) 协议层配置所有请求共用避免每个请求重复声明 val httpConf http .baseUrl(http://127.0.0.1:8080) // 3.0 是 baseUrl不是 baseURL .acceptHeader(application/json) .contentTypeHeader(application/json) .userAgentHeader(perf-gatling/3.0.0) .disableCaching // 压测不读本地缓存否则响应时间偏乐观 .shareConnections // 共享连接池防止用户数上涨时本地连接爆炸 // 2) 数据源circular 表示读完回到文件头循环取 val userFeeder csv(data/users.csv).circular // 3) 登录把 token 从响应里提出来存进 session val login exec( http(POST /login) .post(/api/login) .body(StringBody({user:${username},pwd:${password}})).asJson .check(status.is(200)) .check(jsonPath($.code).is(0)) // 业务码也要校验光看 200 不够 .check(jsonPath($.data.token).saveAs(token)) ).pause(1, 3) // 1 到 3 秒之间的随机思考时间 // 4) 查询带上登录拿到的 token val query exec( http(GET /items) .get(/api/items?page1) .header(Authorization, Bearer ${token}) .check(status.is(200)) .check(jsonPath($.data.list[*]).count.gt(0)) ) // 5) 下单 val order exec( http(POST /order) .post(/api/order) .header(Authorization, Bearer ${token}) .body(StringBody({sku:${sku},num:1})).asJson .check(status.is(200)) .check(jsonPath($.code).is(0)) ) // 6) 场景编排feed 放在最前面保证每次迭代都换一个用户 val scn scenario(下单主链路) .feed(userFeeder) .exec(login) .group(浏览-下单) { exec(query).pause(2) .exec(order) } // 7) 负载模型 断言 兜底时长 setUp( scn.inject( nothingFor(5 seconds), // 先静默 5 秒等被测服务热身 atOnceUsers(10), // 瞬时 10 个用户打一个尖峰 rampUsers(200) over (120 seconds), // 2 分钟内爬到 200 constantUsersPerSec(20) during (300 seconds) // 之后按 20 用户/秒持续注入 ).protocols(httpConf) ).assertions( global.responseTime.percentile3.lt(800), // p95 小于 800ms global.successfulRequests.percent.gt(99.0) // 成功率高于 99% ).maxDuration(15 minutes) // 防止脚本挂死硬性截断 }几个参数值得单独解释。shareConnections是 AsyncHttpClient 的共享连接池开关默认每个虚拟用户独享一个连接池用户数上万时本地会开出一大片连接很容易撞到端口上限或者被测服务的maxconn加上这个开关能明显压住客户端连接数。jsonPath($.data.list[*]).count.gt(0)里的count是把匹配到的元素个数当成一个可断言的量gt(0)表示至少匹配到一个用来防止接口返回空列表但状态码 200 的假成功。.pause(1, 3)是闭区间内的随机值写死一个固定值会让所有用户的请求节奏完全同步测出来的曲线会周期性抖动。feed的位置也有讲究。放在scenario之后的第一个位置意味着每次完整迭代取一条新数据如果放在某次exec之后同一次迭代里的多个请求会共用同一条数据这在需要「同一用户贯穿多次请求」时反而是对的。选哪种取决于你要模拟的语义不是随手放。3.3 命令行跑起来报告重点看哪几栏独立包和 Maven 两种跑法参数含义基本一致# 独立包-s 指定类名-rd 写一段可读描述会显示在报告标题里-rf 指定报告输出目录 bin/gatling.sh \ -s perf.OrderFlowSimulation \ -rd v1.2.0 下单链路基线 \ -rf ./results # Maven 工程 mvn gatling:test -Dgatling.simulationClassperf.OrderFlowSimulation-rd这个参数别省报告目录名是时间戳过两周回来看根本分不清哪次对应哪个版本描述写清楚了能省掉大量翻日志的时间。-rf可以把报告直接输出到归档目录方便后续做趋势。报告打开之后先看四块全局的请求数、响应时间分位数p50/p75/p95/p99、每秒请求数曲线、失败请求列表。分位数比平均值有用得多平均值 200ms 但 p99 是 3 秒的系统用户体感是「偶尔卡死」光看平均值会误判。失败请求列表要点开看具体错误信息连接超时、读超时、5xx 是三类完全不同的根因处理方式也完全不同。提示报告里的响应时间只统计请求本身不含 pause。所以你把 pause 调大响应时间不会变变的是每秒请求数。看到 RPS 掉了先别怀疑被测服务先看负载模型。4. 负载模型inject 的几种注入方式该怎么选脚本写通只是第一步压测真正有价值的部分是「用什么样的流量形状去逼出问题」。这一章讲清楚注入策略的选择逻辑以及怎么让一次压测跑完就能给出结论。4.1 六种注入策略分别适合回答什么问题注入方式流量形状适合回答的问题atOnceUsers(n)瞬时台阶冷启动、缓存击穿、连接池瞬间打满rampUsers(n) over (t)线性爬坡找拐点在哪个并发区间响应时间开始非线性上升constantUsersPerSec(r) during (t)恒定到达率稳态容量在固定 QPS 下系统能撑多久rampUsersPerSec(a) to (b) during (t)到达率线性上升容量上限探测配合响应时间曲线看heavisideUsers(n) over (t)S 形爬坡模拟真实业务放量两端平缓中间陡splitUsers(n) into (s)分段组合把多种形状拼在一次运行里比如先尖峰再稳态选哪种不是看哪个名字顺眼而是先明确这轮压测要回答什么问题。想验证「服务重启后能否顶住第一波流量」用atOnceUsers加一个很短的静默期就够了想知道「系统的容量拐点在哪」用rampUsersPerSec从低到高扫同时盯 p99 曲线的斜率变化想验证「大促期间每小时固定单量能否稳定处理」用constantUsersPerSec跑够时长看错误率和内存是否随时间劣化。实际项目里我一般会把两三种拼在一次运行里先静默、再尖峰、再爬坡、最后稳态一轮跑完能看到系统在几种压力形态下的表现比跑四次省时间。4.2 用 assertions 把压测变成有结论的实验没有断言的压测报告本质上是「一堆数字」看完还得人来判断。3.0 的assertions把判断标准写成代码跑完直接给通过或失败接进流水线之后这一点尤其重要。setUp( scn.inject(constantUsersPerSec(50) during (10 minutes)).protocols(httpConf) ).assertions( // 全局维度 global.responseTime.percentile3.lt(800), // p95 800ms global.responseTime.max.lt(5000), // 最慢请求 5s global.successfulRequests.percent.gt(99.5), // 成功率 99.5% // 单请求维度只对核心接口收紧标准 details(POST /order).responseTime.percentile3.lt(1200), details(POST /order).failedRequests.percent.lt(0.1) )global是整个场景的聚合details(请求名)精确到某一个http(...)里写的名字。注意这个名字必须和脚本里的字符串完全一致包括空格和斜杠改名字的时候容易漏改断言结果断言永远不触发。阈值怎么定我的习惯是三层业务侧给的 SLA 是硬线比如 p95 不超过 800ms上一版的实测值加 20% 是回归线再加一层兜底的绝对值最慢请求不超过 5 秒防止极端情况。三层里任何一层被击穿都应该让这轮压测判定为失败而不是「看看再说」。4.3 feeder 与参数化三种读取策略别用错csv()后面挂的读取策略决定了数据怎么被消费选错了会直接导致压测结果失真。// circular读完回到文件头无限循环。适合需要长时间跑的场景 val circularFeeder csv(data/users.csv).circular // queue读完就抛异常。适合「每个用户只能用一次」的注册、领券类场景 val queueFeeder csv(data/coupons.csv).queue // random每次随机取一条可能重复。适合模拟体量很大的用户池 val randomFeeder csv(data/users.csv).random // batch一次取一批进内存减少 IO适合大文件 val batchFeeder csv(data/users.csv).batch(1000).circular数据文件本身也有两个注意点。第一CSV 默认逗号分隔如果字段里本身含逗号要显式指定分隔符和引号规则否则会切错列症状是${username}取到半截字符串登录接口全量失败但报错信息很难懂。第二文件不要留表头Gatling 不会自动跳过第一行表头会被当成一条正常数据喂进去前几条请求必然失败很容易被误判成脚本 bug。参数化的目的是让每个虚拟用户的行为有差异避免服务端缓存或数据库锁把并发伪造成串行。如果所有用户用同一个账号、同一个商品 ID 打同一个接口测出来的数字再好看也没有参考价值。5. 避坑与排查五个把压测结果做废的细节压测这行的血泪经验大多不是「脚本写不出来」而是「跑出来了但数字是错的」。下面五条是我在实际项目里反复遇到的每条都按现象、原因、解决三段来说。5.1 跑到一半大量连接失败服务端却很闲现象并发爬到某个量级后报告里突然出现大量失败错误信息是java.net.BindException: Cannot assign requested address同时被测服务的 CPU 和连接数都不高。原因压测机本地端口耗尽或者是文件描述符上限太低。每个 TCP 连接占用一个本地端口默认的可用端口范围大约 2.8 万个加上 TIME_WAIT 状态的连接要等一段时间才释放高并发短连接场景下很容易撞墙。文件描述符同理默认 1024 的话几千并发直接不够用。解决压测前先调高上限并把连接改成复用。ulimit -n 65535是常规操作更重要的是在脚本里加上shareConnections让所有虚拟用户共享连接池连接数就等于并发数而不是并发数乘以每用户连接数。如果服务端支持把 HTTP 头里的Connection保持长连接也能显著减少端口占用。5.2 长时间压测后期请求全打到一台已经下线的机器上现象压测跑了半小时之后错误率突然飙升但服务端监控显示那台机器早就被摘掉了或者反过来扩容之后新增的实例收不到任何流量。原因JVM 的 DNS 缓存。默认情况下 JVM 会缓存域名解析结果缓存时间可能长达数分钟甚至整个进程生命周期压测机一直往旧 IP 发请求。这在容器化环境里特别常见实例扩缩容之后 IP 变了压测机的缓存还没过期。解决启动参数里把缓存关掉同时确认压测目标用的是域名而不是硬编码 IP。# 关闭 JVM 的 DNS 正向缓存和负向缓存 # 0 表示每次都重新解析代价是多几次 DNS 查询压测场景完全可接受 bin/gatling.sh -Dnetworkaddress.cache.ttl0 \ -Dnetworkaddress.cache.negative.ttl0 \ -s perf.OrderFlowSimulation如果压测机和被测服务在同一个内网、IP 本身稳定硬编码 IP 反而更可控能排除 DNS 这个变量。两种做法各有取舍关键是团队内部要统一别一半脚本用域名一半用 IP出问题时对不上账。5.3 报告全绿服务端日志里一堆业务异常现象压测报告成功率 100%响应时间也很漂亮但翻服务端日志发现大量业务异常或者数据库里根本没写进去数据。原因校验只看了 HTTP 状态码。很多后端框架会把业务异常包装成 200 返回body 里是{code:500100,msg:库存不足}这样的结构。只写check(status.is(200))等于什么都没验。解决每个请求至少加一层业务码校验再加一层关键字段的存在性校验。http(POST /order) .post(/api/order) .body(StringBody({sku:${sku},num:1})).asJson .check(status.is(200)) .check(jsonPath($.code).is(0)) // 业务成功码 .check(jsonPath($.data.orderId).exists) // 关键字段确实返回了加校验的代价是脚本多一点解析开销但换来的是一份可信的报告。我见过太多「压测通过」的报告最后上线出问题回头一查发现压测期间业务成功率只有六成只是没人看 body。5.4 加了 pause 之后 RPS 掉了一半以为是服务变慢了现象脚本里加了一段pause(2)重新跑每秒请求数直接腰斩但响应时间几乎没变。原因pause 是虚拟等待不占线程也不消耗资源但它实实在在拉长了每个用户的迭代周期。一个用户原本 0.5 秒完成一次迭代加了 2 秒 pause 之后变成 2.5 秒相同用户数下 RPS 自然掉到五分之一。这不是性能退化是负载模型变了。解决先把「用户数」和「RPS」两个概念分清楚。用constantUsersPerSec按到达率注入时pause 不影响总请求数只影响同时在线用户数用rampUsers按用户数注入时pause 直接影响 RPS。做前后对比时要么固定 RPS 变用户数要么固定用户数变 RPS别两个都动否则数据没法解释。5.5 断言写好了流水线却永远是通过现象明明这轮压测 p95 已经 2 秒了阈值设的 800ms构建还是绿的。原因三种可能。一是断言写在了setUp之前根本没生效二是details(请求名)里的名字和脚本里的对不上断言匹配不到任何请求默认视为通过三是执行方式不对用了某种不检查退出码的方式调用断言失败了但构建脚本没接住。解决先在本地跑一次故意写紧的阈值确认构建会红再放宽到正式值。这一步叫「验证你的验证」听起来多余但我确实见过因为断言名字拼错导致连续三个月压测结果都是假绿的情况。另外在 CI 里要看退出码Gatling 断言失败会返回非 0构建脚本必须把这个码透传出去。6. 接进流水线报告归档和趋势回归的两个做法单次压测的价值有限真正能提前发现劣化的是「同一条链路、同一个负载模型、每次发布都跑一遍然后看曲线有没有往下走」。6.1 用 Maven 插件在 CI 里跑断言失败即构建失败流水线里的调用和本地没区别关键是产物归档和退出码处理# -B 批处理模式避免 CI 环境下等待交互输入 # 断言失败时进程返回非 0CI 会直接把这一步标红 mvn -B gatling:test \ -Dgatling.simulationClassperf.OrderFlowSimulation \ -Dgatling.resultsFolder./target/gatling-results归档的时候把整个报告目录收走index.html给业务方看simulation.log留给后面做趋势。上线前的流水线跑稳态模型constantUsersPerSec夜间定时任务跑容量探测模型rampUsersPerSec两种模型分开建档不要混在一个趋势图里否则曲线会毫无规律。6.2 从 simulation.log 里抠出跨版本的趋势数据报告是给人看的趋势是给机器比的。simulation.log是逐行记录的原始事件日志把请求行抽出来就能算出每次运行的分位数存进时序库之后版本间的劣化一目了然。import collections, json, sys def parse(path): # 3.0 的 simulation.log 是制表符分隔的文本先打印一行确认列序再写解析 # 典型列序REQUEST / 场景名 / 用户ID / 分组 / 请求名 / 起始ts / 结束ts / 状态 buckets collections.defaultdict(list) fails collections.Counter() with open(path, encodingutf-8, errorsignore) as f: for line in f: if not line.startswith(REQUEST\t): continue cols line.rstrip(\n).split(\t) if len(cols) 8: continue name, start, end, status cols[4], int(cols[5]), int(cols[6]), cols[7] buckets[name].append(end - start) if status ! OK: fails[name] 1 out {} for name, cost in buckets.items(): cost.sort() n len(cost) out[name] { count: n, p50: cost[int(n * 0.50)], p95: cost[int(n * 0.95)], p99: cost[min(int(n * 0.99), n - 1)], fail: fails[name], } return out if __name__ __main__: print(json.dumps(parse(sys.argv[1]), ensure_asciiFalse, indent2))列序在不同小版本之间可能存在差异所以脚本里先判断前缀再切分跑之前手动head一行确认最保险。算出来的 JSON 直接推到监控系统配上版本号标签就能做「这次发布相比上个版本 p95 涨了多少」的对比。我现在每次改完脚本都会先用 1 个用户跑一遍完整链路把simulation.log拉出来人工看几条请求的耗时和状态确认数据是真的落库了、token 是真的传下去了再上量跑正式模型。这个习惯救过我几次——有一次是下单接口返回 200 但订单状态是「待支付」业务上等于没成功如果直接上量跑报告会漂亮得离谱结论却是错的。希望帮到你。本文还有配套的精品资源点击获取