Skip to content

外部渠道调用中的数据一致性问题:如何实现最终一致性

本文从真实失败案例出发,推导外部渠道调用过程中为什么需要调用记录、状态建模、幂等控制、状态确认、数据对比以及补偿机制。

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:超时、网络异常、渠道错误等异常信息。

orderchannel_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_statuschannel_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_statusorder.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
result

consistency_issue_id 用于把一次补偿和触发它的异常事项关联起来。

如果补偿来自人工处理某一条 consistency_issue,则记录对应的 consistency_issue_id,这样可以从“问题”追溯到“最终修正”,也可以从补偿记录反查最初为什么需要修改。

这个字段允许为空。因为有些差异经过程序判断后已经足够明确,可以直接自动补偿,并不需要为了建立关联而先创建一条 consistency_issue

其中 field_name 用来明确这次修正针对的是哪个字段,例如 channel_statusbusiness_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_requestnotify_inboxconsistency_issuecompensation_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_statusorder.business_status 仍然是两个不同维度。

外部事实确认后,先更新渠道状态,再根据业务规则判断是否推进业务状态。

例如渠道 SUCCESS 并不意味着所有业务都可以无条件进入 PAID,中间仍可能存在金额检查、业务前置条件、订单当前状态等约束。

从更高层看,最终一致性不是一条从开始走到结束就永远不再变化的直线,而是一个不断确认和修正的收敛过程:

text
记录事实

确认 / 校验

   ├── 可以安全判断 ─────► 自动更新

   └── 无法安全判断 ─────► consistency_issue


                           通知人工处理

                     ┌─────────┴─────────┐
                     │                   │
                  无需修正            需要修正
                     │                   │
                     ▼                   ▼
                  关闭事项       compensation_log


                                     再次校验


                                      更新状态

系统不需要自动解决所有异常,但必须尽可能做到:有问题能发现,有事实能保留,有异常能通知,有必要时能让人工介入,并最终让双方状态持续收敛。

9. 总结:最终一致性的本质

外部渠道调用无法彻底避免:

  • 请求超时;
  • 网络中断;
  • 响应丢失;
  • 回调乱序;
  • 并发更新;
  • 双方数据暂时不一致。

因此,一个可靠系统的目标不应该是:

“保证每一次外部调用都立即得到正确结果。”

也不应该是:

“让程序自动解决所有异常。”

真正应该保证的是:

  • 每一次重要调用都有记录;
  • 当前状态能够被准确表达;
  • 不确定结果能够继续确认;
  • 重复操作不会重复执行业务;
  • 多来源状态不会任意覆盖;
  • 长期差异能够被发现;
  • 程序无法安全处理的问题能够形成可追踪的异常事项并及时通知;
  • 人工处理有足够上下文、有明确入口、有处理结论和审计记录;
  • 异常状态能够通过自动处理或人工介入最终恢复。

最终一致性的本质,不是消灭所有短暂的不一致,而是让系统在不一致发生之后,仍然能够记录事实、确认事实、发现差异,并最终恢复到正确状态。