Propagation.REQUIRES_NEW 嵌套后连接翻倍?事务传播级别的三个隐形成本陷阱

上篇文章解决了"同一个类里方法调方法不走代理"的问题——你在方法 A 上加 @Transactional,调本类方法 B,B 的事务配置被无视,退化为 outer 事务的一部分。

现在你知道了,把方法 B 抽到另一个 Service,给它加上 @Transactional(propagation = Propagation.REQUIRES_NEW),于是 B 应该有一个"全新独立"的事务。

这个直觉只对了一半。REQUIRES_NEW 确实是新事务,但它不是"免费"的。每嵌套调用一次,你的连接池可能被吃掉两份连接。 更隐蔽的是,如果 B 和 A 共享同一个 EntityManagerFactory,B 的回滚可能顺手把 A 的 PersistenceContext 也污染了——而你还在开心的 catch 异常,以为万事大吉。

REQUIRES_NEW 最贵的代价不是创建新事务,是外层连接挂起期间——它不吃不喝也不还。

【现场】一个 for 循环,HikariPool 爆了

事故全景

去年有一个线上工单:批量导入接口在数据量超过 50 条时随机超时

代码结构是这样:

// OrderImportService.java
@Service
public class OrderImportService {
    private final OrderValidator validator;

    @Transactional
    public void batchImport(List<OrderImportRequest> requests) {
        for (OrderImportRequest req : requests) {
            try {
                validator.validateAndMark(req);  // REQUIRES_NEW
            } catch (InvalidOrderException e) {
                log.warn("跳过无效订单: {}", req.getOrderNo());
            }
        }
    }
}

// OrderValidator.java
@Service
public class OrderValidator {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void validateAndMark(OrderImportRequest req) {
        // 读取配置表 + 校验规则 + 写入标记
        orderConfigRepo.findByType(req.getType());
        validationRepo.save(mark(req));
    }
}

团队在 validateAndMark 上用了 REQUIRES_NEW,期望的是每条订单独立提交:校验失败的订单回滚自己的记录,不影响其他订单。

但线上跑 50 条订单时,到第 30 条左右接口就 hang 住了。

异常日志

HikariPool exhausted

maxPoolSize=10,active=10,idle=0——池子吃满了。

开发者第一反应:"连接池不够,调大。" 但调大到 20 后,能处理的条数从 30 条涨到了 60 条,依然有上限。

问题在哪?

断言:批量导入 + REQUIRES_NEW 代码结构

问题不在池子小,在 REQUIRES_NEW 每个循环吃 2 个连接

时机 OrderImportService 事务(outer) OrderValidator 事务(inner)
第 1 条订单 持连接 C1,挂起 取连接 C2,执行,释放 C2
第 2 条订单 恢复 C1,再挂起 取连接 C3,执行,释放 C3
... 循环 每次一条新连接
第 k 条订单 C1 一直没还 取连接 C_{k+1}

C1 被外层事务 挂起但没有归还连接池。每个并发的导入请求都占着 C1 不放,再吃一条临时连接给内层。10 个并发请求同时导入 → 每个请求占 C1 + C_临时 → 20 条连接被申请。maxPoolSize=10 时,10 个请求只有 5 个能拿到内层连接,其余 5 个的 inner 方法阻塞等待。

【拆解】REQUIRES_NEW 的三个隐形成本

陷阱一:挂起 = 持连接不还

REQUIRES_NEW 嵌套时的连接占用时序

这是最容易被忽略的代价。DataSourceTransactionManager 处理 REQUIRES_NEW 的核心逻辑:

DataSourceTransactionManager 挂起/恢复源码

关键行在 suspend → begin 之间:C1 只是从线程解绑,物理连接还在,没有归还连接池。这意味着 N 层嵌套 = N 个连接同时被占用

这也是为什么调大连接池能缓解但治标不治本——连接池再大,也架不住每个请求按嵌套层数吃多份。

陷阱二:JPA PersistenceContext 污染——更隐蔽的连锁反应

假设你的代码涉及 JPA/Hibernate:

@Transactional
public void outerMethod(Long orderId) {
    Order order = orderRepo.findById(orderId).orElseThrow();
    order.setStatus("PROCESSING");
    // Hibernate 此时已跟踪 order 为 "脏" 状态

    auditService.auditChange(order);  // REQUIRES_NEW
}

这里有个微妙的问题:Spring JpaTransactionManager 处理 REQUIRES_NEW 时,外层 EntityManager 被挂起,内层创建一个新的 EntityManager。内层执行完提交或回滚后,内层 EM 关闭,外层 EM 恢复。

但 Hibernate 的 PersistenceContext 是有状态的。如果内层方法操作了和外层相同的实体对象:

  • 外层加载了 Order(managed 状态)
  • 内层也加载了同一个 Order,做了修改
  • 内层回滚 → 内层 EM 的 PersistenceContext 清空
  • 外层恢复后 flush → Hibernate 尝试把外层的 order 变更写回数据库
  • 但此时一级缓存中的 order 可能因为内层的操作而处于不一致状态

最典型的现象是 StaleObjectStateExceptionConstraintViolationException,但错误堆栈指向的是外层方法最后一行,让人完全联想不到是内层 REQUIRES_NEW 的回滚导致的。

陷阱三:REQUIRES_NEW 在同类方法上退化为 REQUIRED(上一篇文章的延续)

这个问题和上篇文章本质相同——REQUIRES_NEW 也必须走代理才能生效

@Service
public class OrderService {
    @Transactional
    public void process(Order order) {
        saveOrder(order);
        // 这里直接调本类方法
        deductStock(order);  // REQUIRES_NEW? 不,退化为 REQUIRED
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductStock(Order order) {
        stockRepo.deduct(order.getProductId(), order.getQuantity());
    }
}

Spring AOP 代理的工作机制决定了:process() 内部直接调 deductStock(),走的是 this.deductStock(),不是代理对象 → 事务拦截器不触发 → propagation 配置被无视 → 退化为外层事务(REQUIRED)。

你说:"我把 deductStock 抽到别的 Service 了,REQUIRES_NEW 生效了。那第一个陷阱还适用吗?"

适用。REQUIRES_NEW 生效 + 连接翻倍,是两个独立问题。生效解决的是"事务隔离",翻倍解决的是"资源消耗"。

【处方】选型决策树 + 正确配置

REQUIRES_NEW 不是银弹

事务传播选型决策树

你的需求 用哪个 原因
外层失败时内层也回滚 REQUIRED(默认) 一个事务,全部回滚,无额外连接开销
内层失败时不影响外层 NESTED(JDBC savepoint) 只回滚 savepoint 部分,外层继续,不额外吃连接
内层需要完全独立提交/回滚(不受外层影响) REQUIRES_NEW 独立事务,但注意连接翻倍
批量处理,每条记录独立提交 TransactionTemplate + 手动控制 比 REQUIRES_NEW 更省连接,控制更精确

关键区别: NESTED 用 savepoint 实现,不申请新连接;REQUIRES_NEW 挂起外层 + 取新连接,连接成本翻倍。

当必须用 REQUIRES_NEW 时

如果业务真的需要完全独立的事务(比如审计日志必须单独提交,不随主事务回滚):

方案 A:增大连接池余量

spring.datasource.hikari.maximum-pool-size = (预期并发数 × 嵌套层数) + 其他连接消耗

例:20 并发 × 2 层嵌套 + 5 条管理查询 = 45。但这不是线性增长的——还要算上每个请求的执行时间。

更靠谱的方案 B:内外层用不同的连接池

配置两个 DataSource,内层专用一个较小的池,避免干扰主流程的连接。

spring:
  datasource:
    primary:
      hikari.maximum-pool-size: 20
    audit:
      hikari.maximum-pool-size: 5
@Transactional(
    value = "auditTransactionManager",
    propagation = Propagation.REQUIRES_NEW
)
public void auditLog(...) { ... }

最推荐的方案 C:能用 TransactionTemplate 就别用 REQUIRES_NEW

@Service
public class BatchProcessor {
    private final TransactionTemplate txTemplate;

    public void batchProcess(List<Item> items) {
        for (Item item : items) {
            try {
                txTemplate.execute(status -> {
                    processOneItem(item);
                    return null;
                });
            } catch (TransactionException e) {
                log.error("处理失败: {}", item.getId());
            }
        }
    }
}

REQUIRES_NEW vs TransactionTemplate

TransactionTemplate 本质是每次新建事务,和 REQUIRES_NEW 效果一样,但:

  1. 没有"外层挂起"的开销——因为没有外层事务
  2. 连接用完即还,不会长时间持有
  3. 捕获异常后不会出现 UnexpectedRollbackException

快速自查:REQUIRES_NEW 用对了没有?

□ 目标方法是否在另一个 Service 中?(同类调用会退化为 REQUIRED)
□ 调用方是否真的需要内层完全独立?(NESTED 可能更省连接)
□ 连接池是否已按嵌套翻倍估算?(n 层嵌套 = n 倍单层连接消耗)
□ 如果有 JPA,内层是否操作了和外层相同的实体?(可能导致 PersistenceContext 污染)

【雷标】🔴 搜索你的项目

搜索关键词

propagation = Propagation.REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
REQUIRES_NEW

搜索后检查

  1. REQUIRES_NEW 方法是否被另一个 @Transactional 方法调用? → 逐层数嵌套深度,评估连接翻倍风险
  2. REQUIRES_NEW 方法是否和调用方在同一个类中? → 退化为 REQUIRED,事务配置等于白写
  3. REQUIRES_NEW 方法和调用方是否操作了相同的实体? → 有 PersistenceContext 污染风险,考虑提取独立 DataSource
  4. 批量场景是否一定要用 REQUIRES_NEW? → 试试 TransactionTemplateNESTED,连接消耗降低 50%+

📺 公众号「Ai拆代码的曹操」
🌟 知识星球「Ai拆代码的曹操」