测试的项目代码:https://gitee.com/lickq/jenkins-springboot-test/tree/batchSave/
自定义mybatis-plus拦截器,记录拦截次数
package com.springboot.mytest.interceptor; import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; import org.apache.ibatis.executor.Executor; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.SqlCommandType; import org.apache.ibatis.session.ResultHandler; import org.apache.ibatis.session.RowBounds; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.SQLException; import java.util.Collection; /** * 批量保存日志拦截器(实现MyBatis-Plus的InnerInterceptor接口) */ public class BatchSaveLoggerInnerInterceptor implements InnerInterceptor { static volatile Integer n = 0; @Override public boolean willDoUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException { // 只处理插入操作 if (ms.getSqlCommandType() == SqlCommandType.INSERT) { n++; System.out.println("触发次数"+n); } return true; // 允许继续执行更新操作 } // 其他默认方法可以保持默认实现 @Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, org.apache.ibatis.mapping.BoundSql boundSql) throws SQLException { } @Override public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException { } }
一、示例测试
1、for循环中调用save方法
@Test void test1() { List<MyTest> list = new ArrayList<>(); for (Integer i = 0; i < 1000; i++) { MyTest test = MyTest.builder().name(i.toString()).age(i).build(); list.add(test); } long startTime = System.currentTimeMillis(); for (MyTest myTest : list) { testService.save(myTest); } long endTime = System.currentTimeMillis(); System.out.println("耗时:" + (endTime - startTime) / 1000 + "秒"); }
最终输出:
耗时100秒,触发拦截器1000次。
2、调用mybatis-plus方法:saveBatch
@Test void test2() { List<MyTest> list = new ArrayList<>(); for (Integer i = 0; i < 1000; i++) { MyTest test = MyTest.builder().name(i.toString()).age(i).build(); list.add(test); } long startTime = System.currentTimeMillis(); testService.saveBatch(list, 200); long endTime = System.currentTimeMillis(); System.out.println("耗时:" + (endTime - startTime) / 1000 + "秒"); }
最终输出:
一千条,每批次200条,分为5个批次提交
耗时40秒,触发拦截器1000次。
3、自己实现一个batchInsert方法
@Test void test3() { List<MyTest> list = new ArrayList<>(); for (Integer i = 0; i < 1000; i++) { MyTest test = MyTest.builder().name(i.toString()).age(i).build(); list.add(test); } long startTime = System.currentTimeMillis(); testMapper.batchInsert(list); long endTime = System.currentTimeMillis(); System.out.println("耗时:" + (endTime - startTime) / 1000 + "秒"); }
batchInsert方法
/** * 批量插入数据 * @param list 要插入的MyTest对象列表 * @return 插入成功的记录数 */ @Insert("<script>" + "INSERT INTO test (id, name, age) VALUES " + "<foreach collection='list' item='item' separator=','>" + "(#{item.id}, #{item.name}, #{item.age})" + "</foreach>" + "</script>") int batchInsert(@Param("list") List<MyTest> list);
最终输出:
耗时1秒,触发拦截器1次。
4、还是用mybatis-plus方法:saveBatch,不过增加了rewriteBatchedStatements=true
@Test void test2() { List<MyTest> list = new ArrayList<>(); for (Integer i = 0; i < 1000; i++) { MyTest test = MyTest.builder().name(i.toString()).age(i).build(); list.add(test); } long startTime = System.currentTimeMillis(); testService.saveBatch(list, 200); long endTime = System.currentTimeMillis(); System.out.println("耗时:" + (endTime - startTime) / 1000 + "秒"); }
最终输出:
耗时1秒,触发拦截器1000次。
二、mybatis-plus的saveBatch
底层代码:
@Transactional( rollbackFor = {Exception.class} ) public boolean saveBatch(Collection<T> entityList, int batchSize) { String sqlStatement = this.getSqlStatement(SqlMethod.INSERT_ONE); return this.executeBatch(entityList, batchSize, (sqlSession, entity) -> { sqlSession.insert(sqlStatement, entity); }); }
1. 底层执行逻辑
saveBatch 方法本质上是通过 MyBatis 的 Executor.BATCH 执行器实现的,大致流程为:
- 预编译 SQL:首先对插入语句(如
INSERT INTO table (...) VALUES (...))进行预编译,生成一个预编译 SQL 对象(PreparedStatement)。 - 循环绑定参数:遍历待插入的实体列表,为预编译 SQL 循环绑定不同的参数(每次绑定一个实体的数据)。
- 批量提交:当参数绑定到一定数量(或遍历结束)时,调用
executeBatch()一次性次性提交所有绑定的参数。
2. 与开启 rewriteBatchedStatements 的区别
- 未开启时:
MySQL 驱动不会重写 SQL,executeBatch()会将批量操作拆分为多条独立的单条 INSERT 语句,通过网络逐条发送给数据库执行(只是客户端批量发起,数据库仍逐条处理)。
例如,插入 1000 条数据会生成 1000 条INSERT INTO ... VALUES (...)语句,网络交互次数为 1000 次(或按批次大小分几次,但本质仍是单条执行)。 - 开启时:
MySQL 驱动会将多条单条 INSERT 重写为一条批量 INSERT 语句(如INSERT INTO ... VALUES (...), (...), (...)),只需一次网络交互即可完成批量插入,效率大幅提升。
3. 性能特点
- 相比完全手写循环单条插入,
saveBatch未开启rewriteBatchedStatements时仍有优化:通过预编译 SQL 减少了 SQL 解析开销,且通过BATCH执行器减少了部分客户端与数据库的交互次数(按批次提交)。 - 但由于未合并 SQL,网络交互次数仍较多,在大数据量(如万级以上)场景下,性能远低于开启
rewriteBatchedStatements的情况。
总结
未设置 rewriteBatchedStatements=true 时,MyBatis-Plus 的 saveBatch 是通过预编译 SQL + 批量绑定参数 + 分批次提交的方式执行,但最终数据库仍会逐条处理插入语句。因此,对于大量数据的批量插入,建议开启 rewriteBatchedStatements 以利用 MySQL 的批量 SQL 优化。
三、解释
1、为什么示例2比示例1快?
由于示例2中采用了mybatis-plus的saveBatch,通过预编译 SQL 减少了 SQL 解析开销、BATCH 执行器减少客户端与数据库的交互次数。
1)、预编译 SQL 减少 SQL 解析开销
手写循环单条插入的问题:
for (MyTest myTest : list) { testService.save(myTest); }
- 每次循环都会向数据库发送一条完整的 SQL 语句(如
INSERT INTO Test(...) VALUES(...))。 - 数据库收到每条 SQL 后,都需要执行「语法解析→语义分析→生成执行计划」的完整流程(即使 SQL 结构完全一样)。
- 1000 条数据就会重复执行 1000 次相同的解析过程,这部分属于无意义的性能损耗。
saveBatch 的优化:
saveBatch 会使用 MyBatis 的预编译机制:
- 首先将 SQL 模板(如
INSERT INTO user(name, age) VALUES(?, ?))发送给数据库,数据库只解析一次,生成可复用的执行计划。 - 之后循环时,只向数据库发送参数(name 和 age 的值),不再重复发送完整 SQL,也不再重复解析。
- 1000 条数据只需要 1 次 SQL 解析,剩下的都是直接复用执行计划 + 填充参数,大幅减少数据库的解析开销。
从日志中也可以看出来:
2)、BATCH 执行器减少客户端与数据库的交互次数(按批次提交)
手写循环单条插入的问题:
手写循环插入时,默认是「执行一条 SQL 就立即提交一次」:
- 每条 INSERT 语句都会触发一次客户端(应用程序)与数据库的网络交互(发送 SQL + 参数→数据库执行→返回结果)。
- 1000 条数据就会产生 1000 次网络往返,而网络通信(哪怕是本地数据库)是相对耗时的操作(涉及 TCP 握手、数据包传输等)。
- 同时,每次提交都会触发数据库的事务日志刷新(如 MySQL 的 binlog),频繁提交会加剧性能损耗。
saveBatch 的优化:
saveBatch 使用 MyBatis 的BATCH执行器,本质是「累积一定数量的 SQL 后再批量提交」:
- 执行流程是:循环绑定参数→暂存到本地缓存→达到批次大小(默认 1000 条,可配置)→一次性发送所有参数到数据库执行→一次提交。
- 例如插入 1000 条数据,可能只需要 1 次网络交互(或按配置的批次大小分几次,比如 200 条一批,只需 5 次),而非 1000 次。
- 减少了网络往返次数,同时合并了事务提交,降低了数据库的日志刷新频率。
总结对比
操作方式 |
SQL 解析次数 |
网络交互次数 |
事务提交次数 |
手写循环单条插入 |
1000 次(每条都解析) |
1000 次(每条一次) |
1000 次(默认自动提交) |
saveBatch(未开 rewrite) |
1 次(只解析模板) |
约 1 次(按批次提交) |
约 1 次(按批次提交) |
2、为什么示例3比示例2快?
(1)batchInsert 方法(用 <foreach> 标签)
- 执行原理:通过 MyBatis 的
<foreach>标签,直接在 SQL 层面拼接出一条合并后的批量插入语句。
例如,插入 3 条数据时,生成的 SQL 是:sql
INSERT INTO test (id, name, age) VALUES (#{item1.id}, #{item1.name}, #{item1.age}), (#{item2.id}, #{item2.name}, #{item2.age}), (#{item3.id}, #{item3.name}, #{item3.age})
- 数据库执行方式:数据库会将这条 SQL 当作一个完整的批量操作处理,只需一次解析、一次执行,效率极高。
(2)未开启 rewriteBatchedStatements=true 的 saveBatch
- 执行原理:依赖 MyBatis 的
BATCH执行器,虽然会预编译 SQL 并批量提交,但生成的仍是多条独立的单条插入语句(只是客户端一次性发送给数据库)。
例如,插入 3 条数据时,实际执行的是 3 条独立 SQL:sql
INSERT INTO test (id, name, age) VALUES (#{item1.id}, #{item1.name}, #{item1.age}); INSERT INTO test (id, name, age) VALUES (#{item2.id}, #{item2.name}, #{item2.age}); INSERT INTO test (id, name, age) VALUES (#{item3.id}, #{item3.name}, #{item3.age});
- 数据库执行方式:数据库会逐条解析并执行这 3 条 SQL(即使客户端一次性发送),本质仍是多次单条操作的累积,效率低于单条批量 SQL。
(3)性能差异的核心原因
对比维度 |
标签的批量插入 |
未开 的 |
SQL 数量 |
1 条(合并后的批量 SQL) |
N 条(与数据量相同的单条 SQL) |
数据库解析次数 |
1 次(仅解析一次批量 SQL) |
N 次(每条 SQL 都需解析,即使结构相同) |
数据库执行逻辑 |
一次批量写入操作 |
N 次单条写入操作的累积 |
网络交互次数 |
1 次(发送一条 SQL) |
1 次(客户端批量发送 N 条 SQL,与前者相同) |
可见,<foreach> 标签的批量插入在数据库层面的执行效率上占据绝对优势 —— 它将多条插入逻辑合并为数据库原生支持的批量操作,避免了数据库对多条单条 SQL 的重复解析和执行开销。
(4)注意事项
- 这种方式的局限性是:当数据量极大(如超过 1 万条)时,拼接出的 SQL 语句会非常长,可能导致数据库接收的 SQL 长度超过限制(需调整数据库配置,如 MySQL 的
max_allowed_packet)。 - 与开启
rewriteBatchedStatements=true的saveBatch相比,两者最终效果类似(都生成VALUES(...),(...)形式的批量 SQL),但<foreach>是在应用层拼接,而rewriteBatchedStatements是在 MySQL 驱动层自动重写。
3、为什么示例4比示例2快?
rewriteBatchedStatements 是 MySQL JDBC 驱动(com.mysql.cj.jdbc.Driver)的一个连接参数,它在批量操作场景中与 MyBatis-Plus 的批量处理功能结合使用时能显著提升性能。
其核心作用是:开启 MySQL 的批量 SQL 重写功能,将多条相同结构的 SQL 语句合并为一条批量语句执行。
例如:
将
INSERT INTO user (name) VALUES ('a'); INSERT INTO user (name) VALUES ('b');
重写为
INSERT INTO user (name) VALUES ('a'), ('b');
从而减少网络交互次数,大幅提升批量操作的效率(尤其是数据量较大时)。
4、示例2和示例4谁更快
1. 两者的底层执行逻辑对比
(1)<foreach> 标签的批量插入
- SQL 生成时机:在应用层(MyBatis 动态 SQL 阶段)直接拼接出一条完整的批量 SQL,例如:sql
INSERT INTO test (id, name, age) VALUES (1,'a',20), (2,'b',21), (3,'c',22)
- 数据库执行:通过一次网络请求发送这条 SQL,数据库直接执行一次批量插入。
(2)开启 rewriteBatchedStatements=true 的 saveBatch
- SQL 生成时机:MyBatis 的
BATCH执行器先预编译单条 SQL(INSERT INTO test (...) VALUES (?)),然后循环绑定参数;最终由 MySQL 驱动在底层将多条单条 SQL 重写为上述批量 SQL 格式。 - 数据库执行:同样通过一次网络请求发送重写后的批量 SQL,数据库执行一次批量插入。
2. 性能差异的关键场景
(1)数据量较小时(如 1000 条以内)
两者性能几乎无差别,因为最终执行的都是同一种格式的批量 SQL(VALUES(...),(...)),数据库处理逻辑完全一致。
(2)数据量较大时(如 1 万条以上)
<foreach>标签可能略慢:因为 SQL 拼接在应用层完成,当数据量极大时,拼接出的 SQL 字符串会非常长(可能达几十 KB 甚至更大),这会消耗应用服务器的内存和 CPU(字符串拼接开销),且传输长 SQL 本身也会占用更多网络带宽。saveBatch可能更优:MySQL 驱动在重写 SQL 时采用更高效的二进制协议处理参数,避免了应用层拼接长字符串的开销,对内存和网络的压力更小。
3. 其他影响因素
- 数据库配置:若 MySQL 的
max_allowed_packet配置较小,<foreach>拼接的超长 SQL 可能触发「数据包过大」错误,而saveBatch由驱动处理,更不容易触发此问题。 - 代码维护性:
saveBatch是 MyBatis-Plus 封装的方法,无需手动编写动态 SQL,代码更简洁;而<foreach>标签需要手动维护 SQL 模板,扩展性较差(如新增字段时需同步修改 SQL)。
结论
- 中小数据量(推荐
saveBatch):两者性能接近,但saveBatch代码更简洁,且避免了手动拼接 SQL 的潜在问题(如 SQL 过长)。 - 超大数据量(倾向
saveBatch):saveBatch由驱动处理 SQL 重写,内存和网络效率更高,稳定性更好。 - 极端场景验证:若对性能有极致要求,建议针对具体业务数据量进行压测(如 1 万、10 万条数据对比),不同数据库版本和服务器配置可能出现细微差异。
总体而言,开启 rewriteBatchedStatements=true 的 saveBatch 是更优的选择 —— 它兼顾了性能、稳定性和开发效率。