
系列写到第四篇前几篇我们把JMeter的基础操作、HTTP接口脚本和参数化都过了一遍这次换个口味把压测目标从Web接口挪到数据库本身聊聊如何用JMeter直接对MySQL数据库查询做性能测试。很多人觉得JMeter只用来压HTTP接口其实它原生带的JDBC Request完全可以模拟多用户并发执行SQL查询几步就能把脚本跑起来非常适合做SQL性能验证、索引优化前后对比、以及数据库容量摸底。这篇文章会从环境准备讲起覆盖驱动配置、连接池、脚本编写、参数化、结果解读和排坑全程给可直接照抄的配置和示例适合正在学JMeter性能测试、又想在数据库层多挖两斗的读者。1. 项目概述与核心思路1.1 为什么用JMeter直接压数据库查询我先说结论接口压测遇到响应时间飙升时十有八九要往下翻一层查数据库。很多时候你压Web接口代理层、应用层、缓存层都正常偏偏在SQL查询这一步卡住了这时候如果只有HTTP压测结果很难判断瓶颈到底是应用逻辑处理慢还是数据库服务本身扛不住。直接用JMeter通过JDBC协议把SQL压一遍相当于把中间件全部绕开只看数据库的承受能力这个视角补得特别快。另外JMeter的JDBC Request不需要写代码Graphical界面上拖几个组件就能出一个可执行的压测脚本门槛比写Java程序低太多。开发、测试、DBA三方都可以快速上手在一个统一工具里复现慢SQL、评估索引效果、调整连接池参数比在命令行里反复手动执行SQL靠谱得多。对于正在系统学习性能测试的人这也算从“接口层压测”向“数据层压测”迈出的一步理解会立体很多。1.2 适合压什么、不适合压什么用JMeter压MySQL查询不是所有SQL都能无脑往上怼。先说适合的场景最常见的SELECT查询、分页列表查询、多表JOIN统计、报表汇总、以及存储过程调用只要你把SQL写好用JDBC Request一包就能模拟多个并发用户同时查询。特别适合做索引优化前后的对比实验比如加一个联合索引之前和之后TPS、响应时间、错误率会直观地反映出差别。不适合的场景也要心里有数。UPDATE、DELETE这类写操作不是不能压而是压完数据会被改掉后续结果很难对齐除非你有专门构造的测试环境和对应的数据恢复手段。DDL操作比如ALTER TABLE更是千万别拿来压锁表影响范围大很容易把测试库弄成不可用状态。还有一点如果测试表里只有几百行数据压出来的结果基本没有参考价值因为MySQL在极小数据量下任何索引都显得“快”这和真实业务场景差得很远。1.3 压测前先想清楚目标和指标我建议在打开JMeter之前先在纸上把这次压测的目标列出来。你是想证明某个SQL在并发用户数50时撑得住还是想对比新老两版索引谁更优目标不同线程组参数和结果观察点就完全不同。没有目标的压测最后只会得到一堆密密麻麻的数字很难解释给团队听。常用到的指标大概有四类线程模型、响应时间、吞吐量、错误率。线程模型包括并发线程数、Ramp-Up时间、持续运行时间或循环次数响应时间要关注平均响应时间、中位数、90%分位和99%分位不要只盯着平均值吞吐量通常看每秒事务数TPS或每秒请求数QPS错误率一般要求为0允许少量错误时要结合错误类型分析。除了JMeter侧指标MySQL自身的连接数、慢查询日志、CPU和磁盘IO也要同步观察单看一边容易误判。2. 环境准备与基础配置2.1 装好JMeter、JDK和MySQL驱动JMeter本身是Java应用所以先确认机器上装了JDK。当前JMeter 5.x版本一般要求JDK 8以上建议直接用JDK 11或17兼容性更好。从Apache JMeter官网下载二进制压缩包解压到指定目录Windows下运行bin/jmeter.batLinux下运行bin/jmeter脚本就能打开图形界面。如果你只是用命令行压测写好了JMX脚本后直接执行jmeter -n -t script.jmx -l result.jtl更快。MySQL驱动这一步是大多数人第一次接触JMeter压数据库时最容易卡住的地方。JMeter不会自带任何数据库驱动必须手工放进去。以MySQL 8.0为例驱动包名字通常是mysql-connector-j-8.0.x.jar下载后拷贝到JMeter的lib目录下重启JMeter才能生效。老一点的MySQL版本可能需要使用mysql-connector-java-5.1.x.jar对应的驱动类名也会不一样这个后面详细说。检查驱动是否正确加载的方法很简单在JMeter安装目录的lib下能看到jar包文件然后在JDBC Request里填对Driver Class不再报ClassNotFoundException就说明没问题。2.2 JDBC Connection Configuration核心参数逐个过一遍有了驱动下一步是配置数据库连接池参数。在测试计划上右键选择“添加 - 配置元件 - JDBC Connection Configuration”这里的内容几乎每一个都需要理解清楚。Variable Name for created pool是最关键的一个它相当于给连接池起了个名字后面JDBC Request要通过这个名字找到对应的连接池。比如填mysql_pool那JDBC Request里的“Variable Name of pool declared in JDBC Connection Configuration”也必须填mysql_pool两边对不上就会报找不到连接池。Database URL的格式要注意一般长这样jdbc:mysql://192.168.1.100:3306/testdb?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai其中testdb是数据库名useSSLfalse是关闭SSL校验减少压测时握手开销allowPublicKeyRetrievaltrue是MySQL 8.0在非SSL连接下允许获取公钥不写有时候会报连接失败serverTimezone用来解决时区偏差问题。JDBC Driver Class要按MySQL版本填。MySQL 8.0及以上填com.mysql.cj.jdbc.Driver旧版本MySQL填com.mysql.jdbc.Driver。Username和Password填数据库账号记得这个账号要有足够的权限执行你要压的SQL。连接池参数里Pool Max默认是10很多人在压测时明明开了50个线程结果报连接超时原因就在这里。JMeter中的线程会共享连接池如果池子里只有10个连接而50个线程同时去拿连接剩下的就得排队。线程数较大时建议把Pool Max设置为线程数或者线程数的1.2倍同时也要确保MySQL侧max_connections允许这么多连接。其他参数比如Timeout默认10000毫秒表示等待连接的超时时间版本不同名称可能稍有差异但逻辑一样。2.3 测试数据准备不要拿空表压测我接手过很多“压测数据库查询没效果”的案例最后发现测试表只有几千行数据MySQL二话不说直接全表扫描也很快索引有没有根本看不出来。准备测试数据一定要贴近真实业务量级至少是十万、百万级起步。比如模拟一个用户订单表可以按下面的方式建表和造数。CREATE TABLE user_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ) ENGINEInnoDB; ALTER TABLE user_order ADD INDEX idx_user_time (user_id, create_time);造数可以用存储过程循环插入也可以用MySQL的递归CTE生成只要能达到目标数据量。例如先插入100万行订单记录让user_id在一定范围内随机分布这样查询条件WHERE user_id ?会产生不同的命中结果更接近真实业务。要注意的是造数过程本身很耗时一次性插入大批量数据还会增长binlog建议分批提交。压测前最好用ANALYZE TABLE user_order;更新统计信息帮助优化器生成正确的执行计划。3. 脚本编写完整流程3.1 认识JMeter脚本结构测试计划到JDBC RequestJMeter脚本是一个树形结构从测试计划开始一级一级往下挂组件。压测MySQL查询的典型结构大致是这样的测试计划 └── 线程组 ├── JDBC Connection Configuration配置元件 ├── JDBC Request取样器 ├── 响应断言断言 └── 聚合报告监听器线程组负责定义并发模型JDBC Connection Configuration负责提供数据库连接池JDBC Request负责执行SQL响应断言用来判断结果是否符合预期聚合报告汇总压测结果。这个顺序看起来很常规但有两点容易踩坑。一个是配置元件和取样器的作用域。JMeter沿用了“树形作用域”的规则如果你把JDBC Connection Configuration放在线程组下面那么只有这个线程组里的JDBC Request能使用它如果你放在测试计划下面所有线程组都能共享。另一个是Variable Name必须全局唯一不能有两个同名连接池。如果测试计划里有多个数据库压测场景一定要给每个连接池取不同的名字否则后一个配置会覆盖前一个导致查询执行时找不到正确的连接池。线程组的参数也值得多说一句。一般在压测数据库查询时线程数模拟的是并发连接用户数Ramp-Up时间表示这些线程在多长时间内全部启动循环次数和持续时间二选一。想跑固定时长的推荐勾选“持续时间”比如填120秒循环次数选“永远”这样压测过程不会因为某些线程提前执行完循环而波动想跑固定请求数则用循环次数控制。3.2 JDBC Request的Query Type和SQL写法在线程组上添加“取样器 - JDBC Request”这是执行SQL的入口。JDBC Request界面里有一个Query Type下拉框选项包括Select Statement、Update Statement、Callable Statement等。做查询性能测试时选Select Statementcommit模式通常保持默认即可如果你要通过JDBC调用存储过程选择Callable StatementSQL框里写CALL procedure_name(?)。SQL的写法有两种常见形式。第一种是直接把参数值写死在SQL里比如SELECT * FROM user_order WHERE user_id 123适合快速验证单条SQL的执行计划但不适合压测因为所有请求都查同一条数据数据库缓存会把真实成本掩盖掉。第二种是使用变量占位比如SELECT * FROM user_order WHERE user_id ${uid}运行时从JMeter变量里取uid。还有一种是JDBC Request自带的Parameter Values和Parameter Types功能SQL里写SELECT * FROM user_order WHERE user_id ?然后在Parameter Values里填变量名或引用Parameter Types里指定对应的JDBC类型如BIGINT这更接近PreparedStatement的方式能利用MySQL预编译特性减少SQL解析开销。这里还要特别注意SQL语句结尾不要带分号。JMeter发到MySQL的SQL会原样执行带分号在大多数情况下没问题但如果有多条SQL同时写在一个JDBC Request里就很容易报语法错误或只执行最后一条。一条JDBC Request只放一条SQL多条查询场景拆成多个JDBC Request组件维护起来更清晰。3.3 参数化查询防止每次命中同一条缓存前面已经提过压测查询最忌讳SQL完全相同。如果每个线程都执行SELECT * FROM user_order WHERE user_id 123第一次查完后数据页就进了InnoDB Buffer Pool后面所有请求几乎都是内存命中根本测不出磁盘或复杂查询的真实性能。参数化就是把查询条件变成随机值或按顺序变化的业务值让每次SQL更接近真实用户行为。最简单的参数化方式是使用JMeter函数。比如下面这个SQLSELECT * FROM user_order WHERE user_id ${__Random(1, 1000000, uid)} AND status 1 ORDER BY create_time DESC LIMIT 20__Random函数每次调用时会生成1到1000000之间的随机整数第三个参数uid是把生成值存入变量名后续还可以用${uid}继续引用。除了随机函数也可以用CSV Data Set Config从文件里读取一批真实user_id更贴近业务分布。压测时如果发现用户ID集中在某几个值数据分布不均匀查询结果的参考价值也会打折扣。参数化还会带来一个中文乱码的隐藏问题。JMeter默认读取脚本和CSV时可能使用系统默认编码如果SQL的WHERE条件里包含中文关键字容易出现乱码导致查询结果和预期不一致。建议在JMeter的bin目录下修改jmeter.properties里的sampleresult.default.encodingUTF-8同时把CSV文件另存为UTF-8格式。3.4 断言与监听器配置别在压测时开着结果树数据库压测脚本里断言往往被忽略但这个组件能帮你快速过滤掉“查询语法正确但结果异常”的请求。最简单的做法是在JDBC Request下加一个“响应断言”选择一个结果字段或业务标识做包含匹配。比如SQL查询用户的订单断言字段中必须出现订单号前缀如果结果为空或返回了错误数据断言就会失败在聚合报告里体现为错误。需要说明的是JMeter的JDBC Request本身在SQL执行异常时会直接置为红色失败样本所以断言主要是为了业务级校验不是必须的。监听器方面要分阶段使用。调试脚本时可以用“查看结果树”看返回结果和SQL错误信息但压测期间千万不要开着它尤其线程数大时它会把所有请求的响应数据留在内存里导致JMeter自身发生GC结果严重失真。压测时推荐只加“聚合报告”或者用命令行压测最后再用一个简单的解析工具看.jtl日志。如果需要实时监控可以加“后端监听器”把结果发送到Grafana或InfluxDB不过这套方案配置成本高一些新手可以先用聚合报告。4. 一次完整压测实战模拟并发查询用户订单4.1 场景设计与脚本配置示例我拿一个实际做过的场景来演示。假设业务是一个电商系统线上有个用户订单查询接口压测时发现接口的P95响应时间偏高DBA怀疑是底层SQL的问题。为了验证数据库层情况我直接用JMeter对user_order表做查询压测SQL为SELECT id, user_id, order_no, amount, status, create_time FROM user_order WHERE user_id ? AND status 1 ORDER BY create_time DESC LIMIT 20表里造了500万条订单数据user_id上已经有联合索引idx_user_time(user_id, create_time)业务上每次查询固定取某一用户最近20笔订单。线程组设置如下线程数50Ramp-Up 10秒持续运行120秒循环次数选永远。JDBC Connection Configuration里的Pool Max设成60比线程数略大避免线程等连接池释放Timeout保持10000毫秒。JDBC Request使用参数化user_id从__Random(1, 2000000)函数中取值变量名为uid。接着加一个聚合报告确认没开查看结果树然后保存成mysql_query_test.jmx。场景设计看起来很普通但有几个细节是我专门调整过的。Ramp-Up设为10秒而不是0秒是为了模拟用户逐步进入系统的过程避免所有线程在同一瞬间把数据库连接队列打满虽然这是压力测试中合理的“瞬间冲击”但首次跑预检时最好温和一点先看是否有明显报错。LIMIT 20不能省因为真实接口一定是分页的去掉LIMIT会导致SQL返回大量行压出来的结果反而不能代表线上情况。4.2 执行压测同时盯着数据库脚本准备完毕后有两种执行方式。如果只想看个大概直接在JMeter图形界面点击绿色启动按钮观察聚合报告数字跳动。但我更推荐用命令行执行尤其是线程数较多时图形界面的渲染和多线程交互会占用额外CPU干扰压测结果。命令大致是jmeter -n -t mysql_query_test.jmx -l result.jtl -e -o report-n表示非GUI模式-t指定测试脚本-l输出原始结果日志-e和-o用来生成HTML报表到report目录。压测过程中可以另开一个终端连接MySQL实时观察数据库侧的状态。比如查看当前连接数mysql -h127.0.0.1 -uroot -p -e SHOW GLOBAL STATUS LIKE Threads_connected;这个命令能告诉我们JMeter跑起来后MySQL上实际建立的连接数变化。如果Threads_connected一直逼近MySQL的max_connections说明连接池配置或数据库连接上限需要调整。还可以在压测期间抓取正在执行的SQLmysql -h127.0.0.1 -uroot -p -e SHOW PROCESSLIST;观察是否有大量SQL处于Locked或Sending data状态来判断锁等待和全表扫描的情况。如果有慢查询日志也可以把long_query_time设成1秒压测结束后看哪些SQL被记录基本就能定位到需要优化的查询。4.3 结果解读响应时间、TPS和错误率应该怎么看压测结束后我习惯先看聚合报告里的五类核心字段Samples、Average、90% Line、Error %、Throughput。比如一个接近线下的结果可能长这样Label # Samples Average 90% Line Error % Throughput user_order_query 60000 85ms 146ms 0.00% 498.3/sec这里Samples是总请求数Average是平均响应时间85毫秒90% Line是146毫秒意思是90%的请求在146毫秒内完成Error %为0吞吐量约每秒500次。如果是在加了组合索引之后跑出来的结果我会再和加索引之前的基线对比。所谓基线就是同一个脚本、同样的数据量、同样的线程数在优化前先跑一轮记录结果优化后跑第二轮两轮只改一个变量。假如优化前平均响应时间238毫秒90% Line 480毫秒优化后变成85毫秒和146毫秒那这个结论拿到评审会上就很有说服力。结果解读时不能只看成功率和平均值还要看响应时间分布是否平稳。如果平均响应时间只有100毫秒但99% Line飙到3000毫秒说明存在明显的长尾延迟数据库可能在某一时段发生了资源争用或锁等待。遇到这种情况我会继续去看MySQL的慢查询日志和SHOW ENGINE INNODB STATUS把问题定位到具体SQL和执行计划上。同时压测过程中的错误率一旦大于0就要立刻看错误类型连接超时、驱动返回空结果、事务超时对应的问题解法完全不同。5. 常见问题与排查技巧实录5.1 加载不到MySQL驱动类多半是Jar包位置不对JMeter压数据库最常见的报错就是Cannot load JDBC driver class com.mysql.cj.jdbc.Driver。这个错误90%的情况是因为MySQL驱动jar包没有放进JMeter的lib目录。注意有些版本JMeter支持在测试计划里添加jar包但我还是建议直接用文件拷贝方式最省事、最不容易因为测试计划迁移导致驱动丢失。放进lib后必须重启JMeter因为JMeter启动时才扫描驱动类。如果驱动类名写错也会有同样的报错。MySQL 8.0驱动类名是com.mysql.cj.jdbc.Driver老版本的驱动类名是com.mysql.jdbc.Driver两个不能混用。还有一种情况是数据库URL写成了jdbc:mysql://localhost:3306少了数据库名会报Unknown database。这些错误信息JMeter都会在“查看结果树”里直接显示调试脚本时开着它确认问题修完再关掉。5.2 连接池等待超时MySQL连接数被压满压测过程中第二个高频报错是Connection is not available, request timed out after 10000ms。这个错误出现时第一反应是连接池不够用。如果线程数50Pool Max还是默认10那就明显不够需要把Pool Max调到接近或略大于线程数。但调大连接池不是无脑操作MySQL服务端还有一个max_connections上限默认值根据版本可能从151到数千不等如果JMeter的连接池设置超过了MySQL允许的最大连接数MySQL就会直接拒绝新连接错误信息会变成Too many connections。要检查当前MySQL允许多少连接可以执行SHOW VARIABLES LIKE max_connections;然后合理设置JMeter的Pool Max。调整连接池后还应该看压测时的Threads_connected是否稳定不要出现连接数只增不减的情况。数据库驱动和JMeter的连接释放机制偶尔也会出问题通常报错后会有一个缓慢增长的过程这时候需要检查是不是SQL执行时间太长导致每个连接都被长时间占用线程的真实并发度远高于Pool Max。5.3 压测结果忽高忽低先排除环境干扰有一类问题最让人头疼脚本没变、数据库没动但两轮压测结果差了一倍平均响应时间一会儿40毫秒一会儿200毫秒。遇到这种波动先把锅甩给“环境”通常不会错。压测机如果同时在跑UI自动化、编译任务、下载任务CPU会被抢JMeter本身开了一堆监听器内存被耗尽MySQL所在的主机上有定期备份任务、定时任务在跑都会导致结果失真。解决思路就是控制变量。压测前先看压测机和数据库服务器的CPU、内存、IO负载确保处于空闲状态跑一次“预压”作为预热让MySQL的内存缓存和数据页达到稳定状态再正式记录结果一次只改一个变量比如先只改线程数跑完再改SQL不要同时调线程组和索引。如果做了这些仍然波动那就用趋势图而不是单次结果来做判断多跑几轮看中位数和P95曲线。5.4 压MySQL查询的几条避坑清单最后整理一份我在实际项目中反复用到的避坑清单每次压数据库前都过一遍。压测前先执行EXPLAIN确认SQL的执行计划type列至少要是ref或range如果出现ALL说明全表扫描压测结果大概率不会好看。查询参数一定要参数化避免全部请求命中同一批数据页。压测的对象库要用独立测试环境不要连接生产库查询压测虽然不写数据但高并发仍会消耗大量数据库资源。压测时间不要低于60秒30秒以内的测试只能看出瞬时表现看不出稳定性问题。监听器精简到最少正式压测时关掉查看结果树。记录压测时的MySQL侧状态不只是JMeter的聚合报告两边数据一起看才能定位瓶颈。这些坑我几乎每一条都踩过。只要照着清单走一遍至少能少走很多弯路。这篇文章写到这里基本把我这几周在JMeter压MySQL查询上踩过的坑都抖出来了。我自己最深的体会是数据库压测脚本写起来比HTTP脚本门槛低但要压得准却更难驱动配置、连接池大小、数据分布、参数化方式每一个变量都会直接影响结果。对于正在读性能测试相关书的朋友建议把JMeter当成辅助验证工具和《高性能MySQL》里关于索引、执行计划的章节搭配着用看到理论再跑一轮压测理解会扎实很多。后续我还会继续更新这个系列下一期可能会写JDBC调用存储过程的性能验证或者把数据库结果和Grafana监控串起来到时候再和大家细聊。