ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatisPlus批量插入性能优化与实现方案

MyBatisPlus批量插入性能优化与实现方案

1. 为什么需要自己实现MyBatisPlus批量插入

MyBatisPlus作为MyBatis的增强工具,虽然提供了基础的CRUD操作,但在批量插入场景下存在几个明显的痛点。首先是性能问题,默认的saveBatch()方法实际上是通过for循环执行单条INSERT语句,当处理上千条数据时,与真正的批量插入相比可能有10倍以上的性能差距。

其次是功能限制,比如单次操作默认限制500条记录(可通过调整batchSize参数修改),这在ETL等大数据量场景下显得捉襟见肘。更麻烦的是事务管理——默认实现中如果某条记录插入失败,已执行的插入不会回滚,这与业务系统要求的原子性操作相悖。

我在电商订单系统中就遇到过这样的案例:夜间批量导入10万条促销活动数据时,使用默认方法耗时超过30分钟,而改用真正的批量插入后只需45秒。更关键的是当遇到重复主键时,系统需要完整回滚整个批次而不是部分成功。

2. 三种主流批量插入方案对比

2.1 JDBC原生批量操作

最底层的实现方式是使用JDBC的addBatch()和executeBatch()方法。这种方案性能最佳,实测插入1万条数据仅需800ms左右。核心原理是将多条INSERT语句合并为一个网络请求发送到数据库。

Connection conn = dataSource.getConnection(); conn.setAutoCommit(false); PreparedStatement ps = conn.prepareStatement("INSERT INTO user(name,age) VALUES(?,?)"); for (User user : userList) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.addBatch(); } ps.executeBatch(); conn.commit();

关键点:必须关闭自动提交(setAutoCommit(false)),否则每条语句都会独立提交,失去批量效果

2.2 MyBatis的BATCH执行器

MyBatis内置了BATCH执行器,配置方式如下:

<settings> <setting name="defaultExecutorType" value="BATCH"/> </settings>

或者在SqlSessionFactory中指定:

SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);

这种方案相比JDBC原生方式稍慢(1万条约1.2秒),但能与MyBatis的映射体系更好结合。需要注意的是,执行批量操作后必须调用session.commit(),否则可能因为缓存未刷新导致操作未执行。

2.3 拼接SQL语句

MySQL等数据库支持多值INSERT语法:

INSERT INTO user(name,age) VALUES('张三',20),('李四',22),('王五',25);

通过StringBuilder拼接SQL虽然性能优异(1万条约600ms),但要特别注意SQL注入防护。建议使用MyBatis的 标签安全生成:

<insert id="batchInsert"> INSERT INTO user(name,age) VALUES <foreach collection="list" item="item" separator=","> (#{item.name},#{item.age}) </foreach> </insert>

3. 完整实现方案与性能优化

3.1 基于MyBatisPlus的增强实现

我们可以在BaseMapper基础上扩展批量插入功能。首先定义接口:

public interface ExpandBaseMapper<T> extends BaseMapper<T> { /** * 真正批量插入(JDBC批量模式) * @param entityList 实体列表 * @return 影响行数 */ int insertBatchSomeColumn(@Param("list") List<T> entityList); }

实现类需要继承AbstractMethod并重写injectMappedStatement方法:

public class InsertBatchSomeColumn extends AbstractMethod { @Override public MappedStatement injectMappedStatement(...) { String sql = "<script>INSERT INTO %s %s VALUES %s</script>"; SqlSource sqlSource = languageDriver.createSqlSource(...); return this.addInsertMappedStatement(mapperClass, modelClass, "insertBatchSomeColumn", sqlSource); } }

注册自定义方法:

public class MySqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass) { List<AbstractMethod> methods = super.getMethodList(mapperClass); methods.add(new InsertBatchSomeColumn()); return methods; } }

3.2 动态批次分割策略

处理超大批量数据时(如10万+),需要实现智能分批:

public class BatchInsertHelper { private static final int MAX_BATCH_SIZE = 1000; public static <T> int batchInsert(ExpandBaseMapper<T> mapper, List<T> data, int batchSize) { if(batchSize <=0 || batchSize > MAX_BATCH_SIZE) { batchSize = MAX_BATCH_SIZE; } int total = 0; List<List<T>> partitions = Lists.partition(data, batchSize); for(List<T> batch : partitions) { total += mapper.insertBatchSomeColumn(batch); // 每批提交防止内存溢出 SqlSessionHelper.flushStatements(); } return total; } }

3.3 性能对比测试

使用JMH进行基准测试(单位:毫秒):

数据量saveBatchJDBC批量SQL拼接
1,0001,2008060
10,00012,500800600
100,000超时8,2006,500

实测发现:当单批次超过5000条时,某些数据库驱动会出现内存问题,建议控制在1000-3000条/批

4. 生产环境注意事项

4.1 事务一致性处理

批量操作必须考虑事务原子性。推荐使用编程式事务:

@Transactional public void batchInsertWithTransaction(List<User> users) { try { batchInsertHelper.batchInsert(userMapper, users, 1000); } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw new BusinessException("批量插入失败", e); } }

4.2 主键冲突处理

根据业务需求选择处理方式:

  1. 忽略重复(ON DUPLICATE KEY UPDATE)
INSERT INTO user(id,name) VALUES(1,'张三') ON DUPLICATE KEY UPDATE name=VALUES(name);
  1. 跳过重复(INSERT IGNORE)
INSERT IGNORE INTO user(id,name) VALUES(1,'张三');
  1. 全有或全无(严格事务)

4.3 监控与日志优化

大批量操作需要添加监控点:

StopWatch watch = new StopWatch(); watch.start("batchInsert"); try { // 执行批量插入 } finally { watch.stop(); log.info("批量插入{}条数据,耗时:{}ms", data.size(), watch.getTotalTimeMillis()); Metrics.counter("db.batch.insert") .increment(data.size()); }

5. 高级应用场景

5.1 海量数据分片导入

当处理百万级数据时,可采用「生产者-消费者」模式:

// 创建有界队列 BlockingQueue<List<User>> queue = new ArrayBlockingQueue<>(10); // 生产者线程读数据 executor.submit(() -> { while(hasMoreData()) { List<User> batch = readNextBatch(1000); queue.put(batch); } }); // 消费者线程写数据库 executor.submit(() -> { while(!queue.isEmpty()) { List<User> batch = queue.take(); batchInsert(batch); } });

5.2 与Spring Batch集成

对于需要复杂ETL的场景,可以结合Spring Batch:

@Bean public Step importStep() { return stepBuilderFactory.get("userImport") .<User,User>chunk(1000) .reader(flatFileItemReader()) .processor(validationProcessor()) .writer(userItemWriter()) .build(); } @Bean public ItemWriter<User> userItemWriter() { return items -> { userMapper.insertBatchSomeColumn(items); }; }

5.3 多数据源适配

不同数据库需要调整批量语法:

public class BatchInsertStrategy { public static String generateSql(String table, List<String> columns, int size) { if(dataSourceType == DataSourceType.MYSQL) { // 使用VALUES(),(),()语法 } else if(dataSourceType == DataSourceType.ORACLE) { // 使用INSERT ALL INTO ... SELECT语法 } else if(dataSourceType == DataSourceType.POSTGRESQL) { // 支持COPY命令 } } }

6. 踩坑实录与解决方案

  1. 内存溢出问题:未及时清理的PreparedStatement会导致内存堆积

    • 解决:每批执行后调用clearBatch()
  2. 事务超时:大批量操作超过默认事务超时时间

    • 解决:@Transactional(timeout = 3600)
  3. 主键生成冲突:批量插入时雪花ID生成器产生重复ID

    • 解决:提前批量生成ID或使用数据库序列
  4. 连接泄漏:异常情况下未关闭Connection

    • 解决:try-with-resources语法
  5. MySQL的max_allowed_packet限制

    • 解决:调整批次大小或修改MySQL配置
-- 查看当前包大小 SHOW VARIABLES LIKE 'max_allowed_packet'; -- 临时修改(需重启) SET GLOBAL max_allowed_packet=64*1024*1024;

我在金融交易系统迁移项目中,就遇到过因未考虑Oracle的批量语法差异导致凌晨数据同步失败的情况。后来通过抽象BatchInsertTemplate类,对不同数据库实现差异化处理,才彻底解决问题。这也印证了一个经验:批量操作不能假设所有数据库行为一致,必须针对目标数据库进行充分测试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进