订单超时自动取消
这个是考量分布式海量延迟任务(Delayed Task)怎么设计?
为什么定时任务(Cron)是低级回答。
在低并发、数据量小的系统(比如内部OA),用Spring@Scheduled跑定时任务没问题
在大厂高并发场景下,有三个致命问题:
1、时效性差:轮询有间隔,无法精确到秒级取消
2、数据库压力大:由“推”变“拉”,频繁的全表扫描(Scan)是数据库CPU飙升的元凶。
3、资源浪费:大部分时候可能没有超时订单,但任务还在空跑。
核心架构:3种主流解法
解法1:Redis过期监听(面试官眼里的大坑)
不可靠,redis的过期监听,是发后即忘的,如果你的服务但是重启了,或者网络抖动没收到通知,这个事件就丢了,这个订单永远不会被取消。
延迟大:Redis的过期策略是“惰性+定期”。并不保证Key在30分钟那一刻立刻删除,延迟几分钟是常事。
解法2:Redis ZSet+轮询(中高级标准解法)
利用Redis的有序集合ZSet。
原理:利用Zset的Score属性存储。
订单超时的具体时间戳。value存订单号。
生产节点(下单):ZADD delay_queue < 30分钟后的时间戳>
消费阶段(轮询):启动一个后台线程,每秒从ZSet里“捞”数据
我们要找的是Score<=当前时间的元素(即已经超时的)ZRANGEBYSCORE delay_queue 0 <当前时间戳> LIMIT 0 10
优点:性能高(内存读写)精准(秒级误差)
解法3:消息队列/时间轮(架构师级解法)
如果数据量达到亿级,ZSet的大Key也会有性能瓶颈,这时候要搬出“大杀器”。
A.消息队列(RocketMQ/RabbitMQ)利用MQ的延时消息功能。
1.RocketMQ:
注意!RocketMQ 4.X只支持固定的延时等级(1s,5s...30m),不够灵活。
如果面试官“非固定时间任意延迟怎么办”,你要提RocketMQ 5.0(支持任意时间)或用Redis ZSet兜底。
2.RabbitMQ:
原生TTL+死信队列有个坑叫“队头阻塞”(如果第一个消息没有过期,后期的过期了也取不出来)。必须使用rabbitmq_delayed_message_exchange插件才能解决。
B.时间论算法(Hashed Wheel Timer)
这是Netty、Kafka内部都在用的底层算法
逻辑:想象一个钟表,有60个格子,指针每秒走一格,订单30分钟后过期。
就把它挂在“当前格子+1800”的那个槽位上。
优势:纯内存操作,极其高效。
短板:内存不可靠,重启即丢失。
大厂实践:通常是用Redis ZSet做持久化存储+内存时间轮做高频触发,redis负责存1小时后的任务,应用启动时把近期任务加载到内存时间轮里执行。
最后的“防杠”指南
Q1:多个节点同时轮询ZSet,怎么防止重复取消订单?
答:这是一个经典并发问题。
第一,利用Lua脚本保证ZRANGE和ZREM的原子性,谁抢到谁删,防止多线程读到同一条。
第二,业务幂等。取消订单的Service接口必须实现幂等,不管调几次,状态只能从‘未支付’变‘已取消’,更新成功才返回true,否则返回false.
Q2:Redis ZSet变成大Key怎么办(千万级订单)
答:“分片(sharding)”。不是把所有订单放一个Key。按订单id哈希取模,分散到delay_queue_0到delay_queue_9,这10个Zset里。启动10个线程分别去轮询,吞吐量直接翻10倍。
Q3:万一中间件全崩了,怎么办?
答:虽然概率极低,但是必须有兜底。我会保留一个T+1的离线扫描任务(跑在从库上),每天凌晨把昨天遗漏的未支付订单扫一遍再进行取消。架构设计要有‘中间件解耦’的自信,也要有‘最终一致性’的敬畏。
总结:对于订单超时这种高并发延迟任务,简单的数据库轮询是绝对不行的。性能太差,我的设计思路是存储与计算分离,存储与计算分离,利用中间件解耦:
1.架构选型:首选Redis ZSet 实现轻量级延迟队列。Score存过期时间戳,Value存订单号。
2.核心流程:后台调度线程每秒利用ZRANGEBYSCORE查询超时订单;拿到后利用Lua脚本原子性地移除并执行取消逻辑。
3.可靠性保障:为了防止‘取出后宕机’导致数据丢失,我会引入‘处理中队列’做ACK机制;同时,取消接口严格实现幂等,防止重复消费。
4.进阶优化:如果业务极大,我会考虑RocketMQ5.0的任意延迟消息,彻底解放业务服务。
5.兜底保障:最后,保留一个低频的数据库兜底扫描任务,确保数据在极端情况下也能最终一致性。

浙公网安备 33010602011771号