MyBatis-Plus saveBatch 在异步线程中事务未提交问题排查与修复

问题背景

在批量导入功能中,发现 saveBatch 方法被调用并返回 true,但数据库中没有新记录。调用时传入 164 条实体,执行无报错,但数据未持久化。

问题现象

java 复制代码
// 异步线程中执行
executorService.execute(() -> {
    boolean saveResult = service.saveBatch(entityList);
    // saveResult = true,但数据库中没有记录
});

排查过程

1. 初步假设

  • 假设A:异步线程中事务未提交
  • 假设B:数据构建阶段字段为 null
  • 假设C:关联数据查询返回 null
  • 假设D:构建的对象缺少必填字段
  • 假设E:saveBatch 返回 true 但实际未插入
  • 假设F:插入后立即查询不到(事务隔离)
  • 假设G:异步线程异常被吞掉
  • 假设H:异步线程未执行

2. 添加调试日志

在关键位置添加日志,追踪执行流程:

java 复制代码
// 记录异步线程执行
log.info("异步线程开始执行,线程名: {}", Thread.currentThread().getName());

// 记录数据构建
log.info("构建的实体对象,id: {}, status: {}", 
    entity.getId(), entity.getStatus());

// 记录 saveBatch 调用
log.info("准备调用saveBatch,listSize: {}", entityList.size());

// 记录 saveBatch 结果
log.info("saveBatch执行结果,saveResult: {}, listSize: {}", 
    saveResult, entityList.size());

// 验证插入结果
long actualCount = service.count(...);
log.info("验证插入结果,expectedCount: {}, actualCount: {}", 
    entityList.size(), actualCount);

3. 日志分析结果

日志显示:

  • 异步线程已执行
  • 数据字段正常
  • 关联数据查询正常
  • 对象字段完整
  • saveBatch 已调用(listSize=164)
  • saveBatch 返回 true
  • 验证插入结果:expectedCount=164, actualCount=328

关键发现:实际记录数是期望的 2 倍,说明:

  1. saveBatch 确实执行了
  2. 记录确实插入了
  3. 但查询时包含了旧记录

进一步分析发现:虽然记录已插入,但可能因为事务未提交,导致查询时看不到新数据。

根本原因

问题1:异步线程中事务未提交

在异步线程中执行 saveBatch 时,如果没有显式事务管理,可能出现:

  • 连接池返回的连接的 autoCommit 状态不一致
  • 批量插入需要显式事务才能正确提交
  • 异步线程不在 Spring 事务管理范围内

问题2:事务范围过大

最初将整个方法放在一个事务中:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Result<?> processBatchData(...) {
    // 文件读取、验证、外部服务调用、数据保存都在一个事务中
    // 事务锁定时间过长,可能几十秒
}

这会导致:

  • 数据库锁持有时间过长
  • 影响并发性能
  • 可能超时

修复方案

方案演进

阶段1:添加事务管理(异步场景)
java 复制代码
// 使用 TransactionTemplate 确保事务提交
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
transactionTemplate.execute(status -> {
    service.saveBatch(entityList);
    return true;
});
阶段2:改为同步执行
java 复制代码
@Transactional(rollbackFor = Exception.class)
public Result<?> processBatchData(...) {
    // 同步执行,Spring 自动管理事务
    service.saveBatch(entityList);
}
阶段3:缩小事务范围
java 复制代码
// 主方法不使用事务
public Result<?> processBatchData(...) {
    // 文件读取、验证、外部服务调用(无事务)
    
    // 只对关键操作使用事务
    saveBatchData(entityList, companyId);
}

@Transactional(rollbackFor = Exception.class)
private void saveBatchData(...) {
    service.saveBatch(entityList);
}
阶段4:分批处理 + 事务合并(最终方案)
java 复制代码
// 分批处理,每批在同一事务中
List<List<Entity>> batchList = Lists.partition(entityList, 10);

for (List<Entity> batch : batchList) {
    // 每批的更新关联数据和批量插入放在同一个事务中
    service.updateAndSaveBatch(companyId, batch, relatedDataMap, userName);
}

@Transactional(rollbackFor = Exception.class)
public void updateAndSaveBatch(...) {
    // 更新关联数据
    updateRelatedData(req, userName);
    
    // 批量插入
    service.saveBatch(entityList);
}

最终方案详解

1. 事务范围优化
java 复制代码
// 主方法:无事务
public Result<?> processBatchData(...) {
    // 文件读取(无事务)
    // 数据验证(无事务)
    // 外部服务调用(无事务)
    
    // 分批处理,每批独立事务
    for (List<Entity> batch : batchList) {
        service.updateAndSaveBatch(...);
    }
}

// 批次方法:小事务
@Transactional(rollbackFor = Exception.class)
public void updateAndSaveBatch(...) {
    // 更新关联数据(10条记录)
    updateRelatedData(req, userName);
    
    // 批量插入(10条记录)
    service.saveBatch(entityList);
}
2. 性能优化

批量查询关联数据,避免 N+1 查询:

java 复制代码
// 优化前:循环中每次查询
entityList.stream().map(e -> {
    RelatedData data = relatedDataService.queryById(e.getId());
    // N次数据库查询
})

// 优化后:批量查询一次
Map<String, RelatedData> dataMap = relatedDataService.list(
    Wrappers.lambdaQuery(RelatedData.class)
        .in(RelatedData::getId, idList))
    .stream()
    .collect(Collectors.toMap(RelatedData::getId, Function.identity()));

entityList.stream().map(e -> {
    RelatedData data = dataMap.get(e.getId());
    // 从Map中获取,无数据库查询
})
3. 事务结构
复制代码
主方法(无事务)
├── 文件读取(无事务)
├── 数据验证(无事务)
├── 外部服务调用(无事务)
└── 分批处理
    ├── 第1批(事务1,10条记录)
    │   ├── 更新关联数据
    │   └── 批量插入
    ├── 第2批(事务2,10条记录)
    │   ├── 更新关联数据
    │   └── 批量插入
    └── ...

关键要点

1. 异步线程中的事务管理

  • 异步线程不在 Spring 事务管理范围内
  • 必须使用 TransactionTemplate@Transactional + 代理调用
  • 通过接口调用确保走 Spring 代理

2. 事务范围设计原则

  • 只读操作不使用事务
  • 外部服务调用不使用事务
  • 关键数据操作使用小事务
  • 相关操作放在同一事务中保证原子性

3. 性能优化技巧

  • 批量查询替代循环查询(避免 N+1)
  • 分批处理减少事务锁定时间
  • 合理设置批次大小(10-100 条)

4. 调试方法

  • 添加详细日志追踪执行流程
  • 验证数据状态(构建前后、保存前后)
  • 检查事务是否生效(TransactionSynchronizationManager.isActualTransactionActive()

总结

  1. 问题根源:异步线程中事务未正确提交
  2. 解决方案:使用 @Transactional + 接口代理调用,确保事务生效
  3. 性能优化:缩小事务范围,分批处理,批量查询
  4. 最佳实践:事务只用于关键数据操作,保持事务范围最小

通过以上优化,既解决了数据插入问题,又提升了系统性能和并发能力。

参考代码

java 复制代码
// 最终实现
public Result<?> processBatchData(File file, String userName) {
    // 1. 文件读取、验证、外部服务调用(无事务)
    List<Entity> entityList = parseFile(file);
    validateData(entityList);
    callExternalService(errorList);
    
    // 2. 批量查询关联数据(优化N+1问题)
    List<String> idList = entityList.stream()
        .map(Entity::getId)
        .collect(Collectors.toList());
    Map<String, RelatedData> dataMap = relatedDataService.list(
        Wrappers.lambdaQuery(RelatedData.class)
            .in(RelatedData::getId, idList))
        .stream()
        .collect(Collectors.toMap(RelatedData::getId, Function.identity()));
    
    // 3. 按业务分组处理
    Map<String, List<Entity>> groupMap = entityList.stream()
        .collect(Collectors.groupingBy(Entity::getGroupId));
    
    groupMap.forEach((groupId, groupList) -> {
        // 4. 分批处理,每批独立事务
        List<List<Entity>> batchList = Lists.partition(groupList, 10);
        for (List<Entity> batch : batchList) {
            service.updateAndSaveBatch(
                groupId, batch, dataMap, userName);
        }
    });
    
    return Result.success("处理完成");
}

@Transactional(rollbackFor = Exception.class)
public void updateAndSaveBatch(String groupId, List<Entity> batch,
        Map<String, RelatedData> dataMap, String userName) {
    // 更新关联数据
    List<String> idList = batch.stream()
        .map(Entity::getId)
        .collect(Collectors.toList());
    UpdateRequest req = new UpdateRequest();
    req.setGroupId(groupId);
    req.setIdList(idList);
    updateRelatedData(req, userName);
    
    // 构建批量插入数据
    List<TargetEntity> targetList = batch.stream().map(e -> {
        RelatedData data = dataMap.get(e.getId());
        if (data == null) {
            log.warn("关联数据不存在,跳过,id: {}", e.getId());
            return null;
        }
        return new TargetEntity()
            .setId(e.getId())
            .setName(data.getName())
            .setStatus(StatusEnum.ACTIVE)
            .setCreateTime(new Date())
            .setField1(e.getField1())
            .setField2(e.getField2());
    }).filter(Objects::nonNull)
      .distinct()
      .collect(Collectors.toList());
    
    // 批量插入
    if (!CollectionUtils.isEmpty(targetList)) {
        targetService.saveBatch(targetList);
        log.info("批量插入成功,分组: {},本批{}条", groupId, targetList.size());
    }
}

该方案既保证了数据一致性,又优化了性能和并发能力。

相关推荐
论迹16 小时前
【Redis】-- key的过期策略
数据库·redis·缓存
weixin1997010801616 小时前
废旧物资 item_search - 按关键字搜索商品列表接口对接全攻略:从入门到精通
数据库·python
l1t16 小时前
快速加载CSV文件到数据库的工具pg_csv_loader
数据库·算法
无忧智库16 小时前
深度拆解:某大型医院“十五五”智慧医院建设方案,如何冲刺互联互通五级乙等?(附技术架构与实施路径)
java·数据库·架构
moxiaoran575316 小时前
Java使用Redis ZSet恢复用户能量
数据库·redis·哈希算法
wtsolutions16 小时前
Sheet-to-Doc模板设计最佳实践:创建专业的Word模板
前端·javascript·数据库
辞砚技术录16 小时前
MySQL面试题——索引、B+树
数据结构·数据库·b树·面试
风吹落叶花飘荡17 小时前
mysql数据库创建新用户,并只给其必要的权限
数据库·mysql
悦数图数据库17 小时前
“复旦大学—杭州悦数先进金融图技术校企联合研究中心年度总结会”圆满举行
大数据·数据库·人工智能