Files
cs-note/hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md
T
2026-05-24 20:51:06 +08:00

221 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 存储设计]]