bigywater.tech
分布式事务 · 16

最终一致:对账与修复

最终一致意味着短暂的不一致是允许的,但不能永远不一致。靠什么发现?全量还是增量?业务还在写时怎么避免误报?发现之后怎么修?这篇用两个真实的 MySQL 和 10 万个订单,把对账与修复的这些环节逐个量出来。

0.36 s
10 万订单、两个库的全量对账用时,发现全部 2068 个注入的分歧(误报 0、漏报 0)
5%
只对账最近 1 小时时的召回率(24 小时窗口才是 100%)
311
支付库时钟慢 5 分钟时,6 小时窗口里多出来的误报数

全量对账

实测 · 本机执行输出
  订单库 100000 行(PAID 89972),支付库 89792 行;归并比较了 90241 个订单号,用时 0.36 s
  分歧类型          | 注入的 | 对账报出的 | 其中命中 | 误报(多报) | 漏报
  --------------+-----+-------+------+--------+---
  丢失(订单已付,没有支付) | 899 | 899   | 899  | 0      | 0 
  重复(同一订单两条支付)  | 450 | 450   | 450  | 0      | 0 
  金额不一致         | 450 | 450   | 450  | 0      | 0 
  孤儿(有支付、订单不存在) | 269 | 269   | 269  | 0      | 0 
  (孤儿的判定里会混进“订单是 CANCELLED 但支付库里还有支付”的订单——本数据里没有这种,所以误报为 0)

两边按订单号排序流式读出、归并比较:订单是 PAID 就必须有且只有一条金额相同的支付。四类分歧全部发现,误报漏报都是 0。

增量对账的局限

实测 · 本机执行输出
  对账窗口                                    | 发现的真分歧      | 召回率  | 误报(多报)
  ----------------------------------------+-------------+------+-------
  最近 1 小时                                 | 97 / 2068   | 5%   | 2     
  最近 6 小时                                 | 520 / 2068  | 25%  | 0     
  最近 24 小时                                | 2068 / 2068 | 100% | 0     
  最近 6 小时(支付库时钟慢 5 分钟,支付行盖的时间比真实时间早 5 分钟) | 518 / 2068  | 25%  | 311   
  窗口外的分歧永远发现不了;窗口边缘上订单在窗口里、而支付的时间戳(晚了几秒,或者因为时钟慢了几分钟)落在窗口外,会被报成“丢失”;所以增量对账要有重叠窗口,并定期做全量

只比最近窗口的话,窗口外的分歧永远发现不了;窗口边缘和时钟偏差还会误报。需要重叠窗口并定期做全量。

在线写入的误报

实测 · 本机执行输出
  对账策略    | 每次检查的新订单数(平均) | 每次误报“丢失”(平均) | 最多一次误报
  --------+---------------+--------------+-------
  宽限期 0 秒 | 57            | 0.6          | 1     
  宽限期 1 秒 | 86            | 0.0          | 0     
  宽限期 3 秒 | 102           | 0.0          | 0     
  写入线程这段时间一共产生了 147 个订单,所有订单的支付最终都落地了(没有真实分歧),所以这里的“丢失”全是误报;宽限期换来 0 误报,代价是真正的分歧要等宽限期之后才能发现

订单已提交、支付还在路上的订单会被报成丢失。只对足够老的行下结论,用宽限期消除。

幂等的修复

实测 · 本机执行输出
  第一次修复(用时 0.15 s)各类动作数:{'丢失(订单已付,没有支付)': 899, '重复(同一订单两条支付)': 450, '金额不一致': 450, '孤儿(有支付、订单不存在)': 269}
  修复之后重新对账(只看状态为 PAID 的支付):{'丢失(订单已付,没有支付)': 0, '重复(同一订单两条支付)': 0, '金额不一致': 0, '孤儿(有支付、订单不存在)': 0}
  再跑一遍修复(基于重新对账的结果):各类动作数 {'丢失(订单已付,没有支付)': 0, '重复(同一订单两条支付)': 0, '金额不一致': 0, '孤儿(有支付、订单不存在)': 0} —— 修复是幂等的,没有更多可做的

补、冲、改、退都写成可重复执行,修复后重新对账验证,再修一次不会再动手。

成本

实测 · 本机执行输出
  订单数    | 归并比较的订单号数 | 用时     | 订单/秒   | 发现的分歧总数
  -------+-----------+--------+--------+--------
  20000  | 18123     | 0.08 s | 252082 | 415    
  100000 | 90314     | 0.36 s | 279396 | 2070   
  300000 | 270939    | 1.04 s | 288592 | 6212   
  用时随订单数大致线性增长;只比较了金额与笔数,没有比较其它字段

全量对账用时随订单数线性增长,这里每秒约 25 万到 29 万个订单。

分步演示

最终一致的对账
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用两个独立的 MySQL 26.7.0 实例(订单库、支付库),10 万个订单(其中约 90% 是 PAID),分歧是按固定比例、固定随机种子注入的;对账用服务端游标流式读取(PyMySQL SSCursor),不涉及网络;第三节的写入线程在订单提交 80 ms 后才提交支付,是我设的延迟
    • 没有验证:真实业务里分歧的种类和比例(这里的比例是我设的);比较更多字段(状态、时间)时的开销;跨机房网络下对账的耗时;对账期间读到的是一致快照吗(这里没有做快照一致性读);修复动作造成的二次影响(例如退款会触发别的流程);“孤儿”的判定会混进已取消订单,本数据里没有这种情况
    京ICP备2026067502号-1京公网安备11010502063487号