--- tags: [MQ, 延迟消息, 定时消息, 时间轮, RocketMQ, Kafka] create time: 2026-05-24 19:52 --- # 延迟消息与定时消息 ## 概述 延迟消息(Delayed Message)和定时消息(Scheduled Message)让消息在指定时间点之后才投递给 Consumer,而不是发送后立即可消费。两者看似相似,语义上却有微妙差别:延迟消息关注"发出去多久之后才投递",定时消息关注"在某个绝对时间点投递"。实际工程中两者常混用,但理解区别有助于做出正确设计。 ## 正文 ### 延迟消息 vs 定时消息 | 维度 | 延迟消息 | 定时消息 | |------|---------|---------| | 语义 | 发送后延迟 N 秒投递 | 在指定时间戳投递 | | 时间基准 | 相对时间(relative) | 绝对时间(absolute) | | 典型 API 参数 | `delayLevel` 或 `delay` | `deliverTime` 或 `scheduledTime` | | 时钟依赖 | Broker 本地时钟即可 | 需要全局时钟一致 | > [!question] 思考 > 如果 Producer 和 Broker 的时钟差了 5 秒,定时消息的投递精度会受多大影响?延迟消息呢? ### 典型应用场景 **订单超时取消**:用户下单后 30 分钟未支付,自动取消订单并释放库存。Producer 在订单创建时发送一条延迟 30 分钟的消息,Consumer 收到后检查订单状态,未支付则执行取消。 **延迟重试**:消费失败后不立即重试,而是延迟递增(1s → 5s → 30s)再投递,避免短时间内反复冲击下游服务。 **定时推送**:运营活动在指定时间点触发(如每天早上 9 点推送),需要精确的定时投递能力。 ### 实现方案一:延迟级别 RocketMQ 采用"延迟级别"方案,内置 18 个固定延迟级别: | Level | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | |-------|---|---|---|---|---|---|---|---|---| | 延迟 | 1s | 5s | 10s | 30s | 1m | 2m | 3m | 4m | 5m | | Level | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | |-------|----|----|----|----|----|----|----|----|----| | 延迟 | 6m | 7m | 8m | 9m | 10m | 20m | 1h | 2h | 6h | 核心原理:消息发送后不写入目标 Topic 的 ConsumeQueue,而是写入内部延迟 Topic(`SCHEDULE_TOPIC_XXXX`),每个延迟级别对应一个固定的 Queue。Broker 内部的定时任务扫描这些 Queue,到期后将消息重新投递到原始 Topic。 ```mermaid graph TD Producer["Producer"] -->|"发送延迟消息"| Broker["Broker"] Broker -->|"写入延迟 Topic"| SchedQueue["SCHEDULE_TOPIC_XXXX\n每个 Level 一个 Queue"] SchedQueue -->|"定时任务扫描\n检查到期时间"| Timer["Timer / Quartz"] Timer -->|"到期投递"| OrigTopic["原始 Topic"] OrigTopic -->|"正常消费"| Consumer["Consumer"] style Producer fill:#4A90D9,color:#fff style Broker fill:#F5A623,color:#fff style SchedQueue fill:#6EC1E0,color:#fff style Timer fill:#D0021B,color:#fff style OrigTopic fill:#6EC1E0,color:#fff style Consumer fill:#4A90D9,color:#fff ``` > [!question] 思考 > RocketMQ 为什么只提供 18 个固定的延迟级别,而不是任意延迟时间? 答案很简单:工程取舍。如果支持任意延迟时间,Broker 需要为每条消息维护独立的定时器,内存开销和调度复杂度都会爆炸式增长。固定的 18 个级别让 Broker 只需维护 18 个 Queue + 18 个定时任务,极大降低了实现复杂度。代价是时间粒度粗糙(比如你需要 45 秒延迟,只能选 30 秒或 1 分钟),对于大多数业务场景够用了。RocketMQ 5.x 已经支持任意延迟时间,底层用时间轮替换了 Quartz。 ### 实现方案二:定时任务扫描 最朴素的方案:消息存入数据库,定时任务扫描到期记录并投递。 ```go // 定时任务扫描方案的核心逻辑(伪代码) // 每秒扫描一次数据库中到期的延迟消息 func scanAndDeliver(db *sql.DB, producer Producer) { rows, _ := db.Query( "SELECT id, topic, body FROM delayed_msg WHERE deliver_at <= NOW() LIMIT 1000", ) defer rows.Close() for rows.Next() { var id int64 var topic, body string rows.Scan(&id, &topic, &body) // 投递到实际 Topic producer.Send(topic, []byte(body)) // 标记已投递或删除 db.Exec("DELETE FROM delayed_msg WHERE id = ?", id) } } ``` 方案优点是实现简单、延迟精度可控(取决于扫描间隔),缺点是数据库成为瓶颈——高吞吐场景下每秒扫描百万级记录不现实。适合低频场景,如定时通知、报表生成等。 ### 实现方案三:时间轮算法 时间轮(Timing Wheel)是 Kafka 内部处理延迟任务的核心数据结构,也是很多高性能定时器的底层实现。 **基本原理**:将时间划分为等长的 slot(槽位),每个 slot 对应一个时间间隔。用一个环形数组表示,指针按固定频率推进,每次推进到某个 slot 时,执行该 slot 上的所有到期任务。 ``` 时间轮示意(8 个 slot,每个 slot = 1 秒): Slot 0 → Slot 1 → Slot 2 → ... → Slot 7 ↑ | └─────────── 环形回绕 ────────────┘ 当前指针在 Slot 0,要延迟 5 秒的任务放到 Slot 5 ``` **多层时间轮**:当需要更大时间范围时,可以堆叠多层——底层时间轮转一圈,上层时间轮推进一格,并将上层 slot 中的任务重新分配到底层。这和时钟的秒针/分针/时针是同一个思路。 Kafka 内部使用层级时间轮(Hierarchical Timing Wheel)管理请求超时、延迟操作(如 DelayedFetch、DelayedProduce)。相比 Java 的 `ScheduledThreadPoolExecutor`(内部用堆排序的 DelayQueue),时间轮在大量短延迟任务场景下性能更优——插入和删除都是 O(1)。 下面是一个简化版的单层时间轮实现: ```go package timewheel import ( "sync" "time" ) // Task 延迟任务 type Task struct { delay time.Duration fn func() circle int // 转多少圈后执行 } // TimeWheel 单层时间轮 type TimeWheel struct { slots int // 槽位数量 interval time.Duration // 每个槽位的时间间隔 tasks [][]*Task // 每个槽位上的任务列表 current int // 当前指针位置 mu sync.Mutex } // New 创建时间轮 func New(slots int, interval time.Duration) *TimeWheel { tw := &TimeWheel{ slots: slots, interval: interval, tasks: make([][]*Task, slots), current: 0, } for i := range tw.tasks { tw.tasks[i] = make([]*Task, 0) } return tw } // Add 添加延迟任务 func (tw *TimeWheel) Add(delay time.Duration, fn func()) { tw.mu.Lock() defer tw.mu.Unlock() // 计算需要跳过的槽位数和圈数 steps := int(delay / tw.interval) circle := steps / tw.slots pos := (tw.current + steps%tw.slots) % tw.slots tw.tasks[pos] = append(tw.tasks[pos], &Task{delay: delay, fn: fn, circle: circle}) } // Start 启动时间轮,每 interval 推进一格 func (tw *TimeWheel) Start() { ticker := time.NewTicker(tw.interval) go func() { for range ticker.C { tw.advance() } }() } // advance 推进一格,执行到期任务 func (tw *TimeWheel) advance() { tw.mu.Lock() defer tw.mu.Unlock() // 取出当前槽位的任务 cur := tw.tasks[tw.current] tw.tasks[tw.current] = make([]*Task, 0) for _, t := range cur { if t.circle > 0 { t.circle-- // 还没到,圈数减一放回去 tw.tasks[tw.current] = append(tw.tasks[tw.current], t) } else { go t.fn() // 到期执行 } } tw.current = (tw.current + 1) % tw.slots } ``` 这段代码的核心逻辑:`Add` 根据延迟时间算出目标槽位和圈数,`advance` 每次推进一格,圈数归零的任务立即异步执行。插入和推进都是 O(1),非常高效。 ### 各方案对比 | 维度 | 延迟级别 | 数据库扫描 | 时间轮 | |------|---------|-----------|--------| | 时间精度 | 粗(固定级别) | 高(毫秒级) | 高(毫秒级) | | 吞吐能力 | 高 | 低 | 极高 | | 实现复杂度 | 低 | 低 | 中 | | 内存开销 | 低 | 高(数据库) | 低 | | 适用场景 | 通用 MQ | 低频定时任务 | 高性能定时器 | ## 关联笔记 - [[06-高级特性/18-MQ-事务消息|MQ 事务消息]] - [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]] - [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] - [[07-主流MQ对比/24-RocketMQ|RocketMQ]] - [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]]