@Transactional 里套 REQUIRES_NEW,为什么会把连接池耗尽?

REQUIRES_NEW 不是"事务更安全"的开关。

用对了,它能保住审计日志和失败记录。

用错了,它会在高并发下把数据库连接池直接耗尽。


一、事故现场

线上有个批量确认订单的接口。

业务逻辑很简单:

  1. 查询待确认订单
  2. 循环处理每一笔订单
  3. 每处理一笔,都写一条处理日志
  4. 某一笔失败,不影响日志记录

代码大概长这样:

java 复制代码
@Service
public class OrderBatchService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OrderLogService orderLogService;

    @Transactional(rollbackFor = Exception.class)
    public void batchConfirm(List<Long> orderIds) {
        for (Long orderId : orderIds) {
            orderMapper.updateStatus(orderId, "CONFIRMED");

            orderLogService.saveLog(orderId, "CONFIRM_SUCCESS");
        }
    }
}

日志方法用了 REQUIRES_NEW

java 复制代码
@Service
public class OrderLogService {

    @Autowired
    private OrderLogMapper orderLogMapper;

    @Transactional(
        propagation = Propagation.REQUIRES_NEW,
        rollbackFor = Exception.class
    )
    public void saveLog(Long orderId, String content) {
        orderLogMapper.insert(orderId, content);
    }
}

开发同学当时的想法是:

日志要独立提交。就算外层订单事务回滚,处理日志也要留下来,方便排查。

这个想法本身没错。

结果上线后,高峰期接口越来越慢,最后大量请求超时。

日志里开始出现这种错误:

text 复制代码
HikariPool-1 - Connection is not available, request timed out after 30000ms.

数据库连接池被打满了。


二、第一层误判:以为只是"新开事务"

很多人理解 REQUIRES_NEW 时,只记住了一句话:

挂起当前事务,新开一个事务。

这句话没错,但不够。

它背后还有一个更关键的动作:

新事务通常需要重新拿一个数据库连接。

外层事务已经占着一个连接。

内层 REQUIRES_NEW 要独立提交,就不能继续使用外层那个正在事务中的连接。

所以它会尝试从连接池里再拿一个连接。

也就是说,一次请求里可能同时占用两个连接:

text 复制代码
外层 REQUIRED 事务:占用连接 1
内层 REQUIRES_NEW 事务:再占用连接 2

如果外层事务里循环调用很多次 REQUIRES_NEW,并发再高一点,连接池压力会被瞬间放大。


三、真正的执行过程

看这段代码:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void batchConfirm(List<Long> orderIds) {
    for (Long orderId : orderIds) {
        orderMapper.updateStatus(orderId, "CONFIRMED");

        orderLogService.saveLog(orderId, "CONFIRM_SUCCESS");
    }
}

执行过程大概是这样:

text 复制代码
1. 进入 batchConfirm
2. 开启外层事务,拿到连接 A
3. 更新订单状态,连接 A 不释放
4. 调用 saveLog
5. 挂起外层事务
6. 开启 REQUIRES_NEW,尝试拿连接 B
7. 插入日志
8. 提交内层事务,释放连接 B
9. 恢复外层事务,继续使用连接 A
10. 循环下一笔订单

单线程看起来没什么问题。

但高并发时就不一样了。

假设连接池大小是 10。

现在同时来了 10 个请求。

每个请求都进入外层事务,每个请求先占住一个连接。

连接池状态变成:

text 复制代码
10 个请求
每个请求占 1 个外层连接
连接池 10 个连接全部被占满

接着每个请求都要调用 REQUIRES_NEW

内层事务还想再拿一个连接。

但连接池已经没有空闲连接了。

于是所有请求都卡在这里:

text 复制代码
等待内层事务获取新连接

外层事务又因为代码没执行完,不会释放连接。

这就形成了一个非常难受的局面:

text 复制代码
外层事务占着连接不放
内层事务等新连接
连接池没有空闲连接
请求全部阻塞

最后就是连接池超时。


四、最小复现

把 Hikari 连接池调小一点:

yaml 复制代码
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      connection-timeout: 30000

外层事务:

java 复制代码
@Service
public class OuterService {

    @Autowired
    private InnerService innerService;

    @Autowired
    private OrderMapper orderMapper;

    @Transactional(rollbackFor = Exception.class)
    public void outer(Long orderId) {
        orderMapper.updateStatus(orderId, "PROCESSING");

        innerService.inner(orderId);

        sleep(5000);
    }

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

内层事务:

java 复制代码
@Service
public class InnerService {

    @Autowired
    private OrderLogMapper orderLogMapper;

    @Transactional(
        propagation = Propagation.REQUIRES_NEW,
        rollbackFor = Exception.class
    )
    public void inner(Long orderId) {
        orderLogMapper.insert(orderId, "INNER_LOG");
    }
}

并发调用 10 次:

java 复制代码
for (int i = 0; i < 10; i++) {
    Long orderId = (long) i;
    executorService.submit(() -> outerService.outer(orderId));
}

你很容易看到连接等待或超时。

因为 10 个外层事务先把 10 个连接占满了。

然后每个外层事务都要进入 REQUIRES_NEW,但已经没有第 11 个连接可用了。


五、为什么本地测试很难发现?

这个问题本地不容易暴露,原因有三个。

1. 本地并发太低

你自己点接口,通常只有一个请求。

一个请求最多多占一个连接,连接池扛得住。

2. 数据量太小

本地可能只循环 3 条数据。

线上可能一次批量处理 500 条、1000 条。

3. 外层事务太短

本地数据库快、接口快、网络快。

线上可能还夹着远程调用、复杂 SQL、慢查询、锁等待。

外层事务持有连接的时间一长,问题就会被放大。

所以这个坑经常是:

text 复制代码
测试环境没问题
预发环境没问题
线上高峰期炸了

六、什么时候可以用 REQUIRES_NEW?

REQUIRES_NEW 不是不能用。

它适合这些场景:

场景 是否适合
审计日志必须独立保存 适合
失败记录必须留下来 适合
补偿任务记录 适合
主事务回滚后仍要保留操作痕迹 适合
高频循环里每条数据都开新事务 谨慎
高并发入口里嵌套新事务 谨慎
外层事务很长,还频繁调用内层新事务 高风险

关键不是"能不能用",而是你要知道它的成本。

REQUIRES_NEW 的成本至少有三个:

text 复制代码
1. 需要额外事务边界
2. 通常需要额外数据库连接
3. 内外事务提交和回滚互不绑定

七、怎么改更稳?

方案 1:不要在循环里频繁 REQUIRES_NEW

如果你现在是这样:

java 复制代码
for (Long orderId : orderIds) {
    orderMapper.updateStatus(orderId, "CONFIRMED");
    orderLogService.saveLogRequiresNew(orderId);
}

要先问一句:

每一条日志真的都必须独立事务提交吗?

如果不是,改成批量插入:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void batchConfirm(List<Long> orderIds) {
    List<OrderLog> logs = new ArrayList<>();

    for (Long orderId : orderIds) {
        orderMapper.updateStatus(orderId, "CONFIRMED");
        logs.add(new OrderLog(orderId, "CONFIRM_SUCCESS"));
    }

    orderLogMapper.batchInsert(logs);
}

让它们跟主事务一起提交,一起回滚。

方案 2:失败日志放到外层事务结束后处理

如果你的目标是记录失败原因,可以不要在主事务里硬插日志。

比如主流程失败后,在 catch 外面记录:

java 复制代码
public void batchConfirmWithLog(List<Long> orderIds) {
    try {
        orderBatchService.batchConfirm(orderIds);
    } catch (Exception e) {
        failLogService.saveFailLog(orderIds, e.getMessage());
        throw e;
    }
}

注意这里 batchConfirm()saveFailLog() 要通过不同的 Spring Bean 调用。

失败日志可以自己开事务,但它不再嵌套在外层大事务里。

方案 3:用本地消息表

如果既想让事件和主业务一致,又想异步处理,可以用本地消息表。

主事务里写业务数据和事件:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
    orderMapper.updateStatus(orderId, "CONFIRMED");

    outboxMapper.insert(new OutboxEvent(
        "ORDER_CONFIRMED",
        String.valueOf(orderId),
        "NEW"
    ));
}

事务提交后,再由定时任务或消息投递器处理:

java 复制代码
@Scheduled(fixedDelay = 1000)
public void publishEvents() {
    List<OutboxEvent> events = outboxMapper.selectNewEvents();

    for (OutboxEvent event : events) {
        try {
            producer.send(event);
            outboxMapper.markPublished(event.getId());
        } catch (Exception e) {
            log.error("事件投递失败,等待下次重试", e);
        }
    }
}

这样主事务不会嵌套一堆 REQUIRES_NEW

连接池压力也更可控。

方案 4:缩短外层事务

很多连接池问题,本质都是外层事务太长。

事务里不要放这些东西:

text 复制代码
远程 HTTP 调用
RPC 调用
大文件处理
复杂耗时计算
批量循环慢操作
等待用户输入

事务时间越长,连接占用时间越长。

再叠加 REQUIRES_NEW,就更容易炸。


八、一个容易忽略的点:连接池不是越大越好

有人看到连接池耗尽,第一反应是:

那我把 maximumPoolSize 从 10 改成 100 不就好了?

这只能缓解,不一定能解决。

连接池变大以后,应用能同时向数据库打出更多并发 SQL。

如果数据库扛不住,问题会从应用连接池转移到数据库:

text 复制代码
数据库 CPU 飙高
锁等待变多
慢 SQL 变多
事务堆积更严重

连接池大小要结合这些一起看:

text 复制代码
应用实例数
每个实例连接池大小
数据库最大连接数
接口并发量
事务平均耗时
慢 SQL 情况
是否有嵌套事务

比如你有 6 个应用实例,每个实例连接池 50。

那理论上最多就是 300 个数据库连接。

如果数据库 max_connections、CPU、IO 都撑不住,连接池调大只是把事故推后一点。


九、上线前 checklist

看到 REQUIRES_NEW,建议上线前过一遍这个表。

检查项 风险 建议
是否在循环里调用 高频创建新事务 尽量批量处理
是否在高并发接口里调用 连接池容易打满 评估并发和连接池
外层事务是否很长 长时间占用连接 缩短事务边界
内层事务是否必须独立提交 可能只是误用 能用 REQUIRED 就别用 REQUIRES_NEW
是否涉及远程调用 事务时间不可控 移出事务
连接池大小是否足够 获取连接超时 压测验证
是否有多个应用实例 总连接数放大 按实例数计算总连接
是否可以用消息表 降低嵌套事务压力 优先考虑 Outbox

十、总结

REQUIRES_NEW 最容易被误解成:

更保险的事务。

但它真正的含义是:

挂起当前事务,开启一个独立事务。

独立事务意味着独立提交、独立回滚,也通常意味着额外获取数据库连接。

所以它不是不能用,而是不能无脑用。

尤其要避开这三个组合:

text 复制代码
外层大事务 + 循环调用 REQUIRES_NEW
高并发接口 + 嵌套 REQUIRES_NEW
长事务 + 小连接池

最后记住一句话:

事务传播行为不是背出来的,是要和连接池、并发量、事务耗时一起看的。

很多线上连接池打满的问题,表面看是数据库慢,实际上是代码里某个 REQUIRES_NEW 把连接用法放大了。

下一篇准备写:事务里调用远程接口,为什么线上最容易卡死。

如果你也在排查 Java 后端线上问题,可以关注公众号:云技纵横。 这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。

相关推荐
不好听6136 小时前
JavaScript 的 this 到底指向谁?
javascript·面试
魏祖潇6 小时前
SDD 完整指南——Spec 端打底、Story 端交付、留白区
人工智能·后端
烬羽6 小时前
面试官:聊聊 LocalStorage 和 this 指向?看这篇就够了
面试·程序员
weedsfly6 小时前
JS垃圾回收:从原理到项目实战,彻底根治内存泄漏
前端·javascript·面试
feelmylife596 小时前
消息队列可靠投递与幂等消费 -- 从"消息丢了"到"消息别重复"的完整工程实践
后端
雪隐6 小时前
个人电脑玩AI-10让5060 Ti给你打工——部署 Odysseus:终于有个能打的"AI管家"了
人工智能·后端
copyer_xyf7 小时前
FastAPI 如何连接 MySQL
后端·python
IT_陈寒7 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端
葫芦和十三8 小时前
图解 MongoDB 25|分片架构三件套:mongos、config server 和 shard
后端·mongodb·agent