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

8.4 KiB
Raw Blame History

tags, create time
tags create time
MQ
延迟消息
定时消息
时间轮
RocketMQ
Kafka
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。

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。

实现方案二:定时任务扫描

最朴素的方案:消息存入数据库,定时任务扫描到期记录并投递。

// 定时任务扫描方案的核心逻辑(伪代码)
// 每秒扫描一次数据库中到期的延迟消息
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)。

下面是一个简化版的单层时间轮实现:

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 低频定时任务 高性能定时器

关联笔记