一笔支付可能经过支付、订单、账务、积分和商户通知五个服务,还要调用外部支付渠道。任何一个进程都可能重启,任何一次网络调用都可能超时,但系统不能因为一次超时就重复扣款,也不能因为一个回调丢失就让交易失去踪迹。
本文面向有分布式系统基础、正在接触支付、账户、清算或账务系统的开发者。
文章回答一个具体问题:**一笔金融交易跨越多个边界后,如何让结果最终收敛,并且能够被证明、追踪和修复?**正文讨论分布式事务、最终一致性、TCC、Saga、消息表、状态机和对账的关系;账务模型、并发控制和权限安全只保留边界说明。
一、从一笔支付开始:跨服务到底要收敛什么
本节先建立全文主线。支付服务创建本地事实,只说明“支付意图已经落库”,不说明订单、账务、积分和渠道已经完成相同动作。
这条链路需要守住五个不变量:
- 本地事务提交后的业务意图不能丢。
- 同一业务意图被重复触发时,不能重复产生资金效果。
- 回调、消息和人工操作只能沿着合法状态路径流转。
- 超时表示结果未知,不能直接推断渠道没有执行。
- 本地状态、账务分录、余额和外部账单最终能够相互核对。
因此,分布式一致性不是“调用一次远程接口并等待成功”。它是一条持续收敛的链路:本地事务产生事实,可靠地保存待处理意图,至少一次地传播,消费端幂等处理,状态机推进,定时任务和对账发现缺口,差错通过冲正、补差或人工流程修复。
下面用一笔 100 元支付贯穿后文。用户先在支付服务提交订单,支付服务生成业务键 pay_20260927_0001;渠道可能已经扣款,账务服务可能还没有写入分录,订单、积分和商户通知也可能尚未处理。每个方案都要回答同一个问题:当其中一个步骤在网络超时或进程崩溃时,系统依据什么证据继续推进,依据什么约束避免再次扣款?
二、先把分布式事务、最终一致性和实现手段分层
本节只解决概念混淆。后文的方案选择都依赖这几层的区别。
2.1 分布式事务是问题域
本地事务在一个数据库边界内提供原子性、一致性、隔离性和持久性。分布式事务面对的是跨服务、跨数据库或跨机构的一组相关操作,需要处理网络延迟、进程故障、重复请求和部分成功。
分布式事务不等于强一致事务。它既包括 2PC 这类刚性事务,也包括 TCC、Saga 和可靠消息等柔性事务方案;状态机是常与这些方案配套的运行机制,不是独立的事务协议。
2.2 一致性是目标
强一致主要描述读写对已提交事实的可见性和顺序;参与者像一个整体一样提交或失败,属于原子性和原子提交协议的要求。核心账务内部的短事务、同库的余额和分录更新,需要在同一个本地事务中同时守住原子性与一致性;这不是对跨机构链路的绝对承诺。
最终一致允许系统短时间处于中间状态,但要求在参与者最终可访问、重试链路仍可运行、补偿规则已经定义的前提下,进入约定终态,并且能够验证和修复。订单通知、积分、商户回调和跨机构结果可以采用这个目标;如果外部系统永久不可用或没有可执行的补偿,系统只能进入挂账或人工接管,不能宣称自动最终一致。
BASE 用三个词描述这类取舍:基本可用、软状态和最终一致。它不是“资金可以长期不一致”,而是把等待和恢复交给状态机、重试、补偿与对账。
2.3 手段位于不同层
分布式事务:要解决的问题├── 一致性目标:强一致 / 最终一致├── 协调模式:2PC / XA / 3PC / TCC / Saga├── 消息传播:Outbox / 事务消息 / 最大努力通知└── 运行闭环:幂等 / 状态机 / 重试 / 补偿 / 对账这里的术语不能互相替代。Outbox 解决业务提交后发送意图不能丢,不能替消费者做幂等;TCC 协调资源预留和确认,不能替业务判断退款金额;定时任务推进状态,不能让两个数据库原子提交;对账验证结果,也不等于前面的操作自动正确。
2.4 投递语义为什么会带来重复
投递语义描述消息或请求在发送方与接收方之间如何到达,不直接保证消费者的业务副作用只执行一次。本文把至少一次投递作为金融链路的传输层基线,再用业务幂等约束把重复调用收敛成同一个结果。
假设消费者已经在数据库里完成记账,但进程在确认消息前崩溃。消息系统看不到消费确认,会重新投递;如果消费者先确认、再提交本地事务,进程也可能在两步之间崩溃,造成消息丢失。把确认放在本地事务提交之后,可以避免提前丢消息,但必须接受“业务已提交、确认未送达”时的重复投递。
| 语义 | 发送方的行为 | 主要风险 | 适用判断 |
|---|---|---|---|
| 至多一次 | 不确认或不重试 | 失败时可能丢失 | 可丢弃、可由后续采样覆盖的数据 |
| 至少一次 | 持久化、等待确认、失败重试 | 接收方可能重复处理 | 扣款、记账等必须可恢复的链路,需配合幂等 |
| 恰好一次 | 在特定组件边界内协调投递或处理 | 保证范围容易被误读 | 必须确认它覆盖哪些存储和副作用 |
端到端调用存在不可判定的超时窗口:调用方无法仅凭连接断开分辨“对方没有收到”和“对方已经完成但响应丢了”。消息系统可以在限定边界内提供事务生产或幂等生产能力,但这不自动覆盖消费者数据库、支付渠道和跨机构账务。所谓业务上的“有效一次”,依赖稳定业务键、数据库唯一约束、合法状态转移和后续对账,而不是要求网络只传输一次。
2.5 幂等键、去重记录与状态机各自守什么
幂等键标识业务操作,例如支付请求号或退款单号;消息 ID 只标识一次传输,重新包装同一笔退款时可能变化。消费者应把业务键写入具有唯一约束的业务记录,或与业务变更一起写入 Inbox/去重表。
Inbox 与业务效果要处在同一个本地事务中:若先标记“已消费”再记账,事务外的崩溃会让消息重投时被错误跳过;若先记账再单独标记,崩溃会导致重复记账。把两者放在同一事务后,重复消息命中唯一键,消费者读取既有结果并返回。
去重只回答“这笔业务是否已经处理”,状态机还要回答“现在是否允许处理”。退款操作还需检查原交易状态、累计退款金额和版本顺序;因此幂等键不能取代业务规则,去重表也不能取代状态机。
三、先证明本地事实正确,再谈跨服务收敛
可靠传播只能传播已经落库的事实。如果余额、分录或业务状态在本地就错了,后续重试和消息只会把错误传播得更可靠。
本地边界至少要建立这些约束:
- 余额变更、分录、业务流水和业务状态在正确的本地事务中保持原子性。
- 借贷平衡、科目映射、金额单位、精度、舍入和会计日期有明确规则。
- 业务唯一键和数据库唯一约束阻止重复写入,状态条件更新阻止非法流转。
- 并发扣款、退款和冻结使用行锁、乐观锁或条件更新,不能依赖先查询再写入。
例如,扣款流程不能把“检查余额”和“扣减余额”拆成两个独立操作,也不能让分录在另一个事务中补写。具体账务模型和金额规则见账务模型与金额计算,并发边界见金融并发控制。
这一章的判断可以压缩成一句话:本地事实先正确,可靠外发才有意义。
举例来说,一笔扣款可以把业务占位、余额条件更新和分录写入放在一个数据库事务里。下面是带命名参数的 SQL 伪代码,展示事务边界,不是完整的账户模型:
BEGIN;
INSERT INTO payment_effect (request_id, account_id, amount)VALUES (:request_id, :account_id, :amount)ON CONFLICT (request_id) DO NOTHINGRETURNING request_id;
-- 返回 0 时读取已有结果并回滚本次空操作。UPDATE accountSET balance = balance - :amountWHERE account_id = :account_id AND balance >= :amountRETURNING account_id;
-- 影响行数不是 1 时回滚,不能保留上面的占位记录。INSERT INTO ledger_entry (request_id, account_id, amount, entry_type)VALUES (:request_id, :account_id, :amount, 'debit');
COMMIT;这段 PostgreSQL 风格 SQL 省略了应用层的分支代码。第一次 INSERT 返回 0 时,读取已有业务结果并回滚本次空操作,同时核对账户、金额和币种是否与原请求一致;参数不一致必须拒绝,而不能把同一个请求号当成新的扣款。
余额更新返回 0 时,回滚整个事务,连同刚写入的占位记录一起撤销。只有两个检查都通过,才插入分录并提交。具体实现还要按数据库方言处理并发冲突和返回结果,关键约束是占位、条件扣减、分录与状态更新共用同一个本地事务。
四、分布式事务方案全景:刚性协调与柔性协调
本节建立方案地图,后面再解释每种方案的适用边界。
4.1 刚性事务:2PC、XA 与 3PC
2PC 把提交分为准备和提交两个阶段。准备阶段中,协调者向参与者询问是否可以提交;参与者执行本地变更但不提交,把恢复所需的日志持久化后回复 YES。协调者只有在所有参与者都准备成功后,才写入并广播全局提交决定;任意参与者拒绝或准备超时,则广播回滚决定。
2PC 的关键代价是参与者在准备后必须保留锁和临时资源,直到知道全局决定。故障窗口可以这样看:
| 故障位置 | 参与者看到的状态 | 恢复问题 |
|---|---|---|
| 某参与者准备失败 | 其他参与者可能已经准备 | 协调者广播回滚,释放已准备资源 |
| 协调者准备阶段宕机 | 参与者等待决定并持有资源 | 需要从协调者日志恢复,期间可能阻塞 |
| 协调者发出提交后宕机 | 部分参与者已提交,部分未收到决定 | 参与者无法仅靠本地超时判断全局决定 |
因此,2PC 的目标是原子提交协议,不是“网络断开后仍然自动正确”的保证。参与者需要持久化 prepare/commit 状态,协调者需要可靠日志和恢复流程;即使如此,长事务锁、协调者恢复和跨机构资源不可锁定仍然限制了它在金融外部链路中的适用范围。
以“订单状态和内部账务分录必须同时提交”为例,2PC 的正常路径如下。这里的两个参与者都在本组织可控制的数据库内,协调器可以读取事务日志并恢复决定:
如果账务库在 Prepare 阶段返回 NO,协调器会让订单库回滚;如果协调器在发出 Commit 后宕机,订单库和账务库可能暂时看见不同状态,恢复流程必须从协调器日志重建全局决定。这个例子说明了 2PC 的适用边界:它适合短事务和可控参与者,不适合把外部支付渠道也锁进同一条事务。
XA 是数据库和事务协调器之间的标准接口,适合边界明确、执行时间较短的内部事务。把 XA 跨到外部支付渠道或跨机构网络,会遇到资源不可锁定、接口不支持事务协议和故障恢复边界不清等问题。
3PC 在 2PC 中增加 CanCommit、PreCommit 和 DoCommit 阶段,并给协调者和参与者加入超时行为,试图减少协调者故障带来的无限等待。超时只能依据协议状态做推断;网络分区或参与者状态不一致时,仍可能出现不同参与者做出不同决定。金融系统更常见的做法是缩小强一致边界,再用柔性方案处理跨服务部分。
4.2 最终一致性方案地图
下表把跨服务协调、消息传播和运行补偿放在同一张地图中,但它们不是同一种协议:TCC 和 Saga 负责业务协调,Outbox 和事务消息负责可靠传播,状态机和定时任务负责持续推进与修复。
| 方案 | 所在层次 | 主要目标 | 是否预留或锁定资源 | 业务侵入 | 适合场景 |
|---|---|---|---|---|---|
| 2PC / XA | 刚性协调 | 强一致提交 | 是 | 较低 | 同库或内部短事务 |
| TCC | 业务协调 | 预留后确认或取消 | 是 | 高 | 资金冻结、额度预占 |
| Saga | 业务协调 | 多个本地事务加补偿 | 否 | 中 | 长流程、多步骤业务 |
| Outbox | 消息传播 | 可靠保存发送意图 | 否 | 中 | 本地事务后的事件发送 |
| 事务消息 | 消息传播 | 协调事务与消息投递 | 否 | 中 | 已有消息基础设施的异步场景 |
| 最大努力通知 | 消息传播 | 尽量送达外部接收方 | 否 | 较低 | 商户通知、第三方回调 |
| 状态机加定时任务 | 运行补偿 | 推进和修复中间状态 | 视业务而定 | 中 | 重试、补偿、巡检、兜底 |
五、补偿型事务:TCC 与 Saga
补偿型事务把跨服务协作拆成业务动作。它们依赖参与者提供可重复执行的确认或补偿接口,因此业务侵入比 XA 更高。
5.1 TCC:先预留,再确认或取消
TCC 把一个操作拆成三个接口:
Try:冻结金额或预占额度Confirm:确认使用预留资源Cancel:释放预留资源以转账为例,Try 可以冻结付款账户的金额,同时预留收款侧额度;所有参与者的 Try 成功后,协调器调用 Confirm;任意一步无法预留,则调用 Cancel 释放已经占用的资源。
TCC 的关键不是接口数量,而是每个阶段都要面对重复、乱序和超时:
Try、Confirm和Cancel都必须幂等。Cancel可能在Try没有到达时先执行,这叫空回滚。Cancel先完成后,迟到的Try不能再创建资源占用,这叫悬挂。Confirm或Cancel失败时,需要重试、告警和人工接管。
空回滚和悬挂来自同一个竞态:协调器调用 Try 后等待超时,可能发起 Cancel;网络中延迟的 Try 随后才到参与者。如果参与者把“没有 Try 记录”简单当成取消成功,却没有留下已取消标记,迟到的 Try 就会创建一份本应释放的冻结。
参与者需要按全局事务号或业务操作号持久化分支状态。Cancel 找不到 Try 记录时写入取消墓碑并幂等返回;后到的 Try 检查墓碑后拒绝预留。墓碑的保留期要覆盖最大网络延迟、协调器重试窗口和人工恢复窗口,不能在任务成功后立即清理。Confirm 和 Cancel 也要用条件状态转换保证只能从允许的分支状态执行,重复请求返回已有结果。
协调器 资源参与者 |---- Try ------------->| 请求在网络中延迟 | 超时,发起 Cancel | |---- Cancel ---------->| 未见 Try,记录已取消标记 | | |---- 延迟的 Try ------>| 检查标记,拒绝创建冻结TCC 适合能明确表达“预留”和“释放”的资源。它并没有消除中间状态:冻结会占用可用额度,协调器故障会延长冻结时间,参与者必须监控长时间停留在 TRY 的分支并提供可审计的恢复入口。外部渠道如果只提供一次扣款接口,不能仅靠 TCC 框架把它变成可取消操作。
以转账 100 元为例,付款账户先冻结 100 元,收款账户预留入账额度;两边都 Try 成功后才 Confirm。如果收款账户额度不足,付款账户收到 Cancel,解冻 100 元。与 2PC 相比,TCC 把“锁住数据库资源”改成业务层的冻结记录,因此其他查询可以看到“冻结中”,但不能把这笔钱再次使用。
5.2 Saga:多个本地事务加补偿动作
Saga 把长流程拆成多个已经提交的本地事务,每一步都有对应补偿动作。比如放款流程可以依次完成审批、额度占用、放款指令和通知;其中一步失败时,协调器按业务规则执行已完成步骤的补偿。
Saga 不需要在整个流程中持有资源锁,适合能够接受中间状态的长流程;补偿也不一定等价于数据库回滚。补偿可能产生退款、冲正或新的会计分录,因此每个正向动作和补偿动作都需要稳定业务键、幂等约束、审计记录和重试边界。
Saga 可以由中央协调器按命令推进,也可以由各服务通过事件编排。步骤少时事件编排较直观,步骤和分支变多后,中央协调器更容易追踪流程和执行补偿;选型应以可观测、可恢复和可测试为准。
Saga 常见的恢复方向有两种。向后恢复在某一步失败后,按相反顺序执行已完成步骤的补偿;向前恢复则保留已完成步骤,修复故障后继续重试后续动作,适用于必须完成且重试最终可能成功的流程。两种方向都需要明确业务终态,不能把补偿调用返回成功等同于原业务已经恢复。
Saga 没有 TCC 的预留阶段,也就没有跨步骤的隔离保证。两个并发流程可能同时读取同一额度并各自继续;要通过本地条件更新、资源预占、版本检查或业务串行化防止写偏差。补偿动作也可能失败或只部分抵消原动作,例如已发出的通知不能撤回,已完成的外部转账需要追加反向交易。因此补偿记录应关联原步骤,支持重试与人工处置,不覆盖历史事实。
以贷款放款为例,审批、额度占用、放款指令和通知可以分别提交本地事务。放款指令失败时,Saga 不会回滚已经提交的审批记录,而是执行“释放额度”或“撤销放款申请”等补偿动作:
如果银行已经受理放款指令但响应超时,系统不能直接执行释放额度;应先查询银行结果,把“已受理但本地未知”保留为中间态。这个例子体现了 Saga 的边界:补偿动作改变业务事实,不会让外部世界回到事务开始前的瞬间。
六、消息型事务:Outbox、事务消息与最大努力通知
消息方案把“业务提交”和“通知下游”拆开,但会把重复、乱序和消费失败交给下游处理。可靠消息的终点不是消息到达,而是业务效果正确。
6.1 Outbox:把业务事实和发送意图绑在一个本地事务中
本地消息表和 Outbox 属于同一类模式:应用在业务库中维护一张与业务事务同库的待发送事件表,再由转发器异步投递。“本地消息表”更强调用业务表和消息表配合轮询,Outbox 更强调事件是业务事务提交后的外发记录;两者的可靠性边界相同,都无法消除重复投递。
一条典型流程如下:
- 服务在本地事务中写入业务事实和
Outbox事件。 - 转发器扫描未发送事件,把事件投递到消息系统。
- 消费者用业务幂等键执行业务事务。
- 消费成功后,消费者确认消息或记录消费状态。
- 发送失败、消费失败或超过重试上限的事件进入退避重试、死信或人工处理。
Outbox 解决的是“业务已经提交,但发送意图没有保存”的问题。转发器可能已经发送成功,却在标记事件已发送之前崩溃,因此 Outbox 本身仍然是至少一次投递,下游仍然必须幂等。
两个崩溃窗口决定了它为何需要和消费者幂等配套:
| 崩溃位置 | 恢复后的可见事实 | 安全恢复方式 |
|---|---|---|
| 业务事务提交前 | 业务行和 Outbox 行都回滚 | 请求重试后重新执行本地事务 |
| 本地事务提交后、转发前 | Outbox 仍是待发送 | 转发器重新扫描并发送 |
| 消息发送成功、Outbox 标记前 | 下游可能已收到,Outbox 仍显示待发送 | 允许重发,下游按业务键幂等 |
| 消费者本地事务提交后、ACK 前 | 业务效果已存在,消息仍可能重投 | Inbox 唯一约束命中,返回既有结果后 ACK |
Outbox 表还需要可运维的生命周期:至少保存事件主键、聚合业务键、事件类型、版本、创建时间、发送状态、重试时间和最后错误。转发器用有界批次领取记录,避免多个实例长期占有或扫描同一批;已发送记录的归档与清理必须晚于审计、重放和排障所需的保留期。
对这笔 100 元支付,支付服务可以在同一个本地事务中写入支付单和 PaymentSucceeded 事件。转发器即使在发送成功后宕机,也只会让同一事件再次进入队列;账务消费者用支付流水号做唯一键,第一次写入 100 元分录,第二次读取已有结果后直接确认消息。
6.2 事务消息:缩小本地事务与消息之间的空窗
事务消息一般通过预提交、执行本地事务、确认投递和回查等步骤,协调本地事务结果与消息状态。它适合已有可靠消息基础设施的异步场景,但保证范围必须看清:消息系统的事务能力不自动覆盖消费者数据库,更不覆盖外部渠道。
抽象流程可以画成这样,具体消息系统对“半消息”和“回查”的命名会不同:
如果生产者在本地事务完成后宕机,消息系统只能通过回查或人工接口判断该消息应提交还是回滚;如果消费者提交业务事务后没有确认,消息仍可能重复投递。因此事务消息缩小的是“本地事务与消息之间”的空窗,不是全链路的恰好一次。
看到 Exactly-once 时,继续确认它指的是传输次数、生产端写入,还是包括消费者业务效果。金融系统更常见的组合是:持久化的至少一次投递,加上消费者幂等、合法状态转移和对账修复。
6.3 最大努力通知:外围链路的送达策略
商户通知、第三方回调和短信邮件不适合阻塞核心账务。通知服务可以按退避策略重试,超过上限后进入差错池,并提供主动查询或重新发送入口。
最大努力通知不能替代外部渠道的主动查询和账单对账。回调丢失时,系统必须能够从渠道查询或账单中重新获得外部事实。
七、状态机与定时任务:最终一致性的运行闭环
定时任务不是分布式事务协议。它用本地持久化意图、至少一次调用、下游幂等或可查询、状态机和补偿,把中间状态持续推进到可解释的终态。任务处理的是“本地保存但未确认完成的意图”,不是凭空修复任何数据库之间的原子性。
7.1 用显式状态表示事实边界
支付、退款和转账至少要区分待处理、处理中、成功、明确失败和未知结果:
PENDING ↓PROCESSING ├── SUCCESS ├── FAILED └── UNKNOWN ↓ 查询 / 重试 / 对账 / 人工接管UNKNOWN 不是 FAILED 的别名。调用方超时只说明没有拿到结果,渠道可能已经完成扣款。把未知状态直接改成失败,会让下一次补偿重新发起不可逆操作,形成重复扣款风险。
状态机不能只列出状态名,还要写出事件、前置条件和副作用。以支付单为例,至少需要约束以下转移:
| 当前状态 | 事件 | 必要条件 | 下一状态 | 允许的副作用 |
|---|---|---|---|---|
PENDING | 发起渠道请求 | 请求版本仍是当前版本 | PROCESSING | 记录一次发送尝试 |
PROCESSING | 渠道明确失败 | 返回码属于可判定失败集合 | FAILED | 记录渠道失败证据 |
PROCESSING | 渠道成功 | 渠道金额、币种和业务键匹配 | CHANNEL_SUCCESS | 保存渠道成功事实 |
CHANNEL_SUCCESS | 账务记账成功 | 分录平衡且业务键唯一 | SUCCESS | 写入账务关联和成功事件 |
PROCESSING | 调用超时 | 没有足够证据判断结果 | UNKNOWN | 保存请求摘要,不发起新业务键 |
SUCCESS | 迟到失败回调 | 回调版本不新且与终态冲突 | SUCCESS | 只记录冲突,不改写终态 |
CHANNEL_SUCCESS 这类中间状态用于保存外部事实与本地账务之间的间隔。若把渠道成功直接写成 SUCCESS,订单、积分和通知可能先于账务完成,查询方也无法区分“渠道已扣款但本地未记账”和“钱已经完整入账”。状态转换应带版本或条件,更新影响行数为零时读取当前状态并判断它是否已经由另一条路径完成。
状态机图可以进一步表达“查询结果”和“补偿结果”如何回到同一条路径:
SUSPENSE 表示事实已经发生但本地账务尚未完成,REVERSED 表示系统通过反向分录完成修复。它们不是为了让状态枚举更丰富,而是为了让查询、重试、对账和人工处理知道下一步应该依据哪份证据行动。
7.2 定时任务的完整流程
- 本地事务写入业务记录和待处理意图,状态设为
PENDING,同时保存业务幂等键、重试次数和next_retry_at。 - 任务按状态和时间窗口批量领取到期记录,通过有期限的租约或条件更新标记为
PROCESSING,并保存领取者和租约到期时间。 - 任务调用下游接口,携带同一个业务幂等键;下游成功后,本地以状态条件更新为
SUCCESS。 - 明确业务拒绝进入
FAILED;连接超时或无响应进入UNKNOWN,后续先查询相同业务键的结果,不创建新的资金操作。 - 进程若在领取后崩溃,租约到期后其他实例可以重新领取;旧实例恢复时,必须用版本或领取令牌做条件更新,防止它覆盖新实例的处理结果。
- 超过重试上限的记录进入差错池或人工处理状态,不能把“重试次数用完”当作资金动作一定失败。
任务至少需要这些工程约束:
- 用
(status, next_retry_at)或适配实际查询的等价索引筛选窗口,避免高频全表扫描。 - 每次领取有限批次,多实例通过租约、行锁或条件更新减少重复领取;租约只能协调任务执行,不能代替下游幂等。
- 记录最后错误、下次执行时间和处理版本,使用指数退避并设置上限。
- 补偿调用复用正式接口的幂等和状态检查,不能因为“这是补跑”就绕过校验。
- 监控中间态超时、堆积量、死信数量和人工处理时长。
PROCESSING 不能无限期占住记录。租约时长应覆盖正常处理窗口并允许续租;任务重启后扫描到期租约,再以条件更新接管。远程调用期间租约也可能过期,所以两个任务可能短暂并发调用同一操作,安全性最终仍依赖下游业务幂等键。
7.3 CDC:从数据库日志到业务事件
CDC(Change Data Capture,变更数据捕获)解决的是“数据库里的已提交变更,怎样尽快送到下游”这个问题。应用照常提交本地事务,数据库同时把变更写入自己的事务日志,例如 MySQL 的 Binlog、PostgreSQL 的 WAL。
CDC 采集器读取这些日志,解析出表、主键和 INSERT、UPDATE、DELETE 等行变更,再把变更发送到消息系统。采集器会保存自己读到的位置,通常称为位点或 offset,以便重启后从断点继续。Debezium、Canal 和 Flink CDC 都属于这类实现,具体的事务边界、位点和投递语义要以所用产品的配置为准。
Binlog 和 WAL 首先服务于数据库自身的复制与恢复,它们不是业务消息队列:日志格式描述的是数据库行如何变化,不会直接说明“用户完成了一次支付”;数据库也会按保留策略清理旧日志。CDC 把短期数据库日志转成下游可消费的消息,并维护读取进度,但不会因此让源日志永久保留,也不会自动补全业务语义。
按时间顺序,这条链路发生了五件事:
- 支付服务在本地事务中把支付单从
PAYING更新为CHANNEL_SUCCESS,事务提交。 - 数据库把这次已提交的行变化写入 Binlog 或 WAL。
- CDC 采集器从自己的位点继续读取日志,把变化包装成消息并发送到主题。
- 账务消费者收到消息,在自己的本地事务中检查业务键、版本和状态,再写入分录。
- 采集器或消费者在故障后从可恢复位置继续,因此同一变化可能再次到达,消费者必须把重复处理收敛到原来的结果。
图里的关键边界是:只有数据库事务提交后,变更才应作为 CDC 对下游发布的输入;CDC 把数据库变更送出去,但不替下游完成业务事务。它不是一个跨库提交协议,也不会自动判断“这次行更新代表扣款成功”这样的业务含义。
用一笔 100 元支付走一遍
假设支付服务调用渠道成功后,在一个本地事务里把支付单从 PAYING 改为 CHANNEL_SUCCESS,并递增版本号。事务提交后,数据库日志里出现这次已提交的行变更。CDC 读到日志,解析并发布事件;账务消费者收到后,以 payment_id 和版本号检查是否已经处理,再执行自己的本地事务。
下面只是帮助理解的事件形状,不是某个 CDC 产品规定的协议:
{ "event_id": "payment-20260927-0001-v3", "source": "payment_db", "table": "payment_order", "operation": "UPDATE", "key": { "payment_id": "pay_20260927_0001" }, "before": { "status": "PAYING", "version": 2 }, "after": { "status": "CHANNEL_SUCCESS", "amount_minor": 10000, "currency": "CNY", "version": 3 }}账务消费者不能只看到 UPDATE 就直接记账。它还要确认支付单属于哪个商户、金额和币种是否匹配、当前支付状态是否允许记账,以及这个支付单是否已经有账务分录。若同一事件再次到达,唯一约束或消费记录应让它返回已有处理结果;若版本跳跃或状态不合法,则应暂停推进并进入重试或差错处理,不能把异常当作成功确认。
CDC、Outbox 和定时扫表各负责什么
这三种方式常一起出现,但捕获对象和恢复手段不同:
| 方式 | 捕获什么 | 主要优点 | 主要约束 |
|---|---|---|---|
| Outbox | 应用在本地事务中明确写入的业务事件 | 事件语义由业务定义,能与业务数据同事务提交 | 需要应用写 Outbox,并处理发布、清理和重放 |
| CDC | 数据库日志中的行变更 | 采集延迟低,对已有业务写路径改动较少 | 需要维护日志、位点、Schema 兼容和消费者语义 |
| 定时扫表 | 满足条件的业务状态或待处理记录 | 逻辑直观,容易按业务条件重试和修复 | 有轮询延迟,需要索引、抢占和并发控制 |
常见组合是“Outbox + CDC”:业务事务写入订单和 Outbox 事件,CDC 只负责把 Outbox 表的新增记录送到消息系统。这里 Outbox 负责明确“要发布什么业务事件”,CDC 负责读取和传输;CDC 本身并没有替应用补上事件语义。
另一种做法是直接采集业务表,再由消费者把行变化映射为事件。它改动较少,但表结构变更、字段复用或一笔业务涉及多行时,消费者更容易误解业务含义。
故障后为什么会重复,也可能出现缺口
CDC 链路需要按“至少可能重复、也必须能恢复”的方式设计。具体投递和位点确认语义取决于采集器、消息系统及其配置,不能只凭“用了 CDC”就推断不会丢消息。
| 故障窗口 | 可能结果 | 应对方式 |
|---|---|---|
| 消息已发布,采集器还没保存新位点就崩溃 | 重启后从旧位点读取,同一变更再次发布 | 消费者按业务键和版本幂等处理 |
| 消费者已提交本地事务,但确认消息前崩溃 | 消息重新投递,业务处理可能再次执行 | 消费记录与业务写入放在同一本地事务,或用唯一约束保护 |
| 采集器停机太久,所需日志已被数据库清理 | 采集器无法从原位点续读,可能形成变更缺口 | 监控日志保留窗口;从一致性快照重新初始化,再用持久业务事实回补和核对 |
| 表结构或字段含义变化,解析器或消费者仍按旧规则处理 | 解析失败,或更危险地把新数据解释错 | 版本化事件契约,做兼容检查,隔离错误消息并告警 |
数据库日志的保留时间必须覆盖故障发现、修复和恢复所需时间。若位点已落在日志保留窗口之外,不能随便跳到最新位置继续,因为中间变更可能永远没有机会发送。
重建一致性快照只能恢复快照时刻的当前行状态,不能还原期间发生后又被覆盖的状态变化;若这些变化本身有业务意义,还需要从 Outbox、不可变业务流水或外部账单等持久事实中回补,再核验差异。数据库主从切换、采集器升级和 Schema 变更也应纳入演练。
还要注意事件顺序。若同一个支付单先变为 CHANNEL_SUCCESS,随后又变为 REVERSED,消费者不能把旧版本的成功事件当成最新状态覆盖冲正结果。事件应携带稳定业务键和单调版本,消费者拒绝旧版本;需要跨表事务语义时,则应确认所用采集器能提供事务边界信息,不能假定每条行变更都可独立解释。
CDC 的边界与兜底
CDC 适合需要低延迟传播数据库状态、并且团队已经具备日志采集和消息运维能力的链路。它不负责消费者幂等,也不能替代定时巡检和对账:
- 定时巡检检查本地业务状态,例如支付单长期停留在
CHANNEL_SUCCESS、账务分录却不存在,用于发现内部处理停滞。 - 对账使用渠道流水、银行文件或账务明细等独立事实,发现本地数据库和事件链路都没有证明的外部结果。
- 补偿依据核实后的业务结果补记、冲正或转入人工处理,不能仅凭“CDC 没消息”推断支付失败。
因此,CDC 主要缩短“数据库变更到下游开始处理”的时间;Outbox 明确要传播的业务意图;扫表负责发现和推进卡住的状态;对账验证最终结果。资金链路需要根据业务风险组合这些机制,而不是把 CDC 当作一致性保证本身。
八、金融落地:幂等、状态、重试和事务边界
这一章把前面的方案落到资金链路的工程约束上。
8.1 幂等键必须代表业务操作
支付请求号、支付流水号、退款单号和业务事件主键可以识别“同一笔操作”。随机消息 ID 只能识别某次传输,不能识别业务被重新包装后再次发送。
幂等由三层共同完成:数据库唯一约束拒绝重复写入,状态机拒绝重复推进,业务规则检查累计金额和可操作范围。缓存锁可以减少重复请求压力,但会过期、丢失或在网络分区时失效,不能替代数据库约束。
8.2 状态机要拒绝乱序和非法转移
状态机需要明确哪些状态是中间态,哪些状态是终态。旧回调不能覆盖新状态,普通重试不能逆转成功、冲正或人工接管等终态;非法转移应进入差错池,而不是静默丢弃。
回调、主动查询、消息消费和人工处理应共用同一套状态收敛规则。这样才能保证不同入口不会各自解释一套状态语义。
8.3 重试要有边界
重试使用指数退避、最大次数和死信或差错状态。对于外部副作用,重试前必须确认幂等键是否保持不变,或者先查询上一次请求的结果;更换业务键会把一次不确定操作变成两次独立操作。
本地事务中不要批量调用远程服务或支付渠道。更稳妥的边界是:本地事务写入事实和待处理意图,提交后异步调用外部服务,把成功、失败或未知结果落库,再由补偿链路继续推进。
8.4 审计让结果可以被解释
资金链路至少需要保存业务流水号、请求摘要、状态版本、操作来源、操作者、规则版本和全局 TraceID。原始回调、渠道响应和人工处理理由应可关联、可查询、可重放。
回调入口还必须验证签名、时间窗口和商户/渠道绑定,并用回调事件 ID 或业务键防止重放;验签成功后先持久化原始报文和接收时间,再进入状态收敛,不能把“收到 HTTP 200”当成业务处理成功。冲正、补记账和人工强制推进等修复动作需要最小权限、审批或双人复核,并把原状态、目标状态和修复理由写入不可随意覆盖的审计记录。
权限、密钥轮换、双人复核和紧急开关的完整设计属于另一条安全防线,详见权限审计与安全防线。
九、对账、冲正与差错处理:最终一致性的金融底线
状态进入成功,不等于金额一定正确。对账要独立于消息发送和定时任务日志,使用内部和外部事实验证结果。
9.1 三层对账
对账至少要同时看业务系统、外部系统和账务时间三个维度:
- 系统内对账:订单、支付流水、账务分录和余额是否相互一致,检查漏记、重复记账和余额漂移。
- 系统间对账:本地流水与支付渠道、银行、清算机构或商户账单是否一致。
- 时间维度对账:实时或准实时检查处理中异常,T+1 对账覆盖完整账务日,结算对账验证最终资金流向。
9.2 差异进入工作流
对账发现差异后,不应直接修改余额或覆盖历史状态。差异应进入可追踪的差错池,再按类型执行主动查询、补单、重试、冲正、补差、退款或人工复核。
冲正不是删除错误流水,而是增加一笔有来源、有方向、有审批记录的反向分录。原始交易、差异证据、修复动作和最终结果必须保留,修复动作自身也要有幂等键。
外部渠道一致性的状态、回调、主动查询和账单模型见外部渠道一致性与对账。
9.3 为什么 CDC 和定时任务都不能替代对账
渠道可能已经扣款,但回调没有到达,本地状态没有变化,CDC 和业务表扫描都可能找不到这笔外部事实。只有主动查询或渠道账单,才能发现本地没有记录的成功。
因此,对账不是“定时任务再跑一遍”,而是用另一组事实来源验证系统是否真的做对。没有对账的最终一致,只是对链路正常工作的假设。
例如,渠道账单显示 pay_20260927_0001 已扣款 100 元,但本地支付单仍为 UNKNOWN,账务分录也不存在。对账任务不能直接把余额加 100 元;它应先把差异写入差错池,核对渠道金额、币种和业务键,再选择补记账或冲正,并记录原始账单行和修复动作:
十、这条一致性骨架解决什么,解决不了什么
本章主线解决的是事件链路的可靠性:本地意图可恢复,消息或任务可以重试,重复触发不会重复产生同一业务效果,状态能够沿合法路径推进,差异最终能进入对账和修复流程。它不是“钱一定正确”的充分条件。
至少有四类问题需要单独治理:
| 问题 | 为什么一致性骨架不够 | 还需要什么 |
|---|---|---|
| 计算错误 | 金额、手续费、汇率、舍入或会计日期算错后,可靠传播只会扩大错误 | 账务模型、金额精度、公式测试、借贷平衡和业务不变量 |
| 并发覆盖 | 两个请求可能带着旧版本同时进入本地事务,消息幂等不能替代数据库并发控制 | 条件更新、行锁、乐观锁、隔离级别和并发测试 |
| 外部事实不一致 | 渠道成功但回调丢失,内部任务可能根本看不到外部成功 | 主动查询、渠道账单、差错池、挂账、冲正和补差 |
| 人为与权限风险 | 越权、重放、误操作和密钥泄露不是投递失败 | 最小权限、双人复核、审计、防重放、风控和紧急开关 |
因此,“本地事务 + 幂等 + 状态机 + 重试”是必要骨架,不是资损防线的全部。状态机如果允许终态逆转,幂等键如果不能稳定识别业务,补偿如果绕过金额边界,系统仍然会产生错误结果。
十一、完整案例:支付成功后的跨服务一致性
这一节把前面的概念放回同一条支付链路,观察正常路径和故障路径如何处理。
11.1 推荐的正常路径
- 支付服务在本地事务中创建支付单,写入
PENDING。 - 同一事务写入
Outbox事件,记录支付流水号和业务版本。 - 渠道适配服务携带稳定幂等键发起支付。
- 渠道返回成功、明确失败或未知结果,支付服务记录渠道事实;渠道成功不直接等于支付最终成功。
- 账务服务在自己的本地事务中完成分录和余额更新,支付状态机在渠道事实与账务结果都满足条件后推进最终状态。
- 订单服务更新订单;积分和商户通知通过消息异步消费。
- 定时任务处理长时间停留在中间态的记录。
- 内部和外部对账验证支付、订单、账务与渠道结果。
11.2 故障矩阵
| 故障 | 不能直接推出的结论 | 正确动作 |
|---|---|---|
| 渠道调用超时 | 渠道一定失败 | 查询渠道状态,保留 UNKNOWN |
| 消费者处理成功后崩溃 | 消息只会消费一次 | 允许重复投递,用幂等键挡住重复效果 |
Outbox 已发送但发送状态未更新 | 事件只能发送一次 | 允许重复发送,下游幂等处理 |
| 渠道回调丢失 | 渠道没有扣款 | 主动查询或执行渠道对账 |
| 积分服务失败 | 支付必须回滚 | 重试积分或进入补偿队列 |
| 账务与渠道金额不一致 | 直接修改余额即可 | 进入差错池,执行冲正、补差或人工复核 |
这条链路的核心取舍是:核心账务内部守住本地强一致,订单、积分和通知采用最终一致;所有异步入口共享幂等和状态机;最终结果由对账独立验证。
十二、常见误区:组件不等于保证
以下判断在评审设计或排查故障时应逐条验证:
- 把最终一致理解成可以长期不一致。
- 把超时直接当成失败。
- 把缓存锁当成完整幂等方案。
- 把重试次数多等同于可靠。
- 把补偿当成数据库回滚。
- 把定时任务称为分布式事务。
- 把消息系统的
Exactly-once当成跨服务、跨数据库和外部渠道的全链路恰好一次。 - 在本地事务中调用远程渠道。
- 不设计
UNKNOWN状态。 - 没有对账、差错池和人工接管路径。
- 用修改或删除历史记录代替冲正。
十三、从业务需求推导一致性方案
读完这些模式后,真正需要做的不是背下“什么场景选 TCC”,而是把一笔具体交易拆成事实、边界、失败和证据。可以继续用 pay_20260927_0001 这笔 100 元支付演练下面的六步。
13.1 先画事实边界,不先选中间件
把一次支付拆成可以独立证明的事实:
| 事实 | 谁负责写入 | 写入后能证明什么 | 不能证明什么 |
|---|---|---|---|
支付单 PENDING | 支付服务 | 用户意图和金额已落库 | 渠道是否扣款 |
| 渠道请求记录 | 渠道适配服务 | 使用了哪个幂等键发起请求 | 渠道是否已处理 |
| 渠道成功凭证 | 渠道回调或主动查询 | 渠道确认扣款 100 元 | 本地账务是否已记账 |
| 账务分录 | 账务服务 | 本地借贷和余额变更已提交 | 商户是否收到通知 |
| 对账匹配记录 | 对账服务 | 两组独立事实已经匹配 | 未来不会再出现新差异 |
这张表会直接暴露错误的事务边界:如果系统只有一个“支付成功”字段,却无法区分渠道成功和账务成功,后续重试、查询和人工处理就没有可靠依据。
13.2 再给每个事实写不变量
对这笔支付,至少要写出以下可以被数据库或任务验证的约束:
pay_20260927_0001只能对应一笔 100 元、一个币种和一个商户订单。- 渠道成功凭证的业务键、金额和币种必须与支付单匹配。
- 同一支付流水最多生成一个有效账务记账批次(可能包含成组的借贷分录),重复消费只能读取已有结果。
SUCCESS只能在渠道事实和账务事实都存在后进入,迟到失败回调不能覆盖它。- 渠道成功但账务失败时进入
SUSPENSE或差错池,不能静默回到PENDING再发起一次扣款。
如果某条规则无法写成唯一键、条件更新、状态转换条件或对账规则,它还只是口头要求,不能作为一致性设计的依据。
13.3 按动作性质选择协调方式
对每个跨边界动作问三个问题:资源是否需要先占住?动作是否能被业务补偿?结果是否必须低延迟传播?
| 问题答案 | 优先考虑 | 100 元支付中的例子 |
|---|---|---|
| 需要预留资源,之后确认或释放 | TCC | 冻结余额、预占额度 |
| 步骤很长,每步都有业务补偿 | Saga 或显式状态机 | 审批、放款、通知 |
| 本地事实提交后必须产生事件 | Outbox 或事务消息 | 支付成功事件、账务入账事件 |
| 外部系统只提供通知和查询 | 最大努力通知加对账 | 渠道回调、商户通知 |
| 低延迟不是硬要求,先验证可靠性 | 状态字段加定时任务 | 扫描 UNKNOWN 和超时中间态 |
这里的“优先考虑”不是自动选型。还要检查参与者是否支持幂等、查询和补偿;外部渠道只有一次扣款接口时,不能因为内部使用 TCC 就获得取消能力。
13.4 为每个远程动作列出三种结果
不要只写“成功/失败”。对支付请求至少分别定义:
- 明确成功:渠道返回可验证的成功凭证,金额、币种和业务键匹配。
- 明确失败:返回码说明请求没有产生扣款效果,且业务规则允许重试或终止。
- 结果未知:超时、连接断开或响应解析失败,无法判断渠道是否已经执行。
然后为 UNKNOWN 指定唯一收敛路径:先用同一业务键查询,查询不到时等待渠道账单或人工复核。禁止把未知结果直接改成失败,再用新业务键重新扣款。
13.5 用故障注入验证设计,而不是只读流程图
对 pay_20260927_0001 至少模拟以下故障,并写出数据库最终应该留下什么:
| 故障注入点 | 应留下的证据 | 下一步动作 |
|---|---|---|
| 写支付单后进程崩溃 | 支付单和待发送事件同事务存在或同时不存在 | 重试本地请求或转发事件 |
| 渠道已扣款,响应前连接断开 | 请求记录,状态 UNKNOWN | 用原业务键查询,不新建扣款 |
| 消费者记账后、ACK 前崩溃 | 账务分录已存在,消息仍可重投 | 唯一键命中后返回既有结果 |
| 账务服务长时间失败 | 渠道成功凭证,状态 SUSPENSE | 重试补记账或进入差错池 |
| 回调乱序到达 | 原始回调和版本信息 | 拒绝旧事件改写终态 |
| 渠道账单出现本地未知流水 | 原始账单行和差错项 | 查询、补记账、冲正或人工复核 |
如果设计无法为某个故障指出“哪条记录证明发生过什么”和“哪个动作可以安全重试”,就不能宣称它具备最终一致性。
13.6 再决定是否引入更重的组件
完成前五步后再比较组件成本。检查定时扫描是否满足发现延迟,Outbox 表是否能承受事件量,CDC 的日志保留是否覆盖恢复窗口,消息系统是否提供可操作的死信和回放,TCC 协调器是否能持久化全局决定。
组件只改变触发、投递和协调方式,不改变幂等键、状态机、未知结果和对账这些业务约束。设计文档最终应能回答五个问题:事实写在哪里,重复如何处理,未知如何查询,差异如何修复,谁负责在终态前继续推进。
结语:可靠性是一条可验证的收敛链路
分布式事务描述跨服务协作的问题,最终一致性描述结果如何收敛。TCC、Saga、Outbox、事务消息和定时任务分别处理资源协调、长流程补偿、事件保存、消息投递和状态推进。
金融系统的可靠链路还需要两道边界:本地账务事实先满足不变量,最终结果再由内部和外部对账独立验证。核心账务守住本地强一致,外围服务接受最终一致;任何未知结果都要进入查询、补偿、冲正或人工处理流程。
这条骨架不能替代金额规则、并发控制、渠道治理和权限审计。它解决的是事件不丢、重复不产生错误效果、状态最终可解释,以及差异能够被发现和修复。
参考资料
- Transactional outbox:用于核对 Outbox 的本地事务、转发重试和重复消息边界。
- Apache Kafka Message Delivery Semantics:用于核对至少一次、至多一次和限定边界的恰好一次语义;具体保证仍需结合消费者和外部副作用判断。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
