《Spring Boot + JPA 实战》第16招 批量保存为什么慢
01、痛点
凌晨两点,告警群弹出:订单导入服务 Full GC 超过 12 次,堆内存从 2G 一路涨到 4.2G,最终 OOM。我是老李,被叫起来处理一个 10 万订单导入任务。开发同学第一反应是:我已经用了 saveAll,怎么还这么慢、这么吃内存?
翻代码看到:一个事务里循环读取文件,调用 orderRepository.saveAll(orderList),每 1000 条一批。但监控显示每批耗时越来越长,堆内存曲线像台阶一样上升,最后直接崩掉。问题看起来是 saveAll 不生效,其实是把 saveAll 当成了高性能批处理。类似场景:账单导入、对账文件入库、历史数据迁移,经常出现批量调用了,底下却还是逐条 SQL 和逐条网络往返。
为什么现在要讲?因为这类问题不是个例:很多团队在 JPA 上做大数据量导入,总以为换成 saveAll 就完事了,但生产上 OOM 和慢 SQL 依然频发。如果不把 Hibernate Batch、JDBC Batch、Flush/Clear、对象状态管理、ID 生成策略和网络 RTT 串起来看,很容易在同一个坑里反复踩。
02、是什么
先给结论:saveAll() 只是批量调用语义,不天然代表底层已经使用高性能 JDBC Batch。它解决的问题是减少应用代码循环调用,而不是减少 SQL 条数或网络往返。Hibernate 只有在满足一系列条件时,才会把多条 insert/update 合并成 JDBC Batch 发送给数据库。
这里要区分几个概念:saveAll/saveAllAndFlush 是 Spring Data JPA 提供的仓库方法,底层遍历实体调用 EntityManager.persist/merge。Hibernate Batch 是 ORM 层面向 JDBC 的批量优化,需要配置 hibernate.jdbc.batch_size、hibernate.order_inserts、hibernate.order_updates 等。JDBC Batch 是驱动层真正把多条 SQL 合并成一个网络包发送,例如 PreparedStatement.addBatch() 和 executeBatch()。Flush 是把 Persistence Context 中挂起的 SQL 同步到数据库,Clear 是把一级缓存清空,避免对象越积越多。Persistence Context 是 Hibernate 一级缓存,每个 EntityManager 对应一个,保存加载或持久化的实体状态,用于脏检查等。
对于 10 万订单导入,如果只是 saveAll,每一批仍然可能产生与实体条数相同的 insert SQL;每条 SQL 还有一次网络 RTT。如果 ID 策略是 IDENTITY,Hibernate 为了拿到自增 ID 必须立即执行插入,连 batch 都被破坏。如果不清空 Persistence Context,10 万实体全部变成 Managed 状态,每个实体都持有原始值、关联引用等,JVM 堆会暴涨。
03、为什么
为什么 ORM 批处理成本高?可以从三层叠加看。
第一层:对象状态管理。Hibernate 把每个 persist() 的实体放进 Persistence Context,保存快照用于脏检查。这意味着 10 万实体不只是 10 万行数据,还有 10 万个对象头、字段副本、Map 条目等。一个订单实体如果在数据库里 500 字节,在 JVM 里对象加上快照可能 2KB 到 5KB。100 万 Managed 实体则对应几 GB 堆,这还没算 GC 扫描压力。而且一级缓存会随着事务持续增长,Hibernate 执行 flush 时要遍历所有实体做脏检查,耗时呈非线性增长。
第二层:网络往返。默认情况下,一条 insert 就是一次 JDBC 调用,客户端发 SQL,数据库返回执行结果。如果数据库和应用不在同一机架,单次 RTT 0.5ms 到 2ms,10 万条 SQL 仅网络等待就是 50 到 200 秒。JDBC Batch 把多条 insert 打成一个包,数据库一次返回,大概率减少到原来的 1/20 甚至 1/50。另外,如果事务提交前每次 flush 都小批量发送,又不能真正合并,也会浪费往返。
第三层:数据库写入。即使 SQL 到达数据库,逐条 insert 也面临索引维护、redo/undo 日志、锁开销等。批量写入能让数据库优化器、日志组提交、索引批量构建发挥效果,特别是对 InnoDB 这类基于 B+ 树和 redo 的存储引擎,批量插入通常比单条插入快一个数量级。
ID 生成策略是隐藏杀手:IDENTITY 生成主键时,数据库在插入时才知道 ID,Hibernate 无法提前准备一批,只能插入后立即取回,强制逐条执行。SEQUENCE 或 TABLE 策略则会先取一批 ID,从而允许 JDBC Batch 合并。所以在导入场景,改 ID 策略往往比调 batch size 更有效。
04、架构
把整个链路画成三层:应用业务代码 → ORM/Transaction/Persistence Context → JDBC Driver → 数据库。saveAll 只是应用层的批量调用;Hibernate 的 flush 触发持久化上下文向 JDBC 层发送 SQL;JDBC Batch 决定是否将多条 SQL 合并成一次网络传输。Flush 不提交事务,Clear 不触发删除,它只是解除实体与一级缓存的引用。
架构上要关注两个缓冲:一是 Persistence Context 的内存缓冲,二是 JDBC 的批量缓冲。如果只调大 batch_size,但不清空一级缓存,结果是内存先扛不住;如果频繁 clear,但 batch_size 太小,网络开销还是下不来。合理的架构是在每批处理后 flush + clear,让两个缓冲区步调一致。
一个典型的批处理架构:一个事务内,每读取或组装 batchSize 条实体,调用 entityManager.persist,当计数达到 batchSize 时,手动 flush() 并 clear(),把 SQL 批次真正发送到数据库,同时释放 JVM 内存。batchSize 通常取 30 到 100,过大容易造成单次 flush 时间长,过小则批次合并不明显。如果业务允许,可以在多个事务间分批提交,例如每 1000 条提交一次,兼顾失败恢复和数据库锁持有时间。
需要指出:Hibernate 的 hibernate.jdbc.batch_size 并不等于业务批次大小,它只是 JDBC 批量数量。通常业务每批处理 100 到 500 条,然后 flush/clear,而 JDBC batch_size 可以设成 50 或 100。二者配合,才能既控制内存,又减少 RTT。
05、实战
以一个 10 万订单导入为例,字段包含订单号、用户 ID、金额、状态、创建时间等。数据库 MySQL 8,应用 Spring Boot 3 + Hibernate 6。测试机器 4C8G,数据库同机房,RTT 约 0.2ms。
测试四组:
A 组:循环 save。在一个事务中逐条 orderRepository.save(order),10 万条,不清缓存。结果:约 35 分钟,OOM 前中断,堆内存最高 4.2G。
B 组:saveAll,每 1000 条一批。一个事务中 saveAll(list1000),不清缓存。结果:约 28 分钟,Full GC 30 次,堆最高 3.8G。
C 组:Hibernate Batch。配置 hibernate.jdbc.batch_size=50、hibernate.order_inserts=true,仍然用 saveAll 每 1000 条,但不清缓存。结果:约 16 分钟,堆最高 4.5G,因一级缓存持续增长反而更差。
D 组:分批 flush/clear + Hibernate Batch + SEQUENCE ID。每 100 条实体 persist 后手动 flush() 和 clear();每 1000 条提交一次事务;ID 策略改为 SEQUENCE/TABLE,batch_size=50。结果:约 42 秒,堆内存稳定在 400M 以内,Full GC 0 次。
代码示例:
@Transactional
public void importOrders(List<Order> orders, int batchSize, int commitSize) {
int count = 0;
for (Order order : orders) {
entityManager.persist(order);
count++;
if (count % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
if (count % commitSize == 0) {
// 事务在调用处控制提交
}
}
}
如果用 Spring Data,也可以注入 EntityManager 手动 flush。ID 策略示例:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
private Long id;
结果对比表:
| 组别 | 耗时 | 堆峰值 | Full GC |
|---|---|---|---|
| A 循环 save | 35 分钟 | 4.2G | 多次 |
| B saveAll | 28 分钟 | 3.8G | 30 次 |
| C 仅 Hibernate Batch | 16 分钟 | 4.5G | 更多 |
| D flush/clear + Batch + SEQUENCE | 42 秒 | 400M | 0 次 |
分析:C 组虽然让 JDBC 批量了,但 Persistence Context 不释放,导致 flush 时脏检查慢且内存高。D 组通过 clear 释放对象,通过 SEQUENCE 让批量生效,通过 batch_size 减少网络往返。
06、洞见
从这次调优可以看出:ORM 批处理成本几乎从来不是数据库单方面的问题,而是对象状态管理、网络往返和数据库写入三层叠加。saveAll 解决的是调用便利性,它不会自动帮你做内存释放和网络合并。很多团队把 ORM 当黑盒,看见 Batch 配置就以为万事大吉,结果一级缓存悄悄吃掉堆内存。
一个事务中 Managed 100 万个 Entity 会导致 JVM 内存暴涨,原因至少有三:每个 Managed 实体都在 Persistence Context 中有引用和快照;脏检查机制需要遍历这些实体,不能很快回收;即使数据库已经插入,一级缓存仍保留,直到事务提交或手动 clear。实践中,哪怕堆内存设置到 8G,100 万实体也可能撑爆,而且 GC 压力陡增,单次 Full GC 可能秒级停顿。
另外,ID 生成策略对 JDBC Batch 的影响常被低估。IDENTITY 会让 Hibernate 必须逐条插入后立即取回 ID,从而关闭 batch。而 TABLE 或 SEQUENCE 通过 allocationSize 预取一批 ID,使批量插入成为可能。网络 RTT 的累计成本则解释了为什么同样逻辑,数据库在本地和跨地域表现差异巨大:10 万次 RTT 可以轻松消耗几十秒。批量 flush 后,把多条 SQL 合并成一次往返,实际网络等待从 O(N) 降为 O(N/batch_size)。
最重要的洞见是:批处理不是一个配置项,而是一套组合拳。必须同时控制 JDBC 批次大小、一级缓存生命周期、事务边界和主键生成策略。四个条件缺一,慢或 OOM 就可能再次出现。
07、行动
如果你现在要写一个带 batchSize 的导入任务,建议按下面步骤落地:
第一步,确定 ID 策略。主键生成不要用 IDENTITY,优先 SEQUENCE 或 TABLE,并设置合理的 allocationSize,例如 50。如果是 MySQL 且业务允许,可考虑用雪花算法或 UUID 应用层生成,Hibernate 完全不用回查 ID,批量写入更顺。
第二步,加 Hibernate 配置。spring.jpa.properties.hibernate.jdbc.batch_size=50、spring.jpa.properties.hibernate.order_inserts=true、spring.jpa.properties.hibernate.order_updates=true、spring.jpa.properties.hibernate.batch_versioned_data=true。注意这些配置是全局的,会提高批量能力,但不能解决内存问题。
第三步,在导入任务中手动 flush/clear。建议每 batchSize 条实体执行一次 entityManager.flush() 和 entityManager.clear()。batchSize 可以从 50 或 100 开始调起。每 commitSize(例如 1000 条)提交一次事务,防止一个事务过大、数据库锁和 undo 日志暴涨。
第四步,监控每批耗时和内存。在每个批次前后记录 System.nanoTime() 和 Runtime.totalMemory() - Runtime.freeMemory(),输出到日志或指标系统。观察两个指标:单批 flush 耗时是否随批次数上升?堆内存是否趋于平稳?如果单批耗时持续上升,说明一级缓存还没清干净,或 dirty check 复杂度高,需要降低 batchSize。如果堆内存持续上升,检查是否在其他地方持有引用,或事务没有及时提交。
第五步,压测验证。用 1 万、5 万、10 万数据量分别测试,记录 TPS、P95 延迟、GC 次数和堆峰值。对比调整前后的结果,把关键指标固化为巡检项。
示例监控代码:
long start = System.nanoTime();
entityManager.flush();
entityManager.clear();
long costMs = (System.nanoTime() - start) / 1_000_000;
// 记录 costMs 和堆内存
最终目标不是把 batchSize 调得越大越好,而是找到内存与网络延迟之间的平衡点。通常 30 到 100 是合理区间,具体以监控数据为准。
08、小结
回到最初的问题:saveAll() 只是批量调用语义,不等同于 JDBC Batch。10 万订单导入要从分钟级压到秒级,核心动作是四件事:用 SEQUENCE/TABLE 让批量生效;配置 Hibernate 的 batch_size 和排序;在业务代码里分批 flush/clear 释放一级缓存;按固定条数提交事务并监控每批耗时与堆内存。缺少任何一环,要么慢,要么 OOM。
不要在同一个事务里堆放 100 万 Managed Entity,不要以为加个 saveAll 就能替代真正的批处理设计。数据库批量写入是优化目标,但它必须和对象生命周期管理、网络往返控制一起做。
09、链接与概念说明
- Hibernate Batch:Hibernate ORM 提供的 SQL 批量执行机制,通过 hibernate.jdbc.batch_size 控制每批语句数,https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#batch
- JDBC Batch:JDBC 规范中的批量执行 API,PreparedStatement.addBatch() 和 executeBatch(),https://docs.oracle.com/javase/tutorial/jdbc/basics/batch.html
- Persistence Context:Hibernate 一级缓存,管理实体状态和脏检查,生命周期通常与 EntityManager 或事务绑定。
- Flush/Clear:Flush 将挂起的 SQL 同步到数据库,Clear 将 Persistence Context 清空,释放对实体对象的引用。
- ID 生成策略:IDENTITY 需要数据库立即生成主键,通常会破坏 JDBC Batch;SEQUENCE 或 TABLE 可预取 ID,更适合批量写入。
- 网络 RTT:客户端与数据库之间一次请求响应的网络耗时,逐条 SQL 时会被放大,JDBC Batch 可显著减少往返次数。
- Spring Data JPA saveAll:对实体集合包装为批量保存调用,默认底层遍历实体执行 persist/merge,不自动开启 JDBC Batch。

浙公网安备 33010602011771号