1. 为什么声明式事务是Spring JDBC里最值得吃透的一环
刚接触Spring JDBC那会儿,我对事务的理解还停留在“手动开、手动提交、出错回滚”的阶段。每次写业务方法,开头先拿Connection,中间一堆try-catch,最后在finally里小心翼翼地commit或者rollback。代码能跑,但一旦业务逻辑变复杂,比如一个方法里要操作三张表,中间还调用了别的Service,事务边界就开始变得模糊——到底该在哪一层开事务?嵌套调用会不会重复提交?异常被吞了怎么办?这些问题靠手写JDBC的模板代码几乎没法优雅解决。
后来用上Spring的声明式事务,才真正体会到什么叫“把横切关注点从业务代码里剥离出去”。你只需要在方法或者类上打一个@Transactional,剩下的开启、提交、回滚、连接绑定,Spring全帮你处理了。听起来简单,但实际用起来坑一点都不少:什么情况下事务会失效?传播行为怎么选?只读事务到底有没有用?rollbackFor不写会怎样?这些问题如果没搞清楚,线上出问题的时候你连日志都看不懂。
这篇内容就是围绕Spring JDBC的声明式事务管理展开的。我会从整体设计思路讲起,把核心概念、配置方式、传播行为、隔离级别、失效场景、排查技巧全部拆开揉碎,配上可以直接抄的代码和参数说明。不管你是刚学Spring Boot的新手,还是已经用了几年但一直没系统梳理过的老手,应该都能从中找到一些之前忽略的细节。全文基于常见的Spring Boot + JDBC实践来写,不依赖任何特定框架版本,思路通用。
2. 声明式事务的整体设计与核心思路拆解
2.1 从编程式到声明式:到底省了什么
先说说编程式事务长什么样,这样对比才直观。用Spring的TransactionTemplate写编程式事务大概是这个路子:
transactionTemplate.execute(status -> { jdbcTemplate.update("INSERT INTO orders ..."); jdbcTemplate.update("UPDATE inventory ..."); return null; });这段代码本身没问题,事务边界清晰,异常会自动回滚。但问题是,每个需要事务的方法你都得包一层,业务代码和事务控制代码混在一起。如果方法里还有条件分支、循环调用,代码很快就变得难以维护。
声明式事务的核心思路是AOP代理。Spring在容器启动时,会为标注了@Transactional的Bean生成一个代理对象。当你调用这个Bean的方法时,实际上调用的是代理对象,代理会在方法执行前开启事务、把Connection绑定到当前线程,方法执行完后根据是否抛异常来决定提交还是回滚。业务代码里完全看不到事务的痕迹,这就是“声明式”三个字的含义。
注意:声明式事务依赖代理,所以只有通过代理对象调用的方法才会被事务管理。同一个类内部的方法直接调用(this.method())是不会走代理的,这是最常见的失效场景之一,后面会详细讲。
2.2 为什么Spring JDBC场景下更推荐声明式
有人可能会问,MyBatis、Hibernate也有声明式事务,为什么单独说Spring JDBC?其实声明式事务的底层机制是通用的,但Spring JDBC的场景有几个特点让它特别适合声明式:
第一,Spring JDBC本身就是轻量封装,没有Session、一级缓存这些概念,事务管理的职责非常纯粹,就是Connection的开启、提交、回滚。声明式事务和它的契合度最高,不会出现ORM框架里那种“事务和Session生命周期不一致”的坑。
第二,Spring JDBC通常配合DataSourceTransactionManager使用,这个事务管理器直接操作DataSource和Connection,逻辑简单透明。你只要配置好数据源,事务管理器几乎不需要额外调优。
第三,在微服务或者多数据源场景下,Spring JDBC的声明式事务更容易做定制。比如你需要根据某个条件动态切换数据源,或者在一个事务里操作多个数据源(分布式事务的简化场景),基于DataSourceTransactionManager扩展比基于ORM框架要灵活得多。
2.3 核心组件与执行链路
声明式事务的完整链路涉及几个关键组件,理解它们之间的关系对排查问题非常有帮助:
| 组件 | 职责 | 常见实现 |
|---|---|---|
| PlatformTransactionManager | 事务管理的顶层接口,定义获取、提交、回滚事务的方法 | DataSourceTransactionManager |
| TransactionDefinition | 定义事务属性:传播行为、隔离级别、超时、只读 | DefaultTransactionDefinition |
| TransactionStatus | 表示当前事务的运行状态 | DefaultTransactionStatus |
| TransactionInterceptor | AOP拦截器,负责在方法前后织入事务逻辑 | 由Spring自动装配 |
| BeanFactoryTransactionAttributeSourceAdvisor | 切面顾问,决定哪些Bean和方法需要被事务代理 | 由@EnableTransactionManagement注册 |
执行链路大致是这样的:调用方调用代理对象的方法 →TransactionInterceptor拦截 → 根据TransactionDefinition获取或创建事务 → 执行业务方法 → 根据执行结果提交或回滚 → 清理线程绑定的Connection。
这条链路里任何一个环节出问题,都会导致事务行为不符合预期。比如TransactionDefinition里的传播行为配错了,就会出现“明明抛了异常却没回滚”或者“不该回滚的时候回滚了”。
3. 核心配置与实操要点全解析
3.1 开启声明式事务的两种方式
在Spring Boot项目里,最简单的方式是直接在启动类或者配置类上加@EnableTransactionManagement:
@Configuration @EnableTransactionManagement public class TransactionConfig { // 数据源和JdbcTemplate的配置 }不过实际上,如果你用的是Spring Boot的spring-boot-starter-jdbc,这个注解通常可以省略。因为DataSourceTransactionManagerAutoConfiguration会自动配置一个DataSourceTransactionManager,而TransactionAutoConfiguration会在检测到PlatformTransactionManager存在时自动启用事务管理。也就是说,只要你引入了JDBC starter并且配置了数据源,声明式事务就已经可用了。
但如果你用的是多数据源,或者需要自定义事务管理器,那就必须手动配置。比如:
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }这里有个细节:DataSourceTransactionManager的构造函数接收DataSource,它会通过DataSourceUtils.getConnection()来获取连接。如果你在事务方法里直接用dataSource.getConnection(),拿到的会是另一个连接,事务自然就不生效了。所以务必确保你的DAO层用的是JdbcTemplate或者通过DataSourceUtils获取连接。
3.2 @Transactional注解的关键属性
@Transactional注解有一堆属性,常用的就那么几个,但每一个都值得说清楚:
propagation(传播行为):这是最容易出问题的属性。默认是REQUIRED,意思是如果当前没有事务就新建一个,如果有就加入。其他常用的还有REQUIRES_NEW(挂起当前事务,新建一个独立事务)、NESTED(嵌套事务,基于保存点)、SUPPORTS(有事务就用,没有就非事务执行)、NOT_SUPPORTED(挂起当前事务,非事务执行)。
isolation(隔离级别):默认是DEFAULT,也就是用数据库的默认隔离级别。MySQL的InnoDB默认是REPEATABLE_READ,Oracle默认是READ_COMMITTED。一般情况下不需要改,除非你有特殊的并发需求。
timeout(超时时间):单位是秒,默认是-1(不超时)。如果某个事务可能执行很久,建议设置一个合理的超时时间,避免长时间占用连接。
readOnly(只读):默认是false。设置为true时,Spring会做一些优化,比如设置Connection的readOnly属性,某些数据库驱动会据此优化查询。但要注意,只读事务里如果执行了写操作,行为取决于数据库实现,不一定会报错。
rollbackFor(回滚异常):默认只对RuntimeException和Error回滚,受检异常不会触发回滚。这是很多人踩过的坑——方法里抛了IOException,事务却没回滚。解决办法就是显式指定rollbackFor = Exception.class。
noRollbackFor(不回滚异常):指定哪些异常不触发回滚,用的比较少,但在某些业务场景下有用。
3.3 传播行为的选择逻辑与实战场景
传播行为是声明式事务里最抽象的部分,我用几个实际场景来说明怎么选:
场景一:订单创建 + 库存扣减。这两个操作必须在同一个事务里,要么都成功,要么都失败。用默认的REQUIRED就行,因为它们是同一个业务方法里的连续操作。
场景二:订单创建 + 发送通知。发送通知失败不应该导致订单回滚,这时候可以把发送通知的方法标注为REQUIRES_NEW,让它在一个独立事务里执行。但要注意,如果通知方法内部抛了异常,外层事务不会回滚,你需要自己catch住。
场景三:批量导入,每条记录独立事务。比如导入1000条数据,希望某一条失败不影响其他条。这时候可以在循环里调用一个标注了REQUIRES_NEW的方法,每条记录一个事务。但这样性能会很差,因为每次都要新建连接。更好的做法是用批量操作 + 保存点,或者干脆不用事务,靠数据库的唯一约束来保证一致性。
场景四:嵌套调用,内层失败不影响外层。用NESTED传播行为,内层事务基于保存点,内层回滚不会导致外层回滚。但NESTED需要数据库支持保存点,MySQL的InnoDB是支持的。
实操心得:传播行为的选择没有绝对的对错,关键是想清楚“内层失败时,外层应该怎么办”。如果外层必须回滚,用REQUIRED;如果外层不受影响,用REQUIRES_NEW或NESTED;如果外层有没有事务都行,用SUPPORTS。
3.4 事务管理器与数据源的绑定关系
DataSourceTransactionManager在开启事务时,会通过TransactionSynchronizationManager把Connection绑定到当前线程的ThreadLocal里。后续在同一个线程里调用JdbcTemplate,它会通过DataSourceUtils.getConnection()拿到这个已经绑定的Connection,从而保证所有操作都在同一个事务里。
这个机制有一个重要的推论:事务是线程绑定的。如果你在事务方法里开了新线程去执行数据库操作,新线程拿不到绑定的Connection,事务自然就不生效了。这是多线程场景下事务失效的根本原因。
另外,如果你在事务方法里手动调用了dataSource.getConnection(),拿到的也是一个新连接,不受事务管理。所以务必统一使用JdbcTemplate或者DataSourceUtils。
4. 完整实操流程与关键环节实现
4.1 项目依赖与基础配置
先给出一个最小可运行的配置。假设你用的是Maven,依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>application.yml里的数据源配置:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=UTC username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000这里用的是HikariCP连接池,Spring Boot默认就是它。连接池的配置直接影响事务的表现:如果maximum-pool-size太小,高并发下事务方法会因为拿不到连接而超时;如果connection-timeout太短,稍微慢一点的查询就会报错。一般建议maximum-pool-size设置为数据库最大连接数的70%左右,connection-timeout至少30秒。
4.2 一个完整的业务场景:转账
转账是经典的事务场景,涉及两个账户的余额变动。先建表:
CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, balance DECIMAL(18, 2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 );然后写DAO层,用JdbcTemplate:
@Repository public class AccountDao { private final JdbcTemplate jdbcTemplate; public AccountDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public BigDecimal getBalance(Long id) { return jdbcTemplate.queryForObject( "SELECT balance FROM account WHERE id = ?", BigDecimal.class, id); } public int deduct(Long id, BigDecimal amount) { return jdbcTemplate.update( "UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?", amount, id, amount); } public int add(Long id, BigDecimal amount) { return jdbcTemplate.update( "UPDATE account SET balance = balance + ? WHERE id = ?", amount, id); } }Service层用@Transactional:
@Service public class TransferService { private final AccountDao accountDao; public TransferService(AccountDao accountDao) { this.accountDao = accountDao; } @Transactional(rollbackFor = Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { int deducted = accountDao.deduct(fromId, amount); if (deducted == 0) { throw new IllegalStateException("余额不足"); } accountDao.add(toId, amount); } }这段代码有几个关键点:rollbackFor = Exception.class确保受检异常也回滚;deduct方法里带了balance >= ?条件,防止余额扣成负数;如果扣减失败直接抛异常,事务回滚。
4.3 参数计算与边界条件处理
转账场景里金额的计算必须用BigDecimal,不能用double。BigDecimal的subtract和add方法会返回新对象,不会修改原对象。比较金额时用compareTo而不是equals,因为equals会比较精度。
BigDecimal a = new BigDecimal("100.00"); BigDecimal b = new BigDecimal("100.0"); System.out.println(a.equals(b)); // false System.out.println(a.compareTo(b)); // 0数据库字段用DECIMAL(18, 2),对应Java的BigDecimal。JdbcTemplate在查询时会自动映射,但要注意MySQL的DECIMAL返回的BigDecimal精度和scale是准确的,不需要额外处理。
边界条件方面,需要处理:金额为负数或零、转出账户和转入账户相同、账户不存在、余额不足。这些判断最好在Service层做,抛出自定义异常,让事务回滚。
4.4 事务执行过程的日志观察
想确认事务是否真的生效,最直接的办法是打开Spring的事务日志。在application.yml里加:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor: TRACE启动后调用转账方法,你会看到类似这样的日志:
Creating new transaction with name [com.example.TransferService.transfer]: PROPAGATION_REQUIRED, ISOLATION_DEFAULT Acquired Connection [HikariProxyConnection@123456] for JDBC transaction Switching JDBC Connection to manual commit Initiating transaction commit Committing JDBC transaction on Connection [HikariProxyConnection@123456] Releasing JDBC Connection after transaction如果看到Creating new transaction说明事务开启了,看到Committing说明提交了。如果方法抛异常,会看到Initiating transaction rollback和Rolling back JDBC transaction。这些日志是排查事务问题的第一手资料。
注意:日志级别调到TRACE会输出大量信息,生产环境不要长期开启,排查完记得改回去。
5. 常见问题与排查技巧实录
5.1 事务失效的六大场景
声明式事务失效是最高频的问题,我整理了一个速查表:
| 失效场景 | 原因 | 解决办法 |
|---|---|---|
| 同类内部方法调用 | this调用不走代理 | 注入自身代理,或用AopContext.currentProxy() |
| 方法不是public | Spring AOP只代理public方法 | 改成public,或用AspectJ模式 |
| 异常被catch | 异常没抛到代理层 | 在catch里重新抛出,或手动回滚 |
| rollbackFor没配 | 默认只回滚RuntimeException | 显式指定rollbackFor = Exception.class |
| 多线程调用 | 事务绑定在ThreadLocal | 避免在事务方法里开新线程操作数据库 |
| 数据源不匹配 | 事务管理器和DAO用的不是同一个DataSource | 检查配置,确保统一 |
其中同类内部调用是最隐蔽的。比如:
@Service public class OrderService { public void createOrder() { this.doCreate(); // 这里不会走代理,事务不生效 } @Transactional public void doCreate() { // 数据库操作 } }解决办法有三种:一是把doCreate抽到另一个Service里;二是注入自身:
@Service public class OrderService { @Autowired private OrderService self; public void createOrder() { self.doCreate(); // 通过代理调用 } }三是用AopContext.currentProxy(),但需要开启exposeProxy = true。
5.2 事务不回滚的排查思路
遇到“抛了异常但数据还是写进去了”的情况,按这个顺序排查:
第一步,确认异常类型。如果是受检异常,检查rollbackFor有没有配。默认只回滚RuntimeException和Error。
第二步,确认异常有没有被吞掉。在catch块里打日志,看看异常是不是被捕获后没有重新抛出。
第三步,确认方法是不是public。非public方法不会被代理。
第四步,确认调用方式。是不是同类内部调用?是不是通过new创建的对象?
第五步,打开事务日志,看有没有Creating new transaction。如果没有,说明事务根本没开启。
第六步,检查数据源。如果项目里有多个数据源,确认事务管理器和DAO用的是同一个。
5.3 传播行为导致的“诡异”现象
有一次我遇到一个场景:外层方法标注了@Transactional,内层方法标注了REQUIRES_NEW,结果内层方法执行完后,外层方法抛异常,内层的数据却没有回滚。这其实是符合预期的——REQUIRES_NEW会挂起外层事务,新建一个独立事务,内层事务提交后就和外层无关了。外层回滚不会影响已经提交的内层事务。
反过来,如果内层用NESTED,内层回滚只会回滚到保存点,外层可以选择继续或者也回滚。但NESTED有个限制:它依赖JDBC的保存点,如果数据库不支持保存点(比如某些NoSQL),就会退化成REQUIRED。
还有一个常见的坑:REQUIRES_NEW需要一个新的数据库连接。如果连接池满了,内层事务会一直等连接,导致外层事务也卡住。在高并发场景下要特别注意连接池的配置。
5.4 只读事务的误区
@Transactional(readOnly = true)经常被当成性能优化手段,但实际上它的效果取决于数据库和驱动。在MySQL里,设置readOnly后,驱动会发送SET SESSION TRANSACTION READ ONLY,但InnoDB并不会因此做特殊优化。真正的优化是告诉数据库“这个事务里不会有写操作”,某些数据库可以据此减少锁的开销。
但要注意,只读事务里如果执行了写操作,MySQL会直接报错(Cannot execute statement in a READ ONLY transaction),而有些数据库可能只是忽略。所以不要以为加了readOnly = true就绝对安全,该做的校验还是要做。
另外,readOnly对JdbcTemplate的查询没有额外优化,它只是设置Connection的属性。如果你真的想优化查询性能,应该从索引、SQL语句、连接池配置入手。
5.5 事务超时与连接泄漏
@Transactional(timeout = 5)设置的是事务的超时时间,单位秒。超时后Spring会尝试回滚事务,但实际行为取决于数据库驱动。有些驱动会在超时后抛出异常,有些则不会立即中断正在执行的SQL。
更危险的是连接泄漏。如果事务方法里手动获取了Connection但没有关闭,或者某个操作阻塞了很长时间,连接池的连接会被耗尽。排查连接泄漏可以用连接池的监控功能,比如HikariCP的leakDetectionThreshold:
spring: datasource: hikari: leak-detection-threshold: 60000设置60秒,如果连接超过60秒没归还,就会打警告日志。这个阈值不要设太小,否则正常的长事务也会被误报。
6. 进阶技巧与个人经验补充
6.1 多数据源下的事务管理
多数据源场景下,每个数据源需要独立的事务管理器。如果你在一个方法里同时操作两个数据源,默认情况下只有一个事务管理器生效,另一个数据源的操作不在事务里。解决办法是用ChainedTransactionManager(Spring 5.3之前)或者JtaTransactionManager(需要XA支持)。但更实际的做法是尽量避免跨数据源的强事务,用最终一致性方案替代。
如果确实需要,可以这样配置:
@Bean public PlatformTransactionManager transactionManager( DataSource primaryDataSource, DataSource secondaryDataSource) { DataSourceTransactionManager primary = new DataSourceTransactionManager(primaryDataSource); DataSourceTransactionManager secondary = new DataSourceTransactionManager(secondaryDataSource); return new ChainedTransactionManager(primary, secondary); }但要注意,ChainedTransactionManager并不是真正的分布式事务,它只是按顺序提交多个事务管理器,如果第二个提交失败,第一个已经提交了,无法回滚。所以它只适合“两个数据源的操作大概率都成功”的场景。
6.2 事务与异步的配合
在事务方法里发异步消息或者调用异步方法,要特别小心。因为异步方法在新线程里执行,拿不到事务绑定的Connection,也不会参与事务。如果你希望异步操作在事务提交后再执行,可以用TransactionSynchronizationManager.registerSynchronization:
TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // 事务提交后执行 asyncService.doSomething(); } } );这样能保证异步操作只在事务成功提交后触发,避免“事务还没提交,异步操作已经读了旧数据”的问题。
6.3 事务边界的粒度控制
事务边界不是越大越好。一个大事务会持有连接很长时间,增加锁竞争,降低并发能力。我的一般原则是:事务里只放必要的数据库操作,远程调用、文件IO、复杂计算都放到事务外面。
比如一个下单流程:校验参数 → 查询库存 → 扣减库存 → 创建订单 → 发送通知。其中“查询库存”可以放在事务外,“扣减库存”和“创建订单”放在事务内,“发送通知”放在事务提交后。这样事务的持有时间最短,并发能力最强。
6.4 一个容易被忽略的细节:事务与缓存
如果你在事务方法里操作了缓存(比如Redis),要注意缓存和数据库的一致性。常见做法是先更新数据库,再删除缓存,但如果在事务提交前删了缓存,其他线程可能读到旧数据并重新写入缓存。更稳妥的做法是用afterCommit回调来删缓存:
TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { redisTemplate.delete(cacheKey); } } );这样能保证缓存删除发生在数据库提交之后,减少不一致的窗口。
6.5 测试环境下的事务回滚
写单元测试时,经常希望测试完数据库回到初始状态。Spring Test提供了@Transactional注解,加在测试类上,每个测试方法执行完后会自动回滚:
@SpringBootTest @Transactional public class TransferServiceTest { @Autowired private TransferService transferService; @Test public void testTransfer() { transferService.transfer(1L, 2L, new BigDecimal("100")); // 断言... } }但要注意,如果测试方法里用了REQUIRES_NEW传播行为,内层事务会独立提交,不会随外层回滚。这种情况下需要手动清理数据。
6.6 生产环境的事务监控
生产环境里,事务的执行情况需要监控。可以自定义一个TransactionSynchronization,在事务提交或回滚时打点:
public class TransactionMonitor implements TransactionSynchronization { private final long startTime = System.currentTimeMillis(); @Override public void afterCompletion(int status) { long cost = System.currentTimeMillis() - startTime; if (status == STATUS_COMMITTED) { metrics.recordCommit(cost); } else { metrics.recordRollback(cost); } } }然后在事务方法里注册这个监听器。这样能统计事务的提交率、回滚率、平均耗时,对排查性能问题很有帮助。
7. 写在最后的一些实操体会
声明式事务看起来只是加个注解的事,但真正用好需要理解它背后的代理机制、线程绑定、传播行为。我踩过的坑里,大部分都是因为“想当然”——以为加了注解就万事大吉,结果同类调用失效、异常被吞、传播行为配错,各种问题层出不穷。
我的建议是:新项目里,所有涉及多表写操作的方法都显式加上@Transactional(rollbackFor = Exception.class),不要依赖默认值。传播行为除非有明确需求,否则一律用默认的REQUIRED。事务方法尽量保持public,避免同类内部调用。测试环境打开事务日志,确认事务真的生效了再上线。
还有一点,事务不是银弹。有些场景用事务反而会带来性能问题,比如大批量导入、长耗时的业务操作。这时候可以考虑用补偿机制、状态机、最终一致性方案来替代强事务。技术选型没有绝对的对错,关键是匹配业务场景。
最后分享一个排查事务问题的小技巧:在事务方法的第一行打一条日志,在最后一行也打一条,然后打开DataSourceTransactionManager的DEBUG日志。如果两条业务日志之间没有事务日志,说明事务没生效;如果有Creating new transaction但没有Committing,说明事务回滚了。这个方法简单粗暴,但非常有效。