2882 字
约 9 分钟
17
S17-X09 · 订单超时未支付:六种实现与选型

"下单后 30 分钟未支付自动取消"写进 PRD 只要一行,落地却要回答四个问题:判定精确到秒还是分钟、取消后是否回补库存、超时后用户还能不能付款、取消与支付同时到达谁赢。这四个问题定不下来,选哪种实现都是错。

下面把六种做法摊开,逐一给出机制、精度、可靠性、成本,最后给一张按量级选型的判据表。


1. 先把业务语义钉死

问题 选项 对实现的影响
判定精度 秒级 / 分钟级 / 小时级 决定能否用扫表
超时后能否支付 可以 / 不可以 决定是否需要"关单"这一独立状态
库存处理 下单即扣 / 支付才扣 决定取消时是否需要回补
优惠券处理 占用 / 未占用 同上,且回补要幂等
通知 需要 / 不需要 需要则多一个下游依赖

第二行最容易被漏掉。若"超时后仍可支付",就不能简单置为已取消,而要设计成已取消但可唤起支付、成功后恢复状态的流程,或干脆不关单只提醒。这一条决定后面一半的复杂度。


2. 六种实现的总览

方案 机制 精度 可靠性 成本
定时任务扫表 周期性 SELECT ... WHERE status=0 AND expire_at<=NOW() 取决于调度间隔,分钟级 高(数据在库,重启不丢) 低,但扫描压力随数据增长
延迟队列(MQ 延迟消息) 消息在 Broker 延迟投递,到期消费 受延迟等级/定时精度限制 中高(需处理消息丢失) 中,需要 MQ
Redis 过期监听 订阅 __keyevent@0__:expired 不保证 低(事件可能丢) 低
时间轮 内存环形槽,按 tick 触发 毫秒级 低(单机,重启丢) 低
数据库定时事件 MySQL EVENT 周期执行 最小 1 秒 中(主从/单点问题) 低,但难治理
应用内 ScheduledExecutor 线程池 + DelayQueue 毫秒级 低(重启丢、多实例重复) 最低

"可靠性"一列是关键差异:数据在库里最安全,时间在内存里最快但最脆。单机内存方案在多实例下会变成"每实例执行一次",把压力转给了取消动作的幂等性。


3. 方案一:定时任务扫表

-- 必须有 (status, expire_at) 联合索引,否则每分钟一次全表扫描
SELECT id FROM t_order
WHERE status = 0
  AND expire_at <= NOW()
  AND id % 8 = ?          -- 分片参数,8 个实例各扫一片
ORDER BY expire_at
LIMIT 200;
@Component
public class OrderExpireScanner {

    @Scheduled(fixedDelay = 30_000)   // 上一轮结束后 30 秒再跑,避免堆积
    public void scan() {
        int shard = ShardHolder.current();          // 0..7
        List<Long> ids = orderMapper.selectExpiredIds(shard, 200);
        for (Long id : ids) {
            try {
                cancelService.cancelIfUnpaid(id, "TIMEOUT");   // 内部幂等
            } catch (Exception e) {
                log.warn("cancel failed, id={}", id, e);        // 单条失败不影响整批
            }
        }
    }
}

三个固有问题:

问题 表现 缓解
扫描压力 表越大,单次扫描越贵 联合索引 + LIMIT + 分片键
时间窗精度 30 秒调度 → 实际取消时间在 [30, 60) 分钟之间漂移 接受漂移,或用更短间隔 + 更小批量
任务重复执行 多实例同时跑、调度框架重试 分片参数 + cancelIfUnpaid 幂等

第三个最危险:@Scheduled 在多实例下每个实例都会执行,不分片就是 8 倍压力。ShedLock 或分片参数都能解决,后者更均匀。

精度要写进 PRD:"30 分钟"实际是"30 到 30.5 分钟之间",多数业务可接受,秒杀类不可接受。


4. 方案二:MQ 延迟消息

// RocketMQ:下单成功后投递一条延迟消息(延迟等级按具体版本核对,5.0 起支持任意时间)
Message msg = new Message("order-delay", orderId.toString().getBytes(StandardCharsets.UTF_8));
msg.setDelayTimeLevel(16);           // 对应某个固定延迟档位
producer.send(msg);
@RocketMQMessageListener(topic = "order-delay", consumerGroup = "order-expire")
public class OrderDelayConsumer implements RocketMQListener<String> {
    @Override
    public void onMessage(String orderId) {
        cancelService.cancelIfUnpaid(Long.valueOf(orderId), "TIMEOUT");
    }
}

可靠性取决于两件事:下单与投递是否原子、消息丢了怎么办。前者用事务消息或本地消息表,后者必须有兜底扫描——即使消息全丢,扫表也能把订单捞出来。省掉兜底,MQ 抖一次就有一批订单永久停在待支付。

RocketMQ 4.x 的延迟等级是固定档位,要"30 分钟"只能选最接近的一档,精度由档位决定;5.0 的定时消息支持指定时间戳。按自己的 Broker 版本核对档位表。


5. 方案三与四:Redis 过期监听、时间轮

notify-keyspace-events "Ex"    # redis.conf,E=keyevent,x=expired

在 __keyevent@0__:expired 上挂监听器就能拿到过期通知,但这条链路不可靠:过期走惰性删除 + 定期抽样,key 到期后可能很久才被真正删除;事件走 pub/sub 无持久化,订阅端断线期间的直接消失;主从切换时可能不投递。只能做提醒,不能当关单主链路。

// Netty 时间轮:单机内存,重启即丢
HashedWheelTimer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
timer.newTimeout(t -> cancelService.cancelIfUnpaid(orderId, "TIMEOUT"),
                 30, TimeUnit.MINUTES);

时间轮的优势是单机内海量定时任务调度开销很低,代价是任务只在内存:重启丢失、多实例重复、无法跨机迁移,三者都靠扫表兜底补齐。


6. 方案五与六:数据库事件、应用内调度

-- MySQL EVENT:需要 event_scheduler=ON,且只在主库执行
CREATE EVENT ev_cancel_expired_order
ON SCHEDULE EVERY 1 MINUTE
DO
  UPDATE t_order
     SET status = 20, cancel_reason = 'TIMEOUT', cancel_time = NOW()
   WHERE status = 0 AND expire_at <= NOW()
   LIMIT 500;

数据库事件有三个限制:event_scheduler 默认关闭;主从架构下只在主库生效,切换后要重新确认;批量 UPDATE 持锁,LIMIT 太小追不上、太大锁表。

应用内 DelayQueue 与 ScheduledExecutorService 同类:进程内、重启丢、多实例重复,仅在单体单实例时可用。


7. 取消动作的幂等与并发

用户在第 29 分 59 秒点支付、任务在第 30 分整触发取消,两者必须只有一个赢。"先查再更新"是错的:查与更新之间有窗口,两个线程都能通过。正确做法是把判定压进 UPDATE 的条件,用影响行数决定胜负:

-- 取消:只有仍是待支付才生效
UPDATE t_order
   SET status = 20, cancel_time = NOW(), cancel_reason = 'TIMEOUT'
 WHERE id = ? AND status = 0 AND pay_time IS NULL;
-- affectedRows = 0 → 说明已被支付或已被取消,直接返回成功
-- 支付回调:同样带状态条件,与取消争抢同一行
UPDATE t_order
   SET status = 10, pay_time = NOW()
 WHERE id = ? AND status = 0;
-- affectedRows = 0 → 已取消,进入"超时后支付"的退款或补单流程

状态机本身要定清楚:

当前状态 事件 下一状态 条件
待支付(0) 支付成功 已支付(10) status=0
待支付(0) 超时 已取消(20) status=0 且 expire_at<=NOW()
已取消(20) 关单归档 已关闭(30) status=20 且超过保留期
已支付(10) 退款完成 已关闭(30) status=10
已取消(20) 支付成功(迟到) 待人工处理 记录冲突,走补单

最后一行真实存在:支付网关回调可能晚于取消到达。订单已取消钱却收了,必须有"迟到支付"处理(退款或补单),否则就是资损。

库存回补同样要幂等:写一张 t_stock_restore_log (order_id, sku_id, qty) 并在 (order_id, sku_id) 上建唯一索引,重复回补时抛 DuplicateKeyException,捕获后视为成功。


8. 选型判据

日订单量 精度 推荐方案 理由
万级以下 分钟级 扫表(单实例 + 索引) 最简,重启不丢,够用
十万级 分钟级 扫表分片 或 MQ 延迟 + 扫表兜底 单机扫不动,需要分摊
百万级以上 秒级 MQ 延迟消息 + 扫表兜底 精度与吞吐都要
任意量级 秒级且强一致 延迟消息 + 状态机条件更新 一致性靠 DB 保证
仅需提醒、不关单 不敏感 Redis 过期监听 丢了只是少条提醒

通用规则:任何方案都要配一个低频扫表兜底。延迟消息会丢、时间轮会重启、Redis 事件会掉,扫表不会。兜底频率可以很低。


9. 排错

现象 原因 处理
订单大量停在待支付 延迟消息丢失且无兜底 补扫表兜底,检查投递与下单是否原子
同一订单被取消两次 取消无幂等,多实例重复执行 条件更新 + 回补日志唯一索引
取消后库存被回补两次 回补未做幂等 唯一索引兜底,或状态机前置校验
扫表任务把库打满 缺 (status, expire_at) 索引 补索引 + LIMIT + 分片
用户付了钱订单却是已取消 取消与支付回调竞态未处理 条件更新 + 迟到支付流程
Redis 过期事件收不到 notify-keyspace-events 未开或订阅断线 不作为主链路,改扫表

10. 边界

场景 建议 理由
超时后仍允许支付 不关单,只发提醒 关单会制造迟到支付冲突
库存下单即扣 必须有回补且幂等 不回补就是超卖
库存支付才扣 取消无需回补 复杂度大幅下降
多实例部署 禁止纯内存方案 会重复执行
需要精确到秒 扫表不适用 调度间隔决定精度上限
已有成熟 MQ 优先延迟消息 + 兜底扫表 复用基础设施,少一套组件

一句话:精度靠调度,可靠性靠数据库,一致性靠条件更新。三者分开考虑,选型就不会被"哪个方案更先进"带偏。


11. 自测

  1. "超时后仍可支付"这个业务要求,为什么会改变状态机设计?
  2. 扫表方案里,LIMIT 200 和 id % 8 分别解决什么问题?
  3. Redis 过期事件为什么不能作为关单主链路?
  4. UPDATE ... WHERE id=? AND status=0 的影响行数为 0,代表哪两种可能?
  5. 延迟消息方案为什么还要配一个低频扫表?
  6. 支付回调迟到时,为什么不能简单把订单改回已支付?
S17-X09 · 订单超时未支付:六种实现与选型
https://www.clxhxhhr.top/posts/5801/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。