4.2 掉单补偿:主动查单与拉单

掉单是用户付了钱、订单却停在待支付的现象。它不一定是我们系统的 bug,是分布式系统天然存在的「不知道对方到底成没成功」问题。用户付钱这个动作发生在渠道,回调通知经过网络到达我们,中间任何一段都可能丢。等用户投诉才发现掉单,是最差的处理方式。

场景:掉单的三种来源

掉单按来源分三种,处理方式各不相同。回调丢失:渠道发了回调我们没收到(服务重启、网络抖动)。回调处理失败:收到了但业务异常导致状态没更新成功。渠道系统问题:渠道自己不确定支付状态,需要查证。三种都需要同一个能力:主动去渠道查「这笔订单到底付出去了没有」。

方案:两层查单机制

被动等回调不行,主动查单也要分两层,避免全量查询压垮渠道接口:

层次 查询对象 触发时机 查询频率
实时查单 用户当前订单 用户查看订单页 用户主动触发
定时捞单 近期待支付订单 定时任务 每分钟一批
全量对账 历史所有订单 日终批处理 每日一次

实时查单解决用户体验:用户付款后看不到订单状态,点一下刷新,前端调用查单接口,订单中心主动去渠道查一次。定时捞单兜底:捞范围控制在创建时间 5 分钟到 30 分钟内的待支付订单,这个窗口覆盖了绝大多数延迟回调。

代码:定时捞单的核心步骤

@Scheduled(fixedRate = 60000)
public void sweepPendingOrders() {
    List<Order> pendings = orderDao.listByStatusAndTime(
        OrderStatus.PENDING_PAY,
        now.minusMinutes(30), now.minusMinutes(5));
    for (Order o : pendings) {
        PayResult r = channelClient.query(o.getOrderNo());
        if (r.isPaid()) {
            orderService.handlePaySuccess(o.getOrderNo(), r.getChannelSeq());
        }
    }
}

两个细节。一是处理用状态机的条件更新入口,与回调共用同一条链路,捞单发现已支付就相当于补一次回调,逻辑完全复用。二是 channelClient.query 有调用频率限制,批量查询时对渠道接口做令牌桶限流,避免我们自己把渠道接口打挂。

踩坑:掉单补偿的三个坑

第一个是查询放大。某次定时任务的 where 条件写错,把全库的待支付订单都捞出来了,包括三年前创建的垃圾单,批量查询渠道接口直接触发渠道的限流封禁。加上时间窗口之后解决。捞单范围是设计核心,宁少勿多。

第二个是重复处理。捞单与回调并发执行,两个都拿到已支付的结果,都想做状态更新。因为都走状态机条件更新,只会成功一次,第二次返回 false 时直接当幂等处理,不当错误。这一点在 状态机设计 里已经强调过,所有兜底逻辑共用同一个入口才简单。

第三个是渠道查不到丢单。某些渠道接口的流水可能晚于支付成功 10 分钟才可查,立即查单会得到「未支付」的错误结论。所以实时查单在渠道侧不可查时,不立即关单,而是打上待复核标记,交给定时任务再捞一轮。任何「查不到就关单」的激进决策都可能冤枉用户的钱,这与金融系统的「宁可晚不可错」原则一致。

掉单补偿与日终对账是配套机制:对账是最后兜底,发现所有漏网之鱼,参见 对账设计 一节。补偿机制的设计思路在 高并发系统稳定性 中也有相关讨论。

进阶实战:兜底机制的自我保护

掉单补偿本身也是一个「会犯错的服务」,它查渠道、改数据,万一自己出了 bug,影响面比单个回调更大。我们给它加了三重自我保护。

第一重是查询限流:定时任务扫一批单子,对渠道接口的调用走令牌桶,每秒不超过渠道规定的 50 次。第二重是半开熔断:渠道接口错误率超过 10% 时熔断一分钟,避免把渠道压坏。第三重是补偿任务幂等:任务实例是分片的,每个分片处理结果落库带任务批次号,跑过的批次不会重跑。

保护机制 阈值 动作
查询限流 50 QPS 令牌桶
错误率熔断 10% 熔断 1 分钟
批次幂等 任务批次号 已跑跳过
大单延迟 金额超 1 万 双人复核

掉单处理里的一个产品策略也值得说。大额订单的掉单补偿完成后,系统不自动把状态改成已支付,而是进入人工复核队列。理由是大额订单一旦处理错误损失大,人工复核成本可控。自动化是好事,但不是所有事情都要自动化,越高风险的操作越要给人留一道。

第三个实战经验是「查单结果的可信度分级」。渠道返回已支付的可信度最高,直接处理;返回未支付的可信度中等,标记待复核;返回查无此单的可信度最低,需要人工判断是渠道延迟还是真的丢单。把渠道返回值按可信度分级,而不是一律当真,是少犯错的实用方法。

速查清单

掉单补偿的三层机制:

层次 频率 范围
实时查单 用户触发 当前订单
定时捞单 每分钟 5 到 30 分钟待支付
日终对账 每日 全量历史

小结

掉单治理靠主动查单补齐被动回调:用户实时查、系统定时捞、日终全量对账三层递进。所有补偿都复用业务入口与状态机,捞单范围严格圈定,查不到不等于没付。下一篇讲超时关单,没钱的订单怎么体面地关掉。

常见误解:掉单补偿的三个陷阱

第一个陷阱是「查单接口随便调」。渠道的查单接口往往有调用频率限制(一般每秒几十次),超量会被限流甚至封禁。我们早期没控制频率,批量扫单时把渠道接口打到限流,影响到其他正常的查单请求。令牌桶限流之外,还做了「按单优先级查单」:临近超时的订单先查,刚下单的订单后查,把有限的查询预算花在刀刃上。

陷阱 后果
查单不限流 渠道封禁接口
只信查单结果 时间差诱发误关单
补偿任务不可观测 堆积无人知

第二个陷阱是「查单结果全盘照收」。渠道返回「未支付」可能只是流水还没入账(前面说过延迟十分钟的例子),如果这时候关单,用户几分钟后收到扣款短信就麻烦了。处理方式是查无此单直接进人工队列,未支付做标记延后再查,永远不要在查不到的情况下自动关单一刀切。

第三个陷阱是「补偿任务自身无监控」。捞单任务卡住了谁也不知道,掉单继续积累。我们给补偿任务加了生命周期监控:每批次执行耗时、处理单数、失败单数、队列堆积全部上大盘。任务也是服务,服务就要有监控,这个观念把好几次潜在事故消灭在萌芽阶段。

团队协作:掉单工单的客户视角

掉单的用户体验取决于沟通。我们对掉单处理结果做了产品化:一旦补偿任务修复订单状态,系统自动给用户发一条「订单已恢复,感谢等待」的推送。看似小事,实际上客服工单减少了大半——用户不是不能等,是不能不知道发生了什么。补偿过程中的状态透明化,是这个教训的核心。

用户感知节点 系统动作
用户付款无结果 待复核提示
补偿任务处理中 「正在处理」状态
补偿完成 主动推送结果
补偿失败 人工客服介入提醒

掉单还有一个「教育用户」的小设计:查单接口返回时附带上支付结果的说明文案。有时候用户已经付成功了只是页面没刷新,接口返回「您的订单已支付成功,正在履约」,用户就安心的离开了。好的系统反馈能化解大部分焦虑,减少工单于无形之中。

这套用户体验设计后来被反向输出:客服把用户高频问题整理给产品,产品反过来要求接口返回更友好的状态文案。技术与产品的这种良性循环,让掉单从一个纯技术问题演变成了体验设计问题。用户要的从来不只是系统正确,还有被妥善对待的感觉。掉单补偿后来成了新客服入职培训的案例课,因为它同时讲了系统原理与服务沟通两件事。一个能被拿去培训的案例,本身设计就足够清楚了。

延伸阅读