Redis 如何实现延迟消息?
结论:Redis 没有原生延迟队列,主流做法是用 ZSet 存"执行时间戳 + 任务 ID",由一个调度器轮询到期任务并原子取走。三个必须处理的点:取任务要"查到即删"的原子性、多实例下不能重复投递、任务失败要有重试与兜底。Redisson 的
RDelayedQueue就是这套逻辑的封装(内部用 ZSet + 目标队列 + 定时搬运)。
一、四种实现对比
| 方案 | 原理 | 精度 | 可靠性 | 适用 |
|---|---|---|---|---|
| ZSet 轮询 | score=到期时间,定时 ZRANGEBYSCORE | 取决于轮询间隔(秒级) | 中,需自己做原子取走 | 通用首选 |
| Redisson DelayedQueue | ZSet + 目标 List/Queue,后台线程搬运 | 秒级 | 较高,封装了取走与重投 | 已有 Redisson 的项目 |
| 键过期事件 | 监听 __keyevent@0__:expired | 不可控,可能延迟很久 | 低,事件会丢 | 仅做提醒类 |
| Stream + 定时扫描 | 把到期时间写进字段,扫描后转移 | 秒级 | 中 | 需要消费组语义时 |
二、ZSet 方案的关键实现
producer scheduler(多实例) worker
│ ZADD delay:order score=到期毫秒 member=任务JSON
│ │
│ ├─ Lua: ZRANGEBYSCORE ... LIMIT 0 10
│ │ + ZREM(同一脚本,原子取走)
│ ├─ 取到 → 投递到业务队列 / 直接执行
│ └─ 没取到 → sleep 100ms 再来
▼ ▼
score 必须用"绝对到期时间",不能用"延迟秒数",否则无法排序比较
-- take_due.lua:原子地取出并删除到期任务,避免多实例重复消费
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
if #jobs == 0 then return {} end
redis.call('ZREM', KEYS[1], unpack(jobs))
return jobs
// 调度循环:单次批量 + 短睡眠,避免空转打满 CPU
while (running) {
List<String> due = redis.execute(TAKE_DUE, List.of("delay:order"),
String.valueOf(System.currentTimeMillis()), "20");
if (due.isEmpty()) {
Thread.sleep(100);
continue;
}
due.forEach(job -> executor.submit(() -> handleWithRetry(job)));
}
三、必须补齐的三件事
- 原子取走:
ZRANGEBYSCORE与ZREM分两条命令会有并发重复消费窗口,务必放进同一段 Lua;也可以ZPOPMIN后判断分数是否到期,未到期再ZADD回去(会有短暂"消失")。 - 失败重试:任务执行失败不能直接丢,把它以新的 score(now + 退避时间)重新
ZADD,并记录重试次数,超过上限转入死信 ZSet 供人工处理。 - 分片与容量:单个 ZSet 堆积千万级任务会变大 key,按业务或时间分片(
delay:order:20261103),并定期清理已完成的残留 member。
四、与 MQ 延迟消息的取舍
| 维度 | Redis ZSet 方案 | RocketMQ 延迟消息 | RabbitMQ 延迟插件 |
|---|---|---|---|
| 精度 | 秒级(取决于轮询) | 固定档位或任意秒级 | 毫秒~秒级 |
| 容量 | 受内存限制,百万级 | 磁盘堆积,亿级 | 磁盘堆积 |
| 可靠性 | 需自己保证取走原子与重试 | 原生持久化 + 重投 | 原生持久化 + ack |
| 运维成本 | 已有 Redis 则零新增 | 需部署与维护 | 需插件与集群 |
| 适用 | 中小规模、单体内延迟任务 | 大规模、跨服务 | 已有 RabbitMQ |
判断标准很直接:延迟任务是否与业务数据强绑定、量级是否超过百万、丢失是否可接受。都偏向"轻"就用 Redis,否则上 MQ。
五、常见追问
- 问:轮询间隔设多少?答:业务要求秒级就用 100~500 ms;要求分钟级可以放宽到 1 s 降低空转。精度与 Redis QPS 是此消彼长的关系。
- 问:为什么不用
KEYS或定时扫全库?答:KEYS是 O(N) 且阻塞主线程;延迟任务必须靠有序结构按时间范围取,不能全量扫。 - 问:键过期事件为什么不可靠?答:过期事件在"实际删除时"才发出,惰性删除可能延迟很久;事件不持久化,主从切换或重启会丢;且开启
notify-keyspace-events有额外开销。 - 问:Redisson 的 DelayedQueue 是怎么实现的?答:任务先进目标队列(List/Queue),同时把"到期时间 + 目标队列名"写入 ZSet;后台线程按到期时间把任务从 ZSet 搬到目标队列,消费者从目标队列取。
- 问:多实例部署会不会重复执行?答:只要"取走"是原子的就不会;若用
ZPOPMIN后判断未到期再放回,网络中断会导致任务丢失,需要补偿扫描。 - 问:延迟消息和定时任务框架怎么选?答:数据量小、延迟精度要求不高、想复用现有 Redis,就用 ZSet;需要可视化、分片、失败重试、海量任务时用专业调度平台或 MQ 的延迟消息。
小结
- 核心结构只有一个:
ZSet(score=到期时间, member=任务),配合原子取走。 - 原子性、重试、分片是三个必答题,缺一个就会在生产上出重复消费或任务丢失。
- 精度换成本:轮询越密越准,也越费 CPU 与 QPS,按业务容忍度取值。