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

订单超时取消别再写定时任务扫全表。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 打包:

lua 复制代码
-- 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 秒。给任务加个圈数:

text 复制代码
1800 秒 / 60 刻度 = 30 圈

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

java 复制代码
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:别用"查出来再判断再更新"的三步走,直接上状态机做幂等:

sql 复制代码
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 延时消息还是自研时间轮?评论区聊聊。

有用的话点个赞。

相关推荐
鱼弦44 分钟前
模型服务热加载实战:如何在不停服的情况下更新模型权重?
后端
暗不需求44 分钟前
Docker 入门:从「光盘与 DVD」到全栈项目容器化实战
docker·容器·面试
知守观44 分钟前
Java 项目 FastJSON 1.2.37 安全漏洞排查:autoType 差点让我成了安全新闻主角
后端
IT_陈寒44 分钟前
Redis误删数据后的血泪教训:我竟然这样找回来了
前端·人工智能·后端
吃饱了得干活1 小时前
Hash 全景:从 HashMap 到一致性哈希,一文吃透哈希核心
java·后端
码事漫谈1 小时前
AI圈最近爆火的"哑巴"Jev,到底是个啥?
后端
行百里er1 小时前
Redis 核心数据结构(二)——List 与消息队列
redis·后端
知守观1 小时前
AI 代码审查实战:2022年Java老项目挑出20个坑,老炮只认15个
后端
创新技术阁1 小时前
FastapiAdmin插件介绍
前端·后端·fastapi