订单30分钟未支付自动取消:定时任务为什么被面试官嫌弃

5 阅读6分钟

订单超时取消别再写定时任务扫全表。Redis ZSet 按 score 取队首、RabbitMQ 死信队列靠 TTL 过期转发、单机时间轮 O(1) 挂载——三种方案的取舍和适用场景全在下面。

面试现场:这个答案为什么被嫌弃

"订单 30 分钟未支付怎么自动取消?"

张口就来的答案:写个定时任务,每分钟查一次数据库,捞出 status = UNPAID 且 create_time < now - 30min 的订单循环取消。

面试官听到这基本就结束了。这不是错,是笨重。

两个硬伤摆在这。

时间差。 用户 12:00:00 下单,12:30:00 就该取消。定时任务跑到 12:31:00 才看见它——这一分钟里,库存锁、优惠券、秒杀名额全被一个不会付款的订单白占着。高峰期这一分钟足够让爆款 SKU 少卖几十单。

全表扫描。 订单表几千万行,绝大多数时间根本没有过期订单,任务照样每分钟把表犁一遍。CPU、IO、连接池全在为空跑买单。加索引也救不了"每分钟扫一次"这个动作本身的浪费。

一句话:把笨重的被动轮询,换成基于事件的主动触发。

Redis ZSet:够用,成本几乎为零

把 ZSet 当成一根按时间排好队的队列:

  • member = 订单号
  • score = 下单时间戳 + 30 分钟

用户一下单就 ZADD 塞进去。后台起一个线程当"检票员",盯着 score 最小的元素:当前时间没到它的 score 就睡一会儿再看;到了就取出来取消。

关键是取的时候必须原子,否则多实例部署会重复消费。用 Lua 脚本把 ZRANGEBYSCORE 和 ZREM 打包:

-- KEYS[1] = delay:order:zset
-- ARGV[1] = 当前时间戳 毫秒
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #jobs == 0 then return nil end
local job = jobs[1]
if redis.call('ZREM', KEYS[1], job) == 1 then
  return job          -- 谁 ZREM 成功谁负责执行
end
return nil            -- 被别人抢走了

整个链路:

flowchart LR
 A[用户下单] -->|ZADD score 为下单时间加30分钟| B[Redis ZSet 延时队列]
 B --> C[轮询线程 取 score 最小的元素]
 C -->|score 大于当前时间| D[稍后重试 不做全表扫描]
 D --> C
 C -->|score 小于等于当前时间| E[Lua 脚本原子 ZREM 并返回 orderId]
 E --> F[执行取消 释放库存 退优惠券]

比查数据库强在哪?ZSet 底层是跳表,取最小 score 是 O(log N),而且队列里只有"未支付的单",天然没有无效数据。精度秒级,成本几乎为零。

代价:多了一个 Redis 依赖,得考虑持久化(AOF)和重启后的补扫。

死信队列:把过期这事扔给 Broker

原理像寄存柜——东西放进去,到点自动弹出来。

两个队列:

  1. 缓冲队列:故意不挂消费者,给消息设 30 分钟 TTL
  2. 业务队列:真正的消费者在这里等

用户下单,消息进缓冲队列安静躺 30 分钟。过期后 Broker 发现这是条"死信",丢进死信交换机(DLX),DLX 转手把它路由到业务队列,消费者拿到消息直接取消订单。

flowchart LR
 P[下单消息] --> Q1[缓冲队列 无消费者 TTL 30分钟]
 Q1 -->|消息过期 变成死信| X[死信交换机 DLX]
 X --> Q2[业务队列]
 Q2 --> C[消费者 查出订单直接取消]

好处是业务代码极其干净——你只管发消息、收回调,延迟逻辑全交给中间件。

坑也在这:这套设计成立的前提是同一个缓冲队列里所有消息延迟一致,都是 30 分钟。想把 5 分钟、10 分钟、30 分钟的超时混在一个队列里,队头阻塞会把后面消息的实际延迟硬生生拉长。要做多级延迟就得建多组队列,或者直接换支持延时消息的 RocketMQ。

时间轮:单机扛几十万延时任务

面试官追问"不依赖任何外部中间件,单机内存里怎么处理几十万个延时任务",答案是时间轮。

想象一块机械秒表,60 个刻度代表 60 秒,秒针每秒走一格。任务 5 秒后执行,就挂在第 5 个刻度上,秒针走到 5 就执行。

30 分钟后执行怎么办?刻度只有 60 个,装不下 1800 秒。给任务加个圈数:

1800 秒 / 60 刻度 = 30 圈

任务挂在当前刻度上,标记 circle = 30。秒针每转完一圈路过这个刻度,就把圈数减 1,减到 0 才真正拎出来执行。

class TimeWheelTask {
    long orderId;
    int circle;   // 还剩几圈
    int slot;     // 落在哪个刻度
}

// 添加任务,delay 为延迟秒数
int slot   = (int) ((currentSlot + delay) % 60);
int circle = delay / 60;
wheel[slot].add(new TimeWheelTask(orderId, circle, slot));

// 每秒推进一格
void advance() {
    currentSlot = (currentSlot + 1) % 60;
    for (TimeWheelTask t : wheel[currentSlot]) {
        if (--t.circle == 0) {
            cancelOrder(t.orderId);
        }
    }
}
flowchart TD
 T[新任务 延迟30分钟] --> R[计算 1800 除 60 得 30 圈]
 R --> S[挂到当前刻度 记录圈数 30]
 S --> W[秒针每秒走一格]
 W --> C{走到该刻度?}
 C -->|否| W
 C -->|是| D[圈数减 1]
 D --> E{圈数等于 0?}
 E -->|否| W
 E -->|是| F[取出任务执行取消订单]

时间轮最狠的地方是插入和取出都是 O(1)——链表挂载、指针推进,没有任何排序开销。Netty 的 HashedWheelTimer、Kafka 的延时组件都是这个思路,只是用了多级时间轮应对不同量级。

缺点也明显:纯内存,进程重启任务全丢,多实例还得自己做分片。

怎么选

维度Redis ZSet死信队列时间轮
精度秒级秒级取决于 tick,可做到毫秒
依赖RedisMQ Broker无,纯内存
多实例Lua 原子保证不重复天然支持需要自己分片
可靠性依赖 Redis 持久化最高,可持久化最差,重启即丢
适用量级十万到百万百万级以上单机几十万
落地成本低中中高,要自己写轮子

我的默认选择是 Redis ZSet:够简单、够准、团队都熟。日均订单量上了千万、对可靠性有硬要求,再上 MQ 死信队列。时间轮留给面试作答和自研中间件的场景。

追问链

Q:Redis 挂了或者消息丢了,订单不就永远不取消了?

A:所以定时任务没死,只是降级了。保留一个低频兜底任务,比如每 10 分钟扫一次"创建时间在 30 到 40 分钟之间"的小窗口订单做对账。扫描范围被限制在一个时间窗内走索引,不再是全表犁地。

Q:取消订单和用户支付撞在一起怎么办?

A:别用"查出来再判断再更新"的三步走,直接上状态机做幂等:

UPDATE orders SET status = 'CANCELED', cancel_time = NOW()
WHERE id = ? AND status = 'UNPAID';

影响行数为 0 就说明已经被支付回调改掉了,直接放弃取消、回滚库存预占。谁先抢到状态谁赢,数据库行锁帮你判胜负。

Q:时间轮的精度怎么定?

A:订单超时对精度要求是秒级,tick 设 1 秒、槽位 60 个足够。真要毫秒级就把 tick 降到 100ms、槽位翻 10 倍,或者上多级时间轮。

一句话收束

定时任务是兜底,不是方案;主动触发才是方案。

面试时把这句话说出来,再补上 Redis ZSet 的 Lua 原子取出,这道题就稳了。

写在最后

这三种方案在真实项目里经常混着用:Redis 扛主流程,定时任务做对账兜底。你们团队的订单超时取消是怎么实现的,用的 RocketMQ 延时消息还是自研时间轮?评论区聊聊。

有用的话点个赞。