MyBatis-Plus saveBatch异步事务问题分析与解决方案

📅 2026/8/3 2:57:37 👁️ 阅读次数 📝 编程学习
MyBatis-Plus saveBatch异步事务问题分析与解决方案

1. 问题背景与现象描述

最近在开发一个电商订单处理系统时,遇到了一个棘手的问题:使用MyBatis-Plus的saveBatch方法在异步线程中批量插入数据时,发现事务没有正常提交。具体表现为:

  • 系统使用Spring Boot + MyBatis-Plus架构
  • 订单创建后需要异步处理库存扣减和日志记录
  • 使用@Async注解标记的异步方法中调用了saveBatch批量插入操作日志
  • 日志表中有部分记录插入成功,部分记录丢失
  • 没有抛出任何异常,但数据不完整

这个问题在测试环境偶发出现,但在高并发压测时几乎必现。经过排查发现,这与MyBatis-Plus的批量操作机制、Spring事务管理以及异步线程处理有密切关系。

2. MyBatis-Plus saveBatch原理剖析

2.1 saveBatch的默认实现

MyBatis-Plus的saveBatch方法默认实现是这样的:

// MyBatis-Plus 3.x版本的默认实现 @Transactional(rollbackFor = Exception.class) @Override public boolean saveBatch(Collection<T> entityList, int batchSize) { String sqlStatement = sqlStatement(SqlMethod.INSERT_ONE); return executeBatch(entityList, batchSize, (sqlSession, entity) -> { sqlSession.insert(sqlStatement, entity); }); }

关键点:

  1. 方法本身带有@Transactional注解
  2. 使用MyBatis的SqlSession执行批量插入
  3. 默认batchSize为1000

2.2 批量操作的执行流程

saveBatch的实际执行流程可以分为以下几个步骤:

  1. 开启事务(由Spring管理)
  2. 对集合进行分片处理(根据batchSize)
  3. 对每个分片执行批量插入
  4. 提交事务(如果成功)或回滚(如果失败)

问题在于,当这个方法在异步线程中执行时,第4步的事务提交可能不会按预期工作。

3. 异步环境中的事务问题

3.1 Spring事务管理机制

Spring的事务管理是基于ThreadLocal实现的,关键点包括:

  1. 事务上下文存储在ThreadLocal中
  2. @Transactional注解的事务传播行为默认是REQUIRED
  3. 异步方法会使用新的线程执行,无法继承原有的事务上下文

3.2 @Async与事务的交互

当我们在异步方法中使用@Transactional时,会遇到以下问题:

  1. 异步方法本身需要@Async注解
  2. 如果异步方法内部有@Transactional,会创建新的事务
  3. 这两个注解的执行顺序和交互需要特别注意

3.3 典型的问题场景

在我们的案例中,代码结构大致如下:

@Service public class OrderService { @Autowired private AsyncLogService asyncLogService; @Transactional public void createOrder(OrderDTO dto) { // 订单创建逻辑... asyncLogService.saveOperationLog(logs); } } @Service public class AsyncLogService { @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveOperationLog(List<OperationLog> logs) { logMapper.saveBatch(logs); // 使用MyBatis-Plus的saveBatch } }

这种情况下,虽然两个方法都有@Transactional注解,但由于异步执行,事务可能无法正常提交。

4. 问题排查过程

4.1 复现问题

为了准确复现问题,我们设计了以下测试方案:

  1. 准备1000条测试数据
  2. 在异步方法中调用saveBatch
  3. 观察数据库中的记录数量
  4. 检查日志是否有异常

测试结果:

  • 有时插入全部成功
  • 有时部分成功(如插入300条)
  • 没有异常日志

4.2 日志分析

通过增加事务相关的日志配置:

logging.level.org.springframework.transaction=DEBUG logging.level.org.mybatis=TRACE

从日志中可以观察到:

  1. 主线程的事务正常开启和提交
  2. 异步线程中的事务有时没有提交日志
  3. 没有回滚日志

4.3 线程池配置检查

发现项目中配置了自定义的线程池:

@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.initialize(); return executor; } }

线程池配置可能导致任务被拒绝或线程被回收,影响事务提交。

5. 解决方案

5.1 方案一:使用编程式事务管理

改造异步方法,使用TransactionTemplate:

@Service public class AsyncLogService { @Autowired private TransactionTemplate transactionTemplate; @Async public void saveOperationLog(List<OperationLog> logs) { transactionTemplate.execute(status -> { try { logMapper.saveBatch(logs); return Boolean.TRUE; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }

优点:

  • 明确控制事务边界
  • 避免注解方式的问题

缺点:

  • 代码稍显冗长

5.2 方案二:调整事务传播行为

修改@Transactional的传播行为:

@Async @Transactional(propagation = Propagation.NESTED) public void saveOperationLog(List<OperationLog> logs) { logMapper.saveBatch(logs); }

注意:

  • NESTED需要数据库支持保存点
  • 不是所有场景都适用

5.3 方案三:使用同步批量插入

如果不必须异步,可以改为同步执行:

@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { // 订单创建逻辑... logMapper.saveBatch(logs); // 同步执行 } }

最简单可靠,但可能影响性能。

5.4 最终采用的方案

我们最终选择了方案一结合以下优化:

  1. 增加事务超时设置
  2. 添加重试机制
  3. 完善日志记录

完整实现:

@Async public void saveOperationLog(List<OperationLog> logs) { transactionTemplate.setTimeout(30); // 30秒超时 transactionTemplate.execute(status -> { try { int retryCount = 0; while (retryCount < 3) { try { logMapper.saveBatch(logs); return Boolean.TRUE; } catch (Exception e) { retryCount++; if (retryCount >= 3) { throw e; } Thread.sleep(1000 * retryCount); } } return Boolean.FALSE; } catch (Exception e) { log.error("保存操作日志失败", e); status.setRollbackOnly(); throw new RuntimeException("保存操作日志失败", e); } }); }

6. 深入原理:为什么会出现这个问题

6.1 MyBatis-Plus批量操作的本质

虽然叫"批量插入",但默认实现其实是循环单条插入:

  1. 不是真正的JDBC批量(addBatch/executeBatch)
  2. 每条insert都是独立的SQL语句
  3. 依赖事务保证原子性

6.2 Spring异步执行的原理

@Async的工作机制:

  1. 通过AOP代理拦截方法调用
  2. 提交到线程池执行
  3. 原始线程继续执行
  4. 新线程中方法执行

6.3 事务失效的根本原因

综合来看,问题根源在于:

  1. 异步线程可能被突然终止(如线程池回收)
  2. 事务提交发生在异步线程中
  3. Spring无法保证异步线程的事务一定会提交
  4. MyBatis-Plus的批量不是原子操作

7. 性能优化建议

7.1 使用真正的批量插入

可以重写saveBatch方法,使用JDBC的批量操作:

public class CustomServiceImpl<M extends BaseMapper<T>, T> extends ServiceImpl<M, T> { @Override @Transactional public boolean saveBatch(Collection<T> entityList, int batchSize) { try (SqlSession batchSqlSession = sqlSessionBatch()) { int i = 0; for (T entity : entityList) { batchSqlSession.insert(sqlStatement(SqlMethod.INSERT_ONE), entity); if (i >= 1 && i % batchSize == 0) { batchSqlSession.flushStatements(); } i++; } batchSqlSession.flushStatements(); return true; } } }

7.2 调整批量大小

根据数据库性能调整batchSize:

  • MySQL建议500-1000
  • Oracle建议100-200
  • SQL Server建议1000-2000

7.3 使用多线程批量插入

对于大数据量,可以结合多线程:

public void batchInsertConcurrent(List<Data> dataList) { int threadCount = 4; int batchSize = dataList.size() / threadCount; ExecutorService executor = Executors.newFixedThreadPool(threadCount); List<Future<?>> futures = new ArrayList<>(); for (int i = 0; i < threadCount; i++) { int from = i * batchSize; int to = (i == threadCount - 1) ? dataList.size() : (i + 1) * batchSize; List<Data> subList = dataList.subList(from, to); futures.add(executor.submit(() -> { transactionTemplate.execute(status -> { customService.saveBatch(subList); return null; }); })); } for (Future<?> future : futures) { try { future.get(); } catch (Exception e) { // 处理异常 } } executor.shutdown(); }

8. 其他注意事项

8.1 事务隔离级别的影响

在高并发下,还需要考虑隔离级别:

  • READ_COMMITTED:可能导致幻读
  • SERIALIZABLE:性能影响大
  • 建议根据业务需求选择合适的隔离级别

8.2 连接池配置

确保连接池配置合理:

  1. 足够大的最大连接数
  2. 合理的超时设置
  3. 适当的验证查询

例如HikariCP配置:

spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.validation-timeout=5000 spring.datasource.hikari.leak-detection-threshold=60000

8.3 监控与告警

建议添加以下监控:

  1. 事务执行时间监控
  2. 批量操作成功率监控
  3. 线程池使用情况监控

可以使用Micrometer + Prometheus + Grafana实现。

9. 常见问题解答

9.1 为什么部分数据插入成功了?

这是因为MyBatis-Plus的saveBatch默认不是原子操作。在事务提交前,部分插入已经执行,但如果事务最终没有提交,这些已执行的插入可能会被保留,取决于数据库的具体实现。

9.2 如何确定是事务问题?

可以通过以下方法验证:

  1. 在方法结束后手动抛出异常,看是否回滚
  2. 检查数据库事务日志
  3. 使用Spring的TransactionSynchronizationManager.isActualTransactionActive()

9.3 除了saveBatch,还有其他方法吗?

可以考虑:

  1. 使用MyBatis的 标签实现批量插入
  2. 使用JDBC的addBatch/executeBatch
  3. 使用存储过程处理批量数据

9.4 异步事务的最佳实践是什么?

建议:

  1. 避免在异步方法中进行复杂的多步骤事务
  2. 如果必须使用事务,确保有完善的错误处理和重试机制
  3. 考虑使用消息队列实现最终一致性
  4. 监控异步任务的执行情况

10. 总结与个人建议

经过这次问题排查,我总结了以下几点经验:

  1. 不要想当然地认为批量操作就是原子的,要了解框架的具体实现
  2. 异步和事务结合使用时需要格外小心
  3. 生产环境中的事务问题往往在高压下才会暴露
  4. 完善的日志和监控是快速定位问题的关键

在实际项目中,我建议:

  1. 对于关键业务操作,优先考虑同步执行
  2. 如果必须异步,考虑使用消息队列等更可靠的机制
  3. 对批量操作进行充分的压力测试
  4. 编写详细的文档记录这些"坑",避免团队成员重复踩坑

最后,记住一个原则:分布式系统没有完美的事务解决方案,我们需要根据业务特点在一致性和性能之间找到平衡点。