3.4 数据同步:分片间对账
最终一致方案的诚实之处在于:它承认中间会有不一致,然后配一套对账系统把不一致找出来修掉。没有对账的最终一致是自欺欺人——不一致在数据库里安安静静躺着,直到客诉爆发。本节讲我们的对账系统怎么设计、怎么跑、发现差异后怎么修。
场景:三类必须对账的数据
分库系统里需要周期性核对的数据有三类。第一类是事务上下游:订单与库存,订单创建后库存是否按量扣减。第二类是路由关系:二级路由索引表与订单主表,索引表里没有的订单就是「孤儿索引」。第三类是跨系统计数:订单总量与积分系统发放量。
对账系统在分库项目里的地位很像「不动产登记」:业务系统天天交易,对账系统负责保证账实相符。它不能阻止错误发生,但能保证错误不积累、不藏匿。我们从上线第一天就把对账排在迭代计划里,而不是等出事再补——等到出事再补的系统通常已经积累了三个月的差异,修复成本是指数级的。
| 对账类型 | 比对对象 | 频率 | 发现的问题 |
|---|---|---|---|
| 订单对库存 | t_order 对库存流水 | 每小时 | 漏扣、少扣、多扣 |
| 订单对索引 | t_order 对 t_user_order_idx | 每天 | 孤儿索引、重复索引 |
| 订单对积分 | t_order 对积分流水 | 每天 | 漏发、迟发 |
| 订单对物流 | t_order 对物流单 | 每天 | 漏发货 |
| 金额汇总 | 订单明细对汇总表 | 每小时 | 汇总漂移 |
方案:五步对账流水线
对账系统的流程固定为五步:取快照、比对、分类、修复、复核。取快照是关键——两个系统在同一刻的状态在不断变化,必须取同一时间窗口的「结算点」数据比对,否则永远比对不平。我们的做法是对账任务先记录「对账时间戳 T」,订单与库存两边都只取 T 之前的数据。
| 步骤 | 动作 | 关键点 |
|---|---|---|
| 1 取快照 | 按时间窗口拉取两侧数据 | 时间戳 T 固定,双方只取 T 前数据 |
| 2 比对 | 按 biz_key 做 Map 差集 | 双向差集:多有与少有 |
| 3 分类 | 差异按类型归档 | 业务缺失、系统缺失、金额不符 |
| 4 修复 | 补数据或回滚 | 幂等修复脚本,人工审核大额 |
| 5 复核 | 对修复结果再跑一轮对账 | 修复不彻底则升级告警 |
对账的输出物是一张「差异表」,每条差异记录了比对对象、差异数据、差异原因与修复状态。差异表按 shop 之间无关的主键分片存储,只有处理中的差异才保留,已修复的归档。正常运行下每天的差异在个位数,异常期一天几百条——某次消息队列挂掉半小时,积压了 800 条差异,靠修复脚本两小时清完。
差异治理分级
对账发现的差异不能一刀切处理。我们按影响面把差异分成三级,每级配套不同的修复策略与时效要求。分级之后,自动修复只处理低危类,高危类全部走人工审核:
| 级别 | 典型差异 | 处理方式 | 时效要求 |
|---|---|---|---|
| P0 危急 | 重复扣款、金额错账 | 人工审核修复 | 30 分钟内 |
| P1 重要 | 漏发积分、漏发货 | 修复脚本补发 | 2 小时内 |
| P2 常规 | 索引缺失、汇总漂移 | 定时任务修复 | 当天完成 |
| P0 危急(另一种) | 差异数量突增 | 触发链路告警 | 立即定位 |
P0 的第二种形态是「差异数量突增」本身:平时个位数的差异突然变成几百条,说明上游链路出问题了,这时候对账的价值不是修数据而是当「烟雾报警器」。我们给差异量配了突变告警,某次消息队列消费延迟就是靠这个告警提前 40 分钟发现的,比业务告警快得多。
配置:比对脚本的核心片段
对账脚本的核心是「按 Key 聚合后做差集」,用内存 Map 跑效率足够。下面是订单对库存的核心逻辑:
// 两侧都拿到 Map<bizKey, 数量>
Map<String, Integer> orderCnt = orderDao.countByWindow(T);
Map<String, Integer> stockCnt = stockDao.countByWindow(T);
// 双向差集
Set<String> onlyOrder = difference(orderCnt.keySet(), stockCnt.keySet());
Set<String> onlyStock = difference(stockCnt.keySet(), orderCnt.keySet());
// 金额不符
List<Diff> amountDiff = filter(orderCnt, e -> !Objects.equals(e.getValue(), stockCnt.get(e.getKey())));
真实脚本里还要处理窗口内部分交付的订单( 10 分钟内的订单允许差异、跳过不报),否则对账永远有正常噪音。窗口容忍是差异治理的重要经验:刚下完单 10 分钟内的订单,库存消息可能还在路上,算正常态。容忍窗口的长度用「消息表最大重试耗时加 1 分钟」倒推,我们的消息重试退避到第 8 次最多 40 分钟,但对账的容忍窗口只设 10 分钟——超过 10 分钟的差异按真差异处理,由修复脚本补上,因为重试到第 8 次的消息八成已经进死信,等没有意义。
踩坑:对账不能修所有问题
对账系统能发现差异,但修复手段有限。最严重的一次:某个分片的订单因双向网络问题写了两份(写入超时重试造成的重复),对账发现后只能保留一条。重复订单的修复脚本首先要判断「哪一条是业务认可的」,这里需要人工介入而不是自动删除——自动删除可能删错。对账系统最大的价值不是自动修复,而是把差异可见化,让人在客诉之前知道。
第二个坑是对账任务自己把库压挂。全量对账最初在白天跑,扫几千万行数据把分片的 IO 打满,影响线上查询。后来全面改成凌晨低峰窗口加增量对账(只比对近一小时变更的 Key)。全量对账降级到每周日凌晨一次。
第三个坑是对账的「自己查自己」。有段时期对账服务读的也是订单分片,分片故障时对账也挂了,差异彻底无人发现。对策是对账服务具备独立只读实例的降级路径,并且对账任务成功率本身有监控——「对账连续 3 次没跑成功」就是最高级告警,比任何业务告警都严重。
操作清单:对账系统从零建设的顺序
| 步骤 | 动作 | 常见错误 |
|---|---|---|
| 1 选比对对象 | 从金额与库存两类开始 | 一上来就全量对账 |
| 2 定时间窗口 | 固定时间戳 T | 双方各自取当前时间 |
| 3 写比对脚本 | 双向差集加容忍窗口 | 无噪音过滤 |
| 4 差异分级 | 按影响面分 P0 到 P2 | 全部自动修复 |
| 5 建告警 | 差异量突变告警 | 只在积压后才有告警 |
顺序的核心思想是「先跑通再完善」:第一版对账只对订单与库存,跑顺了再加索引与积分。一个粗糙但每天跑的对账系统,价值远高于一个完美但三个月才上线的系统。
对账系统上线后的头三个月是「校准期」:总会发现各种没想到的正常态差异,比如优惠券实时核销导致的状态延迟、退款在途的金额差异。每发现一类正常态就在容忍清单里加一条规则,差异量会从每天几百条收敛到个位数。耐心收集这些规则比写更聪明的算法更有用。
对账系统的最后一块拼图是「给业务看的数据看板」。把每天的差异数量、修复时效、遗留积压做成日报推送到群里,所有人对系统一致性有统一预期。看不见的一致性没人信任,看得见的一致性才是最终一致方案的竞争力。
小结
对账是最终一致方案的良心:快照固定时间戳、双向差集、差异分类、幂等修复、复核闭环。设计上把「正常态噪音」与「真差异」分开(时间窗口容忍),把「自动修复」与「人工确认」分开(大额与重复类差异必须人工)。对账系统自己的可用性同样重要,它是整套分库体系的最后一道防线,也是最终一致方案能够理直气壮存在的全部理由。这里再给一个排期建议:对账系统上线后的头三个月,每月安排一次「人肉复核日」,把自动修复的差异逐条人工过一遍。我们的对账规则就是在这样一遍遍人工复核中补齐的,半年后误报率从百分之三降到万分之一以下,团队对自动修复的信任度才真正建立起来。
延伸阅读
- go 实战:微服务 CPS:微服务对账系统的设计与告警体系
- java 实战:订单系统:单体订单系统里对账是如何从无到有建立的
- rust 实战:日志管道:海量日志采集与流式对账的工程对照
- MySQL 实战:分库分表订单系统:回到本书,第四篇进入冷热分离与治理
