背景
传统方案:定时任务轮询MySQL,查询status=待支付 && create_time < 当前时间-超时时间的订单,批量关闭。
痛点
- 大表查询,需索引,订单量越大DB压力越高;
- 轮询间隔不好调:间隔大,取消延迟高;间隔小,数据库QPS暴涨;
- 大量无效扫描,大部分订单还未超时。
Redis ZSet实现延迟任务,把待支付订单放入有序集合,到期触发取消,减少数据库轮询压力。
ZSet核心:
score存订单超时时间戳,member存订单号。 按score排序,直接读取已经到时间的订单。
整体流程
1. 创建订单(待支付)
- DB插入订单,状态=待支付
- Redis ZAdd:
zadd order:timeout:zset {超时时间戳} {orderId}
超时时间戳 = 当前时间 + 订单超时时长(例如30分钟)
//伪代码
long expireTs = System.currentTimeMillis() + 30 * 60 * 1000;
redisTemplate.opsForZSet().add("order:timeout:zset", orderId, expireTs);
2. 订单正常支付成功
支付完成,必须从ZSet移除订单,避免后续被误取消
redisTemplate.opsForZSet().remove("order:timeout:zset", orderId);
//db更新订单状态为已支付
3. 定时任务消费ZSet(核心)
开启定时任务(例如1s执行一次),原子取出已经超时的订单
ZRANGEBYSCORE key 0 当前时间戳 WITHSCORES 获取所有score ≤ 当前时间的订单ID。
⚠️重要:不能先查再删,多实例部署会出现重复消费! 推荐方案:Lua脚本保证原子:查询+删除一体
Lua脚本 order_timeout.lua
-- 参数:key, nowTime, count(每次取多少条)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
-- 获取已经超时的订单列表
local orders = redis.call('ZRANGEBYSCORE', key, 0, now, 'LIMIT', 0, limit)
if #orders == 0 then
return {}
end
-- 将这批订单从zset删除
redis.call('ZREM', key, unpack(orders))
return orders
逻辑:一次性读出N条超时订单,同时直接从zset删除;返回订单号列表,交给业务处理。
优势:多服务实例部署,Lua脚本原子,不会重复拿到同一个订单。
4. 业务处理超时订单
拿到订单id列表之后:
- 查询DB订单,二次校验状态:必须是待支付,才执行取消
风险点:极端场景,订单已经支付成功,但是Redis remove网络丢包没执行成功,ZSet还残留该订单,所以一定要查DB校验状态,不能直接按Redis数据取消订单!
- 更新数据库订单状态:已关闭,记录关闭原因【超时未支付】
- 释放库存、释放优惠券、发送消息通知等业务逻辑。
关键问题 & 坑点
🔴坑1:Redis宕机,ZSet数据丢失
ZSet只做延迟触发工具,不做订单数据源,订单真实数据永远在MySQL。
- Redis丢失后,ZSet里面待取消订单全部消失,订单永远不会自动关闭。 ✅解决方案:
- Redis开启AOF持久化;
- 保留低频兜底MySQL轮询,比如5分钟跑一次,扫描真正超时未关闭订单做补偿。(不要删掉mysql轮询,只是降低频率,不再高频轮询)
🔴坑2:网络抖动,支付成功ZREM执行失败
支付成功,DB改状态成功,ZREM网络异常失败,订单还留在ZSet。
消费的时候必须查询DB校验订单状态,发现订单已经支付,直接跳过,不做取消。
🔴坑3:重复消费
使用Lua脚本,读取同时删除,避免多实例重复获取。
禁止:
zrangebyscore查询→业务处理→zrem删除,非原子,多节点会重复消费。
🔴坑4:大流量,单次取出太多订单
脚本增加limit参数,每次只取少量,例如每次取200条,循环消费,防止一次返回上万条数据阻塞Redis。
🔴坑5:订单修改超时时间场景
例如用户延长支付时间,需要:
- ZREM 删除旧orderId
- ZADD写入新的超时时间戳
🔴坑6:订单创建DB成功,ZAdd失败
DB订单创建成功,redis写入失败,订单永远不会被zset触发取消。 ✅兜底:低频mysql轮询补偿;也可以用消息队列保证Redis写入。
和其他方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| MySQL高频轮询 | 实现简单 | DB压力大,大表性能差 |
| Redis ZSet | DB压力小,触发相对精准 | 依赖Redis,需要兜底补偿 |
| DelayQueue(JVM内存) | 本地内存,简单 | 重启丢失,分布式不可用 |
| RocketMQ/RabbitMQ延迟消息 | 专业延迟队列 | 需要消息中间件,运维重 |
ZSet适合:不想引入消息中间件,希望轻量实现订单超时。
架构小结
- 创建订单:DB落库 + ZADD(orderId,超时时间戳)
- 支付成功:DB更新状态 + ZREM(orderId)
- 定时任务调用Lua脚本原子取出超时订单,ZSet中移除
- 拿到订单ID,查询DB校验订单状态,真正执行取消逻辑
- 保留低频MySQL轮询做兜底补偿,防止Redis丢数据
Mermaid流程图
扩展优化
- 分片ZSet:千万级订单,单个zset压力大,可以按订单id哈希分片,拆成多个zset,分散redis压力。
- 消费失败补偿:拿到订单处理失败,可以重新ZAdd回zset(时间往后挪几秒,不要立刻放回),避免死循环。
- 不要把复杂业务数据放进member,member只用订单号,详情全部查DB。
注意:本文归作者所有,未经作者允许,不得转载