Skip to content
Go back

事务内远程调用:一致性幻觉与四条出路

Updated:  at  10:25 AM

在本地事务里加锁、更新数据库、调用远程接口,远程失败就抛异常让事务回滚——这个方案乍看很稳:本地操作和远程调用似乎被绑定在同一个决策里,要么都成功,要么都失败。

但它能提供的,只是一种原子性的安全感。在继续讨论怎么选之前,必须先正视一个前提:数据库事务的回滚能力,从未真正延伸至远程服务。


一、回滚的边界:数据库事务管不到远程服务

远程接口一旦执行成功,它的副作用就已经发生。本地事务回滚,既不会删除对方数据库里的记录,也不会撤回已经发出的消息、已经扣减的额度。

本地事务开始
  ├── 获取分布式锁
  ├── 更新本地数据
  ├── 调用远程接口   ← 副作用已经发生
  └── 远程失败 → 抛异常 → 本地回滚
                          ↑
                 远程副作用无法被回滚

你得到的”一致性”,实际上依赖一个并不稳固的假设:远程调用失败时,它一定没有产生任何副作用。

现实中这个假设很容易被打破:

也就是说,“调用失败”不等于”没有副作用”。这种情况下,本地数据回滚了,远程副作用却留了下来,两边直接不一致。而你的代码看起来一切正常,因为异常被捕获、事务被回滚,没有任何地方报错。

所以,当前方案的真正问题,不在于”选择了一致性级别”,而在于用一种脆弱的工程结构,去承载一个它无法真正兑现的承诺。


二、决策的分叉点:外部接口是否可改造

理解上面的前提后,问题就变成:如何在”事务内嵌远程调用 + 锁”这个已知有缺陷的模式,与其他方案之间做出选择?

这个问题没有普适答案,但有一个关键分叉点:外部同步接口的控制权,在你手上,还是在对方手上?


三、接口可改造:TCC

TCC(Try-Confirm-Cancel)要求远程服务把一次操作拆成两个阶段:

这样,本地事务在 Try 成功后就可以提交,Confirm 或 Cancel 由协调器在事务之外驱动。

Try(预留资源)
    ↓
本地事务提交
    ↓
Confirm(真正提交) / Cancel(释放预留)

它的关键价值在于:锁和远程调用都不再需要被包裹在数据库事务内。事务边界变干净了,连接和锁也不会因为等待远程响应而被长期占用。

代价是业务侵入性很高。每个参与方都要实现三个接口,并处理三个经典难题:

难题含义处理要求
空回滚Try 没有到达,Cancel 却先到了Cancel 必须能安全地空执行
悬挂Cancel 先于 Try 到达,之后 Try 才来后续 Try 必须被识别并拒绝
幂等Confirm / Cancel 可能被重复调用两个阶段都要支持幂等执行

TCC 本质上是用前期较高的开发与设计成本,换取事务边界的彻底干净。它适合”外部接口可改造、强一致优先”的场景,但它不是换个框架就能解决的问题,而是需要上下游一起改造的业务协议。


四、接口不可改造:把远程调用移出事务

如果外部接口完全由第三方控制,无法改造,那么真正的强一致就无法达成。此时的选择只剩下一个:在”接受最终一致性”的前提下,如何把不一致的窗口和风险降到最低。

继续坚持”事务内调用 + 回滚”,是用高风险的工程方式追求一个无法真正达成的目标。更务实的方向是:把远程调用移出事务,再确保它”最终会被执行”,或者”失败时能被发现并处理”。

4.1 事务后同步重试 + 异步对账

将本地事务收缩到只包含数据库操作和锁。事务提交后,在事务外调用外部同步接口:

  1. 调用失败,立即进行有限次数的同步重试;
  2. 重试仍然失败,不回滚本地事务,而是把这次失败记录为”待处理异常”,写入异常表或触发告警;
  3. 由日终对账任务比对本地状态与远程状态,发现不一致后触发人工或自动补偿。
本地事务:更新数据 + 加锁
    ↓ 提交(锁释放)
事务外调用远程接口
    ↓ 失败
有限次同步重试
    ↓ 仍失败
记录异常 + 告警
    ↓
对账任务比对两侧状态
    ↓
自动 / 人工补偿

这个方案的逻辑是:用”事后发现并修复”替代”事前绑定回滚”。它承认不一致可能短暂存在,但要求不一致不能永久沉默。适合接口响应较快、失败率较低、业务能容忍短时间不一致的场景。

4.2 Outbox(本地消息表)

如果外部同步接口可以被包装成异步消费者,例如通过消息队列投递,Outbox 是最干净的解法。

在本地事务中,只做两件事:业务数据变更 + 写入一条”待发送事件”记录到同一数据库。事务提交后,由独立的发布器轮询事件表,异步调用外部接口或发送 MQ 消息。

本地事务 {
    业务数据变更
    写入 outbox 事件
}
    ↓ 提交(锁与数据库连接在此释放)
发布器轮询 outbox
    ↓
调用远程接口 / 发送 MQ
    ↓ 失败
重试 → 死信 → 告警 / 补偿

远程调用的成功与否由发布器通过重试和死信机制保障,锁和数据库连接在事务提交时就已经释放。你彻底消除了事务内的远程调用和锁持有问题,代价是业务从”同步等待结果”变成”异步最终一致”,延迟取决于轮询频率;同时,发布器通常是”至少一次投递”,下游消费必须幂等。


五、如果必须保留现状:四条防御措施

如果受限于现实因素,必须暂时保留”事务内嵌锁和远程调用”的结构,那至少要把爆炸半径控制在可管理范围内。

5.1 为远程调用设置严格且分层的超时

连接超时和读取超时必须显式配置,且读取超时应远小于事务超时和锁的 leaseTime。

绝不能让远程调用成为连接和锁的”无限期黑洞”。一个远程调用卡住,耗尽的可能是整个连接池,而不只是这一个请求。线上表现往往不是”这个接口慢”,而是”所有接口一起不可用”。

5.2 严格评估锁的 leaseTime

如果使用 Redisson 的 lock(leaseTime, unit),显式设置的租期会禁用 WatchDog 自动续期:

RLock lock = redissonClient.getLock(key);
// 显式传入 leaseTime 后,WatchDog 不再为该锁续期
boolean locked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);

你需要基于远程调用的 P99.9 耗时,设置一个足够覆盖最坏情况的租期。锁提前释放导致并发穿透,比锁阻塞的后果要严重得多——阻塞至少还能被发现,穿透往往直接破坏数据。

5.3 远程接口必须具备幂等能力

既然在事务内调用远程接口,就必须假设它可能被重复调用:事务提交阶段网络抖动、应用层重试、上游重复投递,都可能造成重复请求。没有幂等键的远程接口,在这种模式下是极度危险的。

5.4 把”事务回滚”视为尽力而为,并建立对账兜底

不要假设回滚一定能抵消远程副作用。必须有独立的对账机制,定期扫描”本地已回滚,但远程可能已执行”的边界情况,把沉默的不一致变成可见的异常。


六、选型速查

核心约束更可行的方向需要接受的代价
外部接口可改造,强一致优先TCC高业务侵入性,需实现三接口并处理幂等、悬挂、空回滚
外部接口不可改造,能接受短暂不一致事务后重试 + 对账不一致窗口存在,依赖事后发现机制
外部接口不可改造,下游可异步化Outbox / 本地消息表业务从同步变为异步,一致性延迟取决于轮询频率
现实约束极强,必须保留事务内调用防御性工程 + 对账兜底连接池耗尽与锁失效的风险持续存在,需严格监控

七、配套实践代码

两条路径都有可运行的对照实现:tx-lock-rpc。工程使用 Java 17 + Spring Boot 3,以 Redisson 分布式锁、H2 内存库和一个可注入故障的 mock 服务为底座:

mock 服务可以注入 500、超时、慢响应和业务拒绝,用来复现“超时但远端已成功”这类边界情况。启动方式、接口清单和实验剧本见仓库 README。


八、小结

把上面的内容压缩成一份速记清单:

  1. 回滚有边界:数据库事务只能回滚本地数据,撤销不了远程副作用。
  2. 失败不等于无副作用:超时、连接重置、对方部分执行,都可能出现”本地已回滚,远程已生效”。
  3. 先看控制权:外部接口可改造,考虑 TCC;不可改造,接受最终一致性。
  4. 能移出事务就移出:事务后重试 + 对账适合低失败率场景,Outbox 适合可异步化的场景。
  5. 必须留在事务内时:分层超时、合理 leaseTime、远程幂等、对账兜底,一个都不能少。

一致性不是一个可以通过”加个事务、加把锁”声明出来的属性。结构能兑现多少,承诺才有多少。


Suggest Changes

Next Post
会话管理:持久化与恢复