☰
Spring声明式事务管理深度解析:从AOP代理到失效场景全排查
2026/10/12 16:20:22 网站建设 项目流程

1. 从一次数据不一致事故说起:为什么声明式事务不是“加个注解就完事”

几年前我接手过一个后台对账模块,业务逻辑本身不复杂:用户提交一笔结算请求,系统需要同时写入结算主表、更新账户余额表、再往流水表里插一条记录。三张表,三个JdbcTemplate.update()调用,写在一个 Service 方法里。当时我想都没想,直接在方法上加了@Transactional,本地跑了一遍,数据都对,就上线了。

结果上线第三天,财务那边反馈:有几笔结算记录,主表里有,但流水表里没有,账户余额也没扣。查日志发现,那几次请求恰好赶上了数据库连接池短暂抖动,第二条 update 抛了异常,但第一条已经提交了。我当时的第一反应是“我不是加了@Transactional吗?怎么还会这样?”

排查了大半天才明白:那个 Service 类是通过new出来的,根本没走 Spring 容器,注解形同虚设。这件事让我彻底意识到,声明式事务管理(Declarative Transaction Management)看起来只是加一个@Transactional注解,但它背后依赖的是 Spring 的 AOP 代理机制、事务传播行为、异常回滚规则、连接绑定策略这一整套东西。任何一个环节理解不到位,注解就只是一行装饰性代码。

这篇内容就是围绕 Spring JDBC 场景下的声明式事务管理展开的。我会从它到底解决了什么问题讲起,把代理机制、传播行为、回滚规则、只读优化、超时控制这些核心点逐个拆开,再结合我踩过的坑给出可复现的配置和排查思路。适合已经用过JdbcTemplate、但对事务边界和失效场景还不太有把握的开发者,也适合想系统梳理一遍声明式事务底层逻辑的朋友。全程基于 Spring JDBC 这条技术线,不牵扯 JPA 或 MyBatis 的封装差异,讲的是最朴素也最本质的那套东西。

2. 声明式事务到底替我们做了什么:从手动 commit 到 AOP 拦截

2.1 编程式事务的繁琐与声明式事务的切入点

在没有声明式事务之前,用 Spring JDBC 写事务,标准写法是这样的:

TransactionTemplate template = new TransactionTemplate(transactionManager); template.execute(status -> { jdbcTemplate.update("insert into settle_main(...) values(...)"); jdbcTemplate.update("update account set balance = balance - ? where id = ?", amount, id); jdbcTemplate.update("insert into flow(...) values(...)"); return null; });

或者更原始的PlatformTransactionManager手动 begin、commit、rollback。这种编程式事务(Programmatic Transaction)控制粒度最细,但问题是:每个业务方法都要写一遍这套模板代码,事务边界和业务逻辑混在一起,重复且容易漏。一个项目里几十个写操作方法,每个都手写 try-catch-rollback,维护成本极高。

声明式事务的思路完全不同:它把“开启事务、提交事务、异常回滚”这些横切关注点,从业务代码里抽出来,交给 Spring 的 AOP 去处理。你只需要在方法上标注@Transactional,剩下的由框架在方法调用前后自动织入。业务代码里干干净净,只有纯粹的 SQL 操作。

2.2 代理对象才是事务的真正入口

这里有个关键认知:@Transactional生效的前提,是调用方拿到的是 Spring 容器里的代理对象,而不是原始对象。Spring 在启动时,会为标注了@Transactional的 Bean 生成一个代理(JDK 动态代理或 CGLIB 代理),代理对象在目标方法执行前后插入事务逻辑。

用生活化的类比:原始对象是“演员”,代理对象是“经纪人”。你找演员演戏,必须先经过经纪人。经纪人负责在演员上场前布置舞台(开启事务),演完后决定是谢幕(提交)还是叫停(回滚)。如果你绕过经纪人直接找演员(比如new一个对象,或者方法内部this.调用),那舞台就没人管了。

这就解释了为什么下面几种写法事务会失效:

  • Service 类没有交给 Spring 管理:自己new出来的对象,没有代理,注解无效。
  • 同类内部方法自调用:this.methodB()调用的是原始对象的方法,不经过代理,methodB上的@Transactional不生效。
  • 方法不是 public:Spring 默认只对 public 方法生成代理,protected、private、包级方法上的注解会被忽略。
  • 异常被自己 catch 掉了:代理感知不到异常,自然认为一切正常,直接提交。

提示:判断事务是否生效,最直接的办法是在方法里打印this.getClass().getName(),如果输出的是类似XxxService$$EnhancerBySpringCGLIB$$...这种带代理后缀的类名,说明代理生效了;如果输出的是原始类名,那事务大概率没起作用。

2.3 事务管理器与数据源的绑定关系

声明式事务要工作,必须有一个PlatformTransactionManager实现。在 Spring JDBC 场景下,用的就是DataSourceTransactionManager。它需要绑定一个DataSource,事务的开启、提交、回滚最终都落到这个数据源的连接上。

@Configuration public class TxConfig { @Bean public DataSourceTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

这里有个容易被忽略的点:事务管理器绑定的数据源,必须和JdbcTemplate使用的数据源是同一个。如果项目里配了多数据源,JdbcTemplate用的是 A 数据源,事务管理器绑的是 B 数据源,那事务开启的是 B 的连接,而 SQL 执行在 A 的连接上,两者根本不在一个事务里,回滚自然无效。多数据源场景下,要么用@Transactional指定具体的事务管理器,要么用分布式事务方案,这是另一个话题了。

3. 传播行为:嵌套调用时事务到底怎么走

3.1 七种传播行为的实际含义

传播行为(Propagation)解决的是“当一个事务方法被另一个事务方法调用时,事务该如何处理”的问题。Spring 定义了七种,但日常真正高频用到的其实就三四种。我把它整理成一张表,重点看“当前有事务时”和“当前无事务时”两种情况的差异:

传播行为当前有事务当前无事务典型场景
REQUIRED(默认)加入当前事务新建事务绝大多数写操作
REQUIRES_NEW挂起当前,新建独立事务新建事务日志记录、独立审计
NESTED创建保存点,可部分回滚新建事务批量处理中单条失败不影响整体
SUPPORTS加入当前事务以非事务方式执行查询方法
NOT_SUPPORTED挂起当前事务非事务执行耗时操作不想占事务
MANDATORY加入当前事务抛异常强制要求上层有事务
NEVER抛异常非事务执行明确禁止事务

默认的REQUIRED覆盖了 90% 的场景。它的逻辑是:有事务就加入,没有就新建。这意味着如果外层方法已经开了事务,内层方法会“搭便车”,共用同一个事务,一起提交或一起回滚。

3.2 REQUIRES_NEW 与 NESTED 的取舍

REQUIRES_NEW和NESTED是最容易混淆的一对。它们的区别在于回滚的独立性。

REQUIRES_NEW会挂起外层事务,开启一个全新的、完全独立的事务。内层事务提交后,即使外层事务后续回滚,内层已经提交的数据也不会被撤销。典型场景是操作日志:主业务失败了,但“谁在什么时候尝试做了什么”这条日志必须留下来,这时候日志方法就用REQUIRES_NEW。

NESTED则是在当前事务里打一个保存点(Savepoint),内层失败时回滚到保存点,外层可以选择继续或整体回滚。它依赖数据库对保存点的支持,且只对DataSourceTransactionManager这类支持保存点的事务管理器有效。批量导入场景里,如果希望“某一条数据格式错误只跳过这一条,其他照常入库”,NESTED就很合适。

@Transactional(propagation = Propagation.REQUIRED) public void batchImport(List<Record> records) { for (Record r : records) { try { importSingle(r); // 该方法标注 NESTED } catch (Exception e) { log.warn("单条导入失败,跳过:{}", r.getId()); } } } @Transactional(propagation = Propagation.NESTED) public void importSingle(Record r) { jdbcTemplate.update("insert into ..."); }

注意:NESTED的保存点回滚,需要内层方法抛出的异常被外层捕获。如果内层异常直接往外抛,外层事务照样整体回滚,保存点就失去意义了。

3.3 传播行为配置错误的典型症状

我见过一个很隐蔽的 bug:一个订单创建方法标了REQUIRED,里面调用的“扣减库存”方法标了REQUIRES_NEW。结果订单创建失败回滚了,库存却已经被扣掉,因为库存方法开的是独立事务,早就提交了。这种“部分提交”的数据不一致,排查起来非常痛苦,因为日志上看每个方法都执行成功了。

所以配置传播行为时,一定要问自己一句:这个方法失败了,我希望它影响外层吗?外层失败了,我希望它已经提交的数据被撤销吗?想清楚这两个问题,传播行为就不会选错。

4. 回滚规则:为什么你的异常没有触发回滚

4.1 默认只回滚 RuntimeException 和 Error

这是声明式事务里最经典的坑。Spring 默认只在遇到RuntimeException及其子类、Error时才回滚。如果你抛的是受检异常(Checked Exception,比如IOException、自定义的BusinessException extends Exception),事务默认会照常提交。

@Transactional public void doSomething() throws IOException { jdbcTemplate.update("insert into ..."); if (someCondition) { throw new IOException("文件读取失败"); // 默认不回滚! } }

上面这段代码,IOException抛出后,前面的 insert 依然会被提交。很多人第一次遇到时完全懵了,明明抛异常了,数据怎么还在。

解决办法有两个:一是把异常改成RuntimeException的子类;二是在注解上显式指定回滚的异常类型:

@Transactional(rollbackFor = Exception.class) public void doSomething() throws IOException { // ... }

rollbackFor = Exception.class表示“任何异常都回滚”,这是我在实际项目里最常用的配置。因为业务代码里受检异常太常见了,与其每次都去纠结异常类型,不如统一指定,省心。

4.2 noRollbackFor 的适用场景

反过来,有些异常你希望它不回滚。比如“用户重复提交”这种业务异常,你抛出来只是为了提示前端,并不希望已经写入的幂等记录被撤销。这时候用noRollbackFor:

@Transactional(rollbackFor = Exception.class, noRollbackFor = DuplicateSubmitException.class) public void submit(SubmitRequest req) { // ... }

noRollbackFor的优先级高于rollbackFor,两者同时配置时,noRollbackFor指定的异常不会触发回滚。

4.3 异常被吞掉的三种常见写法

比异常类型更隐蔽的,是异常根本没传到代理层。以下三种写法,事务都不会回滚:

// 写法一:自己 catch 了,没往外抛 @Transactional public void methodA() { try { jdbcTemplate.update("..."); int x = 1 / 0; } catch (Exception e) { log.error("出错了", e); // 异常被吃掉,代理感知不到 } } // 写法二:catch 后抛了别的受检异常 @Transactional public void methodB() throws Exception { try { jdbcTemplate.update("..."); } catch (Exception e) { throw new Exception("包装后的异常"); // 受检异常,默认不回滚 } } // 写法三:在事务方法内部又开了新线程 @Transactional public void methodC() { jdbcTemplate.update("..."); new Thread(() -> { throw new RuntimeException("子线程异常"); // 和主线程事务无关 }).start(); }

写法三尤其要注意:事务是和线程绑定的,ThreadLocal里存着当前线程的连接。子线程里抛异常,主线程的事务完全不知情,该提交还是提交。

提示:如果确实需要在事务方法里捕获异常做处理,又希望回滚,可以在 catch 块里手动标记回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();。这行代码告诉事务管理器“虽然我没抛异常,但请把这个事务标记为回滚”。

5. 只读、超时与隔离级别:那些提升稳定性的细节配置

5.1 readOnly 不只是“语义提示”

@Transactional(readOnly = true)很多人以为只是个文档性质的标记,其实它在底层是有实际作用的。对于DataSourceTransactionManager,设置readOnly后,会调用Connection.setReadOnly(true)。这个调用对不同的数据库驱动有不同效果:有的驱动会据此优化查询计划,有的数据库会拒绝在该连接上执行写操作,起到一层保护作用。

在纯查询方法上标注readOnly = true,一方面能避免误写,另一方面在某些数据库上确实能带来性能收益。我习惯把所有查询 Service 方法都标上,形成一种约定:看到 readOnly,就知道这个方法不该有写操作。

5.2 timeout 的单位与生效边界

@Transactional(timeout = 5)表示事务超时时间为 5 秒。注意单位是秒,不是毫秒。超时后,事务会被标记为回滚,并抛出TransactionTimedOutException。

但 timeout 有个容易误解的地方:它计算的是从事务开启到方法返回的总时长,而不是单条 SQL 的执行时间。如果方法里有多次数据库交互,加上业务计算,总耗时超过阈值就会触发。而且 timeout 的精度依赖底层实现,不是精确到毫秒的硬性中断,更像是“在下一个检查点发现超时了就回滚”。

实际配置时,我会根据业务复杂度给一个相对宽松的值,比如普通写操作 10 秒,批量操作 60 秒。设得太紧,正常业务偶尔抖动就超时;设得太松,又失去了保护意义。

5.3 隔离级别的选择与数据库默认值

@Transactional(isolation = Isolation.REPEATABLE_READ)可以指定事务隔离级别。但我的建议是:除非有明确的并发问题需要解决,否则不要轻易改隔离级别,用数据库默认的就好。

原因是隔离级别和数据库强相关。MySQL 默认REPEATABLE_READ,Oracle 默认READ_COMMITTED,PostgreSQL 默认READ_COMMITTED。你在代码里硬编码一个隔离级别,换数据库时行为可能就变了。而且隔离级别越高,并发性能越差,锁竞争越激烈。

真正需要关注隔离级别的场景,通常是“读到了不该读的数据”这类并发问题。这时候优先考虑的是优化 SQL、加合适的索引、调整业务逻辑,而不是简单粗暴地提高隔离级别。

6. 失效场景全排查:一次线上问题的完整定位链路

6.1 问题现象与初步假设

回到开头那次数据不一致的事故。现象是:结算主表有记录,流水表和账户表没变化。日志里能看到第二条 update 抛了CannotGetJdbcConnectionException,但第一条已经落库。

我的初步假设有三个:

  1. 事务根本没开启,三条 update 各自独立提交。
  2. 事务开启了,但异常类型不对,没触发回滚。
  3. 事务开启了也回滚了,但第一条 update 在事务外执行。

6.2 逐步验证:从代理对象查到连接绑定

第一步,确认代理是否生效。我在 Service 方法入口打印了this.getClass().getName(),输出的是原始类名,没有 CGLIB 后缀。这就锁定了方向:代理没生成,事务注解没被处理。

第二步,查为什么没生成代理。翻代码发现,这个 Service 是在另一个类的构造器里通过new SettleService()创建的,而不是通过@Autowired注入的。Spring 容器根本不知道这个对象的存在,自然不会为它生成代理。

第三步,验证连接绑定。为了确认“如果代理生效,三条 update 是否会在同一个连接上”,我写了个小测试:在事务方法里连续执行两次jdbcTemplate.update(),然后打印每次拿到的Connection的hashCode。结果两次是同一个值,说明DataSourceUtils确实把连接绑定到了当前线程的ThreadLocal上,同一个事务内复用同一连接。

第四步,确认异常回滚规则。虽然这次问题的根因是代理没生效,但顺带检查了异常类型。抛出的CannotGetJdbcConnectionException继承自DataAccessException,而DataAccessException是RuntimeException的子类,所以异常类型本身是能触发回滚的。问题纯粹出在代理环节。

6.3 修复方案与验证

修复很简单:把这个 Service 改成通过 Spring 注入,确保拿到的是代理对象。

@Service public class SettleService { private final JdbcTemplate jdbcTemplate; public SettleService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Transactional(rollbackFor = Exception.class) public void settle(SettleRequest req) { jdbcTemplate.update("insert into settle_main(...) values(...)"); jdbcTemplate.update("update account set balance = balance - ? where id = ?", ...); jdbcTemplate.update("insert into flow(...) values(...)"); } }

调用方改成:

@Service public class SettleFacade { @Autowired private SettleService settleService; // 注入的是代理对象 public void handle(SettleRequest req) { settleService.settle(req); } }

改完后重新验证:故意在第二条 update 处制造异常,观察三条 SQL 是否全部回滚。结果主表、账户表、流水表都没有变化,事务生效。

6.4 举一反三:还有哪些写法会让事务悄悄失效

这次事故之后,我整理了一份“事务失效自查清单”,每次写事务方法前扫一眼:

失效场景原因规避方式
对象自己 new没有代理交给 Spring 管理
同类方法自调用this 调用不走代理拆到另一个 Bean,或注入自身
方法非 public代理不覆盖改为 public
异常被 catch代理感知不到手动 setRollbackOnly 或重新抛出
抛受检异常默认不回滚配置 rollbackFor
多数据源未指定事务管理器与数据源不匹配指定 transactionManager
子线程内操作事务与线程绑定子线程内独立开事务

这份清单帮我省了很多排查时间。尤其是“同类方法自调用”这一条,太容易踩了。比如一个类里methodA调用methodB,两个都标了@Transactional,你以为methodB会开新事务,实际上它只是普通方法调用,注解完全不起作用。

7. 多数据源与事务管理器的绑定陷阱

7.1 一个数据源对应一个事务管理器

前面提过,DataSourceTransactionManager必须和JdbcTemplate用同一个DataSource。在多数据源项目里,这个对应关系必须显式维护。假设有两个数据源primaryDataSource和secondaryDataSource,就要配两个事务管理器:

@Bean public DataSourceTransactionManager primaryTxManager( @Qualifier("primaryDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); } @Bean public DataSourceTransactionManager secondaryTxManager( @Qualifier("secondaryDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); }

使用时通过@Transactional(transactionManager = "primaryTxManager")指定。如果不指定,Spring 会去找默认的事务管理器,可能就绑错了数据源。

7.2 跨数据源操作无法用单机事务保证

需要清醒认识到:单机的DataSourceTransactionManager只能管一个数据源的事务。如果业务需要同时写两个库,并且要求原子性,那单靠声明式事务是做不到的。这时候要么用分布式事务方案,要么通过业务设计规避(比如把跨库操作改成异步补偿、最终一致)。

我见过有人试图在一个@Transactional方法里同时操作两个数据源,以为能一起回滚。实际上,事务管理器只绑定了其中一个数据源,另一个数据源的操作是自动提交的,根本不在事务范围内。这种“以为有事务其实没有”的情况,比明确知道没有事务更危险。

7.3 动态数据源下的事务边界

有些项目用AbstractRoutingDataSource做动态数据源切换,根据某个上下文变量决定用哪个库。这种场景下,事务的开启时机很关键:事务一旦开启,连接就已经确定,中途再切换数据源是无效的。因为事务绑定的是开启时拿到的那个连接。

所以动态数据源的正确用法是:在进入事务方法之前就确定好数据源,事务方法内部不要再切换。如果确实需要在一个业务流里操作多个库,那就拆成多个事务方法,各自管理自己的数据源。

8. 我踩过的坑与几条实用经验

8.1 事务方法里不要做耗时操作

事务开启后,数据库连接就被占用了。如果方法里有 HTTP 调用、文件读写、复杂计算这些耗时操作,连接会一直被占着,高并发下连接池很快耗尽。我的习惯是:把耗时操作挪到事务方法外面,事务方法里只保留数据库操作。

public void handle(Request req) { // 耗时操作放在事务外 String result = remoteService.call(req); // 事务方法只做数据库写入 saveResult(req, result); } @Transactional(rollbackFor = Exception.class) public void saveResult(Request req, String result) { jdbcTemplate.update("..."); }

8.2 批量操作要控制单次事务的数据量

用JdbcTemplate.batchUpdate()做批量插入时,如果一次提交几万条,事务日志会非常大,回滚成本极高,还可能触发数据库的锁等待超时。我的经验是分批提交,每批 500 到 1000 条,每批一个独立事务。这样即使某批失败,也只影响那一批,前面的批次已经落库,可以通过重试补齐。

8.3 测试事务回滚不要依赖肉眼观察

验证事务是否回滚,不要只看“方法抛异常了”,要去数据库里查实际数据。我习惯在测试方法里用TransactionTemplate手动控制,或者用@Rollback配合测试框架,确保每次测试后数据状态可预期。更稳妥的做法是写一个专门的验证方法:插入数据、制造异常、捕获异常、再查询确认数据不存在。

8.4 日志里加上事务相关信息

排查事务问题时,日志是第一手资料。我会在事务方法入口和出口打上方法名和关键参数,异常时打印完整堆栈。如果项目允许,还可以在事务管理器的afterCompletion回调里记录事务的提交/回滚状态,这样能清楚看到每个事务的最终归宿。

@Bean public DataSourceTransactionManager transactionManager(DataSource ds) { DataSourceTransactionManager tm = new DataSourceTransactionManager(ds); tm.setNestedTransactionAllowed(true); // 允许 NESTED return tm; }

setNestedTransactionAllowed(true)这个配置,如果你要用NESTED传播行为,是必须开的。默认情况下,DataSourceTransactionManager对嵌套事务的支持取决于具体实现,显式打开更稳妥。

8.5 关于“事务里查不到刚插入的数据”

这个现象通常和事务隔离级别、或者查询走了不同的连接有关。在同一个事务里,用同一个JdbcTemplate查询,应该能看到本事务内未提交的插入。如果查不到,先确认查询方法是不是也走了同一个事务管理器、同一个数据源。如果查询方法标了REQUIRES_NEW,那它开的是新事务、新连接,自然看不到外层未提交的数据。

声明式事务管理这套机制,用起来简单,但真正吃透需要理解它背后的代理、连接绑定、传播、回滚这几层逻辑。我现在的习惯是:每写一个@Transactional方法,都会在脑子里过一遍“这个方法被谁调用、异常怎么处理、失败了影响范围多大”。想清楚这三点,大部分事务相关的坑都能提前避开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询