vault backup: 2026-05-24 20:51:06

This commit is contained in:
hhs
2026-05-24 20:51:06 +08:00
parent 910d16682b
commit 686c0a5bd5
52 changed files with 9246 additions and 0 deletions
@@ -0,0 +1,220 @@
---
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 存储设计]]
@@ -0,0 +1,172 @@
---
tags: [MQ, 事务消息, 分布式事务, RocketMQ, Kafka]
create time: 2026-05-24 19:52
---
# 事务消息
## 概述
事务消息解决的核心问题是:**本地数据库操作与消息发送的原子性**。要么数据库写成功且消息发出去,要么两边都失败——不能出现"数据库写成功了但消息没发出去"或反过来的情况。RocketMQ 原生支持事务消息,Kafka 通过 Producer 事务机制实现类似效果,两者的设计哲学截然不同。
## 正文
### 为什么需要事务消息
考虑一个经典场景:订单服务创建订单后,需要通知库存服务扣减库存。代码可能是:
```go
func CreateOrder(order Order) error {
// 1. 写数据库
db.Insert(order)
// 2. 发消息通知库存服务
mq.Send("topic_inventory", order)
return nil
}
```
这段代码有两个致命问题:
- **消息发送失败**:数据库写成功了,但 `mq.Send` 网络超时,库存不会扣减,数据不一致。
- **数据库回滚但消息已发出**:如果在事务提交前消息就发出去了,库存扣了但订单没创建。
> [!question] 思考
> 能不能把消息发送放在数据库事务里面?比如先 `BEGIN`,写库,发消息,再 `COMMIT`?
不行。消息发送是网络 IO,不在数据库事务的管辖范围内。即使你把发消息放在 `COMMIT` 之后,`COMMIT` 成功到消息发送成功之间仍然有窗口期可能失败。这就是分布式事务的经典难题。
### 两阶段流程:半消息 → 本地事务 → 提交/回滚
RocketMQ 事务消息的核心思想是引入"半消息"(Half Message)——消息先发送到 Broker,但对 Consumer 不可见,等本地事务执行完毕后再决定是提交(Commit)还是回滚(Rollback)。
```mermaid
sequenceDiagram
participant P as Producer
participant B as Broker
participant DB as 本地数据库
participant C as Consumer
P->>B: 1. 发送半消息(Half Message)
B-->>P: 返回发送结果(成功/失败)
Note over P: 半消息发送成功,开始执行本地事务
P->>DB: 2. 执行本地事务(写订单表)
DB-->>P: 事务结果
alt 本地事务成功
P->>B: 3a. 提交半消息(Commit)
B->>C: 消息可见,正常消费
else 本地事务失败
P->>B: 3b. 回滚半消息(Rollback)
Note over B: 消息被丢弃,Consumer 不可见
end
style P fill:#4A90D9,color:#fff
style B fill:#F5A623,color:#fff
style DB fill:#6EC1E0,color:#fff
style C fill:#4A90D9,color:#fff
```
关键点:半消息写入 Broker 后存在内部 Topic(`RMQ_SYS_TRANS_HALF_TOPIC`),不会投递给 Consumer。只有 Commit 之后,消息才会被转移到真正的目标 Topic。
### 事务状态回查
如果 Producer 发送 Commit/Rollback 时网络断了怎么办?Broker 收不到最终决定,半消息就"卡住"了。
RocketMQ 的解决方案是**事务状态回查**:Broker 定期扫描超时的半消息(默认 6 次,每次间隔递增),主动向 Producer 发起回查请求,Producer 根据本地事务执行状态返回 Commit、Rollback 或 Unknown。
```go
// 事务消息的 Producer 核心逻辑
type TransactionListener interface {
// ExecuteLocalTransaction 执行本地事务
ExecuteLocalTransaction(msg *Message) LocalTransactionState
// CheckLocalTransaction Broker 回查时调用
CheckLocalTransaction(msg *MessageExt) LocalTransactionState
}
// 示例实现
type OrderTransactionListener struct {
db *sql.DB
}
func (l *OrderTransactionListener) ExecuteLocalTransaction(msg *Message) LocalTransactionState {
// 执行本地事务:创建订单
order := parseOrder(msg.Body)
err := l.db.Exec("INSERT INTO orders ...", order)
if err != nil {
return RollbackMessage // 失败则回滚半消息
}
return CommitMessage // 成功则提交
}
func (l *OrderTransactionListener) CheckLocalTransaction(msg *MessageExt) LocalTransactionState {
// Broker 回查:查询订单是否已创建
orderID := msg.GetProperty("ORDER_ID")
var count int
l.db.QueryRow("SELECT COUNT(*) FROM orders WHERE id = ?", orderID).Scan(&count)
if count > 0 {
return CommitMessage // 订单存在,说明本地事务成功了
}
return RollbackMessage // 订单不存在,回滚
}
```
### Kafka 的 Producer 事务
Kafka 的思路不同——它不提供"半消息"机制,而是让 Producer 开启事务后,所有写入要么全部可见,要么全部不可见。
```go
// Kafka Producer 事务示例(confluent-kafka-go 风格)
producer, _ := kafka.NewProducer(&kafka.ConfigMap{
"transactional.id": "order-service-001", // 事务 ID,用于幂等
})
producer.InitTransactions(context.Background())
producer.BeginTransaction()
// 在事务中发送多条消息
producer.Produce(orderMsg, nil)
producer.Produce(inventoryMsg, nil)
// 提交事务——所有消息同时可见
err := producer.CommitTransaction(context.Background())
if err != nil {
producer.AbortTransaction(context.Background())
}
```
Consumer 端配合 `isolation.level=read_committed`,只能读到已提交事务的消息,未提交的会被过滤掉。这和数据库的 MVCC 隔离级别是同一个思路。
### 事务消息 vs 其他分布式事务方案
| 维度 | 事务消息 | 2PC | Saga | TCC |
|------|---------|-----|------|-----|
| 一致性模型 | 最终一致性 | 强一致性 | 最终一致性 | 最终一致性 |
| 性能 | 高 | 低(阻塞) | 高 | 中 |
| 实现复杂度 | 低 | 中 | 高 | 很高 |
| 业务侵入 | 低 | 低 | 高(补偿逻辑) | 很高(三个接口) |
| 适用场景 | 事件驱动、异步解耦 | 数据库分布式事务 | 长流程编排 | 资金类强一致 |
| 失败处理 | 回查 + 人工介入 | 全局回滚 | 补偿事务 | Cancel + 防悬挂 |
事务消息的核心优势是**解耦**——Producer 不需要知道下游有哪些 Consumer,也不需要定义补偿逻辑。它适合"本地事务成功后通知其他服务"的场景,不适合需要跨服务同步等待结果的场景。
> [!question] 思考
> 事务消息的回查机制在网络分区时可能失败,如何保证最终一致性?
回查失败时 Broker 会按递增间隔多次重试(默认最多 6 次)。如果全部失败,半消息会进入死信队列(`RMQ_SYS_TRANS_OP_HALF_TOPIC`),运维人员可以介入处理。本质上,事务消息保证的是"最终一致性"而非"强一致性"——在极端故障下,需要人工兜底。生产环境中,建议配合对账任务定期检查本地事务和消息状态的一致性。
### 事务消息的局限性
- **单向通知**:事务消息只保证"本地事务 + 消息发送"的原子性,不保证 Consumer 消费成功。
- **延迟**:半消息从发送到 Consumer 可见有延迟(取决于本地事务执行时间和可能的回查)。
- **不支持批量**:一次事务消息只能关联一条消息,不支持多条消息的原子发送(Kafka 的 Producer 事务支持批量)。
- **回查依赖网络**:回查机制在网络分区时可能失效,需要运维兜底。
## 关联笔记
- [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]]
- [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]]
- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]]
- [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]]
- [[07-主流MQ对比/24-RocketMQ|RocketMQ]]
@@ -0,0 +1,177 @@
---
tags: [MQ, 消息过滤, 消息路由, Tag, SQL92, RocketMQ, RabbitMQ]
create time: 2026-05-24 19:52
---
# 消息过滤与路由
## 概述
一个 Topic 下可能有多种类型的消息,但某个 Consumer 只关心其中一部分。消息过滤(Filtering)让 Consumer 只接收自己需要的消息,消息路由(Routing)决定消息应该流向哪些队列或消费者。两者配合,才能在大规模系统中实现精准、高效的消息分发。
## 正文
### 消息过滤的需求
举个例子:电商系统的 `Topic_Order` 下有"创建"、"支付"、"取消"三种类型的消息。物流服务只关心"支付"类型,风控服务只关心"创建"和"取消"类型。如果没有过滤机制,每个 Consumer 都要接收全量消息再自行判断,浪费网络带宽和 CPU。
### Broker 端过滤 vs Consumer 端过滤
过滤发生在哪里,直接影响系统效率和灵活性:
| 维度 | Broker 端过滤 | Consumer 端过滤 |
|------|-------------|----------------|
| 网络传输 | 只传输匹配的消息,节省带宽 | 传输全部消息,Consumer 自行过滤 |
| Broker 负载 | 增加(需要解析消息属性) | 无影响 |
| 灵活性 | 受限于 Broker 支持的过滤语法 | 任意逻辑,完全灵活 |
| 实现难度 | 高(Broker 需要理解消息语义) | 低(纯客户端逻辑) |
| 典型代表 | RocketMQ Tag/SQL 过滤 | Kafka Consumer 自行过滤 |
```mermaid
graph TD
Producer["Producer"] -->|"发送消息\n带 Tag/Properties"| Broker["Broker"]
subgraph "Broker 端过滤"
Broker -->|"根据过滤规则匹配"| Filter["过滤引擎"]
Filter -->|"匹配的消息"| C1["Consumer A"]
Filter -->|"匹配的消息"| C2["Consumer B"]
end
subgraph "Consumer 端过滤"
Broker -->|"全部消息"| C3["Consumer C"]
C3 -->|"客户端过滤"| Logic["业务逻辑过滤"]
end
style Producer fill:#4A90D9,color:#fff
style Broker fill:#F5A623,color:#fff
style Filter fill:#D0021B,color:#fff
style C1 fill:#6EC1E0,color:#fff
style C2 fill:#6EC1E0,color:#fff
style C3 fill:#6EC1E0,color:#fff
style Logic fill:#6EC1E0,color:#fff
```
> [!question] 思考
> Broker 端过滤可以减少网络传输,但会增加 Broker 负载。如何权衡?
关键在于过滤的"性价比"——如果过滤能淘汰 90% 的消息,那 Broker 多花一点 CPU 做过滤完全值得,因为省下的网络 IO 和 Consumer 处理时间远大于过滤开销。反过来,如果过滤只能淘汰 10% 的消息,不如在 Consumer 端过滤,把 Broker 的 CPU 留给更重要的事(如存储、复制)。实际生产中,Tag 过滤的性价比通常很高,因为一个 Tag 就能精准划分消息类型。
### 过滤方式一:Tag 过滤
RocketMQ 原生支持的最简单过滤方式。Producer 发送消息时指定 Tag,Consumer 订阅时用 Tag 表达式过滤。
```go
// Producer: 发送带 Tag 的消息
msg := NewMessage("Topic_Order", []byte(orderJSON))
msg.SetTags("PAY") // 设置 Tag 为 PAY
producer.Send(msg)
// Consumer: 只订阅 PAY 和 CANCEL 标签
consumer.Subscribe("Topic_Order", "PAY || CANCEL")
```
Tag 过滤发生在 Broker 端(ConsumeQueue 中存储了 Tag 的 hash 值),匹配效率很高。缺点是过滤粒度粗——只能按 Tag 精确匹配,不支持 `>`, `<`, `IN` 等复杂条件。
Tag 的底层实现很巧妙:ConsumeQueue 每条记录有 8 字节存储 Tag 的 hashcode,Broker 过滤时直接比较 hashcode,命中后再精确匹配 Tag 字符串,避免了解析消息体的开销。
### 过滤方式二:SQL92 表达式过滤
RocketMQ 支持基于 SQL92 子集的表达式过滤,功能比 Tag 强大得多。消息通过 `UserProperty` 设置自定义属性,Consumer 用 SQL 表达式过滤。
```go
// Producer: 设置自定义属性
msg := NewMessage("Topic_Order", []byte(orderJSON))
msg.SetTags("PAY")
msg.PutProperty("amount", "99.9")
msg.PutProperty("region", "CN")
producer.Send(msg)
// Consumer: SQL92 表达式过滤
// 支持 AND、OR、IN、BETWEEN、IS NULL、比较运算符
consumer.Subscribe("Topic_Order", "amount > 50 AND region IN ('CN','US')")
```
SQL92 过滤同样在 Broker 端执行,Broker 会编译 SQL 表达式为语法树,对每条消息的属性进行求值。需要注意的是,SQL 过滤比 Tag 过滤消耗更多 Broker CPU,高吞吐场景下要谨慎使用。
支持的运算符和函数:
- 比较:`>`, `<`, `>=`, `<=`, `=`, `<>`, `BETWEEN`
- 逻辑:`AND`, `OR`, `NOT`
- 集合:`IN`
- 空值:`IS NULL`, `IS NOT NULL`
- 字符串:`LIKE`(仅支持 `%` 通配符)
### 过滤方式三:Header 属性过滤
RabbitMQ 的 Headers Exchange 通过消息 Header 属性进行路由匹配,不依赖 Routing Key。
```go
// RabbitMQ Headers Exchange 示例
// Producer: 发送带 Header 的消息
ch.Publish("orders_exchange", "", false, false, amqp.Publishing{
Headers: amqp.Table{
"x-match": "all", // all = 全部匹配, any = 任一匹配
"type": "payment",
"region": "CN",
"amount": 99,
},
Body: orderJSON,
})
// Consumer: 绑定队列时指定 Header 匹配规则
ch.QueueBind("payment_cn_queue", "", "orders_exchange", false, amqp.Table{
"x-match": "all",
"type": "payment",
"region": "CN",
})
```
Headers Exchange 的优势是路由规则完全由消息的 Header 决定,不需要像 Topic Exchange 那样设计 Routing Key 的层级结构。缺点是性能比 Direct/Topic Exchange 略差,因为需要逐个比较 Header 字段。
### 消息路由策略
路由决定消息从 Producer 到 Consumer 的流转路径,常见策略包括:
**Topic 路由**:最基础的路由方式,消息按 Topic 分发,每个 Topic 内按 Queue/Partition 分配。大部分场景下 Topic 路由就够用了。
**自定义路由规则**:当 Topic 粒度不够时,可以通过消息属性 + 过滤规则实现更精细的路由。比如同一个 Topic 下,按 `region` 属性路由到不同地域的 Consumer。
**消息再投递**:当 Consumer 处理失败或需要将消息转发到另一个 Topic 时,消息再投递(Consume-RePublish)模式就派上用场了。常见于消息转换、错误重试、死信转发等场景。
```go
// 消息再投递示例:消费失败时转发到重试 Topic
func handle(msg *MessageExt) ConsumeResult {
err := process(msg)
if err != nil {
// 计算重试次数
retryCount := msg.GetReconsumeTimes()
if retryCount >= 3 {
// 超过重试次数,投递到死信队列
producer.Send("DLQ_Order", msg.Body)
return ConsumeSuccess
}
// 重试:消息会自动投递到 %RETRY% Topic
return ReconsumeLater
}
return ConsumeSuccess
}
```
### 各过滤方式对比
| 维度 | Tag 过滤 | SQL92 过滤 | Header 过滤 |
|------|---------|-----------|-------------|
| 过滤位置 | Broker 端 | Broker 端 | Broker 端 |
| 过滤粒度 | 精确匹配 | 条件表达式 | 键值对匹配 |
| 性能 | 极高 | 中 | 中 |
| 灵活性 | 低 | 高 | 中 |
| 适用场景 | 简单消息分类 | 复杂业务规则 | AMQP 路由 |
| 典型 MQ | RocketMQ | RocketMQ | RabbitMQ |
## 关联笔记
- [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]]
- [[06-高级特性/18-MQ-事务消息|MQ 事务消息]]
- [[02-消息模型/3-MQ-消息模型|MQ 消息模型]]
- [[03-协议与标准/5-AMQP-协议|AMQP 协议]]
- [[07-主流MQ对比/24-RocketMQ|RocketMQ]]
- [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]]
@@ -0,0 +1,162 @@
---
tags: [MQ, Schema, 消息序列化, Kafka]
create time: 2026-05-24 19:52
---
# MQ Schema 管理与演进
## 概述
消息队列中,Producer 和 Consumer 通过"约定"消息格式来通信。当业务迭代导致消息结构变更时,如果没有统一的 Schema 管理机制,线上就会出现序列化/反序列化失败、数据丢失、静默错误等问题。Schema Registry 提供了集中化的 Schema 管理、版本控制和兼容性校验能力,是大规模消息系统不可或缺的基础设施。
## 正文
### 为什么需要 Schema 管理
想象一个电商系统:订单服务往 Kafka 发送订单消息,下游有 5 个消费者。某天订单服务给消息体加了一个 `discount` 字段,但没通知下游。结果:
- 某些消费者用强类型反序列化,直接报错崩溃
- 某些消费者用 JSON 解析,虽然没报错但忽略了新字段,数据不一致
- 某些老版本消费者尝试反序列化,拿到的 `discount` 值为零,业务逻辑出错
> [!question]
> 如果你的系统只有 2 个服务、消息格式一年都不变一次,还需要 Schema Registry 吗?想一想"现在不需要"和"未来不需要"的区别。
这些问题的根源在于:**Producer 和 Consumer 之间缺少一个权威的消息格式定义和变更协调机制**。Schema 管理要解决的核心问题就是:
1. **消息格式的"单一事实来源"** —— 所有服务对同一份 Schema 达成共识
2. **版本演进的可控性** —— 字段增删改必须经过兼容性校验
3. **生产环境的安全网** —— 不兼容的变更在发布前就被拦截,而不是在线上爆炸
### Schema Registry 的作用
Schema Registry 是一个独立的服务,扮演消息格式"中间人"的角色。它的核心职责有三个:
**集中管理 Schema**。所有 Topic 的消息格式都注册到 Registry,Producer 发消息前必须先注册 Schema,Consumer 拉消息时根据 Schema ID 动态获取 Schema 来反序列化。任何一方都不能"自说自话"。
**版本控制**。每次 Schema 变更都会生成一个新版本,保留历史记录。可以回溯到任意版本,也可以比较两个版本之间的差异。
**兼容性校验**。这是最关键的能力。当 Producer 尝试注册一个新版本的 Schema 时,Registry 会根据配置的兼容性策略,自动判断新旧 Schema 是否兼容。不兼容直接拒绝注册,从源头阻止破坏性变更。
### 主流编码格式对比
| 格式 | 可读性 | 体积 | 演进支持 | 典型场景 |
|:---:|:---:|:---:|:---:|------|
| JSON | 高 | 大 | 弱 | 调试、小规模系统、前后端交互 |
| Avro | 低 | 小 | 强 | Kafka 生态首选、大数据管道 |
| Protobuf | 低 | 小 | 强 | gRPC、跨语言高性能通信 |
| Thrift | 低 | 小 | 中 | 内部 RPC、Facebook 生态 |
JSON 的优势在于人可以直接读,调试方便,但它没有原生的 Schema 概念——你可以在 JSON 里随便加字段删字段,编译器不会报错,运行时才发现问题。Avro 和 Protobuf 都要求预先定义 Schema(`.avsc` / `.proto` 文件),序列化时只传数据不传 Schema,体积小得多,而且有完善的字段编号机制来支持 Schema 演进。
> [!question]
> Avro 和 Protobuf 都是二进制格式,都支持 Schema 演进,那 Kafka 生态为什么更偏爱 Avro?(提示:想想"读时 Schema"和"写时 Schema"的区别)
### Schema 兼容性策略
兼容性策略决定了什么样的 Schema 变更是允许的。这是 Schema 管理的核心规则:
| 策略 | 含义 | 允许的变更 | 风险 |
|------|------|------|------|
| **向前兼容** (Forward) | 新 Schema 能读旧数据 | 只能给新字段加默认值 | Consumer 升级后能读老数据 |
| **向后兼容** (Backward) | 旧 Schema 能读新数据 | 只能删除有默认值的字段 | Consumer 未升级也能读新数据 |
| **完全兼容** (Full) | 同时满足前向和向后 | 只能加有默认值的字段 | 最安全,但变更空间最小 |
| **无兼容** (None) | 任意变更 | 不限制 | 线上随时可能炸 |
实际生产中最常见的是 **Backward 兼容**——因为 Consumer 的升级通常滞后于 Producer。你加新字段时带上默认值,老版本 Consumer 反序列化时会忽略新字段,不会报错。等 Consumer 也升级后,就能利用新字段了。
> [!question]
> 为什么实际生产中 Backward 比 Forward 更常用?如果你的场景是 Consumer 先升级、Producer 后升级呢?
### Confluent Schema Registry 工作原理
Confluent Schema Registry 是 Kafka 生态中最广泛使用的 Schema 管理方案,核心概念有三个:
- **Subject**:Schema 的逻辑分组,通常以 `{topic-name}-key` 或 `{topic-name}-value` 命名
- **Schema ID**:每个注册的 Schema 都有一个全局唯一 ID,写入消息时只带 ID,不带完整 Schema
- **Registry API**:RESTful 接口,支持 Schema 的注册、查询、兼容性检查
工作流程如下:
```mermaid
sequenceDiagram
participant Producer
participant Registry as "Schema Registry"
participant Broker as "Kafka Broker"
participant Consumer
Producer->>Registry: POST /subjects/order-value/versions
Note right of Registry: 校验兼容性策略
Registry-->>Producer: 返回 Schema ID (如 42)
Producer->>Broker: 发送消息 [Magic Byte + Schema ID 42 + Avro 数据]
Broker->>Consumer: 拉取消息
Consumer->>Registry: GET /schemas/ids/42
Registry-->>Consumer: 返回 Schema 定义
Consumer->>Consumer: 根据 Schema 反序列化 Avro 数据
```
关键细节:消息体的前 5 个字节是固定的——1 字节 Magic Byte(固定为 0)+ 4 字节 Schema ID。Consumer 读到这 5 个字节就知道该用哪个 Schema 来反序列化。Schema 本身会缓存在 Consumer 本地,不需要每次都去 Registry 拉取。
### Go 代码示例:使用 Avro 编解码
```go
package main
import (
"fmt"
"log"
"github.com/linkedin/goavro/v2"
)
func main() {
// 定义 Avro Schema
schema := `{
"type": "record",
"name": "Order",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "amount", "type": "double"},
{"name": "discount", "type": ["null", "double"], "default": null}
]
}`
// 创建 Codec(编解码器)
codec, err := goavro.NewCodec(schema)
if err != nil {
log.Fatal(err)
}
// 编码:Go map -> Avro 二进制
order := map[string]interface{}{
"order_id": "ORD-2026001",
"amount": 299.99,
"discount": map[string]interface{}{"double": 30.0},
}
binary, err := codec.BinaryFromNative(nil, order)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Avro 编码后 %d 字节,原始 JSON 约 %d 字节\n", len(binary), 80)
// 解码:Avro 二进制 -> Go map
decoded, _, err := codec.NativeFromBinary(binary)
if err != nil {
log.Fatal(err)
}
fmt.Printf("解码结果: %v\n", decoded)
}
```
这段代码展示了 Avro 的核心用法:先定义 Schema(JSON 格式的 `.avsc`),再用 Codec 做编解码。注意 `discount` 字段用了 union 类型 `["null", "double"]`,并设了默认值 `null`——这就是保证向后兼容的关键。即使老版本的消息里没有 `discount` 字段,反序列化时也会得到 `null`,不会报错。
在实际 Kafka 场景中,你还需要把 Schema 注册到 Registry,拿到 Schema ID,然后在消息体前面拼上 Magic Byte + Schema ID,再发到 Broker。Consumer 端反过来:先读 5 字节头,从 Registry 获取 Schema,再解码。
## 关联笔记
- [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]]
- [[06-高级特性/21-MQ-消息压缩与批处理|MQ 消息压缩与批处理]]
- [[07-主流MQ对比/22-Kafka|Kafka]]
- [[12-架构与实战/48-MQ-客户端-SDK-最佳实践|MQ 客户端 SDK 最佳实践]]
@@ -0,0 +1,155 @@
---
tags: [MQ, 压缩, 批处理, Kafka, 性能优化]
create time: 2026-05-24 19:52
---
# MQ 消息压缩与批处理
## 概述
高吞吐消息系统的核心挑战之一是在可靠性、延迟和资源消耗之间找到平衡。压缩和批处理是两个最直接有效的优化手段:压缩减少每条消息的网络传输量和磁盘占用,批处理则通过"攒一批再发"摊薄单条消息的固定开销。两者结合使用时还能产生协同效应——批量越大,重复模式越多,压缩效果越好。
## 正文
### 压缩的动机
消息队列的每一字节数据都要经历"序列化 → 网络传输 → 磁盘存储 → 网络传输 → 反序列化"的完整链路。假设一个日均处理 10 亿条消息的 Kafka 集群,每条消息平均 500 字节:
- 不压缩:每天约 500GB 原始数据,加上副本复制,网络带宽和磁盘消耗翻倍
- 压缩后(以 3:1 压缩比估算):每天约 170GB,网络带宽节省 60% 以上
压缩不仅仅是"省空间",它直接影响到:
1. **网络带宽**:跨机房、跨地域复制时,带宽成本极高
2. **磁盘 I/O**:写入量减少意味着 Page Cache 利用率更高,读取更快
3. **Consumer 吞吐**:拉取同样数量的消息,压缩后的数据量更小,网络往返更少
> [!question]
> 压缩和解压都需要 CPU。如果 CPU 已经是瓶颈了,还要不要开压缩?什么情况下 CPU 的代价比网络/磁盘的收益更大?
### 压缩算法对比
Kafka 支持四种压缩算法,各有取舍:
| 算法 | 压缩率 | 压缩速度 | 解压速度 | CPU 开销 | 适用场景 |
|:---:|:---:|:---:|:---:|:---:|------|
| Gzip | 高 (~70%) | 慢 | 中 | 高 | 带宽极度敏感、吞吐要求不高 |
| Snappy | 中 (~50%) | 快 | 快 | 低 | 通用场景、Google 生态 |
| LZ4 | 中 (~50%) | 极快 | 极快 | 低 | 低延迟、高吞吐首选 |
| Zstd | 高 (~70%) | 中 | 快 | 中 | 压缩率和速度的最佳平衡 |
**LZ4** 是 Kafka 生产环境中最常用的选择——它的压缩/解压速度是 Gzip 的 5-10 倍,虽然压缩率略低,但在消息队列场景下,速度往往比极致压缩率更重要。**Zstd** 是后起之秀,Facebook 开源,在压缩率上接近 Gzip,速度接近 LZ4,正在成为新的默认推荐。
> [!question]
> 为什么 Kafka 不用 Brotli 或 LZMA?这两种算法压缩率更高啊。(提示:想想消息队列的读写比例和延迟要求)
### 压缩层次
压缩可以在三个层次发生,选择不同层次影响着系统行为:
**Producer 端压缩**。Producer 在发送前压缩消息,Broker 原样存储,Consumer 拉取后解压。这是最常见的模式,也是 Kafka 的默认行为。Broker 不需要额外 CPU 开销,但 Broker 无法直接读取消息内容(比如做日志审计、Schema 校验时需要先解压)。
**Broker 端压缩**。Producer 发送未压缩的消息,Broker 接收后重新压缩再存储。这种模式下 Broker 负担重,而且如果 Producer 和 Broker 使用不同的压缩算法,消息会经历"解压→压缩"的双重开销。一般不推荐。
**端到端压缩**。从 Producer 到 Consumer 全程保持压缩态,中间节点(Broker)只做字节存储,不做任何解压操作。Kafka 默认就是端到端压缩——Producer 设置 `compression.type` 后,消息在 Broker 上以压缩格式持久化,Consumer 拉取时自动解压。这种模式最大限度地保护了数据的压缩态,减少了中间环节的 CPU 消耗。
### 批处理(Batching)
批处理的核心思想是"攒够再发",摊薄每条消息的固定开销(网络往返、磁盘刷写、协议头等)。
**Producer 端攒批发送**。Kafka Producer 内部维护一个 RecordAccumulator,每个 Partition 对应一个双端队列(Deque),消息先写入队列中的 RecordBatch。当批次大小达到阈值或等待时间到期,Sender 线程才真正把批次发送到 Broker。
**Consumer 端批量拉取**。Consumer 拉取消息时不是一条一条取,而是通过 `fetch.min.bytes` 和 `fetch.max.wait.ms` 控制单次拉取的数据量和最大等待时间。Broker 会尽量凑够一批再返回,减少网络往返次数。
### batch.size 和 linger.ms 的权衡
这两个 Kafka Producer 参数是调优的关键:
| 参数 | 默认值 | 含义 | 调大 | 调小 |
|------|:---:|------|------|------|
| `batch.size` | 16KB | 单个批次的最大字节数 | 吞吐提升,延迟增加 | 延迟降低,吞吐下降 |
| `linger.ms` | 0 | 等待凑批的时间上限 | 批次更满,压缩更好 | 更快发送,批次可能不满 |
`linger.ms = 0` 意味着"来一条就发一条",几乎不等——这对延迟敏感的场景友好,但浪费了批处理的优化空间。实践中通常设为 `5-100ms`,在延迟和吞吐之间找平衡。
> [!question]
> 如果你的业务要求 P99 延迟 < 10ms,`linger.ms` 该怎么设?如果 P99 延迟要求是 500ms 呢?
### 压缩 + 批处理的协同效应
这是最容易被忽视但收益最大的优化点。压缩算法的本质是找到数据中的重复模式并编码。单条小消息的重复模式少,压缩效果差;把 100 条消息攒成一个批次再压缩,消息之间的结构相似性(相同的字段名、相似的数据分布)会让压缩率大幅提升。
```mermaid
graph LR
P["Producer"] --> |"批量消息"| C1["压缩前: 100 条 x 500B = 50KB"]
C1 --> C2["压缩后: ~15KB (3.3x 压缩比)"]
C2 --> |"网络传输"| Broker["Kafka Broker"]
Broker --> |"拉取压缩数据"| D1["Consumer"]
D1 --> D2["解压: 15KB -> 50KB"]
D2 --> D3["逐条反序列化"]
style C1 fill:#F5A623,color:#fff
style C2 fill:#4A90D9,color:#fff
```
实际数据对比:单条 500 字节消息用 LZ4 压缩,压缩比可能只有 1.2x;同样 100 条消息攒成批次后压缩,压缩比能达到 3x-5x。这就是为什么 `batch.size` 和 `compression.type` 要一起调的原因。
### Go 代码示例:Kafka Producer 配置压缩和批量参数
```go
package main
import (
"fmt"
"log"
"github.com/IBM/sarama"
)
func main() {
config := sarama.NewConfig()
config.Producer.Return.Successes = true
// 启用 LZ4 压缩(Kafka Broker 0.10+ 支持端到端压缩)
config.Producer.Compression = sarama.CompressionLZ4
// 批处理配置:最大 64KB 或等待 50ms,先到先发
config.Producer.Flush.Bytes = 64 * 1024 // batch.size
config.Producer.Flush.Frequency = 50e6 // linger.ms (50ms, 纳秒单位)
config.Producer.Flush.Messages = 200 // 最多攒 200 条
producer, err := sarama.NewSyncProducer([]string{"localhost:9092"}, config)
if err != nil {
log.Fatal(err)
}
defer producer.Close()
// 发送消息 —— 内部自动攒批 + LZ4 压缩
for i := 0; i < 1000; i++ {
msg := &sarama.ProducerMessage{
Topic: "orders",
Value: sarama.StringEncoder(fmt.Sprintf(`{"order_id":"ORD-%d","amount":%.2f}`, i, 99.99)),
}
_, _, err := producer.SendMessage(msg)
if err != nil {
log.Printf("发送失败: %v", err)
}
}
fmt.Println("1000 条消息已发送,LZ4 压缩 + 64KB 批处理")
}
```
代码中三个关键配置的含义:
- `Compression = sarama.CompressionLZ4`:启用 LZ4 压缩,Producer 端压缩,Broker 存储压缩态,Consumer 端自动解压
- `Flush.Bytes = 64KB`:当累积消息达到 64KB 时触发发送,等同于 Kafka 原生的 `batch.size`
- `Flush.Frequency = 50ms`:最多等 50ms 就发送,等同于 `linger.ms`,保证延迟不会无限增长
注意 `Flush.Messages = 200` 是 Sarama 独有的参数,额外提供了一个"条数"维度的触发条件。三个条件(字节、时间、条数)满足任意一个就会触发发送,确保在不同消息速率下都能有合理的批处理行为。
## 关联笔记
- [[06-高级特性/20-MQ-Schema-管理与演进|MQ Schema 管理与演进]]
- [[07-主流MQ对比/22-Kafka|Kafka]]
- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]]
- [[12-架构与实战/48-MQ-客户端-SDK-最佳实践|MQ 客户端 SDK 最佳实践]]