全部文章

· Java 后端 · 更新于

回调已经到了,退款单为什么还在处理中

一笔退款,渠道查询是成功,本地却一直是处理中。查回调日志,也确实收到了成功通知。

这时继续查“回调为什么没来”就有点绕远了。还有一种可能:状态已经被改成成功,随后又被别的代码改了回去。

不妨先把这个可能性缩成一张只有两个字段的表。下面是可以在 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 事件插入放在同一本地事务中,只有更新命中时才插入事件;重复通知不再产生第二个成功事件。下游仍要做自己的幂等处理。

这能避免一种常见的半修复:状态确实不倒退了,每次重复回调却还在重复发记账任务。

还有一条容易混在一起:本地退款单的状态修好了,不代表用户到账问题也随之解释完了。渠道成功、本地成功、用户实际到账,依旧是三份不同的证据。这篇推演解决的是中间那份记录为什么可能写错,不是在给未到账投诉下结论。