杂记-@Transactional使用的一点记录

一、问题记录

在设计一个简单的重命名修改方法的时候考虑使用乐观插入+异常捕获来优化代码,其中用到了@Transaction这个注解。然而这里发现了一个致命缺陷,spring事务的问题。

  • 永远不要在 @Transactional 方法内部依赖 try-catch 来处理由数据库约束引起的 RuntimeException ,并期望事务能继续正常提交
原代码如下:
ini 复制代码
@Transaction
private void processFileNameDuplicate(AccountFileDO accountFileDO) {
    String originalFileName = accountFileDO.getFileName();
    Long accountId = accountFileDO.getAccountId();
    Long parentId = accountFileDO.getParentId();
    
    // 首先尝试插入原始文件名(乐观插入)
    try {
        accountFileMapper.insert(accountFileDO);
        return; // 插入成功,直接返回
    } catch (DataIntegrityViolationException e) {
        // 文件名冲突,需要重命名
    }
    
    // 文件名冲突处理
    String baseName, extension = "";
    int dotIndex = originalFileName.lastIndexOf(".");
    if (dotIndex > 0 && dotIndex < originalFileName.length() - 1) {
        baseName = originalFileName.substring(0, dotIndex);
        extension = originalFileName.substring(dotIndex);
    } else {
        baseName = originalFileName;
    }
    
    int counter = 1;
    String newFileName;
    do {
        newFileName = baseName + "(" + counter + ")" + extension;
        counter++;
        
        accountFileDO.setFileName(newFileName);
        try {
            accountFileMapper.insert(accountFileDO);
            return; // 成功插入新文件名
        } catch (DataIntegrityViolationException e) {
            // 继续循环尝试下一个文件名
        }
    } while (true);
}

分析这个乐观锁加异常捕获的设计(通过数据库的插入成功与否来判断新的命名是否可用),当第一次插入判断失败,进入给文件名加后缀继续插入判断的循环,直到插入判断成功,spring仍然保留着最初的异常DataIntegrityViolationException,最终在默认的@Transaction隔离级别下会执行回滚,数据库的那一套事务会收到spring给的ROLLBACK指令。这样就会出现一个BUG,Java 对象 accountFileDO 的状态不会回滚,尽管被修正了,在数据库中依然查询不到。

二、设计思路和修正思路

策略 方法 缺陷
内存全量加载 使用预查询的方法将文件名全部取到SET集合中,避免事务问题,最终加上联合唯一索引确保最终一致性; 内存瓶颈:当单个目录文件数量巨大时(如超过10万),会消耗大量内存。
目标化数据库查询 先乐观地检查原始名是否存在。如果存在,再用 LIKEREGEXP 查询只捞出相关的重名文件(如 笔记(%).txt),然后在内存中计算 1. 数据库压力LIKEREGEXP 查询可能无法完美利用索引,在千万级文件的目录下仍可能较慢。2. 并发问题:依然存在"检查-操作"的竞态条件,需要数据库唯一索引来兜底。
乐观插入与异常捕获 不做任何 SELECT 检查,直接尝试 INSERT。如果成功,万事大吉。如果失败(捕获唯一键异常),则在 catch 块中处理重命名逻辑,再次尝试 INSERT 1. 失败时最慢 :异常的抛出和捕获、事务的回滚,开销很大。如果冲突频繁,性能会急剧下降。2. 代码复杂性高 :需要处理 Spring @Transactional 的回滚陷阱,通常需要隔离事务,实现起来非常复杂且易错。3. 设计模式争议:滥用异常来控制正常的业务流程。
架构级优化 1. 分布式锁 :在执行任何数据库操作前,先通过 Redis (SETNX) 等中间件获取一个代表该文件名的分布式锁,确保同一时间只有一个线程能操作这个名字。2. ID与名称解耦 :文件在数据库中的唯一标识是 UUID,而用户看到的文件名只是一个可变的"别名"。唯一性约束放在 (parent_id, alias_name) 上。 1. 引入新依赖 :需要引入并维护 Redis 等外部系统,增加了系统复杂度。2. 设计更复杂:需要从系统设计的更高层面去规划,不再是单个函数的优化。

针对于方案3,修正思路是启用一个沙箱机制,将数据库插入判断操作放到由@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = DataIntegrityViolationException.class)注解的方法内部。

代码如下:
kotlin 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DataIntegrityViolationException;

@Component
public class AccountFileSaver {

    @Autowired
    private AccountFileMapper accountFileMapper;

    /**
     * 在一个全新的、独立的事务中尝试插入数据。
     * 这是实现策略三的关键。
     *
     * @param accountFileDO 要插入的文件对象
     * @throws DataIntegrityViolationException 如果发生唯一键冲突,则向上抛出
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void tryInsert(AccountFileDO accountFileDO) {
        // 这个insert操作在自己的"事务沙箱"里运行。
        // 如果成功,这个小事务就自己提交了。
        // 如果因为唯一键冲突失败,它会抛出异常,并且这个小事务自己回滚。
        // 关键在于,它的失败不会"污染"调用它的外部主事务。
        accountFileMapper.insert(accountFileDO);
    }
}

最终调用注入该类,调用tryInsert()方法代替原始的插入。实现外置沙箱处理掉异常,不影响外部事务。

三、使用@Transaction的一些总结

  1. Spring AOP 代理机制
  • @Transactional 注解是通过 Spring AOP(面向切面编程)的代理机制来实现的。
  • 当你调用一个 Bean 的方法时(比如 serviceA.methodA()),你实际调用的是 Spring 为 serviceA 创建的代理对象。这个代理对象在调用真实方法前后,会为你加上开启事务、提交/回滚事务的逻辑。
  • 但是,如果在一个类内部,一个方法调用同一个类 的另一个方法(this.methodB()),这个调用是直接在对象内部发生的,不会经过代理 。因此,methodB 上的 @Transactional 注解就会失效
  • 所以,我们必须tryInsert 方法放到另一个独立的类 (AccountFileSaver) 中,然后通过注入来调用 (accountFileSaver.tryInsert())。这样才能确保每次调用都经过代理,从而让 Propagation.REQUIRES_NEW 生效。
  1. 最终还是采用了策略一,简简单单
  • "文件名重复"的场景下,用多层事务嵌套设计不合理,违背了的整个流程的原子性,外层的@Transactional的事务获取到其他流程抛出的异常后发起回滚,而嵌套的独立事务已经完成提交
相关推荐
code_std2 小时前
保存文件到指定位置,读取/删除指定文件夹中文件
java·spring boot·后端
汤姆yu2 小时前
基于springboot的热门文创内容推荐分享系统
java·spring boot·后端
武昌库里写JAVA2 小时前
在iview中使用upload组件上传文件之前先做其他的处理
java·vue.js·spring boot·后端·sql
嘻哈baby3 小时前
AI让我变强了还是变弱了?一个后端开发的年终自省
后端
舒一笑3 小时前
2025:从“代码搬运”到“意图编织”,我在 AI 浪潮中找回了开发的“爽感”
后端·程序员·产品
用户4099322502123 小时前
Vue3中v-if与v-for为何不能在同一元素上混用?优先级规则与改进方案是什么?
前端·vue.js·后端
blurblurblun3 小时前
Go语言特性
开发语言·后端·golang
Y.O.U..3 小时前
Go 语言 IO 基石:Reader 与 Writer 接口的 “最小设计” 与实战落地
开发语言·后端·golang
冒泡的肥皂3 小时前
25年AI我得DEMO老师
人工智能·后端