做性能测试最怕的不是结果难看,而是测了个寂寞。尤其是ORM这种自带“自动挡”属性的东西,如果选型时全靠拍脑袋,上线后被慢查询教做人的案例,我在不同项目里见过太多次。所以这次我把自己最近做的一轮ORM性能测试Benchmark完整复盘一遍:为什么测、场景怎么设计、为什么最后选JMH而不是JMeter、结果怎么解读、哪些坑必须绕开,一次性讲透。这篇不是学院派报告,是我实际跑完、踩完坑之后沉淀下来的操作记录,适合正在做技术选型、或者被领导要求“出一个ORM性能对比结论”的同学直接参考。
1. 为什么要给ORM做性能测试,这次到底在测什么
1.1 从一次线上事故说起
先讲个真实经历。之前有个项目,上线前技术评审大家一致觉得“用JPA开发效率高”,结果压测环境单测一切正常,一上生产在凌晨的批量任务里把数据库连接池打满。排查下来不是代码逻辑问题,是ORM在批量写入时默认一条条insert,别人一秒钟能干完的活,它磨了三分钟,数据库CPU飙到90%多。从那个项目之后,我就养成了一个习惯:任何新项目做ORM选型,先跑一轮可复现的Benchmark,用数据说话,别用框架的知名度说话。
这次Benchmark的目的很明确:在同一个数据库、同一批数据、同样环境参数的前提下,对几个常见Java ORM框架做横向对比,得出哪些场景差距大、哪些场景差距可以忽略,最后给出选型建议。
1.2 这次Benchmark的范围与边界
先说清楚边界,做性能测试最忌讳的就是“什么都想测”,最后什么都代表不了。
这次测试我圈了这么几个范围:
- 被测对象:Spring JDBC Template(基线)、MyBatis、Hibernate、Spring Data JPA,这四类在Java生态里最有代表性,JdbcTemplate当基线用来标定上限。
- 不测网络开销:数据库部署在同一个Docker网络里,避免把网络抖动算进ORM头上。
- 不测HTTP服务层:这次是纯ORM API级别的基准测试,不是端到端接口压测。
- 测什么:单条增删改查、批量插入、列表查询、分页查询、简单关联查询。
很多团队喜欢用JMeter直接对接口压测,然后得出“ORM性能不好”的结论,说实话这种对比是不公平的,因为里面掺了Web容器线程调度、序列化、网络、数据库连接获取开销,这些干扰项远远大于ORM本身的差异。要单独回答“哪个ORM快”,就得把变量控制到最小,这也是我为什么把Benchmark放到JMH里跑,而不是起服务然后拿JMeter去打。
2. 测试方案设计与工具选型
2.1 为什么核心Benchmark用JMH而不是JMeter
这里想多说几句工具选型。热词榜上“JMeter性能测试”搜的人很多,但JMeter和JMH完全不是一类东西,很多人混为一谈。
- JMeter是压测工具,它能告诉你“我的服务/接口并发1000的时候表现怎么样”,适合全链路压测。
- JMH是Java Microbenchmark Harness,是OpenJDK官方出的微基准测试工具,它可以在JVM层面准确测量一段代码的执行性能,自动处理JIT编译、类加载、死代码消除等干扰。
ORM的性能测试到底属于哪一类?它介于两者之间,但更偏向微基准。如果直接拿JMeter去打接口,你测的是整个请求处理链路的性能,ORM的差异会被Tomcat线程、JSON序列化、连接池、数据库驱动这些环节稀释掉。你得跑很多并发、很多轮次才能让ORM的差异稍微显露出来,而且结果不稳定。JMH的好处是它把JVM这一层管得明明白白,比如预热轮数、迭代次数、Fork次数都配置化,能让结果方差控制得很好。
当然,JMeter不是没用,它适合在Benchmark得出结论之后,对最终选定的技术栈做一次模拟真实场景的服务级验证。我在这次测试的后续也补了一轮JMeter接口压测,目的是验证“Benchmark结论放到真实链路里是否成立”。如果你只想知道ORM本身谁快,就用JMH;如果你想知道生产环境要不要换ORM,那JMeter压一把完整的服务也必不可少。
2.2 被测对象:4个框架的选型逻辑
这次选的四个被测对象,覆盖了Java生态里三条技术路线:
| 被测对象 | 定位 | 特点 |
|---|---|---|
| Spring JDBC Template | 基线 | 几乎无ORM能力,手写SQL,性能上限参考 |
| MyBatis 3 | 半自动ORM | SQL自己控制,映射帮你做,性能和灵活性平衡 |
| Hibernate 5 | 全自动ORM | 对象关系映射,自动生成SQL,带一级/二级缓存 |
| Spring Data JPA | 全自动ORM | 底层基于Hibernate,增加Repository抽象 |
选择这四者的原因很简单:Spring JDBC Template负责告诉你“在同样的SQL逻辑下,手写JDBC能达到什么性能”;MyBatis代表“SQL在手,性能我有”的一派;Hibernate和Spring Data JPA代表“开发效率优先,ORM自动托管”的一派。至于为什么把Spring Data JPA和Hibernate都放进去?因为很多项目用了Spring Data JPA,但实际上底层就是Hibernate,但两者的API调用路径不同,缓存策略也可能不同,分开测更客观。
每个框架我都尽量用它的“推荐方式”去写,而不是为了性能去写特殊的反模式代码。比如MyBatis就用Mapper接口加XML,Hibernate就用EntityManager标准API,Spring Data JPA就用Repository接口。只有用各框架的开发范式去测,得出的性能数据才有选型参考意义,否则就变成“优化技巧大比拼”了。
2.3 环境、连接池与数据准备
测试环境这块我固定为一个比较接近生产的小规格配置:
- 机器:4核8G的云主机,Ubuntu 22.04
- 数据库:MySQL 8.0,Docker容器方式部署,和Benchmark进程在同一台机器
- JDK:JDK 17,默认G1收集器
- 连接池:HikariCP,连接数固定为10,避免连接数成为瓶颈
- 测试数据:单表user_info,10万行记录;关联表order_info,20万行记录
这里有个非常容易被忽略的点:数据库和Benchmark进程尽量放同一台机器或同一内网。我之前试过在云上把数据库单独放一台实例,结果一次简单查询的耗时全被网络往返吃掉了,四个框架的差距几乎被抹平,测了个寂寞。把数据库放在本地Docker里跑,虽然和真实生产有差异,但能放大ORM本身的计算和SQL生成差异,反而更适合横向对比。
数据初始化我用了一个固定的SQL脚本,保证每个框架测到的都是同样分布的数据。主键用自增ID,user_info表里有索引字段、普通字段,还有一个有索引的create_time用于范围查询和分页。数据越接近真实业务越好,但也不要搞几百张表,否则数据准备本身就成了工程负担。
3. 核心测试场景设计
3.1 单条CRUD场景怎么拆
单条操作是“最基本”的,但千万不能只测一个“按主键查一条”,那根本看不出差距。我拆了以下几个典型用例:
- 按主键查询单条:selectById,SQL最简单,最能体现代码路径和缓存策略差异
- 新增一条记录:insertOne,关注SQL生成、参数绑定和主键回填
- 按主键更新一条:updateById,关注变更检测机制
- 按ID删除一条:deleteById,关注SQL生成和执行
为什么要拆这么细?因为不同ORM在不同操作上的开销完全不一样。比如Hibernate在做update之前会先查一次实体快照,然后做脏检查,这个在简单场景下多一次select的消耗是肉眼可见的。MyBatis因为SQL你全手写,几乎没有任何额外开销。JdbcTemplate就更不用说了,纯粹就是JDBC。
如果不拆场景,只给一个平均值,你没法定位项目里的真实瓶颈:你的系统如果是读多写少,就得围绕查询场景做决策;如果你是批量导入业务为主,就得重点看写入场景。这就是“设计场景”比“跑分”更重要的原因。
3.2 列表、分页与关联查询
光看单条CRUD不够,现实中大部分数据库压力来自列表和分页。我设计了这么几个场景:
- 列表查询:不带条件取前1000条,关注结果集映射开销
- 分页查询:按create_time排序取第100页、每页20条,注意分页SQL是否真的传了limit
- 简单关联查询:用户加订单的一对多查询,关注ORM的N+1处理能力
- 聚合查询:count、sum这类操作,关注SQL优化能力和结果映射方式
这里最有意思的是分页。MyBatis用PageHelper做分页时是拦截器拦截SQL再改造成count+limit两条SQL,Hibernate有自己的分页方言处理。两个框架分页性能差异往往不来自数据库执行,而是来自“多出的count查询”和“结果映射机制”。Spring Data JPA的Page查询默认会执行一条count,如果你的页面只关心“下一页”,压根不需要total,那就白白多一次查询。
关联查询这块,必须强调一个经典翻车点:MyBatis的嵌套结果映射如果在集合映射上写得不注意,会出现“同一条父记录被重复包装”的情况;Hibernate如果不显式指定fetch join,默认懒加载会造成典型的N+1问题。这些在Benchmark里如果不控制,最后数据对比就失去了意义。所以我统一用“预加载”的方式(MyBatis用association的查询嵌套,Hibernate用fetch join),保证逻辑等价。
3.3 批处理场景
批处理是ORM性能差距最大的场景,也是很多项目真正翻车的地方。
我设计的批处理场景有两种:
- 批量插入:一次性插入1万条数据,每条20个字段
- 批量更新:一次性更新5000条记录中的某个字段
为什么这里差距大?因为JDBC层面有个极其关键的优化点:rewriteBatchedStatements。MySQL驱动默认不开启这个参数,如果你不显式加上rewriteBatchedStatements=true,哪怕是JdbcTemplate的batchUpdate,实际执行时也可能是一条条execute,而不是真正的多值insert。这个参数没有配置对,再好的ORM也白搭。
Hibernate这边还要注意一个点:它是通过Session管理实体状态的,批量插入时必须手动控制flush和clear的节奏,否则Session越来越大,缓存压力越来越大,速度几何级下降。很多网上帖子说Hibernate批量插入慢,实际上有一大半是flush策略没设置好。这个我在测试时也做了区分:一个是不做任何优化直接插入的“裸测”,一个是手写flush的“优化版”,两个数据都记录,方便看出“性能差到底是框架的错还是用法的错”。
3.4 预热、迭代数与防抖设计
很多人跑Benchmark最大的问题就是上来就测,测一次就下结论。JVM是带JIT编译器的,一段代码跑了几万次之后可能被编译成极其高效的机器码,和前几百次的执行速度是两个世界。如果你不预热,测出来的所谓“性能”很大一部分是解释执行的数据,根本不能代表真实运行情况。
JMH里有几个关键参数,我这次是这样设置的:
@Warmup(iterations = 5, time = 3),预热5轮,每轮3秒@Measurement(iterations = 8, time = 5),正式测量8轮,每轮5秒@Fork(value = 2),Fork两个独立JVM进程跑,避免单次进程的偶然性@BenchmarkMode(Mode.Throughput),统计每秒操作数,单位ops/s@State(Scope.Benchmark),连接池、Mapper这些重量级对象只初始化一次
除了JMH自带的防抖机制,我自己在环境上还注意了两点:一是把无关进程全部停掉,Docker容器里的数据库服务不会突然因为别的容器抢CPU而抖动;二是每轮Benchmark之间留出时间间隔,让连接池和数据库连接状态稳定下来,不能连着快速跑好几轮,否则MySQL的线程池可能还处于忙状态。
4. 核心实现与关键代码
4.1 工程结构
工程是一个标准的Maven多模块项目。我的习惯是每个被测框架一个模块,避免测试类之间的依赖污染。如果一个模块里同时放了MyBatis和Hibernate的Benchmark类,两者初始化时会互相影响类加载和元空间占用,结果会有隐性偏差。
简单列一下目录结构:
orm-benchmark/ ├── benchmark-common # 公共实体类、工具类、数据源配置 ├── benchmark-jdbc # Spring JDBC Template 测试模块 ├── benchmark-mybatis # MyBatis 测试模块 ├── benchmark-hibernate # Hibernate 测试模块 └── benchmark-datajpa # Spring Data JPA 测试模块公共模块里放一个DataSourceFactory,统一创建HikariCP数据源。所有模块都用同一个数据库、同一个连接池配置,最大程度保证横向可比。
4.2 一个JMH Benchmark类的代码示例
以MyBatis的按主键查询为例,核心代码大概是这样的:
@BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.SECONDS) @State(Scope.Benchmark) @Fork(value = 2) @Warmup(iterations = 5, time = 3) @Measurement(iterations = 8, time = 5) public class MybatisSelectBenchmark { private SqlSessionFactory sqlSessionFactory; private UserMapper userMapper; // 测试用的固定ID,避免每次随机产生偏差 private static final long TEST_ID = 10000L; @Setup(Level.Trial) public void setup() { // 这里创建 SqlSessionFactory,并开启 SqlSession // 实际代码:读取 mybatis-config.xml 和 mapper.xml // 关键点:SqlSession 不关闭,在同一个会话里测,避免连接获取开销; // 但要注意 MyBatis 一级缓存会缓存相同查询,如果不想测缓存, // 需要在 XML 的 select 标签上设置 flushCache="true" } @Benchmark public UserInfo selectById() { return userMapper.selectById(TEST_ID); } }这里有一个非常重要的实验控制点:MyBatis的一级缓存默认是开启的,同一个SqlSession内,同一SQL加同一参数的查询直接返回缓存结果,根本不走数据库。如果你不关掉一级缓存,你测的其实是MyBatis的缓存命中性能,而不是SQL执行性能。我在测试时专门区分了两个版本:开启一级缓存的和关闭一级缓存的。但最终横向对比用的数据是关闭一级缓存的,这样才能公平对比各框架真实查询性能。
Sprng Data JPA那边我用了JpaRepository的findById方法,Hibernate用的是EntityManager的find方法。两个框架默认都有第一级缓存,但作用域不同,这本身就是框架设计的一部分。既然选型对比的是“真实用法”,那保留框架默认行为反而更公平。
4.3 结果输出怎么读
JMH运行完会在控制台输出一张结果表,大致长这样:
Benchmark Mode Cnt Score Error Units MybatisSelectBenchmark.selectById thrpt 16 51234.456 ± 1023.123 ops/s HibernateSelectBenchmark.selectById thrpt 16 30123.789 ± 856.456 ops/sScore是吞吐量,单位是每秒多少次,Error是误差范围。比较时先看Score,再看Error是否过大。Error如果超过Score的10%,说明测试稳定性有问题,比如连接池参数抖动、GC频繁、或者数据库在跑其他任务,这种结果不要直接采信。一个有经验的测试者不光看平均数,还会看每轮的原始数据有没有一边倒的趋势;如果前面几轮明显慢、后面几轮明显快,通常就是预热不充分。
5. 测试结果分析与原理解读
5.1 本次测试的结果汇总
我把最终几组有代表性的数据整理成了一张表。需要说明的是,这组数据来自我当时的测试环境,不一定和你机器上的一致,我贴出来主要展示“差距量级”而不是绝对数值。
| 场景 | JdbcTemplate | MyBatis | Hibernate | Spring Data JPA |
|---|---|---|---|---|
| 单条主键查询 | 55120 ops/s | 51234 ops/s | 30123 ops/s | 29450 ops/s |
| 单条插入 | 18500 ops/s | 16200 ops/s | 9800 ops/s | 9300 ops/s |
| 批量插入1万条 | 12.6秒 | 13.1秒 | 35.2秒(裸测) | 34.5秒(裸测) |
| 批量插入(优化后) | 12.6秒 | 13.1秒 | 16.4秒 | 16.1秒 |
| 分页查询第100页 | 31000 ops/s | 28500 ops/s | 20500 ops/s | 17200 ops/s |
| 一对多关联查询 | 18200 ops/s | 16400 ops/s | 9600 ops/s | 9400 ops/s |
这几组数据有几个值得注意的点:
第一,单条查询场景,MyBatis和JdbcTemplate差距很小,Hibernate/JPA明显低一档,大概差40%左右。原因不复杂,Hibernate查询时要解析实体元数据、构造对象、处理状态管理,JPA还要套一层Repository动态代理。
第二,单条插入场景差距更大。主要原因是Hibernate在执行insert前要做实体状态检查、生成INSERT SQL,而且在默认flush策略下有延迟写入的额外开销。
第三,批处理场景是重灾区。Hibernate裸测时35秒,优化flush后16秒,差距巨大。MyBatis和JdbcTemplate差距不大,说明MyBatis的批量SQL拼接效率已经很接近手写JDBC。
第四,分页查询场景,Spring Data JPA最慢,因为它默认多跑了一次count查询。这个在设计分页接口时需要特别注意。
5.2 差异背后的底层原因
很多文章只给结论不给原理,我尽量把“为什么”这条逻辑线讲清楚。
先看SQL生成机制。JdbcTemplate和MyBatis走的是“SQL在手,天下我有”的路线,SQL是你写的,框架只做参数绑定和结果映射。Hibernate和黄Spring Data JPA是自动生成SQL的,生成一个复杂查询需要经过实体元数据解析、HQL/JPQL解析、SQL方言翻译、参数绑定等一系列步骤。这个开销在单条操作里比例很高,但在批量操作里因为每次都是同一个SQL模板,JIT编译后开销会被摊薄,所以越是复杂查询,自动生成SQL的解析成本越明显。
再看结果映射。MyBatis的映射机制是“反射加缓存”,第一次映射一个实体时会缓存元数据,后面直接走缓存,所以性能很接近手写JDBC。Hibernate则要维护实体在持久化上下文中的状态,每次查询都需要做实体身份判断,看对象是不是已经存在于Session中。这个状态追踪机制在单条查询时无所谓,但在大量数据关联查询时,可能造成额外的内存开销和比较逻辑。
然后是缓存。Hibernate和JPA默认带一级缓存,如果数据被查过一次,同Session内再次查询会直接命中。在这个测试里我按主键查询的Session是重新创建的,所以一级缓存没占到便宜。但在真实业务里,如果你在事务里反复查相同ID的数据,Hibernate的缓存优势是能体现的。反过来,缓存也会带来像“批量插入时Session越来越大”的副作用,性能和资源占用会随着实体数量增长而恶化。
5.3 怎么根据结果做技术选型
看完这些结果,我不建议直接得出“JdbcTemplate最强,其他都是废物”的结论。因为ORM选型的维度从来不只有性能。
我的比较大致的决策路径是这样的:
- 如果项目以简单CRUD为主,SQL非常固定,团队又不缺开发时间,JdbcTemplate或者JOOQ是性能最稳的选择。
- 如果项目SQL复杂度高、动态条件多,MyBatis优势最大。它的性能接近于JdbcTemplate,同时又保留了SQL的可控性和动态SQL能力。
- 如果项目业务模型复杂、关联关系多,团队希望少写SQL、开发效率优先,那Hibernate/JPA仍然值得用,但必须接受它在部分场景的性能落差,并通过批量flush、二级缓存、命名查询等手段把关键路径的损耗补回来。
性能测试结果的作用是“揭示代价”,而不是“一票否决”。我个人在选型时会保持这样一个态度:先用Benchmark把各框架的差异摸清楚,然后回到业务场景里看差异能不能被架构吸收,比如通过缓存、读写分离、异步化等手段抵消,最后再拍板。纯粹因为某个框架“性能高”就选它,而后发现开发效率掉了一半,这种代价在长期项目里往往更大。
6. 常见问题与排查技巧实录
6.1 六个最容易踩的坑
这次跑Benchmark踩了不少坑,我把最常见的六个列出来,给后面做类似测试的同学排雷。
连接池参数不一致。有的ORM模块用了默认连接数,有的设置了最大20,最后结果差异其实是连接池差异。统一用同一个HikariCP配置,连接池参数固定,才能保证横向可比。
没有开rewriteBatchedStatements。批量插入时MySQL驱动默认不会把多条insert重写成多值insert。需要在JDBC连接串里加上
rewriteBatchedStatements=true,否则你测的所有框架批量插入都很慢,等于没有测出真实差距。预热不充分。没有预热就跑出来的结果毫无意义,尤其第一次调用时各种反射、动态代理、SQL解析初始化全都在,性能可能只是稳定态的十分之一。
一级缓存和二级缓存没交代清楚。MyBatis一级缓存、Hibernate一级缓存、二级缓存如果不显式说明开关状态,结果完全不可比。我建议把一个缓存场景“配置明确”地写进测试代码,不要用默认值糊弄过去。
数据量太小。表里只有几十条数据,索引完全没发挥作用,查询全部走全表扫描,这种基准测试得出来的结果无法反映生产环境的问题。
拿JMeter直接压ORM服务,然后宣称“ORM性能差”。前面说过,JMeter适合压完整服务链路,不适合做ORM微基准;如果你真要用JMeter做对比,至少要保证四个框架的服务端部署、连接池、序列化方式完全一致,不然测的就不止是ORM。
6.2 排查思路与我的个人习惯
遇到Benchmark结果出现异常波动时,我一般会按下面这个顺序排查:
- 先看Error列,误差超过10%的一律标记为不稳定,重新跑。
- 再看GC日志,如果某一轮发生FGC,成绩必然被拉低,可以通过开启
-Xlog:gc来确认。 - 然后看数据库侧的慢查询日志,有时候ORM生成了一条烂SQL,慢的不是框架本身而是SQL,这类问题要单独捞出来优化。
- 最后看Profiler采样,比如用async-profiler看CPU热点,能直接定位是SQL执行时间占比高,还是结果映射占比高,还是序列化/反射占比高。
我自己还有一个习惯:每轮测试跑完,会把原始数据复制到本地,简单算一下单轮数据的中位数和四分位距,而不是只看平均值。平均值容易被极值拉偏,中位数能反映更稳定的水平。尤其在做技术选型汇报时,数据越细,越能经得起别人追问。
最后分享一个建议:性能测试不是一锤子买卖。这次Benchmark做完,不是拿到结论就结束了,代码更新、依赖升级、数据库版本变化后,都可能影响ORM表现。我现在的项目里已经把这套Benchmark脚本固化到CI里,变成每周一次的定时任务,遇到大版本升级就手动触发一次,让性能退化能在第一时间被发现,而不是等上线后让监控系统报警。