回调已经到了,退款单为什么还在处理中
一笔退款,渠道查询是成功,本地却一直是处理中。查回调日志,也确实收到了成功通知。
这时继续查“回调为什么没来”就有点绕远了。还有一种可能:状态已经被改成成功,随后又被别的代码改了回去。
不妨先把这个可能性缩成一张只有两个字段的表。下面是可以在 SQLite 中执行的小例子,用来推演更新顺序:
CREATE TABLE refund (
id TEXT PRIMARY KEY,
status TEXT NOT NULL
);
INSERT INTO refund VALUES ('r-001', 'INIT');
提交退款的线程在收到“已受理”响应后,执行:
UPDATE refund SET status = 'PROCESSING' WHERE id = 'r-001';
回调处理器收到成功通知后,执行:
UPDATE refund SET status = 'SUCCESS' WHERE id = 'r-001';
两段代码单独看都很直白。可渠道的同步响应和异步通知走的是两条路,没有谁保证本地一定先处理同步响应。
把两条 UPDATE 换个顺序
设想提交线程等待 HTTP 响应时,回调先到了:
-- 回调先处理成功通知
UPDATE refund SET status = 'SUCCESS' WHERE id = 'r-001';
-- 提交线程稍后拿到受理响应
UPDATE refund SET status = 'PROCESSING' WHERE id = 'r-001';
SELECT status FROM refund WHERE id = 'r-001';
-- PROCESSING
数据库没有丢更新,也没有报错。它忠实执行了两条合法语句,只是第二条语句把业务上不该发生的倒退写进去了。
如果日志只有“收到成功通知”和“更新成功”,甚至能把人引到数据库是不是有问题这个方向。这里的“更新成功”只说明当时 SQL 执行成功,没有说明后来还发生过什么。
所以排查这类问题时,我更想看到一笔退款的状态变更记录:原状态、目标状态、触发入口、时间和关联 ID。重点是找最后一次写入,而不是只确认回调代码执行过。
受理响应只能推动 INIT
对于这个简化模型,可以让提交线程只做 INIT → PROCESSING:
UPDATE refund
SET status = 'PROCESSING'
WHERE id = 'r-001' AND status = 'INIT';
成功通知则允许从 INIT 或 PROCESSING 推进:
UPDATE refund
SET status = 'SUCCESS'
WHERE id = 'r-001' AND status IN ('INIT', 'PROCESSING');
这样回调先到时,受理响应的条件更新会影响零行,本地的成功状态留得住。顺序反过来也成立:受理响应先改为处理中,回调随后推进到成功。
这两条 SQL 的前提是成功通知已经过验签、订单与金额核对,且渠道对这个成功结果有确定的协议定义。示例没有展开失败和撤销等状态;真实退款单还要把那些迁移逐条列清楚。
零行更新不能一律当异常
提交线程影响零行,可能是回调已经推进了状态。成功回调影响零行,可能是重复通知,也可能当前记录已经处在一个不应接受成功通知的状态。需要读取已有状态再区分,不能统一吞掉,也不能一律重试。
如果推进成功还要发送后置事件,判断也应基于这次迁移有没有发生。把状态更新和 outbox 事件插入放在同一本地事务中,只有更新命中时才插入事件;重复通知不再产生第二个成功事件。下游仍要做自己的幂等处理。
这能避免一种常见的半修复:状态确实不倒退了,每次重复回调却还在重复发记账任务。
还有一条容易混在一起:本地退款单的状态修好了,不代表用户到账问题也随之解释完了。渠道成功、本地成功、用户实际到账,依旧是三份不同的证据。这篇推演解决的是中间那份记录为什么可能写错,不是在给未到账投诉下结论。