IIWAB

订单超时取消:Redis SortedSet(ZSet) 替代MySQL轮询

IIWAB 17天前 ⋅ 58 阅读

背景

传统方案:定时任务轮询MySQL,查询status=待支付 && create_time < 当前时间-超时时间的订单,批量关闭。 痛点

  1. 大表查询,需索引,订单量越大DB压力越高;
  2. 轮询间隔不好调:间隔大,取消延迟高;间隔小,数据库QPS暴涨;
  3. 大量无效扫描,大部分订单还未超时。

Redis ZSet实现延迟任务,把待支付订单放入有序集合,到期触发取消,减少数据库轮询压力

ZSet核心:score订单超时时间戳member订单号。 按score排序,直接读取已经到时间的订单。


整体流程

1. 创建订单(待支付)

  1. DB插入订单,状态=待支付
  2. 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列表之后:

  1. 查询DB订单,二次校验状态:必须是待支付,才执行取消

风险点:极端场景,订单已经支付成功,但是Redis remove网络丢包没执行成功,ZSet还残留该订单,所以一定要查DB校验状态,不能直接按Redis数据取消订单!

  1. 更新数据库订单状态:已关闭,记录关闭原因【超时未支付】
  2. 释放库存、释放优惠券、发送消息通知等业务逻辑。

关键问题 & 坑点

🔴坑1:Redis宕机,ZSet数据丢失

ZSet只做延迟触发工具,不做订单数据源,订单真实数据永远在MySQL。

  • Redis丢失后,ZSet里面待取消订单全部消失,订单永远不会自动关闭。 ✅解决方案:
  1. Redis开启AOF持久化;
  2. 保留低频兜底MySQL轮询,比如5分钟跑一次,扫描真正超时未关闭订单做补偿。(不要删掉mysql轮询,只是降低频率,不再高频轮询)

🔴坑2:网络抖动,支付成功ZREM执行失败

支付成功,DB改状态成功,ZREM网络异常失败,订单还留在ZSet。

消费的时候必须查询DB校验订单状态,发现订单已经支付,直接跳过,不做取消。

🔴坑3:重复消费

使用Lua脚本,读取同时删除,避免多实例重复获取。

禁止:zrangebyscore查询→业务处理→zrem删除,非原子,多节点会重复消费。

🔴坑4:大流量,单次取出太多订单

脚本增加limit参数,每次只取少量,例如每次取200条,循环消费,防止一次返回上万条数据阻塞Redis。

🔴坑5:订单修改超时时间场景

例如用户延长支付时间,需要:

  1. ZREM 删除旧orderId
  2. ZADD写入新的超时时间戳

🔴坑6:订单创建DB成功,ZAdd失败

DB订单创建成功,redis写入失败,订单永远不会被zset触发取消。 ✅兜底:低频mysql轮询补偿;也可以用消息队列保证Redis写入。


和其他方案对比

方案优点缺点
MySQL高频轮询实现简单DB压力大,大表性能差
Redis ZSetDB压力小,触发相对精准依赖Redis,需要兜底补偿
DelayQueue(JVM内存)本地内存,简单重启丢失,分布式不可用
RocketMQ/RabbitMQ延迟消息专业延迟队列需要消息中间件,运维重

ZSet适合:不想引入消息中间件,希望轻量实现订单超时。

架构小结

  1. 创建订单:DB落库 + ZADD(orderId,超时时间戳)
  2. 支付成功:DB更新状态 + ZREM(orderId)
  3. 定时任务调用Lua脚本原子取出超时订单,ZSet中移除
  4. 拿到订单ID,查询DB校验订单状态,真正执行取消逻辑
  5. 保留低频MySQL轮询做兜底补偿,防止Redis丢数据

Mermaid流程图

flowchart LR A[用户下单] --> B[DB创建订单 待支付] B --> C[ZAdd orderId,超时时间戳] D[用户支付成功] --> E[DB更新订单已支付] E --> F[ZREM删除orderId] G[定时任务执行] --> H[执行Lua脚本:ZRANGEBYSCORE+ZREM原子获取超时订单] H --> I{是否拿到订单} I --无--> G I --有--> J[查询DB真实订单状态校验] J -->|状态=待支付| K[执行订单取消:关单、释放库存] J -->|已支付/已关闭| L[直接跳过] K & L --> G M[低频兜底定时任务] --> N[MySQL扫描超时待支付订单,补偿关闭]

扩展优化

  1. 分片ZSet:千万级订单,单个zset压力大,可以按订单id哈希分片,拆成多个zset,分散redis压力。
  2. 消费失败补偿:拿到订单处理失败,可以重新ZAdd回zset(时间往后挪几秒,不要立刻放回),避免死循环。
  3. 不要把复杂业务数据放进member,member只用订单号,详情全部查DB。

全部评论: 0

    我有话说: