★ 设计一个延迟消息队列,你会怎么做?

结论先行:延迟队列 = 存储 + 时间调度 + 到期投递 + ack/重试 四件事。小规模用 Redis ZSet(score 存到期时间戳、轮询取到期消息);进程内毫秒级调度用时间轮;大规模直接复用 MQ 的延迟能力(RocketMQ 定时消息)或 RabbitMQ 死信队列。回答先分层、再给方案对比,最后落到“可靠性靠 ack + 重试 + 死信”。

一、先谈四要素

要素要解决什么关键设计
存储消息放哪、宕机不丢DB/Redis 持久化、副本
时间调度何时到点时间轮 / ZSet 轮询 / 分级
到期投递到点后发给谁消费端订阅、投递并发控制
ack/重试消费失败怎么办手动 ack、重试次数、死信队列

二、方案对比

方案精度规模复杂度适用
DB 轮询秒级任务量小、可容忍轮询开销
Redis ZSet秒级订单超时、优惠券过期
时间轮毫秒级中(进程内)进程内大量短延迟任务
MQ 延迟能力分级/秒级低(用现成)RocketMQ / RabbitMQ 用户

三、Redis ZSet 实现要点

入队:ZADD delay_queue <到期时间戳> <msgId>
扫描:ZRANGEBYSCORE delay_queue 0 now LIMIT 0 100  → 取出 → ZREM
投递:发给消费者,等待手动 ack
失败:ack 超时/失败 → 重投或进死信 ZSet
  • 精度与空转:轮询间隔取 1s 左右,避免空转;多实例抢任务用分布式锁或按 msgId 分片。
  • 时间轮:一圈代表一个 tick 周期,指针按 tick 推进,延迟任务挂到对应槽位,到期取出执行(思路同 Netty HashedWheelTimer)。

四、可靠性兜底

  • 取出与删除要原子(Lua 脚本),防止“取出了但投递失败”丢消息。
  • 消费必须手动 ack + 重试上限 + 死信队列,人工介入兜底。
  • 重启恢复:扫描未投递完的 ZSet/表数据重新入调度,做到至少一次。

常见追问与记忆点

  • 追问:消息重复怎么办?投递是 at-least-once,消费端必须幂等(业务幂等键去重)。
  • 追问:为什么大厂不自己造而用 RocketMQ 定时消息?延迟消息难在集群级时间调度与持久化,自造性价比低。
  • 记忆点:延迟队列四件事——存储、调度(ZSet/时间轮)、投递、ack 重试死信;可靠性 = 原子取删 + 手动 ack + 幂等消费。
笔记加载中…