☰
Spring JDBC声明式事务管理:从原理到实战的深度解析
2026/10/12 16:34:38 网站建设 项目流程

1. 为什么声明式事务值得单独拎出来讲

做Java后端的人,几乎都绕不开数据库事务这个话题。而Spring JDBC的事务管理,尤其是声明式事务,可以说是日常开发里出现频率最高、也最容易被忽视细节的一块内容。很多人用了好几年@Transactional注解,但真被问到“为什么我的事务没回滚”“为什么嵌套调用之后事务失效了”,往往就答不上来了。

这篇文章要聊的就是Spring JDBC场景下的声明式事务管理。所谓声明式,说白了就是你不需要手写connection.setAutoCommit(false)、commit()、rollback()那一套,而是通过配置或者注解的方式,告诉Spring“这段逻辑我要放在一个事务里”,剩下的开启事务、提交、回滚、释放连接,全部由框架帮你打理。它解决的核心问题是:把事务控制逻辑从业务代码里剥离出来,让开发者专注于业务本身,同时保证事务边界的一致性和可靠性。

这篇文章适合谁看?如果你已经写过Spring JDBC或者MyBatis的代码,但对事务的传播行为、隔离级别、回滚规则只是一知半解,那这篇内容就是给你准备的。如果你刚开始接触Spring,想搞清楚@Transactional到底在背后做了什么,同样可以顺着往下看。我会从设计思路讲到实操细节,再到踩坑排查,尽量把每个“为什么”都讲透。

2. 声明式事务的整体设计与思路拆解

2.1 编程式事务和声明式事务的本质区别

要理解声明式事务,得先知道编程式事务长什么样。早期用原生JDBC的时候,代码大概是这样:

Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行多条SQL conn.commit(); } catch (Exception e) { conn.rollback(); } finally { conn.close(); }

这种写法的问题很明显:事务控制代码和业务代码搅在一起,每个需要事务的方法都要重复一遍这套模板,而且一旦漏了rollback或者close,就是生产事故。后来有了TransactionTemplate,稍微好一点,把模板代码封装起来了,但业务方法里还是得显式调用execute方法,侵入性依然存在。

声明式事务的思路完全不同。它的核心是AOP代理:Spring在容器启动时,会为那些被标记了事务属性的Bean生成一个代理对象。当你调用目标方法时,实际上先经过代理,代理在方法执行前开启事务,执行后根据结果决定提交还是回滚。业务代码里完全看不到事务的痕迹,这就是“声明式”三个字的含义。

2.2 为什么Spring JDBC场景下更推荐声明式

有人可能会问,MyBatis、Hibernate这些ORM框架也有事务管理,为什么单独说Spring JDBC?其实Spring的事务抽象是统一的,不管底层是JDBC、MyBatis还是JPA,用的都是同一套PlatformTransactionManager接口。但在Spring JDBC场景下,声明式事务的优势特别明显,原因有几个。

第一,Spring JDBC本身就是轻量封装,没有Session、一级缓存这些概念,事务边界非常清晰,就是Connection的提交和回滚。第二,JdbcTemplate的每个操作都是独立的,如果不加事务,每条SQL自动提交,加了声明式事务之后,多条SQL能自然地绑在一起。第三,Spring JDBC配合DataSourceTransactionManager是官方最经典的组合,配置简单,行为可预测,不像JPA那样有一堆懒加载、脏检查的坑。

2.3 声明式事务的两种落地方式

声明式事务在Spring里主要有两种实现路径。一种是基于XML配置的,早期Spring项目里很常见,通过<tx:advice>和<aop:config>把事务规则织入到指定的方法上。另一种是基于注解的,也就是现在最流行的@Transactional。注解方式更简洁,但XML方式在需要批量配置、统一规则的时候反而更清晰。

两种方式底层用的是同一套基础设施:TransactionInterceptor负责拦截方法调用,PlatformTransactionManager负责真正的事务操作。区别只在于事务属性从哪里读——XML是从配置里读,注解是从方法或类上的元数据里读。实际项目里,我一般推荐注解为主、XML为辅,比如全局默认规则用XML兜底,特殊方法用注解覆盖。

3. 核心细节解析与实操要点

3.1 @Transactional的关键属性逐个拆解

@Transactional注解看起来简单,但它有一堆属性,每一个都直接影响事务行为。下面这张表是我整理的核心属性速查:

属性名作用常用取值默认值
propagation传播行为REQUIRED、REQUIRES_NEW、NESTED等REQUIRED
isolation隔离级别DEFAULT、READ_COMMITTED、REPEATABLE_READ等DEFAULT
timeout超时时间(秒)正整数-1(不超时)
readOnly是否只读true/falsefalse
rollbackFor触发回滚的异常类型Exception.class等运行时异常
noRollbackFor不触发回滚的异常自定义异常无

这里重点说几个容易搞错的。propagation默认是REQUIRED,意思是如果当前没有事务就新建一个,有就加入。这个默认值在大多数场景下没问题,但在“记录操作日志”这种场景下就会出问题——如果主业务回滚了,日志也会跟着回滚,这通常不是我们想要的,这时候就得用REQUIRES_NEW开一个独立事务。

rollbackFor是另一个高频坑点。Spring默认只在遇到RuntimeException和Error时回滚,受检异常(比如IOException)是不回滚的。很多新手写了个throws Exception的方法,结果异常抛出去了,数据却没回滚,排查半天才发现是这个原因。我的建议是,除非有特殊理由,否则统一写成@Transactional(rollbackFor = Exception.class),省心。

3.2 事务传播行为:七种类型到底怎么选

传播行为是声明式事务里最抽象、也最容易出错的部分。Spring定义了七种,但实际项目里常用的就那么三四种。我用一个生活化的类比来解释:把事务想象成“进会议室开会”。

  • REQUIRED:如果已经有人在开会,我就直接进去一起开;没人开,我就自己开一个。这是默认值,适合绝大多数业务方法。
  • REQUIRES_NEW:不管有没有人在开会,我都要单独开一个新会,原来的会暂停。适合日志记录、独立审计这种不能受主业务影响的场景。
  • NESTED:如果已经有人在开会,我就在旁边开个小分组讨论,出问题只回滚分组,不影响大会;没人开会就自己开一个。适合部分失败不影响整体的场景。
  • SUPPORTS:有人开会我就参加,没人开会我就站着聊。适合查询方法。
  • NOT_SUPPORTED:不管有没有会,我都不参与,原来的会暂停。适合一些不需要事务的操作。
  • MANDATORY:必须有人已经在开会,否则报错。适合强制要求上层开启事务的场景。
  • NEVER:绝对不能有会,否则报错。适合明确不允许事务的场景。

实际开发中,REQUIRED能覆盖90%的场景,REQUIRES_NEW用于日志和独立操作,NESTED用得相对少,因为它依赖数据库的保存点(savepoint)支持,而且和REQUIRES_NEW的行为差异需要仔细区分。

3.3 事务失效的六大典型场景

声明式事务最让人头疼的地方就是“看起来配了,实际没生效”。下面这几种情况,我几乎在每个项目里都遇到过至少一两种。

第一种,方法不是public的。Spring的AOP代理默认只拦截public方法,如果你把@Transactional加在private或protected方法上,注解会被直接忽略,而且不会有任何报错。这个坑特别隐蔽,因为代码能跑,就是事务不生效。

第二种,自调用。同一个类里,方法A调用方法B,即使B上加了@Transactional,事务也不会生效。原因是自调用走的是this引用,不经过代理对象。解决办法是把B方法挪到另一个Bean里,或者通过AopContext.currentProxy()拿到代理再调用。

第三种,异常被吞了。方法里try-catch把异常吃掉了,没有重新抛出,Spring代理感知不到异常,自然就提交了。这种情况要么在catch里重新抛出,要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

第四种,异常类型不对。前面说过的,默认只回滚运行时异常。抛了受检异常但没配rollbackFor,事务照样提交。

第五种,数据库引擎不支持。比如MySQL的MyISAM引擎根本不支持事务,配了也白配。这个现在比较少见,但老项目迁移时偶尔会碰到。

第六种,多数据源没指定事务管理器。项目里配了多个DataSource,但@Transactional没指定transactionManager,Spring不知道该用哪个,事务自然管不起来。

提示:排查事务失效时,第一步先确认方法是不是public,第二步确认是不是自调用,这两条能解决大部分问题。

4. 实操过程与核心环节实现

4.1 环境搭建与基础配置

先说一下基础环境。假设你用的是Spring Boot,依赖里加上spring-boot-starter-jdbc和数据库驱动就够了。Spring Boot会自动配置DataSourceTransactionManager,你几乎不用写什么额外配置。如果是传统的Spring项目,那就得手动配。

传统Spring的XML配置大概长这样:

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

DataSourceTransactionManager是Spring JDBC场景下的核心事务管理器,它做的事情其实不复杂:从DataSource拿一个Connection,关闭自动提交,把Connection绑定到当前线程(通过TransactionSynchronizationManager),后续同一个线程里的JdbcTemplate操作都会复用这个Connection。方法结束时,根据事务状态决定commit还是rollback,最后解绑并释放连接。

这里有个细节值得注意:Connection的绑定是基于ThreadLocal的。这意味着事务的传播范围是“当前线程”,如果你在事务方法里开了新线程去执行SQL,那些SQL是不在事务里的。这个特性在异步场景下要特别小心。

4.2 一个完整的转账案例

光讲理论没意思,用一个转账的例子把整个流程串起来。假设有账户表account,字段是id、name、balance。转账逻辑是A账户扣钱、B账户加钱,两步必须在一个事务里。

先看DAO层,用JdbcTemplate实现:

@Repository public class AccountDao { @Autowired private JdbcTemplate jdbcTemplate; public void deduct(Long id, BigDecimal amount) { jdbcTemplate.update("UPDATE account SET balance = balance - ? WHERE id = ?", amount, id); } public void add(Long id, BigDecimal amount) { jdbcTemplate.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, id); } public BigDecimal getBalance(Long id) { return jdbcTemplate.queryForObject("SELECT balance FROM account WHERE id = ?", BigDecimal.class, id); } }

Service层加上事务注解:

@Service public class TransferService { @Autowired private AccountDao accountDao; @Transactional(rollbackFor = Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { BigDecimal fromBalance = accountDao.getBalance(fromId); if (fromBalance.compareTo(amount) < 0) { throw new RuntimeException("余额不足"); } accountDao.deduct(fromId, amount); accountDao.add(toId, amount); } }

这段代码看起来没问题,但有几个点值得展开说。第一,getBalance的查询也在事务里,因为@Transactional加在transfer方法上,整个方法执行期间Connection都是绑定的。第二,rollbackFor = Exception.class保证了即使抛出受检异常也会回滚。第三,如果add方法执行时数据库挂了抛异常,deduct的操作会自动回滚,因为它们在同一个Connection上。

4.3 传播行为的实际验证

光看文档记不住传播行为,最好的办法是自己写个测试验证一遍。我当初就是这么搞明白的。

准备两个Service,ServiceA和ServiceB,各自的方法都加上@Transactional,然后互相调用,观察不同传播行为下的表现。比如验证REQUIRES_NEW:

@Service public class LogService { @Autowired private JdbcTemplate jdbcTemplate; @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void saveLog(String message) { jdbcTemplate.update("INSERT INTO operation_log(message) VALUES (?)", message); } }

然后在主业务里调用logService.saveLog("xxx"),之后主业务抛异常回滚。你会发现日志记录依然在数据库里,因为REQUIRES_NEW开了一个独立事务,主业务回滚不影响它。如果把传播行为改成REQUIRED,日志就会跟着一起回滚。

验证的时候有个小技巧:在saveLog方法里打个断点,观察TransactionSynchronizationManager.getCurrentTransactionName()的值,能看到当前事务的名字,从而判断是新事务还是复用的事务。

4.4 事务超时与只读优化的实际效果

timeout属性设置的是事务的最长执行时间,单位是秒。超过这个时间,Spring会抛出TransactionTimedOutException并回滚。这个属性在防止慢SQL拖垮系统时很有用,但要注意它依赖底层数据库的支持,而且计时是从事务开启那一刻算起,不是从SQL执行算起。

readOnly = true这个属性经常被忽略,但它其实有实际价值。对于纯查询方法,设置只读之后,Spring会做一些优化,比如Hibernate场景下会跳过脏检查。在Spring JDBC场景下,它主要是给底层Connection一个提示,某些数据库驱动会据此做一些优化。更重要的是,它是一种“声明意图”,让代码可读性更好,也防止有人在查询方法里不小心写了更新操作。

@Transactional(readOnly = true) public BigDecimal queryBalance(Long id) { return accountDao.getBalance(id); }

注意:readOnly只是一个提示,不是强制约束。如果你在只读事务里执行了UPDATE,大多数数据库还是会执行的,只是不会报错。所以别把它当成安全机制。

5. 常见问题与排查技巧实录

5.1 事务不生效的排查清单

事务不生效是最高频的问题,我整理了一个排查顺序,按这个顺序走基本能定位到原因。

排查项检查内容典型表现
方法可见性是否为public注解被忽略,无报错
调用方式是否自调用代理未生效
异常处理是否被catch吞掉异常未传播到代理
异常类型是否匹配rollbackFor受检异常不回滚
事务管理器是否配置正确多数据源时用错管理器
数据库引擎是否支持事务MyISAM等引擎无效
注解位置是否加在接口上某些代理模式下失效

其中“注解加在接口上”这条值得单独说。Spring的AOP代理有两种:JDK动态代理和CGLIB代理。JDK动态代理是基于接口的,如果你把@Transactional加在接口方法上,实现类的方法上没加,在某些配置下事务属性可能读不到。虽然Spring官方文档说支持接口上的注解,但实际项目里我建议统一加在实现类的方法上,避免代理模式切换带来的不确定性。

5.2 嵌套事务的坑与NESTED的实际表现

NESTED传播行为经常被误解。它和REQUIRES_NEW的区别在于:REQUIRES_NEW是真正的新事务,有独立的Connection;NESTED是在当前事务里打一个保存点(savepoint),回滚时只回滚到保存点,外层事务可以继续。

但这里有个大坑:NESTED依赖JDBC的savepoint支持,而savepoint在不同数据库上的行为不一致。比如某些数据库在savepoint回滚后,外层事务的状态可能变得不可预测。而且如果外层事务最终回滚,内层的所有操作也会一起回滚,这点和REQUIRES_NEW完全不同。

我个人的经验是,除非你非常清楚自己在做什么,否则优先用REQUIRES_NEW,它的行为更直观、更可预测。NESTED适合那种“部分失败不影响整体,但整体失败要全部回滚”的场景,比如批量导入时逐条处理,单条失败不影响其他条,但整体失败要全部撤销。

5.3 多数据源下的事务管理器选择

项目里配了多个数据源时,@Transactional默认会找名为transactionManager的Bean。如果你有多个事务管理器,就得显式指定:

@Transactional(transactionManager = "orderTransactionManager", rollbackFor = Exception.class) public void createOrder() { // ... }

这里有个容易忽略的点:跨数据源的事务不是原子性的。也就是说,如果你在一个方法里同时操作两个数据源,即使都配了事务管理器,它们也是两个独立的事务,一个回滚不会影响另一个。要解决这个问题得用分布式事务方案,但那已经超出声明式事务的范畴了。实际项目里,我建议尽量把跨库操作拆开,通过消息队列或者补偿机制来保证最终一致性,而不是硬上分布式事务。

5.4 事务与连接池的配合问题

声明式事务和连接池的配合也有讲究。事务开启时会从连接池借一个Connection,事务结束才归还。如果事务方法执行时间很长,Connection就被长时间占用,连接池很容易被打满。

我遇到过一个典型案例:某个事务方法里调用了外部HTTP接口,接口响应慢的时候要好几秒,结果高峰期连接池直接耗尽,整个服务雪崩。解决办法是把HTTP调用挪到事务外面,先查数据、关事务,再调接口。事务里只放数据库操作,这是铁律。

另外,连接池的maxPoolSize要和数据库的最大连接数匹配。如果应用配了50个连接,数据库只允许30个,那多出来的20个就会等待超时。这个参数在HikariCP里是maximumPoolSize,在Druid里是maxActive,配置时要和DBA确认数据库侧的限制。

提示:事务方法里绝对不要做RPC调用、文件IO、消息发送这些耗时操作,它们会让Connection的持有时间成倍增加。

6. 一些实战中攒下来的经验

声明式事务用久了,会形成一些直觉。比如看到@Transactional就想确认方法是不是public,看到嵌套调用就想确认有没有自调用问题,看到catch块就想确认异常有没有被吞。这些直觉都是踩坑踩出来的。

还有一个经验是关于事务粒度的。事务不是越大越好,也不是越小越好。太大的事务会长时间持有锁和连接,影响并发;太小的事务又保证不了一致性。我的判断标准是:一个事务应该对应一个完整的业务操作。转账是一个事务,下单是一个事务,但“下单+发短信+写日志”就不应该是一个事务,短信和日志应该用REQUIRES_NEW或者干脆异步处理。

最后说一个关于测试的建议。事务相关的代码,单元测试很难覆盖到位,最好写集成测试,用真实的数据库(比如H2或者Testcontainers)跑一遍。测试的时候故意在事务中间抛异常,然后查数据库确认数据有没有回滚。这种测试写一次,能省掉后面无数次线上排查的时间。

这个内容后续还可以往分布式事务方向扩展,比如Seata、TCC、本地消息表这些方案,但那是另一个话题了。声明式事务作为单机事务的基础,把它的每个细节吃透,是往后走更远的前提。

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

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

立即咨询