在本地事务里加锁、更新数据库、调用远程接口,远程失败就抛异常让事务回滚——这个方案乍看很稳:本地操作和远程调用似乎被绑定在同一个决策里,要么都成功,要么都失败。
但它能提供的,只是一种原子性的安全感。在继续讨论怎么选之前,必须先正视一个前提:数据库事务的回滚能力,从未真正延伸至远程服务。
一、回滚的边界:数据库事务管不到远程服务
远程接口一旦执行成功,它的副作用就已经发生。本地事务回滚,既不会删除对方数据库里的记录,也不会撤回已经发出的消息、已经扣减的额度。
本地事务开始
├── 获取分布式锁
├── 更新本地数据
├── 调用远程接口 ← 副作用已经发生
└── 远程失败 → 抛异常 → 本地回滚
↑
远程副作用无法被回滚
你得到的”一致性”,实际上依赖一个并不稳固的假设:远程调用失败时,它一定没有产生任何副作用。
现实中这个假设很容易被打破:
- 请求超时了,但对方其实已经处理成功,只是响应在网络上丢了;
- 对方完成了主流程,却在写日志、发消息等后续步骤失败;
- 连接被重置,调用方无法判断对方究竟执行到了哪一步;
- 对方接口本身不是原子的,执行到一半失败,留下了部分结果。
也就是说,“调用失败”不等于”没有副作用”。这种情况下,本地数据回滚了,远程副作用却留了下来,两边直接不一致。而你的代码看起来一切正常,因为异常被捕获、事务被回滚,没有任何地方报错。
所以,当前方案的真正问题,不在于”选择了一致性级别”,而在于用一种脆弱的工程结构,去承载一个它无法真正兑现的承诺。
二、决策的分叉点:外部接口是否可改造
理解上面的前提后,问题就变成:如何在”事务内嵌远程调用 + 锁”这个已知有缺陷的模式,与其他方案之间做出选择?
这个问题没有普适答案,但有一个关键分叉点:外部同步接口的控制权,在你手上,还是在对方手上?
- 接口由你或你的团队控制,可以协商改造:优先考虑 TCC,把一致性承诺变成可兑现的。
- 接口完全由第三方控制,无法改造:放弃真正的强一致,转而管理”不一致窗口”。
三、接口可改造:TCC
TCC(Try-Confirm-Cancel)要求远程服务把一次操作拆成两个阶段:
- Try:只做资源预留,例如冻结库存,而不是直接扣减;
- Confirm:真正提交,使用 Try 阶段预留的资源;
- Cancel:释放预留资源,用于失败回退。
这样,本地事务在 Try 成功后就可以提交,Confirm 或 Cancel 由协调器在事务之外驱动。
Try(预留资源)
↓
本地事务提交
↓
Confirm(真正提交) / Cancel(释放预留)
它的关键价值在于:锁和远程调用都不再需要被包裹在数据库事务内。事务边界变干净了,连接和锁也不会因为等待远程响应而被长期占用。
代价是业务侵入性很高。每个参与方都要实现三个接口,并处理三个经典难题:
| 难题 | 含义 | 处理要求 |
|---|---|---|
| 空回滚 | Try 没有到达,Cancel 却先到了 | Cancel 必须能安全地空执行 |
| 悬挂 | Cancel 先于 Try 到达,之后 Try 才来 | 后续 Try 必须被识别并拒绝 |
| 幂等 | Confirm / Cancel 可能被重复调用 | 两个阶段都要支持幂等执行 |
TCC 本质上是用前期较高的开发与设计成本,换取事务边界的彻底干净。它适合”外部接口可改造、强一致优先”的场景,但它不是换个框架就能解决的问题,而是需要上下游一起改造的业务协议。
四、接口不可改造:把远程调用移出事务
如果外部接口完全由第三方控制,无法改造,那么真正的强一致就无法达成。此时的选择只剩下一个:在”接受最终一致性”的前提下,如何把不一致的窗口和风险降到最低。
继续坚持”事务内调用 + 回滚”,是用高风险的工程方式追求一个无法真正达成的目标。更务实的方向是:把远程调用移出事务,再确保它”最终会被执行”,或者”失败时能被发现并处理”。
4.1 事务后同步重试 + 异步对账
将本地事务收缩到只包含数据库操作和锁。事务提交后,在事务外调用外部同步接口:
- 调用失败,立即进行有限次数的同步重试;
- 重试仍然失败,不回滚本地事务,而是把这次失败记录为”待处理异常”,写入异常表或触发告警;
- 由日终对账任务比对本地状态与远程状态,发现不一致后触发人工或自动补偿。
本地事务:更新数据 + 加锁
↓ 提交(锁释放)
事务外调用远程接口
↓ 失败
有限次同步重试
↓ 仍失败
记录异常 + 告警
↓
对账任务比对两侧状态
↓
自动 / 人工补偿
这个方案的逻辑是:用”事后发现并修复”替代”事前绑定回滚”。它承认不一致可能短暂存在,但要求不一致不能永久沉默。适合接口响应较快、失败率较低、业务能容忍短时间不一致的场景。
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 服务为底座:
- 路径一对应 4.1:本地事务提交后同步重试,失败写异常表,日终对账先查后补、超限转人工;
- 路径二对应 4.2:业务数据与 outbox 事件同事务写入,发布器轮询、CAS 领取、退避重试、死信重投。
mock 服务可以注入 500、超时、慢响应和业务拒绝,用来复现“超时但远端已成功”这类边界情况。启动方式、接口清单和实验剧本见仓库 README。
八、小结
把上面的内容压缩成一份速记清单:
- 回滚有边界:数据库事务只能回滚本地数据,撤销不了远程副作用。
- 失败不等于无副作用:超时、连接重置、对方部分执行,都可能出现”本地已回滚,远程已生效”。
- 先看控制权:外部接口可改造,考虑 TCC;不可改造,接受最终一致性。
- 能移出事务就移出:事务后重试 + 对账适合低失败率场景,Outbox 适合可异步化的场景。
- 必须留在事务内时:分层超时、合理
leaseTime、远程幂等、对账兜底,一个都不能少。
一致性不是一个可以通过”加个事务、加把锁”声明出来的属性。结构能兑现多少,承诺才有多少。