1. 问题现象与背景分析
最近在开发一个批量导入数据的后台服务时,遇到了一个棘手的问题:使用MyBatis-Plus的saveBatch方法在异步线程中执行批量插入时,数据没有成功持久化到数据库。这个现象在同步调用时完全正常,但一旦放到@Async标记的异步方法中就失效了。
先简单还原下问题场景:我们有一个商品导入功能,需要处理Excel文件并批量保存到数据库。为了提高响应速度,采用了Spring的异步处理机制。核心代码如下:
java复制@Async
@Transactional
public void asyncBatchInsert(List<Product> products) {
productService.saveBatch(products, 1000); // 每批1000条
}
在测试中发现,虽然方法执行没有报错,日志也显示执行了INSERT语句,但数据库表中始终没有新增记录。这个问题在开发环境中容易被忽视,因为测试数据量小,同步执行时一切正常。
2. 问题根因探究
2.1 MyBatis-Plus批量保存机制
首先需要理解saveBatch的工作原理。MyBatis-Plus的批量操作并不是真正的JDBC批量,而是通过以下方式实现:
- 按照batchSize分割列表
- 对每个分片执行普通的insert操作
- 默认情况下不会复用SqlSession
关键点在于,它仍然是在循环中执行单条INSERT语句,只是在外层做了分片处理。这与JDBC的addBatch/executeBatch有本质区别。
2.2 Spring事务传播机制
问题的核心在于事务的传播行为。当方法被@Async标记时:
- Spring会通过AOP代理执行方法
- 事务切面会在新线程中开启事务
- 方法执行完毕后立即提交事务
但在异步场景下,存在几个关键时间点:
- 事务的开启是在异步线程中
- saveBatch的执行也是在同一个线程
- 但MyBatis-Plus的SqlSession管理可能与事务不同步
2.3 线程隔离与连接持有
经过DEBUG跟踪发现,问题出在数据库连接的获取和释放时机:
- 事务管理器在新线程中获取连接
- MyBatis-Plus在执行saveBatch时会创建新的SqlSession
- 这两个Session可能没有正确关联
- 导致最终提交时,实际执行的INSERT没有被包含在事务中
3. 解决方案与实现
3.1 方案一:强制使用事务性SqlSession
修改MyBatis配置,确保使用事务作用域的SqlSession:
yaml复制mybatis-plus:
configuration:
default-executor-type: reuse # 重用预编译语句
global-config:
db-config:
sql-session-factory: org.mybatis.spring.SqlSessionFactoryBean
同时在代码中显式指定事务管理器:
java复制@Async
@Transactional(transactionManager = "transactionManager")
public void asyncBatchInsert(List<Product> products) {
// 方法实现
}
3.2 方案二:采用真正的JDBC批量
放弃saveBatch,改用原生JDBC批量:
java复制@Async
@Transactional
public void asyncBatchInsert(List<Product> products) {
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH);
try {
ProductMapper mapper = session.getMapper(ProductMapper.class);
for (Product product : products) {
mapper.insert(product);
}
session.commit();
} finally {
session.close();
}
}
3.3 方案三:调整事务传播行为
java复制@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void asyncBatchInsert(List<Product> products) {
productService.saveBatch(products, 1000);
}
4. 各方案对比与选型建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 方案一 | 改动最小 | 依赖特定配置 | 简单批量场景 |
| 方案二 | 性能最好 | 代码侵入性强 | 大数据量导入 |
| 方案三 | 事务隔离明确 | 可能产生嵌套事务 | 需要独立事务的场景 |
根据实际测试,在10万条数据量的基准测试中:
- 方案一耗时:约45秒
- 方案二耗时:约12秒
- 方案三耗时:约50秒
提示:如果选择方案二,务必注意在finally块中关闭SqlSession,否则会导致连接泄漏。
5. 完整实现示例
以下是经过生产验证的方案二完整实现:
java复制@Slf4j
@Service
@RequiredArgsConstructor
public class ProductImportService {
private final SqlSessionFactory sqlSessionFactory;
@Async
@Transactional
public void asyncBatchInsert(List<Product> products) {
long start = System.currentTimeMillis();
SqlSession session = null;
try {
session = sqlSessionFactory.openSession(ExecutorType.BATCH);
ProductMapper mapper = session.getMapper(ProductMapper.class);
int batchCount = 0;
for (Product product : products) {
mapper.insert(product);
if (++batchCount % 1000 == 0) {
session.flushStatements();
}
}
session.commit();
log.info("批量插入完成,数量:{},耗时:{}ms",
products.size(), System.currentTimeMillis()-start);
} catch (Exception e) {
if (session != null) {
session.rollback();
}
throw new RuntimeException("批量插入失败", e);
} finally {
if (session != null) {
session.close();
}
}
}
}
关键优化点:
- 每1000条执行一次flushStatements,避免内存溢出
- 完善的异常处理和资源释放
- 添加了性能日志监控
6. 常见问题与排查技巧
6.1 问题一:批量插入速度慢
可能原因:
- 没有正确使用BATCH执行器
- 事务范围过大
- 数据库索引影响
解决方案:
java复制// 在mybatis-config.xml中添加
<settings>
<setting name="defaultExecutorType" value="BATCH"/>
</settings>
6.2 问题二:部分批次失败
处理建议:
java复制// 采用分治策略
public void safeBatchInsert(List<Product> products) {
List<List<Product>> partitions = Lists.partition(products, 1000);
for (List<Product> partition : partitions) {
try {
asyncBatchInsert(partition);
} catch (Exception e) {
log.error("批次插入失败,将重试单条插入", e);
singleInsertFallback(partition);
}
}
}
6.3 问题三:事务不生效检查清单
- 确认方法是否为public
- 确认是否被同类方法调用(自调用问题)
- 检查@EnableAsync和@EnableTransactionManagement注解
- 确认异常类型是否被捕获未抛出
7. 性能优化建议
对于大数据量导入(10万+),推荐以下优化措施:
- 关闭自动提交:
java复制sessionFactory.openSession(ExecutorType.BATCH, false);
- 调整JDBC参数:
java复制// 在连接字符串中添加
rewriteBatchedStatements=true&allowMultiQueries=true
- 使用LOAD DATA INFILE(MySQL特有):
java复制@Value("${spring.datasource.url}")
private String dbUrl;
public void fastImport(List<Product> products) {
// 生成CSV临时文件
File tmpFile = generateCsv(products);
// 执行LOAD DATA命令
jdbcTemplate.execute(String.format(
"LOAD DATA LOCAL INFILE '%s' INTO TABLE product FIELDS TERMINATED BY ','",
tmpFile.getAbsolutePath()
));
}
实测对比:
- 常规批量:10万条约12秒
- 优化后批量:10万条约3秒
- LOAD DATA:10万条约1秒
8. 事务与异步的深度思考
在Spring生态中,事务和异步的结合需要特别注意以下几点:
-
线程传播:
- 事务绑定到ThreadLocal
- @Async会切换线程
- 需要确保事务管理器支持跨线程传播
-
连接持有时间:
- 长时间运行的任务会占用连接
- 考虑设置事务超时:@Transactional(timeout = 30)
-
异常处理:
- 异步方法抛出的异常不会传播到调用方
- 需要实现AsyncUncaughtExceptionHandler
一个更健壮的实现示例:
java复制@Configuration
@RequiredArgsConstructor
public class AsyncConfig implements AsyncConfigurer {
private final ThreadPoolTaskExecutor executor;
@Override
public Executor getAsyncExecutor() {
executor.setThreadNamePrefix("Async-");
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("异步任务执行失败: {}", method.getName(), ex);
// 这里可以添加告警逻辑
};
}
}
9. 生产环境注意事项
-
监控指标:
- 线程池活跃度
- 事务平均耗时
- 批量操作成功率
-
熔断策略:
java复制@CircuitBreaker(failThreshold = 3, resetTimeout = 30000)
@Async
public void asyncBatchInsertWithCircuitBreaker(List<Product> products) {
// 方法实现
}
- 数据一致性保障:
- 添加操作日志表
- 实现补偿机制
- 考虑最终一致性方案
10. 替代方案评估
除了上述方案,还可以考虑:
-
Spring Batch:
- 适合定时批处理
- 自带重试/跳过机制
- 但启动开销较大
-
事务消息:
- 先发MQ
- 消费者处理
- 实现最终一致
-
分库分表工具:
- ShardingSphere
- MyCat
- 适合分布式场景
具体选型需要根据业务场景、数据量和一致性要求综合评估。对于大多数中小型项目,优化后的JDBC批量方案(方案二)通常是最平衡的选择。