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进行基准测试(单位:毫秒):
| 数据量 | saveBatch | JDBC批量 | SQL拼接 |
|---|---|---|---|
| 1,000 | 1,200 | 80 | 60 |
| 10,000 | 12,500 | 800 | 600 |
| 100,000 | 超时 | 8,200 | 6,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 主键冲突处理
根据业务需求选择处理方式:
- 忽略重复(ON DUPLICATE KEY UPDATE)
INSERT INTO user(id,name) VALUES(1,'张三') ON DUPLICATE KEY UPDATE name=VALUES(name);- 跳过重复(INSERT IGNORE)
INSERT IGNORE INTO user(id,name) VALUES(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. 踩坑实录与解决方案
内存溢出问题:未及时清理的PreparedStatement会导致内存堆积
- 解决:每批执行后调用clearBatch()
事务超时:大批量操作超过默认事务超时时间
- 解决:@Transactional(timeout = 3600)
主键生成冲突:批量插入时雪花ID生成器产生重复ID
- 解决:提前批量生成ID或使用数据库序列
连接泄漏:异常情况下未关闭Connection
- 解决:try-with-resources语法
MySQL的max_allowed_packet限制
- 解决:调整批次大小或修改MySQL配置
-- 查看当前包大小 SHOW VARIABLES LIKE 'max_allowed_packet'; -- 临时修改(需重启) SET GLOBAL max_allowed_packet=64*1024*1024;我在金融交易系统迁移项目中,就遇到过因未考虑Oracle的批量语法差异导致凌晨数据同步失败的情况。后来通过抽象BatchInsertTemplate类,对不同数据库实现差异化处理,才彻底解决问题。这也印证了一个经验:批量操作不能假设所有数据库行为一致,必须针对目标数据库进行充分测试。