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,但第一条已经落库。
我的初步假设有三个:
- 事务根本没开启,三条 update 各自独立提交。
- 事务开启了,但异常类型不对,没触发回滚。
- 事务开启了也回滚了,但第一条 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方法,都会在脑子里过一遍“这个方法被谁调用、异常怎么处理、失败了影响范围多大”。想清楚这三点,大部分事务相关的坑都能提前避开。