1. 为什么一个看似简单的批量插入能跑 5 分钟
1.1 接手时的真实场景
先说现场。业务里有一个数据同步任务,每天晚上把上游系统推送来的订单明细写入本地 MySQL 库。数据量不大,一张订单明细表,单次任务大约一万条记录。最初的实现可以说是最标准的写法:Service 方法加 @Transactional,循环调用 orderDetailMapper.insert(entity),代码看起来毫无问题。
但第一次在测试环境跑全量数据的那个下午,我盯着进度日志沉默了。一万条数据整整跑了 5 分多钟,日志里每出现一条“事务提交成功”提示,中间要嘀咕好一阵。刚开始我怀疑是数据库抖动,或者服务器负载高。登录 MySQL 查 show processlist,发现数据库 CPU 很闲,Threads_running 也稳定在个位数,没有锁等待,没有慢查询。
那不是数据库的问题,问题一定出在应用层和网络链路上。当时项目里没有接入专业的数据库中间件,用的是 Spring Boot + MyBatis-Plus 原生能力,业务代码是典型的循环保存。我在本地把同样一万条数据又跑了一遍,发现平均每秒只能插入 30 到 50 条。按这个吞吐量,5 分钟一点也不意外。
1.2 把可疑点拆到最小单元
排查这种问题,不能上来就怀疑 MyBatis 配置、怀疑连接池、怀疑事务。要先把执行链路看清楚。
我做了三个最基础的检查,这三个检查基本能覆盖 80% 的 MyBatis 批量插入性能问题。
第一,确认 Mapper 语法。我用的不是 saveBatch,而是自己写的单条 insert 语句。Service 里表面上是一行 mapper.insert(entity),但 MyBatis 默认的 ExecutorType 是 SIMPLE,这意味着每调用一次 insert,MyBatis 都会走一遍完整的 PreparedStatement 创建、参数绑定、SQL 执行、结果映射流程。循环一万次,就是一万次完整执行。
第二,确认事务边界。方法上有 @Transactional,这点本身不坏,但它只是把一万次 insert 放在同一个数据库事务里,并没有把一万次 insert 合并成“批量”动作。每条 SQL 照样要等 MySQL 执行完再返回,网络往返一次都没少。长事务反而让问题更难发现,因为中途不提交,所有 undo log 都堆积在一个事务里。
第三,看 JDBC 连接串。项目里配置的 URL 是 jdbc:mysql://...?useSSL=false&serverTimezone=Asia/Shanghai,没有任何跟批处理相关的参数。这点后来成为整个优化的核心突破口。
在检查过程中,我还顺手发现了一件事:项目的日志配置文件里,把 Mapper 包路径的日志级别调成了 DEBUG。也就是说,每次循环 insert 时,MyBatis 会把 SQL 语句和参数原样打印出来。一万条 SQL 的日志量,本身也贡献了不少运行时间。日志是否影响性能,完全被低估了。
第一轮排查下来,问题轮廓已经很清晰:业务逻辑没有错,单表写入也没有大事务锁冲突,慢就慢在“一万次循环 × 每次完整建语句执行 × 每次网络往返”这条路径上。接下来的核心问题就变成了:怎样才能让这一万次 insert 真正变成“批量”动作。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种常见的“批量插入”写法,到底谁在真正批量
2.1 循环单条 insert:最慢但最容易写
循环调用单条 insert 是最容易想到的写法。
java复制@Transactional
public void saveOrderDetails(List<OrderDetail> list) {
for (OrderDetail detail : list) {
orderDetailMapper.insert(detail);
}
}
这段代码的瓶颈有三个。
第一个瓶颈是 SQL 解析次数。MySQL 服务器每收到一条 insert 语句,都要做词法分析、语法分析、语义检查,然后生成执行计划。一万条 insert,走一万次这些流程。普通的行数据 insert 确实很轻量,但乘上一万倍,成本就在那里。
第二个瓶颈是网络往返。应用和数据库不在同一台机器上,尤其是跨机房、跨可用区部署时,单次网络 RTT 即使只有 1~2ms,一万次申请读响应就是 10 到 20 秒以上。如果应用在公网环境访问内网数据库,网络延迟会更高。
第三个瓶颈是事务刷盘。如果每条 insert 都是自动提交的模式,MySQL 每执行完一条都要刷一次 binlog 和 redo log。就算应用显式包在一个事务里,循环期间没有提交,日志量也不会减少,因为每条 insert 仍然会产生对应的 undo log 和 redo log。
有人可能会说,我有 @Transactional,事务提交只有一次,为什么还慢?因为 @Transactional 解决的只是“提交次数”问题,没有解决“语句执行路径条数”问题。服务器照样要处理一万条独立 SQL。
2.2 foreach 拼接多值 insert:有提升但容易踩雷
很多项目会自然而然改用 MyBatis 的 foreach 语法,把列表拼成一条多值 insert:
xml复制<insert id="batchInsert" parameterType="list" useGeneratedKeys="false">
insert into t_order_detail
(order_no, sku_code, sku_name, quantity, price, create_time)
values
<foreach collection="list" item="item" separator=",">
(#{item.orderNo}, #{item.skuCode}, #{item.skuName},
#{item.quantity}, #{item.price}, #{item.createTime})
</foreach>
</insert>
这条 SQL 最后发送给 MySQL 的是一整条 insert into ... values (...), (...), (...)... 语句。从网络层面看,一万条数据确实只需要一次往返;从服务器角度看,只做了一次语法解析,所以比循环单条 insert 快很多。
但它有几个非常典型的隐患,尤其是数据量一大,问题就会暴露。
一是单条 SQL 太长。MySQL 服务端有个 max_allowed_packet 参数,默认值可能是 16MB 或 64MB,取决于版本和安装配置。一万条订单明细拼在一起,文本量轻轻松松超过几十 MB。一旦超过限制,服务端会直接报 PacketTooBigException 之类的错误;更尴尬的是,单独执行其中一条没问题,用批量工具或脚本导入时报错,很多人第一反应是找 DBA 查运行参数,其实 SQL 本身的体积就已经超限了。
二是 MyBatis 拼接 SQL 时,会有大量的字符串处理。我在项目里测过,一万条记录用 foreach 拼接,MyBatis 自身生成 SQL 文本的过程就要花掉几百毫秒到一秒以上,这个开销虽然比网络往返小,但也不可忽略。
三是如果其中一条数据有问题,整条巨型 SQL 会整体失败。排查时你很难定位到底是哪一行有问题,恢复成本很高。
所以 foreach 拼接多值 insert 适合百行、最多千行级别的批量写入,不适合一万行以上的场景。正确做法是把一万条拆成多个小批,每批 500 或 1000 条,分多次调用 Mapper 方法。这样确实能快不少,但它仍然不是终极方案。
2.3 JDBC 原生 batch 和 MyBatis ExecutorType.BATCH
第三种方法是通过 JDBC 的 batch 机制,MyBatis 提供了对应的 ExecutorType.BATCH 执行器。
先从原理讲。JDBC 的批量提交并不是简单地把多条 SQL 文本拼在一起发出去,而是让应用先把一条 SQL 的预编译语句准备好,然后循环调用 addBatch() 把每次的参数值填充进去,最后再调用 executeBatch() 一次性执行。JDBC 驱动可以针对这种情况做优化,减少客户端和服务器的交互次数。
对应到 MyBatis,要使用这个能力,不能继续用默认的 SqlSessionTemplate,而要手动打开一个 SqlSession,并指定执行器类型为 BATCH:
java复制SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
try {
OrderDetailMapper mapper = session.getMapper(OrderDetailMapper.class);
for (OrderDetail detail : list) {
mapper.insert(detail);
}
session.commit();
} catch (Exception e) {
session.rollback();
throw e;
} finally {
session.close();
}
注意,这里的 Mapper insert 方法和第一章里循环调用的方法,在代码上看起来几乎一模一样。区别只在于:SIMPLE 执行器每次 insert 都会直接送到数据库,而 BATCH 执行器会先缓存在 JDBC 层,最后统一发送。
但是,这里就藏着一个很多人调了很久都没搞明白的点:只把执行器切换成 BATCH,性能不一定有质的改善。我当时的实测是,只切 BATCH,一万条数据从 5 分多钟下降到 3 分钟左右,确实快了一点,但仍然很慢。原因在于 MySQL 的 JDBC 驱动在默认情况下,并没有把 batch 真正合成一次多值 insert 发到服务端。
该轮到连接参数登场了。
3. 让“批量”真正生效的隐藏开关:rewriteBatchedStatements
3.1 MySQL 驱动的默认行为为什么让人失望
先看源码层面。MyBatis 的 BatchExecutor 在实现时,会把一系列相同结构的语句收集到一个 Statement 里,最终调用的是 JDBC 的 executeBatch()。但 executeBatch() 是接口,具体发送方式由数据库驱动决定。
MySQL Connector/J 在很长一段时间里的默认行为是:即使应用调用了 executeBatch(),驱动也会把批里的每一条语句拆开,逐条发给 MySQL 服务器。它在客户端帮忙做了一些本地缓存,减少了应用层一次次调用的开销,但从应用服务器到 MySQL 服务器的网络路径上,本质上仍然是一万条独立 SQL,一条都没少。
这就解释了为什么我用 ExecutorType.BATCH 之后性能有提升,却没有大幅提升。提升的部分只是应用层和驱动层的调用开销被压缩了,但真正耗时的网络往返和服务器 SQL 解析没有明显变化。
要让 MySQL 驱动把这些语句真正合并成一条多值 SQL 再发出去,必须显式打开一个参数:
code复制rewriteBatchedStatements=true
这个参数的作用是,当驱动检测到一批 SQL 都是同一种模式的 INSERT,且结构完全一致时,会把它们内部的 values 部分重写合并到一个 INSERT 语句中。也就是说,一万条单行 insert 语句,真正通过网络发送到 MySQL 服务器的,可能只是十来条批量 SQL,每条 SQL 包含几百个 values 元组。
配置方式是修改 JDBC URL:
text复制jdbc:mysql://数据库地址:3306/业务库?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
光看名字会误以为它是“重写批量语句”,理解成把批量 SQL 合并成大 SQL 就好。它确实是解决 MySQL 批量插入性能的关键。
3.2 哪些情况下这个开关不生效或有副作用
rewriteBatchedStatements=true 不是万能药,有几种情况要特别小心。
第一种,SQL 语句不能随意重写。它主要针对的是 INSERT、REPLACE 这类 write SQL。如果你的批量语句是 UPDATE ... WHERE id = ?,驱动无法把多条 update 合并成一条 SQL,因为 semantics 不允许,参数也没法简单拼装。遇到这种情况,batch 能减少客户端调用开销,但服务器端还是要逐条执行。
第二种,useServerPrepStmts=true 会干扰 rewrite 的生效。MySQL 连接串里如果开了服务端预编译,驱动可能无法对 SQL 文本做批量重写,导致这个参数白设。实践中的做法是不要开启 useServerPrepStmts=true,让 MySQL 走客户端预编译,配合 cachePrepStmts=true 也不会有太大问题。
第三种,useGeneratedKeys="true" 会影响 batch 的优化空间。MyBatis 的 insert 语句如果配置了回填自增主键,驱动在执行完后需要拿到 getGeneratedKeys() 的结果。当多条 insert 被 rewrite 成一条多值 SQL 后,自增主键的生成关系就很难精确映射回原对象。测试下来,很多版本下会退化为逐条执行。所以如果批量插入场景根本不需要应用侧获取自增 ID,建议把 useGeneratedKeys 关掉,这是很多性能优化文章没有强调的坑。
下面这个表格是我在本项目里实际用的连接参数组合,可以直接参考:
| 参数 | 值 | 作用 |
|---|---|---|
| rewriteBatchedStatements | true | 核心参数,驱动将批量 SQL 重写为多值 insert |
| useServerPrepStmts | false | 避免服务端预编译干扰 rewrite |
| cachePrepStmts | true | 缓存客户端预编译语句,减少重复准备 |
| prepStmtCacheSize | 250 | 缓存数量,业务 SQL 不多时不需要太大 |
| useLocalSessionState | true | 减少连接状态查询的往返次数 |
| characterEncoding | utf8mb4 | 保证中文等字符正确存储 |
| useSSL | false | 内网环境省去 SSL 握手开销 |
| allowMultiQueries | 不设置 | 不要开,与批量语句合并无关且有安全隐患 |
4. 从 5 分钟到 3 秒的完整改造过程
4.1 改造 Mapper 接口与 XML
我在改造时没有把 Mapper 方法改成 foreach,因为那只是绕开问题,不是理解问题。我把 Mapper 接口保持为单条 insert,并在 XML 里关闭 useGeneratedKeys:
java复制public interface OrderDetailMapper {
int insert(OrderDetail detail);
}
xml复制<insert id="insert" parameterType="OrderDetail" useGeneratedKeys="false">
insert into t_order_detail
(order_no, sku_code, sku_name, quantity, price, create_time)
values
(#{orderNo}, #{skuCode}, #{skuName},
#{quantity}, #{price}, #{createTime})
</insert>
这里尤其要提醒 useGeneratedKeys。如果之前业务依赖插入后的自增 ID,需要先问清楚:批量导入任务里,真的需要每条数据的自增 ID 吗?大多数批量任务只是把上游数据原样写入,不需要回填 ID,关掉没有任何副作用。如果确实需要拿到每个实体的自增主键,那就意味着 rewrite 大概率退化为逐条执行,性能目标会打折扣,需要从别的方向找补。
4.2 使用 Batch Executor 并控制批次大小
手动事务和提交时机的控制,是实现 3 秒优化里最关键的工程细节。如果全程用一个事务包住一万条数据,一次性 commit,对 MySQL 来说事务日志量会非常大,而且一旦出现异常,整个任务只能整体回滚,对超大 batch 非常不友好。如果每 1 条就提交一次,那又回到逐条 commit 的泥潭,性能照样完蛋。
合理的做法是:每积累一定数量就 flushStatements(),把当前批推送到 MySQL,但不要立即 commit;等所有批次都发送完,再做一次最终 commit。这样可以平衡网络交互次数、事务日志量和出错恢复成本。
我实现的大致逻辑如下:
java复制public int importOrderDetails(List<OrderDetail> list) {
int batchSize = 1000;
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
int count = 0;
try {
OrderDetailMapper mapper = session.getMapper(OrderDetailMapper.class);
for (int i = 0; i < list.size(); i++) {
mapper.insert(list.get(i));
count++;
if (count % batchSize == 0) {
session.flushStatements();
}
}
session.commit();
return count;
} catch (Exception e) {
session.rollback();
throw new RuntimeException("批量插入失败", e);
} finally {
session.close();
}
}
这里 flushStatements() 会触发当前缓存批次的 SQL 发送。不要认为它等于 commit,它只是把数据推到数据库执行引擎,事务仍然没有提交。如果后面发生异常,还是可以 rollback。
batchSize 的选择需要根据单行数据体积和表字段数量来定。我这边一行记录约 200 字节,8 个字段,1000 条一批十分合适。如果单行数据里包含很长的 remark 或 JSON 字段,建议缩小到 500,甚至 200。
4.3 实测结果:参数每加一个,耗时变化多少
为了不让结论停留在“感觉很爽”层面,我把各阶段的实测结果整理了出来。测试环境:Spring Boot 应用一台,MySQL 8.0 一台,同一内网,约 0.5ms RTT;数据量 10000 条,单表 8 个字段。
| 方案 | 耗时 | 备注 |
|---|---|---|
| 循环单条 insert + @Transactional | 约 5 分 20 秒 | 基线版本 |
| ExecutorType.BATCH,无 rewrite | 约 2 分 50 秒 | 客户端调用开销减少,但网络未合并 |
| ExecutorType.BATCH + rewriteBatchedStatements,无分批 | 约 8 秒 | 大部分收益来自 rewrite |
| ExecutorType.BATCH + rewrite + 每 1000 flush | 约 3.5 秒 | 推荐的一档 |
| 以上全部 + 关闭 useGeneratedKeys + 关闭 SQL 日志 | 约 2.9 秒 | 最终线上配置 |
可以看到,真正带来质变的是 rewriteBatchedStatements=true。前面执行的 BATCH 切换、手动事务只是打好了地基。关闭 SQL 日志和 useGeneratedKeys 是压掉最后零点几秒的关键,如果在日志量大的生产环境里,日志关闭带来的收益会更大。
5. 从 Demo 到上线的工程化落点
5.1 Spring 事务与 BATCH 模式不能无脑混用
很多人在本地用小 Demo 测试时一切正常,一旦把代码放进 Spring 管理的 Service 层,套上 @Transactional,就开始出现各种奇怪问题:要么事务根本没生效,要么明明手动调用了 commit,外层方法却抛 TransientDataAccessResourceException。
原因在于 Spring 的 SqlSessionTemplate 和数据源事务管理器有自己的一套连接绑定逻辑。如果你的 Service 方法已经开启了事务,Spring 事务管理器会把一个数据库连接绑定到当前线程上;如果你再手动从 sqlSessionFactory.openSession(ExecutorType.BATCH, false) 打开一个新的 SqlSession,这个新 Session 用的连接和线程绑定的连接不是同一个,commit 和外部事务容易互相干扰。
我踩过一次比较深的坑是:外层 Service 有 @Transactional,内层批量方法又手动 open session,数据实际上是用手动 session 的隔离连接插入的。外层方法结束时,Spring 事务管理器尝试提交它管理的那个连接;而手动 session 的数据已经先行提交或回滚了。最后项目里出现了“看似整体回滚,其实部分数据没回滚干净”的情况,非常隐蔽。
所以我的建议是:批量插入任务的 Service 方法上不要加 @Transactional,直接用批处理方法内部手动控制事务。如果整个业务链路确实需要和别的写操作保持同一原子性,那就要考虑用一个专用的事务模板,把执行器类型设为 BATCH,让 Spring 统一管理 SqlSession。示例做法是构造一个 SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH),配合 @Transactional 注解使用。但要注意,这个 SqlSessionTemplate 要注入到与普通业务代码不同的独立 Bean,避免影响全局默认的 SIMPLE 执行器。
5.2 异常回滚与幂等策略
大文件、大数据量场景下,最怕的不是慢,怕的是中途失败后不知道哪些数据入库了,哪些没入库,然后又不敢盲目重跑。
如果业务不要求整批任务严格原子性,推荐按批次记录进度。每 1000 条提交一次,把成功批次的最后一个 ID 或具体业务编号记录到任务日志表里;出错后从失败批次开始续传。配合唯一索引或业务编号做幂等校验,就能实现“断点续传”的效果。这种方案适合订单导入、对账明细同步等场景。
如果业务严格要求要么全成功、要么全失败,那手动分批提交的模型就不太合适,因为一批一旦 commit,前面的批次无法用 rollback 撤回。这时候思路要反过来:先把所有要插入的数据写入一张临时表,校验通过后,再用一条 INSERT INTO ... SELECT FROM 临时表 把数据插入业务表。这个方案可以保证最终数据原子性,同时写入临时表时依然能享受 BATCH 优化的红利。等临时表数据量验证 OK 后,再动态迁移到真实业务表,迁移阶段即使执行几分钟业务也不受影响。
5.3 MyBatis-Plus 的 saveBatch 并不一定是“免检产品”
如果你是 MyBatis-Plus 用户,可能直接调用了 IService.saveBatch(list),发现和逐条 insert 快不了多少。原因就是连接串里缺了 rewriteBatchedStatements=true。MyBatis-Plus 的 saveBatch 内部确实会走批量执行器,但它改变不了 MySQL 驱动层的默认行为。所以先用上面的 JDBC URL 配置跑一次,可能立刻就有改观。
另一个容易踩的坑是 MyBatis-Plus 的逻辑删除。实体类上标注了 @TableLogic 之后,默认的查询方法都会自动追加 deleted = 0 条件。如果你在批量插入前还要先查一下“这批订单号里哪些已经存在”,就可能在循环中触发上万条带逻辑删除条件的小查询,拖慢整个任务。更好的做法是先把上游订单号一次性查出来,用 in 条件在内存里做差集,不要循环查询。这里并不是说逻辑删除一定拖慢了插入,而是这类框架自动附加条件会影响你对系统行为的判断,排查时容易被误导。
6. 更深层的调优手段与常见坑
6.1 单条多值 SQL 长度受 max_allowed_packet 影响
打开 rewriteBatchedStatements 之后,驱动会把一批 insert 合并成一条很长的 SQL。如果一批的条数设置得非常大,比如一次把一万条数据全塞进一批,生成的 SQL 文本可能超过 MySQL 服务端的 max_allowed_packet。此时表现往往是单条数据插入没问题,批量插入报错;用 DBeaver 或 Navicat 导入大批量数据也可能报同样的错误,单独执行一条却正常。
线上如果遇到这种情况,不要只在应用代码层面找原因。先检查数据库服务端参数:
sql复制show variables like '%max_allowed_packet%';
默认值一般有 64MB 或 16MB。注意 max_allowed_packet 有两个作用层级,服务端和客户端各自有最大值限制,都影响最终能不能顺利发送。修改服务端参数需要在 MySQL 配置文件 my.cnf 的 [mysqld] 段下设置,然后重启 MySQL;连接侧的参数可以继续加在 JDBC URL 中,例如 maxAllowedPacket=67108864,但这个连接参数与驱动版本有关,有时直接在应用程序里通过 max_allowed_packet 发送大数据反而更简单。
经验上,与其盲目调大 max_allowed_packet,不如把 batchSize 控制在一个合理范围。1000 条一批,单条数据 200 字节,合成文本不到 300KB;但如果你把单条记录从 200 字节换成包含 2KB 文本的 article_content,1000 条就会接近 3MB,500 条一批才是稳妥选择。也就是说 batchSize 要根据字段宽度动态调整,不能写死不换。
6.2 打印 SQL 日志对批处理的隐性伤害
前面提到日志,值得单独再强调一次。排查问题时,我们确实需要看到 MyBatis 实际发送的 SQL,但批量任务里如果使用常规的样式输出,会把一万条 SQL 全部打印出来。日志本身就是大量的 IO 操作,如果日志系统还会做异步刷盘或上报,CPU 和内存的额外开销会非常可观。
实践中的处理方式是把 SQL 打印严格限定在开发环境。生产环境即使需要排查,也优先使用 MySQL 的 general_log 配合过滤条件查看,或者让 MyBatis 的日志插件只打印第一个分页的参数,而不是把所有参数完整序列化。很多 MyBatis 日志插件会默认把每条 SQL 都打完整,即使你只执行了批量插入,控制台也不会像想象中那样显示一条合并后的多值 SQL,反而可能因为参数太多把 I/O 打满。这个点如果不注意,即使前面 JDBC URL 和 ExecutorType 全部调到位,最终性能也会被日志拖住三分之一以上。
6.3 当数据量上升到十万、百万级,单一连接还能不能打
本文的优化目标是万行级别,方案也主要围绕单连接串行批处理展开。但如果未来数据量继续增长到十万、百万级,光靠一个 SQL 连接执行 batch 可能不够。
优先做的是横向扩展,把数据按业务维度或主键 hash 分片,用多个线程同时写多张目标表,
