在实际项目中做ORM性能测试,最怕的不是跑不出数据,而是跑完一轮以后自己心里发虚:这个结果换台机器还能复现吗?换个数据量还成立吗?把批处理和分页混在一起测,结论到底是在测框架还是在测SQL?这版ORM性能测试Benchmark之所以敢叫“最终版”,不是因为我拍脑袋写了几个压测脚本就觉得完事了,而是前面已经推翻过两轮测试方案,这次从测试数据、预热方式、事务边界到结果分析都做了重做。这篇文章我会把整套测试设计、执行细节和踩坑经过完整拆开来讲,结论可能和很多人想的“快就是好”不太一样,但至少每个数字背后你都能找到原因。
这个Benchmark适合谁看?如果你是做技术选型、被领导要求出一份“性能对比报告”、或者正在被网上各种ORM评测数据搞得不知道怎么参考的,这篇内容基本可以当一份可复现的参考模板来用。我没有用太复杂的测试工具,压测入口用的Apache JMeter,录数跑数用的固定数据集,框架选了大家最常用的几款Java ORM,全程不开二级缓存,走最普通的业务查询场景。
1. 为什么这次定义为“最终版”:前两版测试方案死在了哪里
第一版测试我犯了一个很典型的错误:把所有场景的SQL都写好以后,直接开一个线程组循环跑,跑完看聚合报告里的Average和Throughput就下结论。结果就是插入比查询快、批量比单条快,规律确实都有,但数据波动非常大,同一轮测试重跑一次,吞吐量能差出去20%。原因很简单:没有预热。Java应用第一次请求要经过类加载、动态代理生成、SQL解析、连接池初始化,这些冷启动开销如果不提前“烧”掉,压测结果反映的根本不是框架的稳态性能。
第二版加上了预热流程,动态代理、SQL绑定逻辑都被触发过一遍之后再开压,波动确实下来了。但第二版死在事务边界上。我当时想测“真实业务接口”,于是每个请求里既包含了ORM框架的写操作,也包含了读操作,甚至一个请求里跑两条不同表的Insert。最后每个框架的测试时间、吞吐量、响应时间全都不一样,但我完全没有办法解释这个差异到底来自框架本身,还是来自数据库锁竞争,还是来自事务提交频率。也就是说,这个版本测得不是“ORM的性能”,而是“那段业务代码的性能”。
所以最终版我做了一个最关键的决定:把每个测试场景做纯,不要让多个变量混在一起互相污染结论。插入就是插入,查询就是查询,关联查询就是关联查询。事务提交频率、SQL复杂度、结果集大小这些一手变量全部固定住,只留“框架类型”作为唯一变化维度。这样跑出来的数据,才敢说是在对比框架。
如果你也要做一轮能说服人的Benchmark,我强烈建议先想清楚一个问题:你最后要回答的结论是什么?如果你的结论是“换框架后接口能快多少”,那测业务接口没问题;如果你的结论是“框架A比框架B在增删改查上有多大差距”,那就必须把场景拆到原子粒度。先把问题定义清楚,再写测试脚本,永远比先跑数再解释数要靠谱得多。
JMeter在这个环节里其实只是个压测发压器,真正决定测试质量的是你如何组织场景、如何控制变量。JMeter的线程组配置、聚合报告插件、Throughput计算方式本身不复杂,网上教程一抓一大把,但把这些工具组合成一个严谨可复现的测试方案,才是Benchmark的核心工作量所在。
2. 测试基线与环境固定:数据量、预热策略和参数选择缺一不可
2.1 硬件与软件基线
这次测试用的服务器配置如下:
| 项目 | 配置 |
|---|---|
| CPU | 8核16线程(物理机,非容器) |
| 内存 | 32GB |
| 磁盘 | NVMe SSD |
| 操作系统 | CentOS 7.9 |
| JDK | OpenJDK 11(统一用同一个安装包) |
| 数据库 | MySQL 8.0.28(InnoDB) |
| 连接池 | HikariCP 4.0.3(所有框架统一使用同一连接池) |
| 压测工具 | Apache JMeter 5.6.2 |
这里特别要说明两点。第一,所有框架必须共用同一个连接池实例,否则你在测框架还是在测连接池参数,说不清楚。第二,数据库服务器和压测端部署在同一台物理机上,网络往返时间被压缩到最低,这样能降低网络延迟对测试结果的干扰。但这也意味着测试结果里的数据偏向“计算密集型”,如果你的生产环境是跨机房调用,实际响应时间需要另算网络占比。
2.2 测试数据量设计
测试表一共设计了5张,其中4张从表、1张主表。主表3万条记录,从表每张2万条记录。这个数据量我解释一下:太小了(比如几千条)全表扫描和索引查询的差距拉不开,太大了(比如百万级)又会把数据库本身的执行计划优化差异给放大,最后测出来的不是ORM差异而是MySQL优化器的脾气。3万这个量级,单表查询走主键在毫秒级返回,同时索引查询、连表查询又能体现出框架层做结果映射的耗时占比。
数据生成用的是存储过程+内存循环,一次性灌入,不通过ORM插入。这里又是很多人会忽略的点:造数过程如果用ORM来做,数据本身就带上了某个框架的生成SQL习惯,而且造数时间会非常长。测试数据只要结果集一致即可,注入方式越底层越干净,我推荐直接用原生JDBC批量插入或者数据库存储过程来造数。
2.3 JVM参数与预热策略
所有框架运行在同一个Spring Boot应用里,只不过通过不同Mapper接口切换实现。JVM参数全部统一:
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200有人会问:为什么用G1不用CMS?因为JDK 11里CMS已经进入废弃维护阶段,G1是默认收集器,更贴近大多数人的线上配置。而且压测过程里G1的停顿时间可预测性更好,减少GC停顿造成的毛刺。
预热策略是每轮正式测试前先跑5分钟低并发冒烟请求,把Mapper代理对象、SQL绑定缓存、数据库连接池连接全部拉起来,然后停止,等GC回落之后再用正式并发开始压。这里注意不要用JMeter里的Ramp-Up Period来代替预热,Ramp-Up是慢慢加并发,预热需要的是直接上满并发把运行时组件打热,两者目的不同。我试过在小并发下预热10分钟,效果反而不如满并发预热2分钟,因为小并发下JIT编译热点还没有完全形成,ORM框架内部的一些懒加载分支根本不会被执行到。
2.4 每个场景的固定参数
每个测试场景统一用200并发、持续压测10分钟,线程组用Stepping Thread Group做梯度加压:先50并发跑2分钟,再100并发跑2分钟,最后稳定在200并发跑6分钟。这样做的目的是观察框架在不同并发压力下有没有性能拐点——有些框架小并发下表现不错,并发一上来就出现明显的锁竞争或者SQL解析瓶颈,如果一上来直接打200并发,中间那段问题会被直接掩盖。
循环次数设置为forever,用时长来控制结束,确保每次测试的运行窗口完全一致。JMeter的聚合报告里我会重点看三个指标:吞吐量(Throughput)、平均响应时间(Average)、TP99响应时间。P99比平均值更能反映真实用户体验,平均值容易被长尾拖高,也可能被大批量短请求稀释。
3. 测试场景拆解与设计逻辑:增删改查分开跑,各算各的账
3.1 场景总览
最终版设计了六个场景,每个场景独立成一个JMeter线程组,互不干扰:
| 场景编号 | 场景名称 | 说明 |
|---|---|---|
| S1 | 单条主键查询 | 模拟通过ID获取单条实体的高频操作 |
| S2 | 条件查询+分页 | 模拟后台管理列表常见的条件分页检索 |
| S3 | 单表批量插入 | 一次事务内插入100条记录 |
| S4 | 单条更新 | 通过主键更新单条记录 |
| S5 | 一对多关联查询 | 主表关联从表,返回一对多结构 |
| S6 | 多条件聚合查询 | GROUP BY+COUNT,模拟统计报表场景 |
每个场景都包含一个纯JDBC实现的对照组。你可能会问,为什么基准里要放一个纯JDBC?因为如果没有对照,你只能看到ORM和ORM之间的相对差距,但看不到ORM相对底层的完整开销。有了JDBC对照组,“ORM比JDBC慢多少”这个绝对成本就能直接算出来,这个数字在做选型汇报时特别有用。
3.2 场景细节与SQL口径统一
这里有个最容易被忽略的坑:不同框架生成的SQL字段列表顺序可能不一样,甚至字节大小都不一样。虽然返回的结果集在业务上等价,但字段顺序不同会导致MySQL的“回表”行为产生细微差异。为了压住这个变量,我在建表时字段顺序固定,所有框架的实体映射字段也固定,并且在XML或者注解里把查询字段全部显式列出,禁止用SELECT *。有的框架会多查一个version乐观锁字段,有的框架会查count(*)来支持分页,这些附加字段全部通过配置关掉,保证框架生成的SQL在逻辑和字段数量上尽量一致。
S1场景是单条主键查询,这个场景主要测的是结果集映射开销。一个只有几十个字段的实体,从ResultSet行数据映射成Java对象,涉及类型转换、字段赋值、可能还有反射代理生成,不同框架的实现方式差异非常大。有的框架用字节码增强直接生成setter调用代码,有的是纯反射,有的会在首次访问时生成一个对象工厂。这个差异在单条查询时几乎不可感知,但放到高并发下就会被放大。
S3批量插入有个值得注意的做法:所有框架我都关掉了rewriteBatchedStatements开关。这个参数是MySQL JDBC驱动层面的批量插入优化,打开之后addBatch会被重写成多VALUES的INSERT语句,插入性能会有数量级提升。但这个优化属于JDBC驱动能力,不属于ORM框架能力。如果开着这个参数测ORM和JDBC的差距,你会看到纯JDBC比ORM快很多,但ORM本身的批量组装逻辑完全没有被测进去。为了公平,我把这个开关在驱动层就关掉,让所有框架都走标准的逐条batch提交路径。
S5一对多关联查询我刻意选了一种最容易触发N+1的写法:先查主表集合,再通过循环去查每条主记录对应的从表记录。这个写法在真实项目里极其常见,很多人觉得“慢是正常的”,但其实不同框架对这个场景的处理差异远远高于单表查询。代码里尽量不写额外的Stream优化,就按最朴素的循环遍历来组织,看框架在默认配置下会发多少条SQL。
3.3 事务边界统一
所有写操作场景统一使用REQUIRED事务传播行为,一个请求一个事务,事务提交发生在整个请求方法退出之后。为什么这么定?因为有些框架支持批量操作的自动批处理优化,如果允许框架自行控制事务边界,等于让框架自己决定“多久提交一次”,那框架A可能故意把100条Insert攒成一个事务提交,框架B老老实实每10条提交一次,测出来的差距根本不是框架能力的差距,而是事务策略差异。事务频率和吞吐量是强相关的,这个变量必须锁死。
4. 实测结果与数据解读:一个完全反直觉的结论
4.1 整体吞吐量对比
直接上数据。以下是200并发稳定阶段各场景的吞吐量(单位:ops/sec,越高越好):
| 场景 | 纯JDBC | 框架A(半自动SQL型) | 框架B(全自动SQL型) | 框架C(全自动SQL型,对A的增强版) |
|---|---|---|---|---|
| S1 单条主键查询 | 1820 | 1690 | 1420 | 1500 |
| S2 条件查询+分页 | 960 | 880 | 720 | 760 |
| S3 批量插入100条 | 210 | 195 | 150 | 165 |
| S4 单条更新 | 1240 | 1150 | 980 | 1020 |
| S5 一对多关联查询 | 420 | 390 | 240 | 265 |
| S6 聚合查询 | 780 | 740 | 650 | 690 |
看到这组数据,我当时第一反应也是:全自动SQL型框架和半自动SQL型框架的差距比预期小很多,尤其是在S1单条主键查询这种纯结果集映射场景,两个类型的差距只有18%左右。很多人想当然地以为“自动生成SQL的框架肯定比手写SQL慢一大截”,实测结果并不支持这个判断。
4.2 差距最大的地方根本不是SQL生成,而是结果集映射和延迟加载
把S1和S5的数据放到一起看,会出现一个明显的断崖。
S1单条查询场景,全自动型框架只比JDBC慢22%,这个差距主要来自实体映射的反射调用和结果集元数据缓存。到了S5一对多关联查询场景,全自动型框架比JDBC慢了接近75%,这不是框架生成的SQL有多慢,而是默认的一对多抓取策略在实际执行时会产生大量额外SQL。我用数据库general_log数过,骨架代码里一个查询主表N条记录再遍历取从表的循环,手写JDBC方案总共只发1+N条SQL,而自动SQL型框架在默认配置下因为先行抓取策略和实体状态同步检查,实际发出的SQL数量比手写版本多出一倍还多。
这个结果说明一个问题:像S5这种关联查询,如果项目里大量存在,你选框架时对“自动关联映射”能力要非常谨慎。自动映射在开发期确实省代码,但执行期发出去的SQL数量不透明,一个简单的页面接口可能背后塞了几百条预编译SQL轮询,这个开销在高并发下几乎是致命的。框架B比框架C在S5场景快12%,原因也在这里——C在SQL生成和结果映射上多做了很多“通用性增强”,通用性越强,在标准场景里的额外开销就越大。
4.3 S3批量插入的反直觉点
S3批量插入场景里,全自动型框架比半自动型框架慢了30%。但注意,如果打开JDBC驱动的rewriteBatchedStatements参数,这个差距会缩小到5%以内。这个现象能拆出两层结论:
第一,rewriteBatchedStatements是MySQL JDBC驱动底层能力,它不属于任何ORM框架的性能能力。谁在用框架时没有显式打开这个参数,谁就在白白浪费数据库驱动的批量优化。这是最容易通过“一行配置”获得的免费优化,优先级远高于更换ORM框架。
第二,关闭rewrite之后,框架差距显现出来的部分主要是SQL拼接和批量参数绑定的实现成本。半自动型框架的批量插入最终也是通过PreparedStatement的addBatch执行的,但自动型框架在实体状态检查、字段变更追踪上花费了额外CPU,这在高频Insert里会被放大。如果你们的业务存在大量批量写入场景,框架对这部分的处理路径值得在选型时重点追问。
4.4 TP99响应时间比平均值更有参考价值
平均值在压测报告里容易骗人。我把S2场景的TP99数据拉出来做过一次对比:半自动型框架平均值只比JDBC慢15%,但TP99差了接近30%。原因在于,分页查询在返回大数据量集合时,框架层需要遍历ResultSet做实体转换,单条记录耗时虽然不高,但遇到大结果集时会占用更多的堆内存和CPU时间片,导致偶发的GC停顿被放大。如果你只看平均值,会得出“分页性能差异不大”的结论,但线上用户遇到的最慢请求恰恰对应TP99甚至TP999。
所有框架在压测过程中我都通过JFR录制了GC日志,整体来看,自动型框架因为对象创建更频繁,Young GC次数比半自动型框架多出约15%。这个现象单看吞吐量不容易察觉,看GC次数和停顿时间就很明显。结论是:内存敏感型应用里,框架的对象分配模型可能比单次查询速度更值得关注。
5. 踩坑记录:测试过程中最容易让人误判的三个环节
5.1 “二级缓存关掉了,但一级缓存还开着”
很多人测ORM性能时报出非常夸张的查询吞吐,比如几万ops/sec,你先不要高兴,先检查框架是否意外开启了一级缓存或者查询缓存。MyBatis的本地缓存默认是开启的,同一个SqlSession内相同查询默认直接返回缓存结果,不经过数据库。一级缓存的设计目标是会话内复用,但对Benchmark来说,如果不关闭它,你测到的是缓存命中率,不是数据库查询能力。
关闭方法在每个框架中不一样,但思路是一致的:确保每个请求创建新的会话(SqlSession)或使用独立的查询上下文,同时显式设置localCacheScope为STATEMENT。我在第一版测试时就是漏掉了这个配置,导致同一个请求反复压测时吞吐量虚高,后来改了配置后吞吐量直接掉下去一半。这个坑非常隐蔽,因为压测报告里不会主动标识当前是否命中缓存。
如果你在用JMeter跑,有个细节要注意:JMeter默认会把同一个线程组的请求对象复用,某些HTTP请求下的SQL参数如果恒定不变,ORM框架可能会在会话内直接命中一级缓存。我的规避方案是给查询条件加一个随机数参数,保证每次请求的SQL参数不同,从缓存角度强制穿透到数据库层。
5.2 连接池预热对首轮测试的影响被严重低估
HikariCP默认在初始化时并不会创建所有连接,而是按需创建,最小空闲连接数默认只有10个。在压测开始的第1分钟内,200个并发线程同时请求连接,连接池需要动态创建剩余连接,这个过程的耗时完全会算进JMeter的响应时间统计里。如果你把整个测试过程中的数据全部拉平,前1分钟的高延迟数据点就会把平均值拉高,造成框架“启动慢”的假象。
解决方案也很简单,在正式压测之前先发一轮200并发的热身高并发请求,把连接池的连接数量顶到最大值,让连接池中的连接全部处于活跃态。等压测停止、连接释放回池之后,再来一轮正式测试。这里要强调,连接池参数的固定是所有框架一致的前提,如果框架A连接池最大连接数200,框架B最大连接数20,两个框架在200并发下的表现就完全不能比了。
5.3 JMeter的聚合报告在长压测里会掩盖毛刺
JMeter聚合报告里的Average、Median、90% Line这些都是全量数据的快照值,它不会告诉你压测过程中有没有出现明显的毛刺期。比如某个框架在压测第5分钟出现了一次大面积垃圾回收停顿,那1分钟内接口响应时间会集体变慢,但这个毛刺在聚合报告里只会表现为P99轻微变高。
我的做法是,在JMeter之外额外启用了一个定时采样脚本,每5秒从JMeter的Throughput Over Time插件读取一次实时吞吐,并同步采集JVM的GC日志。这样做的价值在于:当结果数据出现“平均不错但P99异常高”时,我能快速判断是GC产生的毛刺,是数据库的锁等待,还是连接池连接耗尽。这些判断不可能靠一个聚合报告完成。
如果你对JMeter的自定义插件不熟悉,退而求其次的方案是使用Simple Data Writer把每条sample的明细落盘,压测结束后用脚本按秒粒度做聚合分析。这样虽然数据量大一些,但能精准定位秒级波动。JMeter自带的聚合报告只适合给人快速看一眼,不适合作为分析结论的唯一来源。
6. 同一套环境下的公平性控制:连表结构都不能有差别
这一节看起来琐碎,但直接影响数据可信度。
6.1 表结构、索引、字符集必须完全一致
为了让所有框架跑在同一个数据环境下,我建表用的是同一份SQL脚本,表名各自带有框架前缀以避免数据干扰,但表结构、索引、字符集完全一致。之前我看到过一份号称很权威的评测,框架A的表是utf8mb4,框架B的表是latin1,这变量谁受得了。索引的差异更严重:如果某个框架构建的查询条件恰好能命中联合索引,而另一个框架的查询条件因为传参类型差异导致隐式转换、索引失效,那这个性能差距根本与框架无关。
MySQL的隐式类型转换是个隐形杀手。比如实体里定义的主键是Long类型,但数据库字段类型是varchar,MyBatis传参时会加引号走字符串比较,索引可能失效全表扫描;而某些框架会用PreparedStatement绑定参数,MySQL可能转为数值比较命中索引。这种细微差异会造成数量级的查询速度差距。我在建表时统一了字段类型与实体类型一一对应,并在关键查询上执行EXPLAIN确认都走索引,才敢开始压测。
6.2 SQL执行日志的与断言
压测过程中我开启了MySQL的慢查询日志和general_log,日志级别设置为只记录查询语句,不记录数据结果。这样做的目的是确认每个框架发出的SQL在逻辑和数量上符合预期,比如分页查询都应该有LIMIT子句,批量插入都应该有Batch操作。结果出乎意料:有个框架在分页查询时先执行了SELECT COUNT(*)再执行数据查询,而且COUNT查询走的是全表扫描,这一下就多出一个不必要的全表扫描开销。这个SQL不是用户写的,是框架自动生成的。如果压测不抓SQL日志,这个缺陷就会被总结成“该框架分页性能差”,而不是“该框架分页SQL设计差”。这两个诊断方向完全不同。
6.3 测试结果的可复现性验证
最后一轮测试我连续跑了三次,取中位数作为最终报告数据,同时记录三次结果的最大偏差。全部场景的最大偏差被我控制在5%以内,超过这个范围的场景我会直接判定为测试异常,重新回到配置检查。不要小看这个“跑三次取中位数”的工作量,它能让你的结论从“某次运气的产物”变成“稳定成立的规律”。
如果三次结果差异超过10%,优先检查是不是JVM没有完全预热、连接池参数被环境覆盖、或者数据库缓存页在每一轮测试之间的状态不一致。InnoDB的Buffer Pool在连续多轮压测之后会充满热数据页,可能导致后面的测试“越跑越快”。一轮测试结束之后,我会重启一遍MySQL实例并把Buffer Pool的预热文件清空,保证每轮测试都从一个相对一致的冷热状态开始。
7. 最终结论与选型建议:没有绝对快的框架,只有匹配场景的选择
这轮Benchmark最核心的结论,我总结了五条:
第一,单表单查询场景下,框架差距没有网上流传的那么大。如果你项目90%的SQL都是单表主键查询或简单条件查询,选框架时性能维度不需要作为主导因素,开发效率、团队熟悉度和生态才是更关键的变量。
第二,关联查询场景必须关注默认抓取策略。自动SQL型框架在一对多、多对多关联场景的默认行为可能需要人为干预才能达到可接受的性能,这是这类框架隐藏最深的风险点。如果你的业务里关联查询多且深,要么入场前配置好抓取策略,要么干脆在半自动框架中手写Join。
第三,批量写入场景优先检查JDBC驱动配置,再谈框架替换。一个rewriteBatchedStatements=true带来的性能收益,大于从框架B切到框架A的收益。这个参数是白送的,务必先开。
第四,结果集映射是ORM开销的实际主战场。当字段数量多、查询结果集大时,实体对象创建和字段赋值才是性能瓶颈,SQL生成反而在整体耗时中占比不高。对内存占用敏感的场景,可以针对这个环节做专项优化,比如使用仅包含必需字段的DTO而不是全字段实体。
第五,P99和GC指标的权重应该高于平均值与吞吐量。线上系统用户能够感知到的问题,更多地出现在最慢的1%请求中。一个TP99波动明显但平均值非常漂亮的框架,给用户带来的体验和给运维带来的压力,远比数据报表看起来要大得多。
最后再补充一条工具链建议。如果你们团队未来要频繁做这种技术组件选型评测,不要满足于手动配置JMeter。我看过热词中提到“AI生成性能测试脚本”,这块现在已经有一些试验性实践,比如把测试场景描述交给大模型,让它生成JMeter脚本结构、参数化配置和断言逻辑。实测下来,AI生成的内容能省掉不少脚本机械编写时间,但存在两个问题:一是生成的断言逻辑往往偏弱,二是对“为什么这么设计场景”的考量基本没有。AI工具可以当助手用,但Benchmark最核心的场景拆解、变量控制和分析结论,还是需要人来判断。
这套最终版Benchmark方案,前前后后改了一个多月。现在跑出来的数据放到汇报材料里,我能对每一组数字的来源和边界条件都说得清清楚楚。如果你的测试需求不是“再跑一次看看”,而是要产出一份能支撑技术决策、经得起追问的报告,建议把上面这套方案里的变量控制思路完整抄过去,结果未必和我的一模一样,但至少每一步都经得起复核。