ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微服务业务完整性验证:状态机、幂等与对账三道防线

微服务业务完整性验证:状态机、幂等与对账三道防线

1. 先把"业务完整性"和"数据一致性"这两笔账分开算

做微服务这几年,我被问得最多的一类问题是:"订单支付成功了,库存没扣,这算事务没做好吧?"每次我都得先纠正一句:这多半不是事务问题,是业务完整性验证没设计到位。在微服务架构里谈业务完整性,跟当年单体应用里@Transactional一把梭完全不是一回事。单体时代,一个本地事务把订单、库存、账户三张表圈在一起,数据库帮你保证"要么全成要么全败";拆成服务之后,三个库三个连接,数据库根本不认识这三件事之间有因果关系,它只保证自己那张表内部自洽。

所以这篇文章想聊的,是微服务架构中的业务完整性验证设计——注意是"验证",不是"保证"。这两个词差别很大:保证是幻想,验证是工程。我们要做的不是造一个跨服务的神奇事务,而是设计一整套可观测、可补偿、可对账的机制,让"业务规则被破坏"这件事在系统里能被及时发现、准确定位、自动或半自动修复。适合谁看?正在做服务拆分、被分布式事务方案选型折磨过的后端同学;线上出过"数据看着都对、业务就是不对"事故的运维和测试同学;以及准备把单体系统往微服务迁移、想提前避坑的架构同学。下面我按自己实际趟过的路子,从概念拆解到代码落地完整讲一遍,能抄的部分直接抄。

1.1 业务完整性管的是"规则层"的账,不是"存储层"的账

我习惯给团队新人画一条分界线:数据一致性回答的是"同一份数据在多个副本、多个存储里是否相同",比如主从延迟、缓存与数据库不一致、分库分表后跨分片查询漏数据;业务完整性回答的是"这组业务规则是否同时成立"。举个最典型的例子:库存扣减成功、订单创建失败,此时库存表、订单表各自内部完全一致,主从也没延迟,缓存也是对的——数据一致性满分,业务完整性零分,因为"有扣减就必须有对应订单"这条规则被打破了。

业务完整性在系统里通常表现为一组不变式(invariant),我一般会把它写成可判定的表达式,比如:

  • 订单状态为"已支付"时,必然存在一条成功的扣款流水,且金额等于订单应付金额;
  • 库存的"已锁定数量"之和,必然等于所有处于"待履约"状态订单的明细数量之和;
  • 用户可用额度 + 已冻结额度 + 已消耗额度,恒等于总额度;
  • 一笔退款完成时,其累计退款金额不得超过原单可退金额。

这些表达式有个共同特点:跨服务、跨表、可计数。它们不关心你是用 MySQL 还是 TiDB,只关心最终的数字对不对得上。这也是为什么我说业务完整性验证的核心动作是"对账"——把两边该相等的数字拿出来比一比。很多团队一上来就奔着分布式事务框架去,花两个月接了 Seata,结果线上真正出问题的地方是"运营手动补数据补错了""消息重复消费导致积分加两次",框架一点忙帮不上。

1.2 微服务一拆分,完整性漏洞是从哪几个缝里漏进来的

我把这些年遇到过的事故做了个归类,漏洞来源基本就这么五类,而且每一类对应的防护手段完全不同:

漏洞来源典型表现影响范围主要防护手段
跨库事务缺失扣库存成功、建订单失败单笔业务残留脏数据状态机 + 补偿
同步调用部分成功调用超时但下游已执行重复执行或漏执行幂等键 + 查询确认
异步消息至少一次投递积分重复到账、通知重复发数据放大消费端幂等
消息乱序与延迟先收到"退款成功"后收到"支付成功"状态被覆盖回退版本号 + 状态机守卫
人工运维与脚本补数据脚本条件写错批量脏数据,最难查变更审计 + 对账兜底

第一类最好理解。第二类最阴险——调用方超时了,它不知道该不该重试;下游其实已经执行成功,重试就重复,不重试就可能漏。这时候正确的做法不是纠结重试策略,而是给这次调用一个唯一业务号,下游按业务号做幂等,调用方超时后主动查询结果,用"查询确认"代替"盲目重试"。第三类在消息中间件里是常态,任何宣称"消息只投一次"的方案我都持怀疑态度,工程上就按至少一次设计,消费端自己扛幂等。

第四类最容易被忽略。我见过一个场景:支付回调和退款回调几乎同时到达,由于没有版本号保护,"退款成功"先写入(状态 3),"支付成功"后写入(状态 2),最终订单卡在"已支付"但钱已经退了。这种问题的排查成本极高,因为日志上看每一步都是"合法的状态迁移",只是顺序不对。第五类则是纯管理问题,但它造成的破坏往往最大,因为脚本一跑就是几千条,而且不经过任何业务校验。所以后面我讲的三道防线里,专门有一道是给"人"准备的。

2. 整体设计骨架:三道防线,别指望一道事务通吃

方案选型这块我踩过不少坑,先把结论放前面:不要试图用一种技术手段解决所有完整性问题。XA/2PC 性能代价大、参与者一多就锁等待;Seata AT 模式依赖全局锁,热点行上并发一高就要重试到怀疑人生;纯 Saga 补偿逻辑写起来比重写一遍业务还累。比较务实的做法是把完整性验证拆成三道防线,各管一段,层层兜底。

2.1 第一道防线:入口约束 + 幂等,把明显不合法的请求挡在门外

第一道防线放在服务入口,做的是"事前"的事:参数校验、状态前置检查、唯一约束、幂等控制。它的目标很朴素——不能让不合法的请求走进来。比如"取消订单"接口,进来第一件事不是去调库存服务释放库存,而是先校验订单当前状态是否允许取消(已支付、未发货才允许),这一步用本地数据库的条件更新就能做到,update order set status=4 where id=? and status=2,影响行数为 0 就直接返回失败,根本不进入后续流程。

这一层还有个关键点是唯一索引。很多业务完整性问题是可以通过数据库唯一约束消灭的,比如"同一订单同一类型只能有一条成功的退款流水",那就建uk_order_type(order_id, refund_type, status)这样的联合唯一索引(把状态设计成只对成功态生效的形式,或者用单独的流水号)。数据库的唯一约束是最后一道物理保险,它不依赖任何应用逻辑,应用写错了它也不放行。我一直建议团队:凡是能用唯一索引表达的业务规则,优先用唯一索引,代码里再补校验只是为了让报错更友好。

2.2 第二道防线:状态机 + 补偿,让流程可中断、可续跑、可回退

第二道防线是"事中"的。跨服务调用必然存在中间态,关键是让中间态显式、可停、可续。我的做法是把每个核心业务对象建模成状态机,状态迁移必须带守卫条件(guard),迁移过程记录在事件表里。这样即使某一步失败了,系统里也留着一个"卡在中间态"的记录,后续的补偿任务或人工介入都有据可依。

补偿这块要提前想清楚:哪些动作是可逆的(冻结额度可以解冻、锁定库存可以释放),哪些是不可逆的(已发出的短信、已生成的电子凭证)。可逆动作走自动补偿,不可逆动作必须前置——也就是在真正执行不可逆动作之前,确保所有可逆动作都已完成并且结果已确认。我通常会把流程切成"预占—确认—执行"三段:预占阶段全是可逆操作,确认阶段只做状态翻转,执行阶段才碰不可逆的副作用。这样一旦出问题,绝大多数情况下停在预占阶段,清理起来很干净。

2.3 第三道防线:异步对账与巡检,兜住所有漏网的差异

第三道防线是"事后"的,也是我认为最不能省的一道。前面两道防线的假设是"代码没写错、配置没配错、人没操作错",但现实里这三件事迟早会发生,所以必须有一个跟业务代码解耦的独立校验通道。对账任务的核心逻辑很简单:按时间窗口把两边的业务数据拉出来,按业务主键做全量或增量比对,差异落表、分级告警、自动修复或转人工。

这里有个设计要点:对账逻辑必须独立实现,不能复用业务代码的查询方法。复用看起来省事,但如果业务代码本身有 bug(比如查询条件漏了某个状态),对账会跟着一起错,等于自己检查自己。我们的做法是对账任务直接读库、直接写 SQL,跟业务服务的代码完全隔离,甚至部署在独立的对账服务里,用只读账号连库。

三种方案的成本与适用面,我做了个对比,选型时可以直接对照:

方案一致性强度性能开销实现复杂度适用场景
XA / 2PC强高,锁持有时间长低(框架封装)参与方少、并发低的内部系统
TCC 事务最终强一致中高,每个参与方要写三个方法资金、额度类核心链路
本地消息表 + 补偿最终一致低中绝大多数异步解耦场景
状态机 + 对账兜底最终一致极低中长流程、多参与方、跨天业务

绝大多数团队其实落在最后两行。别被"最终一致"这四个字吓到,业务上真正要求毫秒级强一致的场景比想象中少得多,用户点完支付等 200 毫秒看到"处理中"完全可接受,关键是不能永远停在处理中。

3. 核心机制拆解:状态机、幂等、消息、补偿怎么落地

这一章讲的是第二道防线里的四个零件,每个零件我都给出具体的实现思路和踩过的坑。

3.1 状态机:把业务规则写成可判定的迁移表,而不是散落的 if-else

状态机最朴素的做法就是一张迁移表:当前状态、触发事件、目标状态、守卫条件。我一般会在代码里用枚举 + Map 表达,或者干脆存到数据库配置表里,方便运营调整。核心接口就两个:canTransit(from, event)和transit(id, event, expectVersion)。前者用于前置校验,后者用乐观锁执行迁移。

迁移必须带期望版本号,这是防并发覆盖的关键。SQL 大致长这样:

UPDATE biz_order SET status = #{toStatus}, version = version + 1, update_time = NOW() WHERE id = #{id} AND status = #{fromStatus} AND version = #{expectVersion};

影响行数为 1 才算成功,为 0 说明状态已经被别人改过,此时不要去猜,直接查一次最新状态,再判断当前事件是否还适用。这套机制看起来笨,但它把"并发下状态被覆盖"这类最难查的问题变成了"更新失败",问题从不可见变成可见,这本身就是巨大进步。

### 3.2 幂等键的选择:业务号优于流水号,流水号优于时间戳

幂等的实现方式我按可靠性排了个序。最可靠的是业务唯一键,比如"订单号 + 操作类型",因为它天然去重,同一个订单的同一个操作不管来多少次都只算一次。其次是调用方生成的请求流水号,需要保证全局唯一且同一业务重试时复用同一个号——这里有个坑,很多团队重试时重新生成流水号,幂等直接失效。最差的是用时间戳或 IP 做键,基本等于没有。

幂等表的建法我推荐"唯一索引 + 插入即判定":先insert,捕获唯一键冲突异常就说明重复,直接返回上次的结果。这里要注意两点:一是幂等表要记录处理结果,不能只记录"来过",否则重复请求拿不到结果只能报错;二是幂等表要有过期清理策略,不然一年下来几亿行,索引都撑不住。我的经验值是保留 7 到 30 天,具体看业务重试窗口,一般重试不会超过 24 小时,留 7 天足够安全。清理用定时任务分批删,每批 1000 行,间隔 200 毫秒,避免长事务锁表。

### 3.3 消息可靠投递:本地消息表 + 定时补发,比事务消息更好维护

跨服务异步通知,我用得最多的是本地消息表(Outbox 模式)。业务操作和消息落表在同一个本地事务里完成,这样"业务成功但消息没发"这个经典问题就被消灭了——要么都成功,要么都回滚。然后由一个独立的投递线程扫表发送,发送成功标记已发送,失败按退避重试。

退避间隔我一般这样配:1 秒、5 秒、30 秒、2 分钟、10 分钟、30 分钟,最多 6 次,超过就进死信表并告警。为什么不无限重试?因为持续失败通常意味着下游有结构性问题(比如消息体格式不兼容、下游服务长期不可用),继续重试只会堆积。死信表要有人看,我们把它接进了每日巡检报表,超过 10 条就触发值班提醒。

扫表频率和批量大小也需要算。假设日均消息量 200 万,峰值集中在 4 小时内,那就是每小时 50 万条,每分钟约 8300 条。单次批量取 200 条,每 3 秒扫一次,理论吞吐是每分钟 4000 条,不够,那就把批量提到 500 或者把间隔缩到 1 秒。实际配置我建议留 50% 余量,别卡着算。表上必须建(status, next_retry_time)联合索引,否则扫表全表扫描,越跑越慢。

3.4 补偿的边界:哪些能自动补,哪些必须转人工

补偿不是万能的。我给团队定的规则是:补偿动作必须是幂等的、可重复执行的、不产生新副作用的。释放库存可以补,因为释放两次和释放一次结果一样(配合状态判断);给用户发一张优惠券不能盲目补,因为多发一张就是资损。所以补偿逻辑里必须再包一层状态校验,确认目标资源确实处于"该被释放"的状态。

还有一类情况必须转人工:补偿重试超过阈值、差异金额超过设定上限、对账连续三天不平。这些情况系统已经"不确定自己是否该动手"了,强行自动修复风险大于收益。我们的做法是差异落表后进入一个简易的工单队列,附带差异详情和推荐的修复动作,人工确认后一键执行。这里有个小技巧:把推荐修复动作做成参数化的模板,人工只需要核对数字,不需要重新写 SQL,既提效又降低误操作概率。

4. 落地实操:一个跨服务下单场景的完整实现

光讲原理容易飘,我拿一个简化场景把整套东西串起来:用户下单,需要扣减库存、冻结额度、生成订单,三个动作分属三个服务(库存服务、账户服务、订单服务),走异步消息串联。这不是真实项目,是抽象出来的最小可复现模型,结构上跟大多数交易类系统同构。

4.1 表结构设计:三张核心表撑起整个链路

订单表、状态迁移事件表、本地消息表,这三张表是骨架。订单表存当前状态和版本号,事件表记录每一次迁移(谁触发、从哪到哪、什么时候),消息表负责可靠投递。

CREATE TABLE biz_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0初始化 1库存已锁 2额度已冻 3已确认 4已取消 5已关闭', version INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_status_time (status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE biz_order_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, event VARCHAR(32) NOT NULL COMMENT 'LIKE: 事件类型', operator VARCHAR(64) NOT NULL COMMENT 'system/人工工号', remark VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE biz_local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_key VARCHAR(64) NOT NULL COMMENT '业务唯一键, 幂等用', topic VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2死信', retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_key (msg_key), KEY idx_status_retry (status, next_retry_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

biz_order_event这张表看起来是"额外开销",但我强烈建议保留。出问题时,它是唯一能还原"谁在什么时候把状态改成了什么"的证据链,比翻应用日志高效十倍。春节期间我们靠它定位过一次运营脚本误改状态的问题,五分钟就查清了影响范围。

4.2 核心代码:状态迁移 + 本地消息同事务提交

下单主流程我写成三段:本地建单(状态 0)→ 发消息触发库存锁定 → 库存回调后更新状态并继续。关键是每一步的"状态更新"和"消息落表"必须在同一个本地事务里。

@Service public class OrderCreateService { @Resource private OrderMapper orderMapper; @Resource private OrderEventMapper eventMapper; @Resource private LocalMessageMapper messageMapper; @Transactional(rollbackFor = Exception.class) public void createOrder(CreateOrderCmd cmd) { // 1. 幂等: 同一业务号重复提交直接返回 BizOrder exist = orderMapper.selectByOrderNo(cmd.getOrderNo()); if (exist != null) { return; } BizOrder order = new BizOrder(); order.setOrderNo(cmd.getOrderNo()); order.setUserId(cmd.getUserId()); order.setSkuId(cmd.getSkuId()); order.setQuantity(cmd.getQuantity()); order.setAmount(cmd.getAmount()); order.setStatus(OrderStatus.INIT); order.setVersion(0); orderMapper.insert(order); // 2. 记录初始事件 eventMapper.insert(buildEvent(order.getOrderNo(), null, OrderStatus.INIT, "CREATE", "system")); // 3. 本地消息表落一条待发送消息(同事务) LocalMessage msg = new LocalMessage(); msg.setMsgKey(order.getOrderNo() + ":LOCK_STOCK"); msg.setTopic("order.lock.stock"); msg.setPayload(JsonUtil.toJson(new LockStockCmd(order.getOrderNo(), order.getSkuId(), order.getQuantity()))); messageMapper.insertIgnore(msg); } /** * 库存锁定成功后的回调: 状态迁移 + 抛下一条消息 */ @Transactional(rollbackFor = Exception.class) public void onStockLocked(StockLockedEvent evt) { int rows = orderMapper.transitStatus( evt.getOrderNo(), OrderStatus.INIT, OrderStatus.STOCK_LOCKED, evt.getExpectVersion()); if (rows == 0) { // 幂等: 已经迁移过, 或者状态被并发修改, 查一次现状再决定 BizOrder cur = orderMapper.selectByOrderNo(evt.getOrderNo()); if (cur.getStatus() >= OrderStatus.STOCK_LOCKED) { return; } throw new IllegalStateException("状态迁移失败, orderNo=" + evt.getOrderNo() + ", cur=" + cur.getStatus()); } eventMapper.insert(buildEvent(evt.getOrderNo(), OrderStatus.INIT, OrderStatus.STOCK_LOCKED, "STOCK_LOCKED", "system")); LocalMessage msg = new LocalMessage(); msg.setMsgKey(evt.getOrderNo() + ":FREEZE_CREDIT"); msg.setTopic("order.freeze.credit"); msg.setPayload(JsonUtil.toJson(new FreezeCreditCmd(evt.getOrderNo(), evt.getUserId(), evt.getAmount()))); messageMapper.insertIgnore(msg); } }

这里有两个细节值得单说。第一,insertIgnore配合唯一键,让消息落表天生幂等,重复调用不会插两条。第二,状态迁移失败时不要无脑抛异常重试,先查一次当前状态——如果已经是目标状态或更靠后的状态,说明是重复消息,直接返回即可;如果状态比预期还靠前,那才是真异常,该告警。

4.3 参数计算:超时、重试、对账窗口怎么定

这部分最容易被拍脑袋决定,其实都能算出来。

调用超时:取下游接口 P99 耗时的 3 倍,再加一个网络抖动余量。比如库存锁定接口 P99 是 80 毫秒,超时设 300 毫秒比较合适。设太小会制造大量"假失败"进而触发重试风暴,设太大则故障时线程池会被拖垮。计算依据是:3 倍 P99 能覆盖 99.9% 以上的正常请求,剩下的那部分本来也大概率是异常。

重试次数:按 1、5、30 秒、2、10、30 分钟退避,总共 6 次,累计窗口约 43 分钟。为什么是这个窗口?因为大多数下游故障(重启、扩容、短暂抖动)在 5 分钟内能恢复,给到 40 多分钟足够覆盖。超过这个窗口还不恢复,说明是结构性故障,重试没有意义。

对账窗口:T+1 全量对账 + 每小时增量对账。增量对账只扫最近 2 小时的业务(留 1 小时缓冲,避免边界数据的时钟误差),全量对账在凌晨低峰跑。订单量 100 万以内的系统,全量对账单机跑一般 20 分钟内能完成;超过这个量级就要按时间分片并行,分片数 = 总量 / 单分片处理上限,比如单分片 20 万,100 万就分 5 片。

状态停留超时告警:任何订单停留在中间态(库存已锁、额度已冻)超过 10 分钟就告警。这个阈值怎么定?取业务正常处理时长的 P99 再乘 5。正常链路 2 秒内完成,P99 大概 8 秒,乘 5 是 40 秒。我取 10 分钟是因为中间还可能有消息重试,留足余量能减少误报,同时 10 分钟对业务来说仍然是可接受的响应时间。

4.4 对账任务实现:增量比对 + 差异分级 + 自动修复

对账任务我通常独立成一个定时服务,写法上尽量"笨"——只用 SQL 和最简单的聚合,不调业务接口。

-- 差异1: 状态为"库存已锁"但库存服务无对应锁定记录(漏锁) SELECT o.order_no, o.sku_id, o.quantity FROM biz_order o LEFT JOIN stock_lock_record s ON s.order_no = o.order_no AND s.status = 1 WHERE o.status = 1 AND o.create_time >= #{startTime} AND o.create_time < #{endTime} AND s.id IS NULL; -- 差异2: 库存锁定数量与订单数量不符(数量错) SELECT o.order_no, o.quantity AS order_qty, s.lock_qty FROM biz_order o JOIN stock_lock_record s ON s.order_no = o.order_no AND s.status = 1 WHERE o.create_time >= #{startTime} AND o.create_time < #{endTime} AND o.quantity <> s.lock_qty; -- 差异3: 额度冻结总额与在途订单应收总额不符(汇总级) SELECT DATE(o.create_time) AS d, SUM(o.amount) AS expect_frozen, (SELECT SUM(freeze_amount) FROM credit_freeze_record WHERE status = 1 AND DATE(create_time) = d) AS actual_frozen FROM biz_order o WHERE o.status IN (2, 3) AND o.create_time >= #{startTime} AND o.create_time < #{endTime} GROUP BY DATE(o.create_time);

差异处理分三级:可自动修复(漏锁、重复锁,直接重发对应事件)、需人工确认(数量不符、金额差异超过 100 元)、只告警不处理(汇总级差异,可能是统计口径问题)。每一级对应不同的告警通道,自动修复的记录留痕,人工确认的进工单。我特别想强调一点:自动修复本身也要幂等,因为对账任务可能重跑,修复动作不能叠加。

5. 常见问题与排查技巧实录

这一章是我这些年攒下的"病历本",基本都是线上真出过的问题。

5.1 典型问题速查表:从现象到根因的最短路径

现象最可能根因排查动作修复方式
订单长期停在中间态消息丢失或消费失败进死信查biz_local_message中该 msg_key 的 status 和 retry_count重置 status 为 0,手动触发投递
库存被扣两次消费端无幂等,消息重复投递查库存流水表同 order_no 记录数反向冲正一条,补唯一索引
额度冻结金额对不上部分成功的调用被重试查账户服务调用日志的请求流水号按流水号去重,重算冻结额
状态被"回退"消息乱序,无版本号保护查biz_order_event时间序列补版本号 + 状态守卫,人工修数据
对账任务越跑越慢缺(status, next_retry_time)索引或表膨胀EXPLAIN 看执行计划补索引,历史数据归档
补偿任务把正常单也补了补偿条件判断不严,未校验目标状态抽查补偿日志和订单事件表补偿逻辑加前置状态校验

这里重点讲两个我印象最深的。一个是"库存被扣两次",根因是消费端用了if (记录不存在) 插入这种先查后插的写法,两个消费者并发进来都查到不存在,于是都插入了。改法很直接:加唯一索引,把并发问题交给数据库。另一个是"状态被回退",日志里每一步都合法,最后是靠事件表的时间戳排序才发现"退款事件"比"支付事件"早了 3 秒——消息乱序,解决方案就是状态机加"只允许向前迁移"的守卫,遇到旧事件直接丢弃。

5.2 避坑心得:文档里不会写但你必须知道的几条

第一条,别在补偿逻辑里调用业务主流程。我见过有人为了省事,补偿时直接重新走一遍下单接口,结果每次补偿都产生新订单。补偿必须是目标导向的——你要的是"库存释放",就直接调释放接口,别绕回业务流程。

第二条,中间态的数量要能被监控。我现在的习惯是每个核心状态都配一个"停留时长"指标,超过阈值就告警。这个指标比任何日志都灵敏,因为它是从"结果"反推"过程卡住了",不依赖你去猜哪一步会出问题。用一条简单的select status, count(*) from biz_order where create_time > now() - interval 1 hour group by status就能做出基础看板。

第三条,对账的差异表要能追溯。差异落表时,把比对用的原始数据(两边的主键、关键字段快照)一起存下来,别只存"差异类型"。因为三个月后再来查这笔差异,业务数据早就变了,没有快照根本还原不了当时的现场。

第四条,人工修复必须走工具,不能直连数据库。这条是血泪教训。我们早期让运维直接跑 SQL 修数据,出过一次改错条件的批量事故,影响了两千多笔订单。后来做了一个内部修复工具,所有操作都是参数化的模板,执行前预览影响行数,执行后自动写审计日志和事件表,还支持反悔(生成反向操作)。工具本身不复杂,但它把"人"这个最大的不确定性因素框住了。

第五条,别把完整性问题当性能问题治。有些同学看到对账任务慢就想去优化它,加缓存、加并行,结果对账逻辑变复杂了反而更容易出错。对账任务的核心诉求是"准",慢一点没关系,跑在低峰期、独立库、只读账号,稳字当头。

6. 我个人的几条经验

最后聊几句实在的。微服务下的业务完整性验证,本质上是一场跟不确定性的长期博弈——你没法证明系统永远不出错,只能让出错的代价可控、发现的速度够快、修复的动作够简单。我现在的设计习惯是:任何一个跨服务流程,先问三个问题——中间态是什么?卡住了谁能发现?发现了怎么修?这三个问题答不上来的方案,我不会让它上生产。

还有一个体会是,别过早引入重型方案。很多团队一上来就上分布式事务框架,结果发现 90% 的场景用状态机加对账就能覆盖,剩下 10% 才需要 TCC。先用最笨的办法把链路跑通、把监控立起来,等真的遇到性能瓶颈或一致性要求提升,再替换局部实现,这条路走得最稳。我见过太多团队在框架选型上耗掉两个月,而线上真正救命的,往往就是那张每天早上八点准时跑的对账报表。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进