Appearance
外部渠道调用中的数据一致性问题:如何实现最终一致性
本文从真实失败案例出发,推导外部渠道调用过程中为什么需要调用记录、状态建模、幂等控制、状态确认、数据对比以及补偿机制。
1. 一个支付订单为什么会出现两边数据不一致
1.1 从一个看似简单的支付流程开始
以第三方支付渠道创建支付订单为例,一个最常见的流程是:
用户发起支付 -> 本系统创建订单 -> 调用支付渠道创建支付单 -> 根据结果更新订单状态。
如果只看成功路径,这个流程并不复杂:
- 本系统保存订单;
- 调用渠道创建支付单;
- 渠道返回成功;
- 本系统把订单更新为对应状态。
真正的问题出现在失败路径。
外部渠道调用和本地数据库操作不在同一个事务边界里。数据库事务可以保证本地多条 SQL 要么全部成功,要么全部回滚,但无法把第三方系统一起纳入同一个本地事务。
所以实际运行过程中会遇到很多“中间状态”:
- 请求已经发出,但接口超时;
- 渠道已经处理成功,但响应在网络中丢失;
- 本系统已经拿到成功结果,但更新本地订单失败;
- 主动查询、异步通知和人工处理先后到达,返回了不同状态。
这类问题的共同特点是:本系统和外部渠道对于同一个业务事实,暂时产生了不同认知。
本文不是从表结构开始设计,而是从这些失败场景出发,逐步推导为什么需要后面的调用记录、状态模型、幂等、查询、通知、数据对比和补偿机制。
本文重点讨论业务系统中的最终一致性设计,不展开 TCC、Saga 等分布式事务模式,也不展开具体分布式事务协议和消息中间件实现。
1.2 问题一:调用渠道成功,但本地订单保存失败
假设系统采用这样的顺序:
text
调用渠道
↓
保存本地订单如果渠道创建支付单成功,而本地订单保存失败,就会出现一个很危险的状态:
- 渠道存在一笔真实交易;
- 本系统不存在对应订单;
- 后续查询时找不到本地业务入口;
- 再次提交时又不知道渠道是否已经创建过。
此时最麻烦的并不是“数据库插入失败”,而是系统丢掉了用于追踪这笔业务的本地事实。
本质上,这是因为外部系统和本地系统不属于同一个事务边界。
调用外部渠道前,应该先保存本系统能够确认的业务事实。即使后面的外部调用失败、超时或进程中断,本地也至少保留一个可以继续查询、恢复和人工处理的入口。
这直接引出两个最基础的数据对象:
order:保存当前业务事实;channel_request:保存每一次外部调用过程。
1.3 问题二:请求超时后无法确认真实结果
再看一个更常见的场景:
text
发送请求
↓
等待响应
↓
超时很多系统最开始都会把“接口超时”直接当成“接口失败”。
但超时实际上只说明一件事:本系统在指定时间内没有收到确定结果。
它并不能说明:
- 请求没有到达渠道;
- 渠道一定处理失败;
- 渠道没有继续异步处理;
- 渠道没有成功,只是响应没有回来。
例如:
text
本系统发送创建请求
↓
渠道收到请求并创建成功
↓
返回响应
↓
网络异常,响应丢失
↓
本系统超时从本系统视角看,这是一次 timeout。
从渠道视角看,却已经是一笔成功交易。
如果直接把 timeout 记成 FAIL,然后自动重新创建,就可能制造重复交易。
反过来,如果直接把 timeout 当成成功,又可能漏掉真正没有成功的请求。
timeout 不是业务失败,而是“结果暂时无法确认”。因此系统需要显式表达 UNKNOWN 这类不确定状态,并通过后续机制继续确认真实结果。
这又引出两个方向:
数据模型上,要把业务状态和渠道状态分开;
状态确认上,需要主动查询、异步通知,必要时还需要人工处理。
1.4 问题三:重复提交导致重复创建
重复创建并不只发生在自动重试。
用户连续点击两次提交、浏览器重发请求、上游系统重复调用,都可能让同一个业务意图被执行多次。
例如:
text
第一次 CREATE
↓
渠道成功
第二次 CREATE
↓
渠道再次成功如果系统没有业务级幂等控制,就可能在渠道侧形成两笔交易。
这里真正需要避免的不是“相同 HTTP 请求重复出现”,而是:
同一个业务意图被执行多次。
因此,系统必须有一个稳定的业务唯一号,例如 order_no,用来表示“这是同一笔业务”。
后续的渠道创建、状态查询、通知处理、数据对比,都应该围绕这个业务唯一号建立关联。
1.5 问题四:多个来源更新同一业务事实导致状态冲突
一个订单的状态可能来自多个来源:
text
创建接口响应
\
主动查询结果 ----> 订单状态
/
异步通知结果这些来源并不会严格按照业务顺序到达。
例如:
text
10:00 创建接口返回 PROCESSING
10:05 主动查询返回 SUCCESS
10:10 延迟通知返回 PROCESSING如果系统采用最简单的写法:
text
update order set status = channelStatus那么 10:10 到达的旧通知反而会把已经确认的 SUCCESS 覆盖回 PROCESSING。
这说明状态更新不能采用“最后到达的数据覆盖前面的数据”这种规则。
真正需要的是:
- 明确状态来源;
- 定义合法状态流转;
- 对并发写入做版本控制;
- 对异常情况保留人工处理和补偿能力。
如果这些问题没有在写入前被阻止,后续还需要通过数据对比和补偿把错误状态修正回来。
2. 调用前保存事实
2.1 先保存本地业务事实
从问题一可以得到一个非常重要的原则:
先让本系统拥有一个可追踪的业务对象,再去调用外部渠道。
也就是说,创建订单时应该先产生本地 order,之后再发起渠道调用。
这样即使后续发生:
- JVM 进程退出;
- 网络异常;
- 渠道超时;
- 更新状态失败;
系统仍然知道“这里存在一笔没有完成确认的业务”。
2.2 order:保存当前业务事实
一个最小化的订单状态模型可以包含:
text
order
order_no
business_status
channel_status
version其中:
order_no:本系统业务唯一号;business_status:当前业务状态;channel_status:当前渠道状态快照;version:用于并发更新时的乐观锁控制。
order 保存的是当前结果,而不是完整过程。
页面查询、列表展示、业务判断,通常都直接读取这里的当前状态。
2.3 channel_request:保存外部调用过程
每一次真正发往渠道的调用,都应该留下单独记录,例如:
text
channel_request
order_no
channel_trade_no
request_type
request_time
response_time
channel_status
error_message其中:
order_no:关联本地业务;channel_trade_no:渠道返回的交易流水号;request_type:CREATE、QUERY 等调用类型;request_time:请求发起时间;response_time:收到响应时间;channel_status:本次调用确认到的渠道状态;error_message:超时、网络异常、渠道错误等异常信息。
order 和 channel_request 是一对多关系:
text
order 1 : N channel_request因为一笔订单可能经历:
- 第一次 CREATE 超时;
- 第一次 QUERY 返回 PROCESSING;
- 第二次 QUERY 返回 SUCCESS。
如果只在订单表保存最后结果,这些中间事实就全部丢失了。
所以这里的原则是:
当前状态保存在主表,过程事实保存在记录表。
这不仅为了排查问题,也是后续自动恢复的重要依据。
3. 业务状态和渠道状态需要分开
3.1 两类状态回答的是不同问题
业务状态回答的是:
“这笔业务目前走到了哪里?”
例如:
- CREATED;
- PAYING;
- PAID;
- CANCELLED。
渠道状态回答的是:
“外部渠道目前确认到了什么结果?”
例如:
- INIT;
- PROCESSING;
- SUCCESS;
- FAIL;
- UNKNOWN。
这两个维度不能混在一起。
例如:
text
business_status = PAYING
channel_status = UNKNOWN它表达的是:
本系统认为业务仍处于支付处理中,但当前无法确认渠道最终结果。
如果只有一个 status 字段,就很难准确表达这种状态。
3.2 UNKNOWN 为什么必须存在
UNKNOWN 不是一个业务失败状态,而是一种“外部事实暂时无法确定”的状态。
它常见于:
- 请求超时;
- 网络中断;
- 响应格式异常;
- 渠道返回不完整;
- 本系统无法判断渠道是否已经完成处理。
有了 UNKNOWN,系统就不必强迫自己在 SUCCESS 和 FAIL 之间立即做出错误选择。
后续可以继续查询,直到事实被确认。
需要注意,UNKNOWN 只用于“结果无法确认”的场景。如果渠道同步、明确返回了失败结果,例如参数校验不通过、余额不足等确定性失败,则可以正常进入 FAIL,不需要再经过 UNKNOWN 和后续状态确认。
3.3 快照和历史记录的关系
order.channel_status 和 channel_request.channel_status 看起来重复,但实际上承担不同职责。
order.channel_status 是当前快照,用于:
- 页面展示;
- 列表查询;
- 当前业务判断。
channel_request.channel_status 是某一次具体调用得到的事实,用于:
- 查看历史;
- 分析失败过程;
- 判断状态来源;
- 后续补偿。
因此不能简单理解为:
“取时间最新的一条 channel_request,直接覆盖 order.channel_status。”
晚到的数据不一定更正确。
订单快照是否能够更新,要经过后面第 6 节的状态流转规则。
4. 业务唯一号和幂等设计
4.1 order_no 必须进入渠道侧
仅仅在本地生成 order_no 还不够。
创建渠道订单时,还需要把 order_no 作为商户侧业务唯一号传递给渠道。
这样渠道侧才能知道:
“这两次请求其实对应同一笔业务。”
后续才能围绕它实现:
- 幂等创建;
- 状态查询;
- 重试判断;
- 通知关联;
- 数据对比。
如果业务唯一号没有传给渠道,一旦同步响应丢失,本地就可能只知道“发过请求”,却不知道渠道创建出的那笔交易究竟对应什么。
4.2 超时后为什么不能直接重试
创建请求超时后,最危险的处理方式是:
text
timeout -> retry CREATE正确流程应该是:
text
timeout
↓
查询原 order_no 在渠道侧是否已经存在
↓
根据真实状态决定下一步可能得到几种结果:
如果渠道已经 SUCCESS,则本地直接根据事实推进状态,不再创建。
如果渠道还在 PROCESSING,则继续等待或稍后查询。
只有明确确认渠道没有创建成功时,才考虑重新发起 CREATE。
这也是为什么“可按业务唯一号查询”是一个非常重要的渠道能力。
4.3 如果渠道不支持按业务唯一号查询怎么办
理想情况下,渠道允许根据我方 order_no 查询。
但现实中并不是所有渠道都支持。
有些渠道的在线查询接口只能根据 channel_trade_no 查询。
这会产生一个问题:
如果创建已经成功,但同步响应丢失,本系统连 channel_trade_no 都没拿到,那么主动查询这条路径就失去了入口。
但这并不意味着后面的数据对比也一定失效。4.1 要求在创建时把 order_no 传给渠道,除了用于幂等和在线查询,还承担一个更长期的作用:让本系统和渠道能够在同步响应丢失后重新关联同一笔业务。
因此,如果渠道交易明细、日终文件或对账文件中包含商户订单号 order_no,即使本地没有拿到 channel_trade_no,仍然可以通过 order_no 找回渠道侧交易,再补齐关联关系。
这时可退化到其他恢复路径,例如:
- 根据渠道后台人工排查;
- 使用包含
order_no的渠道交易明细做数据对比; - 使用日终对账文件识别;
- 必要时人工补偿。
如果这些离线数据同样不包含本方业务唯一号,那么自动恢复能力会进一步下降,只能依赖渠道提供的其他关联字段或人工核对。
这也是第 7 节数据对比机制存在的重要原因之一:在线查询能力不能覆盖所有异常,而创建时传给渠道的业务唯一号,是后续重新关联双方事实的重要基础。
4.4 并发创建不是只有重试才会发生
还有一个容易遗漏的问题:
即使系统从不自动重试,也仍然可能发生重复创建。
例如:
- 用户连续点击两次;
- 上游同时发来两个相同请求;
- 两个线程同时判断“当前还没有创建请求”,然后同时向渠道提交。
因此,创建过程本身还需要并发约束。
一种思路是:
针对 CREATE 请求,同一个 order_no 在任意时刻只能存在一个尚未结束的创建请求,例如:
text
CREATE + INIT
CREATE + PROCESSING已经处于这类状态时,不允许另一个线程再发起新的 CREATE。
但 QUERY 与 CREATE 不同。
查询本身可以执行多次,每次查询都应该形成新的 channel_request,以保留确认过程。
具体采用数据库锁、状态占位、部分唯一索引还是其他并发控制方式,取决于实际技术栈,本文不展开。
5. 状态确认:如何知道外部真实结果
5.1 主动查询解决单笔实时确认
主动查询主要解决的是:
“我现在就想确认这一笔订单,在渠道侧到底是什么状态?”
常见触发场景有:
- CREATE 超时后立即确认;
- UNKNOWN 状态定时扫描;
- 后台人工排查;
- 定时补偿任务。
一次 QUERY 本身也是一次外部调用,因此同样应该记录到 channel_request。
例如:
text
request_type = QUERY
order_no = xxx
channel_trade_no = CH2026xxxx
channel_status = SUCCESS这样后续不仅知道订单最终成功了,也知道:
这个成功结论来自哪一次查询。
如果创建阶段因为响应丢失,没有保存到 channel_trade_no,而这次查询又成功返回了渠道流水号,那么应该把这个值保存下来。至少应记录在本次 QUERY 对应的 channel_request 中,并根据实际数据模型补齐该订单与渠道流水号之间的关联。这样后续再次查询、数据对比和人工排查时,就不必继续依赖一次已经丢失的同步响应。
5.2 异步通知解决渠道主动推送结果
有些渠道会主动把最终处理结果通知回来。
这时一个常见错误是:
收到通知 -> 立即执行业务更新 -> 成功后再返回渠道。
如果处理过程中出现数据库异常、下游异常或者服务重启,就可能导致:
通知已经收到,但事实没有留下。
更稳妥的方式是使用 Inbox 思路:
先保存通知事实,再处理业务。
例如:
text
notify_inbox
notify_id
order_no
payload
received_time
processed
process_time回调到达后,先把原始通知或必要字段保存下来,然后尽快返回渠道。
后续再根据 notify_inbox 中的数据执行:
- 幂等判断;
- 状态校验;
- 业务状态推进。
这样即使处理失败,系统仍然保留收到通知这个事实,可以再次处理。
6. 状态流转约束
6.1 不能让最后到达的数据决定最终状态
前面已经看到,一个订单的渠道状态可能来自:
- CREATE 返回;
- QUERY 查询;
- 异步通知;
- 人工处理。
这些数据不是严格按照时间顺序到达的。
因此不能采用:
“谁最后到,谁就覆盖当前状态。”
例如:
text
PROCESSING -> SUCCESS通常是合理推进。
但:
text
SUCCESS -> PROCESSING通常就是状态倒退。
所以状态修改必须通过一个明确的状态流转入口,而不是散落在各个 Service、Callback、定时任务里直接 update。
状态机拒绝某次更新,并不意味着这次外部事实可以被丢掉。
例如订单当前已经是 SUCCESS,此时又收到一条渠道推送的 PROCESSING 通知:
text
当前状态:SUCCESS
↓
收到通知:PROCESSING
↓
保存 notify_inbox
↓
状态机判断 SUCCESS -> PROCESSING 非法
↓
拒绝更新 order
↓
创建 consistency_issue
↓
发送异常通知 / 生成待办
↓
人工处理这里需要区分三件事:
notify_inbox记录“渠道实际发来了什么”;consistency_issue记录“系统发现了什么无法安全自动处理的问题”;order.channel_status只保存当前经过校验后认可的状态。
也就是说,事实可以被拒绝用于更新当前状态,但事实本身不能被无声丢弃。
对于程序能够明确判断的正常场景,由程序自动处理;对于状态冲突、事实矛盾或者程序无法安全判断的情况,系统应该阻止错误继续扩散,同时创建异常事项并发出通知,让人工介入。
consistency_issue 可以保存一个最小化的异常事项模型:
text
consistency_issue
id
order_no
source_type
source_id
issue_type
detected_time
status
operator
resolution
closed_time其中:
source_type:异常来自哪类事实,例如 CHANNEL_REQUEST、NOTIFY_INBOX、DATA_COMPARE;source_id:对应事实记录的主键,用于回溯原始现场;issue_type:异常类型。在线状态确认场景例如 ILLEGAL_STATE_TRANSITION、STATE_CONFLICT、CHANNEL_QUERY_FAILED;数据对比场景例如 MISSING_LOCAL_RECORD、MISSING_CHANNEL_RECORD、STATUS_MISMATCH;status:事项状态,例如 OPEN、CLOSED;operator:处理人;resolution:人工判断和处理结论;closed_time:关闭时间。
这样人工打开事项时,可以直接追溯到触发问题的原始事实,而不是只看到一条脱离上下文的告警消息。
issue_type 描述的是系统当时能够确定的“问题类型”,而不是最终根因。
例如数据对比发现“本地存在、渠道数据中暂未找到”时,可以记录为 MISSING_CHANNEL_RECORD;后续人工确认发现只是对账文件时间窗口尚未覆盖,这个结论更适合记录在 resolution 中,而不是把所有可能根因都提前设计成 issue_type。
6.2 状态机解决逻辑错误,version 解决并发覆盖
状态机和乐观锁解决的不是同一个问题。
状态机负责判断:
“从当前状态到目标状态,在业务逻辑上是否合法?”
例如:
text
INIT -> PROCESSING 合法
PROCESSING -> SUCCESS 合法
SUCCESS -> PROCESSING 非法而 version / 乐观锁负责判断:
“我更新时看到的旧状态,是否还是数据库当前状态?”
例如两个线程同时读取:
text
channel_status = PROCESSING
version = 10线程 A 先更新为 SUCCESS,version 变为 11。
线程 B 再按照 version=10 更新时应该失败,而不是覆盖线程 A 的结果。
乐观锁失败后,也不应该把原来的更新语句直接无脑重试。更稳妥的做法是重新读取最新状态,再按照状态机重新判断这条事实是否仍然需要应用:
- 如果当前状态仍允许推进,就基于新的 version 再更新;
- 如果最新状态已经是终态,或者已经包含更高优先级的事实,则放弃这次更新;
- 如果重新读取后仍然无法安全判断,则记录异常并通知人工处理。
所以二者分别解决:
- 状态机:逻辑合法性;
- version:并发一致性。
两者的目标都不是“让程序自动解决一切”,而是尽可能自动处理确定场景,并在无法安全处理时把问题暴露出来,而不是静默吞掉。
6.3 人工处理也是系统流程的一部分
现实系统一定会存在人工处理。
例如:
- 渠道无法查询;
- 状态机发现相互矛盾的事实;
- 数据对比发现历史异常;
- 外部后台人工调整;
- 业务需要确认特殊订单。
程序不是万能的,也没有必要为了覆盖极少数复杂异常,把整个系统设计得无限复杂。
一个可靠系统更重要的是做到:
text
正常场景
↓
程序自动处理
异常但能够识别
↓
阻止错误状态继续传播
↓
创建 consistency_issue
↓
发送通知 / 生成待办
无法安全自动判断
↓
人工处理人工处理因此不是“系统失败以后临时去数据库改数据”,而是可靠性设计中的正常一环。
系统至少应该保证:
- 异常事实已经保存;
- 当前状态没有被错误覆盖;
- 异常已经形成可追踪的
consistency_issue; - 问题能够及时通知到人;
- 人工能够看到足够的上下文;
- 处理结论能够留痕;
- 真正发生状态修正时能够留下审计记录。
人工处理本身并不一定意味着必须生成 compensation_log。
例如人工确认某条 PROCESSING 只是延迟通知,而当前 SUCCESS 状态正确,那么只需要在 consistency_issue 中记录处理结论并关闭事项,不需要修改订单。
只有人工确认确实需要修正 order.channel_status 或 order.business_status 时,才生成对应的 compensation_log,并继续经过同样的状态机和 version 校验。
这样几类记录的职责就比较清楚:
text
channel_request / notify_inbox
记录“发生了什么”
consistency_issue
记录“系统发现了什么问题,以及这个问题如何被处理”
compensation_log
记录“最终对业务状态做了什么修正”人工处理和自动处理使用的是同一套状态规则,只是“谁做出判断”不同。
7. 数据对比与补偿恢复
7.1 为什么已有查询和通知仍然不够
到这里为止,系统已经有了:
- channel_request;
- UNKNOWN;
- 主动查询;
- notify_inbox;
- consistency_issue;
- 状态机;
- version。
看起来已经覆盖了绝大部分线上异常。
但现实里,这些机制本身仍然可能失败。
例如:
| 机制 | 解决的问题 | 仍可能出现的问题 |
|---|---|---|
| channel_request | 保存调用事实 | 本地记录本身异常或历史数据缺失 |
| 主动查询 | 单笔状态确认 | 渠道查询接口长期不可用 |
| 异步通知 | 接收最终结果 | 通知丢失、渠道未发送 |
| 状态机 | 防止错误覆盖 | 外部事实后来发生变化,本地没有收到 |
所以仍然需要一个更低频、更全面的兜底手段:
数据对比。
主动查询和数据对比不是同一个机制。
主动查询解决的是:
单笔订单、实时或准实时状态确认。
数据对比解决的是:
全量或批量数据中的长期差异发现。
数据对比通常应该使用:
- 渠道交易明细;
- 日终文件;
- 对账文件;
- 其他批量数据源。
而不是简单写一个循环,对所有历史订单逐笔调用查询接口。
7.2 数据对比通常会发现哪几类差异
外部存在,本地不存在
例如:
text
渠道:SUCCESS
本地:无订单这通常意味着:
渠道已经形成业务事实,但本地记录缺失。
可能原因包括:
- 历史上采用了先调用渠道再落库的流程;
- 本地保存失败;
- 数据迁移遗漏。
这种情况通常风险较高,因为本地甚至没有一个正常业务对象,需要补建、关联或人工处理。
本地存在,外部不存在
例如:
text
本地:PROCESSING
渠道:无记录可能原因包括:
- 请求在真正发出前就失败;
- 网络异常导致渠道未收到;
- 查询条件错误;
- 当前对账文件覆盖范围不完整。
这类情况不能看到“不存在”就直接判定 FAIL,还需要结合 channel_request 判断请求究竟发生到了哪一步。
双方都存在,但状态不同
例如:
text
本地:PROCESSING
渠道:SUCCESS这说明业务对象没有丢失,只是状态没有及时同步。
相比前两类,这种问题往往更适合自动补偿:
确认渠道状态可信后,把本地状态推进到正确结果即可。
7.3 compensation_log 记录的是修正事实
补偿操作不应该复用 channel_request。
因为 channel_request 表达的是:
“本系统和外部渠道发生了什么交互。”
而 compensation_log 表达的是:
“系统为什么要修正已有业务事实。”
例如:
text
compensation_log
order_no
consistency_issue_id
field_name
reason
operator
execute_time
before_value
after_value
resultconsistency_issue_id 用于把一次补偿和触发它的异常事项关联起来。
如果补偿来自人工处理某一条 consistency_issue,则记录对应的 consistency_issue_id,这样可以从“问题”追溯到“最终修正”,也可以从补偿记录反查最初为什么需要修改。
这个字段允许为空。因为有些差异经过程序判断后已经足够明确,可以直接自动补偿,并不需要为了建立关联而先创建一条 consistency_issue。
其中 field_name 用来明确这次修正针对的是哪个字段,例如 channel_status 或 business_status。这很重要,因为全文一直把渠道状态和业务状态作为两个不同维度处理,补偿记录本身也不能把它们重新混在一起。
compensation_log 不是业务状态本身,而是一次状态修正的审计事实。它记录的是“哪个字段因为什么原因,从什么值修正成了什么值”。
例如:
text
field_name = channel_status
before_value = PROCESSING
after_value = SUCCESS
reason = CHANNEL_RECONCILIATION补偿动作确认后,系统还需要真正更新 order。
这个状态更新仍然要经过第 6 节的状态机和 version 校验。
也就是说:
text
发现差异
↓
生成补偿动作
↓
记录 compensation_log
↓
执行状态校验
↓
更新 order这样才能避免“用于修复一致性的补偿流程,本身又制造一次非法状态覆盖”。
8. 完整可靠性模型
前面的各个机制并不是一条简单的单向流水线。
更准确地说,系统里同时存在两类不同节奏的机制:
- 在线状态确认:围绕单笔订单发生,由创建调用、主动查询、异步通知等事实推动当前状态变化;
- 周期性数据对比:独立于某一笔订单的实时流程,定期从渠道交易明细、日终文件或对账文件中发现长期未收敛的差异。
两条链路最终都会进入同一套状态校验逻辑。
当程序能够明确判断时自动推进;当状态冲突或事实不足以安全判断时,不强行修改订单,而是保留原始事实、创建 consistency_issue、发出通知并进入人工处理。
text
在线状态确认
业务请求
│
▼
order
│
▼
创建调用 ──────────────► channel_request
▲
│
主动查询
异步通知 ──────────────► notify_inbox
channel_request ──────────┐
│
notify_inbox ──────────────┼────► 状态校验
│ (状态机 + version)
│
├── 合法、可确定
│ │
│ ▼
│ 更新 order.channel_status
│ │
│ ▼
│ 判断并推进 order.business_status
│
└── 非法 / 冲突 / 无法安全判断
│
▼
consistency_issue
│
▼
发送异常通知 / 待办
│
▼
人工处理
│
┌──────────┴──────────┐
│ │
无需修正 需要修正
│ │
▼ ▼
记录结论并关闭事项 compensation_log
│
└────► 状态校验
周期性数据对比
渠道交易明细 / 日终文件 / 对账文件
│
▼
数据对比
│
发现疑似差异
│
进一步校验
│
┌─────────┼──────────────┐
│ │ │
▼ ▼ ▼
实际无差异 差异明确 无法安全判断
│ 且可自动修正 │
│ │ ▼
│ ▼ consistency_issue
│ compensation_log │
│ │ 通知 / 人工处理
│ │ │
│ │ ┌───────┴────────┐
│ │ │ │
│ │ 无需修正 需要修正
│ │ │ │
│ │ ▼ ▼
│ │ 关闭事项 compensation_log
│ │ │
│ └──────────────┬────────┘
│ │
│ ▼
│ 状态校验
│ (状态机 + version)
│ │
│ ▼
│ 更新 order.channel_status
│ │
│ ▼
│ 判断并推进 order.business_status
│
└──────────────────────────────┐
│
下一周期数据对比 ◄┘这里有几个关键关系。
第一,channel_request、notify_inbox、consistency_issue、compensation_log 不是一张“大流水表”的不同类型。
它们分别表达:
channel_request:本系统主动发出的外部调用事实;notify_inbox:外部渠道主动推送进来的通知事实;consistency_issue:系统发现但无法安全自动处理的一致性问题;compensation_log:经过程序或人工确认后产生的业务状态修正事实,必要时关联对应的consistency_issue。
第二,收到事实不等于一定要修改订单。
创建返回、主动查询、异步通知等事实首先进入统一的状态校验逻辑。能够明确判断且流转合法时,才更新 order.channel_status,再根据业务规则判断是否推进 order.business_status。
如果出现 SUCCESS -> PROCESSING 这类非法倒退,或者多个事实互相冲突到程序无法安全判断,系统应该保留原始事实、拒绝错误更新、创建 consistency_issue 并发出通知,而不是为了追求“全自动”自行猜测结果。
第三,图中在线链路和周期性数据对比链路里的人工处理,是同一套人工处理机制在不同触发时机下的复用。
在线链路中的人工处理,通常由状态冲突、渠道查询异常等实时问题触发;数据对比链路中的人工处理,则由批量差异发现触发。二者都通过 consistency_issue 承载待处理问题;如果最终确认需要修正订单,再产生 compensation_log,并经过相同的状态校验流程。
第四,数据对比不是“每一笔订单在业务状态确定之后都要执行的一步”。
它是独立运行的周期性或批量机制,可能在任意时间发现某一笔历史订单看起来和渠道事实不一致。
这里首先发现的是“疑似差异”,而不是立即得出“本地一定错了”的结论。进一步校验后可能出现三种结果:
- 实际没有差异,例如只是对账文件时间窗口尚未覆盖,直接结束本次处理;
- 差异已经明确且能够安全自动修正,直接生成
compensation_log并进入状态校验;此时compensation_log.consistency_issue_id可以为空,因为不需要为了统一流程强行先创建一条consistency_issue; - 仍然无法安全判断,创建
consistency_issue,通知人工处理。
订单完成一次补偿以后,未来周期仍会再次参与数据对比,直到双方事实长期保持一致。
第五,order.channel_status 和 order.business_status 仍然是两个不同维度。
外部事实确认后,先更新渠道状态,再根据业务规则判断是否推进业务状态。
例如渠道 SUCCESS 并不意味着所有业务都可以无条件进入 PAID,中间仍可能存在金额检查、业务前置条件、订单当前状态等约束。
从更高层看,最终一致性不是一条从开始走到结束就永远不再变化的直线,而是一个不断确认和修正的收敛过程:
text
记录事实
↓
确认 / 校验
│
├── 可以安全判断 ─────► 自动更新
│
└── 无法安全判断 ─────► consistency_issue
│
▼
通知人工处理
│
┌─────────┴─────────┐
│ │
无需修正 需要修正
│ │
▼ ▼
关闭事项 compensation_log
│
▼
再次校验
│
▼
更新状态系统不需要自动解决所有异常,但必须尽可能做到:有问题能发现,有事实能保留,有异常能通知,有必要时能让人工介入,并最终让双方状态持续收敛。
9. 总结:最终一致性的本质
外部渠道调用无法彻底避免:
- 请求超时;
- 网络中断;
- 响应丢失;
- 回调乱序;
- 并发更新;
- 双方数据暂时不一致。
因此,一个可靠系统的目标不应该是:
“保证每一次外部调用都立即得到正确结果。”
也不应该是:
“让程序自动解决所有异常。”
真正应该保证的是:
- 每一次重要调用都有记录;
- 当前状态能够被准确表达;
- 不确定结果能够继续确认;
- 重复操作不会重复执行业务;
- 多来源状态不会任意覆盖;
- 长期差异能够被发现;
- 程序无法安全处理的问题能够形成可追踪的异常事项并及时通知;
- 人工处理有足够上下文、有明确入口、有处理结论和审计记录;
- 异常状态能够通过自动处理或人工介入最终恢复。
最终一致性的本质,不是消灭所有短暂的不一致,而是让系统在不一致发生之后,仍然能够记录事实、确认事实、发现差异,并最终恢复到正确状态。