1. 为什么需要自己实现批量插入?
MyBatis-Plus作为MyBatis的增强工具,虽然提供了很多便捷的CRUD操作,但在批量插入方面确实存在一些局限性。官方提供的saveBatch()方法底层实际上是循环执行单条插入,当数据量达到500条时性能下降明显。我在实际项目中就遇到过这样的场景:导入Excel数据时,3000条记录用了近20秒才完成插入,这显然无法满足业务需求。
批量插入与单条循环插入的本质区别在于SQL语句的生成方式。前者使用INSERT INTO table (col1,col2) VALUES (v1,v2),(v3,v4)...的语法,一次网络往返完成多条数据插入;而后者则是多次执行INSERT INTO table (col1,col2) VALUES (v1,v2)。根据MySQL官方文档,批量插入的性能通常比单条插入快10倍以上。
2. 实现方案设计与选型
2.1 基于XML映射文件的传统方案
最直接的实现方式是在Mapper XML中编写批量插入SQL:
<insert id="batchInsert" parameterType="java.util.List"> INSERT INTO user (name, age) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}) </foreach> </insert>这种方案的优点是简单直观,但缺点也很明显:需要为每个实体单独编写XML,且当表结构变更时需要同步修改多个文件。我在早期项目中采用过这种方式,随着业务发展,维护成本越来越高。
2.2 基于注解的动态SQL方案
为了避免XML的维护问题,可以考虑使用@Insert注解配合动态SQL:
@Insert({ "<script>", "INSERT INTO user (name, age) VALUES ", "<foreach collection='list' item='item' separator=','>", "(#{item.name}, #{item.age})", "</foreach>", "</script>" }) int batchInsert(@Param("list") List<User> list);这种方案虽然摆脱了XML文件,但SQL字符串拼接容易出错,且IDE的语法检查支持较弱。我在团队协作项目中发现,开发人员经常因为漏写script标签或参数绑定错误导致运行时异常。
2.3 基于SQL注入器的扩展方案
经过多次实践,我认为最优雅的方案是扩展MyBatis-Plus的SQL注入器机制。这种方案的核心思想是:
- 自定义SQL方法
- 注入到BaseMapper
- 动态生成批量插入SQL
具体实现需要以下几个组件:
- 自定义InsertBatch方法
- SQL注入器注册机制
- 动态SQL生成器
3. 完整实现步骤
3.1 定义批量插入方法接口
首先在自定义Mapper接口中声明方法:
public interface CustomBaseMapper<T> extends BaseMapper<T> { /** * 批量插入(自定义实现) * @param entityList 实体列表 * @return 影响行数 */ int insertBatch(@Param("list") List<T> entityList); }3.2 实现SQL注入器
创建自定义SQL注入器继承AbstractSqlInjector:
public class CustomSqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass) { List<AbstractMethod> methodList = super.getMethodList(mapperClass); methodList.add(new InsertBatch()); return methodList; } }3.3 实现InsertBatch方法
关键部分是实现AbstractMethod的子类:
public class InsertBatch extends AbstractMethod { @Override public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) { String sql = "<script>INSERT INTO %s %s VALUES %s</script>"; String fieldSql = prepareFieldSql(tableInfo); String valueSql = prepareValuesSql(tableInfo); String finalSql = String.format(sql, tableInfo.getTableName(), fieldSql, valueSql); SqlSource sqlSource = languageDriver.createSqlSource( configuration, finalSql, modelClass); return this.addInsertMappedStatement( mapperClass, modelClass, "insertBatch", sqlSource, new NoKeyGenerator(), null, null); } private String prepareFieldSql(TableInfo tableInfo) { StringBuilder fieldSql = new StringBuilder("("); tableInfo.getFieldList().forEach(field -> { fieldSql.append(field.getColumn()).append(","); }); fieldSql.deleteCharAt(fieldSql.length() - 1); fieldSql.append(")"); return fieldSql.toString(); } private String prepareValuesSql(TableInfo tableInfo) { StringBuilder valueSql = new StringBuilder("<foreach collection=\"list\" item=\"item\" separator=\",\">("); tableInfo.getFieldList().forEach(field -> { valueSql.append("#{item.").append(field.getProperty()).append("},"); }); valueSql.deleteCharAt(valueSql.length() - 1); valueSql.append(")</foreach>"); return valueSql.toString(); } }3.4 配置Spring Bean
最后在Spring配置中注册SQL注入器:
@Bean public CustomSqlInjector customSqlInjector() { return new CustomSqlInjector(); }4. 性能优化与注意事项
4.1 批处理大小控制
即使使用批量插入,也需要注意单次操作的数据量。根据MySQL官方建议,单个INSERT语句最好不要超过1MB。我通常设置每1000条数据执行一次批量插入:
public <T> int batchInsert(List<T> dataList, int batchSize) { int total = 0; for (int i = 0; i < dataList.size(); i += batchSize) { List<T> subList = dataList.subList(i, Math.min(i + batchSize, dataList.size())); total += mapper.insertBatch(subList); } return total; }4.2 事务管理
批量插入必须放在事务中执行,否则部分失败会导致数据不一致:
@Transactional public void batchInsertUsers(List<User> users) { userMapper.insertBatch(users); }重要提示:Spring的默认事务超时时间是-1(无限制),对于大数据量插入建议设置合理超时时间:
@Transactional(timeout = 30)
4.3 主键生成策略
如果使用自增ID,需要注意批量插入后无法获取所有记录的ID。解决方案包括:
- 使用UUID等客户端生成的主键
- 插入后执行查询获取ID范围
- 使用JDBC的getGeneratedKeys方法(需要额外处理)
5. 实际效果对比测试
为了验证优化效果,我进行了以下测试(环境:MySQL 5.7,10000条记录):
| 方式 | 耗时(ms) | 内存占用(MB) |
|---|---|---|
| saveBatch | 4521 | 85 |
| 自定义批量插入 | 623 | 32 |
| 原生JDBC批量 | 587 | 28 |
测试结果表明:
- 自定义批量插入比saveBatch快7倍以上
- 内存占用减少约60%
- 与原生JDBC方案性能接近,但开发效率更高
6. 常见问题排查
6.1 SQL语法错误
错误现象:SQLSyntaxErrorException: You have an error in your SQL syntax
可能原因:
- 表名或字段名包含MySQL关键字未转义
- VALUES列表末尾有多余逗号
- 字段数与值数不匹配
解决方案:
// 在prepareFieldSql和prepareValuesSql方法中添加日志 log.debug("Generated SQL: {}", finalSql);6.2 数据类型不匹配
错误现象:DataIntegrityViolationException或类型转换异常
处理建议:
- 检查实体类字段类型与数据库类型
- 特别处理LocalDateTime等时间类型
- 使用@TableField注解指定jdbcType
6.3 批量大小限制
错误现象:PacketTooBigException
解决方案:
- 调整MySQL的max_allowed_packet参数
- 减小单次批量插入的数据量
- 在连接字符串中添加参数:
jdbc:mysql://...?maxAllowedPacket=16777216
7. 高级扩展方案
对于更复杂的场景,可以考虑以下优化:
7.1 多线程批量插入
// 使用线程池并行处理 ExecutorService executor = Executors.newFixedThreadPool(4); List<Future<Integer>> futures = new ArrayList<>(); for (int i = 0; i < 4; i++) { final int start = i * 2500; futures.add(executor.submit(() -> { return batchInsert(dataList.subList(start, start + 2500), 500); })); } int total = futures.stream().mapToInt(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } }).sum();7.2 使用LOAD DATA INFILE
对于超大数据量(百万级),可以考虑生成CSV文件后使用MySQL的LOAD DATA命令:
String sql = "LOAD DATA LOCAL INFILE '" + filePath + "' INTO TABLE " + tableName + " FIELDS TERMINATED BY ','"; jdbcTemplate.execute(sql);7.3 与MyBatis-Plus租户插件集成
如果项目使用了多租户插件,需要在SQL生成时考虑tenant_id字段:
private String prepareFieldSql(TableInfo tableInfo) { StringBuilder fieldSql = new StringBuilder("("); // 添加租户字段 if (tableInfo.isWithTenantId()) { fieldSql.append(tableInfo.getTenantIdColumn()).append(","); } // ...原有字段处理 }