部分退款被并发退超:把「已退 + 在途」扣在一条条件更新里
一笔 300 元订单,客服后台退 100,同时自动补偿 worker 判定超时也要退全款 300。两边都调了退款接口。微信侧会做累计校验,第二笔可能报错;但本地账上,第一笔的 SUCCESS 还没回来(PROCESSING 能拖很久),第二笔又成功了,结果订单 refunded_amount 变成 400,对账时才发现。
核心判断:退款额度必须在调用通道之前原子占位,不能等 SUCCESS 再累加。
前提与不适用
适用微信 v3 / 支付宝同一笔订单多次部分退款,且本地自己记额度;不适用一次性全额退款;Stripe 的 refund 有独立幂等键与累计校验模型,账法不同。
为什么不能等 SUCCESS 再记账
- 受理成功(200 +
refund_id)只代表退款单被建立,PROCESSING 可能持续数天甚至到 7 天转 CLOSED; - 这期间本地额度没锁,任何入口都能再发起一笔;
- 通道的累计校验发生在你调用之后,报错时本地状态已经写成“处理中”,实际没退出去;
- 所以顺序是:占额度 → 调通道 → 回执只做结算。
表结构
refund_order(out_refund_no 唯一键, order_no, amount, status INIT/CALLING/PROCESSING/SUCCESS/CLOSED/ABNORMAL, channel_refund_id, create_time)
orders(pay_amount, refunded_amount, refunding_amount)
占位 SQL
UPDATE orders
SET refunding_amount = refunding_amount + :amt
WHERE order_no = :no
AND refunded_amount + refunding_amount + :amt <= pay_amount;
RowsAffected=0 即额度不足(并发抢走或真的退超),直接拒绝,不要重试。
结算与失败终态
结算是两条更新,必须带状态门做幂等:
UPDATE refund_order
SET status = 'SUCCESS'
WHERE out_refund_no = :rno
AND status IN ('CALLING', 'PROCESSING');
affected=1 时才执行额度结算:
UPDATE orders
SET refunding_amount = refunding_amount - :amt,
refunded_amount = refunded_amount + :amt
WHERE order_no = :no
AND refunding_amount >= :amt;
失败终态(CLOSED / ABNORMAL)只释放在途:
UPDATE orders
SET refunding_amount = refunding_amount - :amt
WHERE order_no = :no
AND refunding_amount >= :amt;
三个反模式
- 无条件
refunded_amount = refunded_amount + ?:重复回调直接翻倍; - 先 SELECT 校验再 UPDATE:读后写窗口,就是经典的 TOCTOU;
- 把通道报错一律当失败并把额度归零:要区分“调用失败未受理”和“已受理后被 CLOSED”。前者用原单号原参数重试(同
out_refund_no只退一笔),后者原号作废,要充余额后换新out_refund_no重提。
通道侧硬限制(文档口径,未实测)
- 同一订单部分退款最多 50 次,多次部分退款需间隔 1 分钟以上;补偿脚本 1 分钟级高频重试会撞 429
FREQUENCY_LIMITED,这时不能换号,隔 1 分钟用原号重试或先查单; - 请求体
amount.total是原订单总金额,amount.refund才是本次退款金额,单位分; - 分账订单退款出资账户不同,报 403
NOT_ENOUGH是账户余额/分账结构问题,不代表这笔退款失败,别把额度记成已退; - 退款有效期从支付成功起算 365 天。
对账兜底
T+1 账单按 out_refund_no 聚合后与本地 refunded_amount 比对;修复脚本要按账单值 SET 重算(派生量重算),不要 += 差值,否则重复执行会再错一次。注意账单里金额单位是元,接口是分。
排查清单 5 条
- 查
orders的refunded + refunding是否大于pay_amount,超了说明占位逻辑缺失; - 查
refund_order同一order_no下是否有多个非终态单; - 有 SUCCESS 但
refunded_amount没动的,说明结算被状态门挡住,看refund_order当前状态; - 有 CLOSED 但额度没释放的,看释放语句是否用了 status 门导致的漏执行;
- 和账单差异先按
out_refund_no对齐,再判断是通道多退还是本地多记。
未实测声明:50 次上限、1 分钟间隔、365 天、429 语义来自微信支付商户文档口径,未在真实商户号上逐项压测;支付宝的对应限制不同,接入前各自核对官方文档。