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 分钟待支付 |
| 日终对账 | 每日 | 全量历史 |
小结
掉单治理靠主动查单补齐被动回调:用户实时查、系统定时捞、日终全量对账三层递进。所有补偿都复用业务入口与状态机,捞单范围严格圈定,查不到不等于没付。下一篇讲超时关单,没钱的订单怎么体面地关掉。
常见误解:掉单补偿的三个陷阱
第一个陷阱是「查单接口随便调」。渠道的查单接口往往有调用频率限制(一般每秒几十次),超量会被限流甚至封禁。我们早期没控制频率,批量扫单时把渠道接口打到限流,影响到其他正常的查单请求。令牌桶限流之外,还做了「按单优先级查单」:临近超时的订单先查,刚下单的订单后查,把有限的查询预算花在刀刃上。
| 陷阱 | 后果 |
|---|---|
| 查单不限流 | 渠道封禁接口 |
| 只信查单结果 | 时间差诱发误关单 |
| 补偿任务不可观测 | 堆积无人知 |
第二个陷阱是「查单结果全盘照收」。渠道返回「未支付」可能只是流水还没入账(前面说过延迟十分钟的例子),如果这时候关单,用户几分钟后收到扣款短信就麻烦了。处理方式是查无此单直接进人工队列,未支付做标记延后再查,永远不要在查不到的情况下自动关单一刀切。
第三个陷阱是「补偿任务自身无监控」。捞单任务卡住了谁也不知道,掉单继续积累。我们给补偿任务加了生命周期监控:每批次执行耗时、处理单数、失败单数、队列堆积全部上大盘。任务也是服务,服务就要有监控,这个观念把好几次潜在事故消灭在萌芽阶段。
团队协作:掉单工单的客户视角
掉单的用户体验取决于沟通。我们对掉单处理结果做了产品化:一旦补偿任务修复订单状态,系统自动给用户发一条「订单已恢复,感谢等待」的推送。看似小事,实际上客服工单减少了大半——用户不是不能等,是不能不知道发生了什么。补偿过程中的状态透明化,是这个教训的核心。
| 用户感知节点 | 系统动作 |
|---|---|
| 用户付款无结果 | 待复核提示 |
| 补偿任务处理中 | 「正在处理」状态 |
| 补偿完成 | 主动推送结果 |
| 补偿失败 | 人工客服介入提醒 |
掉单还有一个「教育用户」的小设计:查单接口返回时附带上支付结果的说明文案。有时候用户已经付成功了只是页面没刷新,接口返回「您的订单已支付成功,正在履约」,用户就安心的离开了。好的系统反馈能化解大部分焦虑,减少工单于无形之中。
这套用户体验设计后来被反向输出:客服把用户高频问题整理给产品,产品反过来要求接口返回更友好的状态文案。技术与产品的这种良性循环,让掉单从一个纯技术问题演变成了体验设计问题。用户要的从来不只是系统正确,还有被妥善对待的感觉。掉单补偿后来成了新客服入职培训的案例课,因为它同时讲了系统原理与服务沟通两件事。一个能被拿去培训的案例,本身设计就足够清楚了。
