★ 设计一个延迟消息队列,你会怎么做?
结论先行:延迟队列 = 存储 + 时间调度 + 到期投递 + 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 + 幂等消费。