1. 问题现象与背景分析
最近在重构一个订单处理系统时,遇到了一个诡异的问题。系统采用SpringBoot+MyBatis-Plus技术栈,其中有个批量保存订单明细的功能,为了提高性能,我将其改造成了异步处理。核心代码如下:
@Transactional public void processOrder(OrderDTO order) { // 主订单入库 orderMapper.insert(order); // 异步处理订单明细 CompletableFuture.runAsync(() -> { List<OrderItem> items = convertToItems(order); orderItemService.saveBatch(items, 1000); // 批量插入 }, executor); }理论上,主订单和明细应该要么全部成功,要么全部失败。但实际运行中却发现:主订单记录正常入库,但明细数据经常丢失。更奇怪的是,在开发环境调试时,这个问题并非100%复现,大约有30%的概率会出现。
2. 事务传播机制与线程边界
2.1 Spring事务的基本原理
Spring的事务管理是基于ThreadLocal实现的。当我们使用@Transactional注解时,Spring会:
- 在方法调用前,通过AOP创建一个Connection对象
- 将该Connection绑定到当前线程的ThreadLocal中
- 方法内所有数据库操作都使用这个Connection
- 方法结束后根据执行情况提交或回滚事务
关键点在于:事务上下文是与线程绑定的。当我们在异步线程中执行数据库操作时,会使用新的Connection,与原线程的事务完全隔离。
2.2 saveBatch的内部实现
MyBatis-Plus的saveBatch方法看似简单,但内部有多个关键步骤:
// MyBatis-Plus 3.5.1 源码片段 public boolean saveBatch(Collection<T> entityList, int batchSize) { String sqlStatement = getSqlStatement(SqlMethod.INSERT_ONE); return executeBatch(entityList, batchSize, (sqlSession, entity) -> { sqlSession.insert(sqlStatement, entity); }); }实际上它会:
- 自动判断是否开启事务(通过TransactionSynchronizationManager.isSynchronizationActive())
- 如果没有事务,会为每个batch创建独立的事务
- 每个batch提交后立即提交事务
这就解释了为什么我们的明细数据会丢失:异步线程中的saveBatch操作与原方法的事务无关,一旦异步线程执行失败,主事务不会回滚。
3. 问题复现与根因定位
3.1 最小化复现代码
为了彻底理解问题,我构建了一个最小复现案例:
@SpringBootTest public class TransactionTest { @Autowired private TestService testService; @Test public void testAsyncBatch() { testService.mainMethod(); // 等待异步操作完成 Thread.sleep(3000); } } @Service class TestService { @Transactional public void mainMethod() { // 主线程插入 mainMapper.insert(new MainEntity()); CompletableFuture.runAsync(() -> { // 模拟批量插入 List<SubEntity> list = generateData(100); subMapper.saveBatch(list); }); } }通过这个测试案例,可以稳定复现主表成功、子表失败的情况。
3.2 关键问题诊断
使用调试模式跟踪执行过程,发现了几个关键现象:
- 主线程和异步线程使用的是不同的Connection对象
- 异步线程中的saveBatch每次都会自动提交
- 如果异步操作抛出异常,主事务不会回滚
- 在MySQL的general_log中可以看到多个独立的事务
4. 解决方案设计与实现
4.1 方案一:使用编程式事务(不推荐)
最直观的解决方案是在异步线程中手动管理事务:
CompletableFuture.runAsync(() -> { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.execute(status -> { return orderItemService.saveBatch(items); }); }, executor);这种方案的缺点是:
- 代码侵入性强
- 需要手动处理事务传播行为
- 与主事务仍然是分离的
4.2 方案二:使用TransactionSynchronizationManager(推荐)
更优雅的方案是利用Spring的事务同步机制:
@Transactional public void processOrder(OrderDTO order) { orderMapper.insert(order); // 注册事务同步 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 在主事务提交后执行 orderItemService.saveBatch(convertToItems(order)); } } ); }这个方案的优点是:
- 保证主事务提交后才执行批量操作
- 仍然保持异步执行的优势
- 代码结构清晰
4.3 方案三:使用事件监听机制(分布式场景适用)
对于更复杂的系统,可以考虑使用Spring的事件机制:
@Transactional public void processOrder(OrderDTO order) { orderMapper.insert(order); applicationEventPublisher.publishEvent(new OrderCreatedEvent(order)); } @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleOrderCreatedEvent(OrderCreatedEvent event) { orderItemService.saveBatch(convertToItems(event.getOrder())); }这种方案的扩展性更好,适合未来可能需要的分布式事务场景。
5. 生产环境验证与性能对比
5.1 性能测试数据
我们对三种方案进行了压测(1000次调用,批量插入100条记录):
| 方案 | 平均耗时(ms) | 成功率 | 备注 |
|---|---|---|---|
| 原始方案 | 1200 | 70% | 数据不一致 |
| 编程式事务 | 1500 | 100% | 性能较差 |
| 事务同步 | 1250 | 100% | 推荐 |
| 事件监听 | 1300 | 100% | 扩展性好 |
5.2 事务监控配置
为了确保方案可靠性,我们配置了事务监控:
# application.yml spring: datasource: hikari: pool-name: HikariCP register-mbeans: true jmx: enabled: true management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: export: prometheus: enabled: true通过Prometheus + Grafana监控事务相关指标:
- spring_transactions_active
- spring_transactions_committed
- spring_transactions_rollback
6. 扩展思考与最佳实践
6.1 MyBatis-Plus版本选择
经过测试发现,不同版本的MyBatis-Plus对批量操作的支持有差异:
- 3.4.x:批量操作性能一般,事务控制不够灵活
- 3.5.x:优化了批量插入逻辑,推荐使用
- 4.x:API有较大变化,需要评估迁移成本
当前推荐使用3.5.3版本,与SpringBoot 2.7.x兼容性最好。
6.2 批量操作优化建议
- 合理设置batchSize:通常500-2000之间性能最佳
- 考虑使用rewriteBatchedStatements=true(MySQL)
- 对于超大批量,建议分片处理
// 分片处理示例 List<List<OrderItem>> partitions = Lists.partition(items, 1000); partitions.forEach(partition -> { orderItemService.saveBatch(partition); });6.3 事务设计原则
- 保持事务短小精悍
- 避免在事务中进行远程调用
- 异步操作要明确事务边界
- 对于关键业务,添加补偿机制
7. 常见问题排查指南
7.1 问题现象:数据部分丢失
排查步骤:
- 检查是否跨线程操作
- 查看数据库连接池配置
- 检查@Transactional注解位置
- 查看MyBatis-Plus版本
7.2 问题现象:性能突然下降
可能原因:
- 批量大小设置不合理
- 没有启用批处理优化
- 事务隔离级别过高
解决方案:
-- MySQL批处理优化 SET GLOBAL max_allowed_packet=256M; SET GLOBAL net_buffer_length=1M;7.3 问题现象:死锁
处理方法:
- 分析死锁日志
- 调整批量处理顺序
- 考虑使用乐观锁
@Version private Integer version;8. 个人实践心得
在实际项目中处理这个问题时,我总结了几个关键经验:
不要轻信"自动提交":很多开发者以为MyBatis-Plus的saveBatch会自动参与当前事务,这是常见的误解。实际上它的行为取决于具体场景。
线程切换是事务的隐形杀手:在微服务架构中,线程切换经常发生(如Feign调用、异步处理等),要特别注意事务上下文是否延续。
测试要包含失败场景:仅测试成功路径是不够的,必须模拟各种异常情况,特别是网络抖动、超时等边界条件。
监控是最后防线:无论设计多么完善,生产环境总会出现意外。完善的事务监控可以快速定位问题。
文档要注明限制:在团队内部文档中,我特别标注了哪些方法必须在事务内调用,哪些可以异步处理,避免了其他同事踩坑。
这个案例让我深刻认识到,框架的便利性有时会掩盖底层复杂性。作为开发者,我们需要在享受便利的同时,保持对底层原理的好奇心和理解深度。特别是在并发和事务这种核心领域,一点点的疏忽就可能导致严重的数据不一致问题。