MyBatis-Plus批量插入性能优化实践

MyBatis-Plus批量插入性能优化实践 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中编写批量插入SQLinsert idbatchInsert parameterTypejava.util.List INSERT INTO user (name, age) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}) /foreach /insert这种方案的优点是简单直观但缺点也很明显需要为每个实体单独编写XML且当表结构变更时需要同步修改多个文件。我在早期项目中采用过这种方式随着业务发展维护成本越来越高。2.2 基于注解的动态SQL方案为了避免XML的维护问题可以考虑使用Insert注解配合动态SQLInsert({ script, INSERT INTO user (name, age) VALUES , foreach collectionlist itemitem separator,, (#{item.name}, #{item.age}), /foreach, /script }) int batchInsert(Param(list) ListUser list);这种方案虽然摆脱了XML文件但SQL字符串拼接容易出错且IDE的语法检查支持较弱。我在团队协作项目中发现开发人员经常因为漏写script标签或参数绑定错误导致运行时异常。2.3 基于SQL注入器的扩展方案经过多次实践我认为最优雅的方案是扩展MyBatis-Plus的SQL注入器机制。这种方案的核心思想是自定义SQL方法注入到BaseMapper动态生成批量插入SQL具体实现需要以下几个组件自定义InsertBatch方法SQL注入器注册机制动态SQL生成器3. 完整实现步骤3.1 定义批量插入方法接口首先在自定义Mapper接口中声明方法public interface CustomBaseMapperT extends BaseMapperT { /** * 批量插入自定义实现 * param entityList 实体列表 * return 影响行数 */ int insertBatch(Param(list) ListT entityList); }3.2 实现SQL注入器创建自定义SQL注入器继承AbstractSqlInjectorpublic class CustomSqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass) { ListAbstractMethod 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 scriptINSERT 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(ListT dataList, int batchSize) { int total 0; for (int i 0; i dataList.size(); i batchSize) { ListT subList dataList.subList(i, Math.min(i batchSize, dataList.size())); total mapper.insertBatch(subList); } return total; }4.2 事务管理批量插入必须放在事务中执行否则部分失败会导致数据不一致Transactional public void batchInsertUsers(ListUser users) { userMapper.insertBatch(users); }重要提示Spring的默认事务超时时间是-1无限制对于大数据量插入建议设置合理超时时间Transactional(timeout 30)4.3 主键生成策略如果使用自增ID需要注意批量插入后无法获取所有记录的ID。解决方案包括使用UUID等客户端生成的主键插入后执行查询获取ID范围使用JDBC的getGeneratedKeys方法需要额外处理5. 实际效果对比测试为了验证优化效果我进行了以下测试环境MySQL 5.710000条记录方式耗时(ms)内存占用(MB)saveBatch452185自定义批量插入62332原生JDBC批量58728测试结果表明自定义批量插入比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注解指定jdbcType6.3 批量大小限制错误现象PacketTooBigException解决方案调整MySQL的max_allowed_packet参数减小单次批量插入的数据量在连接字符串中添加参数jdbc:mysql://...?maxAllowedPacket167772167. 高级扩展方案对于更复杂的场景可以考虑以下优化7.1 多线程批量插入// 使用线程池并行处理 ExecutorService executor Executors.newFixedThreadPool(4); ListFutureInteger 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(,); } // ...原有字段处理 }