《SpringBoot4进阶实战》第11招 事务到底在哪生效
# 方法明明写了 @Transactional,为什么事务仍可能不生效? ```text 记忆注解 → 设计边界 → 一致性 ``` 我是老李,在深圳做架构师,常年跟 Spring Boot 打交道。 Petclinic 是 Spring 官方教学项目,Owner、Pet、Visit 三张表天然构成多步骤写操作场景。 我见过太多团队把 @Transactional 当护身符,写上就以为万事大吉,结果数据错乱、连接耗尽、回滚失效。 方法明明写了 @Transactional,为什么事务仍可能不生效? 该把事务注解放在哪一层,才能让回滚真正覆盖业务边界? 选错事务边界,会不会让数据库连接被长事务拖死,最终拖垮整个应用? # 01、故事 ```text 凌晨 1:30 → 深圳某金融公司机房 → 任务是排查订单重复入账 → 冲突:日志显示事务已提交,但数据库里出现半截数据 ``` 凌晨 1:30,机房冷气开得足,我盯着屏幕上的异常堆栈,咖啡已经凉透。 任务是排查订单重复入账问题,业务方说用户付了两次钱,但只收到一次货。 我打开 Petclinic 的 Visit 新增接口,发现它先更新 Owner,再插入 Pet,最后写 Visit,三步操作被拆在三个方法里。 每个方法都标了 @Transactional,可中间一步抛异常后,前两步的数据竟然留在了库里。 日志里明明显示事务已提交,可数据库里就是半截数据,像被啃过的苹果。 > 注解不是护身符, > 代理才是真门神。 > 内部调用绕过去, > 回滚落空泪满襟。 # 02、问题 ```text 需求变化:一次前台操作要同时写 Owner、Pet、Visit → 冲突:三个方法各自开事务,中间失败只回滚自己 → 后果:数据库出现孤儿 Pet、脏 Owner ``` Petclinic 原本新增 Visit 只写一张表,事务边界清晰。 现在需求变了,一次前台操作要同时更新 Owner 信息、新增 Pet、登记 Visit,三步必须同生共死。 我把三个方法都标上 @Transactional,以为每个方法自带事务,合起来就是整体事务。 结果中间步骤抛异常,只有最后那个方法回滚了,前面两个方法的数据已经提交,数据库里出现孤儿 Pet。 影响是数据不一致,用户看到宠物还在,但就诊记录没了,下次查询直接报错。 可验证的验收标准:模拟中间步骤失败,Owner、Pet、Visit 三张表都不应有新增或修改。 > 需求一变边界乱, > 三个事务各自转。 > 中间失败留残局, > 孤儿数据惹人烦。 # 03、原理 ```text Spring 事务基于 AOP 代理,只有外部调用才走代理,内部 this 调用直接绕过代理,事务注解形同虚设 ``` Spring 的 @Transactional 不是魔法,它靠 AOP 代理实现。 当你在方法上标 @Transactional,Spring 会为 Bean 生成一个代理对象,调用方法时先经过代理,代理开启事务、执行业务、提交或回滚。 但如果你在同一个类里,用 this 调用另一个带 @Transactional 的方法,调用直接走原始对象,代理根本不知道,事务自然不生效。 反直觉判断:方法上写了 @Transactional,不代表它一定在事务里运行,关键看谁在调用它。 另一个反直觉点:@Transactional 默认只回滚 RuntimeException 和 Error,受检异常(如 Exception)不会触发回滚,除非显式指定 rollbackFor。 事务传播级别也常被误解,REQUIRED 不是新开事务,而是“有则加入,无则新建”,理解错会导致事务边界比预期大得多。 > 代理门前三叩首, > 内部调用不回头。 > 受检异常不落滚, > 传播级别暗流涌。 # 04、架构 ```text 输入:HTTP 请求 → 本模块:Service 层事务边界 → 输出:一致的三表数据 / 异常回滚 → 边界:事务只覆盖业务逻辑,不覆盖 HTTP 连接与外部调用 ``` 事务边界应该画在 Service 层,而不是 Controller 或 Repository。 Controller 负责参数校验和响应封装,Repository 只做单表 CRUD,Service 层编排多步骤业务,事务注解放在 Service 的公开方法上。 收益是事务粒度与业务操作对齐,回滚范围清晰,代码可读性高。 代价是 Service 层方法不能随意拆小,否则容易触发内部调用陷阱。 适用条件是业务操作确实需要多表一致性,如果只是单表写,事务放 Repository 反而增加无谓开销。 > 边界画在服务层, > 控制仓储两边分。 > 事务粒度对业务, > 回滚清晰不迷魂。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + MySQL 8 + Petclinic 源码 → 配置:开启事务管理 → 代码:VisitService 三个方法各标 @Transactional → 启动:正常 → 验证:模拟中间失败 → 结果:部分提交 ``` 我基于 Petclinic 源码,在 visit 包下新建 VisitService,版本用 Spring Boot 3.2(需按当前官方文档核验)。 先看 Petclinic 的 Owner、Pet、Visit 实体,它们有 JPA 注解,Visit 关联 Pet,Pet 关联 Owner。 我在 pom.xml 里确认有 spring-boot-starter-data-jpa 和 mysql-connector-j,配置 application.properties 指向本地 MySQ L。 ```properties spring.datasource.url=jdbc:mysql://localhost:3306/petclinic spring.datasource.username=root spring.datasource.password=secret spring.jpa.hibernate.ddl-auto=update ``` 然后写 VisitService,三个方法分别更新 Owner、新增 Pet、新增 Visit,每个方法都标 @Transactional。 ```java @Service public class VisitService { private final OwnerRepository ownerRepository; private final PetRepository petRepository; private final VisitRepository visitRepository; public VisitService(OwnerRepository ownerRepository, PetRepository petRepository, VisitRepository visitRepository) { this.ownerRepository = ownerRepository; this.petRepository = petRepository; this.visitRepository = visitRepository; } @Transactional public void updateOwner(Owner owner) { ownerRepository.save(owner); } @Transactional public Pet addPet(Pet pet) { return petRepository.save(pet); } @Transactional public Visit addVisit(Visit visit) { return visitRepository.save(visit); } } ``` Controller 里调用这三个方法,模拟一次前台操作。 ```java @PostMapping("/owners/{ownerId}/visits") public ResponseEntity addVisit(@PathVariable int ownerId, @RequestBody VisitRequest request) { Owner owner = ownerService.findById(ownerId); owner.setLastName(request.getOwnerLastName()); visitService.updateOwner(owner); Pet pet = new Pet(); pet.setName(request.getPetName()); pet.setOwner(owner); Pet savedPet = visitService.addPet(pet); Visit visit = new Visit(); visit.setPet(savedPet); visit.setDate(request.getDate()); visit.setDescription(request.getDescription()); visitService.addVisit(visit); return ResponseEntity.status(HttpStatus.CREATED).build(); } ``` 启动应用,用 curl 发请求,故意让 addPet 里抛异常(比如宠物名称为空时抛 IllegalArgumentException)。 ```bash curl -X POST http://localhost:8080/owners/1/visits \ -H "Content-Type: application/json" \ -d '{"ownerLastName":"Smith","petName":"","date":"2025-01-01","description":"checkup"}' ``` 预期是整体回滚,但实际查询数据库,发现 Owner 的 lastName 被改了,Pet 没插入,Visit 没插入。 示例输出(日志): ```text 2025-01-01 10:00:00.123 ERROR 12345 --- [nio-8080-exec-1] o.s.j.support.JobService : Pet name must not be empty 2025-01-01 10:00:00.124 INFO 12345 --- [nio-8080-exec-1] jdbc.sqltiming : UPDATE owners SET last_name='Smith' WHERE id=1 ``` Owner 更新已提交,Pet 插入失败,事务没有整体回滚。 成功标准是三步要么全成功要么全失败,现在明显没达到。 > 三个方法各开火, > 中间爆炸留残壳。 > Owner 已改 Pet 空, > 事务边界没画妥。 # 06、排查 ```text 诊断树:现象是部分提交 → 怀疑事务没生效 → 检查代理是否被绕过 → 证据是日志显示 Owner 更新已提交 → 根因是内部调用绕过代理 → 修复:合并方法或注入自身代理 ``` 问题一:为什么 Owner 更新提交了,Pet 插入失败却没回滚? 现象是部分提交,怀疑是事务没生效。 我检查 VisitService 的字节码,确认 @Transactional 注解存在,但发现 Controller 调用的是 visitService.updateOwner(),这是外部调用,应 该走代理。 可问题是三个方法各自独立事务,Pet 失败只回滚 Pet 自己的事务,Owner 的事务已经提交。 证据是数据库里 Owner 的 lastName 变了,Pet 表没有新行。 根因是事务边界粒度太细,每个方法一个事务,没有形成整体。 修复方法:把三个方法合并成一个公开方法,事务注解只标在合并方法上。 ```java @Transactional public void addVisitFlow(Owner owner, Pet pet, Visit visit) { ownerRepository.save(owner); Pet savedPet = petRepository.save(pet); visit.setPet(savedPet); visitRepository.save(visit); } ``` 问题二:如果我把三个方法合并了,但内部又调用另一个带 @Transactional 的私有方法,事务会怎样? 现象是私有方法上的 @Transactional 不生效,怀疑是代理问题。 我检查调用链,发现私有方法通过 this 调用,直接绕过代理。 证据是日志里私有方法执行时没有事务开启记录。 根因是 Spring AOP 基于代理,私有方法无法被代理拦截。 修复方法:把私有方法改成 public,并注入自身代理(@Lazy 注入),或者干脆不标事务,让外层事务统一管理。 **错误尝试:** 我一开始以为把 @Transactional 加到每个方法上就能保证整体性,结果发现事务是独立的,根本没用。 后来我又尝试用 @Transactional(propagation = Propagation.REQUIRES_NEW) 强制新事务,结果每个方法都开新事务,回滚范围更小,数据更乱。 > 代理绕过暗门开, > 私有方法不理会。 > 事务独立各自飞, > 回滚范围碎成灰。 # 07、优化 ```text V1:三个方法各标事务 → 证据:部分提交 → V2:合并成一个公开方法,事务注解只标一次,并显式指定 rollbackFor ``` V1 的问题在于事务边界太碎,我改成 V2:把整个流程合并成一个公开方法 addVisitFlow,事务注解只标一次。 同时显式指定 rollbackFor = Exception.class,因为业务里可能抛受检异常,默认不回滚。 ```java @Transactional(rollbackFor = Exception.class) public void addVisitFlow(Owner owner, Pet pet, Visit visit) { ownerRepository.save(owner); Pet savedPet = petRepository.save(pet); visit.setPet(savedPet); visitRepository.save(visit); } ``` Controller 改为只调用这一个方法。 ```java @PostMapping("/owners/{ownerId}/visits") public ResponseEntity addVisit(@PathVariable int ownerId, @RequestBody VisitRequest request) { Owner owner = ownerService.findById(ownerId); owner.setLastName(request.getOwnerLastName()); Pet pet = new Pet(); pet.setName(request.getPetName()); pet.setOwner(owner); Visit visit = new Visit(); visit.setDate(request.getDate()); visit.setDescription(request.getDescription()); visitService.addVisitFlow(owner, pet, visit); return ResponseEntity.status(HttpStatus.CREATED).build(); } ``` 再次用同样的 curl 请求,模拟 Pet 名称为空抛异常。 这次日志显示事务回滚,Owner 的 lastName 没有被修改。 示例输出(日志): ```text 2025-01-01 10:00:00.456 ERROR 12345 --- [nio-8080-exec-1] o.s.j.support.JobService : Pet name must not be empty 2025-01-01 10:00:00.457 INFO 12345 --- [nio-8080-exec-1] o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback 2025-01-01 10:00:00.458 INFO 12345 --- [nio-8080-exec-1] jdbc.sqltiming : UPDATE owners SET last_name='Smith' WHERE id=1 (rolled back) ``` Owner 更新被回滚,数据库三张表都没有变化。 > 合并方法定边界, > 回滚异常显式写。 > 一次事务管到底, > 数据一致心不怯。 # 08、演进 ```text V2:整体事务生效 → 能力:一致性边界清晰 → 遗留:长事务风险,需控制事务内外部调用 ``` V2 解决了回滚问题,但新问题来了:事务范围变大,如果方法里有耗时的外部调用,比如 HTTP 请求保险系统,事务会一直持有数据库连接。 我模拟一个场景:在 addVisitFlow 里调用一个远程保险接口,响应需要 5 秒,期间事务未提交,数据库连接被占用。 用 V1 的同一组请求验证:先发一个正常请求,再发一个失败请求,观察连接池状态。 示例输出(连接池监控): ```text Active connections: 10 Max connections: 10 Waiting threads: 3 ``` 连接池被打满,其他请求排队等待,系统吞吐量骤降。 收益是数据一致性有保障,代价是事务时间变长,连接占用增加。 适用条件是事务内只做数据库操作,外部调用必须移到事务外。 遗留问题是长事务风险,需要设计事务边界时考虑外部依赖。 > 事务变大连接紧, > 外部调用拖时间。 > 连接池满请求等, > 吞吐下降心发慌。 # 09、洞见 ```text 从现象到判断:事务不生效不是注解问题,是代理边界问题;长事务不是事务问题,是架构问题 ``` ## 事务注解是契约,不是实现 ```text 注解 → 代理 → 事务 ``` @Transactional 只是告诉 Spring“这个方法需要事务”,但真正开启事务的是代理对象。 如果调用没经过代理,注解就是一张废纸。 反直觉判断:你写的注解可能根本没生效,但代码不报错,数据却悄悄错。 ## 内部调用是事务失效的头号杀手 ```text this 调用 → 绕过代理 → 事务丢失 ``` 同一个类里,方法 A 调用方法 B,如果 B 标了 @Transactional,B 的事务不会生效。 因为 this 调用直接走原始对象,代理没机会拦截。 反直觉判断:把方法拆得越细,越容易触发这个陷阱,事务边界反而越模糊。 ## 回滚范围由事务边界决定,不由异常决定 ```text 异常 → 事务边界 → 回滚范围 ``` 异常只是触发回滚的信号,真正决定回滚多少数据的是事务边界。 如果边界画小了,异常只能回滚局部,其他已提交的数据无法挽回。 所以设计事务边界时,要问“哪些操作必须同生共死”,而不是“哪里抛异常”。 ## 长事务是连接池的隐形杀手 ```text 事务内 HTTP → 连接占用 → 池耗尽 ``` 事务内做外部调用,数据库连接被白白占用,连接池很快耗尽。 这不是事务的问题,是架构问题:外部调用不该进事务。 判断标准:事务方法里有没有非数据库 I/O,有就要重构。 > 注解契约非实现, > 代理边界要看清。 > 内部调用绕道走, > 长事务里藏祸心。 # 10、系统落地 ```text 入口:HTTP Controller → 服务:VisitService → 本模块:事务边界设计 → 存储:MySQL → 未实现:外部调用异步化、事务监控 ``` Petclinic 原本只有简单的单表 CRUD,事务隐式存在,没人关心边界。 本篇增加了 VisitService 作为事务边界,把 Owner、Pet、Visit 三步写操作纳入一个事务,并验证了回滚。 现在系统能保证多步骤写操作的一致性,还能通过日志观察事务提交和回滚。 下一篇缺的是事务监控和外部调用治理,比如用 Actuator 暴露事务指标,把保险 HTTP 调用移到事务外。 官方文档首页:https://docs.spring.io/spring-boot/index.html > 入口控制接请求, > 服务层里定边界。 > 存储一致有保障, > 外部调用待迁移。 # 11、小结 ```text 三问三答:事务不生效因为代理被绕过;边界应画在 Service 公开方法;选错边界导致部分提交或连接耗尽 ``` 第一问:方法写了 @Transactional 为什么不生效?因为 Spring 事务靠 AOP 代理,内部 this 调用绕过代理,注解形同虚设。 第二问:事务注解该放哪一层?放在 Service 层公开方法上,覆盖完整业务操作,避免拆碎。 第三问:选错边界会怎样?边界太小导致部分提交,数据不一致;边界太大且含外部调用,导致长事务占用连接,拖垮系统。 当前状态:Petclinic 的 Visit 新增已具备整体回滚能力,事务边界清晰,但还需警惕长事务。 > 三问三答心自明, > 代理边界要分清。 > 回滚一致有保障, > 长事务里再修行。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring 事务注解在私有方法上不生效? 因为 Spring AOP 基于代理,私有方法无法被代理拦截,调用时直接走原始对象,事务逻辑不会执行。 ## 实战题:在 Petclinic 中,把 VisitService 的 addVisitFlow 方法改成内部调用两个带 @Transactional 的 public 方法,观察事务是否整体回滚。 如果两个 public 方法通过 this 调用,事务不会整体回滚,因为内部调用绕过代理。 ## 排障题:线上出现“Connection is not available, request timed out”,如何排查是否由长事务引起? 先查连接池监控,看活跃连接数是否打满;再查数据库慢查询日志,找长时间未提交的事务;最后检查代码,看事务方法内是否有外部 HTTP 调用。 ## 架构判断题:一个事务方法需要调用第三方保险接口,且要求接口失败时回滚数据库,该如何设计? 不应该把 HTTP 调用放进事务,而应把事务拆成两步:先调用保险接口,成功后再开启事务写数据库;如果接口失败,直接返回错误,不写数据库。 > 作业四题练真功, > 代理边界记心中。 > 长事务里找线索, > 架构设计不盲从。 # 13、升华 ```text 工程结论:事务设计是边界设计,不是注解堆砌;一致性来自清晰的边界和代理的正确使用 ``` 事务不生效的根因不是注解写错,而是对 Spring AOP 代理机制理解不透。 真正的事务设计,是回答“哪些操作必须同生共死”,然后把边界画在 Service 层公开方法上。 长事务问题提醒我们,事务边界不能无限扩大,外部调用必须隔离在事务外。 下一篇可以深入事务传播级别和隔离级别,在 Petclinic 中模拟并发场景,进一步验证边界设计。 > 事务设计是边界, > 代理机制是根基。 > 一致性从边界来, > 架构演进不停息。 **系列项目:** Spring 官方 Petclinic,一个宠物诊所管理系统,服务对象是诊所前台和兽医。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层开发、数据访问与 Repository 设计。 **本篇:** 多步骤写操作的事务边界设计,包括 @Transactional 原理、代理陷阱、回滚验证、长事务风险。 **下一篇:** 事务传播级别与隔离级别实战,在 Petclinic 中模拟并发访问,设计合适的传播行为。