221 lines
8.4 KiB
Markdown
221 lines
8.4 KiB
Markdown
|
|
---
|
|||
|
|
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 存储设计]]
|