@Transactional 嵌套调用中 REQUIRES_NEW 与连接池耗尽的关系判定

面试里常听到这样一句结论:「REQUIRES_NEW 会挂起外层事务、占用两条数据库连接,嵌套一两层就把连接池打满了。」这句话一半对一半错。对的部分是 REQUIRES_NEW 确实会新开连接;错的部分是——真正导致连接池耗尽的往往不是"多占一条连接",而是外层事务卡在等锁或等结果,内层又拿不到连接,形成互相等待的僵局。本文用一个可复现的工程,把"占用两条连

面试里常听到这样一句结论:「REQUIRES_NEW 会挂起外层事务、占用两条数据库连接,嵌套一两层就把连接池打满了。」这句话一半对一半错。对的部分是 REQUIRES_NEW 确实会新开连接;错的部分是——真正导致连接池耗尽的往往不是”多占一条连接”,而是外层事务卡在等锁或等结果,内层又拿不到连接,形成互相等待的僵局。本文用一个可复现的工程,把”占用两条连接”和”连接池耗尽”这两件事拆开验证,给出可落地的判定方法与修复策略。

一、先把机制说清楚:REQUIRES_NEW 到底做了什么

Spring 的事务传播机制由 AbstractPlatformTransactionManager#getTransaction 实现,其中 PROPAGATION_REQUIRES_NEW 的关键分支是这样的(以 DataSourceTransactionManager 为参照):

  • 检测到当前线程已存在事务(TransactionSynchronizationManager.isExistingTransaction() 返回 true);
  • 调用 doSuspend() 把当前 ConnectionHolder 从 ThreadLocal(TransactionSynchronizationManager.resources)中解绑,返回一个 SuspendedResourcesHolder;
  • 调用 doBegin() 向 DataSource 申请一条新的物理连接,并设置 autoCommit=false;
  • 内层事务提交或回滚后,在 doResume() 中把之前挂起的 ConnectionHolder 重新绑定回 ThreadLocal。

所以准确的表述是:在嵌套调用期间,同一线程同时持有 N 条连接(N = 嵌套的独立事务层数),而不是”多申请一次就多一条”这种模糊说法。如果外层事务已经拿了 1 条,内层 REQUIRES_NEW 再拿 1 条,那么这层嵌套结构在时间窗口内至少占用 2 条连接。若内层方法里还有一层 REQUIRES_NEW,就是 3 条,以此类推。

关键的时间语义是:这 2 条连接是同时被持有的。挂起只是解绑 ThreadLocal,物理连接并没有归还池子,它要等内层事务结束后才可能归还(具体归还时机取决于连接是内层事务新建的还是复用的,见第三节)。

二、构造一个可复现的最小工程

下面这套代码基于 Spring Boot 3.2.x + HikariCP + MySQL 8,可以完整跑通并复现连接池耗尽。先看依赖与配置。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

故意把连接池调小,让问题在压测下必然出现。这里用 4 条连接、超时 3000ms,是为了快速失败、便于观察:

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/tx_lab?useSSL=false&serverTimezone=Asia/Shanghai
    username: app
    password: app_pwd
    hikari:
      maximum-pool-size: 4
      minimum-idle: 4
      connection-timeout: 3000
      pool-name: TxLabPool
      leak-detection-threshold: 20000
  jpa:
    open-in-view: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
logging:
  level:
    com.zaxxer.hikari: DEBUG
    org.springframework.transaction: DEBUG

业务代码模拟一个”下单 + 写审计日志”的场景。审计日志用 REQUIRES_NEW,保证主事务回滚时日志仍然落库——这是 REQUIRES_NEW 最典型的正当用法:

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditService auditService;
    private final JdbcTemplate jdbcTemplate;

    public OrderService(OrderRepository orderRepository,
                        AuditService auditService,
                        JdbcTemplate jdbcTemplate) {
        this.orderRepository = orderRepository;
        this.auditService = auditService;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(rollbackFor = Exception.class)
    public void placeOrder(String orderNo, long amount) {
        orderRepository.insert(new OrderEntity(orderNo, amount));
        // 模拟业务耗时,放大连接同时被持有的时间窗口
        sleep(200);
        auditService.writeAudit(orderNo, "ORDER_CREATED");
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

@Service
public class AuditService {

    private final JdbcTemplate jdbcTemplate;

    public AuditService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void writeAudit(String orderNo, String action) {
        jdbcTemplate.update(
            "INSERT INTO audit_log(order_no, action, created_at) VALUES (?, ?, NOW())",
            orderNo, action);
        sleep(100);
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

建表语句:

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL UNIQUE,
    amount BIGINT NOT NULL
) ENGINE=InnoDB;

CREATE TABLE audit_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    action VARCHAR(64) NOT NULL,
    created_at DATETIME NOT NULL
) ENGINE=InnoDB;

用 8 个并发线程、每个线程调用一次 placeOrder,把 maximum-pool-size 设为 4,控制台会稳定出现下面的报错(这是 HikariCP 的原样输出):

java.sql.SQLTransientConnectionException: TxLabPool - Connection is not available,
request timed out after 3000ms (total=4, active=4, idle=0, waiting=4)
    at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:162)
    at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
    at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(...)
    ...

注意报错里的关键三元组:total=4, active=4, idle=0, waiting=4。active=4 意味着 4 条连接全部被借出,idle=0 意味着池里一条空闲都没有,waiting=4 意味着有 4 个线程在排队等连接。这正是”连接池耗尽”的判定特征,但要下结论,还需要看这些连接被谁持有。

三、判定:耗尽由”数量”造成还是由”等待”造成

3.1 用 Thread Dump 判定持有者

在执行 jstack <pid> 之后,搜 DataSourceTransactionManager 与 HikariPool 相关栈帧。一个典型的两类线程会同时出现:

"http-nio-8080-exec-3" #33 daemon prio=5 os_prio=0 tid=0x00007f2c... nid=0x4a2b waiting on condition
   java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:304)
    at com.zaxxer.hikari.util.ConcurrentBag.borrow(ConcurrentBag.java:174)
    at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:178)
    at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
    at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:262)
    - locked <0x000000076b8a4c30> (a org.springframework.jdbc.datasource.DataSourceTransactionManager)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(...)
    at com.example.tx.AuditService.writeAudit(AuditService.java:24)

"http-nio-8080-exec-7" #37 daemon prio=5 os_prio=0 tid=0x00007f2c... nid=0x4a2f waiting on condition
   java.lang.Thread.State: TIMED_WAITING (sleeping)
    at java.lang.Thread.sleep(Native Method)
    at com.example.tx.OrderService.sleep(OrderService.java:36)
    at com.example.tx.OrderService.placeOrder(OrderService.java:29)
    - locked <0x000000076b8a4c58> (a com.example.tx.OrderService)

读法很直接:exec-3 在 AuditService.writeAudit 的 doBegin 处等连接,说明它已经完成了外层事务,正在为内层 REQUIRES_NEW 申请第二条连接;而 exec-7 卡在 OrderService.placeOrder 的 sleep 里,它正握着外层那条连接不放。也就是说,池子里的 4 条连接被 4 个”外层事务持有者”占满,所有内层 REQUIRES_NEW 都无处申请。

3.2 三种典型形态的区分

形态 线程栈特征 Hikari 指标 是否根因在 REQUIRES_NEW
纯并发压力大 大量线程在 HikariPool.getConnection,栈中无嵌套事务 active≈max,waiting 高 否,扩池或降并发
嵌套放大占用 部分线程持外层连接在 sleep/IO,部分在 doBegin 等连接 active=max,idle=0 是,主要因素
锁等待引发雪崩 外层线程在 SocketInputStream.read 等 MySQL 响应 active=max,DB 侧 innodb_lock_wait_timeout 相关 间接,嵌套是放大器

第二种形态就是本次实验的场景。一个快速量化公式:设池大小 P,每个请求的独立事务层数为 k,则单请求峰值占用约 k 条连接,理论最大并发安全请求数约为 P / k。池为 4、k=2 时,只要并发超过 2 个请求,就必然有线程在等连接。这解释了为什么”只嵌套一层”也会打满:不是层级深,而是并发数乘以层级后超过了池容量。

四、为什么”挂起”不归还连接:一个容易被忽略的细节

很多人以为挂起事务会把外层连接还回池子,其实不会。DataSourceTransactionManager.doSuspend 的源码只做三件事:解绑资源、清理同步器、返回 holder。物理连接依然由 SuspendedResourcesHolder 引用着,直到 doResume 把它重新绑定回来、外层事务 commit 后才调用 DataSourceUtils.releaseConnection 归还。

还有一处容易踩的坑:如果内层 REQUIRES_NEW 方法抛异常,Spring 会先回滚内层事务、归还内层连接,再恢复外层事务;外层如果 rollbackFor 覆盖了该异常,就整体回滚。但如果内层异常被 try-catch 吞掉,外层会继续执行并最终 commit,此时连接虽然归还了,业务语义已经错乱——审计日志写成功、订单却没生效,或者反过来。这类问题在排查连接池耗尽时经常被混淆成”连接泄漏”,实际是事务边界设计错误。

另外,@Transactional 的自调用失效问题会直接影响判定。下面这种写法内层根本不会走代理,自然不会多申请连接,池耗尽另有原因:

@Service
public class BadService {

    @Transactional
    public void outer() {
        // 直接 this 调用,不经过 Spring 的事务代理,REQUIRES_NEW 不生效
        inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() {
        // 实际运行在同一事务、同一条连接上
    }
}

判定方法:开启 logging.level.org.springframework.transaction=DEBUG,正常生效时能看到

Creating new transaction with name [com.example.tx.AuditService.writeAudit]:
PROPAGATION_REQUIRES_NEW, ISOLATION_DEFAULT
Acquired Connection [HikariProxyConnection@1234567 wrapping com.mysql.cj.jdbc.ConnectionImpl@...] for JDBC transaction
Suspending current transaction, creating new transaction with name [...]

若日志里只有 Participating in existing transaction,说明 REQUIRES_NEW 没生效,连接池耗尽与它无关,需要继续查并发量、慢 SQL 或连接泄漏。

五、修复策略:从”改池”到”改设计”

5.1 先认清 REQUIRES_NEW 是否必要

大多数用 REQUIRES_NEW 的场景其实只有两个诉求:一是审计/日志不随主事务回滚,二是独立提交的幂等记录。如果诉求是”日志不随回滚”,更稳妥的做法是把日志异步化——用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 或消息队列,主事务提交后再落库,既不占第二条连接,也不会因为内层失败影响主流程。

@Component
public class AuditListener {

    private final JdbcTemplate jdbcTemplate;

    public AuditListener(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderCreated(OrderCreatedEvent event) {
        jdbcTemplate.update(
            "INSERT INTO audit_log(order_no, action, created_at) VALUES (?, ?, NOW())",
            event.orderNo(), "ORDER_CREATED");
    }
}

注意 AFTER_COMMIT 阶段执行时原事务已经提交,此处执行的 JDBC 语句默认不在事务中(autoCommit=true),仍需要连接但只是一条、且持有时间极短,不会产生”两条连接同时被持有”的放大效应。

5.2 如果必须保留 REQUIRES_NEW,控制并发与超时

  • 把池大小与嵌套层数挂钩:按 maximum-pool-size >= 最大并发请求数 × 嵌套层数 × 1.2 估算,同时不超过 DB 实例的 max_connections 安全水位(一般不超过其 60%)。
  • 压缩连接持有时间:外层事务里不要在持有连接的状态下做远程调用、sleep、大批量循环。示例代码里的 sleep(200) 就是在模拟这种反模式——真实代码里对应 HTTP 调用、文件上传、第三方支付回调。
  • 缩小事务注解范围:@Transactional 只标注在真正的 DB 写操作上,不要标在 Controller 或包含非 DB 逻辑的 Service 方法上。

5.3 用连接泄漏检测辅助定位

配置了 leak-detection-threshold: 20000 之后,若某条连接被持有超过 20 秒,HikariCP 会打印堆栈。这条日志与”池耗尽”是两回事:泄漏是连接借出后不归还(如手动 getConnection 未 close),耗尽是并发超出容量。看到下面的日志,说明是泄漏而非容量不足:

[TxLabPool connection adder] WARN com.zaxxer.hikari.proxy.ProxyLeakTask
- Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@5f2b9d1e
on thread http-nio-8080-exec-5, stack trace follows
    java.lang.Exception: Apparent connection leak detected
        at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
        at com.example.tx.ReportService.export(ReportService.java:41)

5.4 实在不需要独立事务时,降级为 REQUIRED

如果业务上确认内层失败本就应该让外层一起回滚,把 Propagation.REQUIRES_NEW 改为默认的 REQUIRED,内层会复用外层连接,嵌套层数对连接的占用降为 1。这是收益最大、改动最小的优化。判断依据只有一个:内层失败时,外层是否必须继续提交。答案是否,就不该用 REQUIRES_NEW。

六、面试回答模板

被问到这道题时,按下面四步作答,既体现机制理解,又体现排查能力:

  1. 机制:REQUIRES_NEW 会挂起外层事务(解绑 ThreadLocal 中的 ConnectionHolder),并向数据源新申请一条连接,因此嵌套期间同一线程同时持有两条连接,物理连接数不会因为”挂起”而减少。
  2. 判定:连接池耗尽看 Hikari 的 total/active/idle/waiting 与线程栈。若栈中一半线程在 DataSourceTransactionManager.doBegin 等连接、另一半持有外层连接在业务逻辑里,则 REQUIRES_NEW 的并发放大是主因;若栈中没有嵌套事务痕迹,则先查并发量、慢 SQL 和连接泄漏。
  3. 数量关系:安全并发量约为 池大小 / 嵌套层数,池为 10、两层嵌套时,超过 5 个并发请求就会出现排队。
  4. 修复:优先把日志类需求改为 @TransactionalEventListener(AFTER_COMMIT) 或 MQ;必须保留 REQUIRES_NEW 时缩短外层事务持有时长、按公式扩容、开启 leak-detection-threshold 排除泄漏。

小结

REQUIRES_NEW 与连接池耗尽之间的关系不是”必然因果”,而是”条件放大”:它让单请求的连接占用从 1 变成 k,从而把安全并发阈值压低到 P/k。只要并发数超过这个阈值,且外层事务持有连接的时间足够长,池就一定会耗尽。判定的核心在于三样证据——Hikari 的 active/idle/waiting 三元组、线程栈中是否出现嵌套事务的 doBegin 等待、以及事务 DEBUG 日志里是否真的出现 Suspending current transaction。三者齐备,才能把锅准确扣在传播行为上,否则容易把慢 SQL、连接泄漏或自调用失效误判成 REQUIRES_NEW 的罪过。

FAQ

Q1:嵌套一层 REQUIRES_NEW 到底占几条连接?

在内层事务执行期间,同一线程同时持有 2 条:外层 1 条(被挂起但未归还)+ 内层 1 条(新申请)。内层结束并提交后,内层连接归还,外层继续使用原来的那条。

Q2:报错 Connection is not available, request timed out after 3000ms 一定是 REQUIRES_NEW 导致的吗?

不一定。先看括号里的 total/active/idle/waiting:若 active 达到 maximum-pool-size 而 waiting 很多,是容量问题;再抓线程栈,若没有 doBegin 等待嵌套事务的栈帧,则优先排查慢 SQL、事务范围过大(在事务里做 HTTP 调用)、连接泄漏(开启 leak-detection-threshold)。

Q3:把 maximum-pool-size 调大就能解决吗?

只能缓解。池大小受数据库 max_connections 约束,MySQL 默认 151,云数据库常按规格限制,盲目扩大容易把 DB 压垮。更有效的是缩短连接持有时间、降低嵌套层数。经验上池大小不应超过 DB 连接上限的 60%。

Q4:内层 REQUIRES_NEW 抛异常,外层能捕获继续提交吗?

可以,但要注意:内层独立事务已回滚,外层若捕获异常继续执行,最终提交的是外层自己的数据。若外层也用 @Transactional(rollbackFor = Exception.class) 且异常未被捕获,则整体回滚。此外,内层方法若被同类自调用,REQUIRES_NEW 不生效,异常会直接标记外层事务为 rollback-only,此时再捕获异常也会在提交时抛 UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only。

Q5:REQUIRES_NEW 与 NESTED 有什么区别?

NESTED 依赖 JDBC 的 savepoint,复用外层同一条连接,不会多申请连接,回滚粒度是 savepoint;REQUIRES_NEW 是真正的新事务、新连接、独立提交。连接池耗尽风险只来自后者。注意 NESTED 在 JPA/Hibernate 环境下支持有限,使用前需确认 JpaTransactionManager 开启了 nestedTransactionAllowed。

Q6:事务 DEBUG 日志里没看到 Suspending current transaction,说明什么?

说明 REQUIRES_NEW 没有生效。常见原因有三:同类内部 this 调用绕过代理;方法不是 public;类没有被 Spring 管理(自己 new 出来的对象)。这种情况下连接池耗尽与传播行为无关,应转向其他方向排查。