Files
cs-note/hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md
T

221 lines
8.4 KiB
Markdown
Raw Normal View History

2026-05-24 20:51:06 +08:00
---
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 存储设计]]