Skip to content

一笔订单,在取消的那一刻用户刚好付款了,怎么办? ​

  • 标签:后端、场景题

🏛 业务架构师 ​

这是支付经典竞态问题,本质是订单状态机和资金流的一致性,处理不好就是双重资损:吞用户的钱(付了款订单没了)或重复发货。我按"实时处理 + 兜底对账"两层讲。

第一层:状态机 + 原子变更(主防线)

订单状态流转必须是数据库层面的原子操作,用条件更新保证只有一个动作生效:

sql
-- 取消:只有"待支付"才能取消
UPDATE orders SET status = 'CANCELED'
WHERE order_id = ? AND status = 'WAIT_PAY';

-- 支付回调:只有"待支付"才能变已支付
UPDATE orders SET status = 'PAID'
WHERE order_id = ? AND status = 'WAIT_PAY';

两个操作并发时,谁先抢到行锁谁生效,后到者 affected rows = 0:

  • 取消先成功:支付回调发现订单已取消 → 走自动退款流程,钱原路退回并通知用户
  • 支付先成功:取消请求失败 → 订单正常履约

为降低竞态概率,取消动作前可对订单加分布式锁(或依赖行锁天然串行化),回调必须幂等(支付流水号唯一索引,重复回调直接返回成功)。

第二层:对账兜底(资损防线)

真实世界的支付状态永远以渠道为准:每天与微信/支付宝对账文件核对,发现"渠道成功、本地取消"的单子自动触发退款任务,宁可多退不可吞钱——吞钱是客诉 + 监管投诉,退款只是资金占用几天。

业务收尾:这个场景的设计原则是用户无感知:用户付款成功那一刻起,无论系统内部发生什么,结果只能是"拿到货"或"钱退回",绝不允许"钱货两空"状态存在超过退款时效。

🏗 系统架构师 ​

这是经典的状态机竞态问题:取消和支付回调并发到达。解法核心是一句话:让数据库的原子状态变更做最终裁判,谁后到谁负责善后。

第一道防线:状态机 + 乐观锁原子更新 订单状态流转定义为单向:待支付 → 已支付、待支付 → 已取消,两个方向互斥。支付回调和取消都执行条件更新:

sql
-- 支付回调
UPDATE orders SET status = 'PAID', pay_time = now()
WHERE order_id = ? AND status = 'UNPAID';

-- 取消任务
UPDATE orders SET status = 'CANCELED'
WHERE order_id = ? AND status = 'UNPAID';

行锁保证串行执行,只有一方 affected rows = 1,另一方返回 0 后走各自的补偿分支。这是以 DB 为仲裁者的 CAS,比分布式锁简单可靠得多。

第二道防线:后到者的补偿

  • 支付回调发现订单已取消:钱已实际扣了,不能吞掉,触发自动退款逆向流程,记录异常事件供对账。
  • 取消发现订单已支付(理论上被上面的更新挡住):直接忽略,幂等返回成功。

第三道防线:对账兜底 定时任务扫描"已取消但存在成功支付流水"的订单,自动发起退款并告警。任何并发时序漏洞最终由对账收敛,这是最终一致性系统的标配安全网。

架构细节:回调必须幂等(第三方会重发,以支付流水号唯一约束挡重);取消操作要"软取消"——先原子改状态再异步释放库存/优惠券,失败进重试队列。整个方案不追求分布式强一致,而是用状态机 + 补偿把所有竞态分支都定义清楚,这才是资损问题的正确架构姿势。

💻 全栈开发专家 ​

这是并发状态竞争题,核心答案:支付回调与取消逻辑串行化(同一把锁)+ 状态机条件更新,谁先改成功谁生效,输的一方走逆向流程。

实现(两道保险)

  1. 数据库乐观锁兜底(最后一道防线,必须做):
sql
-- 取消
UPDATE orders SET status = 'CANCELED' WHERE order_no = ? AND status = 'PENDING';
-- 支付回调
UPDATE orders SET status = 'PAID' WHERE order_no = ? AND status = 'PENDING';

两个都是条件更新,影响行数为 0 说明状态已被对方改走,天然互斥。

  1. 分布式锁串行化(避免"取消成功的同时钱也付了"的窗口):
java
RLock lock = redisson.getLock("order:" + orderNo);
lock.lock(5, TimeUnit.SECONDS);
try {
    // 取消:校验 status=PENDING 再改 CANCELED、回库存
    // 支付回调:校验 status=PENDING 再改 PAID、发货
} finally { lock.unlock(); }

如果取消先赢了:用户实际已扣款,回调方发现 status=CANCELED,走自动退款并通知用户,同时记对账流水人工兜底。

踩过的坑

  • 支付渠道回调可能重复推送,回调处理必须幂等(渠道流水号唯一索引)。
  • 锁要加在"读状态 + 变更 + 发下游消息"整体外面,只锁 update 挡不住"读旧状态后发消息"的竞态。
  • 第三方回调先到、取消任务后执行时,取消任务要重新查状态再决定是否回滚,不能拿入队时的旧快照。