延迟队列有哪些实现方式?
简化版
延迟队列让消息在到期后才可消费,常见实现有 Broker 原生定时消息、固定延迟级别、多级时间轮、Redis ZSet 扫描和数据库定时扫描。选型要看最大延迟、精度、吞吐、取消修改、持久化和到期风暴,而不是只看能否“晚点投递”。
详细版
消息应保存 eventId、deliverAt、业务 key 和版本;服务端以自己的时间判断到期,生产者时间只作为输入。到期搬运与投递至少一次,消费者必须幂等;取消不能只删内存任务,要用状态或版本阻止已在途消息生效。
时间轮适合大量定时任务但精度受 tick 影响,ZSet 适合中小规模但轮询与热 key 明显,数据库便于事务一致但扫描成本高。无论哪种方式,都要监控到期延迟、待触发量、最老任务、失败重试和时钟异常。
完整版教学
一、延迟队列保证的是什么
它通常保证“不早于 deliverAt 可见”,不保证恰好在该毫秒执行。Broker 调度、消费排队和下游限流都会产生晚到;面试时要区分触发精度与业务完成时间。
记忆钩子:延迟队列管“最早什么时候开始”,不是预约一个绝对准点的完成时刻。
二、四类实现对比
| 方案 | 精度 | 扩展性 | 主要边界 |
|---|---|---|---|
| Broker 原生 | 中高 | 高 | 产品能力差异 |
| 时间轮 | tick 级 | 高 | 重启恢复 |
| Redis ZSet | 轮询级 | 中 | 热点与持久化 |
| 数据库扫描 | 秒/分钟级 | 中低 | 索引与扫描压力 |
数据库方案适合任务与业务记录同事务创建;高吞吐长延迟更适合原生 Broker 或分层时间轮。
三、时间轮怎样工作
时间轴被分成槽,任务按剩余延迟放入槽与轮次;指针每个 tick 前进,到槽时递减轮次或投递。多级时间轮用秒、分、时多层降低大范围槽数量。
tick=1s, wheel=60 slots
delay=125s -> slot=(now+5)%60, rounds=2
在不考虑停顿和排队时,tick=1 秒带来的量化误差通常小于一个 tick;再叠加调度抖动与投递排队,仍不能宣称毫秒准点。
四、ZSet 与数据库扫描
ZSet 以 deliverAt 为 score,工作线程原子领取 score<=now 的成员。读取后再删除会被多实例重复领取,应使用 Lua、claim 状态或搬入处理队列。数据库用 (status, deliver_at) 索引分批 FOR UPDATE SKIP LOCKED,避免全表扫描。
两者都要限制单批数量;到期任务 100 万条同时触发时,立即全部推送只会把问题转成下游雪崩。
五、取消、修改与重复
取消可能与到期投递并发。可靠做法是任务带 version,消费者执行前查询或验证最新状态;取消把版本推进或状态改为 CANCELED,旧消息即使到达也不生效。修改时间可创建新版本并废弃旧版本。
eventId 唯一约束处理重复投递,但“同一业务的新版本”不能被旧幂等记录错误拦截,需把版本纳入处理条件。
六、容量与故障恢复
每秒到期 5000 条、单实例处理 1000/s,至少需 5 个实例,实际还要留失败与峰值余量。系统停机 10 分钟会集中释放过期任务,应按速率回放并允许业务判断是否已经失去意义。
持久化任务、调度水位和领取状态;进程恢复后重扫未确认任务。监控 schedule lag、触发 QPS、积压字节、重试和过期丢弃。
跨时区任务应先把业务时区规则解析成绝对时间,并记录原始时区与规则版本;夏令时切换不能只靠字符串时间临时换算。
七、常见误区与追问
- 误区:延迟消息会准点执行。 它只保证不提前,执行仍受排队影响。
- 误区:ZSet 取出后删除两步足够。 多实例并发会重复领取或丢任务。
- 误区:取消就是删除消息。 已在途消息必须靠状态或版本失效。
- 误区:全部到期后立即投递最快。 到期风暴会压垮消费者和下游。
- 追问:时间轮精度由什么决定? tick、调度抖动和投递队列共同决定。
- 追问:如何支持超长延迟? 多级时间轮、分层存储或 Broker 原生能力。
- 追问:停机后过期任务怎么办? 持久化重扫,并按业务过期规则限速恢复。
八、加强记忆
延迟队列按“存时间—等到期—原子领取—至少一次投递—幂等执行”理解。选型比较精度、范围、吞吐与持久化;取消靠版本,到期风暴靠限速,恢复靠持久水位。能延迟不等于准点,更不等于恰好执行一次。