订单超时关闭、红包到期退回这类延迟任务,要求「到点触发、至少执行一次」。Redis 有四条常见路径:ZSet 轮询、keyspace 过期通知、Redisson DelayedQueue,以及干脆交给专业 MQ 的延迟消息——可靠性与成本差别很大。

先明确两个硬指标

延迟任务的及格线有两条:时效性(到点尽快触发,偏差不失控)和可靠性(任务不丢,允许重复则由消费端幂等兜底)。后文每个方案都拿这两条来量。

方案一:ZSet 时间戳轮询

思路是把执行时间戳存为 score,任务内容存为 member:

# 提交任务:指定未来的执行时间戳(毫秒)
ZADD delay:tasks 1735689600000 "order:1001"
# 工作线程循环捞取已到期的任务
ZRANGEBYSCORE delay:tasks 0 <now_ms> LIMIT 0 100

捞到不等于抢到。多实例并发轮询时,必须用 Lua 脚本把「查到期任务 + ZREM 删除」合成原子操作,谁删成功谁执行,否则会重复消费。

优点是结构简单、任务随 RDB/AOF 持久化、精度可控;缺点是轮询本身有空转开销,触发精度取决于轮询间隔,间隔太短空耗 CPU、太长则延迟毛糙。折中做法是空闲时拉长间隔、临近任务密集时缩短,或直接看下面的 Redisson 封装。

方案二:keyspace 过期通知(不推荐进生产)

开启 notify-keyspace-events Ex 后,key 过期被删除时,Redis 会向 __keyevent@0__:expired 频道发布事件,订阅该频道即可得知「某 key 到期了」,间接实现延迟触发。思路很巧,但坑有三个:

  1. 时效性差:事件在「key 真正被删除的那一刻」发布,而不是到期时刻。过期 key 依赖惰性删除加定期抽查,到期后可能躺很久才被清掉,延迟完全不可控。
  2. 会丢消息:事件走 Pub/Sub,发后即忘、不持久化。订阅者断线或重启期间发生的事件直接消失。
  3. 重复消费:Pub/Sub 是广播语义,N 个服务实例都会收到同一条事件,去重要自己实现。

结论:只适合对时效与可靠性都无所谓的场景(如缓存失效后顺手刷新),订单、退款这类红线业务不要用。

方案三:Redisson DelayedQueue(开箱即用)

不想手写轮询,直接用 Redisson 的 RDelayedQueue。它的底层正是 ZSet:offer 的任务以执行时间戳为 score 进入有序集合,Redisson 自带的后台线程定期把到期任务搬运到目标队列,客户端用阻塞式 take() 获取。

相比自己撸 ZSet 轮询,它解决了三个工程问题:任务随 Redis 持久化,宕机只丢极少量(视持久化配置而定);所有实例从同一个目标队列取任务,天然不重复;API 干净,不用维护 Lua 与调度循环。代价是绑定 Redisson 组件与 JVM 技术栈。

与专业 MQ 延迟消息的对比

RocketMQ 4.x 提供 18 个固定延迟级别,5.x 起支持任意时间戳的定时消息;RabbitMQ 则靠 TTL 加死信交换机或延迟插件实现。与 Redis 方案相比,专业 MQ 赢在:消息落盘不丢、堆积能力强、消费确认与重试机制成熟、监控告警完善。Redis 方案赢在:复用现有组件、零额外部署、延迟极低、运维简单。

执行端的通用纪律

无论选哪条路线,三条纪律不变:

  1. 消费端幂等:延迟任务大多只保证「至少一次」,同一任务可能被执行多次。用业务单号加状态机(待执行 → 执行中 → 已完成)判重,重复触发直接短路。
  2. 状态落库 + 补偿扫描:任务的创建与状态变更写数据库,另起一个低频定时任务扫描「早该执行却仍待执行」的记录做兜底。这样即使 Redis 侧整条链路出问题,业务也能自愈。
  3. 时钟对齐:比较的是 Redis 所在机器的时间,多实例部署时用 NTP 对齐各机器时钟,并统一秒与毫秒单位,混用是线上事故的高发原因。

选型建议

  • 任务量小、延迟在分钟级、能容忍极小概率丢失:Redisson DelayedQueue,最省事。
  • 想自研可控:ZSet + Lua 原子抢任务,配上文的三条纪律。
  • 延迟长、流量大、不允许丢:直接上 RocketMQ 定时消息,把可靠性交给专业 MQ。
  • keyspace 通知:当作理解 Redis 过期机制的案例来学习,不进生产。