尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java ORM性能基准测试实战:JMH对比MyBatis、Hibernate与JPA选型指南

Java ORM性能基准测试实战:JMH对比MyBatis、Hibernate与JPA选型指南 做性能测试最怕的不是结果难看而是测了个寂寞。尤其是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半自动ORMSQL自己控制映射帮你做性能和灵活性平衡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接口加XMLHibernate就用EntityManager标准APISpring Data JPA就用Repository接口。只有用各框架的开发范式去测得出的性能数据才有选型参考意义否则就变成“优化技巧大比拼”了。2.3 环境、连接池与数据准备测试环境这块我固定为一个比较接近生产的小规格配置机器4核8G的云主机Ubuntu 22.04数据库MySQL 8.0Docker容器方式部署和Benchmark进程在同一台机器JDKJDK 17默认G1收集器连接池HikariCP连接数固定为10避免连接数成为瓶颈测试数据单表user_info10万行记录关联表order_info20万行记录这里有个非常容易被忽略的点数据库和Benchmark进程尽量放同一台机器或同一内网。我之前试过在云上把数据库单独放一台实例结果一次简单查询的耗时全被网络往返吃掉了四个框架的差距几乎被抹平测了个寂寞。把数据库放在本地Docker里跑虽然和真实生产有差异但能放大ORM本身的计算和SQL生成差异反而更适合横向对比。数据初始化我用了一个固定的SQL脚本保证每个框架测到的都是同样分布的数据。主键用自增IDuser_info表里有索引字段、普通字段还有一个有索引的create_time用于范围查询和分页。数据越接近真实业务越好但也不要搞几百张表否则数据准备本身就成了工程负担。3. 核心测试场景设计3.1 单条CRUD场景怎么拆单条操作是“最基本”的但千万不能只测一个“按主键查一条”那根本看不出差距。我拆了以下几个典型用例按主键查询单条selectByIdSQL最简单最能体现代码路径和缓存策略差异新增一条记录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的N1处理能力聚合查询count、sum这类操作关注SQL优化能力和结果映射方式这里最有意思的是分页。MyBatis用PageHelper做分页时是拦截器拦截SQL再改造成countlimit两条SQLHibernate有自己的分页方言处理。两个框架分页性能差异往往不来自数据库执行而是来自“多出的count查询”和“结果映射机制”。Spring Data JPA的Page查询默认会执行一条count如果你的页面只关心“下一页”压根不需要total那就白白多一次查询。关联查询这块必须强调一个经典翻车点MyBatis的嵌套结果映射如果在集合映射上写得不注意会出现“同一条父记录被重复包装”的情况Hibernate如果不显式指定fetch join默认懒加载会造成典型的N1问题。这些在Benchmark里如果不控制最后数据对比就失去了意义。所以我统一用“预加载”的方式MyBatis用association的查询嵌套Hibernate用fetch join保证逻辑等价。3.3 批处理场景批处理是ORM性能差距最大的场景也是很多项目真正翻车的地方。我设计的批处理场景有两种批量插入一次性插入1万条数据每条20个字段批量更新一次性更新5000条记录中的某个字段为什么这里差距大因为JDBC层面有个极其关键的优化点rewriteBatchedStatements。MySQL驱动默认不开启这个参数如果你不显式加上rewriteBatchedStatementstrue哪怕是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/sState(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 标签上设置 flushCachetrue } 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 本次测试的结果汇总我把最终几组有代表性的数据整理成了一张表。需要说明的是这组数据来自我当时的测试环境不一定和你机器上的一致我贴出来主要展示“差距量级”而不是绝对数值。场景JdbcTemplateMyBatisHibernateSpring Data JPA单条主键查询55120 ops/s51234 ops/s30123 ops/s29450 ops/s单条插入18500 ops/s16200 ops/s9800 ops/s9300 ops/s批量插入1万条12.6秒13.1秒35.2秒裸测34.5秒裸测批量插入优化后12.6秒13.1秒16.4秒16.1秒分页查询第100页31000 ops/s28500 ops/s20500 ops/s17200 ops/s一对多关联查询18200 ops/s16400 ops/s9600 ops/s9400 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连接串里加上rewriteBatchedStatementstrue否则你测的所有框架批量插入都很慢等于没有测出真实差距。预热不充分。没有预热就跑出来的结果毫无意义尤其第一次调用时各种反射、动态代理、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里变成每周一次的定时任务遇到大版本升级就手动触发一次让性能退化能在第一时间被发现而不是等上线后让监控系统报警。
返回列表