单库单表的时候,一个BEGIN...COMMIT就能兜住的事,拆成六个服务之后,你得写两套校验、一张对账表、一个补偿任务,半夜还可能被叫起来查为什么订单是"已支付"但库存没扣。我见过太多团队在微服务拆分的第一版设计里,把业务完整性验证当成"后面再加"的东西,结果上线三个月,差异数据攒了几万条,人工修数据的成本比做一整套机制还高。这篇就聊微服务架构中的业务完整性验证设计——不是讲分布式事务的教科书定义,而是讲一个真正落过地的系统里,完整性该怎么分层、校验该放在哪个环节、方案该怎么选、对不上账的时候该顺着哪根线往下挖。
适合谁看?正在做服务拆分、正在被跨服务数据不一致折磨、或者准备给现有系统补一致性机制的后端同学。无论你是刚接手一个已经拆得七零八落的老系统,还是从零设计一套新的交易链路,下面这些取舍逻辑和踩坑记录应该都用得上。
1. 拆库那一刻,业务完整性就从数据库的活变成了架构的活
很多人对这件事的认知转折点,都发生在第一次遇到"订单状态已更新,但下游积分没加"这种问题的时候。在单体应用里,这两件事在同一个事务里,数据库帮你保证了;拆分之后,它们分属两个服务、两个数据源、两次网络调用,数据库管不着了。这个变化听起来简单,但它带来的连锁反应远比想象中复杂。
1.1 单库事务兜住的东西,拆开之后谁来兜
先明确一下单体时代数据库到底帮你做了什么。一个事务里,它保证了原子性(要么全成要么全败)、一致性(约束不被破坏)、隔离性(并发之间不互相看到中间态)、持久性(提交了就不会丢)。拆成微服务之后,这四条里有三条直接失效:跨服务的原子性没有了,跨服务的隔离性没有了(下游能看到上游的中间状态),只有单个服务内部的持久性还在。
这不是"加个分布式事务框架"就能完全补回来的。因为分布式事务能补的只有原子性的一部分,隔离性在绝大多数跨服务场景下是补不回来的——Saga 模式下,中间状态一定是可见的。所以设计的第一件事,是承认这个现实:微服务架构里的业务完整性,不是"恢复 ACID",而是"在 BASE 的前提下,把破坏完整性的窗口压缩到业务可接受的范围,并且保证所有破坏最终都能被发现和修复"。
这句话听起来有点绕,但它是后面所有决策的前提。你不能指望一套机制让跨服务永远不出错,你能做的是三件事:把出错的概率压低、把出错的影响面控制住、把出错的发现时间缩短到分钟级而不是月底对账时才发现。
1.2 业务完整性不等于数据一致性
这两个词经常被混用,但实际含义差很远,混用会直接导致方案选错。
数据一致性更多是技术层面的描述:两个存储里的同一份数据,值是不是相等。比如订单服务里的用户 ID 和用户服务里的用户 ID 是不是同一个。
业务完整性是业务语义层面的:一组业务规则在所有时刻是否都被满足。举例来说,"一个订单的已支付金额不得超过订单总额"、"一个优惠券只能被使用一次"、"库存不能为负"、"已发货的订单不能取消"——这些规则才是业务完整性真正关心的东西,它不一定要求两个表的值实时相等。
区分清楚的意义在于:很多团队为了满足一条业务规则(比如优惠券不能重复使用),去上了一整套分布式事务,其实用一张唯一索引加一个状态机前置条件就够了;反过来,有些团队以为"最终一致"就够了,结果忽略了规则在中间状态下被违反的可能性,导致超卖。
我一般会要求团队把业务规则列成一张清单,每条规则标注三件事:违反的后果有多严重、违反的窗口期能容忍多久、违反之后能不能自动修复。这三个维度直接决定了这条规则该用哪一档的机制去守。后果轻、窗口期长、能自动修的,靠对账兜就行;后果重(比如资金)、窗口期几乎为零的,必须前置拦截。
1.3 三种最常见的完整性断裂场景
聊方案之前先看断在哪,这比背方案名称有用得多。实际项目里我遇到的断裂点,绝大多数落在下面三类:
| 断裂类型 | 典型表现 | 根本原因 |
|---|---|---|
| 调用失败型 | 上游成功、下游失败,或下游成功但上游没收到响应 | 网络不确定性,超时导致的"结果未知" |
| 状态撕裂型 | 中间态被其他服务读到,基于中间态做了错误决策 | 缺少跨服务的隔离性,中间态可见 |
| 规则漂移型 | 各服务各自维护一份规则,规则版本不一致 | 业务规则没有单一事实来源 |
第一类最好理解,也最好处理,重试加幂等基本能覆盖大部分。第二类最隐蔽,因为它不是"错了",而是"在某段时间里看起来是对的,但基于这个状态做出的后续决策是错的"。第三类最容易被忽略,典型例子是每个服务都在自己的代码里写了一遍"什么算有效订单"的判断,某天业务调整了规则,只改了两个服务,第三个服务还在用老逻辑。
提示:如果你正在做拆分设计,先花半天时间把这三类断裂点在你们的业务里各举两个具体例子。能举出具体例子的,说明你理解了;举不出来的,说明你还没真正想清楚这次拆分的边界在哪。
2. 把"完整性"切成四层:不同层的验证手段根本不是一回事
我见过最糟糕的设计,是把所有完整性问题都往上抬到"分布式事务"这一层去解决。结果就是明明一个参数校验就能拦住的问题,绕了一大圈走到全局事务协调器里,性能掉一个量级,还引入了新的故障点。正确的做法是把完整性验证分层,每层用成本最低的手段解决。
2.1 第一层:字段与格式约束,别浪费分布式方案的预算
最基础的一层是入参和字段本身的约束:金额不能为负、数量必须是正整数、手机号格式、枚举值必须在允许集合内、必填字段不能为空。
这层用普通的参数校验框架就够了,比如 Java 生态里的 Bean Validation 注解,Go 里手写校验函数,前端再做一层兜底。关键点不在技术,而在于必须在服务入口处统一做,而不是散落在业务逻辑里。散落的结果就是某个分支忘了校验,脏数据从那个缝里漏进去。
这一层还要加一道:数据库层面的约束。非空、唯一索引、外键(如果你们用的话)、检查约束。很多人觉得"应用层校验过了数据库就不用管",这是错的。应用层的校验是给正常流量准备的,数据库约束是给异常流量(并发、重放、绕过接口的直接写入)准备的。两者不冲突,是互补。
实际经验:唯一索引这一条,能挡掉大概 60% 以上的重复提交问题。很多团队花大力气做分布式幂等,结果发现只要在核心单据表上加一个业务唯一键(比如biz_no),重复插入直接被数据库拒绝,处理逻辑简化一大半。
2.2 第二层:聚合内不变量,本地事务加上乐观锁就够
一个服务内部的多个实体之间,通常存在必须同时满足的约束,领域驱动设计里管这叫聚合内不变量。比如"订单主表的总额必须等于明细行金额之和"、"账户余额变动必须和流水记录一一对应"。
这类约束完全在单个服务、单个数据库的范围内,本地事务就是最好的工具,没有任何理由引入分布式机制。写主表和写明细放在一个本地事务里,要么都成要么都败。
真正需要注意的是并发。本地事务保证了原子性,但不保证并发下的正确性。典型场景是两个请求同时读余额、同时判断"余额充足"、同时扣减,结果扣成了负数。这时候要上乐观锁(版本号或 CAS 更新)或者悲观锁(SELECT ... FOR UPDATE)。
我的选择倾向是:冲突概率低的用乐观锁加重试,冲突概率高的用悲观锁短事务。判断冲突概率的方法很土但有效——看这块数据的 QPS 和热点程度。同一个账户被每秒改几十次,乐观锁重试会打满 CPU;反过来,一天改几次的数据用悲观锁纯属浪费。
这里有个坑要说清楚:乐观锁重试必须有次数上限和退避,不能无限重试。我见过一个实现是while(true)里重试,遇到热点数据直接把线程池打挂。合理的做法是重试 3 次,间隔用指数退避加随机抖动,超过次数直接返回失败让上层处理。
2.3 第三层:跨服务业务规则,靠预校验加资源预留
到了跨服务这一层,情况就变了。A 服务要基于 B 服务的状态做决策,比如下单时要确认库存充足、要确认优惠券可用。
最直觉的做法是"先查再写":调 B 服务查一下,确认没问题再执行 A 的操作。这个做法在低并发下能用,但在有并发的情况下会出问题——查的时候库存是够的,写的时候被别人抢走了。这就是典型的"检查与使用之间的时间窗"问题。
解决办法是把校验和使用合并成一个原子操作,交给 B 服务自己完成。也就是说,不是让 A 去查库存够不够然后自己扣,而是调 B 的"预占库存"接口,B 在本地事务里判断并扣减预留额度,返回成功或失败。这样校验和执行在 B 的内部是原子的。
这个模式有个名字叫资源预留或者额度冻结,本质上是把跨服务的"检查-执行"两步,压缩成 B 服务内部的一次本地事务。它的代价是需要在 B 侧维护"预留"和"实扣"两个状态,以及一个超时释放的逻辑。
超时释放这块要特别注意:释放必须幂等,而且释放前要确认这笔预留还是预留状态。我踩过一次坑,释放任务因为重试把已经实扣的额度给放回去了,直接导致超卖。后来加了一条规则:状态流转必须是单向的,任何操作执行前先校验当前状态是否允许这次流转。
2.4 第四层:跨服务状态终态,只能靠对账收口
前三层做得再好,也无法保证跨服务的最终状态一定正确。网络分区、进程崩溃、消息丢失、人工误操作,总有一款会找上你。所以第四层必须存在:对账。
对账的作用不是"防止错误发生",而是"保证错误一定会被发现"。它的价值在于给你一个确定性的兜底——无论前面出了什么幺蛾子,T+1(或者更短)一定能捞出来。
关于对账怎么设计,后面第 6 节会详细展开。这里只需要建立一个认知:对账不是一个可选项,任何跨服务的业务流都必须配一个对账机制。没有对账的分布式系统,等于没有安全网的高空作业。
3. 校验时机才是真问题:前置拦截和后置补偿的取舍
方案选型之前,有个更根本的问题需要想清楚:这次校验到底该放在哪。放在事前,会牺牲可用性;放在事后,会留下资损窗口。这个取舍没有标准答案,只能结合业务来定。
3.1 前置校验的成本是可用性,后置校验的成本是资损窗口
前置校验的意思是:在所有依赖条件都确认满足之后,才允许执行主操作。好处是出错概率极低,坏处是你的可用性变成了所有依赖服务可用性的乘积。
举个例子,下单链路依赖库存、优惠券、风控、账户四个服务。如果四个都要同步前置校验,每个服务 99.9% 的可用性,乘起来就是 99.6%,一个月大概有 3 小时的不可用窗口。这在很多业务里是不可接受的。
后置校验的意思是:先把主操作做了,然后异步去校验,发现不对再补偿。好处是主链路短、可用性高,坏处是在补偿完成之前,业务处在一个错误状态。如果这个错误状态会导致用户能薅到羊毛,那就是真实的资损。
我的判断标准是看错误的代价和窗口期的乘积。如果补偿窗口是 5 分钟,5 分钟内最多产生 100 笔错误,每笔损失 10 元,那最坏情况 1000 元,完全可以用后置。如果每笔损失是 10000 元,或者窗口内可能产生 10 万笔,那就必须前置。
3.2 资源预留在什么场景下必须做
资源预留本质上是"前置校验的软化版本"——不是校验完就完事,而是校验的同时把资源锁定住,防止在后续流程中被别人抢走。
必须做预留的场景,我总结有三类:
第一类,稀缺资源的分配。库存、座位、名额、额度,这类资源的特点是总量有限、先到先得。不预留的话,校验通过和实际使用之间的时间差里,资源可能被抢光。
第二类,需要多步操作才能完成的流程。比如支付要先冻结额度、等银行返回、再实际扣款。中间任何一步失败都要能回滚,冻结状态就是这个回滚的抓手。
第三类,跨天的长流程。比如预授权、预订单。这类流程中间会跨越很长的时间,必须有明确的"占用中"状态来标识资源归属。
反过来,不需要预留的场景也很明确:无状态的计算、可以重复执行且结果一致的操作、资源不稀缺的场景。给这些场景加预留,纯属给自己找麻烦,多了一套状态机要维护,还多了超时释放的定时任务。
3.3 校验穿过服务边界之后的失效风险
这一点特别容易被忽略:校验结果是有时效性的,一旦穿过服务边界,它就可能已经过期了。
A 服务在 T1 时刻校验了"用户状态正常",然后在 T2 时刻执行操作。这中间如果用户被冻结了,A 的操作就是基于过期信息做的。这类问题在单体应用里不存在(因为都在一个事务里),在微服务里是常态。
处理方式有两个方向。
一个方向是缩短校验和使用的间隔,把校验尽量靠近使用点。比如不要在流程开始时就校验,而是在真正要写的那一刻校验。代价是校验次数变多。
另一个方向是让下游自己做最终校验。上游的校验只是"快速失败"的优化,真正的把关放在真正执行操作的那个服务里。这个思路更稳妥,因为它不依赖上游的校验是否及时。这也是我倾向于的方案:上游校验用于提升用户体验(快速报错),下游校验用于保证正确性(最后防线)。
4. 一致性方案选型:别一上来就 TCC,先看业务能容忍什么
到了具体方案这一层,市面上的选择就那么几种,但选错的成本很高——选重了性能扛不住,选轻了数据对不上。我的做法是先画一张业务容忍度表,再对着表选方案。
4.1 一张选型对照表
| 方案 | 一致性强度 | 业务侵入 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| 本地事务 + 唯一约束 | 强(单服务内) | 低 | 极低 | 聚合内约束、防重 |
| 两阶段提交(XA/2PC) | 强 | 低 | 高 | 强一致、低并发、短事务 |
| TCC | 强(业务层面) | 极高 | 中 | 资金、库存等核心资源 |
| Saga(编排/协同) | 最终一致 | 中高 | 低 | 长流程、多步骤业务 |
| 本地消息表 / 事务性发件箱 | 最终一致 | 低 | 低 | 事件通知、异步解耦 |
| 最大努力通知 | 弱 | 低 | 极低 | 非核心通知、可丢失场景 |
| 对账 + 补偿 | 兜底 | 低 | 低(离线) | 所有跨服务流程的底线 |
这张表的用法不是"选一个",而是"组合"。一个成熟的系统里,往往是本地事务打底、本地消息表做异步通知、关键节点上 TCC、全局靠对账兜底。
两阶段提交我要单独说一句:它在很多团队里是被过度恐惧的。它的性能问题主要来自协调者瓶颈和锁持有时间长,如果你的场景是低频、短事务、强一致要求(比如某些配置下发、账户开户),2PC 其实是很省心的选择。真正不能用的场景是高并发交易链路,别在那里硬上。
4.2 本地消息表与事务性发件箱的落地细节
这是我最推荐的默认方案,因为它业务侵入小、可靠性高、实现简单。核心思路是:把"业务数据变更"和"待发送的消息"写在同一个本地事务里,然后由独立的投递器去发消息,发成功了更新消息状态。
具体落地有几个细节必须处理到位:
第一,消息表和业务表必须在同一个库。这是整个方案成立的前提。跨库就没有本地事务了,方案退化。
第二,投递器的扫描策略。常见做法是按状态加时间索引,扫"待发送"且创建时间早于 N 秒的记录,避免扫到还没提交的事务产生的记录。批量大小我一般设在 100 到 500 之间,太大容易长时间持锁,太小吞吐不够。扫描间隔根据消息量调整,常见的 1 到 5 秒。
第三,投递失败的处理。要有重试次数上限,超过上限进死信表并告警。死信不能只是躺着,得有人看。我一般会配一个每日的死信统计报表,超过阈值报警。
第四,消费端的幂等。消息一定会重复,这是定律不是概率。消费端必须幂等,否则前面所有的努力都白费。
这套方案有个变种是用 CDC(变更数据捕获)订阅数据库日志来发消息,好处是业务代码完全不用改,坏处是引入了额外组件,且消息内容和业务语义的映射变得间接。我倾向于在核心链路上用手写的消息表(可控性强),在边缘链路上用 CDC(省事)。
4.3 Saga 的补偿边界与不可补偿操作
Saga 的思路是把一个长事务拆成一串本地事务,每个都配一个补偿操作,失败时逆序执行补偿。听起来很美好,但实际用起来最麻烦的是补偿操作本身的可行性。
有些操作是不可补偿的。发出去的短信、推送出去的通知、给第三方调用的扣款、已经生效的合同——这些你没法"撤销",只能"再发一条更正"。所以设计 Saga 的第一步,是把流程里的每一步标注为可补偿、可对冲、不可逆三类。
- 可补偿:创建了记录可以删除、冻结了额度可以解冻、占用了库存可以释放。
- 可对冲:不能删除但可以反向操作,比如账户加钱可以用减钱对冲(要留痕)。
- 不可逆:短信已发、外部系统已确认。
对于不可逆的操作,唯一的办法是把它挪到流程的最后一步,前面所有可能失败的步骤都完成了,再做不可逆的事。这样失败发生在它之前的概率最大,一旦走到它,说明前面的都成了。
还有一点:Saga 的补偿不保证隔离性。中间状态对其他人是可见的。如果你的业务不能容忍"订单处于半完成状态被查询到",那就要么加状态标记让查询过滤掉,要么就别用 Saga。
4.4 什么时候该放弃强一致,直接上对账
这是个很现实的判断。有些业务场景,做一套完整的分布式事务方案,投入产出比极低,不如老老实实做对账。
判断标准我看三条:
第一,错误率。如果历史数据显示这类不一致的发生率低于万分之一,且单次影响可控,那对账加人工处理完全够用。
第二,修复成本。如果差异数据可以自动修复(比如重新触发一次同步),那对账就是最优解。如果每条差异都要人工分析半天,那还是前置拦一下更划算。
第三,业务对延迟的敏感度。如果业务要求毫秒级响应,任何同步的分布式事务都会拖慢响应,这时候异步加对账是唯一选择。
说个反直觉的结论:大部分业务系统,对账能解决的问题比 TCC 多。因为 TCC 只能处理"预料之中的失败",而对账能发现"预料之外的问题",包括代码 bug、配置错误、人工误操作。TCC 是防君子不防小人,对账是兜底。
5. 幂等、空回滚、悬挂:分布式事务最容易被忽略的三个细节
前面讲的都是架构层面的选择,这一节讲落地时的细节。这三个问题之所以单拎出来讲,是因为它们不会在正常流程里暴露,只在异常路径上出现,测试环境很难复现,上线之后才会咬人。
5.1 幂等的三种实现与各自的边界
幂等的实现方式主要有三类,各自的适用边界差别很大。
第一类是唯一约束。在数据库上加一个业务唯一键,重复插入直接被拒。这是最简单也最可靠的,但它只适用于"插入"语义,更新和删除用不了。另外要注意唯一键的选择——用业务单号比用自增 ID 靠谱,因为业务单号是由上游保证唯一性的。
第二类是状态机前置条件。每次状态流转都带上原状态作为条件,比如UPDATE order SET status='PAID' WHERE id=? AND status='UNPAID'。影响行数为 0 说明状态已经变过了,直接返回成功。这个方式优雅且高效,但要求你的状态流转是明确的、单向的。如果状态可能来回横跳,就不适用。
第三类是去重表 / Token 机制。请求进来先拿一个唯一标识去去重表里登记,登记成功才继续处理。适用于没有自然唯一键的场景,比如某些计算型操作。代价是多一次写。
实际项目里我通常第一类和第二类一起用,第三类尽量不用(因为它本身要维护一张表,还要处理登记成功但业务失败的情况)。
边界要说清楚:幂等只保证"重复执行结果一致",不保证"只执行一次"。如果操作有副作用(比如发短信),重复执行虽然状态一致,但短信会发两条。这种场景要么把副作用也做成幂等的(用去重表),要么把副作用挪到单独的通知环节。
5.2 空回滚是怎么产生的,怎么拦住
空回滚是 TCC 模式下的经典问题。场景是这样的:Try 请求因为网络问题没到达(或者到达了但超时),全局事务判定失败,发起 Cancel。这时候 Cancel 先于 Try 到达,或者到达时发现根本没有 Try 的记录。
如果 Cancel 逻辑是"无条件释放资源",那它在没有 Try 的情况下执行,可能释放了别的业务占用的资源,或者释放了一个不存在的额度,导致数据错乱。
拦住的办法是:Cancel 执行前先检查是否存在 Try 记录。如果不存在,说明 Try 没执行过或者还没到,此时应该记录一条"空回滚"标记然后返回成功,不做任何实际的资源释放。
这个"空回滚标记"还有第二个用途,就是解决悬挂问题。
5.3 悬挂问题的状态机解法
悬挂是空回滚的孪生问题:Try 请求在网络里飘了很久,等全局事务已经回滚完成(Cancel 也执行过了),Try 才姗姗来迟到达。这时候如果 Try 正常执行,就会预留一批资源,而这批资源永远不会被释放,因为全局事务早就结束了。
解法就是利用上一条说的空回滚标记:Try 执行前,先检查是否已经存在回滚记录或空回滚记录。如果有,直接拒绝这次 Try,返回失败。
用一个状态机来表述整个逻辑:
| 当前状态 | 收到 Try | 收到 Cancel |
|---|---|---|
| 初始 | 执行预留,置为 RESERVED | 置为 ROLLED_BACK(空回滚) |
| RESERVED | 幂等返回成功 | 释放资源,置为 ROLLED_BACK |
| ROLLED_BACK | 拒绝,返回失败 | 幂等返回成功 |
| COMMITTED | 拒绝 | 幂等返回成功 |
这个状态机看起来简单,但必须在存储层面落地,也就是每个事务分支的状态要在数据库里有记录,且状态流转用条件更新保证原子性。靠内存里的标记是不行的,因为 Cancel 和 Try 可能在两台机器上。
注意:这套机制依赖一个前提——同一个事务分支的 Try 和 Cancel 会落到同一个存储上。如果做了分库分表,要保证路由键一致,否则查不到状态记录,空回滚判断就失效了。
5.4 消息重投与重复消费的实测数据
说点实测的。我们内部做过一次压测,模拟消息中间件在消费者处理时间较长时触发重投。配置上,多数消息中间件在消费超时或者消费端没有及时确认时会重新投递,重投间隔通常从几秒开始逐次拉长。
实测结果是:当消费者处理耗时超过可见性超时的 60% 时,重复消费的概率开始明显上升。也就是说,如果你的可见性超时是 30 秒,而消费逻辑平均要跑 20 秒,那基本每次流量高峰都会有重复。所以配置上有个经验值:可见性超时至少设为消费平均耗时的 3 倍,且要覆盖 P99。
重复消费本身不可怕,可怕的是消费逻辑不幂等。我见过最隐蔽的一类 bug 是:消费逻辑里有"先查后写",查的时候没查到,写的时候并发两个请求都写成功了,因为没有唯一约束。这种在压测里很难发现,因为需要精确的并发时序。
处理方式还是老一套:数据库唯一约束是最后的防线,不能指望业务逻辑自己判断得滴水不漏。
6. 对账体系怎么搭:从 T+1 全量到准实时差异池
对账这件事,说起来是"核对两边数据",做起来细节很多。设计得不好,要么每天跑完对账花六个小时,要么差异列表几万条没人看,要么差异处理完第二天又出现一批同样的。
6.1 对账的三种粒度与数据来源
按粒度和时效,我一般把对账分成三档:
第一档,T+1 全量对账。每天凌晨跑一次,把两边的全量数据或者当天的增量数据拉出来比对。这是标配,任何跨服务流程都应该有。数据来源可以是各服务的数据库从库(注意从库延迟问题,一般会设定一个安全水位,比如只对账前一天的数据),也可以是各服务自己导出的对账文件。
第二档,准实时增量对账。按小时或者按分钟跑一次增量比对,只对最近一段时间内的数据。适用于资金类、高价值订单类业务。实现上一般是订阅变更事件,两边分别记录到一张对账宽表里,定时比对。
第三档,实时核验。在关键节点上做同步的双向确认,比如支付回调之后立即回查一次订单状态。这一档开销最大,只在最关键的环节用。
数据来源的选择上,有个原则:尽量用双方各自的"权威数据"来对,而不是用一方的数据去推另一方。比如订单和支付的比对,应该拿订单库里的应付金额和支付库里的实付金额直接比,而不是拿订单库里的金额去反推支付应该是什么。
6.2 差异分类与自动处置策略
差异捞出来之后,最怕的是不分青红皂白全扔给人工。我的做法是先分类,能自动处理的自动处理,剩下的才进人工池。
常见的差异分类和处置方式:
| 差异类型 | 特征 | 处置方式 |
|---|---|---|
| 单边账 | 一侧有记录,另一侧完全没有 | 查询事件日志确认是否漏处理,可按规则补录 |
| 金额不符 | 两侧都有记录但金额不同 | 以权威侧为准,自动冲正差额并留痕 |
| 状态不一致 | 一侧已完成,另一侧仍是处理中 | 检查是否卡在中间态,触发状态推进 |
| 时序错乱 | 事件顺序与预期不符 | 一般是消息乱序,需要幂等重放修正 |
| 重复记录 | 同一业务单号出现多条 | 保留一条主记录,其余标记作废 |
自动处置的关键是留痕。每一条自动修复都要记录:什么时候、修复了什么、依据是什么、修复前后的值。这不只是为了审计,更是为了出问题时能回滚。
自动修复的规则要有"安全带":单次批量修复的条数设上限,超过上限暂停并告警。我吃过这个亏——某次规则写错了,一次跑批把几千条正常数据给"修正"成了错的值,等发现的时候已经很难回滚了。
6.3 对账任务本身的工程细节
对账任务是个批处理,它的工程细节和在线服务完全不同,但同样重要。
第一,可重入。对账任务必须能从任意中断点重新开始,且结果幂等。实现方式是按业务日期分片,每个分片独立处理并记录完成标记。
第二,限流。对账会大量读数据,容易打垮从库。要控制并发度和批大小,我一般按从库的剩余容量来定,读取速率不超过从库承载能力的 30%。
第三,对账结果的可追溯。每次对账跑完,要保存一份完整的结果快照,包括对账的总条数、差异条数、差异率、处理情况。这份快照是对账质量本身的度量,能看出系统健康状况的变化趋势。
第四,差异率的告警阈值。差异率突然升高,往往意味着上游有变更或者有故障。我一般设两级:超过 0.01% 预警,超过 0.1% 告警并自动暂停自动修复(只捞不修,等人工确认)。
7. 出问题时的排查链路:顺着单据号把断点挖出来
讲完设计,讲排查。因为设计得再好也会有对不上的时候,能不能快速定位决定了故障的持续时间。
7.1 先看状态机,再看事件流
排查跨服务的数据不一致,我的固定顺序是:先看单据的状态机,再看事件流的时间线,最后看下游服务的处理日志。
看状态机是为了确定"卡在哪一步"。比如订单状态停在"支付中",那问题一定在支付回调这一段,不用去看发货逻辑。这一步能砍掉 80% 的排查范围。
看事件流是为了确定"这一步发生了什么"。理想情况下每个状态变更都对应一条事件记录(谁触发的、什么时间、结果如何),把这些记录按时间排出来,断点一目了然。
看下游日志是为了确定"为什么没处理成功"。可能是消息没收到、可能是收到了但处理报错、可能是处理成功了但回写失败。
这个顺序的价值在于从粗到细,每一步都在缩小范围。反过来,一上来就翻日志 grep,是最低效的方式。
7.2 一个真实的排查过程复盘
说一个我印象比较深的案例。某天发现有一批订单,支付成功了但订单状态还是待支付。数量不大,几十条,但时间分布很分散,不是集中在某个时间点。
第一步,看状态机。确认这些订单确实停在"待支付",支付侧是成功的。
第二步,看事件流。发现这些订单在支付成功之后,没有产生"支付成功事件"的记录。也就是说,问题出在支付回调的处理环节。
第三步,看支付服务的日志。发现回调请求确实收到了,也返回了成功,但事件没有发出去。
第四步,深入看代码。发现回调处理逻辑是:更新支付单状态 → 发消息。这两步在同一个本地事务里(通过消息表实现)。那为什么消息表里没有记录?
第五步,查数据库。发现消息表里确实有记录,但状态是"待发送",而且创建时间已经很早了。
第六步,看投递器。投递器有个过滤条件,只扫"创建时间在最近 24 小时内"的待发送记录。这些消息因为某种原因(当时投递器刚好在重启)没被及时发出去,超过 24 小时之后就永远扫不到了。
根因清楚了:投递器的扫描窗口设计有缺陷,把"老旧但未发送"的记录排除在外了。
修复方式很简单:去掉时间窗口过滤,改成按状态加索引扫描。同时加了一个监控指标——"待发送消息的最老年龄",超过阈值告警。这个指标后来救过好几次。
这个案例的教训是:任何带时间窗口的扫描逻辑,都要想清楚"如果这条记录在窗口期内没被处理,会怎样"。绝大多数情况下答案都是"永远丢失",而这正是最危险的情况。
7.3 排查工具与日志规范
排查效率很大程度上取决于日志质量。我要求团队做到几条:
统一追踪标识。每个请求带一个全局的 trace id,跨服务透传,所有日志都带上。这样一次检索就能把整条链路串起来。业务单据号也要打进日志,因为用业务单号查比用 trace id 查更符合人的思维习惯。
状态变更必须留痕。每次状态流转记一条记录,包含原状态、新状态、触发来源、操作人(或系统)、时间戳。这条记录比业务日志重要得多,因为它是对账和排查的共同基础。
关键决策点打日志。比如"库存校验通过"、"优惠券校验失败,原因:已使用",这类判断的结论要记下来。不然出了问题你只能看到最终结果,看不到为什么走到这个结果。
错误日志要有上下文。只打一个异常堆栈是不够的,要带上入参、关键中间变量、当前状态。当然敏感信息要脱敏。
工具层面,一个能按业务单号一键聚合所有相关日志和状态记录的查询页面,价值极高。我们内部做过一个简单的,接入之后平均排查时间从小时级降到了分钟级。
8. 让完整性问题在用户投诉前暴露:监控与演练
最后聊监控。完整性问题有个特点:它不一定会导致接口报错,往往是悄悄地积累,等到财务对账或者用户投诉才爆发。所以必须有一套主动发现的机制。
8.1 该监控哪些指标
我把完整性相关的监控指标分成四类:
第一类,流程指标。每个关键流程的成功率、失败率、超时率、平均耗时。这类指标能反映整体健康度,但对单条数据的完整性不敏感。
第二类,中间态指标。处于中间状态的单据数量和停留时长。比如"支付中"停留超过 5 分钟的单据数、"预留中"超过 1 小时的库存数。这类指标是发现卡单的关键,我强烈建议每个中间态都配上。
第三类,差异指标。对账差异条数、差异率、自动修复成功率、人工待处理积压量。这些是对账体系的输出,直接反映数据质量。
第四类,补偿指标。补偿任务的执行次数、成功率、失败原因分布。补偿频繁触发本身就是个信号——说明前置环节有问题。
指标之外,还要有业务层面的断言。比如"每日完成的订单数应该等于支付成功数减去退款数",这类断言能发现单点监控发现不了的系统性问题。实现上可以做成每天定时跑的校验任务,结果不符就告警。
8.2 故障注入与演练怎么做
监控只能发现已经发生的问题,演练能发现"还没发生但迟早会发生"的问题。
我一般会做三类演练:
第一类,依赖超时演练。人为给某个下游服务注入延迟或者直接拒绝,观察上游的降级行为是否符合预期,以及恢复之后数据是否一致。这类演练最容易暴露的问题就是前面说的悬挂——延迟的 Try 在 Cancel 之后到达。
第二类,消息异常演练。模拟消息丢失、重复投递、乱序投递。重点验证消费端的幂等和顺序处理逻辑。乱序是最容易被忽略的,很多消费逻辑默认消息有序,实际中间件不保证。
第三类,数据修复演练。人为制造一批差异数据,跑一遍对账和自动修复流程,验证修复逻辑是否正确、是否留痕、是否可回滚。这个演练的价值在于——你永远不希望第一次执行自动修复是在生产事故现场。
演练的频率上,核心链路我建议每个季度至少一次,每次选一个场景深入做,而不是每次都把全部场景跑一遍走个过场。演练之后必须复盘,把发现的问题变成具体的改进项,否则演练就是一场表演。
我个人在实际操作中的体会是:完整性这件事,投入的边际收益在前期特别高。你在设计阶段多花两天想清楚校验放哪、状态怎么流转、对账怎么对,能省掉后面几个月的救火。反倒是等到问题暴露了再去补,成本会翻好几倍——因为那时候你面对的是已经在跑的业务、已经积累的脏数据、已经被用户形成的预期。
还有一个容易被忽略的点:完整性机制本身也需要被监控。对账任务挂了、补偿任务停了、消息投递卡住了,这些都会让兜底机制失效。所以兜底机制的存活状态,要比业务本身监控得更严格。我现在的习惯是,任何兜底任务都必须配一个"心跳"指标,超过预期时间没跑就直接告警到人。这个简单的动作,帮我避免过至少两次"兜底失效导致问题积累一周"的事故。