跳到正文

Spring 事务管理 ​

一句话:声明式事务是“用 AOP 代理包住方法,把连接的提交/回滚交给 PlatformTransactionManager”;换来业务代码不写事务模板,代价是只有经过代理的 public 方法、且异常符合回滚规则时才生效。

适用版本:Spring Framework 6.x / Spring Boot 3.x,数据库以 MySQL 8.x InnoDB 或 PostgreSQL 15+ 为例。

为什么需要 ​

直接在业务方法里写 conn.setAutoCommit(false)、commit()、rollback() 有三个问题:

  • 事务边界和业务逻辑混在一起,每个方法都要重复模板代码。
  • 多个 Service 组合时,无法声明“加入当前事务”还是“独立开一个事务”。
  • 异常路径容易漏掉回滚,连接也容易忘记归还。

Spring 把事务抽象成 PlatformTransactionManager,用 AOP 在方法前后开启、提交或回滚;业务代码只保留 @Transactional 和传播行为声明。

核心机制 ​

代理、拦截器与线程绑定 ​

  • @Transactional 本身不做事,真正生效的是 TransactionInterceptor 和 PlatformTransactionManager。
  • JDBC 场景用 DataSourceTransactionManager:从事务上下文取连接,把连接绑定到当前线程(TransactionSynchronizationManager),同一线程内的 Mapper/JdbcTemplate 复用同一连接。
  • JPA 场景用 JpaTransactionManager;多数据源要各自配事务管理器,并用 @Transactional(transactionManager = "xxxTxManager") 指定。
  • 事务绑定在线程上,@Async 子线程、线程池任务不会自动继承;跨线程要显式传递或在新线程里重新开事务。

传播行为:用场景选,不要背表 ​

传播行为行为真实场景
REQUIRED(默认)有事务就加入,没有就新建下单扣库存、写订单,要一起提交或回滚
REQUIRES_NEW挂起当前事务,JDBC 下用新连接开独立事务审计日志、幂等记录:主业务回滚也要留下
NESTED在当前连接内设 Savepoint,内层回滚到保存点批量导入中“单条失败不影响整批”,且用 JDBC 事务管理器
SUPPORTS有事务就加入,没有就非事务执行查询方法,可被事务调用也可独立调用
MANDATORY必须已有事务,否则抛异常内部方法强制要求调用方开事务
NOT_SUPPORTED挂起当前事务,非事务执行长耗时报表,不想占着事务和锁
NEVER已有事务就抛异常明确不允许事务的旁路操作

两个容易写错的点:

  • REQUIRES_NEW 不是“加入外层事务”:它挂起外层;JDBC 事务管理器下会拿第二条连接。外层持有行锁时,内层再更新同一行会互相等待;连接池也要同时容纳两条连接。用之前先确认锁顺序和池大小。
  • NESTED 不是通用能力:它依赖 JDBC Savepoint,DataSourceTransactionManager 这类 JDBC 事务管理器支持;JTA 不支持,JPA 下要看方言和驱动,不能当成所有事务管理器都有的语义。内层“提交”只是释放保存点,外层回滚会一起回滚。

回滚规则 ​

  • 默认只回滚 RuntimeException 和 Error;受检异常(Exception 的非运行时子类)默认不回滚。
  • 业务写方法建议显式 @Transactional(rollbackFor = Exception.class)。
  • 方法内 catch 后只打日志不抛出,Spring 感知不到异常,事务会提交;要么重新抛出,要么调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
  • noRollbackFor 用于“异常是预期分支,仍然要提交”的场景,例如重复提交被忽略。

隔离级别 ​

级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED不会可能可能
REPEATABLE_READ不会不会视数据库实现
SERIALIZABLE不会不会不会

表是 SQL 标准语义。实际行为由数据库决定:MySQL InnoDB 默认 REPEATABLE_READ,用 MVCC + next-key lock 处理幻读;PostgreSQL 默认 READ_COMMITTED,REPEATABLE_READ 是快照隔离。不要只按表背结论,先确认数据库和隔离级别。

实战场景 ​

一、REQUIRES_NEW 把连接池吃穿 ​

现象:支付回调服务在晚高峰(约 800 TPS,经验值,需校准)大面积报 HikariPool-1 - Connection is not available, request timed out after 30000ms,同时数据库出现锁等待;白天低峰完全正常,重启后好转十几分钟再次复现。

排查:连接池耗尽有三条独立证据,缺一条就不能定性。

定位层取证据判定
等待发生在哪线程栈里 HikariPool.getConnection 阻塞 vs SQL 执行阻塞前者是池不够,后者是数据库慢
一个线程占几条连接SHOW PROCESSLIST 按 Host 分组数连接;对照业务线程数线程数 × 2 ≈ 活跃连接数 → 有嵌套事务
谁持有锁information_schema.innodb_trx 看 trx_started 与 trx_query;performance_schema.data_lock_waits 看等待链存在同一行被两个连接争用
事务边界代码里搜 REQUIRES_NEW,确认其更新是否与外层同表同行命中即形成自我锁等待

这个案例的链条是:外层事务已更新订单行 → 内层 REQUIRES_NEW 用第二条连接更新同一张订单的扩展字段 → 内层等外层释放行锁,外层等内层方法返回。800 TPS 下每个请求占两条连接,maximum-pool-size=10 的池子在第 6 个并发就锁死,剩下的请求全在 getConnection 上排队。池子小不是根因,锁顺序才是。

对症做法:

java
// 1. 内层不再碰外层已锁的行:把扩展字段写入拆到外层事务里一次完成
@Transactional(rollbackFor = Exception.class)
public void handle(Callback c) { orderRepo.updateStatus(c); orderRepo.updateExtra(c); }

// 2. 内层确实需要独立提交(审计、幂等记录),改成不写同一行 + 不共享锁资源
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 3, rollbackFor = Exception.class)
public void logAudit(Audit a) { ... }   // timeout 必须小于外层,避免无限等

// 3. 池容量按最坏嵌套深度复核(HikariCP 默认 10)
spring.datasource.hikari.maximum-pool-size: 20
spring.datasource.hikari.connection-timeout: 3000   # 不要用 30s,快速失败比排队更容易定位

改池大小的同时要给 REQUIRES_NEW 加 timeout。没有超时时间的嵌套事务,一旦锁顺序出错就是把整个池拖死,而不是慢一点。

复盘要点:REQUIRES_NEW 的代价是“多一条连接 + 一条独立锁链”,评审时应作为显式风险项。把 innodb_trx 里事务存活时长超过 spring.transaction.default-timeout 的样本做成告警,比事后看连接池监控早很多。

二、事务里套 RPC,下游抖动变成上游雪崩 ​

现象:营销服务调用优惠券服务平均 40ms、P99 80ms,本地一切正常。大促当天优惠券服务发布导致 P99 涨到 3s,营销服务在 2 分钟内连接池耗尽、Tomcat 线程打满,整条下单链路不可用;优惠券服务恢复后营销服务仍需要重启才好。

排查:判断“事务是否覆盖了远程调用”,trace 比日志可靠。

定位层取证据判定
事务存活时长trace 里 @Transactional span 的时长 vs 其下 RPC span 的时长两者接近 → 事务被远程调用拖住
连接占用hikaricp_connections_active 与 RPC P99 曲线是否同步抬升同步 → 事务边界过宽
恢复慢的原因是否积压请求在解阻塞后集中进入事务雪崩后二次拥塞
边界代码事务方法内是否存在 Feign/Dubbo、rocketMQTemplate.send、OSS 上传命中即为根因

“恢复后仍要重启”不是数据库问题:连接池里的等待线程在被唤醒后立刻又开始新的大事务,池子仍在饱和状态。

对症做法:事务只包住本地写,远程动作挪到事务外。

java
// 做法 1:先本地落状态,事务提交后再调远程(失败靠补偿任务扫表)
@Transactional(rollbackFor = Exception.class)
public void create(Order o) { orderRepo.save(o); outboxRepo.save(o); }   // 本地消息表在同一事务

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)   // 提交成功后才发
public void onCreated(OrderCreatedEvent e) { couponClient.apply(e.id()); }

// 做法 2:给 RPC 单独设更短的超时,且不让它落在事务边界内
@EnableFeignClients(defaultConfiguration = ShortTimeoutConfig.class)
feign.client.config.coupon.connectTimeout: 500
feign.client.config.coupon.readTimeout: 1000

给全局事务加天花板:spring.transaction.default-timeout: 30(单位秒)。它不是解决方案,是把“无限长的事务”变成“可在 30 秒内被观测到的失败”。

复盘要点:这次故障的可预防点是 review 规则——事务方法内不允许出现 RPC、消息发送、文件上传;用 ArchUnit 写一条架构测试,检查带 @Transactional 的方法体内不引用 *Client、*Template 类型,比培训有效。跨服务一致性交给本地消息表或 分布式事务 的方案,不是靠把 RPC 圈进事务。

三、异常被吞、事务照提:静默的数据不一致 ​

现象:对账作业跑完后,order.status 已置为 SETTLED,但同方法内的流水表没有写入记录;作业日志里只有一条 WARN 上传对账文件失败,没有堆栈抛出。影响 1 个批次共 240 条流水需人工补。

排查:这类“抛了异常但没回滚”只有四种成因,按顺序排。

检查项取证据结论
是否经过代理打日志 aopUtils.isAopProxy(bean);同类自调用直接排除自调用 → 注解完全失效
异常类型栈里是 IOException / SQLException 这类受检异常还是 RuntimeException受检异常默认不回滚
是否被 catch 吞掉搜 catch (Exception 后面只跟 log.warn 的代码块吞掉 → 拦截器看不到异常,照常提交
事务是否真存在方法内打印 TransactionSynchronizationManager.isActualTransactionActive()false → 边界根本没开
拦截器是否进入logging.level.org.springframework.transaction.interceptor: TRACE无日志 → 代理未生成

本例命中第二、三条:upload() 抛 IOException,被方法内 catch 后只记日志;同时该方法默认回滚规则不含受检异常,两处叠加让事务顺利提交。

对症做法:

java
// 1. 写方法显式声明回滚范围(本专栏的默认写法)
@Transactional(rollbackFor = Exception.class, timeout = 60)

// 2. 需要“记录异常但主流程继续”时,明确边界,不要让副作用留在同一事务里
try { upload(file); }
catch (IOException e) {
    log.error("upload failed, batch={}", batchId, e);
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();   // 仍要回滚时显式标记
}

// 3. 受检异常统一转成领域异常,避免回滚规则依赖异常继承关系
catch (IOException e) { throw new SettleFailedException(batchId, e); }

setRollbackOnly() 适合“就地决定不回滚就没有意义”的批处理;普通业务更推荐抛出运行时异常,让边界在调用方统一处理。

复盘要点:这条链路的真正缺陷是“失败被写成一条 WARN”。判据应可验证:给关键作业加断言——状态置为 SETTLED 的批次,流水表必须存在对应行数;把该断言做成日级校验任务,比依赖开发者记得写 rollbackFor 可靠。默认回滚规则只覆盖 RuntimeException 和 Error,这是 Java 语言层面的历史选择,不会替你思考业务语义。

最小上手 ​

Spring Boot 3.x + H2,验证 REQUIRES_NEW 的审计日志不会被外层回滚:

yaml
# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:tx;DB_CLOSE_DELAY=-1
    username: sa
    password:
  sql:
    init:
      mode: always
sql
-- schema.sql
CREATE TABLE orders (id BIGINT PRIMARY KEY, status VARCHAR(20));
CREATE TABLE audit_log (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, msg VARCHAR(100));
java
@Service
public class OrderService {
    private final JdbcTemplate jdbc;
    private final AuditService audit;
    public OrderService(JdbcTemplate jdbc, AuditService audit) { this.jdbc = jdbc; this.audit = audit; }
    @Transactional(rollbackFor = Exception.class)
    public void create(long id) {
        jdbc.update("insert into orders(id,status) values (?,?)", id, "NEW");
        audit.log("created " + id);                 // 独立事务
        throw new IllegalStateException("boom");    // 外层回滚
    }
}

@Service
class AuditService {
    private final JdbcTemplate jdbc;
    AuditService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void log(String msg) { jdbc.update("insert into audit_log(msg) values (?)", msg); }
}
java
@SpringBootTest
class TxTest {
    @Autowired OrderService service;
    @Autowired JdbcTemplate jdbc;
    @Test void requiresNewSurvivesOuterRollback() {
        assertThrows(IllegalStateException.class, () -> service.create(1L));
        assertEquals(0, jdbc.queryForObject("select count(*) from orders", Integer.class));
        assertEquals(1, jdbc.queryForObject("select count(*) from audit_log", Integer.class));
    }
}

运行 mvn test;两个断言通过,说明外层订单回滚、内层审计提交。

生产动作 ​

  • spring.transaction.default-timeout(无默认值):给全局事务设上限,例如 30s;也可在方法上写 @Transactional(timeout = 30)(单位秒,TransactionDefinition 语义)。
  • spring.transaction.rollback-on-commit-failure=false(默认 false):提交阶段失败是否回滚;涉及强一致账务时评估是否改为 true,并确认业务能处理提交失败。
  • spring.datasource.hikari.maximum-pool-size=10(HikariCP 默认 10):REQUIRES_NEW 会让一个业务线程同时占两条连接,池子小、并发高时容易在事务内等连接。
  • 排障顺序:现象“抛异常但数据没回滚” → 查方法是否 public、是否同类自调用、异常是否为受检异常或被 catch 吞掉 → 打开 logging.level.org.springframework.transaction.interceptor=TRACE 看拦截器是否进入 → 用 TransactionSynchronizationManager.isActualTransactionActive() 在方法内确认事务是否真的存在。
  • 排障顺序:现象“事务内查不到刚提交的数据” → 确认是否用了 REQUIRES_NEW(新连接、新事务)或更高隔离级别 → 再看连接是否指向不同数据源。
  • 大事务信号:活跃事务数上升、单事务持有时长变长、锁等待和连接池等待同时升高 → 先查事务方法里是否有 Feign/RPC、消息发送、文件上传或用户交互。

边界 ​

  • 自调用、非 public、final 方法:基于代理的声明式事务默认只拦截 public 方法;同类 this.method() 不经过代理。需要自调用时拆到另一个 Bean,或显式使用 AopContext.currentProxy()。
  • 事务不跨越远程调用:跨库、跨服务的一致性要靠 Seata、Saga、本地消息表等方案,@Transactional 只管当前数据源。
  • readOnly=true 是提示不是强制:是否路由到只读库、是否拒绝写,取决于驱动和数据源配置。
  • 长事务不是“更安全”:它放大锁持有时间、Undo Log、主从延迟和连接占用;能拆小就拆小。
  • NESTED 的适用面窄:JDBC Savepoint 之外不要假设它可用;需要独立提交的场景用 REQUIRES_NEW,需要整体回滚的场景用 REQUIRED。

讲清楚 ​

Spring 事务的本质是 AOP 包了一层 TransactionInterceptor:方法进入前向事务管理器要一个事务,正常返回提交,抛异常按回滚规则决定提交还是回滚。连接通过 TransactionSynchronizationManager 绑定到线程,所以同一线程里的多个 Mapper 调用能复用同一个事务,跨线程就不行。

传播行为是在回答“当前已经有一个事务时怎么办”。REQUIRED 加入,REQUIRES_NEW 挂起并另开连接,NESTED 在原连接里设 Savepoint。最容易踩的是把 REQUIRES_NEW 当成“加入外层”,结果多占一条连接、还可能和外层锁互相等待;以及把 NESTED 当成所有事务管理器都支持。

生产上先确认三件事:方法是否经过代理、异常是否触发回滚、事务里有没有远程调用。这三件事排掉,大多数“事务不生效”和“连接池耗尽”都能定位。

把一门技术讲透到既懂也会用