zhaojie.tech/me
Spring 2026-10-06T00:00:00.000Z

REQUIRES_NEW 不是万能药,但幂等占位离不开它

赵杰 · zhaojie.tech

回调重试打了三次,账上多记了两笔——幸好是测试环境。

这是我在自己做连锁零售 ERP 时踩过的一课。渠道的支付回调会重试,这是所有支付对接的常识:你应答慢了、网络抖了、甚至你就是处理成功了但应答包丢了,渠道都会把同一笔通知再发一遍。当时我的记账逻辑挂在回调事务里,第一次处理成功、应答超时,渠道重发两次,账上就多出两笔收款。测试环境暴露了它,但也意味着生产环境迟早会暴露。

问题的形状

回调幂等的标准做法是”占位”:处理前先往一张幂等表里插一条记录,靠唯一索引拦住重复。坑在于占位记录和业务写操作放在同一个事务里:

  1. 回调进来,事务开始,插占位、记账,一切正常;
  2. 某个环节业务校验失败(或下游抖动),整个事务回滚——占位记录也回滚了;
  3. 渠道重试到达,查幂等表:干净。再记一笔账。

占位随业务回滚,等于每次失败都把重试窗口重新打开。所以要倒过来:占位必须在业务事务之前、以独立事务提交,业务回滚不带走占位——渠道重试再来,直接被存储层拦掉,宁可让对账补偿去处理”占位了但没记账”的少数派,也不让系统重复记账。

为什么是 REQUIRES_NEW

Spring 事务传播七种,这里逐个过一遍就清楚为什么只有它合适:

  • REQUIRED:加入外层事务——占位跟着回滚,正是事故根因,排除;
  • NESTED:同一物理连接里用 JDBC Savepoint 做存档点,内层回滚只退回存档点、外层回滚连坐内层。占位要的是”外层回滚、我活着”,NESTED 恰恰做不到,排除;
  • NOT_SUPPORTED/NEVER:挂起或不允许事务,占位自己就没有事务保障,排除;
  • SUPPORTS/MANDATORY:行为依赖调用方,不确定性太大,排除。

REQUIRES_NEW 的语义是:挂起当前事务,总是新开一个物理事务、拿一个新连接、独立提交。落地代码非常短:

@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryAcquire(Long tenantId, String idempotentKey, String eventType) {
    ChannelIdempotentEntity entity = new ChannelIdempotentEntity();
    entity.setIdempotentKey(idempotentKey);
    entity.setEventType(eventType);
    entity.setHandledDate(new Date());
    try {
        return save(entity);          // 撞 uk_idem(tenant_id, idempotent_key)
    } catch (DuplicateKeyException dke) {
        return false;                  // 重复事件,直接拒
    }
}

回调入口先调 tryAcquire,拿到 true 才继续写 outbox、走记账;拿到 false 就应答成功,让渠道停止重试。

它在 Spring 内部发生了什么

值得拆开看一眼原理,因为”第二物理连接”是 REQUIRES_NEW 全部代价的来源:

  1. Spring 的事务状态放在 TransactionSynchronizationManager 的 ThreadLocal 里,连接被 ConnectionHolder 绑定在线程上;
  2. 进入 REQUIRES_NEW 方法前,事务拦截器先执行 suspend:把当前 ConnectionHolder 从 ThreadLocal 解绑,封装进 SuspendedResources 挂在一边;
  3. 从连接池再借一个连接,绑定 ThreadLocal,开启第二个物理事务;
  4. 内层方法返回即提交第二个事务,然后 resume:把旧连接重新绑回线程,外层事务无感知地继续。

两个实践推论:

第一,连接占用翻倍。 外层持连接 A 不放(挂起≠释放),内层要借连接 B。并发回调一多,连接池有被耗尽的风险——线程之间互相持有连接等连接,超时雪崩。嵌套深度和并发量必须对着连接池大小评估,这也是我说它”不是万能药”的原因:能不用就不用,用的时候想清楚池子。

第二,同库下占位行锁不碰业务行。 占位事务提交前,锁的是 ss_channel_idempotent 里自己那一行(以及唯一索引上的冲突检查锁),和业务表的行不在一张页上,不会和外层事务形成锁等待环。真正要警惕的是并发插同一键值时 InnoDB 的 S 锁排队,偶发死锁是已知行为,捕获 DuplicateKeyException 的同时也别把死锁重试漏了。

顺带一提,同一思想我在另一处也用了:单据号引擎的序列取号(CurrentNoAllocator),播种和取号各走一个 REQUIRES_NEW 短事务——取号事务独立提交,调用方回滚不回收号,允许跳号、不允许重号,和幂等占位是同一个”独立提交换确定性”的取舍。

踩过的坑

  • 占位和业务同事务:上文事故,不再重复。
  • DuplicateKeyException 没翻译就往上抛:渠道收到 500,更猛烈地重试。占位失败应该返回业务语义(“该事件已处理”),应答成功让渠道收兵。
  • 在 REQUIRES_NEW 里塞业务逻辑:它应该是几行代码的短事务,提交越快,第二连接持有时间越短。有人顺手把校验、查询都塞进去,连接池压力立刻上来。

一句收尾

事务边界的选择本质是在回答”这段写入希望跟谁共生共死”——幂等占位必须比业务事务活得更久,所以它值得一个独立的物理事务;而对连接池的敬畏,决定了你敢在多高的并发下这么做。

← 全部文章 赵杰 · me.zhaojie.tech