43.7 延迟消息与定时消息
一句话
延迟消息的本质是“到期才可见”,实现只有三条路:数据库轮询(简单但精度/吞吐差)、时间轮(内存 O(1) 插入、精度可调)、延迟级别(固定档位,最省事但不支持任意时间)——精度每提高一档,成本就上一个台阶。
三条实现路线
| 方案 | 精度 | 吞吐 | 存储 | 典型 |
|---|---|---|---|---|
| 数据库轮询 | 秒级(取决于轮询间隔) | 低(每次扫表) | 持久化 | 小规模定时任务 |
| 时间轮 | 毫秒–秒级(看槽粒度) | 高(O(1) 插入) | 内存 + 持久化 | Kafka 定时器、Netty、RocketMQ 内部 |
| 延迟级别 | 固定档位(如 1s/5s/10s…) | 高 | broker 内部 | RocketMQ 的 18 个级别 |
package main
import (
"fmt"
"math"
"math/rand"
)
const (
nTimers = 10000
maxDelayS = 3600 // 1 小时内的延迟
)
func main() {
r := rand.New(rand.NewSource(42))
delays := make([]float64, nTimers)
for i := range delays {
delays[i] = 1 + r.Float64()*(maxDelayS-1)
}
type wheel struct {
name string
slotSec float64 // 每个槽代表多少秒
slots int
}
wheels := []wheel{
{"1 秒粒度(3600 槽)", 1, 3600},
{"10 秒粒度(360 槽)", 10, 360},
{"1 分钟粒度(60 槽)", 60, 60},
}
fmt.Printf("%d 个定时器,延迟 1–%d 秒\n\n", nTimers, maxDelayS)
fmt.Printf("%-24s %-12s %-14s %-14s\n", "方案", "槽数(内存)", "平均误差", "最大误差")
// 时间轮:到期时间向上取整到槽边界
for _, w := range wheels {
sumErr, maxErr := 0.0, 0.0
for _, d := range delays {
boundary := (float64(int(d/w.slotSec)) + 1) * w.slotSec
e := boundary - d
sumErr += e
if e > maxErr {
maxErr = e
}
}
fmt.Printf("%-24s %-12d %-14s %-14s\n", w.name, w.slots,
fmt.Sprintf("%.1fs", sumErr/float64(nTimers)), fmt.Sprintf("%.1fs", maxErr))
}
// 对照:最小堆(精确,但每个定时器都要存一个节点)
fmt.Printf("%-24s %-12d %-14s %-14s\n", "最小堆(精确)", nTimers, "0s", "0s")
fmt.Println("\n=== 插入成本对比 ===")
fmt.Printf("时间轮:O(1)(算槽位、挂链表),与定时器数量无关\n")
fmt.Printf("最小堆:O(log n),%d 个定时器时每次插入约 %.0f 次比较\n",
nTimers, math.Log2(float64(nTimers)))
fmt.Println("\n=== 同一毫秒到期的定时器太多会怎样 ===")
// 模拟“整点”效应:大量任务设在 00:00:00 或整分钟
aligned := 0
for _, d := range delays {
if int(d)%60 == 0 {
aligned++
}
}
fmt.Printf("随机延迟里落在整分钟边界的只有 %d 个(概率 1/60)\n", aligned)
fmt.Printf("但如果业务都写“整点执行”,同一槽会有成百上千个任务 → 该槽处理时间成为尖峰\n")
}
本机实测(macOS,Apple M1,Go 1.27):
10000 个定时器,延迟 1–3600 秒
方案 槽数(内存) 平均误差 最大误差
1 秒粒度(3600 槽) 3600 0.5s 1.0s
10 秒粒度(360 槽) 360 5.0s 10.0s
1 分钟粒度(60 槽) 60 30.1s 60.0s
最小堆(精确) 10000 0s 0s
=== 插入成本对比 ===
时间轮:O(1)(算槽位、挂链表),与定时器数量无关
最小堆:O(log n),10000 个定时器时每次插入约 13 次比较
=== 同一毫秒到期的定时器太多会怎样 ===
随机延迟里落在整分钟边界的只有 144 个(概率 1/60)
但如果业务都写“整点执行”,同一槽会有成百上千个任务 → 该槽处理时间成为尖峰
读数:
| 方案 | 槽数 | 平均误差 | 结论 |
|---|---|---|---|
| 1 秒粒度 | 3,600 | 0.5s | 秒级精度足够,内存极小 |
| 10 秒粒度 | 360 | 5s | 内存省 10 倍,误差 10 倍 |
| 1 分钟粒度 | 60 | 30s | 只适合粗粒度任务 |
| 最小堆 | 10,000 节点 | 0 | 精确,但内存与插入成本随数量增长 |
🔑 时间轮的精髓是用“精度”换“内存与插入成本”:槽数固定,插入 O(1),不管有多少定时器。代价是到期时间被“向上取整”到槽边界——所以精度由槽粒度决定,而不是由定时器数量决定。
延迟消息的四个坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 精度与业务预期不符 | 用户设“30 分钟后提醒”,实际 30 分 50 秒才到 | 选精度足够的方案,或文档写明误差 |
| 同槽尖峰 | 整点大量任务同时触发,消费端被打满 | 加抖动(±30s)、分批消费 |
| 延迟消息不持久 | broker 重启,内存里的定时器丢了 | 选持久化实现,或落库 + 扫描 |
| 到期后重复投递 | 时间轮触发 + 消费失败重试,业务收到两次 | 幂等(43.6) |
反模式
| 反模式 | 后果 |
|---|---|
| 用“轮询数据库”做百万级延迟消息 | 每次全表扫描,数据库被打满 |
| 用固定延迟级别做“任意时间” | 只能近似到最近档位,误差不可控 |
| 槽粒度 1 分钟却要求秒级 | 平均晚 30 秒,用户投诉 |
| 不持久化定时器 | broker 重启后所有延迟消息丢失 |
| 不做抖动,全部整点触发 | 周期性尖峰,消费端超时 |
| 到期消息重复投递但不幂等 | 重复发短信/重复扣款 |
自检
Q1 为什么时间轮的插入是 O(1),而最小堆是 O(log n)?各适合什么规模?
▸ 答案:时间轮把“到期时间”映射到固定槽位,插入只是算槽号并挂到链表,成本与定时器数量无关;最小堆要比较并上浮,成本是 O(log n)。定时器数量少(几千)时差别可以忽略,堆实现简单且精确;数量大(百万级)、且能接受毫秒级误差时,时间轮的内存与 CPU 优势明显。
Q2 RocketMQ 的“18 个延迟级别”为什么不能做任意时间延迟?
▸ 答案:因为它把延迟消息按固定档位(1s/5s/10s/…/2h)存到不同的内部 topic,到期扫描是按“当前时间 − 档位”来捞的,只支持“落在某一档”的延迟。要实现任意时间,需要改造成“时间轮/定时队列 + 持久化”,或者用“延迟级别 + 二次投递”逼近(例如 30 分钟 = 30 个 1 分钟档,代价是多次投递与幂等)。
Q3 延迟消息为什么必须幂等,哪怕队列“只投一次”?
▸ 答案:因为延迟消息的触发路径通常包含“扫描 → 投递 → 消费确认”,任何一步失败都会重试;而且定时器可能重复触发(节点时钟回拨、扫描重复、主备同时触发)。所以“到期消息被投递两次”是常态而非异常,消费端必须按消息 ID/业务键去重。