如何基于Redis实现延时任务?
订单超时关闭、红包到期退回这类延迟任务,要求「到点触发、至少执行一次」。Redis 有四条常见路径:ZSet 轮询、keyspace 过期通知、Redisson DelayedQueue,以及干脆交给专业 MQ 的延迟消息——可靠性与成本差别很大。
先明确两个硬指标
延迟任务的及格线有两条:时效性(到点尽快触发,偏差不失控)和可靠性(任务不丢,允许重复则由消费端幂等兜底)。后文每个方案都拿这两条来量。
方案一:ZSet 时间戳轮询
思路是把执行时间戳存为 score,任务内容存为 member:
# 提交任务:指定未来的执行时间戳(毫秒) |
捞到不等于抢到。多实例并发轮询时,必须用 Lua 脚本把「查到期任务 + ZREM 删除」合成原子操作,谁删成功谁执行,否则会重复消费。
优点是结构简单、任务随 RDB/AOF 持久化、精度可控;缺点是轮询本身有空转开销,触发精度取决于轮询间隔,间隔太短空耗 CPU、太长则延迟毛糙。折中做法是空闲时拉长间隔、临近任务密集时缩短,或直接看下面的 Redisson 封装。
方案二:keyspace 过期通知(不推荐进生产)
开启 notify-keyspace-events Ex 后,key 过期被删除时,Redis 会向 __keyevent@0__:expired 频道发布事件,订阅该频道即可得知「某 key 到期了」,间接实现延迟触发。思路很巧,但坑有三个:
- 时效性差:事件在「key 真正被删除的那一刻」发布,而不是到期时刻。过期 key 依赖惰性删除加定期抽查,到期后可能躺很久才被清掉,延迟完全不可控。
- 会丢消息:事件走 Pub/Sub,发后即忘、不持久化。订阅者断线或重启期间发生的事件直接消失。
- 重复消费: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 方案赢在:复用现有组件、零额外部署、延迟极低、运维简单。
执行端的通用纪律
无论选哪条路线,三条纪律不变:
- 消费端幂等:延迟任务大多只保证「至少一次」,同一任务可能被执行多次。用业务单号加状态机(待执行 → 执行中 → 已完成)判重,重复触发直接短路。
- 状态落库 + 补偿扫描:任务的创建与状态变更写数据库,另起一个低频定时任务扫描「早该执行却仍待执行」的记录做兜底。这样即使 Redis 侧整条链路出问题,业务也能自愈。
- 时钟对齐:比较的是 Redis 所在机器的时间,多实例部署时用 NTP 对齐各机器时钟,并统一秒与毫秒单位,混用是线上事故的高发原因。
选型建议
- 任务量小、延迟在分钟级、能容忍极小概率丢失:Redisson DelayedQueue,最省事。
- 想自研可控:ZSet + Lua 原子抢任务,配上文的三条纪律。
- 延迟长、流量大、不允许丢:直接上 RocketMQ 定时消息,把可靠性交给专业 MQ。
- keyspace 通知:当作理解 Redis 过期机制的案例来学习,不进生产。

