Mysql事务的“原子性”陷阱

发布于:2025-08-16 ⋅ 阅读:(13) ⋅ 点赞:(0)

今天遇到一个mysql事务的一个坑事情是这样的 我在做一个拆分文档的功能,但是可能会出现拆分失败的情况于是为了方便后续对拆分失败文档的追踪,我想加一个日志表记录一个日志

   /**
     * 上传标书文档
     * @param file 文件
     * @param libraryId 文档资料库id
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void uploadDocument(MultipartFile file, Long libraryId) {
        try {
            Long userId;
            try {
                userId = SecurityUtils.getUserId();
            } catch (Exception e) {
                throw new ServiceException("获取用户信息异常",HttpStatus.UNAUTHORIZED);
            }
            //判断当前库是不是用户所属的库
            BidMaterialLibrary library = libraryMapper.selectById(libraryId);
            if (ObjectUtil.isEmpty(library)){
                throw new ServiceException("所选择文件夹不存在");
            }
            if (!ObjectUtil.equal(library.getUserId(),userId)){
                throw new ServiceException("文件夹选择错误,请重新选择!");
            }
           //........此处代码省略
            log.info("扣减用户的标书文档容量,userId,{},扣减字节,{},扣减文件,{}",userId,fileSize,uploadPath);
            userMapper.updateBidDocumentSizeByUserId(userId, fileSize);
            //拆分文档
            int level=3;
            //2.1拆分目录
            insertWordHeading(uploadPath,document.getId(),userId,level);
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
 /**
     * 拆分文档标题
     * @param inputPath
     * @param fileId
     * @param userId
     * @param level
     */
    public  void insertWordHeading(String inputPath,Long fileId,Long userId,Integer level) {
        try {
            // 自动生成 outputDir
            File inputFile = new File(inputPath);
            String fileName = inputFile.getName();
            int dotIndex = fileName.lastIndexOf(".");
            String baseName = (dotIndex == -1) ? fileName : fileName.substring(0, dotIndex);
            String parentDir = inputFile.getParent();
            String outputDir = parentDir + File.separator + baseName;
            // 确保输出目录存在
            File dir = new File(outputDir);
            if (!dir.exists()) {
                dir.mkdirs();
            }
            com.aspose.words.Document doc = new com.aspose.words.Document(inputPath);
            //是否拆分了
            boolean parseFlag=false;
            for (Section section : doc.getSections()) {
                NodeCollection allNodes = section.getBody().getChildNodes(NodeType.ANY, false);
                List<HeadingNode> headingTree = HeadingNode.buildHeadingTree(allNodes,level);

                if (ObjectUtil.isNotEmpty(headingTree)){
                    parseFlag=true;
                    //新增目录
                    saveHeadingTree(headingTree, 0L,fileId,userId);
                    //拆分文档
                    parseDocument(allNodes,headingTree,fileId,userId,outputDir);
                }
            }
            if (!parseFlag){
                throw new ServiceException("文档拆分失败");
            }
//            BidDocument document = documentMapper.selectById(fileId);
//            document.setStatus(3);
//            documentMapper.updateById(document);

        } catch (Exception e) {
            //保存文档拆分失败记录
            DocSplitFailureLog log=new DocSplitFailureLog();
            log.setUserId(userId);
            log.setFileId(fileId);
            log.setCreateTime(new Date());
            log.setInputPath(inputPath);
            failureLogMapper.insert(log);
            throw new ServiceException("文档拆分失败");
        }
    }

后来发现死活数据塞不进去,后来想应该是事务的原因,但是怎么解决呢?

问题剖析:事务的“原子性”陷阱

  1. 事务开始: 当外部调用 uploadDocument 方法时,由于 @Transactional 注解,Spring会为你开启一个数据库事务。此后,所有在这个方法内执行的数据库操作(增、删、改)都会被纳入这个事务的管理范围,变成一个“原子操作单元”。

  2. 执行业务逻辑:

    • documentMapper.insert(document) 被执行。此时,BidDocument 记录被插入到数据库,但事务尚未提交,它处于“待定”状态。

    • userMapper.updateBidDocumentSizeByUserId(...) 被执行。用户的容量被更新,同样,这个更新也处于“待定”状态。

    • 程序调用 insertWordHeading(...) 方法。

  3. 进入catch:

    • insertWordHeading 方法内部,文档拆分失败,进入了 catch (Exception e) 块。

    • 你的代码执行 docSplitFailureLogMapper.insert(log)此时,DocSplitFailureLog 记录被插入数据库,但它也被纳入了同一个事务管理,所以它也处于“待定”状态。

  4. 异常抛出与事务回滚:

    • catch 块的最后一行是 throw new ServiceException("文档拆分失败");

    • 这个异常被抛出,离开了 insertWordHeading 方法,也离开了 uploadDocument 方法。

    • @Transactional 注解的机制捕获到了这个未被处理的异常。

    • 由于你设置了 rollbackFor = Exception.class,这个 ServiceException(它是 Exception 的子类)触发了事务回滚

  5. 最终结果:

    • 事务管理器向数据库发送 ROLLBACK 命令。

    • 所有处于“待定”状态的操作全部被撤销,数据库恢复到事务开始前的状态。

    • 这不仅包括 BidDocument 的插入和 SysUser 的更新,也包括你刚刚插入的 DocSplitFailureLog 记录。

所以,虽然你的日志插入代码确实执行了,但它被紧随其后的事务回滚给“抹掉”了。

如何解决:让日志记录脱离主事务

你需要让保存失败日志这个操作在一个独立的、新的事务中执行,这样它就不会被主业务事务的回滚所影响。这在Spring中被称为事务传播行为(Transaction Propagation)

最优雅、最推荐的解决方案是创建一个专门的日志服务,并设置其事务传播级别为 REQUIRES_NEW

步骤 1: 创建一个新的日志服务类

这个服务专门负责处理日志的持久化。

FailureLogService.java (接口)

public interface FailureLogService {
    /**
     * 保存文档拆分失败记录
     * @param log 日志实体
     */
    void saveSplitFailureLog(DocSplitFailureLog log);
}

FailureLogServiceImpl.java (实现)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class FailureLogServiceImpl implements FailureLogService {

    @Autowired
    private DocSplitFailureLogMapper docSplitFailureLogMapper;

    /**
     * 使用 REQUIRES_NEW 传播级别
     * 1. 如果当前存在事务,则将当前事务挂起。
     * 2. 开启一个全新的事务。
     * 3. 在这个新事务中执行方法体内的数据库操作。
     * 4. 无论成功还是失败,新事务都会被提交或回滚,且不会影响外部的事务。
     * 5. 外部被挂起的事务得以恢复。
     */
    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveSplitFailureLog(DocSplitFailureLog log) {
        // 这个 insert 操作将在一个独立的、全新的事务中执行
        docSplitFailureLogMapper.insert(log);
    }
}

步骤 2: 修改 insertWordHeading 方法

在你的主业务类中,注入这个新的 FailureLogService 并调用它,而不是直接调用 Mapper

@Autowired
private FailureLogService failureLogService; // 注入新创建的服务

// ...

public void insertWordHeading(String inputPath, Long fileId, Long userId, Integer level) {
    try {
        // ... 省略你的业务逻辑 ...
        if (!parseFlag){
            throw new ServiceException("文档拆分失败");
        }
    } catch (Exception e) {
        // 保存文档拆分失败记录
        DocSplitFailureLog log = new DocSplitFailureLog();
        log.setUserId(userId);
        log.setFileId(fileId);
        // log.setCreateTime(new Date()); // 如果数据库有默认值,这一行可以省略
        log.setInputPath(inputPath);
        
        // 调用新的服务方法来保存日志
        // 这个调用会开启一个新事务,执行完后立刻提交,独立于外层事务
        failureLogService.saveSplitFailureLog(log);
        
        // 仍然向上抛出异常,让主事务回滚
        throw new ServiceException("文档拆分失败");
    }
}

另一种优秀方案:异步化 (@Async)

对于日志记录这种非核心的旁路操作,异步执行是另一个非常好的选择。它不仅能解决事务问题(异步方法默认会在新线程中执行,从而脱离当前事务),还能提高主流程的响应速度。

  1. 在启动类上加 @EnableAsync

  2. FailureLogServiceImplsaveSplitFailureLog 方法上加上 @Async 注解。

FailureLogServiceImpl.java (异步版本)

@Service
public class FailureLogServiceImpl implements FailureLogService {

    @Autowired
    private DocSplitFailureLogMapper docSplitFailureLogMapper;

    @Override
    @Async // 标记为异步方法
    // 异步方法通常不需要再关心事务传播,因为它已经在新线程中,自然脱离了调用方的事务
    @Transactional 
    public void saveSplitFailureLog(DocSplitFailureLog log) {
        // 这个方法会由一个独立的线程池来执行
        docSplitFailureLogMapper.insert(log);
    }
}

这样修改后,当主线程调用 saveSplitFailureLog 时,它会立即返回,而日志的保存操作会在后台线程中完成,完全不会阻塞主流程或受其事务影响。


网站公告

今日签到

点亮在社区的每一天
去签到