Redis 如何实现延迟消息?

#中间件#面试八股

Redis 如何实现延迟消息?

结论:Redis 没有原生延迟队列,主流做法是用 ZSet 存"执行时间戳 + 任务 ID",由一个调度器轮询到期任务并原子取走。三个必须处理的点:取任务要"查到即删"的原子性、多实例下不能重复投递、任务失败要有重试与兜底。Redisson 的 RDelayedQueue 就是这套逻辑的封装(内部用 ZSet + 目标队列 + 定时搬运)。

一、四种实现对比

方案原理精度可靠性适用
ZSet 轮询score=到期时间,定时 ZRANGEBYSCORE取决于轮询间隔(秒级)中,需自己做原子取走通用首选
Redisson DelayedQueueZSet + 目标 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,按业务容忍度取值。
笔记加载中…