先说结论:同一套机器、同一份数据、同一个压测脚本,原生 JDBC 一定比绝大多数 ORM 快,但快多少,完全取决于你让 ORM 帮你做了哪些事。这次 ORM 性能测试 Benchmark 我前前后后跑了五轮,中间推翻了三套方案,最后才把结果稳定到可以对外解释的程度。最终版的数据不是为了让某个框架“丢人”,而是回答一个几乎所有团队都会遇到的实际问题:我们到底该不该为了性能,把项目从 JPA/MyBatis 切到手动 SQL 更重的框架?
如果你是技术负责人、架构师,或者正在给团队做 ORM 选型调研,这份记录会非常有用。文中不会只丢一张跑分表,还会把测试环境、表结构、场景设计、统计口径、隐藏的配置差异全部摊开。因为踩过太多次“换个环境结果完全反过来”的坑,我越来越确认一件事:ORM Bench 里最难的从来不是跑代码,而是让所有人承认,你比的往往是配置、SQL 写法和模型设计,而不是框架本身。
1. 为什么是“最终版”:问题出在跑分设计本身
1.1 之前两版结果为什么没人敢用
第一版结果出来的时候,团队里第一个反应不是“哦原来 JPA 这么慢”,而是“你这数据我不信”。当时用的是 JMH,写起来很顺手,代码也短,但跑了两轮出来的方差大到离谱。JDBC 同一个查询,P99 抖动超过 30%,MyBatis 甚至有时比 JDBC 还快,有时慢三倍。问题根本不在于 CPU 或内存,而是连接池、GC、数据库连接的建立时间、系统冷热页缓存这些外部因素全混在里面。
第二次我吸取了教训,换成 Shell 脚本独立进程跑,每个框架一个进程,不共享 Spring 上下文。可还是有问题:连接池初始是懒创建的,前几百次请求全在建立新连接,日志里能看到 1 秒以上的毛刺;范围查询固定从 offset=0 开始,导致所有请求都压在 InnoDB B+ 树的同一小块数据上,缓存命中率是 100%,跟真实业务完全不像。这两版结果发出去,评审第一句话就是:你的预热去哪了?你的随机偏移呢?数据为什么看着像玩具?
所以第三版开始,我不再急着写代码,而是先写测试设计文档。把“有没有预热”“连接怎么创建”“事务边界怎么切”“query 命中的是不是热点页”“返回结果集多大”全部明确,再动手。最终版之所以叫最终版,不是因为我以后再也不测 ORM,而是这套方案反复跑了十轮,中位数的相对误差稳定在 5% 以内,至少敢拿出去当选型依据了。
1.2 最终版只回答三个问题
这次跑分不要贪多,会问自己三个问题:
第一,单表 CRUD 这种最常见的业务操作,ORM 比 JDBC 慢多少?如果只有 10%~20%,那开发效率换性能完全划算,不用焦虑。第二,批量写入和分页查询这种容易“踩雷”的场景,慢的到底是谁?是 ORM 本身,还是框架默认把很多工作做在了你看不到的地方?第三,如果我用五种框架写“等价”的功能,返回同样 JSON 数据,最终瓶颈会出现在什么环节?是 SQL 生成与结果映射,还是数据库真正执行的 SQL 次数?
带着这三个问题,就不会再出现“MyBatis 天下第一”这种结论。因为同一套业务里,不同 ORM 可能会生成不同形态的 SQL,有的是一条大 SQL,有的是几十条小 SQL;有的自动帮你 count 一遍,有的不会。这些都会在数据上产生数量级的差异,可比性就需要设计得足够细。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试设计:先把变量关进笼子再谈数据
2.1 被测对象、版本和环境怎么锁
版本不锁死,Benchmark 等于白做。比如 Hibernate 6.x 比 5.x 在很多查询路径上优化过不少;MyBatis 不同版本对批处理 Executor 的行为也有差异。最终版锁定了下面这套环境:
| 组件 | 版本/配置 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 22.04 LTS | 和生产容器同大版本 |
| CPU / 内存 | 8 核 16 线程 / 32 GB | 压测应用和数据库分开部署 |
| Java | OpenJDK 17.0.8 | 常见的服务端 LTS 版本 |
| MySQL | 8.0.33 | 生产版本,same bufffer pool 4G |
| 连接池 | HikariCP 5.0.1 | maximumPoolSize=20,minimumIdle=20 |
| JDBC 驱动 | mysql-connector-j 8.1.0 | 所有框架共用同一个驱动版本 |
被测对象一开始就定成五个:原生 JDBC 作为基线,接着是 MyBatis 3.5.13、MyBatis-Plus 3.5.3.2、Spring Data JPA(底层 Hibernate 6.2.13)、jOOQ 3.18.2。没有纳入 Ebean、QueryDSL、Exposed,是因为团队当前候选就这几个,跑分要给选型服务,不是给开源社区做排行榜。
版本选择还有个容易被忽略的地方:Spring Boot 一旦把依赖版本统一管理,MyBatis-Plus 3.5.3.2 和 Spring Data JPA 的兼容性其实并不完全一致。测试前我把所有框架都放到同一个 Spring Boot 3.1.5 工程里,分别用独立 Maven profile 打包,确保不是“A 框架运行在 Spring Boot 3.1,B 框架跑在 3.2”这种不公平条件。
2.2 表结构与数据规模别拍脑袋
测试只跑单表主键查询是看不出问题的。真实系统里最典型的是用户 + 订单这种一对多模型,所以我建了两张表。用户表 20 万行,字段包含 id、name、email、status、created_at,其中 status 上建了普通索引。订单表 300 万行,字段包含 id、user_id、order_no、amount、status、pay_time,user_id 建了普通索引,status 和 pay_time 建了联合索引。
数据量我特意选到 300 万而不是 1 万。原因很简单:如果表只有几万行,MySQL 优化器很可能直接走全表扫描也很快,ORM 生成 SQL 的差异会被数据库自身的强壮性盖住。300 万行在 4G 的 buffer pool 下,索引和部分数据能够被缓存,但也不是整表全在内存,贴合业务中“大部分热数据命中,偶尔冷数据回表”的状态。
生成数据时我用了存储过程,确保 user_id 在订单表里不是均匀分布。特意让一部分用户有几千单,一部分用户只有一两单,这样才能模拟出真实推荐场景下的倾斜。如果所有用户的订单数一样,一对多查询结果集的映射成本就没有差异,N+1 的放大效应也不真实。
2.3 8 个能反映真实接口的测试场景
场景设计是这次改进最大的一块。我最后选了 8 个,每个都尽量对应真实业务代码:
- 单条主键查询:select by id,模拟按主键拿用户详情。
- 唯一键查询:by email,模拟登录或去重逻辑。
- 范围查询返回 100 行:select * from user where status=? limit 100,对应后台列表列表接口。
- 分页查询第 10000 页:order by id limit 20 offset 199980,对应深分页问题。
- 批量插入 100 条订单:对应订单导入、结算单生成。
- 单条主键更新:update set name=? where id=?,模拟字段更新。
- 一个用户关联最近 10 条订单:测试一对多映射和 N+1。
- 聚合查询:按订单状态分组,统计金额总和、订单数,对应用报表里的饼图接口。
场景定了之后不许临时加条件。所有 ORM 都跑同一个场景,SQL 尽量等价。比如一对多场景,JPA 如果默认懒加载会发 N+1 条,明显吃亏;所以我在最终版里同时测了“JPA 默认写法”和“JPA 改写 join fetch 写法”两种,把它们当两个不同场景记录,而不混在一起下结论。
2.4 执行方式与统计口径
最终版没有用 JMH 做最终执行。不是 JMH 不好,而是 ORM 压测的瓶颈往往在数据库外部服务和连接池争夺上,和 JMH 擅长的 CPU/纯内存微基准不太匹配。我自己写了一个很小的压测入口,每个框架一个独立进程,Shell 脚本依次串行执行,避免五个框架共享 Spring 容器导致配置互相污染。
bash复制for f in jdbc mybatis mybatisplus jpa jooq; do
java -Xms4g -Xmx4g -jar benchmark-$f.jar \
--warmup=2000 --rounds=10000 --scenario=all > result-$f.log
done
执行统计上有几个约束:读操作每轮执行 10000 次,前 2000 次只用来预热不计入结果;写操作每轮 5000 次,取中间 3000 次。每个场景跑 10 轮,最终上报中位数和 P99。平常用平均数的问题在于 GC、系统抖动会把结果拉高,特别是 JPA 这种对象分配量大的框架,几次 Full GC 就能让平均值变得很难看。我自己采用中位数为主、P99 为辅的分桶统计,误差收敛不少。
3. 最终数据:读、写、分页被拉开的真正场景
3.1 读操作:主键查询差距不大,范围查询开始分化
先放读场景的中位数结果,单位是 ms/次,数字越低越好。这个绝对值受机器和网络影响很大,重点看相对趋势。
| 场景 | JDBC | MyBatis | MyBatis-Plus | JPA(Hibernate) | jOOQ |
|---|---|---|---|---|---|
| 主键查询 | 0.19 | 0.24 | 0.27 | 0.35 | 0.28 |
| email 唯一键查询 | 0.22 | 0.28 | 0.31 | 0.41 | 0.33 |
| 范围查询 100 行 | 0.58 | 0.66 | 0.74 | 1.02 | 0.81 |
| 聚合查询 | 0.77 | 0.88 | 1.07 | 1.02 | 0.93 |
主键查询的差距其实很小,JDBC 0.19ms,MyBatis 0.24ms,JPA 0.35ms。对一个几十毫秒的接口来说,这 0.16ms 完全无感知。但范围查询这个场景,JPA 到了 1.02ms,比 JDBC 慢了近 76%。主要开销不是 SQL 执行本身,而是 Hibernate 要实例化 100 个 User 对象,维护持久化上下文,还要做 ResultSet 到实体属性的类型转换、集合初始化。jOOQ 因为返回的是 Record 而不是强实体,反而比 JPA 轻一些,但也比 MyBatis 略重。
聚合查询在没有复杂对象映射时,JPA 如果用原生 @Query 写在恰当位置,并没有太夸张的劣势。这里提醒一下:如果你遇到“JPA 聚合查询慢到离谱”,大概率是查询返回了 List<Object[]> 或者把整表实体都 Load 进内存了,而不是框架本身的问题。
3.2 批量写:JPA 默认配置最吃亏
写场景才是这次跑分最炸裂的部分。批量插入 100 条订单,同样是插入,不同框架的默认行为差别大到让人怀疑是不是写错了:
| 写入方式 | 耗时(ms/批,越低越好) | 备注 |
|---|---|---|
| JDBC executeBatch | 3.2 | URL 开启 rewriteBatchedStatements=true |
| JDBC 没开 rewrite | 21.8 | 驱动仍会逐条发送 |
| MyBatis BatchExecutor | 4.5 | SQL 仍是批量执行 |
| MyBatis-Plus saveBatch | 12.8 | 每 1000 条一批,内部有对象处理 |
| JPA 默认配置 | 68.3 | Hibernate batch_size=0,逐条 INSERT |
| JPA 配置 batch_size=50 | 9.7 | 开启 order_inserts=true |
| jOOQ batch | 5.1 | DSL batch API |
JPA 默认配置下 68ms 这个数字,第一次跑出来我自己都不信,后来打开 MySQL general log 一看,Hibernate 真的一条一条 INSERT,每一条都是一次网络往返。说白了,不是 JPA“慢”,是默认没开批处理,所有 ORM 在不用批量 API 时都可能这样。
我的建议很直接:如果项目里用 Spring Data JPA 做批量导入,要么把 Hibernate 批处理配置打开,要么直接注入 JdbcTemplate 手动 batch,不要在循环里调用 repository.save()。因为即使配置了 batch_size,Hibernate 对 ID 生成策略、实体状态流转也有各种限制,不一定能合并成你想要的多行 VALUES。MyBatis-Plus 的 saveBatch 看起来方便,但实际内部要处理忽略字段、填充字段、分批执行,如果数据量大,性能仍然不如自己写批量 SQL。
3.3 分页接口里最容易忽略的隐式 COUNT
分页是另一个容易“冤枉”框架的地方。很多后端接口都习惯返回一个 Page 对象,里面带 total、pages 这些字段。可一旦要返回 total,框架几乎都会自动执行一次 select count(*):
| 分页方式 | 耗时(ms/次) | 实际执行 SQL |
|---|---|---|
| JDBC 手写 limit,不要 total | 0.89 | 只执行数据查询 |
| MyBatis 手写 limit,单独 count | 2.21 | 数据查询 + count |
| MyBatis-Plus Page | 3.18 | 分页插件自动多跑一次 count |
| Spring Data JPA Page | 3.56 | 自动 count + limit 查询 |
| jOOQ 查完手动 count | 2.34 | 自己写 count |
你可能会说“那让前端不要传 total 不就行了”。很多后台管理系统的表格确实需要 total。问题出在 count 查询的代价:如果 status 条件区分度不高,MySQL 可能要扫描几百万行的索引来算总数,这个开销可能会超过数据查询本身好几倍。
所以分页 Bench 必须区分“返回 Page 对象”和“只返回当前页 List”两种场景。真实业务里,列表接口如果总量变化不频繁,比较聪明的做法是缓存 total,或者只返回“是否还有下一页”而不是精确总数。这样做可以让 MyBatis-Plus 和 Spring Data JPA 的分页性能直接向 JDBC 靠拢。
4. ORM 的性能损耗到底花在哪些环节
4.1 从方法调用到 SQL 执行:每一层都在交税
拿 Hibernate 举个例子。你调用 userRepository.findById(id) 的时候,框架会先构建 JPQL 或 Criteria 查询,再将其翻译成 SQL,绑定参数,执行查询;拿到 ResultSet 后,Hibernate 不是直接把数据塞给实体,而是要识别主键,检查持久化上下文里有没有已存在的对象,如果没有才用反射创建实例,再逐列做类型转换,处理属性填充和集合包装。查询结束之后,这个实体还会被放进 EntityManager 的缓存里,后续 flush 时还要做脏检查。
这些步骤单个看都不重,可能几十微秒,但叠加起来就是成本。最明显的是反射调用和对象实例化,JPA 在返回 100 行结果集时,比 MyBatis 多出来的时间基本都在这里。jOOQ 避免了一部分反射,但它会把查询结果包成 Record 和 TableRecord,也不是零成本。MyBatis 之所以更接近 JDBC,是因为它不维护完整的实体状态,ResultSet 拿完后立刻转成对象就不管了,没有后续的快照对比和级联操作。
这里的类比是:ORM 像一个翻译官,你给它一句中文,它要先理解成内部意思,再翻译成英语,对方回复后再翻回中文。翻译官本身能力再强,也得花时间;但真正拖垮沟通的,往往不是翻译那几个词,而是他让你为了同一件事反复跑了好几趟。
4.2 比对象映射更贵的往往是 SQL 次数
这个结论是测了很多轮之后最想告诉大家的:框架本身的映射成本通常只在几十微秒到一两毫秒之间,可一条 SQL 多发出去一次,在网络往返和数据库执行上的开销可能就要 0.2 到 1ms。一旦出现 N+1,几百条 SQL 直接把接口从 50ms 打到几百甚至上千毫秒。
ForkJoin 案例在测试里很明显。一对多查询里,JPA 默认懒加载写起来最省事,遍历 20 个用户时再访问 user.getOrders(),每访问一个用户就触发一条订单查询,一共 21 条 SQL。MyBatis 如果使用嵌套 resultMap 的 collection,默认也可能产生 N+1。换成 JDBC 手动查三条 SQL 再组装,总耗时反而最低。最终数据是:正确优化后的 JPA 用 join fetch 也能把 SQL 压到 1 条,大约 4.3ms;懒加载写法 N+1 直接干到 74ms。这 17 倍差距没有体现在任何“ORM 对比评测”的框架柱状图里,但它在真实项目里的影响比框架差异大得多。
所以,当有人拿着 Bench 结果说“MyBatis 比 JPA 快 3 倍”的时候,我第一反应一定是问他:你确认两边执行 SQL 的数量一样吗?如果 MyBatis 你手动写了一条连表 SQL,JPA 你用的是懒加载遍历,那不是框架差别,是写代码的水平差别。
4.3 很多“ORM 慢”其实是配置背锅
读场景里我特意做了一组配置对照实验,同样的 JPA 环境,开和不开下面几个配置,结果差距非常大:
| 配置项 | 性能影响 |
|---|---|
| hibernate.show_sql=true | 每条 SQL 都打印日志并执行格式化,读场景直接翻倍 |
| hibernate.jdbc.batch_size=0 | 批量插入退化成逐条 INSERT |
| hibernate.jdbc.batch_size=50 + order_inserts=true | 批量插入大幅提速 |
| 一级缓存(Session/EntityManager) | 同一事务内重复查询会走缓存,但跨事务没法利用 |
| Hibernate 二级缓存 | 默认关闭,开启后对重复查询可能提速 |
| MyBatis 二级缓存 | 默认关闭,不要拿测试环境残留在对比 |
| MyBatis-Plus 分页插件 count 查询 | 列表返回 Page 时会额外执行 count |
Benchmark 最容易犯的错误就是只看“默认配置”,因为不同框架对默认值的取舍差异很大。比如 JPA 为了安全默认关批处理,MyBatis 为了轻量默认不分析关联对象,jOOQ 默认不做脏检查。跑分之前必须规定好:是比“开箱默认”,还是比“调优到最优”。我最终版的做法是两种都记录,但结论里严格区分“默认值带来的差距”和“框架机制本身的差距”,不混在一起。
5. 看完 Benchmark 之后:选型与重构怎么落地
5.1 先确认瓶颈不是 SQL、索引和数据量
如果你看到自己项目里某个接口很慢,不要第一反应就是“换成 MyBatis 或 JDBC 就会快”。按我这几年的排查顺序,永远先动数据库侧。先看慢查询日志,确认 SQL 是不是全表扫描,条件字段有没有索引,返回列是不是超出必要范围,数据量是不是已经大到需要重新设计表。绝大多数慢接口,加一个联合索引、把 select * 改成指定列,或者去掉深分页,性能提升要比换 ORM 明显得多。
Benchmark 是决策工具,不是甩锅工具。当数据库执行本身已经是 200ms,JDBC 和 JPA 之间 0.3ms 的差异根本不值得讨论。真正该用 ORM Bench 的时机,是你已经确认 SQL 形态相同、索引命中相同,框架的映射层成为瓶颈了,再做判断。
5.2 按团队业务属性选 ORM 的参考表
这次跑分的结果,我整理成了一张适合我们内部投票的选型参考表,不一定适合所有团队,但思路可以复用:
| 业务特征 | 推荐方向 | 理由 |
|---|---|---|
| 支付、账户、交易系统,SQL 要求完全可控 | JDBC / JdbcTemplate | 减少中间层黑盒,易做 SQL Review |
| 标准 CRUD + 后台管理,开发效率优先 | MyBatis-Plus 或 JPA | 模型简单时差距不大 |
| 报表、BI、复杂聚合与动态 SQL | jOOQ | 类型安全、SQL 可控高,避免字符串拼接 |
| 传统企业级领域模型复杂、跨部门协作 | Spring Data JPA | 实体关系表达能力强,约束统一就够用 |
| 已经使用 MyBatis 但被枚举/通用字段映射坑 | MyBatis-Plus | 减少样板代码 |
你会发现我的表格里没有“某框架绝对不能碰”这种话。原因是同样一个框架,用得好和用得差性能能差出 10 倍。关键不是名字,而是团队是否愿意为它的运行模式付出维护成本。比如使用 JPA 就必须接受实体状态管理、懒加载代理、批处理配置这些东西;使用 MyBatis 就得接受大量 XML 和 SQL 重复。
5.3 在不动框架的情况下先做这四件事
如果团队已经用了 Spring Data JPA,但启动这些优化之前不建议贸然整体切到 MyBatis。我建议先按下面四件事做一轮:
第一,把 hibernate.jdbc.batch_size 设成 30~50,同时开启 order_inserts 和 order_updates。这个动作对批量导入和循环更新优化最大,改动量极小。第二,把所有一对多关联查询改成 left join fetch 或 @EntityGraph,从源头消灭 N+1。代码评审时可以把“集合属性懒加载遍历”列为禁止项。第三,检查接口是不是真的需要返回 total,能去掉就去掉;不能去掉的,为 count 查询单独设计轻量 SQL,不要让它和列表查询接同一个重量级条件。第四,复杂报表查询不要走实体查询,用 @Query 写原生 SQL,或者单独引入一个轻量查询组件(比如 JdbcTemplate/jOOQ),只做查询不做实体关系管理。
这四件事做完,大多数 JPA 项目的性能问题能解决八成,完全不需要动架构。
6. 最终版复盘:三版被推翻的方案与避坑清单
6.1 第一次坑在把数据库压测写成了 CPU 基准
第一次用 JMH 跑 ORM,其实已经能跑出数字,但数字的解释力很弱。JMH 会尽量保证 CPU 资源平均、方法内联、死代码消除,可 ORM 这种涉及数据库连接、网络 IO、连接池状态的东西,和 JMH 最擅长的纯 CPU Bench 不一样。更多时候,你测的不是框架,而是数据库连接在那一瞬间是否可拿到。后来的自定义压测脚本虽然“土”,但每个场景都能明确看到网络往返和数据库执行时间,反而更容易取信于人。
6.2 第二次坑在预热不足和偏移固定
没有预热的结果不能看。HikariCP 默认 minimumIdle 为 10,但连接并不是一开始就建立好的,应用刚启动的前几百个请求都在等新连接创建。更糟的是,范围查询固定从第一页开始跑,结果所有流量都集中在同一棵索引树的叶子页上,命中率虚高。最终版规定每进程启动后先执行 2000 次热请求,范围条件的偏移量从随机数生成器取,同时每次两个相邻请求之间不让 offset 相同,避免数据库缓存被压测程序“惯坏”。
6.3 第三次坑在结果表与业务表互相干扰
这可能是最容易被忽视也最离谱的坑。前几次测试输出都写到同一个 MySQL 实例里,我跑完所有场景后,会把每个场景的结果 insert 到一张 benchmark_result 表。结果第二天重新做抽样验证时,发现所有数字都比前一天慢 15% 左右。查到最后发现,结果表所在的 buffer pool 一直在用额外空间,而且有一条统计 SQL 生成了慢查询记录,把实例的 IO 拖动了。后来我把结果写到了另一个数据库实例,或者直接落本地文件,干扰立刻消失。
6.4 稳定复现后,我的跑分规则
经过这三轮推翻,我给自己定了一份跑分规则,建议你也直接抄:被测应用和数据库分机器部署,数据库独立实例;每个框架独立进程、独立 JVM、同样堆内存;启动后必须预热;每个操作重复足够多次,不用单次计时;上报中位数而不是平均值,P99 只做参考;固定版本号和环境变量;结果记录不要放在被测库上;最后把 SQL 日志打开一轮,确认各框架的 SQL 条数和查询形态确实等价。
遵守这套规则以后,最终版十轮跑下来,读场景中位数波动控制在 5% 以内,写场景也只有 8% 左右。这已经足够让我在方案评审里说出“这个结论可复现”这句话。
如果你也准备在自己的项目里做一轮 ORM 性能测试 Benchmark,不用完全照抄我的环境。把跑分脚本和数据量改到你生产环境差不多的规模,重点观察相对趋势,而不是那串具体毫秒数。跑完之后你会发现,真正值得优化的通常不在框架选择上,而在那些被默认配置和代码习惯藏起来的多余 SQL 里。
