你有没有遇到过这种场景:用户付了钱,页面上明明显示"支付成功",钱也从他卡里扣走了,可你的后台订单还停在"待支付"。你等啊等,等那个异步回调,它就是不来了。最后用户找上门问"我钱都付了怎么还不发货",你才发现这笔单子"卡"在那里,一动不动。
这就是回调丢失。做支付的人,几乎都栽过这个跟头。而我要先纠正你一个根深蒂固的误解:回调,本来就是不可靠的。它不是千兆光纤里那种保证送达的消息,它只是一个"通道有空就给你发一下"的通知,丢了、晚了、重复了,都是再正常不过的事。指望回调高比例送到,就像指望快递从来不丢件一样天真。
所以这篇文章要讲的,不是"怎么让回调不丢"——那做不到。我要讲的是:回调丢了之后,你怎么把丢掉的这笔单子救回来。答案就一个词:主动查单。
为什么不能只靠回调:三个你控制不了的因素
先想清楚一个问题:回调为什么会丢?搞明白原因,你才知道为什么"补"是必须的,而不是可选项。
第一,网络是不可靠的。通道给你发回调通知的那一刻,你的服务器可能在重启、在扩容、在打补丁,你的域名可能在换解析,你的机房可能在抖动。任何一个环节卡一下,这条通知就没了。通道通常会重试几次,但重试也有次数上限,重试完还没送到,它就放弃了。这笔交易,在通道那边是"成功",在你这边就是"黑洞"。
第二,你的回调接口可能超时或报错。通道发通知过来,是有超时限制的,通常是几秒。如果你的回调处理逻辑太重——又要验签、又要查库、又要发货、又要记账,一套下来超过了通道的等待时间,通道就判定"没送达",下次再发。更糟的是,如果你的接口抛了个异常、返回了个非 200 的状态码,通道也会认为失败了。
第三,通道的策略你管不着。有些通道,回调重试的策略是"退避式"的,第一次失败等 1 分钟,第二次等 5 分钟,第三次等半小时……间隔越来越长。如果你在第一次失败后没及时发现,等它重试到第四次,可能已经是几个小时之后了。你根本不知道它什么时候会再来,甚至它可能已经彻底不来了。
这三个因素,没有一个是你能完全控制的。所以结论很明确:回调是"尽力而为"的通知,不是"保证送达"的承诺。你的系统,必须有一套不依赖回调的兜底机制,否则丢单是迟早的事。
主动查单:把"等通知"变成"去确认"
兜底机制的名字,就叫主动查单。它的思路特别朴素:既然回调不可靠,那我就不干等它,我主动去通道那边问——"这笔订单,现在到底什么状态?"
通道一般都提供一个"订单查询"接口(也叫查单接口、query 接口)。你拿着商户订单号去问它,它告诉你这笔订单的真实状态:未支付、支付成功、已退款、已关闭等等。这个接口返回的状态,是通道自己的账本上的记录,是"真相",不依赖任何通知。
所以主动查单的本质,就是把"等通道告诉你"变成"你去通道那里核实"。回调丢了没关系,你主动去问,一样能拿到真实结果,一样能把订单状态改对、把货发出去。
这一步说起来简单,但真正落地,有几个关键设计必须做对,做错了一个,查单就是白查。下面我一个一个讲。
设计一:查单的触发时机,要盯住"悬而未决"的单子
查单不是无脑地每笔都查——那样接口会被你打爆,通道也会嫌你烦。查单只针对一类单子:状态停在"中间态"、迟迟没有结论的订单。
什么叫中间态?就是那种"既没确认成功、也没确认失败"的状态。典型的就是"待支付"和"处理中"。一笔订单,如果用户点了支付,但你的系统既没收到同步成功、也没收到回调,它的状态就会一直卡在"待支付",这就是典型的中间态。
你要做的,是定时去扫这些悬而未决的单子。比如每隔几分钟扫一次,把所有"创建时间超过 N 分钟、但状态还是待支付"的订单捞出来,批量去通道查一遍真实状态。如果查出来已经成功了,就补上状态、发货;如果查出来确实没支付,就继续等,或者按你的业务规则关闭它。
这里有个时间参数要拿捏好:N 分钟设多大?设太小,会把刚下单、用户还在输密码的单子也捞出来查,浪费接口;设太大,发现得太晚,用户等急了。我的经验是,结合通道回调的到达时间来看——正常情况下,回调在用户支付成功后几秒内就来了。所以 N 设成"远超正常回调时间、但又不至于让用户等太久",通常 5 到 15 分钟之间比较合适。具体多少,看你的业务对时效的容忍度。
设计二:查单必须幂等,别查一次改一次
这是主动查单里最容易踩的坑,也是我要重点强调的。
查单查到"支付成功"了,你要做的动作是:把订单状态从"待支付"改成"已支付",然后发货、记账。听起来没问题对吧?但你想过没有——如果这笔订单,其实回调已经正常送达过、你早就处理成"已支付"了,结果定时任务又把它捞出来查了一遍,又查到了"成功",你会不会再发货一次?
会。如果你不做幂等,就会给用户发两次货、记两次账。所以查单这个动作,和回调处理一样,必须走幂等:查单查出来的结果,只是一个"输入",真正改状态、发货的动作,必须经过幂等校验——先看订单当前状态,已经是"已支付"的,直接跳过,什么都不做;只有还是"待支付"的,才往下走。
关于幂等键怎么建、唯一索引怎么加、状态判断的顺序,我之前专门写过一篇,链接我放在文末。这里你只要记住一个原则:查单是"补漏",补漏也要守规矩,不能因为补漏反而制造新的重复。
设计三:查单的补偿任务,要有重试和退避
查单接口本身也不是高比例可靠的。你去查,通道可能返回"系统繁忙",可能超时,可能报个莫名其妙的错。这时候怎么办?总不能查一次失败就放弃了,那和没查一样。
所以查单要包在补偿任务里,失败要重试,重试要退避。
具体做法:每一笔需要查单的订单,在查单任务里都有一个"重试次数"和"下次重试时间"。第一次查失败,记下失败原因,把"下次重试时间"往后推——比如 1 分钟后再试;再失败,推 5 分钟;再失败,推 30 分钟……这就是退避。退避的好处是,既不会因为通道一时抖动就反复狂打,也不会因为一次失败就彻底放弃。
同时要设一个重试上限。你不能无限重试下去,那样任务队列会被一堆"查不出来的死单"塞满。设个上限,比如重试 10 次还查不出来,就把这笔单子标记成"异常",转人工处理,同时告警。人工介入兜底,是自动化的最后一道防线。
这里还有个细节,很多人会忽略:查单任务本身要能"断点续跑"。如果任务跑到一半,服务器重启了、进程挂了,重启之后不能从头再来一遍(那样会重复查、甚至重复发货),而是要从上次的进度继续。所以查单任务的状态要持久化,不能只存在内存里。
设计四:查单和回调,要走同一条处理链路
这是主动查单最精髓的一条设计原则,理解了它,你就理解了整个补偿机制。
回调处理,和查单处理,本质上是同一件事——都是"拿到一笔订单的真实状态,然后更新订单、发货、记账"。区别只在于,这个"真实状态"是从哪里来的:回调是通道推给你的,查单是你主动去问的。
既然本质是同一件事,那它们就应该走同一条处理链路、同一套幂等逻辑、同一个发货函数。不能回调一套代码、查单另一套代码,两套代码各写各的,迟早会出不一致的 bug——比如回调那边做了幂等,查单这边忘了做,结果查单补漏补出了重复发货。
正确的设计是:把"处理一笔支付成功"这个动作,抽象成一个统一的入口,比如叫 handlePaymentSuccess(orderNo, source),其中 source 表示"这次成功信号来自哪里"(回调 / 查单 / 人工)。回调来了,调它;查单查到成功了,也调它。入口内部统一做幂等校验、状态判断、发货记账。这样,无论成功信号从哪条路进来,结果都严格一致,永远不会重复。
这个设计,是主动查单能不能落地的关键。很多团队查单出问题,不是查单本身难,而是他们让查单和回调各搞一套,最后把自己搞乱了。
设计五:查单要防"查单风暴",控制频率和并发
最后讲一个工程上的现实问题:如果你的订单量大,定时查单任务可能会瞬间产生大量对通道的查询请求,把通道的查单接口打爆,甚至被通道限流、封号。
所以要控制两样东西:频率和并发。
频率,就是多久查一轮。这个前面说过了,结合业务时效来定,别太勤快。
并发,就是一轮查单里,同时发多少个查询请求。这个要限流,比如用信号量或者队列,控制同一时刻对通道的查询请求不超过某个阈值。宁可查得慢一点,也不能把通道打爆——打爆了,你连正常的查单都做不了,反而更糟。
另外,查单接口的响应时间也要监控。如果发现查单越来越慢、超时越来越多,说明通道可能扛不住了,或者你的重试策略太激进,这时候要主动调低频率,而不是硬扛。
把主动查单变成你的"安全网"
写到这里,我想再回到开头的那个场景。用户付了钱、回调丢了、订单卡在"待支付"。如果你有主动查单,这个单子会在几分钟内被自动救回来:定时任务捞到它、去通道查到"已成功"、走幂等链路更新状态、发货,用户甚至感觉不到中间发生了什么。
这就是主动查单的价值——它不是让你"少丢单",而是让你"丢了单也能自动补回来"。回调是主干道,查单是安全网。主干道偶尔堵了、断了,安全网能兜住,交易就不会悬在半空。
对支付系统来说,"钱"和"状态"必须时刻一致。回调保证不了这一点,只有主动查单能。所以别再问"要不要做主动查单"了,答案是:只要你在收钱,就必须做。
如果你对查单里反复提到的"幂等"还不熟,强烈建议先看这篇支付重复扣款是怎么发生的?一个幂等键没做对,用户付了两次钱,把幂等键和唯一索引搞懂,查单才不会补出重复单;回调本身的排查,这篇易支付回调地址设置后不生效?一个案例复盘讲得很细,配合着看更完整。