REQUIRES_NEW不是"事务更安全"的开关。用对了,它能保住审计日志和失败记录。
用错了,它会在高并发下把数据库连接池直接耗尽。
一、事故现场
线上有个批量确认订单的接口。
业务逻辑很简单:
- 查询待确认订单
- 循环处理每一笔订单
- 每处理一笔,都写一条处理日志
- 某一笔失败,不影响日志记录
代码大概长这样:
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 的真实踩坑复现。