|

Aimee

Write the Code. Change the World.

定时任务取消订单,和用户付款撞了——你以为不会发生

· 分享镜

定时任务取消订单,和用户付款撞了——你以为不会发生

订单超时未支付,定时任务把它取消了。但几乎同一时刻,用户点了"立即支付",付款成功了。

结果:钱扣了,订单是"已取消"状态,商家没有收到履约通知。

这个场景不是极端 case,只要有定时任务 + 支付两个并发路径,就会撞上。


两条路径的竞态

定时任务执行流程:

查到订单状态=UNPAID, 超时 → UPDATE status=CANCELLED → 释放库存

用户支付执行流程:

查到订单状态=UNPAID → 调支付渠道扣款 → 收到回调 → UPDATE status=PAID → 触发履约

两条路径都以"当前状态是 UNPAID"为前提,但它们之间没有协调机制。


用状态机 + CAS 解决

核心思路:状态更新用 CAS,WHERE status = 旧状态,确保只有一方能成功推进状态。

定时任务侧:

// 只更新状态为 UNPAID 的,其他状态不动
int rows = orderDao.updateStatus(orderId, CANCELLED, UNPAID);
if (rows == 0) {
    // 已被更新为其他状态(比如 PAID),不继续取消
    return;
}
// 取消成功,释放库存
inventoryService.release(orderId);

支付回调侧:

@Transactional
public void onPaySuccess(String outTradeNo) {
    Order order = orderDao.findByTradeNo(outTradeNo);
    // 只有 UNPAID 的订单才能推进到 PAID
    int rows = orderDao.updateStatus(order.getId(), PAID, UNPAID);
    if (rows == 0) {
        // 订单已被取消,需要退款
        alipay.refund(outTradeNo, order.getAmount());
        return;
    }
    // 状态更新成功,触发履约
    fulfillmentService.triggerFulfillment(order);
}

这两段代码都用 WHERE status = UNPAID 作为 CAS 条件,并发执行时只有一个能拿到 affected rows = 1,另一个感知到状态已变,走各自的异常分支。


取消在先,付款在后

定时任务先取消了(status = CANCELLED),支付回调后到:

  • 支付回调的 WHERE status = UNPAID 匹配不到行 → rows = 0
  • 发现订单已取消 → 主动发起退款
  • 用户钱退回来,体验虽差但数据没错

付款在先,取消在后

支付回调先到(status = PAID),定时任务后执行:

  • 定时任务的 WHERE status = UNPAID 匹配不到行 → rows = 0
  • 识别到订单已支付 → 跳过取消,不释放库存
  • 订单正常履约

时序图

流程示意图


CAS 是兜底,时间窗口是第一道防线

定时任务触发的时机很重要。如果订单超时是"30分钟未支付",定时任务不应该在第 30 分 01 秒就立刻取消——用户可能刚在支付页面点了"确认",支付渠道还在处理中。

实践上通常多留几分钟缓冲,比如 35 分钟后才触发取消。时间窗口减少竞态概率,CAS 处理真正撞上的情况,两者结合才稳。

评论0

登录后参与评论。

还没有评论,来抢沙发吧。

回到顶部