订单超时取消别再写定时任务扫全表。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
原理像寄存柜——东西放进去,到点自动弹出来。
两个队列:
- 缓冲队列:故意不挂消费者,给消息设 30 分钟 TTL
- 业务队列:真正的消费者在这里等
用户下单,消息进缓冲队列安静躺 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,可做到毫秒 |
| 依赖 | Redis | MQ 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 延时消息还是自研时间轮?评论区聊聊。
有用的话点个赞。