前两天,有个做了 3 年 Java 的朋友找我复盘面试。
二面问得不算偏,甚至可以说很常见:
用户下单后 30 分钟没支付,系统怎么自动取消订单?
他几乎没停顿,直接回答:
写个定时任务,每分钟扫一次订单表,把超时未支付的订单改成已取消。
如果放在小系统里,这个答案其实不算离谱。
但面试官接着追了三句,他一下就没法往下接了:
- 如果未支付订单有上千万条,你每分钟扫一次数据库?
- 每分钟扫一次,订单可能 30 分 59 秒才取消,这个误差业务能接受?
- 如果定时任务那台机器挂了,漏掉的订单怎么办?
很多人挂在这道题上,不是因为不会做,而是因为只答了"能跑",没答到"怎么稳定地跑"。
一、为什么"定时任务扫库"不是好答案?
这个方案的问题,不在于不能用,而在于规模一上来就很吃力。
所以这类题真正要表达的是:
不要让系统不断去数据库里"找"哪些订单过期了,而是要让"到期的订单"自己进入处理链路。
这就是典型的**延迟任务设计**。
二、很多人会想到 Redis 过期监听,但它不适合当主方案
有些候选人会说:
下单的时候往 Redis 写一个 Key,TTL 设 30 分钟,Key 过期后监听事件,再取消订单。
这思路听起来挺顺,但面试里最好别把它当核心方案。
原因很简单:
所以 Redis 过期监听可以提一句,说明你知道这条路,但最好顺手点明:
这个方案简单,但可靠性不够,不适合作为订单超时取消的主链路。
三、比较稳的做法:Redis ZSet 做延迟队列
这道题里,更常规、也更容易展开的答案,是Redis ZSet。
思路不复杂:
score存订单超时时间member存订单号- 消费端按当前时间去取已经到期的订单
生产数据时
ZADD order_delay_queue 1737558000 order_10086
这里的1737558000,就是"当前时间 + 30 分钟"的时间戳。
消费数据时
ZRANGEBYSCORE order_delay_queue 0 当前时间戳 LIMIT 0 100
取出的,就是已经到期的订单。
四、这套方案为什么比扫库强?
它的核心优势有两个:
第一,不扫订单表。 数据库不用反复查"哪些数据超时了",查询压力从 MySQL 转移到了 Redis。
第二,只处理已到期任务。 不是把海量订单全拿出来筛,而是直接处理应该被取消的那一小批。
五、真正拉开差距的,不是 ZSet,而是你怎么处理"任务丢失"
面试官通常不会停在"用 ZSet"这一步。
更关键的问题是:
如果你把订单从 ZSet 里取出来了,但业务还没执行完,服务就挂了,这个订单怎么办?
这时候,如果你只会说"重试一下",基本还不够。
更稳的设计是做成二段式处理。
css
flowchart TD
A[用户下单] --> B[写入 Redis ZSet<br/>score=超时时间]
B --> C[消费者扫描到期任务]
C --> D[Lua 脚本原子转移到 processing_queue]
D --> E[执行取消订单逻辑]
E --> F{取消成功?}
F -- 是 --> G[从 processing_queue 删除]
F -- 否 --> H[保留任务,等待补偿重试]
这里最重要的点有两个:
1. 不要直接"取出就删"
更稳妥的是:
- 先从
delay_queue原子转移到processing_queue - 业务执行成功后,再从
processing_queue删除 - 如果服务中途挂了,补偿线程还能从
processing_queue找回任务
这样就不会因为服务异常,导致任务直接丢失。
2. 业务接口必须幂等
即使任务被重复执行,也不能重复取消。
比如取消订单时,SQL 应该带状态条件:
ini
UPDATEorders
SETstatus='CANCELED'
WHEREorder_id = ?
ANDstatus='WAIT_PAY';
只有"待支付"的订单才允许取消。
如果订单已经支付了,或者已经被取消了,这条 SQL 就不会再生效。
这就是面试里很加分的一句话:
延迟任务可以重复投递,但业务结果必须幂等。
六、如果订单量到了千万级,怎么继续往下做?
如果数据规模继续变大,一个 ZSet 也会慢慢变成大 Key。
这时候就不能只说"我用 Redis",还要补一句分片。
分片思路
按订单号做 Hash,把任务分散到多个队列里:
路由规则类似这样:
ini
queueIndex = hash(orderId) % 16
这样做的好处很直接:
- 单个 ZSet 不会无限膨胀
- 可以多线程、多实例并行消费
- 某个分片积压,不会把全部任务拖死
当然,多个节点并发消费时,还要继续保证一件事:
抢任务必须原子化,取消订单必须幂等化。
这两件事少一个,方案都不算完整。
七、如果面试官继续追问,还可以再往上答
再大一点的场景,可以把方案再往上抬一层:
这里不用讲太深,但你可以顺手补一句:
如果公司已经有成熟 MQ 体系,我会优先评估延时消息能力;如果追求更高调度效率,可以考虑"持久化队列 + 时间轮"的组合。
这句话的作用,不是显摆,而是告诉面试官:
你知道这不是一道"背 Redis"题,而是一道"延迟任务系统设计"题。
八、面试里可以怎么答,比较顺?
如果你想把这道题说得更完整,可以按这个顺序回答:
订单超时取消,本质上是延迟任务问题。 小系统可以用定时任务扫库,但在高并发场景下,这个方案会有数据库压力大、时效性不稳定、任务容错差的问题。
我更倾向用 Redis ZSet 做延迟队列。下单时把订单号写入 ZSet,score 存 30 分钟后的过期时间。后台线程按当前时间拉取已到期订单,再触发取消逻辑。
为了避免并发重复消费,我会用 Lua 脚本保证取任务的原子性。为了避免任务取出后服务宕机导致丢失,我会引入 processing 队列,处理成功后再 ACK 删除,失败任务走补偿重试。
另外,取消订单接口本身必须做幂等控制,保证订单只能从待支付改成已取消,防止重复执行带来脏数据。
如果数据量继续增大,我会对延迟队列做分片,必要时再评估 MQ 延时消息方案。最后还会保留一个低频兜底扫描任务,确保极端情况下数据最终一致。
这套回答,基本就已经超过"知道怎么做"的层级了。
写在最后
这道题表面看,是"30 分钟后取消订单"。
但面试官真正想看的是另外几件事:
- 你有没有规模意识
- 你有没有可靠性意识
- 你知不知道任务会丢、会重、会积压
- 你会不会给系统留兜底
很多人不是不会写功能,而是习惯只讲"正常流程"。
可系统设计题,恰恰最看重异常流程。
所以,下次再被问到"订单超时自动取消",别急着回答"定时任务"。
先把延迟任务、原子性、幂等、补偿机制、最终一致性这几个关键词讲出来,答案就立住了。