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); }); }关键点:
- 方法本身带有@Transactional注解
- 使用MyBatis的SqlSession执行批量插入
- 默认batchSize为1000
2.2 批量操作的执行流程
saveBatch的实际执行流程可以分为以下几个步骤:
- 开启事务(由Spring管理)
- 对集合进行分片处理(根据batchSize)
- 对每个分片执行批量插入
- 提交事务(如果成功)或回滚(如果失败)
问题在于,当这个方法在异步线程中执行时,第4步的事务提交可能不会按预期工作。
3. 异步环境中的事务问题
3.1 Spring事务管理机制
Spring的事务管理是基于ThreadLocal实现的,关键点包括:
- 事务上下文存储在ThreadLocal中
- @Transactional注解的事务传播行为默认是REQUIRED
- 异步方法会使用新的线程执行,无法继承原有的事务上下文
3.2 @Async与事务的交互
当我们在异步方法中使用@Transactional时,会遇到以下问题:
- 异步方法本身需要@Async注解
- 如果异步方法内部有@Transactional,会创建新的事务
- 这两个注解的执行顺序和交互需要特别注意
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 复现问题
为了准确复现问题,我们设计了以下测试方案:
- 准备1000条测试数据
- 在异步方法中调用saveBatch
- 观察数据库中的记录数量
- 检查日志是否有异常
测试结果:
- 有时插入全部成功
- 有时部分成功(如插入300条)
- 没有异常日志
4.2 日志分析
通过增加事务相关的日志配置:
logging.level.org.springframework.transaction=DEBUG logging.level.org.mybatis=TRACE从日志中可以观察到:
- 主线程的事务正常开启和提交
- 异步线程中的事务有时没有提交日志
- 没有回滚日志
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 最终采用的方案
我们最终选择了方案一结合以下优化:
- 增加事务超时设置
- 添加重试机制
- 完善日志记录
完整实现:
@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批量操作的本质
虽然叫"批量插入",但默认实现其实是循环单条插入:
- 不是真正的JDBC批量(addBatch/executeBatch)
- 每条insert都是独立的SQL语句
- 依赖事务保证原子性
6.2 Spring异步执行的原理
@Async的工作机制:
- 通过AOP代理拦截方法调用
- 提交到线程池执行
- 原始线程继续执行
- 新线程中方法执行
6.3 事务失效的根本原因
综合来看,问题根源在于:
- 异步线程可能被突然终止(如线程池回收)
- 事务提交发生在异步线程中
- Spring无法保证异步线程的事务一定会提交
- 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 连接池配置
确保连接池配置合理:
- 足够大的最大连接数
- 合理的超时设置
- 适当的验证查询
例如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=600008.3 监控与告警
建议添加以下监控:
- 事务执行时间监控
- 批量操作成功率监控
- 线程池使用情况监控
可以使用Micrometer + Prometheus + Grafana实现。
9. 常见问题解答
9.1 为什么部分数据插入成功了?
这是因为MyBatis-Plus的saveBatch默认不是原子操作。在事务提交前,部分插入已经执行,但如果事务最终没有提交,这些已执行的插入可能会被保留,取决于数据库的具体实现。
9.2 如何确定是事务问题?
可以通过以下方法验证:
- 在方法结束后手动抛出异常,看是否回滚
- 检查数据库事务日志
- 使用Spring的TransactionSynchronizationManager.isActualTransactionActive()
9.3 除了saveBatch,还有其他方法吗?
可以考虑:
- 使用MyBatis的 标签实现批量插入
- 使用JDBC的addBatch/executeBatch
- 使用存储过程处理批量数据
9.4 异步事务的最佳实践是什么?
建议:
- 避免在异步方法中进行复杂的多步骤事务
- 如果必须使用事务,确保有完善的错误处理和重试机制
- 考虑使用消息队列实现最终一致性
- 监控异步任务的执行情况
10. 总结与个人建议
经过这次问题排查,我总结了以下几点经验:
- 不要想当然地认为批量操作就是原子的,要了解框架的具体实现
- 异步和事务结合使用时需要格外小心
- 生产环境中的事务问题往往在高压下才会暴露
- 完善的日志和监控是快速定位问题的关键
在实际项目中,我建议:
- 对于关键业务操作,优先考虑同步执行
- 如果必须异步,考虑使用消息队列等更可靠的机制
- 对批量操作进行充分的压力测试
- 编写详细的文档记录这些"坑",避免团队成员重复踩坑
最后,记住一个原则:分布式系统没有完美的事务解决方案,我们需要根据业务特点在一致性和性能之间找到平衡点。