From 686c0a5bd561dfcca7d04d29e78f07191723c5e5 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 24 May 2026 20:51:06 +0800 Subject: [PATCH] vault backup: 2026-05-24 20:51:06 --- hhs/MQ/01-基础概念/1-MQ-基础概念.md | 146 +++++++++ hhs/MQ/01-基础概念/2-MQ-适用场景与选型原则.md | 162 ++++++++++ hhs/MQ/02-消息模型/3-MQ-消息模型.md | 210 +++++++++++++ hhs/MQ/03-协议与标准/4-MQ-消息协议总览.md | 100 ++++++ hhs/MQ/03-协议与标准/5-AMQP-协议.md | 238 ++++++++++++++ hhs/MQ/03-协议与标准/6-MQTT-协议.md | 170 ++++++++++ .../03-协议与标准/7-JMS-与-OpenMessaging.md | 162 ++++++++++ hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md | 127 ++++++++ hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md | 149 +++++++++ hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md | 192 ++++++++++++ hhs/MQ/04-存储引擎/9-Kafka-存储设计.md | 236 ++++++++++++++ .../05-可靠性保障/12-MQ-消息确认与持久化.md | 159 ++++++++++ .../05-可靠性保障/13-MQ-Exactly-Once-语义.md | 178 +++++++++++ hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md | 160 ++++++++++ hhs/MQ/05-可靠性保障/15-MQ-顺序性保障.md | 181 +++++++++++ .../05-可靠性保障/16-MQ-死信队列与消息回溯.md | 140 +++++++++ .../06-高级特性/17-MQ-延迟消息与定时消息.md | 220 +++++++++++++ hhs/MQ/06-高级特性/18-MQ-事务消息.md | 172 ++++++++++ hhs/MQ/06-高级特性/19-MQ-消息过滤与路由.md | 177 +++++++++++ hhs/MQ/06-高级特性/20-MQ-Schema-管理与演进.md | 162 ++++++++++ hhs/MQ/06-高级特性/21-MQ-消息压缩与批处理.md | 155 +++++++++ hhs/MQ/07-主流MQ对比/22-Kafka.md | 223 +++++++++++++ hhs/MQ/07-主流MQ对比/23-RabbitMQ.md | 214 +++++++++++++ hhs/MQ/07-主流MQ对比/24-RocketMQ.md | 140 +++++++++ hhs/MQ/07-主流MQ对比/25-Apache-Pulsar.md | 153 +++++++++ .../26-NATS-NSQ-Redis-Streams.md | 173 +++++++++++ hhs/MQ/07-主流MQ对比/27-MQ-选型对比.md | 117 +++++++ .../28-MQ-Competing-Consumers-模式.md | 153 +++++++++ .../29-MQ-CQRS-与-Event-Sourcing.md | 174 +++++++++++ .../30-MQ-Claim-Check-与消息瘦身.md | 168 ++++++++++ hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md | 158 ++++++++++ hhs/MQ/08-消息设计模式/32-MQ-请求-回复模式.md | 140 +++++++++ hhs/MQ/09-流处理与事件驱动/33-MQ-与流处理.md | 156 ++++++++++ .../34-事件驱动架构-EDA.md | 216 +++++++++++++ hhs/MQ/09-流处理与事件驱动/35-MQ-与-CDC.md | 141 +++++++++ hhs/MQ/10-监控与运维/36-MQ-监控指标与告警.md | 178 +++++++++++ hhs/MQ/10-监控与运维/37-MQ-消费积压治理.md | 192 ++++++++++++ .../10-监控与运维/38-MQ-消息轨迹与链路追踪.md | 188 +++++++++++ .../10-监控与运维/39-MQ-容器化与-K8s-部署.md | 200 ++++++++++++ hhs/MQ/10-监控与运维/40-MQ-性能调优.md | 180 +++++++++++ hhs/MQ/10-监控与运维/41-MQ-测试策略.md | 189 +++++++++++ hhs/MQ/11-安全与多租户/42-MQ-认证与授权.md | 216 +++++++++++++ hhs/MQ/11-安全与多租户/43-MQ-加密与审计.md | 272 ++++++++++++++++ hhs/MQ/12-架构与实战/44-MQ-高可用架构.md | 160 ++++++++++ .../12-架构与实战/45-MQ-跨集群复制与容灾.md | 128 ++++++++ hhs/MQ/12-架构与实战/46-MQ-分布式事务实践.md | 223 +++++++++++++ hhs/MQ/12-架构与实战/47-MQ-与微服务.md | 133 ++++++++ .../48-MQ-客户端-SDK-最佳实践.md | 188 +++++++++++ hhs/MQ/12-架构与实战/49-MQ-客户端连接管理.md | 197 ++++++++++++ hhs/MQ/12-架构与实战/50-MQ-设计与实现.md | 237 ++++++++++++++ hhs/MQ/13-云消息服务/51-云消息服务.md | 149 +++++++++ hhs/MQ/README.md | 294 ++++++++++++++++++ 52 files changed, 9246 insertions(+) create mode 100644 hhs/MQ/01-基础概念/1-MQ-基础概念.md create mode 100644 hhs/MQ/01-基础概念/2-MQ-适用场景与选型原则.md create mode 100644 hhs/MQ/02-消息模型/3-MQ-消息模型.md create mode 100644 hhs/MQ/03-协议与标准/4-MQ-消息协议总览.md create mode 100644 hhs/MQ/03-协议与标准/5-AMQP-协议.md create mode 100644 hhs/MQ/03-协议与标准/6-MQTT-协议.md create mode 100644 hhs/MQ/03-协议与标准/7-JMS-与-OpenMessaging.md create mode 100644 hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md create mode 100644 hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md create mode 100644 hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md create mode 100644 hhs/MQ/04-存储引擎/9-Kafka-存储设计.md create mode 100644 hhs/MQ/05-可靠性保障/12-MQ-消息确认与持久化.md create mode 100644 hhs/MQ/05-可靠性保障/13-MQ-Exactly-Once-语义.md create mode 100644 hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md create mode 100644 hhs/MQ/05-可靠性保障/15-MQ-顺序性保障.md create mode 100644 hhs/MQ/05-可靠性保障/16-MQ-死信队列与消息回溯.md create mode 100644 hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md create mode 100644 hhs/MQ/06-高级特性/18-MQ-事务消息.md create mode 100644 hhs/MQ/06-高级特性/19-MQ-消息过滤与路由.md create mode 100644 hhs/MQ/06-高级特性/20-MQ-Schema-管理与演进.md create mode 100644 hhs/MQ/06-高级特性/21-MQ-消息压缩与批处理.md create mode 100644 hhs/MQ/07-主流MQ对比/22-Kafka.md create mode 100644 hhs/MQ/07-主流MQ对比/23-RabbitMQ.md create mode 100644 hhs/MQ/07-主流MQ对比/24-RocketMQ.md create mode 100644 hhs/MQ/07-主流MQ对比/25-Apache-Pulsar.md create mode 100644 hhs/MQ/07-主流MQ对比/26-NATS-NSQ-Redis-Streams.md create mode 100644 hhs/MQ/07-主流MQ对比/27-MQ-选型对比.md create mode 100644 hhs/MQ/08-消息设计模式/28-MQ-Competing-Consumers-模式.md create mode 100644 hhs/MQ/08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing.md create mode 100644 hhs/MQ/08-消息设计模式/30-MQ-Claim-Check-与消息瘦身.md create mode 100644 hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md create mode 100644 hhs/MQ/08-消息设计模式/32-MQ-请求-回复模式.md create mode 100644 hhs/MQ/09-流处理与事件驱动/33-MQ-与流处理.md create mode 100644 hhs/MQ/09-流处理与事件驱动/34-事件驱动架构-EDA.md create mode 100644 hhs/MQ/09-流处理与事件驱动/35-MQ-与-CDC.md create mode 100644 hhs/MQ/10-监控与运维/36-MQ-监控指标与告警.md create mode 100644 hhs/MQ/10-监控与运维/37-MQ-消费积压治理.md create mode 100644 hhs/MQ/10-监控与运维/38-MQ-消息轨迹与链路追踪.md create mode 100644 hhs/MQ/10-监控与运维/39-MQ-容器化与-K8s-部署.md create mode 100644 hhs/MQ/10-监控与运维/40-MQ-性能调优.md create mode 100644 hhs/MQ/10-监控与运维/41-MQ-测试策略.md create mode 100644 hhs/MQ/11-安全与多租户/42-MQ-认证与授权.md create mode 100644 hhs/MQ/11-安全与多租户/43-MQ-加密与审计.md create mode 100644 hhs/MQ/12-架构与实战/44-MQ-高可用架构.md create mode 100644 hhs/MQ/12-架构与实战/45-MQ-跨集群复制与容灾.md create mode 100644 hhs/MQ/12-架构与实战/46-MQ-分布式事务实践.md create mode 100644 hhs/MQ/12-架构与实战/47-MQ-与微服务.md create mode 100644 hhs/MQ/12-架构与实战/48-MQ-客户端-SDK-最佳实践.md create mode 100644 hhs/MQ/12-架构与实战/49-MQ-客户端连接管理.md create mode 100644 hhs/MQ/12-架构与实战/50-MQ-设计与实现.md create mode 100644 hhs/MQ/13-云消息服务/51-云消息服务.md create mode 100644 hhs/MQ/README.md diff --git a/hhs/MQ/01-基础概念/1-MQ-基础概念.md b/hhs/MQ/01-基础概念/1-MQ-基础概念.md new file mode 100644 index 0000000..8500393 --- /dev/null +++ b/hhs/MQ/01-基础概念/1-MQ-基础概念.md @@ -0,0 +1,146 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 基础概念 + +## 概述 + +消息队列(Message Queue,简称 MQ)是一种异步通信机制,允许应用程序通过消息的生产和消费来实现解耦与协作。本文从生活类比出发,梳理核心术语、基本工作流程以及 MQ 的三大核心价值。 + +## 正文 + +### 什么是消息队列 + +想象一下你去寄快递:你把包裹交给快递柜,快递柜暂存包裹,收件人有空时再来取件。你不需要知道收件人什么时候在家,快递柜也不关心包裹里装的是什么——三方各司其职。 + +消息队列做的事情完全一样:**Producer(生产者)把消息投递到 Broker(消息服务器),Broker 暂存消息,Consumer(消费者)按自己的节奏拉取并处理。** 生产和消费在时间上解耦,双方不需要同时在线。 + +> [!question] +> 生活中还有哪些场景天然就是"消息队列"的模式?食堂排队打饭算不算? + +### 核心术语图解 + +| 术语 | 说明 | 类比 | +|------|------|------| +| **Producer** | 消息的发送方 | 寄件人 | +| **Broker** | 消息服务器,负责存储和转发 | 快递柜 / 邮局 | +| **Consumer** | 消息的接收方和处理方 | 收件人 | +| **Topic** | 消息的逻辑分类,Producer 按 Topic 发送 | 快递柜的格口编号 | +| **Queue** | 消息的物理存储单元 | 实际的格口 | +| **Message** | 传输的数据单元 | 包裹 | +| **Offset** | 消息在队列中的位置标识,用于消费进度追踪 | 快递单号 | +| **Partition** | Topic 的物理分片,实现并行消费 | 同一排快递柜的不同列 | + +### 基本工作流程 + +```mermaid +sequenceDiagram + participant P as "Producer" + participant B as "Broker" + participant C as "Consumer" + + P->>B: 1. 发送消息到 Topic + B->>B: 2. 持久化消息,分配 Offset + C->>B: 3. 拉取消息(Pull)或接收推送(Push) + B-->>C: 4. 返回消息 + C->>C: 5. 处理业务逻辑 + C->>B: 6. 提交消费确认(Ack) +``` + +> [!question] +> 第 6 步的 Ack 确认机制为什么重要?如果没有 Ack 会怎样? + +### MQ 的三大核心价值 + +#### 1. 解耦 + +没有 MQ 时,下单服务需要直接调用库存、积分、物流等多个下游服务,形成蜘蛛网般的依赖。引入 MQ 后,下单服务只需发一条消息,各下游自行订阅。 + +```go +// 解耦前: 下单服务直接依赖所有下游 +func CreateOrder(order Order) { + db.Save(order) + inventorySvc.Deduct(order.ItemID, order.Qty) // 强依赖 + pointsSvc.Add(order.UserID, order.Points) // 强依赖 + logisticsSvc.Create(order) // 强依赖 + // 新增通知服务?改代码、重新部署... +} + +// 解耦后: 只管发消息,下游自己订阅 +func CreateOrder(order Order) { + db.Save(order) + mq.Publish("order_created", order) // 一条消息,谁关心谁订阅 +} +``` + +解耦的本质是把**"我知道谁需要"变成"谁需要谁知道"**,新增下游服务时生产者代码零改动。 + +#### 2. 异步 + +有些操作不需要用户等待结果。比如注册后发欢迎邮件、生成报表等,可以把这些耗时操作异步化。 + +```go +// 同步: 用户等所有操作完成,响应慢 +func Register(user User) { + db.Save(user) // 50ms + emailSvc.SendWelcome(user) // 200ms + analyticsSvc.Track(user) // 100ms + // 总耗时 350ms,用户等到怀疑人生 +} + +// 异步: 用户只等核心操作,其余扔给 MQ +func Register(user User) { + db.Save(user) // 50ms + mq.Publish("user_registered", user) // 5ms + // 总耗时 55ms,邮件和埋点慢慢处理 +} +``` + +#### 3. 削峰 + +秒杀场景下,瞬间流量可能打垮数据库。MQ 充当缓冲区,让下游按自己的处理能力消费消息。 + +```go +// 削峰前: 瞬时 1 万 QPS 直接打到数据库,DB 直接跪 +func Seckill(userID, itemID string) { + if stock > 0 { + db.UpdateStock(itemID, -1) // DB 瞬间被打爆 + } +} + +// 削峰后: 先接住流量,慢慢消费 +func Seckill(userID, itemID string) { + mq.Publish("seckill_request", SeckillReq{userID, itemID}) // 瞬时接住 +} + +// 消费者按 DB 承受能力匀速消费 +func ConsumeSeckill() { + for msg := range mq.Subscribe("seckill_request") { + if stock > 0 { + db.UpdateStock(msg.ItemID, -1) // 匀速处理,DB 稳如老狗 + } + } +} +``` + +### MQ vs 直接调用 + +| 维度 | 直接调用 | 消息队列 | +|------|---------|---------| +| 耦合度 | 强耦合,调用方需感知下游 | 松耦合,生产者不关心消费者 | +| 响应时间 | 等待所有下游完成 | 只等核心链路,非核心异步处理 | +| 可靠性 | 下游挂了直接失败 | Broker 持久化,支持重试 | +| 吞吐能力 | 受限于最慢的下游 | 通过缓冲削峰,提升整体吞吐 | +| 复杂度 | 简单直观 | 引入额外组件,运维成本上升 | +| 一致性 | 容易保证强一致性 | 最终一致性,需要额外设计 | + +> [!question] +> 如果不需要解耦和削峰,还有必要引入 MQ 吗? + +## 关联笔记 + +- [[2-MQ-适用场景与选型原则]] +- [[3-MQ-消息模型]] diff --git a/hhs/MQ/01-基础概念/2-MQ-适用场景与选型原则.md b/hhs/MQ/01-基础概念/2-MQ-适用场景与选型原则.md new file mode 100644 index 0000000..0d877e3 --- /dev/null +++ b/hhs/MQ/01-基础概念/2-MQ-适用场景与选型原则.md @@ -0,0 +1,162 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 适用场景与选型原则 + +## 概述 + +MQ 并非银弹。本文详解三大经典适用场景的架构实践,明确不适合使用 MQ 的边界,并给出选型决策框架和常见反模式警示。 + +## 正文 + +### 三大经典场景详解 + +#### 场景一:系统解耦 + +当多个下游系统需要感知同一事件时,MQ 让生产者无需感知消费者的存在。 + +```mermaid +graph LR + O["Order Service"] -->|Publish| MQ["MQ Broker"] + MQ -->|Subscribe| INV["Inventory Service"] + MQ -->|Subscribe| PTS["Points Service"] + MQ -->|Subscribe| LOG["Logistics Service"] + MQ -->|Subscribe| NOTIFY["Notification Service"] +``` + +新增"数据仓库"服务?只需要订阅 `order_created` topic,Order Service 的代码一行不用动。 + +```go +// Order Service 只管发消息 +func CreateOrder(order Order) { + db.Save(order) + mq.Publish("order_created", order) +} + +// Inventory Service 独立订阅 +func HandleOrderCreated(msg Message) { + var order Order + json.Unmarshal(msg.Body, &order) + inventory.Deduct(order.ItemID, order.Qty) +} +``` + +#### 场景二:异步处理 + +将非核心链路从主流程中剥离,缩短用户感知的响应时间。 + +```mermaid +graph TD + U["User Request"] --> API["API Gateway"] + API --> DB["Core DB Write"] + API -->|Async| MQ["MQ Broker"] + MQ --> EMAIL["Email Service"] + MQ --> ANALYTICS["Analytics Service"] + MQ --> SEARCH["Search Index Update"] +``` + +以电商下单为例,核心链路是扣库存和创建订单记录(同步),而发邮件、更新搜索索引、埋点统计等可以异步处理。 + +```go +func PlaceOrder(order Order) { + // 同步: 核心链路 + db.CreateOrder(order) + db.DeductStock(order.ItemID, order.Qty) + + // 异步: 非核心链路,扔给 MQ + mq.Publish("order_placed", order) // 邮件、搜索索引、埋点各自消费 +} +``` + +> [!question] +> 如果异步任务失败了怎么办?比如邮件发送失败,用户收不到通知——你需要什么机制来保证最终成功? + +#### 场景三:流量削峰 + +秒杀、抢购等场景下,瞬时流量远超系统承载能力。MQ 作为缓冲层,让下游匀速处理。 + +```mermaid +graph LR + REQ["10000 QPS"] --> LB["Load Balancer"] + LB --> APP["App Server"] + APP -->|Instant peak| MQ["MQ Buffer"] + MQ -->|Rate limited| DB["Database"] +``` + +```go +// 生产者: 接住流量 +func Seckill(ctx context.Context, req SeckillReq) error { + // 先做前置校验(限流、参数校验) + if !rateLimiter.Allow() { + return ErrTooManyRequests + } + return mq.Publish("seckill", req) +} + +// 消费者: 匀速消费,保护数据库 +func SeckillConsumer() { + for msg := range mq.Subscribe("seckill") { + // 限速: 每秒最多处理 500 个请求 + rateLimiter.Wait() + processSeckill(msg) + } +} +``` + +> [!question] +> 削峰场景中,如果 MQ 的消费速度持续低于生产速度,会发生什么?如何应对? + +### 不适合使用 MQ 的场景 + +MQ 解决了分布式系统中的很多问题,但有些场景引入 MQ 反而画蛇添足: + +**1. 强一致性要求** + +金融转账、扣款等场景要求操作原子性。MQ 的异步特性天然支持最终一致性,但无法保证强一致。如果 A 扣款成功但消息丢失,B 没收到钱——这就是事故。 + +**2. 简单的 RPC 调用** + +如果 A 服务调用 B 服务,只需要一个同步返回值(比如查询用户信息),直接 HTTP/gRPC 调用更简单。引入 MQ 多了一层 Broker,增加了延迟和运维成本,纯属多此一举。 + +**3. 实时性极高的场景** + +游戏服务器的实时状态同步、在线聊天等场景要求毫秒级响应。MQ 的存储转发机制会引入额外延迟,直接 WebSocket 或长连接更合适。 + +### 选型决策框架 + +选型时需要综合考虑以下维度,没有"最好"的 MQ,只有"最合适"的: + +| 维度 | Kafka | RabbitMQ | RocketMQ | Pulsar | +|------|-------|----------|----------|--------| +| 吞吐量 | 极高(百万级/s) | 中等(万级/s) | 高(十万级/s) | 极高(百万级/s) | +| 延迟 | 毫秒~秒级 | 微秒~毫秒级 | 毫秒级 | 毫秒级 | +| 消息可靠性 | 高(副本机制) | 高(镜像队列) | 高(同步双写) | 高(BookKeeper) | +| 运维成本 | 中等(依赖 ZK/KRaft) | 低(开箱即用) | 中等 | 高(组件多) | +| 生态 | 极丰富(Flink/Spark) | 成熟(插件体系) | 较丰富 | 成长中 | +| 典型场景 | 大数据管道、日志 | 企业应用、复杂路由 | 电商、金融 | 多租户、云原生 | + +> [!question] +> 如果你的团队只有 3 个人,系统日均消息量 10 万条,你会选择哪个 MQ?为什么? + +### 反模式警示 + +**MQ 万能论** + +"所有异步操作都用 MQ"是一个常见误区。如果只是简单的后台任务,用一个 goroutine 池或者任务调度器就够了,不需要引入一个分布式 Broker。MQ 的运维成本、消息丢失风险、幂等性处理等都是实打实的工程代价。 + +**过度异步化** + +把本该同步完成的核心链路也异步化,会导致: +- 事务边界变得模糊,难以保证数据一致性 +- 排查问题时调用链断裂,日志追踪困难 +- 用户反馈"下单成功了但库存没扣"——因为扣库存的消息还在队列里排队 + +记住一个原则:**用户需要立即看到结果的操作,不要异步化。** + +## 关联笔记 + +- [[1-MQ-基础概念]] +- [[3-MQ-消息模型]] diff --git a/hhs/MQ/02-消息模型/3-MQ-消息模型.md b/hhs/MQ/02-消息模型/3-MQ-消息模型.md new file mode 100644 index 0000000..a07bc06 --- /dev/null +++ b/hhs/MQ/02-消息模型/3-MQ-消息模型.md @@ -0,0 +1,210 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 消息模型 + +## 概述 + +消息模型决定了消息如何从生产者流向消费者。本文对比队列模型与发布订阅模型,深入剖析 Consumer Group 机制,比较 Push/Pull 两种消费模式,并梳理主流 MQ 的消息模型设计。 + +## 正文 + +### 队列模型 vs 发布订阅模型 + +消息队列领域有两种基本模型,理解它们的区别是掌握所有 MQ 的基础。 + +**队列模型(Point-to-Point)**:一条消息只能被一个消费者消费。就像排队取号,每个号码只能被一个窗口叫到。 + +**发布订阅模型(Pub/Sub)**:一条消息可以被多个消费者组各自消费一次。就像广播,每个家庭都能听到同一条新闻。 + +```mermaid +graph TD + subgraph "Queue Model" + P1["Producer"] --> Q1["Queue"] + Q1 --> C1["Consumer A"] + Q1 --> C2["Consumer B"] + Q1 --> C3["Consumer C"] + end +``` + +```mermaid +graph TD + subgraph "Pub/Sub Model" + P2["Producer"] --> T1["Topic"] + T1 --> SUB1["Consumer Group A"] + T1 --> SUB2["Consumer Group B"] + SUB1 --> CA1["Consumer A1"] + SUB1 --> CA2["Consumer A2"] + SUB2 --> CB1["Consumer B1"] + end +``` + +> [!question] +> 如果队列模型中,你想让同一条消息被两个不同的服务分别处理,有什么办法? + +### Consumer Group 机制详解 + +**为什么需要 Consumer Group?** + +假设一个 Topic 有 100 万条消息需要处理,单个消费者处理太慢。你需要多个消费者并行消费,但又不想同一条消息被重复处理——Consumer Group 就是为此而生的。 + +同一个 Consumer Group 内的消费者**分摊**消息(每条消息只被组内一个消费者处理),不同 Consumer Group **各自独立**消费全量消息。 + +**如何分配?** + +以 Kafka 为例,分配策略的核心逻辑: + +```mermaid +graph TD + T["Topic: 6 Partitions"] --> P0["Partition 0"] + T --> P1["Partition 1"] + T --> P2["Partition 2"] + T --> P3["Partition 3"] + T --> P4["Partition 4"] + T --> P5["Partition 5"] + P0 --> C1["Consumer 1"] + P1 --> C1 + P2 --> C2["Consumer 2"] + P3 --> C2 + P4 --> C3["Consumer 3"] + P5 --> C3 +``` + +常见的分配策略: +- **Range**:按范围连续分配,简单但容易不均衡 +- **RoundRobin**:轮询分配,更均匀但要求 Topic 数一致 +- **Sticky**:粘性分配,Rebalance 时尽量保持原有分配,减少不必要的重分配 + +**Rebalance 触发条件** + +当以下事件发生时,Consumer Group 会触发 Rebalance(重新分配 Partition): +1. 新消费者加入组 +2. 消费者离开组(主动退出或心跳超时) +3. Topic 的 Partition 数量变化 + +Rebalance 期间所有消费者暂停消费,因此**Rebalance 的速度和频率直接影响系统可用性**。 + +> [!question] +> 如果一个消费者处理消息特别慢,导致心跳超时被踢出组,会触发 Rebalance。Rebalance 又会导致更多消息积压——这是一个恶性循环,你有什么解决思路? + +### Push 模式 vs Pull 模式 + +| 维度 | Push 模式 | Pull 模式 | +|------|----------|----------| +| 实时性 | 高,消息到达即推送 | 取决于拉取频率 | +| 消费者压力 | 被动接收,容易被打爆 | 主动拉取,按自身能力控制速率 | +| 吞吐量 | 低(频繁推送开销大) | 高(批量拉取) | +| 实现复杂度 | Broker 端复杂(需维护推送状态) | Consumer 端复杂(需实现拉取循环) | +| 典型代表 | RabbitMQ | Kafka | + +Push 模式就像外卖送到家门口——方便,但高峰期外卖员不停按门铃你也会崩溃。Pull 模式就像自己去快递柜取件——按自己的时间来,但需要你主动去"查"。 + +### 主流 MQ 的消息模型 + +#### Kafka:Consumer Group + Pull + +Kafka 的核心设计哲学是"消费者自己来拉": +- 每个 Topic 分为多个 Partition,是并行消费的基本单元 +- Consumer Group 内的消费者与 Partition 一一绑定(一个 Partition 只能被组内一个消费者消费) +- 消费者通过 Offset 追踪消费进度,支持回溯消费 + +```go +// Kafka 消费者组: Pull 模式 +func main() { + consumer, _ := kafka.NewConsumer(&kafka.ConfigMap{ + "bootstrap.servers": "localhost:9092", + "group.id": "order-service-group", // Consumer Group + "auto.offset.reset": "earliest", // 从最早消息开始消费 + }) + consumer.SubscribeTopics([]string{"order_created"}, nil) + + for { + msg, _ := consumer.ReadMessage(-1) // 主动拉取,阻塞等待 + processOrder(msg.Value) + } +} +``` + +#### RabbitMQ:Exchange + Push + +RabbitMQ 使用 Exchange(交换器)实现灵活的消息路由: +- Producer 将消息发送到 Exchange,而非直接发送到 Queue +- Exchange 根据 Binding Rule(绑定规则)将消息路由到一个或多个 Queue +- Broker 主动将消息推送给订阅了 Queue 的 Consumer + +Exchange 的四种类型:Direct(精确匹配)、Topic(通配符匹配)、Fanout(广播)、Headers(头部匹配)。 + +#### RocketMQ:ConsumerGroup + Pull + +RocketMQ 的模型介于 Kafka 和 RabbitMQ 之间: +- 采用 ConsumerGroup 概念,与 Kafka 类似 +- 默认使用长轮询 Pull 模式(Push 的底层实现其实是 Pull 封装) +- 支持顺序消费和延迟消息等高级特性 + +### Go 代码示例:消费者组消费逻辑 + +下面是一个简化的消费者组实现,演示了 Partition 分配和消费的核心逻辑: + +```go +type ConsumerGroup struct { + GroupID string + Consumers []string + Partitions []int +} + +// Rebalance: 将 Partition 均匀分配给消费者 +func (cg *ConsumerGroup) Rebalance() map[string][]int { + assignment := make(map[string][]int) + sort.Strings(cg.Consumers) + + for i, partition := range cg.Partitions { + // 轮询分配: Partition i 分配给 Consumer i % len + consumer := cg.Consumers[i%len(cg.Consumers)] + assignment[consumer] = append(assignment[consumer], partition) + } + return assignment +} + +// 消费循环: 拉取消息并处理 +func Consume(broker Broker, topic string, groupID string, handler func(Message)) { + partitions := broker.GetPartitions(topic) + assignment := assignPartitions(groupID, partitions) + + for _, partition := range assignment { + go func(p int) { + offset := broker.GetOffset(groupID, p) + for { + msgs := broker.Pull(p, offset, batchSize) // 批量拉取 + for _, msg := range msgs { + handler(msg) + } + offset += len(msgs) + broker.CommitOffset(groupID, p, offset) // 提交消费进度 + } + }(partition) + } +} +``` + +这段代码展示了三个关键设计: +1. **轮询分配**保证 Partition 均匀分布到各消费者 +2. **批量拉取**提升吞吐量,减少网络往返 +3. **手动提交 Offset** 确保消息不丢失(处理完才提交) + +> [!question] +> Push 模式看起来更实时,为什么 Kafka 选择了 Pull? + +Kafka 选择 Pull 模式有三个核心考量: +1. **消费者速率控制**:消费者按自己的处理能力拉取,天然避免被打爆。Push 模式下 Broker 不知道消费者的处理速度,需要额外的流控机制。 +2. **批量消费**:Pull 模式允许消费者一次拉取一批消息,显著提升吞吐量。Push 模式逐条推送,网络开销大。 +3. **消息回溯**:Pull 模式下消费者可以自由控制 Offset,实现"回放"历史消息。Push 模式下消息被推送后就消费掉了,回溯困难。 + +当然 Pull 模式的代价是实时性不如 Push——但 Kafka 通过**长轮询(Long Polling)**弥补了这个短板:消费者发起拉取请求,如果没有新消息就阻塞等待,直到有消息到达或超时。 + +## 关联笔记 + +- [[1-MQ-基础概念]] +- [[2-MQ-适用场景与选型原则]] diff --git a/hhs/MQ/03-协议与标准/4-MQ-消息协议总览.md b/hhs/MQ/03-协议与标准/4-MQ-消息协议总览.md new file mode 100644 index 0000000..356824a --- /dev/null +++ b/hhs/MQ/03-协议与标准/4-MQ-消息协议总览.md @@ -0,0 +1,100 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 消息协议总览 + +## 概述 + +消息协议是消息队列系统中 Producer、Broker、Consumer 之间的"通信语言"。不同的 MQ 产品使用不同的协议,协议的选择直接影响互操作性、性能和适用场景。本文梳理主流消息协议的设计定位与演进脉络。 + +## 正文 + +### 为什么需要消息协议 + +试想一个场景:你用 RabbitMQ 的客户端库去连接 Kafka,能成功吗?显然不能。因为它们"说的不是同一种语言"。消息协议就是定义客户端与 Broker 之间如何交换数据的规范——消息怎么编码、命令怎么发送、连接怎么建立、确认怎么回传,全都由协议约定。 + +协议存在的意义在于两点:**互操作性**和**标准化**。有了统一的协议,不同厂商的客户端和 Broker 可以互相通信;没有协议(或协议私有),就只能绑定在单一产品上。 + +> [!question] +> 如果你是一家公司的架构师,需要同时接入 RabbitMQ 和 Kafka,你会选择统一协议层还是各自接入?为什么? + +### 协议栈层次模型 + +消息协议工作在应用层,底层依赖 TCP 或 WebSocket 等传输协议。层次关系如下: + +```mermaid +graph TB + AppLayer["应用层协议: AMQP, MQTT, STOMP, JMS, OpenMessaging"] + TransLayer["传输层: TCP / WebSocket / TLS"] + NetLayer["网络层: IP"] + + AppLayer --> TransLayer + TransLayer --> NetLayer +``` + +关键点:**应用层协议决定了消息的语义和格式,传输层只负责字节流的可靠传输**。这也意味着同一个应用层协议可以跑在 TCP 上(性能优先),也可以跑在 WebSocket 上(Web 场景)。 + +### 主流协议一览 + +| 协议 | 定位 | 设计目标 | 典型场景 | 代表产品 | +|------|------|----------|----------|----------| +| **AMQP 0-9-1** | 企业级消息中间件标准 | 可靠投递、灵活路由、事务支持 | 金融交易、企业集成 | RabbitMQ | +| **AMQP 1.0** | AMQP 的正式 ISO 标准版本 | 跨平台互操作、去掉 Broker 强依赖 | 云原生、跨组织通信 | Azure Service Bus, ActiveMQ | +| **MQTT** | IoT 轻量级协议 | 低带宽、低功耗、离线支持 | 物联网、移动推送 | EMQX, Mosquitto | +| **STOMP** | 简单文本协议 | 易于调试、人可读 | WebSocket 消息、简单队列 | RabbitMQ (STOMP 插件) | +| **JMS** | Java 消息服务 API 规范 | Java 生态统一编程接口 | Java 企业应用 | ActiveMQ, IBM MQ | +| **OpenMessaging** | 跨语言跨平台标准 | 云原生、厂商中立 | 多云部署 | 阿里云 RocketMQ | +| **Kafka 私有协议** | 高吞吐流处理 | 追求极致性能,协议与实现强绑定 | 日志收集、实时流计算 | Apache Kafka | + +> [!question] +> 观察这张表,你会发现"标准化"和"性能优化"之间似乎存在矛盾——Kafka 放弃了标准化却获得了极致性能。你觉得这种取舍合理吗? + +### 协议的设计哲学差异 + +协议之间的差异不只是格式不同,背后是**设计哲学的分歧**: + +- **AMQP** 走的是"标准化 + 丰富语义"路线。它定义了 Exchange、Queue、Binding 等抽象模型,Broker 承担了大量路由和过滤工作。好处是功能强大,代价是协议复杂。 +- **MQTT** 走的是"极简 + 弱网适应"路线。协议报文最小只有 2 字节,支持 QoS 0/1/2 三级投递保障,专门为带宽受限的 IoT 设备设计。 +- **Kafka** 走的是"性能优先"路线。它的私有协议直接操作文件系统的 offset,零拷贝传输,协议与存储引擎深度耦合。标准化反而会成为性能瓶颈。 +- **STOMP** 走的是"简单可读"路线。纯文本协议,用 `telnet` 就能手动发送消息,调试极其方便,但性能和功能都比较有限。 + +### 协议演进趋势 + +消息协议的演进大致经历了三个阶段: + +```mermaid +graph LR + Phase1["阶段1: 私有协议"] + Phase2["阶段2: 标准化"] + Phase3["阶段3: 跨平台云原生"] + + Phase1 -->|"JMS 规范 Java 生态"| Phase2 + Phase2 -->|"AMQP 0-9-1 业界采用"| Phase3 + Phase3 -->|"AMQP 1.0 / OpenMessaging"| Future["未来: 协议互操作"] +``` + +1. **私有协议时代**:每个 MQ 厂商各搞一套,JMS 虽然是规范但限于 Java 生态,且只定义 API 不定义线路协议(wire protocol),不同实现之间依然不互通。 +2. **标准化时代**:AMQP 0-9-1 的出现让 RabbitMQ 成为事实标准。协议定义了线路层格式,真正实现了"一个客户端连多个 Broker"的可能性。 +3. **跨平台云原生时代**:AMQP 1.0 成为 ISO/IEC 标准,去掉了对 Broker 模型的强依赖;OpenMessaging 由中国厂商主导,面向多云和 Serverless 场景。 + +> [!question] +> OpenMessaging 是由阿里等中国厂商主导的协议标准。你觉得在消息中间件领域,中国厂商能否主导下一个通用标准?需要具备哪些条件? + +### 如何选择协议 + +选协议本质上是选场景。几个判断维度: + +- **需要 IoT/移动端?** → MQTT(轻量、离线消息) +- **需要企业级可靠投递?** → AMQP(确认机制、事务、灵活路由) +- **需要高吞吐流处理?** → Kafka 私有协议(性能无可替代) +- **需要快速调试或 WebSocket 场景?** → STOMP(文本协议、简单直观) +- **需要跨云厂商中立?** → OpenMessaging 或 AMQP 1.0 + +没有银弹,理解每种协议的设计意图,才能做出合理的技术选型。 + +## 关联笔记 + +- [[5-AMQP-协议]] diff --git a/hhs/MQ/03-协议与标准/5-AMQP-协议.md b/hhs/MQ/03-协议与标准/5-AMQP-协议.md new file mode 100644 index 0000000..79a25a2 --- /dev/null +++ b/hhs/MQ/03-协议与标准/5-AMQP-协议.md @@ -0,0 +1,238 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# AMQP 协议 + +## 概述 + +AMQP(Advanced Message Queuing Protocol)是应用最广泛的消息队列开放标准之一。本文重点解析 AMQP 0-9-1 版本的核心模型、Exchange 路由机制,并与 1.0 版本进行对比,最后给出 Go 语言的完整收发示例。 + +## 正文 + +### AMQP 的版本线 + +AMQP 并非只有一个版本,理解版本差异是正确使用它的前提: + +- **AMQP 0-8**:早期版本,功能有限,已基本淘汰。 +- **AMQP 0-9-1**:2008 年发布,是 RabbitMQ 实现的版本。定义了完整的 Broker 模型(Exchange/Queue/Binding),是目前**使用最广泛**的版本。严格来说它不是 OASIS 正式标准,而是社区规范。 +- **AMQP 0-10**:由另一组人维护,与 0-9-1 差异较大,采用者较少(如 Apache Qpid)。 +- **AMQP 1.0**:2012 年成为 OASIS 标准,后纳入 ISO/IEC 19464。这是一个**完全重新设计**的协议,去掉了 Exchange 等抽象概念,引入 Link/Terminus 模型。Azure Service Bus、ActiveMQ Artemis 等支持此版本。 + +> [!question] +> 0-9-1 和 1.0 看起来像是两个完全不同的协议。为什么同一个标准组织会允许如此大的断裂?你觉得这是技术进步还是标准碎片化? + +### AMQP 0-9-1 核心模型 + +AMQP 0-9-1 的消息流转模型是理解 RabbitMQ 的基础: + +```mermaid +graph LR + Producer["Producer"] + Exchange["Exchange"] + QueueA["Queue A"] + QueueB["Queue B"] + ConsumerA["Consumer A"] + ConsumerB["Consumer B"] + + Producer -->|"发送消息 + Routing Key"| Exchange + Exchange -->|"Binding Key: order.*"| QueueA + Exchange -->|"Binding Key: log.#"| QueueB + QueueA --> ConsumerA + QueueB --> ConsumerB +``` + +核心组件及其职责: + +| 组件 | 职责 | +|------|------| +| **Connection** | TCP 长连接,负责认证和加密 | +| **Channel** | Connection 内的虚拟连接,多路复用,避免频繁建 TCP | +| **Exchange** | 消息路由引擎,根据规则将消息分发到 Queue | +| **Queue** | 消息存储容器,消费者从这里拉取或推送消息 | +| **Binding** | Exchange 和 Queue 之间的绑定关系,携带路由规则 | + +消息投递流程:Producer 将消息发送到 Exchange(不是直接发到 Queue),Exchange 根据自身的类型和 Binding 规则决定消息去往哪些 Queue。**这种间接路由模型是 AMQP 最核心的设计思想。** + +### Connection 与 Channel + +一个 TCP Connection 上可以复用多个 Channel,这是 AMQP 的性能优化手段: + +```go +// 建立一个 Connection +conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/") +defer conn.Close() + +// 在同一 Connection 上打开两个 Channel +ch1, _ := conn.Channel() +ch2, _ := conn.Channel() +// ch1 和 ch2 互不干扰,可以并行操作 +``` + +为什么需要 Channel?因为每个线程/协程持有独立的 Channel 可以避免锁竞争,而且建立 TCP 连接的开销远大于创建 Channel。 + +### 四种 Exchange 类型 + +Exchange 是消息路由的核心,不同类型的 Exchange 适用于不同的路由策略。 + +#### Direct Exchange + +精确匹配 Routing Key 和 Binding Key。消息只会到达 Binding Key 完全匹配的 Queue。 + +```mermaid +graph LR + P["Producer"] -->|"RoutingKey: order.created"| Ex["Direct Exchange"] + Ex -->|"BindKey: order.created"| Q1["Order Queue"] + Ex -->|"BindKey: payment.created"| Q2["Payment Queue"] +``` + +适用场景:点对点的消息分发,比如将订单消息路由到订单处理队列。 + +#### Fanout Exchange + +忽略 Routing Key,将消息广播到所有绑定的 Queue。 + +```mermaid +graph LR + P["Producer"] -->|"RoutingKey: (忽略)"| Ex["Fanout Exchange"] + Ex --> Q1["Analytics Queue"] + Ex --> Q2["Logging Queue"] + Ex --> Q3["Audit Queue"] +``` + +适用场景:日志广播、事件通知,一份消息多个消费者各自处理。 + +#### Topic Exchange + +基于模式匹配的路由。Binding Key 和 Routing Key 都用 `.` 分隔单词,支持两个通配符:`*` 匹配一个单词,`#` 匹配零或多个单词。 + +```mermaid +graph LR + P["Producer"] -->|"RoutingKey: order.pay.success"| Ex["Topic Exchange"] + Ex -->|"BindKey: order.*.success"| Q1["Success Queue"] + Ex -->|"BindKey: order.#"| Q2["All Orders Queue"] + Ex -->|"BindKey: log.#"| Q3["Log Queue"] +``` + +适用场景:灵活的事件订阅,消费者可以按需订阅感兴趣的消息子集。 + +#### Headers Exchange + +不依赖 Routing Key,而是根据消息 Headers 中的键值对进行匹配。绑定时指定一组键值对和匹配规则(`x-match=all` 表示全部匹配,`x-match=any` 表示任一匹配)。 + +适用场景:消息属性复杂、需要多维度过滤的场景。实际使用较少,因为 Topic Exchange 在大多数情况下已经够用。 + +> [!question] +> 四种 Exchange 类型中,Fanout 最简单、Headers 最复杂。但在实际项目中,Direct 和 Topic 的使用频率远高于另外两种。你觉得这是为什么? + +### Virtual Host:多租户隔离 + +Virtual Host(vhost)是 AMQP 中的逻辑隔离单元。每个 vhost 拥有独立的 Exchange、Queue 和 Binding 集合,不同 vhost 之间的资源完全隔离。 + +典型用途: +- **环境隔离**:`/dev`、`/staging`、`/prod` 各用一个 vhost +- **租户隔离**:SaaS 平台为每个租户分配独立 vhost +- **权限控制**:可以按 vhost 粒度分配用户权限 + +```go +// 连接时指定 vhost +conn, _ := amqp.Dial("amqp://user:pass@localhost:5672/production") +``` + +### AMQP 1.0 vs 0-9-1 的关键差异 + +AMQP 1.0 并非 0-9-1 的升级版,而是**重新设计**。核心差异: + +| 维度 | 0-9-1 | 1.0 | +|------|-------|-----| +| 路由模型 | Exchange + Binding + Queue | Link + Terminus(Source/Target) | +| Broker 角色 | Broker 负责路由和存储 | Broker 是可选的,可以点对点 | +| 协议复杂度 | 中等,概念清晰 | 较高,状态机复杂 | +| 生态 | RabbitMQ 生态完善 | Azure、ActiveMQ 支持 | +| 标准化 | 非正式标准 | ISO/IEC 19464 | + +1.0 中 Producer 通过 Link 直接连接到 Target(类似 Queue),不再经过 Exchange 路由。这简化了点对点通信,但失去了 0-9-1 灵活的路由能力。 + +> [!question] +> AMQP 1.0 去掉了 Exchange 概念。如果让你重新设计,你会保留 Exchange 还是去掉它?Exchange 的灵活性和复杂度之间如何权衡? + +### Go 代码:完整收发示例 + +使用 `amqp091-go` 库(RabbitMQ 官方维护的 Go 客户端)演示 Topic Exchange 的消息收发。 + +**发送端**: + +```go +package main + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "log" +) + +func main() { + conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/") + defer conn.Close() + + ch, _ := conn.Channel() + defer ch.Close() + + // 声明 Topic Exchange + ch.ExchangeDeclare("events", "topic", true, false, false, false, nil) + + // 声明两个队列 + q1, _ := ch.QueueDeclare("order-events", true, false, false, false, nil) + q2, _ := ch.QueueDeclare("log-events", true, false, false, false, nil) + + // 绑定:order 相关事件 → order-events 队列 + ch.QueueBind(q1.Name, "order.*", "events", false, nil) + // 绑定:所有事件 → log-events 队列 + ch.QueueBind(q2.Name, "#", "events", false, nil) + + // 发送一条消息,Routing Key 为 order.created + ch.Publish("events", "order.created", false, false, + amqp.Publishing{ + ContentType: "application/json", + Body: []byte(`{"order_id": 12345, "amount": 99.9}`), + }) + log.Println("消息已发送") +} +``` + +**接收端**: + +```go +package main + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "log" +) + +func main() { + conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/") + defer conn.Close() + + ch, _ := conn.Channel() + defer ch.Close() + + // 消费 order-events 队列 + msgs, _ := ch.Consume("order-events", "", false, false, false, false, nil) + + for msg := range msgs { + log.Printf("收到订单消息: %s", msg.Body) + msg.Ack(false) // 手动确认,确保消息不丢失 + } +} +``` + +代码要点: +- `ExchangeDeclare` 声明 Exchange,`true` 表示持久化,Broker 重启后 Exchange 依然存在。 +- `QueueBind` 将 Queue 绑定到 Exchange 并指定 Binding Key,这里用 `order.*` 匹配 `order.created`、`order.paid` 等。 +- `Consume` 的 `autoAck` 设为 `false`,配合 `msg.Ack(false)` 实现手动确认,避免消费者崩溃时消息丢失。 + +## 关联笔记 + +- [[4-MQ-消息协议总览]] diff --git a/hhs/MQ/03-协议与标准/6-MQTT-协议.md b/hhs/MQ/03-协议与标准/6-MQTT-协议.md new file mode 100644 index 0000000..8130325 --- /dev/null +++ b/hhs/MQ/03-协议与标准/6-MQTT-协议.md @@ -0,0 +1,170 @@ +--- +tags: [MQ, MQTT, IoT, 协议] +create time: 2026-05-24 19:52 +--- + +# MQTT 协议 + +## 概述 + +MQTT(Message Queuing Telemetry Transport)是专为物联网(IoT)场景设计的轻量级消息协议。它的设计哲学极其明确:在低带宽、高延迟、不可靠的网络环境下,用最小的资源开销实现设备间的消息传递。从智能家居传感器到工业 SCADA 系统,MQTT 已经成为 IoT 通信的事实标准。 + +## 正文 + +### 为什么需要 MQTT? + +想象一个场景:成千上万个温度传感器部署在偏远地区,它们通过 2G/3G 网络上报数据。这些设备 CPU 弱、内存小、电量有限、网络时断时续。在这种约束下,HTTP 的冗长头部和请求-响应模型就成了灾难。MQTT 就是为解决这类问题而生的——它把协议开销压缩到极致(最小报文仅 2 字节),支持持久连接,提供灵活的消息可靠性保障。 + +### 报文结构 + +MQTT 的每条报文由三部分组成: + +``` ++------------------+--------------------+----------+ +| Fixed Header | Variable Header | Payload | ++------------------+--------------------+----------+ +``` + +- **Fixed Header**(必选):包含报文类型(4 bit)、标志位(4 bit)和剩余长度(可变字节编码)。报文类型定义了 14 种操作,如 CONNECT、PUBLISH、SUBSCRIBE 等。 +- **Variable Header**(可选):根据报文类型不同而变化,比如 PUBLISH 报文里放 Topic 名称,CONNECT 报文里放协议版本和连接标志。 +- **Payload**(可选):实际消息内容。CONNECT 报文的 Payload 是 Client ID 和遗嘱消息,PUBLISH 报文的 Payload 就是应用数据。 + +> [!question] 为什么 Fixed Header 的"剩余长度"要用可变字节编码(Variable Byte Encoding)? +> 因为 IoT 场景下大部分消息体很小(几十字节),如果用固定 4 字节表示长度就太浪费了。可变编码让 0-127 字节的长度只占 1 字节,128-16383 字节占 2 字节,以此类推——小消息用小开销,大消息也能表示。 + +### QoS 等级:三种可靠性语义 + +MQTT 最精妙的设计之一就是三个 QoS(Quality of Service)等级,让发布者按需选择投递保证,而不是"一刀切"。 + +**QoS 0 - 最多一次(At Most Once)** + +fire-and-forget,发送即忘。没有确认机制,消息可能丢失。适合传感器周期性上报——丢一帧温度数据无所谓,下一帧马上就来。 + +**QoS 1 - 至少一次(At Least Once)** + +发布者收到 PUBACK 后才确认发送完成。如果超时未收到 ACK,会重发消息(DUP 标志置 1)。这意味着消息不会丢,但可能重复投递——消费者需要自行处理幂等。 + +**QoS 2 - 恰好一次(Exactly Once)** + +通过四次握手保证消息恰好送达一次,是最高等级但也最重的语义。 + +```mermaid +sequenceDiagram + participant P as "Publisher" + participant B as "Broker" + participant C as "Consumer" + + Note over P,B: "QoS 0: fire and forget" + P->>B: "PUBLISH QoS 0" + B->>C: "PUBLISH QoS 0" + + Note over P,B: "QoS 1: at least once" + P->>B: "PUBLISH QoS 1" + B-->>P: "PUBACK" + B->>C: "PUBLISH QoS 1" + C-->>B: "PUBACK" + + Note over P,B: "QoS 2: exactly once" + P->>B: "PUBLISH QoS 2" + B-->>P: "PUBREC" + P->>B: "PUBREL" + B-->>P: "PUBCOMP" + B->>C: "PUBLISH QoS 2" + C-->>B: "PUBREC" + B->>C: "PUBREL" + C-->>B: "PUBCOMP" +``` + +> [!question] QoS 2 的四次握手会不会很慢?在什么场景下值得用? +> 相比 QoS 0/1,QoS 2 确实多了额外的往返开销。但在需要精确计数的场景下(如扣费指令、库存变更、IoT 设备的命令下发),一条消息重复执行可能导致严重后果。实际部署中,很多系统选择 QoS 1 + 消费端幂等的组合来平衡性能和可靠性,只在真正"不能重复"的关键路径上才用 QoS 2。 + +### 核心概念 + +#### Topic 与通配符 + +MQTT 使用层级结构的 Topic 进行消息路由,用 `/` 分隔层级,例如 `home/livingroom/temperature`。 + +订阅时支持两种通配符: +- `+`:匹配单个层级。`home/+/temperature` 匹配 `home/livingroom/temperature` 和 `home/bedroom/temperature`。 +- `#`:匹配多个层级(必须放在末尾)。`home/#` 匹配 `home/` 下所有 Topic。 + +#### 遗嘱消息(Will Message) + +客户端在连接时可以设置一条"遗嘱消息"。如果客户端异常断线(没有发送 DISCONNECT),Broker 会自动将这条遗嘱消息发布到指定 Topic。这对 IoT 设备的在线状态监控非常有用——订阅遗嘱 Topic 就能实时知道哪些设备掉线了。 + +#### 保留消息(Retain) + +当 PUBLISH 报文的 Retain 标志置 1 时,Broker 会保存这条消息。后续任何新订阅该 Topic 的客户端会立即收到这条保留消息,而不是等到下一次发布。典型场景:传感器发布当前温度为保留消息,新上线的监控端一订阅就能拿到最新值,不用等下一次上报。 + +#### Clean Session + +- **Clean Session = 1**:每次连接都是全新会话,Broker 不保留任何订阅关系和未确认消息。适合无状态客户端。 +- **Clean Session = 0**:Broker 为客户端持久化会话(订阅列表 + QoS 1/2 的未确认消息)。客户端断线重连后能恢复之前的订阅并收到离线期间的消息。 + +### MQTT 5.0 新特性 + +MQTT 5.0(2019 年发布)是一次重大升级,引入了多项企业级特性: + +- **共享订阅(Shared Subscriptions)**:多个客户端组成一个订阅组,消息在组内负载均衡分发。这解决了 MQTT 之前缺乏 Consumer Group 的痛点,类似 Kafka 的分区分配。 +- **用户属性(User Properties)**:在报文头部附加自定义键值对,可用于传递链路追踪 ID、业务上下文等元信息,而不用修改消息体。 +- **原因码(Reason Codes)**:所有响应报文都携带结构化的原因码,取代了之前模糊的返回码,方便客户端精确处理错误(如"Topic 名称不合法" vs "鉴权失败")。 +- **Session Expiry Interval**:可以设置会话过期时间,不再只有"立即过期"和"永不过期"两种选择。 +- **请求-响应模式**:引入 Response Topic 和 Correlation Data 属性,让 MQTT 原生支持请求-响应模式。 + +### Go 代码示例 + +使用 Eclipse Paho MQTT Go 客户端实现消息的发布和订阅。 + +```go +package main + +import ( + "fmt" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +func main() { + // 创建客户端选项 + opts := mqtt.NewClientOptions(). + AddBroker("tcp://localhost:1883"). + SetClientID("go-mqtt-demo"). + SetCleanSession(true) + + // 设置连接成功后的订阅回调 + opts.OnConnect = func(c mqtt.Client) { + // 订阅 Topic,QoS 1 + c.Subscribe("sensor/temperature", 1, func(_ mqtt.Client, msg mqtt.Message) { + fmt.Printf("收到消息: topic=%s payload=%s\n", msg.Topic(), string(msg.Payload())) + }) + } + + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + // 发布消息,QoS 1,Retain 标志设为 false + token := client.Publish("sensor/temperature", 1, false, "23.5°C") + token.Wait() + + time.Sleep(time.Second) + client.Disconnect(250) +} +``` + +这段代码做了三件事:连接 Broker、订阅 `sensor/temperature` Topic、发布一条温度消息。`OnConnect` 回调确保断线重连后会重新订阅,这是 MQTT 客户端开发的最佳实践。 + +--- + +> [!tip] 实践建议 +> - IoT 设备资源紧张时优先用 QoS 0,需要可靠投递用 QoS 1,关键控制指令才用 QoS 2。 +> - 生产环境务必启用 TLS 加密。MQTT 默认端口 1883 是明文的,安全端口是 8883。 +> - Client ID 必须全局唯一,否则会踢掉前一个同 ID 的连接。 + +## 关联笔记 + +- [[03-协议与标准/4-MQ-消息协议总览|MQ 消息协议总览]] +- [[03-协议与标准/5-AMQP-协议|AMQP 协议]] +- [[01-基础概念/2-MQ-适用场景与选型原则|MQ 适用场景与选型原则]] diff --git a/hhs/MQ/03-协议与标准/7-JMS-与-OpenMessaging.md b/hhs/MQ/03-协议与标准/7-JMS-与-OpenMessaging.md new file mode 100644 index 0000000..9085428 --- /dev/null +++ b/hhs/MQ/03-协议与标准/7-JMS-与-OpenMessaging.md @@ -0,0 +1,162 @@ +--- +tags: [MQ, JMS, OpenMessaging, Java, 协议] +create time: 2026-05-24 19:52 +--- + +# JMS 与 OpenMessaging + +## 概述 + +JMS(Java Message Service)是 Java 生态中消息中间件的接口规范,它不实现任何消息传输逻辑,而是定义了一套统一的 API,让应用代码可以在不同 MQ 产品之间切换。OpenMessaging 则是阿里巴巴发起的下一代开放标准,试图突破 JMS 的 Java 绑定和厂商碎片化问题,构建跨语言、跨平台、云原生的消息规范。 + +## 正文 + +### JMS 是什么? + +JMS 不是一个 MQ 产品,而是一份"接口契约"。就像 JDBC 定义了数据库访问的标准接口,JMS 定义了消息通信的标准接口。你的代码面向 JMS API 编写,底层可以换成 ActiveMQ、IBM MQ、TIBCO 等任何 JMS 实现,理论上不需要改一行业务代码。 + +这个设计思路在 Java 一统天下的企业级开发时代非常有吸引力——应用服务器(如 WebLogic、WebSphere)内置 JMS 实现,开发者只需要注入 `ConnectionFactory` 就能开始收发消息。 + +### 两种消息模型 + +JMS 定义了两种经典的消息通信模型: + +**Point-to-Point(队列模型)** + +一条消息只被一个消费者消费。生产者把消息发到 Queue,消费者从 Queue 拉取。消费后消息从 Queue 中移除(或标记已消费)。典型场景:订单处理、任务分发。 + +**Pub/Sub(发布订阅模型)** + +一条消息可以被多个订阅者同时接收。生产者发布到 Topic,所有订阅该 Topic 的消费者都会收到副本。典型场景:事件广播、通知推送。 + +```mermaid +graph LR + P1["Producer"] -->|"send"| Q["Queue"] + Q -->|"consume"| C1["Consumer A"] + Q -->|"consume"| C2["Consumer B"] + + P2["Publisher"] -->|"publish"| T["Topic"] + T -->|"deliver"| S1["Subscriber X"] + T -->|"deliver"| S2["Subscriber Y"] + T -->|"deliver"| S3["Subscriber Z"] + + style Q fill:#4A90D9,color:#fff + style T fill:#F5A623,color:#fff +``` + +> [!question] Queue 模型中,如果多个消费者监听同一个 Queue,消息会被谁收到? +> JMS 规范本身没有规定负载均衡策略,具体由实现厂商决定。但通常的行为是:消息在多个消费者之间轮询(round-robin)分发,每条消息只投递给其中一个消费者。这和 Kafka Consumer Group 的行为类似。 + +### JMS API 核心接口 + +JMS 的编程模型围绕以下几个核心接口展开: + +| 接口 | 职责 | +|------|------| +| **ConnectionFactory** | 创建 Connection 的工厂,通常由 JMS 实现提供,配置了 Broker 地址和认证信息 | +| **Connection** | 到 Broker 的 TCP 连接,支持异步消息分发和连接恢复 | +| **Session** | 单线程上下文,用于创建 Producer/Consumer 和管理事务 | +| **MessageProducer** | 发送消息到 Queue 或 Topic | +| **MessageConsumer** | 从 Queue 或 Topic 接收消息,支持同步 `receive()` 和异步 `setMessageListener()` | +| **Message** | 消息本体,包含 Header(JMSDestination、JMSDeliveryMode 等)、Properties(自定义键值对)和 Body | + +### 消息类型 + +JMS 规范定义了五种消息体类型,覆盖了常见的数据传输需求: + +| 类型 | 说明 | 适用场景 | +|------|------|----------| +| **TextMessage** | 字符串文本(通常为 XML/JSON) | 最常用,Web 服务间通信 | +| **ObjectMessage** | 序列化的 Java 对象 | Java 生态内部传输,但有安全风险 | +| **MapMessage** | 键值对集合(类似 Map) | 结构化但不需要完整对象的场景 | +| **BytesMessage** | 原始字节数组 | 二进制数据、跨语言场景 | +| **StreamMessage** | 顺序读写的原始类型流 | 需要流式处理的场景 | + +> [!question] ObjectMessage 看起来很方便,为什么生产中反而不推荐? +> 因为 ObjectMessage 依赖 Java 序列化,存在严重的安全漏洞(反序列化攻击链)。而且它和 Java 绑死,跨语言几乎不可能。现代实践更倾向用 TextMessage + JSON/Protobuf 编码。 + +### JMS 的局限性 + +JMS 在 Java 企业级开发中统治了十多年,但它有几个根深蒂固的问题: + +1. **Java 绑定**:JMS 是纯 Java API,无法直接被 Go、Python、C++ 等语言使用。在微服务和多语言架构兴起后,这个限制变得越来越致命。 +2. **缺乏跨语言互操作**:不同 JMS 实现之间的有线协议(wire protocol)不统一,ActiveMQ 用 OpenWire,IBM MQ 用 MQI,互相不能直连。 +3. **厂商实现碎片化**:虽然有统一规范,但各厂商在扩展特性(如延迟消息、事务消息、死信队列)上的实现差异很大,迁移成本并不低。 +4. **规范更新缓慢**:JMS 2.0(2013 年)才引入简化 API(JMSContext),在此之前用 JMS 写代码要处理大量的样板代码(try-finally 关闭资源)。 + +### OpenMessaging:面向云原生的开放标准 + +2017 年,阿里巴巴联合 Apache RocketMQ 团队发起了 OpenMessaging 项目,目标很明确:定义一个厂商中立、语言无关、云原生的消息和流标准规范。 + +OpenMessaging 的核心设计原则: + +- **跨语言**:规范不限定编程语言,提供 Java、Go、C++ 等多语言 Binding。 +- **跨平台**:不绑定任何特定 MQ 产品,Kafka、RocketMQ、Pulsar 等都可以实现 OpenMessaging 接口。 +- **云原生**:原生支持分区、事务、消息追踪、延迟消息等现代需求,而不是事后打补丁。 +- **标准化扩展**:通过 Namespace、Region 等概念支持多租户和跨地域部署。 + +### OpenMessaging vs JMS 对比 + +| 维度 | JMS | OpenMessaging | +|------|-----|---------------| +| 发起方 | Sun Microsystems / Oracle | 阿里巴巴 + 社区 | +| 语言绑定 | Java Only | 多语言(Java、Go、C++) | +| 消息模型 | Queue + Topic | Partitioned Queue + Topic + Streaming | +| 有线协议 | 不统一(厂商各自实现) | 标准化 RPC 协议 | +| 事务支持 | JTA 事务集成 | 原生分布式事务 | +| 云原生 | 无原生支持 | Namespace、多租户、弹性伸缩 | +| 生态成熟度 | 非常成熟,20+ 年积累 | 仍在发展中,落地项目较少 | +| 典型实现 | ActiveMQ、IBM MQ | Apache RocketMQ(部分支持) | + +> [!question] OpenMessaging 提出了很好的愿景,为什么它的落地进展相对缓慢? +> 一方面,Kafka 和 RocketMQ 各自已经形成了庞大生态,应用层集成稳定,切换标准的动力不足。另一方面,消息协议不像 HTTP 那样有极强的网络效应——MQ 的抽象层通常在 SDK 内部,业务开发者感知不到底层协议差异。标准化的收益没有数据库连接池(如 HikariCP 替换 DBCP)那么直接。 + +### Go 代码:JMS 思路的对等实现 + +JMS 没有官方 Go SDK,但我们可以用 Go 的接口抽象来实现类似 JMS 的分层设计。以下示例展示如何用面向接口的方式构建消息客户端,底层可插拔不同 MQ 实现。 + +```go +package mq + +// Message 对应 JMS 的 Message 接口 +type Message interface { + Topic() string + Body() []byte + Properties() map[string]string +} + +// Producer 对应 JMS 的 MessageProducer +type Producer interface { + Send(topic string, body []byte, props map[string]string) error + Close() error +} + +// Consumer 对应 JMS 的 MessageConsumer +type Consumer interface { + Subscribe(topic string, handler func(Message)) error + Close() error +} + +// ClientFactory 对应 JMS 的 ConnectionFactory +// 不同 MQ 产品实现这个接口即可插拔替换 +type ClientFactory interface { + NewProducer() (Producer, error) + NewConsumer(group string) (Consumer, error) +} +``` + +这段代码的核心思想和 JMS 一模一样:面向接口编程。业务代码依赖 `Producer` / `Consumer` 接口,不关心底层是 Kafka、RocketMQ 还是 NATS。替换实现只需要换一个 `ClientFactory`,这正是 JMS 当年设计的初衷——只不过 JMS 把它限定在了 Java 生态里。 + +--- + +> [!tip] 实践建议 +> - 如果你的系统是纯 Java 栈,JMS 仍然是成熟可靠的选择,配合 Spring JMS Template 能大幅简化开发。 +> - 跨语言微服务架构下,优先考虑 Kafka 或 RocketMQ 的原生 SDK,它们的 Go/Python/Node.js 支持都很成熟。 +> - 不要为了"标准化"而标准化。协议的价值在于互操作性——如果你的系统不需要对接多个 MQ 产品,直接用原生 SDK 反而更简单。 + +## 关联笔记 + +- [[03-协议与标准/4-MQ-消息协议总览|MQ 消息协议总览]] +- [[03-协议与标准/5-AMQP-协议|AMQP 协议]] +- [[03-协议与标准/6-MQTT-协议|MQTT 协议]] +- [[12-架构与实战/47-MQ-与微服务|MQ 与微服务]] diff --git a/hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md b/hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md new file mode 100644 index 0000000..98e9684 --- /dev/null +++ b/hhs/MQ/04-存储引擎/10-RocketMQ-CommitLog.md @@ -0,0 +1,127 @@ +--- +tags: [MQ, RocketMQ, 存储引擎, CommitLog] +create time: 2026-05-24 19:52 +--- + +# RocketMQ CommitLog 三层存储模型 + +## 概述 + +RocketMQ 采用 CommitLog + ConsumeQueue + IndexFile 三层存储架构,将所有 Topic 的消息统一追加写入同一个 CommitLog 文件,再通过异步构建的索引文件实现高效消费和查询。这种设计牺牲了一定的读取局部性,但换来了极致的写入吞吐和灵活的消息查询能力。 + +## 正文 + +### 1. 三层存储模型总览 + +RocketMQ 的存储核心是三个文件的协作: + +- **CommitLog**:消息的物理存储,所有 Topic 的消息统一追加写入。 +- **ConsumeQueue**:消费队列索引,每个 Topic-Queue 维护一个轻量级索引文件,存储消息在 CommitLog 中的位置。 +- **IndexFile**:哈希索引,支持按 MessageKey 或时间范围查询消息。 + +```mermaid +graph TD + Producer["Producer 发送消息"] --> CommitLog["CommitLog: 顺序追加写入"] + CommitLog -->|"异步构建索引"| CQ["ConsumeQueue: 按 Topic-Queue 组织"] + CommitLog -->|"异步构建索引"| Index["IndexFile: 按 MessageKey 哈希"] + CQ -->|"顺序读取索引"| Consumer["Consumer 消费消息"] + Index -->|"按 Key 查询"| Query["消息回溯/查询"] + + style CommitLog fill:#4A90D9,color:#fff + style CQ fill:#F5A623,color:#fff + style Index fill:#6EC1E0,color:#fff +``` + +### 2. CommitLog:万物皆追加 + +CommitLog 是 RocketMQ 存储的灵魂。所有 Topic、所有 Queue 的消息,不分青红皂白,全部顺序追加到同一个文件(或一组分片文件)中。 + +每个 CommitLog 文件默认 1GB,写满后自动切换到下一个文件。单条消息的存储格式包括:消息长度、魔数(MagicCode)、CRC 校验、Body、Properties 等字段。 + +> [!question] 为什么 RocketMQ 要把所有 Topic 写到同一个 CommitLog,而不是像 Kafka 那样按 Partition 分开存储? +> +> 核心原因是**磁盘顺序写**。机械硬盘的顺序写性能接近 SSD,但随机写性能极差。Kafka 按 Partition 分文件,当 Topic 数量很多时(几千甚至上万),每个 Partition 都要维护独立的文件句柄,大量小文件同时写入会导致磁盘随机 I/O 激增。RocketMQ 把所有消息塞进同一个 CommitLog,不管有多少 Topic,写入永远是**单一文件的顺序追加**,在 Topic 数量爆炸的场景下优势明显。 +> +> 代价是什么?Consumer 读取时需要先查 ConsumeQueue 拿到 offset,再去 CommitLog 随机读——多了一次寻址。但读操作通常由 Page Cache 命中,实际影响不大。 + +### 3. ConsumeQueue:消费的桥梁 + +ConsumeQueue 是 CommitLog 和 Consumer 之间的桥梁。每个 Topic 的每个 Queue 对应一个 ConsumeQueue 文件,每条记录固定 20 字节: + +| 字段 | 大小 | 说明 | +|------|------|------| +| CommitLog Offset | 8 字节 | 消息在 CommitLog 中的物理偏移 | +| Size | 4 字节 | 消息长度 | +| Tag HashCode | 8 字节 | 消息 Tag 的哈希值,用于过滤 | + +Consumer 拉取消息时,先读 ConsumeQueue 获取 offset 列表,再根据 offset 去 CommitLog 读取完整消息。由于 ConsumeQueue 的条目是定长的,可以直接通过下标计算偏移量进行随机读取,效率很高。 + +Tag 过滤也是在 ConsumeQueue 层完成的:Consumer 携带订阅的 Tag 哈希,Broker 遍历 ConsumeQueue 条目时先比对 Tag HashCode,不匹配的直接跳过,避免了读取 CommitLog 的开销。 + +### 4. IndexFile:按 Key 查消息 + +IndexFile 是可选的哈希索引文件,结构类似 HashMap:通过 MessageKey 的哈希值定位到槽位(Slot),每个槽位指向一个链表,链表节点存储消息的 CommitLog Offset 和时间戳。 + +这使得 RocketMQ 支持按 MessageKey 精确查询消息,也支持按时间范围回溯消息——在排查问题、重放历史消息时非常有用。 + +### 5. MappedFile 与 mmap + +RocketMQ 使用 `MappedFile` 抽象封装了 mmap(内存映射文件)操作。每个 CommitLog / ConsumeQueue / IndexFile 文件都对应一个 MappedFile 实例。 + +mmap 的核心优势:将磁盘文件映射到进程虚拟内存空间,写入时直接操作内存,由操作系统内核负责异步刷盘。相比 `write()` 系统调用,mmap 减少了一次内核态到用户态的数据拷贝,写入性能更高。 + +```go +// 简化版 CommitLog 写入逻辑 +func (cl *CommitLog) PutMessage(msg *Message) error { + // 1. 序列化消息为字节数组 + data := serialize(msg) + + // 2. 在 MappedFile 当前写入位置追加数据(内存映射写入) + mappedFile := cl.mappedFileQueue.GetLastMappedFile() + offset := mappedFile.GetCurrentWritePosition() + mappedFile.AppendMessage(data) + + // 3. 根据刷盘策略决定何时落盘 + if cl.flushPolicy == SyncFlush { + mappedFile.Flush() // 同步刷盘:阻塞等待数据写入磁盘 + } + // 异步刷盘则由后台线程定时执行,不阻塞写入 + + // 4. 异步构建 ConsumeQueue 和 IndexFile 索引 + cl.reputService.BuildIndex(msg, offset) + + return nil +} +``` + +这段代码体现了 CommitLog 写入的核心流程:序列化 -> 追加到 MappedFile -> 刷盘 -> 异步建索引。整个过程是单文件顺序写,没有锁竞争,吞吐极高。 + +### 6. 同步/异步刷盘与主从同步 + +**刷盘策略**决定了消息写入 MappedFile(内存)后何时真正持久化到磁盘: + +- **同步刷盘(SYNC_FLUSH)**:写入后立即调用 `fsync`,数据安全性高,但吞吐较低。适合对可靠性要求极高的场景(如金融交易)。 +- **异步刷盘(ASYNC_FLUSH)**:由后台 FlushCommitLogService 线程定时刷盘,吞吐高但宕机时可能丢失少量消息。适合大多数业务场景。 + +**主从同步**方面,RocketMQ 4.x 引入了基于 Raft 协议的 **DLedger** 模式替代传统的 Master-Slave 异步复制。DLedger 要求消息写入多数节点后才算成功,从根本上解决了主从异步复制时 Master 宕机丢数据的问题。 + +```mermaid +graph LR + Producer["Producer"] -->|"发送消息"| Master["Master Broker"] + Master -->|"同步/异步刷盘"| Disk1["磁盘"] + Master -->|"DLedger Raft 复制"| Slave1["Follower 1"] + Master -->|"DLedger Raft 复制"| Slave2["Follower 2"] + Slave1 -->|"本地刷盘"| Disk2["磁盘"] + Slave2 -->|"本地刷盘"| Disk3["磁盘"] + + style Master fill:#4A90D9,color:#fff + style Slave1 fill:#6EC1E0,color:#fff + style Slave2 fill:#6EC1E0,color:#fff +``` + +## 关联笔记 + +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[07-主流MQ对比/24-RocketMQ|RocketMQ]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]] diff --git a/hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md b/hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md new file mode 100644 index 0000000..a8f0036 --- /dev/null +++ b/hhs/MQ/04-存储引擎/11-RabbitMQ-消息存储.md @@ -0,0 +1,149 @@ +--- +tags: [MQ, RabbitMQ, 存储引擎, Erlang] +create time: 2026-05-24 19:52 +--- + +# RabbitMQ 消息存储 + +## 概述 + +RabbitMQ 基于 Erlang/OTP 平台构建,消息存储依赖 Erlang 进程模型和 Mnesia 分布式数据库。本文深入分析 RabbitMQ 的消息持久化机制、队列类型演进(经典队列、仲裁队列、流式队列)、内存管理策略,以及消息从写入到删除的完整生命周期。 + +## 正文 + +### 1. 存储架构:Erlang 进程 + Mnesia + +RabbitMQ 的每个 Queue 本质上是一个 Erlang 进程(gen_server),拥有独立的 mailbox 和状态。这种设计天然隔离了不同队列的故障,但也意味着每个队列的资源消耗(内存、调度时间)受到 Erlang 虚拟机(BEAM)的约束。 + +持久化元数据(Exchange 定义、Binding 关系、用户权限等)存储在 **Mnesia** 分布式数据库中。Mnesia 是 Erlang 原生的数据库,支持事务和分布式复制,但它的设计目标是存储小量元数据,而非大规模消息数据。实际的消息体存储由各队列进程自行管理。 + +```mermaid +graph TD + Producer["Producer"] -->|"AMQP 发布"| Exchange["Exchange"] + Exchange -->|"路由规则"| Binding["Binding"] + Binding --> Queue1["Queue 进程 1"] + Binding --> Queue2["Queue 进程 2"] + Queue1 -->|"持久化写入"| MsgStore["消息存储"] + Queue1 -->|"内存缓存"| Memory["进程内存"] + MsgStore --> Disk["磁盘文件"] + Queue1 --> Consumer["Consumer"] + + style Exchange fill:#4A90D9,color:#fff + style Queue1 fill:#F5A623,color:#fff + style Queue2 fill:#F5A623,color:#fff + style MsgStore fill:#6EC1E0,color:#fff +``` + +### 2. 消息持久化机制 + +RabbitMQ 中消息要真正持久化,需要同时满足两个条件: + +- **Queue 声明为 durable**:队列的元数据在 Broker 重启后保留。 +- **Message 的 delivery mode 设为 2(persistent)**:消息体写入磁盘。 + +> [!question] durable 和 persistent 有什么区别? +> +> `durable` 是队列的属性,控制的是"队列本身在重启后是否还存在";`persistent` 是消息的属性,控制的是"这条消息是否需要写入磁盘"。两者必须同时设置才能实现真正的持久化。如果只有 durable 没有 persistent,队列重启后还在,但里面的消息会丢失;反过来则队列重启后直接消失,消息也就无从谈起了。 + +消息写入磁盘的时机并非立即的。RabbitMQ 使用一种"惰性"写入策略:消息先写入内存,当满足一定条件(如内存压力达到阈值、或显式 flush)时才批量刷入磁盘。这意味着在极端情况下(如 Broker 突然崩溃),少量消息可能丢失。 + +### 3. Queue 类型演进 + +RabbitMQ 3.8+ 引入了三种队列类型,各自面向不同的可靠性与性能需求: + +**经典队列(Classic Queue)**是 RabbitMQ 最早的队列实现。消息存储在 Erlang Mnesia 表中(实际上从 3.x 开始使用自定义的文件存储引擎),支持可选持久化。单 Master 架构,不支持复制。在消息堆积时性能急剧下降,因为队列进程需要维护一个按 offset 索引的磁盘文件结构,大量消息会导致 GC 压力和 I/O 放大。 + +**仲裁队列(Quorum Queue)**基于 Raft 共识协议,每个队列在多个节点上维护副本(奇数个,默认 3 个)。消息写入需要多数节点确认,天然保证了数据安全。底层使用 WAL(Write-Ahead Log)顺序写盘,性能在大量消息堆积时保持稳定。 + +**流式队列(Stream)**是 3.9 引入的新类型,借鉴了 Kafka 的设计思想。消息以 append-only log 形式存储,支持多消费者独立读取(非竞争消费),消息不会被消费后删除,支持回溯重放。适合事件驱动、审计日志等场景。 + +| 特性 | Classic Queue | Quorum Queue | Stream | +|------|:---:|:---:|:---:| +| 复制 | 不支持 | Raft 多副本 | 可选 | +| 持久化 | 可选 | 必须 | 必须 | +| 消费后删除 | 是 | 是 | 否 | +| 消息回溯 | 不支持 | 不支持 | 支持 | +| 适合场景 | 简单队列 | 高可靠 | 事件流 | + +> [!question] 经典队列在大量消息堆积时性能为什么会急剧下降? +> +> 经典队列的存储引擎在消息堆积时面临两个瓶颈:一是 Erlang 进程的 mailbox 膨胀导致调度延迟增大,GC 暂停时间变长;二是磁盘存储使用了按消息 ID 索引的文件结构,大量随机 I/O 使得写入性能断崖式下跌。仲裁队列通过 WAL 顺序写解决了 I/O 问题,通过 Raft 副本机制分散了单节点压力,从而在堆积场景下保持稳定。 + +### 4. Lazy Queue:磁盘优先策略 + +Lazy Queue 是经典队列的一种特殊模式,将消息尽可能早地写入磁盘,只在消费者需要时才加载到内存。适合消息堆积量大但消费速度跟不上的场景。 + +配置方式:声明队列时设置 `x-queue-mode: lazy`。在 Quorum Queue 中,由于 WAL 本身就是顺序写磁盘的,不需要额外的 Lazy 模式。 + +### 5. 内存管理:告警、流控与信用机制 + +RabbitMQ 的内存管理是一个三层防护体系: + +**内存告警(Memory Alarm)**:当节点内存使用超过阈值(默认物理内存的 40%),Broker 触发内存告警,阻塞所有生产者的消息发布,直到内存回落到安全水位。 + +**流控(Flow Control)**:当某个 Erlang 进程(如队列进程)的消息积压超过一定阈值,会主动向 TCP 连接进程发送 `pause` 信号,让生产者的 TCP 接收窗口降为 0,从而在协议层面实现背压。 + +**信用机制(Credit Flow)**:这是 Erlang 进程间的流控机制。每个进程维护一个 credit 计数器,消息传递消耗 credit,消费端处理完毕后归还 credit。当 credit 归零时,发送端阻塞,防止过快地往下游进程灌消息。 + +```mermaid +graph TD + Producer["Producer"] -->|"发送消息"| Conn["连接进程"] + Conn -->|"credit 检查"| Queue["队列进程"] + Queue -->|"内存检测"| MemCheck{"内存超阈值?"} + MemCheck -->|"是"| Alarm["触发内存告警"] + Alarm -->|"阻塞生产"| Producer + MemCheck -->|"否"| Process["正常处理"] + Process --> Consumer["Consumer"] + + style Alarm fill:#D0021B,color:#fff + style Queue fill:#F5A623,color:#fff +``` + +### 6. 消息的生命周期:写入与删除 + +消息从进入 RabbitMQ 到最终消失,经历以下阶段: + +1. **写入内存**:消息到达队列进程后,先存入进程内存(Erlang ETS 表或进程状态)。 +2. **写入磁盘**(如果 persistent):当内存压力达到一定阈值,或消息被标记为 persistent 时,消息体被写入磁盘文件。 +3. **投递消费者**:消息从内存中读取并发送给消费者。 +4. **等待 ACK**:消息在未收到 ACK 前不会被删除。 +5. **收到 ACK 后删除**:消息从内存和磁盘中移除。对于持久化消息,磁盘文件中的标记被清除,空间在文件 compaction 时回收。 + +### 7. Go 代码示例 + +```go +package main + +import ( + amqp "github.com/rabbitmq/amqp091-go" +) + +func main() { + conn, _ := amqp.Dial("amqp://guest:guest@localhost:5672/") + defer conn.Close() + + ch, _ := conn.Channel() + defer ch.Close() + + // 声明持久化队列:durable = true, autoDelete = false, exclusive = false + ch.QueueDeclare("order_events", true, false, false, false, amqp.Table{ + "x-queue-type": "quorum", // 使用仲裁队列,替代默认的经典队列 + }) + + // 发布持久化消息:DeliveryMode = Persistent + ch.Publish("", "order_events", false, false, amqp.Publishing{ + ContentType: "application/json", + DeliveryMode: amqp.Persistent, // 消息持久化 + Body: []byte(`{"order_id":"12345","status":"created"}`), + }) +} +``` + +上面的代码展示了两个关键点:一是 `QueueDeclare` 设置 `durable: true` 并指定 `x-queue-type: quorum` 使用仲裁队列;二是 `Publishing` 设置 `DeliveryMode: amqp.Persistent` 确保消息持久化。两者缺一不可。 + +## 关联笔记 + +- [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]] +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[03-协议与标准/5-AMQP-协议|AMQP 协议]] diff --git a/hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md b/hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md new file mode 100644 index 0000000..2eebc7c --- /dev/null +++ b/hhs/MQ/04-存储引擎/8-MQ-存储引擎设计.md @@ -0,0 +1,192 @@ +--- +tags: [MQ] +create time: 2026-05-24 19:52 +--- + +# MQ 存储引擎设计 + +## 概述 + +存储引擎是 MQ 性能的基石。消息从生产者发出到被消费者拉取,中间经历的"写入—持久化—读取"全链路,每一步都与存储设计息息相关。本文从磁盘 I/O 模型出发,依次讲解顺序写、零拷贝、Page Cache、批量刷盘、Append-Only 日志等核心原理,帮你建立对高性能消息存储的系统认知。 + +## 正文 + +### 为什么存储引擎是 MQ 的性能基石 + +MQ 的本质是一个"写多读多"的中转站:生产者不断写入消息,消费者不断拉取消息。如果底层存储扛不住写入吞吐,上层的协议优化、集群扩展全是空谈。可以说,**存储引擎的设计直接决定了 MQ 的性能上限**。 + +> [!question] 思考 +> 如果让你从零设计一个 MQ,你会选择把消息存在哪里?关系数据库?Redis?还是直接写文件? + +### 磁盘顺序写 vs 随机写 + +传统认知里,磁盘(尤其是机械硬盘 HDD)是性能瓶颈。但这里有个关键区分:**顺序写**和**随机写**的性能差距是数量级的。 + +- **随机写**:磁头需要反复寻道,HDD 的 IOPS 通常只有 100-200 次/秒。 +- **顺序写**:磁头几乎不需要移动,HDD 顺序写吞吐可达 600MB/s 以上,甚至媲美 SATA SSD 的顺序写性能。 + +这就是为什么 Kafka、RocketMQ 等高性能 MQ 都选择了**顺序写盘**的策略——即使是廉价的 HDD 也能获得极高的写入吞吐。 + +```mermaid +graph LR + A["Producer"] -->|"顺序追加写入"| B["磁盘文件"] + B -->|"Consumer 按偏移量顺序读取"| C["Consumer"] + + style A fill:#4A90D9,color:#fff + style B fill:#F5A623,color:#fff + style C fill:#6EC1E0,color:#fff +``` + +### 零拷贝(Zero-Copy)技术 + +传统网络传输一条消息,数据需要经历 4 次拷贝和 4 次上下文切换: + +1. 磁盘 → 内核缓冲区(DMA 拷贝) +2. 内核缓冲区 → 用户空间缓冲区(CPU 拷贝) +3. 用户空间缓冲区 → Socket 缓冲区(CPU 拷贝) +4. Socket 缓冲区 → 网卡(DMA 拷贝) + +`sendfile` 系统调用可以让数据直接从内核缓冲区传到网卡,跳过用户空间的两次拷贝,只需要 2 次 DMA 拷贝 + 1 次上下文切换。**Kafka 正是利用 sendfile 实现了消费者拉取数据时的零拷贝**,这让消费者读取消息几乎不消耗 CPU 资源。 + +```mermaid +graph LR + subgraph "传统拷贝" + D1["磁盘"] -->|"DMA"| K1["内核缓冲区"] + K1 -->|"CPU"| U1["用户空间"] + U1 -->|"CPU"| S1["Socket 缓冲区"] + S1 -->|"DMA"| N1["网卡"] + end + + subgraph "零拷贝 sendfile" + D2["磁盘"] -->|"DMA"| K2["内核缓冲区"] + K2 -->|"DMA"| N2["网卡"] + end + + style D1 fill:#F5A623,color:#fff + style D2 fill:#F5A623,color:#fff +``` + +### Page Cache 与内存映射(mmap) + +操作系统内核会自动将最近访问的磁盘数据缓存在 **Page Cache** 中。MQ 的写入和读取天然契合 Page Cache 的工作模式: + +- **写入时**:消息先写入 Page Cache,由操作系统异步刷盘,应用层返回极快。 +- **读取时**:如果消费者读取的是刚写入的数据(大多数场景),直接从 Page Cache 返回,无需访问磁盘。 + +**mmap(内存映射)** 则更进一步:将磁盘文件直接映射到进程的虚拟地址空间,读写文件就像读写内存一样,省去了 `read/write` 系统调用的开销。RocketMQ 的 MappedFile 就是基于 mmap 实现的。 + +> [!question] 思考 +> Page Cache 加速了读写,但如果 Broker 进程崩溃,Page Cache 中还没刷盘的数据会丢失吗?这对"异步刷盘"策略意味着什么? + +### 批量写入与刷盘策略 + +MQ 通常不会每条消息都触发一次磁盘 I/O,而是将多条消息在内存中攒批后再统一写入,这就是**批量写入**。刷盘策略则决定了数据何时真正落盘: + +| 策略 | 机制 | 可靠性 | 性能 | +|------|------|--------|------| +| **同步刷盘** | 每批消息写入后,调用 `fsync` 等待数据落盘才返回 | 高:数据不丢 | 较低:受磁盘 I/O 限制 | +| **异步刷盘** | 消息写入 Page Cache 即返回,后台线程定时刷盘 | 较低:宕机可能丢最后几条 | 高:写入延迟极低 | + +> [!question] 思考 +> 异步刷盘宕机可能丢数据,为什么很多 MQ 还是默认异步刷盘? + +答案在于**实际场景的权衡**:大多数业务场景可以容忍极少量消息丢失(配合重试机制),但无法容忍高延迟。而且异步刷盘配合主从同步(消息复制到其他节点后再返回),可以在性能和可靠性之间找到平衡。只有金融级场景才需要同步刷盘。 + +### 日志追加(Append-Only)设计 + +为什么 MQ 不用数据库(如 MySQL)存储消息?核心原因有三: + +1. **写入模式不匹配**:数据库面向"随机读写"优化,B+ 树索引在高并发写入下会成为瓶颈;而 MQ 是纯粹的"顺序追加 + 顺序读取",Append-Only 日志天然适配。 +2. **无索引开销**:消息不需要按内容检索,只需要按偏移量(offset)顺序读取,省去了索引维护的代价。 +3. **删除成本低**:过期日志直接截断文件头,不需要像数据库那样做 VACUUM 或标记删除。 + +### 存储写入流程总览 + +```mermaid +graph TD + P["Producer 发送消息"] --> B["Broker 接收"] + B --> M["消息序列化写入内存缓冲区"] + M --> BQ{"是否达到批量阈值?"} + BQ -->|"否"| M + BQ -->|"是"| FS["追加写入磁盘日志文件"] + FS --> F{"刷盘策略?"} + F -->|"同步"| FSYNC["fsync 确保落盘"] + F -->|"异步"| PC["写入 Page Cache 后返回"] + PC --> BG["后台线程定时 fsync"] + FSYNC --> ACK["返回 ACK 给 Producer"] + BG --> ACK + + style P fill:#4A90D9,color:#fff + style B fill:#6EC1E0,color:#fff + style ACK fill:#2ECC71,color:#fff +``` + +### Go 伪代码:一个简单的 Append-Only Log 存储 + +下面的代码展示了一个最简的 Append-Only Log 的核心逻辑——顺序追加写入和按偏移量读取。 + +```go +// AppendLog 是一个简化版的顺序写日志存储 +type AppendLog struct { + mu sync.Mutex + file *os.File + offset int64 // 当前写入位置 +} + +// NewAppendLog 打开或创建日志文件 +func NewAppendLog(path string) (*AppendLog, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + // 获取当前文件大小作为起始偏移量 + stat, _ := f.Stat() + return &AppendLog{file: f, offset: stat.Size()}, nil +} + +// Append 顺序追加一条消息,返回该消息在文件中的偏移量 +func (a *AppendLog) Append(data []byte) (int64, error) { + a.mu.Lock() + defer a.mu.Unlock() + + // 写入长度前缀 + 数据(Length-Prefix 编码,方便读取时知道边界) + buf := make([]byte, 4+len(data)) + binary.BigEndian.PutUint32(buf[:4], uint32(len(data))) + copy(buf[4:], data) + + n, err := a.file.Write(buf) // 顺序追加,OS 自动利用 Page Cache + if err != nil { + return 0, err + } + pos := a.offset + a.offset += int64(n) + return pos, nil // 返回偏移量,消费者后续按此读取 +} + +// ReadAt 从指定偏移量读取消息(消费者按 offset 拉取) +func (a *AppendLog) ReadAt(offset int64) ([]byte, error) { + // 读取 4 字节长度头 + header := make([]byte, 4) + if _, err := a.file.ReadAt(header, offset); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(header) + + // 读取消息体 + data := make([]byte, length) + if _, err := a.file.ReadAt(data, offset+4); err != nil { + return nil, err + } + return data, nil +} +``` + +**核心要点**:写入只做追加(`O_APPEND`),不修改已有数据;读取通过偏移量直接定位,无需索引。这就是 Kafka、RocketMQ 存储引擎的最简原型。 + +## 关联笔记 + +- [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]] +- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]] +- [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]] diff --git a/hhs/MQ/04-存储引擎/9-Kafka-存储设计.md b/hhs/MQ/04-存储引擎/9-Kafka-存储设计.md new file mode 100644 index 0000000..8c262c3 --- /dev/null +++ b/hhs/MQ/04-存储引擎/9-Kafka-存储设计.md @@ -0,0 +1,236 @@ +--- +tags: [MQ] +create time: 2026-05-24 19:52 +--- + +# Kafka 存储设计 + +## 概述 + +Kafka 的高吞吐能力不仅来自"顺序写盘"和"零拷贝"等通用优化,更源于其精心设计的存储结构:Partition Log 的分段存储、稀疏索引、日志压缩,以及内部定时任务调度所用的时间轮。本文深入 Kafka 存储层的每一个关键设计,帮你理解"为什么 Kafka 这么快"。 + +## 正文 + +### Partition Log 的整体结构 + +Kafka 的存储层次可以概括为:**Topic → Partition → Segment**。每个 Partition 是一个有序的、不可变的消息序列,底层由多个 Segment 文件组成。 + +一个 Partition 目录下的典型文件结构: + +``` +partition-0/ +├── 00000000000000000000.log # 第一个 Segment 的消息数据 +├── 00000000000000000000.index # 稀疏偏移索引 +├── 00000000000000000000.timeindex # 时间戳索引 +├── 00000000000000523840.log # 第二个 Segment(起始 offset = 523840) +├── 00000000000000523840.index +├── 00000000000000523840.timeindex +└── ... +``` + +文件名是该 Segment 的 **base offset**(起始偏移量)。当 Segment 达到配置的大小或时间阈值时,Kafka 会"滚动"出一个新的 Segment。 + +```mermaid +graph TD + T["Topic: orders"] --> P0["Partition 0"] + T --> P1["Partition 1"] + T --> P2["Partition 2"] + + P0 --> S1["Segment 0\nbase offset = 0"] + P0 --> S2["Segment 1\nbase offset = 523840"] + P0 --> S3["Segment 2\nbase offset = 1048576"] + + S1 --> L1["00000.log"] + S1 --> I1["00000.index"] + S1 --> TI1["00000.timeindex"] + + S2 --> L2["523840.log"] + S2 --> I2["523840.index"] + S2 --> TI2["523840.timeindex"] + + style T fill:#4A90D9,color:#fff + style P0 fill:#6EC1E0,color:#fff + style P1 fill:#6EC1E0,color:#fff + style P2 fill:#6EC1E0,color:#fff +``` + +> [!question] 思考 +> 为什么不把整个 Partition 写成一个巨大的日志文件,而要拆分成多个 Segment? + +### 分段存储策略 + +Segment 滚动的触发条件有两个(满足任一即滚动): + +- **按大小**:默认 1GB(`log.segment.bytes`) +- **按时间**:默认 7 天(`log.roll.ms` / `log.roll.hours`) + +分段的好处是多方面的: + +1. **快速定位**:消费者只需根据 offset 找到对应 Segment,再在小文件内查找,而不是在一个 TB 级大文件中扫描。 +2. **高效清理**:过期数据直接删除整个 Segment 文件,无需逐条标记删除或做 compaction。 +3. **并行 I/O**:不同 Segment 可以被不同的消费者线程并发读取。 + +### 稀疏索引(Sparse Index) + +Kafka 不是每条消息都建索引——那会带来巨大的存储和维护开销。它采用**稀疏索引**:每隔一定字节(默认 4KB,由 `log.index.interval.bytes` 控制)才记录一条索引项,格式为 `offset → 文件物理位置`。 + +当消费者需要查找某个 offset 的消息时,流程如下: + +1. 在 `.index` 文件中用**二分查找**找到不大于目标 offset 的最近索引项。 +2. 从该索引项指向的物理位置开始,在 `.log` 文件中**顺序扫描**直到找到目标 offset。 + +因为索引本身是有序的,二分查找效率为 O(log N);而顺序扫描的范围通常只有几条消息(4KB 内),开销极小。这种设计用极少的索引空间换来了接近 O(1) 的查找性能。 + +### 日志压缩(Log Compaction) + +普通的消息保留策略是按时间或大小删除过期 Segment。但 Kafka 还提供了另一种策略——**Log Compaction**:保留每个 Key 的最后一条消息,删除之前的旧版本。 + +工作原理: + +- Log Compaction 由 Cleaner 线程在后台执行。 +- Cleaner 为每个 Key 维护一个"最新 offset"映射,只保留最新消息。 +- 清理后的 Segment 中,每个 Key 只有一条记录。 +- Consumer 从头消费时,仍然能拿到每个 Key 的最新状态。 + +**典型场景**:变更数据捕获(CDC)、状态快照同步。比如数据库的一张用户表,每次更新都发一条消息到 Kafka(Key = userId),下游系统通过 Log Compaction 拿到的就是每个用户的最新状态。 + +> [!question] 思考 +> Log Compaction 保证的是"每个 Key 至少保留最新一条",但如果有两个 Key 相同的消息几乎同时到达,Compaction 会保留哪条? + +### 时间轮(Timing Wheel) + +Kafka 内部有大量的定时任务:延迟消息、会话过期、日志清理等。如果每个定时任务都开一个 goroutine(或 Java 的 Timer),任务数多了之后,调度开销会非常大。 + +Kafka 使用**层级时间轮(Hierarchical Timing Wheel)**来高效管理定时任务: + +- 时间轮是一个环形数组,每个槽位(slot)代表一个时间区间。 +- 新任务根据到期时间插入对应槽位。 +- 时钟每推进一个 tick,处理当前槽位的所有任务。 +- 当低层时间轮溢出时,任务会被"滴答"到上层时间轮,等待合适时机再降下来。 + +时间轮的插入和删除都是 O(1) 操作,远优于优先队列的 O(log N)。 + +```mermaid +graph TD + TW["时间轮"] --> L1["第一层: 1ms 精度\n20 个槽位"] + TW --> L2["第二层: 20ms 精度\n20 个槽位"] + TW --> L3["第三层: 400ms 精度\n20 个槽位"] + + L1 --> SLOT1["slot 0: 任务 A"] + L1 --> SLOT2["slot 5: 任务 B"] + L2 --> SLOT3["slot 3: 任务 C\n溢出后降级到第一层"] + + style TW fill:#4A90D9,color:#fff + style L1 fill:#6EC1E0,color:#fff + style L2 fill:#F5A623,color:#fff + style L3 fill:#D0021B,color:#fff +``` + +### 页缓存与 Sendfile:读写路径中的内核级优化 + +Kafka 的读写路径深度依赖 Linux 内核的两个能力: + +**写入路径**:Producer 发来的消息写入 Page Cache(内存),由内核异步刷盘。应用层不调用 `fsync`,写入延迟极低。多个 Partition 的写入共享 Page Cache,操作系统会自动管理缓存淘汰。 + +**读取路径**:Consumer 拉取消息时,Kafka 通过 `sendfile` 系统调用直接将 Page Cache 中的数据传输到网卡,**数据完全不经过用户空间**。这意味着: + +- 没有用户态/内核态的上下文切换 +- 没有内存拷贝 +- 大量 Consumer 并发拉取时,CPU 开销几乎不增长 + +这就是为什么 Kafka 在普通硬件上就能达到百万级 TPS 的核心秘密——它把操作系统的缓存和网络能力用到了极致。 + +> [!question] 思考 +> 如果 Kafka Broker 的内存足够大,Page Cache 能缓存大量数据。但如果消费者需要回溯到很早的消息(不在 Page Cache 中),会发生什么?性能会下降多少? + +### Go 代码:简化版 Segment 文件的读写 + +下面展示一个简化版的 Segment 文件实现,包含日志文件和稀疏索引的写入与查找。 + +```go +type Segment struct { + baseOffset int64 + logFile *os.File + indexFile *os.File + index []indexEntry // 内存中的稀疏索引 +} + +type indexEntry struct { + offset int64 // 消息的逻辑偏移量 + position int32 // 消息在 .log 文件中的物理位置 +} + +// Write 追加一条消息到 Segment +func (s *Segment) Write(offset int64, data []byte) error { + // 记录当前写入位置作为物理偏移 + stat, _ := s.logFile.Stat() + pos := stat.Size() + + // Length-Prefix 编码写入日志文件 + header := make([]byte, 4+len(data)) + binary.BigEndian.PutUint32(header[:4], uint32(len(data))) + copy(header[4:], data) + if _, err := s.logFile.Write(header); err != nil { + return err + } + + // 每隔一定消息数写入一条索引(稀疏索引,不是每条都写) + if len(s.index) == 0 || offset-s.index[len(s.index)-1].offset >= 4 { + entry := indexEntry{offset: offset, position: int32(pos)} + s.index = append(s.index, entry) + // 同步写入 .index 文件(简化:每条 12 字节 = 8 offset + 4 position) + buf := make([]byte, 12) + binary.BigEndian.PutUint64(buf[:8], uint64(offset)) + binary.BigEndian.PutUint32(buf[8:], uint32(pos)) + s.indexFile.Write(buf) + } + return nil +} + +// Read 根据 offset 查找并读取消息 +func (s *Segment) Read(offset int64) ([]byte, error) { + // 第一步:二分查找稀疏索引,找到最近的索引项 + idx := sort.Search(len(s.index), func(i int) bool { + return s.index[i].offset > offset + }) - 1 + + var startPos int32 + if idx >= 0 { + startPos = s.index[idx].position // 从索引指向的位置开始 + } + + // 第二步:从 startPos 开始顺序扫描 .log 文件 + pos := int64(startPos) + for { + header := make([]byte, 4) + if _, err := s.logFile.ReadAt(header, pos); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(header) + data := make([]byte, length) + s.logFile.ReadAt(data, pos+4) + + // 简化:假设 offset 是连续递增的 + currentOffset := s.baseOffset + (pos / int64(4+length)) + if currentOffset == offset { + return data, nil + } + pos += int64(4 + length) + } +} +``` + +**核心要点**:写入时不是每条消息都建索引(稀疏索引),读取时先二分查找索引再顺序扫描——用极小的索引空间和极少的扫描范围实现高效定位。 + +> [!question] 思考 +> Kafka 的 Segment 为什么要同时维护 .log 和 .index 两个文件?只用 .log 行不行? + +答案是:**理论上行,但实践中代价太大**。如果只用 .log,消费者查找某个 offset 的消息时,必须从 Segment 头部开始顺序扫描,时间复杂度为 O(N)。在消息量巨大的场景下(单 Segment 可达 1GB、数百万条消息),这会严重拖慢消费速度。稀疏索引将查找降为 O(log N) 的二分 + 少量顺序扫描,代价只是每个 Segment 多几 KB 的索引文件,性价比极高。此外,`.timeindex` 文件支持按时间戳查找消息(用于"从某时刻开始消费"的场景),仅靠 `.log` 文件无法高效实现。 + +## 关联笔记 + +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]] +- [[07-主流MQ对比/22-Kafka|Kafka]] +- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]] +- [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]] diff --git a/hhs/MQ/05-可靠性保障/12-MQ-消息确认与持久化.md b/hhs/MQ/05-可靠性保障/12-MQ-消息确认与持久化.md new file mode 100644 index 0000000..4153567 --- /dev/null +++ b/hhs/MQ/05-可靠性保障/12-MQ-消息确认与持久化.md @@ -0,0 +1,159 @@ +--- +tags: [MQ, 消息确认, 持久化, ACK, 可靠性] +create time: 2026-05-24 19:52 +--- + +# MQ 消息确认与持久化 + +## 概述 + +消息从 Producer 发出到 Consumer 处理完成,中间经历多个环节,任何一个环节出问题都可能导致消息丢失或重复。本文从三种投递语义出发,分别剖析 Producer 端、Broker 端、Consumer 端的确认与持久化机制,帮助你理解如何在可靠性与性能之间做出合理取舍。 + +## 正文 + +### 三种投递语义 + +消息投递语义定义了"一条消息最多/至少/恰好被投递几次"的承诺,是所有可靠性设计的理论基础。 + +| 语义 | 含义 | 典型场景 | 代价 | +|------|------|----------|------| +| **At-Most-Once** | 消息最多投递一次,可能丢失 | 日志采集、监控指标上报 | 不丢就行,丢了也无所谓 | +| **At-Least-Once** | 消息至少投递一次,可能重复 | 订单创建、支付通知 | 业务端需做幂等处理 | +| **Exactly-Once** | 消息恰好投递一次 | 资金转账、库存扣减 | 实现复杂,性能开销大 | + +> [!question] +> 实际工程中,At-Most-Once 和 At-Least-Once 哪个更常用?为什么 Exactly-Once 很少被端到端保证? + +严格意义上的 Exactly-Once 在分布式系统中几乎不可能端到端实现。大多数 MQ 选择 At-Least-Once 作为默认语义,把去重的责任交给业务层。这样设计的原因是:网络不可靠、进程可能宕机、任何一跳的 ACK 都可能丢失——与其追求完美的一次投递,不如让每层做好自己的事。 + +### Producer 端确认 + +Producer 发送消息后,需要确认 Broker 是否成功接收。不同确认方式对应不同的可靠性级别。 + +**同步发送 + ACK**:Producer 发送后阻塞等待 Broker 返回 ACK。最可靠,但吞吐最低。适合对可靠性要求极高的场景,如金融交易。 + +**异步发送 + 回调**:Producer 发送后立即返回,通过回调函数处理结果。吞吐高,但回调可能乱序,且如果 Producer 在回调前宕机,消息可能丢失。 + +**重试机制**:网络抖动或 Broker 暂时不可用时,Producer 会自动重试。需要注意:重试可能导致重复消息,所以 Broker 端需要配合去重(如 Kafka 的幂等 Producer)。 + +```go +// 同步发送:阻塞等待 Broker 确认 +msg := &sarama.ProducerMessage{ + Topic: "order-events", + Value: sarama.StringEncoder(`{"order_id": "1001"}`), +} +partition, offset, err := producer.SendMessage(msg) +if err != nil { + // 发送失败,根据策略决定是否重试 + log.Printf("send failed: %v", err) +} +// partition 和 offset 说明 Broker 已确认收到 +log.Printf("sent to partition %d, offset %d", partition, offset) +``` + +同步发送的优势在于语义简单——`SendMessage` 返回成功就代表 Broker 已经持久化了这条消息。代价是每条消息都要等一次网络往返,吞吐受限于 RTT。 + +### Broker 端持久化 + +Broker 收到消息后,需要将其写入存储。这里有两个关键决策点:**刷盘策略**和**主从同步模式**。 + +**同步刷盘 vs 异步刷盘**: + +- **同步刷盘**:消息写入 Page Cache 后立即调用 `fsync` 落盘。可靠性高,但每次写入都要等磁盘 I/O,吞吐受限于磁盘性能。 +- **异步刷盘**:消息写入 Page Cache 后立即返回,由后台线程批量刷盘。吞吐极高(利用了操作系统的 Page Cache),但 Broker 突然宕机时,Page Cache 中未刷盘的消息会丢失。 + +**主从同步模式**: + +- **同步复制**:Producer 发送的消息必须被所有(或多数)副本确认后才算写入成功。可靠但延迟更高。 +- **异步复制**:主节点确认后即返回,副本异步拉取。延迟低,但主节点宕机时可能丢失尚未同步的数据。 + +> [!question] +> Kafka 的 ISR(In-Sync Replicas)机制是如何在同步复制和异步复制之间找到平衡的? + +Kafka 的 ISR 是一个动态集合,只有与 Leader 保持同步的 Follower 才在 ISR 中。Producer 通过 `acks=all` 配置要求 ISR 中所有副本确认。如果某个 Follower 落后太多,会被踢出 ISR。这样既保证了可靠性(ISR 中的副本都有最新数据),又避免了慢副本拖累整体性能。 + +### Consumer 端确认 + +Consumer 从 Broker 拉取消息后,需要告知 Broker "我已经处理完了",这就是 Consumer 端的 ACK。 + +**手动 ACK vs 自动 ACK**: + +- **自动 ACK**:Consumer 拉取消息后立即自动确认。实现简单,但如果 Consumer 在处理消息前宕机,消息就丢了——因为 Broker 以为它已经被消费了。 +- **手动 ACK**:Consumer 处理完业务逻辑后显式调用 ACK。更可靠,但需要开发者自己管理确认时机。 + +**ACK 时机的选择**: + +- **收到即 ACK**:拉取到消息就确认,然后在本地处理。风险等同于自动 ACK。 +- **处理完再 ACK**:业务逻辑执行成功后再确认。如果处理过程中 Consumer 崩溃,消息会被重新投递(At-Least-Once)。这是生产环境的推荐做法。 + +### 端到端消息投递流程 + +下图展示了从 Producer 到 Consumer 的完整投递链路,标注了每个环节的确认方式: + +```mermaid +sequenceDiagram + participant P as "Producer" + participant B as "Broker" + participant C as "Consumer" + + P->>B: "发送消息" + Note right of P: "同步等待 / 异步回调 / 重试" + B->>B: "写入 Page Cache" + Note over B: "同步刷盘 / 异步刷盘" + B->>B: "主从同步复制" + Note over B: "同步复制 / 异步复制" + B-->>P: "ACK 确认" + C->>B: "拉取消息" + B-->>C: "返回消息" + C->>C: "处理业务逻辑" + Note over C: "手动 ACK / 自动 ACK" + C-->>B: "ACK 确认消费完成" + B->>B: "更新消费偏移量" +``` + +整个链路中,每一跳的 ACK 都可能因为网络或进程故障而丢失。这就是为什么端到端的 Exactly-Once 极难实现——你需要在每个环节都做好容错。 + +### Go 代码:手动 ACK 的消费者示例 + +```go +func consumeWithManualAck(consumer sarama.ConsumerGroup) error { + handler := &consumerGroupHandler{} + + // 消费者组自动管理分区分配和 Rebalance + for { + if err := consumer.Consume(context.Background(), []string{"order-events"}, handler); err != nil { + return err + } + } +} + +// consumerGroupHandler 实现 sarama.ConsumerGroupHandler 接口 +type consumerGroupHandler struct{} + +func (h *consumerGroupHandler) Setup(_ sarama.ConsumerGroupSession) error { return nil } +func (h *consumerGroupHandler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil } + +func (h *consumerGroupHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error { + for msg := range claim.Messages() { + // 先处理业务逻辑 + if err := processOrder(msg.Value); err != nil { + log.Printf("process failed: %v, will retry", err) + continue // 不 ACK,消息会被重新投递 + } + + // 业务处理成功后再手动确认 + sess.MarkMessage(msg, "") + } + return nil +} +``` + +这段代码的核心逻辑是:**先消费,再确认**。如果 `processOrder` 返回错误,我们选择不调用 `MarkMessage`,这样消息不会被标记为已消费,下次拉取时会再次投递。这就是 At-Least-Once 的典型实现——宁可重复处理,也不能丢失消息。 + +## 关联笔记 + +- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]] +- [[05-可靠性保障/14-MQ-消息幂等性|MQ 消息幂等性]] +- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] +- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] +- [[07-主流MQ对比/22-Kafka|Kafka]] diff --git a/hhs/MQ/05-可靠性保障/13-MQ-Exactly-Once-语义.md b/hhs/MQ/05-可靠性保障/13-MQ-Exactly-Once-语义.md new file mode 100644 index 0000000..697ba20 --- /dev/null +++ b/hhs/MQ/05-可靠性保障/13-MQ-Exactly-Once-语义.md @@ -0,0 +1,178 @@ +--- +tags: [MQ, Exactly-Once, 幂等, 事务, Kafka] +create time: 2026-05-24 19:52 +--- + +# MQ Exactly-Once 语义 + +## 概述 + +Exactly-Once 是消息投递语义中最理想也最难实现的一种。本文从"为什么难"出发,拆解 Producer、Broker、Consumer 三个环节各自的 Exactly-Once 机制,重点剖析 Kafka 的幂等 Producer 和事务 Producer 实现原理,最后解释为什么端到端的 Exactly-Once 严格意义上不可能实现。 + +## 正文 + +### Exactly-Once 为什么难 + +要理解 Exactly-Once 的难度,先看三个分布式系统的"铁律": + +1. **网络不可靠**:消息可能丢失、延迟、乱序。Producer 发送后收不到 ACK,不知道 Broker 到底有没有收到。 +2. **进程可能宕机**:Consumer 处理完消息但还没来得及 ACK,崩溃重启后消息被重新投递。 +3. **没有全局时钟**:分布式系统中无法通过时间戳判断消息是否重复。 + +这三条合在一起意味着:**任何一跳的确认都可能丢失,而发送方无法区分"对方没收到"和"对方收到了但 ACK 丢了"**。后者会导致重试,重试导致重复。 + +> [!question] +> 如果网络永远可靠、进程永不宕机,还需要 Exactly-Once 吗? + +不需要。Exactly-Once 的所有复杂性都来自于对故障的容忍。在理想环境下,At-Most-Once 就够了。但现实是分布式系统必须面对故障,所以我们才需要在各个层面做额外的工作。 + +### 端到端 vs 单环节 Exactly-Once + +这是一个非常重要的区分: + +- **单环节 Exactly-Once**:在某一个环节(如 Producer→Broker)保证消息不丢不重。这是 MQ 可以做到的。 +- **端到端 Exactly-Once**:从 Producer 发送到 Consumer 处理完成,整条链路保证恰好一次。这几乎不可能由 MQ 单独实现。 + +为什么端到端做不到?因为 Consumer 处理消息涉及外部系统(数据库、API、文件系统等),MQ 无法控制这些外部操作的原子性。消息确认和业务处理是两个独立的操作,无法在一个事务中完成。 + +所以实践中常说的"Exactly-Once",通常是:**MQ 保证 Producer→Broker 不重复 + Consumer 端业务幂等** = 业务层面的效果等价于 Exactly-Once。 + +### Producer 端:幂等与事务 + +#### 幂等 Producer + +Kafka 0.11 引入了幂等 Producer,核心机制是 **PID(Producer ID)+ SequenceNumber**。 + +每个 Producer 实例启动时会被分配一个唯一的 PID。每条消息携带一个单调递增的 SequenceNumber。Broker 端为每个 `` 维护一个最近收到的 SequenceNumber。如果收到的消息 SequenceNumber <= 已知最大值,说明是重复消息,直接丢弃。 + +```go +// Kafka Producer 开启幂等模式(sarama 配置) +config := sarama.NewConfig() +config.Producer.Idempotent = true // 开启幂等 +config.Net.MaxOpenRequests = 1 // 幂等要求同一时刻只有一个未确认请求 +config.Producer.RequiredAcks = sarama.WaitForAll // 必须所有 ISR 副本确认 +config.Producer.Return.Successes = true + +producer, err := sarama.NewSyncProducer(brokers, config) +``` + +幂等 Producer 解决的是**单分区、单会话**内的重复问题。Producer 重启后 PID 会变,跨会话的重复无法处理。跨分区的原子写入也需要更高级的机制。 + +#### 事务 Producer + +事务 Producer 在幂等 Producer 的基础上,增加了**跨分区原子写入**的能力。核心思路: + +1. Producer 开启事务,获取一个 Transaction ID。 +2. 向多个 Topic/Partition 发送消息,这些消息暂时对外不可见(标记为"未提交")。 +3. Producer 发送 Commit 标记,Broker 将所有未提交的消息一次性对外可见。 +4. 如果 Producer 在 Commit 前崩溃,Broker 会根据 Transaction ID 回滚未完成的事务。 + +> [!question] +> 事务 Producer 的 Transaction ID 和 PID 有什么区别?为什么需要两个标识? + +PID 是 Broker 分配的,Producer 重启后会变,用于幂等去重。Transaction ID 是用户配置的,跨会话保持不变,用于标识"同一个业务事务"。当一个 Producer 以相同的 Transaction ID 重启时,Broker 会主动终止上一个未完成的事务(通过 Epoch 机制),避免僵尸事务。 + +### Broker 端:去重与事务日志 + +Broker 在 Exactly-Once 中扮演关键角色: + +**去重机制**:基于 PID + SequenceNumber 的幂等去重,确保同一条消息不会被写入两次。Broker 为每个 `` 维护状态,定期清理过期数据。 + +**事务日志**:Kafka 内部有一个特殊的 Topic `__transaction_state`,记录所有事务的状态(Prepare、Commit、Abort)。即使 Broker 重启,也能从事务日志恢复事务状态,保证事务的持久性。 + +### Consumer 端:为什么只能靠业务幂等 + +Consumer 端的 Exactly-Once 面临的根本问题是:**消费和确认是两个独立操作,无法原子化**。 + +考虑这个场景:Consumer 拉取消息 → 写入数据库 → 提交 Offset。如果在"写入数据库"和"提交 Offset"之间崩溃,重启后会重复消费。 + +Kafka 提供了 **Consumer Offset 事务化** 的能力——Consumer 可以将 Offset 提交和业务写入放在同一个事务中(需要目标数据库支持事务)。但这要求: + +1. 业务系统的目标存储支持事务。 +2. Consumer 的消费逻辑和 Offset 提交在同一个事务中完成。 + +大多数场景下,这个条件不满足。所以最务实的方案是:**Consumer 端做业务幂等**。无论消息被投递几次,业务处理的结果都一样。 + +### Kafka Exactly-Once 实现详解 + +```mermaid +flowchart TD + A["Producer 启动"] --> B["获取 PID"] + B --> C["开启事务"] + C --> D["发送消息到多个 Partition"] + D --> E["Broker 写入消息"] + E --> F{"消息是否重复?"} + F -->|"是"| G["Broker 丢弃重复消息"] + F -->|"否"| H["写入 Partition Log"] + H --> I["发送 Commit 标记"] + I --> J["Broker 写入事务日志"] + J --> K["消息对外可见"] + K --> L["Consumer 拉取消息"] + L --> M["处理业务逻辑"] + M --> N["业务幂等校验"] + N --> O{"是否已处理过?"} + O -->|"是"| P["跳过,提交 Offset"] + O -->|"否"| Q["执行业务操作"] + Q --> P +``` + +这个流程展示了 Kafka 的 Exactly-Once 如何在各环节协同工作:Producer 端通过幂等 + 事务保证不重复写入,Broker 端通过去重 + 事务日志保证存储层一致性,Consumer 端通过业务幂等保证最终效果。 + +### Go 代码:Kafka 事务性生产者 + +```go +func transactionalProducer() error { + config := sarama.NewConfig() + config.Producer.Idempotent = true + config.Producer.Transaction.ID = "order-service-tx-001" // Transaction ID + config.Producer.RequiredAcks = sarama.WaitForAll + config.Net.MaxOpenRequests = 1 + + producer, err := sarama.NewSyncProducer(brokers, config) + if err != nil { + return err + } + defer producer.Close() + + // 开启事务 + if err := producer.BeginTxn(); err != nil { + return fmt.Errorf("begin txn: %w", err) + } + + // 在事务中发送多条消息(原子操作) + msgs := []*sarama.ProducerMessage{ + {Topic: "order-events", Value: sarama.StringEncoder(`{"order_id": "1001"}`)}, + {Topic: "inventory-events", Value: sarama.StringEncoder(`{"sku": "A001", "delta": -1}`)}, + } + + for _, msg := range msgs { + if _, _, err := producer.SendMessage(msg); err != nil { + // 发送失败,回滚整个事务 + _ = producer.AbortTxn() + return fmt.Errorf("send failed, txn aborted: %w", err) + } + } + + // 所有消息发送成功,提交事务 + if err := producer.CommitTxn(); err != nil { + return fmt.Errorf("commit txn: %w", err) + } + + return nil +} +``` + +这段代码的关键在于:`BeginTxn` 和 `CommitTxn` 之间的所有消息要么全部可见,要么全部不可见。如果中间任何一条消息发送失败,`AbortTxn` 会回滚整个事务。这保证了跨 Topic/Partition 的原子写入。 + +> [!question] +> 事务 Producer 的性能比普通 Producer 差多少?在什么场景下值得使用? + +事务 Producer 的性能开销主要来自:事务协调器的额外网络往返、事务日志的写入、未提交消息的暂存。通常吞吐会下降 10%-30%。如果你的业务需要跨 Topic/Partition 的原子写入(如同时更新订单和库存),值得使用。如果只是单 Partition 写入,幂等 Producer 就够了。 + +## 关联笔记 + +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[05-可靠性保障/14-MQ-消息幂等性|MQ 消息幂等性]] +- [[06-高级特性/18-MQ-事务消息|MQ 事务消息]] +- [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]] +- [[07-主流MQ对比/22-Kafka|Kafka]] diff --git a/hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md b/hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md new file mode 100644 index 0000000..6c62675 --- /dev/null +++ b/hhs/MQ/05-可靠性保障/14-MQ-消息幂等性.md @@ -0,0 +1,160 @@ +--- +tags: [MQ, 幂等性, 去重, 分布式系统] +create time: 2026-05-24 19:52 +--- + +# MQ 消息幂等性 + +## 概述 + +消息重复是分布式系统的常态,而不是异常。无论是网络超时重试、Consumer Rebalance 还是 Producer 重试,都可能产生重复消息。幂等性保证"执行多次与执行一次效果相同",是处理重复消息的核心手段。本文介绍四种常见的幂等实现方案,并给出适用场景对比。 + +## 正文 + +### 为什么会产生重复消息 + +在讨论幂等方案之前,先搞清楚重复消息的来源。知己知彼,才能对症下药。 + +**网络超时重试**:Producer 发送消息后等待 ACK 超时,不确定 Broker 是否收到,于是重试。如果 Broker 其实已经收到了第一条,重试就会产生重复。这是最常见的重复来源。 + +**Consumer Rebalance**:Consumer Group 发生 Rebalance 时(如消费者加入或退出),分区会被重新分配。旧 Consumer 可能已经拉取了消息但还没来得及提交 Offset,新 Consumer 又会重新拉取同一批消息。 + +**Producer 重试**:Broker 返回一个可重试的错误(如 NotLeaderForPartition),Producer 自动重试发送。如果上一次请求其实已经成功写入,重试就会重复。 + +> [!question] +> 能不能通过"先查询再处理"的方式避免重复?比如先查数据库,如果订单已存在就跳过? + +这种方案存在经典的 **TOCTOU(Time of Check to Time of Use)** 问题:查询时订单不存在,但在你插入之前,另一个重复消息刚好完成了插入。两个消费者都认为订单不存在,都执行了插入操作。所以"先查询再处理"不能保证幂等,必须借助原子性的去重机制。 + +### 幂等的定义 + +幂等(Idempotent)的数学定义是:**f(f(x)) = f(x)**。应用到消息处理中,意味着同一条消息被消费一次和被消费 N 次的结果完全一样。 + +注意幂等不等于"不处理"。幂等是"处理了但效果只生效一次"。比如扣减库存,幂等的做法是记录已处理的消息 ID,重复消息直接跳过扣减逻辑。 + +### 方案一:唯一 ID + 去重表 + +最直观的方案:每条消息携带一个全局唯一的 MessageID,消费前先查去重表,如果已存在则跳过。 + +**Redis Set 实现**:将 MessageID 写入 Redis Set,利用 `SADD` 的原子性实现"检查 + 写入"一步完成。 + +**数据库唯一索引**:在去重表上对 MessageID 建唯一索引,插入重复记录时会触发唯一约束冲突,直接跳过。 + +两种实现各有优劣:Redis 性能高但需要处理过期策略;数据库可靠但吞吐受限于磁盘 I/O。 + +```go +// 基于 Redis 的消息去重实现 +func processWithDedup(rdb *redis.Client, msg *Message) error { + dedupKey := fmt.Sprintf("mq:dedup:%s", msg.ID) + + // SADD 原子操作:如果 key 不存在则添加并返回 1,已存在则返回 0 + added, err := rdb.SAdd(context.Background(), dedupKey, "1").Result() + if err != nil { + return fmt.Errorf("redis sadd: %w", err) + } + + if added == 0 { + // 已经处理过,直接跳过 + log.Printf("duplicate message %s, skipped", msg.ID) + return nil + } + + // 设置过期时间,避免 Redis 内存无限增长 + rdb.Expire(context.Background(), dedupKey, 24*time.Hour) + + // 执行业务逻辑 + if err := processOrder(msg); err != nil { + // 业务处理失败,删除去重标记,允许重试 + rdb.Del(context.Background(), dedupKey) + return err + } + + return nil +} +``` + +这段代码利用了 Redis `SADD` 的原子性,把"检查是否重复"和"标记已处理"合成一个操作,避免了 TOCTOU 问题。注意业务处理失败时要删除去重标记,否则消息就永远无法被重试了。 + +### 方案二:乐观锁(版本号控制) + +适用于"更新已有数据"的场景。每条记录带一个版本号,更新时带上版本号条件: + +```sql +UPDATE inventory SET stock = stock - 1, version = version + 1 +WHERE sku = 'A001' AND version = 5; +``` + +如果版本号不匹配(说明已经被其他消息更新过),`affected_rows` 返回 0,业务层判定为重复操作。 + +这种方案不需要额外的去重表,利用了数据库自身的并发控制能力。适合库存扣减、账户余额变更等场景。 + +> [!question] +> 乐观锁方案能处理"插入"类操作吗?比如创建订单? + +不太适合。乐观锁天然适合"更新"场景,因为更新时已有记录和版本号。对于"插入"类操作,还是用唯一 ID + 去重表更直接。当然你也可以用 `INSERT ... ON CONFLICT DO NOTHING`(PostgreSQL)来实现插入幂等。 + +### 方案三:Token 机制 + +Token 机制的核心思路是:**先获取令牌,再执行操作**。 + +1. Consumer 处理消息前,先向 Token 服务申请一个全局唯一的 Token。 +2. 消费者带着 Token 执行业务操作。 +3. 业务服务端校验 Token 是否有效,有效则执行并销毁 Token,无效则拒绝。 + +Token 服务保证每个 Token 只能使用一次,天然实现了幂等。这种方案适合跨系统的幂等控制,比如下游 API 的幂等调用。 + +缺点是多了一次网络调用获取 Token,且 Token 服务本身需要高可用。 + +### 方案四:状态机约束 + +很多业务天然具有状态流转特性,利用状态机的单向性可以实现幂等。 + +比如订单状态:`待支付 → 已支付 → 已发货 → 已完成`。消费"支付成功"消息时,执行: + +```sql +UPDATE orders SET status = '已支付' +WHERE order_id = '1001' AND status = '待支付'; +``` + +如果订单已经是"已支付"状态,`affected_rows` 为 0,自然幂等。不需要额外的去重表,业务逻辑和幂等校验融为一体。 + +```mermaid +flowchart TD + A["收到消息"] --> B["提取消息 MessageID"] + B --> C["查询去重表"] + C --> D{"是否已处理?"} + D -->|"是"| E["跳过,记录日志"] + D -->|"否"| F["执行业务逻辑"] + F --> G{"业务执行成功?"} + G -->|"是"| H["写入去重表"] + H --> I["提交消费 Offset"] + G -->|"否"| J["不写去重表"] + J --> K["等待重试"] +``` + +### 各方案适用场景对比 + +| 方案 | 实现复杂度 | 性能影响 | 适用场景 | 局限性 | +|------|-----------|---------|----------|--------| +| 唯一 ID + 去重表 | 低 | 低(Redis)/ 中(DB) | 通用场景,最常用 | 需要额外存储,需处理过期清理 | +| 乐观锁 | 低 | 低 | 更新类操作(库存、余额) | 不适合插入操作,存在 ABA 问题 | +| Token 机制 | 高 | 中(额外网络调用) | 跨系统 API 幂等 | Token 服务需高可用 | +| 状态机约束 | 低 | 低 | 有明确状态流转的业务 | 业务模型必须支持状态机 | + +实际项目中,这几种方案往往会组合使用。比如:订单系统用状态机约束核心流转,同时用唯一 ID + 去重表作为兜底。没有银弹,关键是理解每种方案的适用边界。 + +> [!question] +> 如果消息体本身很大,用 Redis Set 存 MessageID 会不会有内存问题?怎么优化? + +Redis Set 存的是 MessageID(通常是 36 字符的 UUID),不是消息体本身,所以内存占用可控。但如果消息量极大(每天数亿条),可以考虑以下优化: + +1. **Bloom Filter**:用极小的内存(每元素几个 bit)判断消息"可能存在"或"一定不存在"。存在误判(false positive),但不会漏判。适合作为第一层快速过滤。 +2. **分片 + 过期**:按日期分 Key(如 `mq:dedup:2026-05-24:{msgID}`),过期后自动清理。 +3. **数据库归档**:Redis 只保留近期去重数据,历史数据归档到数据库。去重窗口通常只需要几天到一周。 + +## 关联笔记 + +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]] +- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] +- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] diff --git a/hhs/MQ/05-可靠性保障/15-MQ-顺序性保障.md b/hhs/MQ/05-可靠性保障/15-MQ-顺序性保障.md new file mode 100644 index 0000000..fd10ff1 --- /dev/null +++ b/hhs/MQ/05-可靠性保障/15-MQ-顺序性保障.md @@ -0,0 +1,181 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 顺序性保障 + +## 概述 + +消息队列中的顺序性是分布式系统设计的经典难题。本文深入分析消息乱序的根源,对比全局有序与分区有序的适用场景,并详解 Kafka、RocketMQ 等主流 MQ 的顺序保障机制。 + +## 正文 + +### 为什么消息会乱序 + +在分布式消息系统中,消息乱序几乎是个"天然"现象,根源在于三个层面: + +**多 Partition 并行**:消息被分散到多个分区,每个分区独立消费,跨分区的顺序自然无法保证。 + +**多 Consumer 并发**:同一消费组内多个消费者并行拉取消息,处理速度不同,先收到的消息可能后处理完。 + +**重试机制干扰**:消费失败后重试,重试的消息可能在后续消息处理完之后才被再次消费。 + +> [!question] +> 既然乱序几乎不可避免,那什么场景下我们才真正需要严格有序?付出的代价值得吗? + +### 全局有序 vs 分区有序 + +**全局有序**要求所有消息严格按照生产顺序被消费,实现代价极大——通常只能用单分区 + 单消费者,完全牺牲了并行能力。 + +**分区有序**只要求同一业务维度(如同一用户、同一订单)的消息有序,不同维度之间允许乱序。这是绝大多数业务场景的合理选择。 + +| 维度 | 全局有序 | 分区有序 | +|------|---------|---------| +| 吞吐量 | 极低 | 高 | +| 实现复杂度 | 简单但受限 | 中等 | +| 适用场景 | 金融流水号等 | 订单状态变更等 | + +### 分区有序的实现 + +核心思路:**相同业务 Key 路由到同一 Partition**。 + +生产者根据业务 Key(如用户 ID、订单 ID)计算哈希值,对分区数取模,确保同一 Key 的消息总是发到同一个分区。再配合单分区内单消费者(或顺序消费模式),就能保证该维度的消息有序。 + +```mermaid +flowchart LR + P["Producer"] + H["Hash by Key"] + P0["Partition 0"] + P1["Partition 1"] + P2["Partition 2"] + C0["Consumer 0"] + C1["Consumer 1"] + C2["Consumer 2"] + + P --> H + H -->|"Key = OrderA"| P0 + H -->|"Key = OrderB"| P1 + H -->|"Key = OrderC"| P2 + P0 --> C0 + P1 --> C1 + P2 --> C2 +``` + +### Kafka 的顺序保障 + +Kafka 的顺序性建立在两个基础之上: + +1. **Partition 内有序**:单个 Partition 内的消息严格按写入顺序分配偏移量,消费时也按偏移量顺序拉取。 +2. **单 Partition 单 Consumer**:同一消费组内,一个 Partition 只能被一个 Consumer 消费。 + +要实现分区有序,生产者需要指定分区策略: + +```go +// 按订单 ID 路由到固定 Partition +func partitionByOrderID(key []byte, numPartitions int32) int32 { + hash := fnv.New32a() + hash.Write(key) + return int32(hash.Sum32()) % numPartitions +} + +// 生产者发送时指定 Key +msg := &sarama.ProducerMessage{ + Topic: "order-events", + Key: sarama.StringEncoder(orderID), // 相同 orderID 路由到同一 Partition + Value: sarama.StringEncoder(payload), +} +``` + +这段代码利用 FNV 哈希将订单 ID 映射到固定分区,确保同一订单的所有事件(创建、支付、发货)都进入同一个 Partition,从而保证消费顺序。 + +> [!question] +> Kafka 的 Partition 数量在创建 Topic 时确定,如果后期需要扩容 Partition,已经按 Key 路由的消息会怎样? + +### RocketMQ 的顺序保障 + +RocketMQ 提供了更显式的顺序消费 API: + +**生产者**通过 `MessageQueueSelector` 自定义路由逻辑: + +```go +// 自定义选择器:按订单 ID 选择队列 +selector := func(mqs []MessageQueue, msg Message, arg interface{}) MessageQueue { + orderID := arg.(string) + hash := hashCode(orderID) + index := hash % len(mqs) + return mqs[index] +} + +// 发送顺序消息 +err := producer.SendOneWay(ctx, msg, selector, orderID) +``` + +**消费者**启用顺序消费模式: + +```go +// 顺序消费模式:同一队列串行消费 +consumer, _ := rocketmq.NewPushConsumer( + consumer.WithGroupName("order-group"), + consumer.WithConsumeOrderly(true), // 关键:启用顺序消费 +) +``` + +RocketMQ 的顺序消费模式保证同一 MessageQueue 内的消息严格按顺序处理,配合 `MessageQueueSelector` 实现分区有序。 + +### 多消费者场景下的顺序挑战 + +当消费逻辑需要并行处理时,保序变得更加棘手。常见方案是在消费者内部引入**内存队列 + 分发器**: + +```mermaid +flowchart TD + MQ["MessageQueue"] + D["Dispatcher"] + Q1["Memory Queue - Key A"] + Q2["Memory Queue - Key B"] + W1["Worker Goroutine A"] + W2["Worker Goroutine B"] + + MQ --> D + D -->|"Key = A"| Q1 + D -->|"Key = B"| Q2 + Q1 --> W1 + Q2 --> W2 +``` + +消费者拉取到消息后,根据业务 Key 分发到不同的内存队列,每个队列由独立的 Worker 串行处理。这样既保证了同一 Key 的消息有序,又能充分利用多核并行。 + +Go 代码实现思路: + +```go +type OrderedConsumer struct { + queues map[string]chan Message // Key -> 内存队列 +} + +func (c *OrderedConsumer) Dispatch(msg Message) { + key := msg.BusinessKey + ch, ok := c.queues[key] + if !ok { + ch = make(chan Message, 100) + c.queues[key] = ch + go c.processLoop(ch) // 为新 Key 启动独立处理协程 + } + ch <- msg +} + +func (c *OrderedConsumer) processLoop(ch chan Message) { + for msg := range ch { + // 串行处理,保证顺序 + process(msg) + } +} +``` + +> [!question] +> 如果某个 Key 的消息量突然暴增,会导致对应的内存队列积压。你会如何设计背压机制? + +## 关联笔记 + +- [[14-MQ-消息可靠性]] +- [[16-MQ-死信队列与消息回溯]] diff --git a/hhs/MQ/05-可靠性保障/16-MQ-死信队列与消息回溯.md b/hhs/MQ/05-可靠性保障/16-MQ-死信队列与消息回溯.md new file mode 100644 index 0000000..266b9ee --- /dev/null +++ b/hhs/MQ/05-可靠性保障/16-MQ-死信队列与消息回溯.md @@ -0,0 +1,140 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# 死信队列与消息回溯 + +## 概述 + +死信队列(DLQ)是消息消费失败后的"兜底方案",消息回溯则是重新处理历史数据的能力。本文详解死信队列的产生机制、各主流 MQ 的实现差异,以及消息回溯的实践策略。 + +## 正文 + +### 死信队列的产生原因 + +消息进入死信队列通常有三种触发条件: + +**消费失败超过最大重试次数**:这是最常见的情况。消费者处理消息时抛出异常,经过 N 次重试后仍然失败,消息被转移到死信队列。 + +**消息过期(TTL)**:消息在队列中等待时间超过设定的 TTL(Time To Live),始终未被消费,成为"死信"。 + +**队列容量满**:队列达到最大长度限制,新消息无法入队,部分消息可能被丢弃或转入死信队列。 + +> [!question] +> 消费失败和消费超时是同一种情况吗?在设计重试策略时需要区别对待吗? + +### 死信消息的特征与处理策略 + +死信消息有几个显著特征:**携带原始元数据**(生产时间、重试次数、失败原因)、**语义已不确定**(不知道业务状态是否已被部分修改)、**需要人工判断**。 + +处理策略通常有三种: + +1. **人工介入排查**:最常见的做法。运维或开发人员分析失败原因,修复问题后手动重发。 +2. **自动告警 + 限流处理**:当死信消息数量超过阈值时触发告警,同时对死信队列的消费进行限流,避免雪崩。 +3. **转存到其他系统**:将死信消息写入数据库或 ES,便于后续分析和批量重处理。 + +```mermaid +flowchart TD + P["Producer"] + Q["Normal Queue"] + C["Consumer"] + DLQ["Dead Letter Queue"] + R["Retry"] + A["Alert System"] + DB["Database / ES"] + H["Human Intervention"] + + P --> Q + Q --> C + C -->|"Success"| ACK["ACK"] + C -->|"Fail"| R + R -->|"Retry < Max"| C + R -->|"Retry >= Max"| DLQ + DLQ --> A + DLQ --> DB + DLQ --> H +``` + +### 各主流 MQ 的 DLQ 实现 + +**RabbitMQ**:通过 `x-dead-letter-exchange` 和 `x-dead-letter-routing-key` 属性配置。当消息被拒绝(reject/nack)且 `requeue=false` 时,自动路由到指定的死信交换机。 + +```go +// 声明带死信配置的队列 +args := amqp.Table{ + "x-dead-letter-exchange": "dlx-exchange", + "x-dead-letter-routing-key": "dlq-routing-key", + "x-message-ttl": 30000, // 30 秒 TTL +} +ch.QueueDeclare("order-queue", true, false, false, false, args) +``` + +**RocketMQ**:内置 `%DLQ%+ConsumerGroup` 命名的死信 Topic。消费失败超过最大重试次数(默认 16 次)后自动进入,无需额外配置。 + +**Kafka**:没有原生 DLQ 机制,需要自行实现。常见的做法是在消费者 catch 异常后,将失败消息发送到专门的死信 Topic。 + +### 消息重试机制 + +合理的重试策略是减少死信消息的关键。核心原则是**间隔递增 + 次数上限**: + +```go +func retryDelay(attempt int) time.Duration { + // 指数退避:1s, 2s, 4s, 8s... + base := time.Second + delay := base * time.Duration(1< 5*time.Minute { + delay = 5 * time.Minute // 设置上限 + } + return delay +} +``` + +为什么用指数退避?如果下游服务暂时不可用,立即重试只会加重负担。递增间隔给下游恢复的时间,也能减少无效的重试次数。 + +> [!question] +> 重试间隔设多长合适?太短可能压垮下游,太长又影响消息时效性。你会怎么平衡? + +### 消息回溯 + +消息回溯是指将消费位点回退到某个历史位置,重新消费已处理过的消息。典型场景包括:**业务逻辑 Bug 修复后需要重新处理**、**数据丢失后从消息中恢复**、**新上线的消费者需要历史数据初始化**。 + +**按时间回溯**:Kafka 支持 `offsetsForTimes` API,可以找到指定时间戳对应的偏移量: + +```go +// 回溯到 2024-01-01 00:00:00 +targetTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local) +offset, err := client.GetOffset(topic, partition, targetTime.UnixMilli()) +if err == nil { + // 从该偏移量开始重新消费 + consumer.Seek(topic, partition, offset) +} +``` + +**按偏移量回溯**:直接指定要回退到的偏移量位置,精确但需要提前记录关键节点的偏移量。 + +**重放消费**:创建新的消费组,从头消费整个 Topic 的消息。适用于数据恢复或新消费者冷启动。 + +```mermaid +flowchart LR + T["Timeline"] + N["Now"] + B["Bug Introduced"] + F["Bug Fixed"] + R["Replay From B"] + + T --> B + B --> F + F --> N + F -.->|"Seek to offset"| R + R -->|"Re-consume"| N +``` + +> [!question] +> 如果消息回溯时发现部分消息已经被下游系统消费并产生了副作用(比如扣款),你会如何处理这种"幂等性"问题? + +## 关联笔记 + +- [[14-MQ-消息可靠性]] +- [[15-MQ-顺序性保障]] diff --git a/hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md b/hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md new file mode 100644 index 0000000..f0729dc --- /dev/null +++ b/hhs/MQ/06-高级特性/17-MQ-延迟消息与定时消息.md @@ -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 存储设计]] diff --git a/hhs/MQ/06-高级特性/18-MQ-事务消息.md b/hhs/MQ/06-高级特性/18-MQ-事务消息.md new file mode 100644 index 0000000..052436a --- /dev/null +++ b/hhs/MQ/06-高级特性/18-MQ-事务消息.md @@ -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]] diff --git a/hhs/MQ/06-高级特性/19-MQ-消息过滤与路由.md b/hhs/MQ/06-高级特性/19-MQ-消息过滤与路由.md new file mode 100644 index 0000000..fa366a1 --- /dev/null +++ b/hhs/MQ/06-高级特性/19-MQ-消息过滤与路由.md @@ -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]] diff --git a/hhs/MQ/06-高级特性/20-MQ-Schema-管理与演进.md b/hhs/MQ/06-高级特性/20-MQ-Schema-管理与演进.md new file mode 100644 index 0000000..e3f5d9e --- /dev/null +++ b/hhs/MQ/06-高级特性/20-MQ-Schema-管理与演进.md @@ -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 最佳实践]] diff --git a/hhs/MQ/06-高级特性/21-MQ-消息压缩与批处理.md b/hhs/MQ/06-高级特性/21-MQ-消息压缩与批处理.md new file mode 100644 index 0000000..1be8f79 --- /dev/null +++ b/hhs/MQ/06-高级特性/21-MQ-消息压缩与批处理.md @@ -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 最佳实践]] diff --git a/hhs/MQ/07-主流MQ对比/22-Kafka.md b/hhs/MQ/07-主流MQ对比/22-Kafka.md new file mode 100644 index 0000000..9356115 --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/22-Kafka.md @@ -0,0 +1,223 @@ +--- +tags: [MQ, Kafka, 消息队列, 分布式系统] +create time: 2026-05-24 19:52 +--- + +# Kafka + +## 概述 + +Kafka 是 LinkedIn 开源的分布式流处理平台,以极高的吞吐量和水平扩展能力著称。它采用分区日志(Partition Log)作为核心存储模型,配合副本机制和消费者组协议,成为大数据和事件驱动架构的事实标准。 + +## 正文 + +### 1. 架构总览 + +Kafka 集群由多个 Broker 组成,消息按 Topic 逻辑分类,每个 Topic 被划分为若干 Partition 分散在不同 Broker 上。每个 Partition 有多个 Replica(副本),其中一个为 Leader 负责读写,其余 Follower 通过拉取同步数据。 + +早期版本依赖 ZooKeeper 管理元数据(Broker 注册、Topic 配置、Leader 选举等),从 Kafka 3.3 开始正式推出 KRaft 模式,用内置的 Raft 共识协议取代 ZooKeeper,简化了运维和启动流程。 + +```mermaid +graph TB + subgraph Cluster["Kafka 集群"] + B1["Broker 1
Leader P0, Follower P1"] + B2["Broker 2
Leader P1, Follower P2"] + B3["Broker 3
Leader P2, Follower P0"] + end + + Producer["Producer"] --> B1 + Producer --> B2 + Producer --> B3 + B1 --> ConsumerGroup["Consumer Group"] + B2 --> ConsumerGroup + B3 --> ConsumerGroup + + B1 <-.-> B2 + B2 <-.-> B3 + B3 <-.-> B1 + + Controller["KRaft Controller
元数据管理"] --> B1 + Controller --> B2 + Controller --> B3 + + style Cluster fill:#f9f9f9,color:#333 + style Controller fill:#4A90D9,color:#fff +``` + +### 2. 高吞吐原理 + +Kafka 单集群可轻松达到百万级 TPS,核心秘诀在于: + +- **顺序写磁盘**:Partition 是 append-only 日志,写入永远追加到文件末尾,避免了随机 I/O。顺序写磁盘的吞吐甚至可以媲美内存随机写(600MB/s vs 200MB/s 这种量级)。 +- **零拷贝(sendfile)**:Consumer 拉取消息时,Kafka 直接通过 Linux `sendfile` 系统调用将 Page Cache 中的数据发送到网卡,绕过用户态缓冲区,减少两次上下文切换和数据拷贝。 +- **批量发送**:Producer 端将多条消息攒成一个批次(batch)再发送,减少网络 RTT。`linger.ms` 和 `batch.size` 控制攒批策略。 +- **消息压缩**:支持 Snappy、LZ4、Zstd 等压缩算法,在 Producer 端压缩、Consumer 端解压,显著减少网络带宽和磁盘占用。 +- **分区并行**:多个 Partition 可以被不同的 Consumer 并行消费,也可以被不同的 Producer 并行写入,天然支持水平扩展。 + +> [!question] 思考:零拷贝要求数据不能被修改,那 Kafka 对消息做压缩怎么办? +> 压缩发生在 Producer 端,写入 Partition 的数据已经是压缩后的字节流。Broker 存储和传输时保持原样,解压由 Consumer 端完成。所以零拷贝和压缩并不冲突——Broker 不需要解压就能直接 sendfile。 + +### 3. 副本机制 + +Kafka 的数据可靠性靠副本机制保证。每个 Partition 有多个 Replica,分布在不同 Broker 上: + +- **Leader**:处理所有读写请求。 +- **Follower**:被动拉取 Leader 的数据,保持同步。 +- **ISR(In-Sync Replicas)**:与 Leader 保持同步的副本集合。如果 Follower 落后太多(由 `replica.lag.time.max.ms` 控制),会被踢出 ISR。 +- **LEO(Log End Offset)**:每个副本最后一条消息的下一个偏移量。 +- **HW(High Watermark)**:ISR 中最小的 LEO。Consumer 只能读到 HW 之前的消息,保证读到的数据在所有 ISR 副本上都存在。 + +Leader 崩溃时,Controller 从 ISR 中选举新 Leader。如果 `unclean.leader.election.enable=true`,允许从非 ISR 副本中选举(可能丢数据),生产环境应设为 `false`。 + +> [!question] 为什么 HW 要取 ISR 中最小的 LEO,而不是最大或平均值? +> 因为 HW 的意义是"所有同步副本都确认收到的位置"。取最小值才能保证:即使 Leader 挂了,任何一个 ISR 副本接替后,Consumer 已读到的数据都不会丢失。 + +### 4. Producer 分区策略 + +Producer 发送消息时需要决定发往哪个 Partition: + +| 策略 | 说明 | +|------|------| +| **轮询(Round-Robin)** | 默认策略,消息均匀分配到各 Partition,保证最大吞吐 | +| **Key 哈希** | 指定 Key 时,对 Key 做 hash 取模确定 Partition。相同 Key 的消息总是发到同一 Partition,保证局部有序 | +| **自定义** | 实现 `Partitioner` 接口,按业务逻辑路由(如按用户 ID 分区) | + +**acks 参数**控制 Producer 的可靠性级别: + +| acks | 含义 | 适用场景 | +|------|------|----------| +| `0` | 不等确认,发完即忘 | 日志采集等允许丢失的场景 | +| `1` | Leader 写入成功即返回 | 大多数业务场景,平衡性能与可靠性 | +| `all` | 所有 ISR 副本写入成功才返回 | 金融级可靠性要求 | + +> [!question] 思考题:Kafka 的 acks=all + min.insync.replicas=2 配置为什么能兼顾性能和可靠性? +> +> **提示**:想想如果只有 1 个副本(min.insync.replicas=1),acks=all 等于 acks=1,可靠性没有实质提升。设为 2 意味着至少有两个副本确认,即使 Leader 宕机,数据也不会丢。而 ISR 副本通常是同步延迟极低的(毫秒级),所以对性能影响有限。这个组合的精髓在于:acks=all 保证写入被多个副本确认,min.insync.replicas 保证 ISR 中至少有足够副本,两者配合才真正实现了"不丢数据"。 + +### 5. Consumer 与 Consumer Group + +Consumer Group 是 Kafka 实现"发布-订阅"和"队列"两种语义的核心机制: + +- 同一 Group 内的 Consumer 分担消费(类似队列模式)。 +- 不同 Group 各自独立消费全部消息(类似发布-订阅模式)。 + +**Rebalance**:当 Consumer 加入/离开 Group,或 Topic 分区数变化时,触发分区重新分配。Kafka 引入了 **Cooperative Rebalance**(渐进式再平衡),避免 Stop-the-World 式的全局暂停。 + +**Offset 管理**: +- **自动提交**:`enable.auto.commit=true`,后台定期提交 Offset。简单但可能丢消息(提交后还没处理完就崩溃)或重复消费。 +- **手动提交**:消费完成后显式调用 `commitSync()` 或 `commitAsync()`。更可靠,是生产环境推荐做法。Offset 存储在 `__consumer_offsets` 内部 Topic 中。 + +```go +// 使用 confluent-kafka-go 创建 Producer +package main + +import ( + "fmt" + "github.com/confluentinc/confluent-kafka-go/v2/kafka" +) + +func main() { + // 创建 Producer 实例,指定 Broker 地址 + p, err := kafka.NewProducer(&kafka.ConfigMap{ + "bootstrap.servers": "localhost:9092", + "acks": "all", // 等待所有 ISR 副本确认 + }) + if err != nil { + panic(err) + } + defer p.Close() + + topic := "my-topic" + // 发送消息 + err = p.Produce(&kafka.Message{ + TopicPartition: kafka.TopicPartition{ + Topic: &topic, + Partition: kafka.PartitionAny, // 自动选择分区 + }, + Value: []byte("hello kafka"), + }, nil) + if err != nil { + fmt.Printf("produce failed: %v\n", err) + } + + // 确保所有消息发送完成 + p.Flush(5000) +} +``` + +```go +// 使用 confluent-kafka-go 创建 Consumer +package main + +import ( + "fmt" + "github.com/confluentinc/confluent-kafka-go/v2/kafka" +) + +func main() { + // 创建 Consumer 实例,加入 Consumer Group + c, err := kafka.NewConsumer(&kafka.ConfigMap{ + "bootstrap.servers": "localhost:9092", + "group.id": "my-group", + "auto.offset.reset": "earliest", // 从最早消息开始消费 + "enable.auto.commit": false, // 关闭自动提交,手动控制 + }) + if err != nil { + panic(err) + } + defer c.Close() + + // 订阅 Topic + c.SubscribeTopics([]string{"my-topic"}, nil) + + for { + // 轮询拉取消息,超时 100ms + msg, err := c.ReadMessage(100e6) + if err != nil { + continue // 超时或错误,继续轮询 + } + fmt.Printf("received: %s (partition=%d, offset=%d)\n", + string(msg.Value), msg.TopicPartition.Partition, msg.TopicPartition.Offset) + + // 处理完成后手动提交 Offset + if _, err := c.CommitMessage(msg); err != nil { + fmt.Printf("commit failed: %v\n", err) + } + } +} +``` + +### 6. Kafka 生态 + +Kafka 不只是一个消息队列,而是一个完整的流处理生态: + +| 组件 | 作用 | +|------|------| +| **Kafka Connect** | 数据集成框架,通过 Source/Sink Connector 在 Kafka 与外部系统(MySQL、Elasticsearch、S3 等)之间搬运数据,无需写代码 | +| **Kafka Streams** | 客户端流处理库,直接嵌入应用中,支持窗口、Join、聚合等操作,无需独立的流处理集群 | +| **Schema Registry** | 管理消息 Schema(Avro/Protobuf/JSON Schema),保证生产者和消费者的 Schema 兼容性,防止数据格式不匹配 | +| **ksqlDB** | 用 SQL 语法实时查询和处理 Kafka 流数据,降低流处理门槛 | + +### 7. KRaft 模式 + +Kafka 早期依赖 ZooKeeper 做元数据管理和 Leader 选举,但 ZooKeeper 带来了额外的运维负担(独立集群、脑裂风险、元数据瓶颈)。KRaft(Kafka Raft)模式将元数据管理内置到 Kafka 自身: + +- Controller 节点通过 Raft 协议选举,维护元数据日志。 +- Broker 从 Controller 拉取元数据,不再依赖 ZooKeeper。 +- 启动更快、集群规模上限更高(支持百万级 Partition)、运维更简单。 +- Kafka 3.3 起 KRaft 进入生产就绪状态,4.0 版本已完全移除 ZooKeeper 支持。 + +> [!question] KRaft 用 Raft 管理元数据,那 Kafka 本身的数据副本同步为什么不用 Raft? +> Kafka 的数据副本同步是 Leader-Follower 模型 + ISR 机制,本质是"主从异步复制 + 可配置同步确认"。Raft 更适合小数据量的元数据共识,而 Kafka 的 Partition 日志数据量巨大,ISR 模型通过 HW 机制实现了类似 Raft 的"多数派确认"语义,同时保持了高吞吐的顺序写优势。 + +## 关联笔记 + +- [[01-基础概念/1-MQ-基础概念|MQ 基础概念]] +- [[02-消息模型/3-MQ-消息模型|MQ 消息模型]] +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] +- [[06-高级特性/21-MQ-消息压缩与批处理|MQ 消息压缩与批处理]] +- [[09-流处理与事件驱动/33-MQ-与流处理|MQ 与流处理]] +- [[12-架构与实战/45-MQ-跨集群复制与容灾|MQ 跨集群复制与容灾]] +- [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] diff --git a/hhs/MQ/07-主流MQ对比/23-RabbitMQ.md b/hhs/MQ/07-主流MQ对比/23-RabbitMQ.md new file mode 100644 index 0000000..4b084eb --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/23-RabbitMQ.md @@ -0,0 +1,214 @@ +--- +tags: [MQ, RabbitMQ, 消息队列, AMQP] +create time: 2026-05-24 19:52 +--- + +# RabbitMQ + +## 概述 + +RabbitMQ 是基于 Erlang/OTP 实现的开源消息代理,实现了 AMQP 0-9-1 协议。它以灵活的路由能力、丰富的协议支持和成熟的插件生态著称,是企业级消息中间件的经典选择。 + +## 正文 + +### 1. 架构与 AMQP 协议 + +RabbitMQ 使用 Erlang 语言编写,天然具备高并发和软实时特性。其核心概念围绕 AMQP 0-9-1 协议展开: + +- **Connection**:TCP 长连接,客户端与 Broker 之间的一条物理连接,包含认证和 TLS 握手。 +- **Channel**:在 Connection 上的多路复用虚拟连接。多个 Channel 共享同一个 TCP 连接,避免频繁创建/销毁 TCP 的开销。一个线程对应一个 Channel。 +- **Virtual Host(vhost)**:逻辑隔离单元,类似数据库中的 schema,不同 vhost 的 Exchange 和 Queue 完全隔离。 +- **Exchange**:接收 Producer 发来的消息,根据路由规则分发到 Queue。 +- **Queue**:消息的最终存储位置,Consumer 从 Queue 拉取或被推送消息。 +- **Binding**:连接 Exchange 和 Queue 的规则,定义路由条件。 + +```mermaid +graph LR + Producer["Producer"] -->|"publish"| Exchange["Exchange"] + Exchange -->|"binding rule"| Q1["Queue A"] + Exchange -->|"binding rule"| Q2["Queue B"] + Q1 -->|"consume"| C1["Consumer A"] + Q2 -->|"consume"| C2["Consumer B"] + + style Exchange fill:#4A90D9,color:#fff +``` + +### 2. 四种 Exchange 类型 + +Exchange 的类型决定了消息如何路由到 Queue,这是 RabbitMQ 最灵活的设计: + +**Direct Exchange**:精确匹配。消息的 Routing Key 与 Binding Key 完全一致时才路由。适合点对点定向投递。 + +**Fanout Exchange**:广播模式。忽略 Routing Key,将消息投递到所有绑定的 Queue。适合事件通知、广播场景。 + +**Topic Exchange**:通配符匹配。Routing Key 和 Binding Key 支持 `*`(匹配一个词)和 `#`(匹配零或多个词)。适合按主题分类订阅,如 `order.*.created`。 + +**Headers Exchange**:基于消息 Header 属性匹配(而非 Routing Key)。支持 `x-match=all`(所有条件匹配)和 `x-match=any`(任一条件匹配)。灵活但性能不如前三种。 + +```mermaid +graph TB + P["Producer"] --> EX_D["Direct Exchange
routing_key = order"] + P --> EX_F["Fanout Exchange
广播"] + P --> EX_T["Topic Exchange
order.*.created"] + + EX_D -->|"rk = order"| QA["Queue A"] + EX_F --> QA + EX_F --> QB["Queue B"] + EX_F --> QC["Queue C"] + EX_T -->|"order.pay.created"| QB + EX_T -->|"order.ship.created"| QD["Queue D"] + + style EX_D fill:#4A90D9,color:#fff + style EX_F fill:#7B68EE,color:#fff + style EX_T fill:#F5A623,color:#fff +``` + +> [!question] 生产环境中,什么时候该用 Topic Exchange 而不是 Direct Exchange? +> 当消费者需要按模式订阅一类消息时用 Topic。比如日志系统中,`log.error.*` 可以匹配所有 error 级别的日志,而 Direct 只能精确匹配一个 routing key。如果你的路由规则是固定的、一一对应的,Direct 更简单高效。 + +### 3. 高级特性 + +**TTL(Time-To-Live)**: +- **消息 TTL**:通过 `x-message-ttl` 设置队列级别 TTL,或在发布时通过 `expiration` 属性设置单条消息 TTL。过期消息被丢弃或进入死信队列。 +- **队列 TTL**:通过 `x-expires` 设置,队列在空闲(无消费者、无声明)超过指定时间后自动删除。适合临时队列。 + +**死信队列(DLX, Dead Letter Exchange)**:消息在以下情况会成为"死信":被消费者拒绝(reject/nack 且 requeue=false)、消息 TTL 到期、队列达到最大长度。死信会被路由到配置的 DLX 对应的队列,实现延迟重试、异常消息归档等模式。 + +**延迟队列**:RabbitMQ 原生不支持延迟消息(不像 RocketMQ 有延迟级别)。社区提供了 `rabbitmq_delayed_message_exchange` 插件,消息在 Exchange 中暂存,到期后再投递到目标队列。常用于订单超时取消、定时提醒等场景。 + +> [!question] 死信队列和延迟队列有什么关系? +> 延迟队列的一种经典实现方式就是"利用消息 TTL + 死信队列":将消息发到一个没有消费者的队列并设置 TTL,到期后消息变成死信,被路由到 DLX 绑定的真正消费队列。但这有个缺点——队列头部消息未过期会阻塞后面的消息(因为 RabbitMQ 只检查队头)。延迟消息插件则用定时器解决这个问题。 + +### 4. 集群模式 + +RabbitMQ 支持多种集群模式,可靠性逐步递增: + +**普通集群**:所有节点共享元数据(Exchange、Binding 等),但 Queue 的数据只存在于声明它的那个节点。其他节点收到消息后需要跨节点转发,存在单点风险。 + +**镜像队列(Mirrored Queue)**:在普通集群基础上,将 Queue 数据同步到多个节点。一个 Master + 若干 Slave,所有读写都经过 Master。缺点是:所有操作都由 Master 串行处理,性能受限于 Master 节点;同步方式是"发一份拷贝",网络开销大;Slave 只是热备,不承担读流量。**这就是镜像队列性能差的根本原因——单 Master 瓶颈。** + +**Quorum Queue**:基于 Raft 共识协议的队列类型,是镜像队列的现代替代方案。Leader 负责接收写入,消息被复制到多数派 Follower 后才确认。Follower 可以分担读流量(`x-queue-leader-locator=balanced`),Leader 故障后自动选举新 Leader。**Quorum Queue 的改进在于:Raft 共识比简单的主从复制更可靠,Follower 可以参与读操作,且日志复制有明确的多数派确认语义。** + +> [!question] 思考题:RabbitMQ 的镜像队列为什么性能不好?Quorum Queue 是如何改进的? +> +> **提示**:镜像队列的核心问题是"所有流量都走 Master"——写入要 Master 确认,读取也要 Master 响应,Slave 只是被动同步。当 Master 成为瓶颈时,加 Slave 不能提升吞吐。Quorum Queue 用 Raft 日志复制替代简单的消息拷贝,写入由多数派确认(而非 Master 独占),且支持从 Follower 读取,分散了压力。 + +### 5. RabbitMQ Stream + +Stream 是 RabbitMQ 3.9 引入的全新队列类型,对标 Kafka 的流式存储模型: + +- 消息以日志形式持久化,支持多次回放(不像普通队列消费即删除)。 +- 使用 offset 而非 ACK 来跟踪消费进度,支持从任意位置开始消费。 +- 性能远超传统队列,适合高吞吐的日志/事件流场景。 +- 通过 AMQP 1.0 或专用 Stream 协议访问。 + +Stream 本质上让 RabbitMQ 同时具备了"传统消息队列"和"流处理平台"两种能力。 + +### 6. 插件生态 + +RabbitMQ 的可扩展性通过插件机制实现: + +| 插件 | 作用 | +|------|------| +| **Management UI** | Web 管理界面,监控队列、Exchange、连接,管理策略和权限 | +| **Prometheus 插件** | 暴露 Prometheus 格式的监控指标,配合 Grafana 看板 | +| **Shovel** | 单向消息搬运,将消息从一个 Broker 转发到另一个,适合跨机房同步 | +| **Federation** | 跨集群消息联邦,支持 Exchange/Queue 级别的联邦,比 Shovel 更灵活,支持按需连接 | + +### 7. 消息确认机制 + +RabbitMQ 提供了完整的可靠投递保证: + +**Publisher Confirm(发布确认)**:Producer 将 Channel 设置为 confirm 模式后,Broker 成功将消息写入所有镜像(或 Quorum 多数派)后返回确认。支持同步 confirm 和异步 confirm(批量/单条)。注意区分 confirm 和事务——confirm 更轻量,性能更好。 + +**Consumer ACK(消费确认)**:消费者处理完消息后显式调用 `basicAck`,Broker 才从队列中删除消息。如果消费者崩溃未 ACK,消息会被重新投递。支持 `basicNack` / `basicReject` 拒绝消息并决定是否 requeue。 + +**Return 机制**:当消息无法路由到任何 Queue(没有匹配的 Binding),且 `mandatory=true` 时,Broker 通过 Return 回调通知 Producer。配合 `alternate-exchange` 可以将无法路由的消息转入备用 Exchange。 + +```go +// 使用 amqp091-go 的完整发布确认 + 手动 ACK 示例 +package main + +import ( + "context" + "fmt" + amqp "github.com/rabbitmq/amqp091-go" + "log" + "time" +) + +func main() { + // 建立连接 + conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/") + if err != nil { + log.Fatal(err) + } + defer conn.Close() + + ch, err := conn.Channel() + if err != nil { + log.Fatal(err) + } + defer ch.Close() + + // 声明 Quorum Queue(生产环境推荐) + _, err = ch.QueueDeclare("order-queue", true, false, false, false, amqp.Table{ + "x-queue-type": "quorum", + }) + if err != nil { + log.Fatal(err) + } + + // ===== 发布确认 ===== + // 将 Channel 设置为 confirm 模式 + if err := ch.Confirm(false); err != nil { + log.Fatal(err) + } + // 获取确认通知 channel + confirms := ch.NotifyPublish(make(chan amqp.Confirmation, 1)) + + // 发布消息 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = ch.PublishWithContext(ctx, "", "order-queue", false, false, amqp.Publishing{ + ContentType: "application/json", + Body: []byte(`{"order_id": "1001", "amount": 99.9}`), + DeliveryMode: amqp.Persistent, // 持久化消息 + }) + if err != nil { + log.Fatal(err) + } + + // 等待 Broker 确认 + conf := <-confirms + if !conf.Ack { + log.Fatal("message was nacked by broker") + } + fmt.Println("publish confirmed") + + // ===== 消费端手动 ACK ===== + msgs, err := ch.Consume("order-queue", "", false, false, false, false, nil) + if err != nil { + log.Fatal(err) + } + + for msg := range msgs { + fmt.Printf("received: %s\n", msg.Body) + // 处理完成后手动确认,false 表示只确认当前消息 + if err := msg.Ack(false); err != nil { + log.Printf("ack failed: %v", err) + } + } +} +``` + +## 关联笔记 + +- [[03-协议与标准/5-AMQP-协议|AMQP 协议]] +- [[04-存储引擎/11-RabbitMQ-消息存储|RabbitMQ 消息存储]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] +- [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]] +- [[12-架构与实战/44-MQ-高可用架构|MQ 高可用架构]] +- [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] diff --git a/hhs/MQ/07-主流MQ对比/24-RocketMQ.md b/hhs/MQ/07-主流MQ对比/24-RocketMQ.md new file mode 100644 index 0000000..ba924ea --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/24-RocketMQ.md @@ -0,0 +1,140 @@ +--- +tags: [MQ, RocketMQ, 消息队列, 分布式事务] +create time: 2026-05-24 19:52 +--- + +# RocketMQ + +## 概述 + +RocketMQ 是阿里巴巴开源的分布式消息中间件,最初为支撑淘宝双十一的万亿级消息流转而设计。它以高可用、高吞吐、低延迟著称,原生支持事务消息和延迟消息,在金融支付、电商交易、物流跟踪等场景中被广泛使用。本文从架构设计、核心特性、存储机制、集群部署到 5.0 新特性,系统梳理 RocketMQ 的知识体系。 + +## 正文 + +### 整体架构 + +RocketMQ 的架构由四个核心角色组成,各司其职: + +```mermaid +graph TB + Producer["Producer 生产者"] -->|"发送消息"| NameServer + NameServer["NameServer 路由中心"] -->|"路由发现"| Consumer["Consumer 消费者"] + Producer -->|"写入消息"| Broker["Broker 消息代理"] + Consumer -->|"拉取消息"| Broker + Broker -->|"注册路由 + 心跳"| NameServer + + style NameServer fill:#4A90D9,color:#fff + style Broker fill:#F5A623,color:#fff + style Producer fill:#6EC1E0,color:#fff + style Consumer fill:#6EC1E0,color:#fff +``` + +- **NameServer**:无状态的路由注册中心。Broker 启动时向所有 NameServer 注册路由信息(Topic 分布、Broker 地址等),Producer 和 Consumer 从 NameServer 拉取路由表。它不做任何消息中转,节点之间互不通信,水平扩展极为简单。 +- **Broker**:消息存储和转发的核心。负责接收 Producer 发来的消息、持久化存储、响应 Consumer 的拉取请求。一个 Broker 可管理多个 Topic,每个 Topic 可设置多个 MessageQueue(类似 Kafka 的 Partition)。 +- **Producer**:消息生产者,支持同步发送、异步发送和单向发送(Oneway)。 +- **Consumer**:消息消费者,支持集群消费和广播消费两种模式。 + +> [!question] RocketMQ 的 NameServer 和 Kafka 的 ZooKeeper/KRaft 在职责上有什么本质区别? +> NameServer 是一个纯粹的路由注册表,只存储 Broker 的元数据,不做 Leader 选举、不做分区分配。而 ZooKeeper/KRaft 在 Kafka 中还承担 Controller 选举、Partition 副本分配、ISR 管理等职责。NameServer 的无状态设计让 RocketMQ 集群运维更简单,但路由信息的一致性是"最终一致"的(依赖 Broker 心跳),而非强一致。 + +### 核心特性 + +**事务消息**是 RocketMQ 最具辨识度的能力之一。它采用"半消息 + 本地事务 + 状态回查"三阶段机制:Producer 先发送一条半消息(Half Message)到 Broker,此时消息对 Consumer 不可见;Producer 执行本地事务后,根据结果提交(Commit)或回滚(Rollback);如果 Broker 长时间未收到确认,会主动回查 Producer 的本地事务状态。这套机制天然适合分布式事务的最终一致性场景。 + +**延迟消息**支持 18 个固定延迟级别(1s/5s/10s/30s/1m/2m...2h),底层通过定时任务 + 延迟队列实现。5.0 版本后新增任意时间精度的延迟消息,通过时间轮算法支撑更灵活的定时投递需求。 + +**消息过滤**分两层:Tag 过滤在 Broker 端完成,效率高但表达能力有限;SQL92 表达式过滤支持对消息属性做复杂条件判断(如 `amount > 100 AND region = 'CN'`),由 Consumer 端的 FilterServer 执行。 + +**消息回溯**允许 Consumer 按时间戳重置消费位点,重新消费历史消息,这在数据修复和问题排查中非常实用。 + +### 存储设计 + +RocketMQ 的存储模型采用三层结构:**CommitLog**(所有消息顺序追加写入一个大文件)+ **ConsumeQueue**(按 Topic-Queue 维度的逻辑索引,固定 20 字节/条)+ **IndexFile**(按消息 Key 的哈希索引,支持按 Key 查询消息)。 + +这种"先写大文件,再异步构建索引"的设计,将随机写转化为顺序写,最大化磁盘 IO 性能。ConsumeQueue 体积小,可常驻 PageCache,消费时几乎零磁盘 IO。 + +> [详细存储原理见 [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]]] + +### 集群部署模式 + +RocketMQ 支持两种主从部署模式: + +- **普通主从模式**:Master 接收读写请求,Slave 异步或同步复制数据。同步复制(SYNC_MASTER)保证数据不丢但延迟较高,异步复制(ASYNC_MASTER)延迟低但主节点宕机可能丢少量消息。 +- **DLedger 模式**(推荐):基于 Raft 协议的自动选主模式。每个 Broker 组内 3 个节点,Leader 由 Raft 选举产生,日志通过 Raft 复制到多数节点后才提交。解决了普通主从模式下 Master 宕机需要人工介入的问题。 + +> [!question] 生产环境选同步复制还是异步复制? +> 看业务对数据可靠性的要求。金融交易场景选 SYNC_MASTER + SYNC_FLUSH(同步复制+同步刷盘),最大化数据安全;对延迟敏感但允许极少量消息丢失的场景选 ASYNC_MASTER + ASYNC_FLUSH。 + +### 消费模式 + +RocketMQ 的消费模式有两组维度: + +| 维度 | 模式 | 说明 | +|------|------|------| +| 消费方式 | 集群消费(Clustering) | 同一 ConsumerGroup 内的实例分摊消息,每条消息只被消费一次 | +| | 广播消费(Broadcasting) | 每个 Consumer 实例都收到全量消息,适用于本地缓存刷新等场景 | +| 并发策略 | 并发消费 | 多线程同时消费,吞吐高但不保证顺序 | +| | 顺序消费 | 同一 MessageQueue 内的消息严格按顺序消费,通过 MessageListenerOrderly 实现 | + +### RocketMQ 5.0 新特性 + +RocketMQ 5.0 带来了几项重要演进: + +- **gRPC 协议**:替代自定义 RemotingCommand 协议,使用标准 gRPC 通信,多语言客户端支持更友好。 +- **Pop 消费模式**:消费者不再需要维护 MessageQueue 的本地锁,由 Broker 端分配消息,更适应弹性伸缩和 Serverless 场景。 +- **逻辑队列**:将物理 MessageQueue 抽象为逻辑队列,支持更灵活的队列映射和负载均衡策略。 + +### Go 代码示例 + +使用 `rocketmq-client-go` 发送和消费消息: + +```go +// Producer: 同步发送消息 +p, _ := rocketmq.NewProducer( + producer.WithNameServer([]string{"127.0.0.1:9876"}), + producer.WithGroupName("my-producer-group"), +) +p.Start() +defer p.Shutdown() + +msg := rocketmq.NewMessage("OrderTopic", []byte(`{"orderId":"1001","amount":99.9}`)) +msg.WithTag("order-create") // Tag 过滤标签 +result, err := p.SendSync(context.Background(), msg) +fmt.Println("发送结果:", result.MessageID) +``` + +```go +// Consumer: 集群消费模式 +c, _ := rocketmq.NewPushConsumer( + consumer.WithNameServer([]string{"127.0.0.1:9876"}), + consumer.WithGroupName("my-consumer-group"), + consumer.WithConsumeFromWhere(consumer.ConsumeFromLastOffset), // 从最新位点开始 +) +c.Subscribe("OrderTopic", consumer.MessageSelector{ + Type: consumer.TAG, + Expression: "order-create", // 只消费 Tag 为 order-create 的消息 +}, func(ctx context.Context, msgs ...*ext.Message) (consumer.ConsumeResult, error) { + for _, msg := range msgs { + fmt.Println("收到消息:", string(msg.Body)) + } + return consumer.ConsumeSuccess, nil +}) +c.Start() +defer c.Shutdown() +// 阻塞等待消费 +select {} +``` + +代码解析:Producer 通过 `SendSync` 同步发送消息到 `OrderTopic`,并通过 `WithTag` 设置 Tag。Consumer 通过 `Subscribe` 订阅同一 Topic,使用 Tag 表达式做过滤,消费成功返回 `ConsumeSuccess`。`ConsumeFromLastOffset` 表示新加入的消费者从最新消息开始消费,避免历史消息堆积。 + +## 关联笔记 + +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]] +- [[06-高级特性/18-MQ-事务消息|MQ 事务消息]] +- [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]] +- [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]] +- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] +- [[12-架构与实战/44-MQ-高可用架构|MQ 高可用架构]] +- [[12-架构与实战/45-MQ-跨集群复制与容灾|MQ 跨集群复制与容灾]] +- [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] diff --git a/hhs/MQ/07-主流MQ对比/25-Apache-Pulsar.md b/hhs/MQ/07-主流MQ对比/25-Apache-Pulsar.md new file mode 100644 index 0000000..82c65a0 --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/25-Apache-Pulsar.md @@ -0,0 +1,153 @@ +--- +tags: [MQ, Pulsar, 消息队列, 计算存储分离] +create time: 2026-05-24 19:52 +--- + +# Apache Pulsar + +## 概述 + +Apache Pulsar 是 Yahoo 于 2016 年开源、后捐赠给 Apache 基金会的云原生消息流平台。它最显著的设计特点是**计算存储分离**——Broker 只负责消息路由和计算,数据持久化交给专门的 BookKeeper 存储层。这种架构让 Pulsar 在弹性扩缩容、多租户支持和分层存储方面天然优于传统 MQ。本文深入解析 Pulsar 的架构设计、存储模型和独特能力。 + +## 正文 + +### 设计哲学:计算存储分离 + +传统 MQ(如 Kafka、RocketMQ)将计算和存储绑定在同一节点上。一个 Broker 既负责消息路由,又管理本地磁盘上的日志文件。这带来一个实际问题:扩容时新节点需要从其他节点迁移数据(Rebalance),数据量越大迁移越慢,可能需要数小时甚至数天。 + +Pulsar 的回答是:**把计算和存储拆开**。Broker 是无状态的,可以秒级扩缩容;数据存储在 BookKeeper 集群中,新 Broker 加入后直接开始服务,无需数据迁移。存储层也可以独立扩展——磁盘不够了加 BookKeeper 节点,流量大了加 Broker 节点,两者互不影响。 + +> [!question] Pulsar 的计算存储分离在扩缩容时比 Kafka 快,具体快在哪里? +> Kafka 扩容 Broker 时,需要把部分 Partition 的数据从旧节点复制到新节点(Partition Rebalance),数据量可达 TB 级,耗时很长。Pulsar 扩容 Broker 时,新 Broker 直接从 BookKeeper 读取数据,无需数据迁移。扩容 BookKeeper 时,新节点只接收新写入的 Ledger,存量数据不会迁移。整个过程是"加法"而非"搬运"。 + +### 架构详解 + +```mermaid +graph TB + Producer["Producer"] -->|"发送消息"| Broker1["Broker 1"] + Producer -->|"发送消息"| Broker2["Broker 2"] + Consumer["Consumer"] -->|"拉取消息"| Broker1 + Consumer -->|"拉取消息"| Broker2 + + Broker1 -->|"写入数据"| BK1["Bookie 1"] + Broker1 -->|"写入数据"| BK2["Bookie 2"] + Broker1 -->|"写入数据"| BK3["Bookie 3"] + Broker2 -->|"写入数据"| BK1 + Broker2 -->|"写入数据"| BK2 + Broker2 -->|"写入数据"| BK3 + + Broker1 -->|"元数据"| ZK["ZooKeeper"] + Broker2 -->|"元数据"| ZK + BK1 -->|"元数据"| ZK + BK2 -->|"元数据"| ZK + BK3 -->|"元数据"| ZK + + style Broker1 fill:#F5A623,color:#fff + style Broker2 fill:#F5A623,color:#fff + style BK1 fill:#4A90D9,color:#fff + style BK2 fill:#4A90D9,color:#fff + style BK3 fill:#4A90D9,color:#fff + style ZK fill:#6EC1E0,color:#fff + style Producer fill:#999,color:#fff + style Consumer fill:#999,color:#fff +``` + +三层架构各司其职: + +- **Broker(计算层)**:无状态,负责消息路由、协议处理、消息分发。Topic 的所有权可以在 Broker 之间快速转移(Ownership Transfer),一旦某个 Broker 宕机,它持有的 Topic 会被秒级重新分配给其他 Broker。 +- **BookKeeper / Bookie(存储层)**:分布式日志存储系统。每个 Bookie 节点独立管理本地磁盘上的数据片段(Ledger Segment)。数据写入时,Broker 选择多个 Bookie 并行写入,通过 Quorum 机制保证持久性。 +- **ZooKeeper(元数据层)**:管理 Broker 和 Bookie 的集群成员信息、Topic 元数据、Cursor(消费位点)等。 + +### 存储模型:Segment 分片 + +Pulsar 的存储单元是 **Ledger**,每个 Ledger 是一个只追加的日志文件。一个 Topic 的数据被切分为多个 Ledger,每个 Ledger 的数据段(Segment)分散存储在不同的 Bookie 上。 + +这种 Segment 级别的分片带来了几个好处:数据天然分散在多台机器上,单个 Topic 不会成为热点;新写入的数据分配到新的 Segment,不涉及旧数据迁移;旧 Ledger 可以被独立清理或下沉到冷存储。 + +### 多租户 + +Pulsar 原生支持多租户,采用三级命名空间: + +``` +Tenant(租户) → Namespace(命名空间) → Topic(主题) +``` + +每个 Tenant 是一个逻辑隔离单元,有独立的认证和授权策略。Namespace 是策略管理的最小单元(消息 TTL、保留策略、卸载策略都在 Namespace 级别配置)。同一个 Namespace 下的 Topic 共享配置。这种层级结构天然适合 SaaS 平台按租户隔离消息流量。 + +### Pulsar 特有功能 + +**分层存储(Tiered Storage)**:Pulsar 支持将过期的 Ledger 段自动下沉到廉价对象存储(如 S3、GCS、HDFS)。消息仍然可以通过 API 读取,但存储成本大幅降低。这对需要长期保留消息(如审计日志、事件溯源)的场景非常有吸引力——热数据在 BookKeeper SSD 上,冷数据在 S3 上,对应用完全透明。 + +**Pulsar Functions**:轻量级的流处理计算框架。用几行代码(Java/Python/Go)编写处理逻辑,提交到 Pulsar 集群后自动运行,无需部署 Flink/Spark 集群。适合简单的消息转换、过滤、路由等场景。 + +**Pulsar IO(Connector)**:预构建的数据连接器生态,包括 Kafka Source/Sink、Elasticsearch Sink、JDBC Sink、文件系统 Sink 等。将 Pulsar 与外部系统打通,降低集成成本。 + +### Pulsar vs Kafka 架构对比 + +| 维度 | Pulsar | Kafka | +|------|--------|-------| +| 存储模型 | 计算存储分离,Segment 存储在 BookKeeper | 计算存储耦合,Partition Log 本地磁盘 | +| 扩展性 | Broker 和 Bookie 独立扩展,秒级加入 | 扩容需 Partition Rebalance,数据量大时耗时长 | +| 多租户 | 原生支持 Tenant → Namespace → Topic | 无原生支持,需通过 ACL + Topic 命名约定模拟 | +| 运维复杂度 | 需维护 Broker + BookKeeper + ZooKeeper 三套组件 | 只需 Broker 集群(KRaft 模式可去掉 ZK) | +| 消息保留 | 天然支持,分层存储可下沉 S3 | 依赖 Log Compaction 和 retention 配置 | +| 协议兼容 | 原生协议 + Kafka Protocol Handler | Kafka 自有协议 | +| 生态成熟度 | 成长期,社区活跃但不如 Kafka 庞大 | 非常成熟,几乎所有大数据组件都有 Kafka 集成 | + +> [!question] 运维复杂度更高是不是 Pulsar 的致命弱点? +> 确实,Pulsar 需要维护三套组件,初期学习和运维成本更高。但随着 Kubernetes Operator(如 StreamNative 的 pulsar-helm-chart)的成熟,部署复杂度已大幅降低。而且计算存储分离带来的弹性优势在大规模场景下会反过来降低运维负担——扩缩容不再需要数小时的数据迁移。技术选型没有银弹,关键看规模和团队能力。 + +### Go 代码示例 + +使用 `pulsar-client-go` 实现消息的发送和消费: + +```go +// Producer: 发送消息 +client, _ := pulsar.NewClient(pulsar.ClientOptions{ + URL: "pulsar://localhost:6650", +}) +defer client.Close() + +producer, _ := client.CreateProducer(pulsar.ProducerOptions{ + Topic: "my-topic", +}) +defer producer.Close() + +_, err := producer.Send(context.Background(), &pulsar.ProducerMessage{ + Payload: []byte(`{"event":"user-signup","userId":"u1001"}`), +}) +if err != nil { + fmt.Println("发送失败:", err) +} +``` + +```go +// Consumer: 订阅消费 +consumer, _ := client.Subscribe(pulsar.ConsumerOptions{ + Topic: "my-topic", + SubscriptionName: "my-sub", // 订阅名称,用于持久化消费位点 + Type: pulsar.Shared, // 共享模式,多消费者轮询 +}) +defer consumer.Close() + +for { + msg, err := consumer.Receive(context.Background()) + if err != nil { + fmt.Println("接收失败:", err) + continue + } + fmt.Println("收到消息:", string(msg.Payload())) + consumer.Ack(msg) // 确认消费成功 +} +``` + +代码解析:Producer 通过 `Send` 同步发送消息到 `my-topic`。Consumer 通过 `Subscribe` 创建订阅,`SubscriptionName` 是 Pulsar 持久化消费位点的关键标识——即使 Consumer 下线重连,也能从上次的位置继续消费。`pulsar.Shared` 模式允许多个 Consumer 分摊消息,类似 RocketMQ 的集群消费。 + +## 关联笔记 + +- [[07-主流MQ对比/22-Kafka|Kafka]] +- [[07-主流MQ对比/24-RocketMQ|RocketMQ]] +- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] +- [[12-架构与实战/44-MQ-高可用架构|MQ 高可用架构]] +- [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] +- [[10-监控与运维/39-MQ-容器化与-K8s-部署|MQ 容器化与 K8s 部署]] diff --git a/hhs/MQ/07-主流MQ对比/26-NATS-NSQ-Redis-Streams.md b/hhs/MQ/07-主流MQ对比/26-NATS-NSQ-Redis-Streams.md new file mode 100644 index 0000000..b39d56f --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/26-NATS-NSQ-Redis-Streams.md @@ -0,0 +1,173 @@ +--- +tags: [MQ, NATS, NSQ, Redis Streams, 消息队列, 轻量级MQ] +create time: 2026-05-24 19:52 +--- + +# NATS / NSQ / Redis Streams + +## 概述 + +并不是所有场景都需要 Kafka 这样的重量级消息平台。微服务间的轻量通信、已有 Redis 基础设施上的流处理、去中心化的简单队列——这些场景下 NATS、NSQ 和 Redis Streams 各有胜场。本文分别解析三者的设计理念和核心能力,最后给出选型建议。 + +## 正文 + +### NATS:极简高性能的消息系统 + +NATS 由 Derek Collison 创建,设计哲学是"做一件事并做到极致"——提供简单、快速、可靠的消息通信。NATS Server 是一个单一的 Go 可执行文件,无外部依赖,启动即用。 + +**Subject-Based 消息模型**:NATS 使用 Subject(主题)路由消息,支持通配符匹配(`orders.*` 匹配 `orders.create`,`orders.>` 匹配 `orders.create.us` 等任意层级)。发送者只需指定 Subject,NATS 自动将消息路由到所有匹配的订阅者。 + +**核心特点**: +- 极致性能:单节点可支撑每秒千万级消息,微秒级延迟 +- At-Most-Once 语义:默认不持久化,消息发出去就不管了,适合实时性要求高但允许丢消息的场景 +- 自适应发现:客户端无需知道完整的集群拓扑,连接任意节点即可自动发现 + +**NATS JetStream**:这是 NATS 的持久化层,补齐了消息持久化和流式消费的短板。JetStream 提供: +- 消息持久化到磁盘 +- 消费者可以回放历史消息 +- At-Least-Once 和 Exactly-Once 语义支持 +- 消费者组(Queue Groups)和消息确认机制 + +```mermaid +graph LR + Publisher["Publisher"] -->|"Subject: orders.new"| NATS["NATS Server Cluster"] + NATS -->|"实时投递"| Sub1["Subscriber 1"] + NATS -->|"实时投递"| Sub2["Subscriber 2"] + NATS -->|"持久化"| JS["JetStream 存储"] + JS -->|"回放消费"| Sub3["JetStream Consumer"] + + style NATS fill:#4A90D9,color:#fff + style JS fill:#F5A623,color:#fff + style Publisher fill:#6EC1E0,color:#fff + style Sub1 fill:#6EC1E0,color:#fff + style Sub2 fill:#6EC1E0,color:#fff + style Sub3 fill:#6EC1E0,color:#fff +``` + +> [!question] NATS 和 RabbitMQ 都是"传统的"消息中间件,本质区别在哪? +> NATS 的核心是"消息路由器",设计优先级是速度和简洁,持久化是后加的能力(JetStream)。RabbitMQ 的核心是"消息代理",设计优先级是投递保证和灵活路由(Exchange/Binding),持久化是一等公民。简单说:NATS 是跑车,RabbitMQ 是卡车。 + +### NSQ:去中心化的实时消息平台 + +NSQ 由 Bitly 开发,最大特点是**去中心化**——没有中心化的元数据管理节点,每个 nsqd 实例独立运行,通过 nsqlookupd 实现拓扑发现。 + +**架构组件**: +- **nsqd**:消息守护进程,负责接收、队列和投递消息。每个 nsqd 独立管理自己 Topic 和 Channel(类似 Consumer Group),数据存储在本地。 +- **nsqlookupd**:拓扑发现服务,nsqd 启动时向 nsqlookupd 注册自己,消费者通过查询 nsqlookupd 发现某个 Topic 在哪些 nsqd 上。nsqlookupd 之间互不通信,可以部署多个实现高可用。 + +**消息存储**:NSQ 采用内存 + 磁盘的混合策略。消息先写入内存队列,内存队列达到阈值后溢写到磁盘。这种设计在性能和持久性之间取了折中。 + +**特点**:去中心化意味着没有单点故障,但也不支持消息回溯——消费过的消息就没了(At-Most-Once)。适合日志收集、实时通知等对消息可靠性要求不高的场景。 + +```mermaid +graph TB + Producer1["Producer"] -->|"发布消息"| NSQD1["nsqd 1"] + Producer1 -->|"发布消息"| NSQD2["nsqd 2"] + NSQD1 -->|"注册"| Lookupd["nsqlookupd"] + NSQD2 -->|"注册"| Lookupd + Lookupd -->|"拓扑发现"| Consumer["Consumer"] + Consumer -->|"消费消息"| NSQD1 + Consumer -->|"消费消息"| NSQD2 + + style Lookupd fill:#4A90D9,color:#fff + style NSQD1 fill:#F5A623,color:#fff + style NSQD2 fill:#F5A623,color:#fff + style Producer1 fill:#6EC1E0,color:#fff + style Consumer fill:#6EC1E0,color:#fff +``` + +### Redis Streams:基于 Redis 的流数据结构 + +Redis 5.0 引入的 Streams 是一个功能完备的流数据结构,基于 **Radix Tree**(基数树)实现内存中的高效存储。它让 Redis 从"缓存+简单队列"进化为一个轻量级消息流平台。 + +**核心命令**: +- `XADD key ID field value`:向 Stream 追加消息,返回自动生成的消息 ID(时间戳-序号) +- `XREAD COUNT 10 BLOCK 5000 STREAMS key ID`:从指定位置读取消息,支持阻塞等待 +- `XREADGROUP GROUP mygroup consumer1 COUNT 10 BLOCK 5000 STREAMS key >`:消费者组模式消费 + +**Consumer Group 支持**:Redis Streams 原生支持消费者组,多个消费者共享一个 Stream,每条消息只被组内的一个消费者处理。通过 Pending Entries List(PEL)跟踪已投递但未确认的消息,支持消息重试和死信处理。 + +```mermaid +graph LR + Producer["Producer"] -->|"XADD"| Stream["Redis Stream Radix Tree"] + Stream -->|"XREADGROUP"| CG["Consumer Group"] + CG -->|"分配消息"| C1["Consumer 1"] + CG -->|"分配消息"| C2["Consumer 2"] + C1 -->|"XACK"| Stream + C2 -->|"XACK"| Stream + + style Stream fill:#D0021B,color:#fff + style CG fill:#F5A623,color:#fff + style Producer fill:#6EC1E0,color:#fff + style C1 fill:#6EC1E0,color:#fff + style C2 fill:#6EC1E0,color:#fff +``` + +### 三者对比 + +| 维度 | NATS(JetStream) | NSQ | Redis Streams | +|------|-------------------|-----|---------------| +| 持久化 | JetStream 支持,可配置副本数 | 内存+磁盘混合,不支持回溯 | 基于 AOF/RDB 持久化,可配置 | +| 吞吐量 | 极高(千万级/秒) | 高(百万级/秒) | 中等(十万级/秒,受限于单线程) | +| 延迟 | 微秒级 | 毫秒级 | 毫秒级 | +| 运维复杂度 | 低,单一二进制 | 低,组件少但 nsqlookupd 需独立部署 | 低,如果已有 Redis 则零额外成本 | +| 消费语义 | At-Most-Once / At-Least-Once / Exactly-Once | At-Most-Once | At-Least-Once(Consumer Group + ACK) | +| 消息回溯 | 支持 | 不支持 | 支持(按 ID 回溯) | +| 适用场景 | 微服务通信、IoT、实时事件 | 日志收集、实时通知 | 已有 Redis 基础设施上的轻量队列 | + +### 轻量级 MQ 选型建议 + +> [!question] Redis Streams 功能这么强大,为什么还需要专门的 MQ? +> Redis Streams 功能确实很全面,但它的吞吐受限于 Redis 单线程模型,大数据量下会成为瓶颈。而且 Redis 本质是内存数据库,Streams 只是它的一个数据结构——如果消息量把内存撑爆,会影响 Redis 上其他业务。更重要的是,专门的 MQ(如 NATS JetStream)在集群扩展、数据分片、消费语义保证上做得更深入。简单说:Redis Streams 是"顺带能做消息队列",NATS/Kafka 是"专门为消息队列而生"。 + +**什么时候选 NATS**:微服务间高频通信、IoT 设备消息上报、需要极低延迟的实时事件系统。NATS 的 Go 原生实现和零依赖特性,让它特别适合云原生和 Kubernetes 环境。 + +**什么时候选 NSQ**:日志收集管道、实时通知推送、对消息可靠性要求不高的异步任务分发。去中心化设计让它在需要快速搭建、不想维护额外基础设施时很香。 + +**什么时候选 Redis Streams**:团队已经深度使用 Redis,需要在不引入新组件的前提下获得消息队列能力;消息量不大(日均千万级以内);对延迟和吞吐要求不极端。 + +### Go 代码示例:NATS JetStream + +```go +// 连接 NATS 并使用 JetStream 发布和消费 +nc, _ := nats.Connect("nats://localhost:4222") +defer nc.Close() + +js, _ := nc.JetStream() + +// 创建 Stream(持久化存储单元) +js.AddStream(&nats.StreamConfig{ + Name: "ORDERS", + Subjects: []string{"orders.>"}, // 订阅所有 orders. 开头的 Subject + Storage: nats.FileStorage, // 持久化到磁盘 + Replicas: 3, // 3 副本 +}) + +// 发布消息 +_, err := js.Publish("orders.new", []byte(`{"orderId":"1001","item":"laptop"}`)) +if err != nil { + fmt.Println("发布失败:", err) +} + +// 消费消息(Push 模式) +sub, _ := js.Subscribe("orders.>", func(msg *nats.Msg) { + fmt.Println("收到消息:", string(msg.Data)) + msg.Ack() // 确认消费 +}, nats.Durable("order-processor"), // 持久化消费者名,重启后从上次位点继续 + nats.ManualAck(), // 手动确认模式 +) +defer sub.Unsubscribe() + +select {} // 阻塞等待 +``` + +代码解析:先通过 `AddStream` 创建一个名为 `ORDERS` 的 JetStream Stream,配置了文件存储和 3 副本。`Publish` 将消息发送到 `orders.new` Subject,该 Subject 匹配 Stream 的 `orders.>` 通配符。`Subscribe` 使用 Push 模式消费,`Durable` 参数让消费位点持久化——即使消费者重启也能从断点继续。这是 NATS JetStream 相比原生 NATS 最大的增强。 + +## 关联笔记 + +- [[07-主流MQ对比/22-Kafka|Kafka]] +- [[07-主流MQ对比/24-RocketMQ|RocketMQ]] +- [[07-主流MQ对比/25-Apache-Pulsar|Apache Pulsar]] +- [[02-消息模型/3-MQ-消息模型|MQ 消息模型]] +- [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] +- [[12-架构与实战/47-MQ-与微服务|MQ 与微服务]] diff --git a/hhs/MQ/07-主流MQ对比/27-MQ-选型对比.md b/hhs/MQ/07-主流MQ对比/27-MQ-选型对比.md new file mode 100644 index 0000000..f356a08 --- /dev/null +++ b/hhs/MQ/07-主流MQ对比/27-MQ-选型对比.md @@ -0,0 +1,117 @@ +--- +tags: [MQ, 消息队列, 选型, 技术对比] +create time: 2026-05-24 19:52 +--- + +# MQ 选型对比 + +## 概述 + +消息队列选型没有"银弹",只有最匹配业务场景的选择。本文从吞吐量、延迟、可靠性、消息模型、运维复杂度、社区生态、云原生支持七个维度,横向对比 Kafka、RabbitMQ、RocketMQ、Pulsar 和 NATS,并给出场景化选型建议和常见误区。 + +## 正文 + +### 选型维度说明 + +选型不是看"谁最强",而是看"谁最适合"。以下是七个核心维度: + +- **吞吐量**:单位时间能处理多少消息,决定系统上限。 +- **延迟**:消息从生产到被消费的端到端时间。 +- **可靠性**:消息不丢、不重的能力,以及故障恢复速度。 +- **消息模型**:支持的消息模式(队列、发布订阅、分区有序等)。 +- **运维复杂度**:部署、扩缩容、升级、故障排查的难度。 +- **社区生态**:客户端语言支持、插件、文档、社区活跃度。 +- **云原生支持**:K8s Operator、Helm Chart、弹性伸缩能力。 + +> [!question] +> 你们当前的系统最在意哪个维度?是吞吐量优先,还是延迟敏感?还是运维团队人力有限,需要低运维成本? + +### 核心对比表 + +| 维度 | Kafka | RabbitMQ | RocketMQ | Pulsar | NATS | +|------|-------|----------|----------|--------|------| +| **吞吐量** | 极高(百万级/s) | 中等(万级/s) | 高(十万级/s) | 极高(百万级/s) | 极高(千万级/s,轻量消息) | +| **延迟** | 毫秒~十毫秒 | 微秒~毫秒 | 毫秒级 | 毫秒级 | 微秒级 | +| **可靠性** | 高(ISR + 副本) | 高(镜像队列) | 高(同步双写) | 高(BookKeeper) | 中(JetStream 增强) | +| **消息模型** | 发布订阅 + 分区有序 | 队列 + 交换机路由 | 队列 + 发布订阅 + 事务 | 发布订阅 + 分区 + 多租户 | 发布订阅 + 队列组 | +| **运维复杂度** | 中高(ZK/KRaft) | 低 | 中(NameServer) | 高(Broker + BookKeeper) | 极低 | +| **社区生态** | 极丰富(Confluent 生态) | 极丰富(插件机制) | 丰富(阿里主导) | 快速成长 | 活跃(CNCF) | +| **云原生支持** | 中(Strimzi Operator) | 中(RabbitMQ Operator) | 中(RocketMQ Operator) | 原生支持(计算存储分离) | 原生支持(极轻量) | + +### 场景化选型建议 + +#### 日志收集 / 大数据管道 → Kafka + +Kafka 是大数据生态的事实标准。配合 Kafka Connect、Kafka Streams、Schema Registry,可以构建完整的数据管道。如果你的下游是 Flink、Spark、ClickHouse,Kafka 几乎是唯一选择。 + +#### 企业级消息中间件 → RabbitMQ + +RabbitMQ 的 AMQP 协议和灵活的 Exchange 路由(Direct、Fanout、Topic、Headers)让它成为企业应用集成的首选。对 Java/C#/.NET 生态友好,插件丰富,上手门槛低。 + +#### 电商 / 金融事务消息 → RocketMQ + +RocketMQ 原生支持事务消息、延迟消息、消息回溯,在阿里巴巴双十一经历过极端流量验证。如果你的场景涉及分布式事务或严格的顺序消费,RocketMQ 是首选。 + +#### 多租户 / 云原生 → Pulsar + +Pulsar 的计算存储分离架构(Broker 无状态 + BookKeeper 持久化)天然适合云原生部署。原生多租户支持(Tenant / Namespace)让平台团队可以为不同业务线隔离资源。 + +#### 微服务轻量通信 → NATS + +NATS 的核心优势是极致轻量——单个二进制文件、无外部依赖、微秒级延迟。NATS JetStream 提供了持久化和消费确认,适合微服务间的事件通知和命令分发。 + +> [!question] +> 如果你的团队同时有日志收集和微服务通信两种需求,应该选一个 MQ 统一方案,还是两个 MQ 各司其职? + +### 选型决策树 + +```mermaid +graph TD + Start["开始选型"] --> Q1{"需要大数据生态集成?"} + Q1 -->|"是"| Kafka["选择 Kafka"] + Q1 -->|"否"| Q2{"需要事务消息或延迟消息?"} + Q2 -->|"是"| RocketMQ["选择 RocketMQ"] + Q2 -->|"否"| Q3{"需要灵活路由和协议兼容?"} + Q3 -->|"是"| RabbitMQ["选择 RabbitMQ"] + Q3 -->|"否"| Q4{"需要多租户和云原生?"} + Q4 -->|"是"| Pulsar["选择 Pulsar"] + Q4 -->|"否"| Q5{"需要极致轻量和低延迟?"} + Q5 -->|"是"| NATS["选择 NATS"] + Q5 -->|"否"| Q6{"团队技术栈?"} + Q6 -->|"Java 为主"| RocketMQ2["考虑 RocketMQ"] + Q6 -->|"Go / 云原生"| NATS2["考虑 NATS"] + Q6 -->|"混合栈"| Kafka2["考虑 Kafka"] + + style Start fill:#4A90D9,color:#fff + style Kafka fill:#D0021B,color:#fff + style RocketMQ fill:#D0021B,color:#fff + style RabbitMQ fill:#D0021B,color:#fff + style Pulsar fill:#D0021B,color:#fff + style NATS fill:#D0021B,color:#fff +``` + +### 常见选型误区 + +**误区一:盲目追求高吞吐** + +Kafka 吞吐确实高,但如果你的业务日均消息量只有几十万,RabbitMQ 完全够用,还省去了运维 Kafka 集群的成本。高吞吐的代价是更高的运维复杂度和资源消耗。 + +**误区二:忽视运维成本** + +Pulsar 架构先进,但 Broker + BookKeeper + ZooKeeper 的组件数量意味着更多的运维负担。如果团队只有 2-3 个后端开发,选 NATS 或 RabbitMQ 可能更务实。 + +**误区三:忽略团队技术栈** + +团队全是 Java 开发,选 RocketMQ 的学习成本最低;团队主力是 Go,NATS 的 Go 客户端体验最好。技术选型要尊重团队现状,而不是追逐"最优解"。 + +> [!question] +> 你们团队的技术栈是 Java,但业务需要高吞吐日志收集,应该选 RabbitMQ 还是 Kafka?为什么? + +## 关联笔记 + +- [[07-主流MQ对比/22-Kafka|Kafka]] +- [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]] +- [[07-主流MQ对比/24-RocketMQ|RocketMQ]] +- [[07-主流MQ对比/25-Apache-Pulsar|Apache Pulsar]] +- [[07-主流MQ对比/26-NATS-NSQ-Redis-Streams|NATS / NSQ / Redis Streams]] +- [[01-基础概念/2-MQ-适用场景与选型原则|MQ 适用场景与选型原则]] diff --git a/hhs/MQ/08-消息设计模式/28-MQ-Competing-Consumers-模式.md b/hhs/MQ/08-消息设计模式/28-MQ-Competing-Consumers-模式.md new file mode 100644 index 0000000..8181202 --- /dev/null +++ b/hhs/MQ/08-消息设计模式/28-MQ-Competing-Consumers-模式.md @@ -0,0 +1,153 @@ +--- +tags: [MQ, 消息队列, 设计模式, 负载均衡] +create time: 2026-05-24 19:52 +--- + +# MQ Competing Consumers 模式 + +## 概述 + +Competing Consumers(竞争消费者)模式通过多个消费者同时消费同一个队列来实现负载均衡,是提升消息处理吞吐量最直接的手段。本文深入讲解该模式的工作原理、分区分配算法、Consumer Rebalance 机制及其常见问题与优化方案。 + +## 正文 + +### 模式定义 + +传统单消费者模型中,一个队列只有一个消费者按顺序处理消息。当消息量增大时,单个消费者成为瓶颈。Competing Consumers 模式的核心思想很简单:**让多个消费者"抢"同一个队列的消息**,谁抢到谁处理,从而实现水平扩展。 + +```mermaid +graph LR + P["Producer"] --> Q["Queue"] + Q --> C1["Consumer 1"] + Q --> C2["Consumer 2"] + Q --> C3["Consumer 3"] + + style P fill:#4A90D9,color:#fff + style Q fill:#F5A623,color:#fff + style C1 fill:#6EC1E0,color:#fff + style C2 fill:#6EC1E0,color:#fff + style C3 fill:#6EC1E0,color:#fff +``` + +### 与传统单消费者的对比 + +| 维度 | 单消费者 | Competing Consumers | +|------|---------|---------------------| +| 吞吐量 | 受单机性能限制 | 线性扩展(理论上) | +| 消息顺序 | 天然有序 | 需要额外保障(分区有序) | +| 可用性 | 消费者故障则停摆 | 单个消费者故障不影响整体 | +| 复杂度 | 低 | 高(需要处理 Rebalance) | + +> [!question] +> Competing Consumers 提升了吞吐量,但牺牲了全局顺序性。如果你的业务需要"同一用户的操作有序",该如何在分区级别保障顺序? + +### 分区分配算法详解 + +在 Kafka 中,Consumer Group 内的消费者通过分区分配算法决定"谁消费哪个 Partition"。不同的分配策略直接影响负载均衡效果和 Rebalance 开销。 + +#### Range(范围分配) + +将 Topic 的 Partition 按序号范围分配给消费者。例如 6 个 Partition、3 个消费者:Consumer 0 拿到 [0,1],Consumer 1 拿到 [2,3],Consumer 2 拿到 [4,5]。 + +优点是简单直观,但如果多个 Topic 使用 Range 策略,可能导致某个消费者被分配到多个 Topic 的"头部"分区,造成负载不均。 + +#### Round-Robin(轮询分配) + +将所有 Partition 轮询分配给消费者。6 个 Partition、3 个消费者:Consumer 0 拿到 [0,3],Consumer 1 拿到 [1,4],Consumer 2 拿到 [2,5]。 + +分配更均匀,但要求同一 Consumer Group 内所有消费者订阅的 Topic 完全一致,否则会出现分配异常。 + +#### Sticky(粘性分配) + +在 Round-Robin 的基础上增加"粘性":Rebalance 时尽量保留原有的分配关系,只迁移必要的 Partition。这大幅减少了 Rebalance 期间的 Partition 迁移量。 + +#### Cooperative Sticky(协作式 Rebalance) + +传统 Rebalance 是"Stop-the-World"——所有消费者暂停消费,等待分配完成。Cooperative Sticky 采用增量式 Rebalance:只暂停被迁移的 Partition,其余 Partition 继续消费。 + +```mermaid +graph TD + subgraph Before["Rebalance 前"] + B1["Consumer 1"] --> BP1["Partition 0, 1, 2"] + B2["Consumer 2"] --> BP2["Partition 3, 4, 5"] + end + + subgraph After["Rebalance 后 (Cooperative Sticky)"] + A1["Consumer 1"] --> AP1["Partition 0, 1"] + A2["Consumer 2"] --> AP2["Partition 3, 4, 5"] + A3["Consumer 3 (新加入)"] --> AP3["Partition 2"] + end + + Before --> After + + style Before fill:#F5A623,color:#fff + style After fill:#6EC1E0,color:#fff +``` + +注意图中:Cooperative Sticky 只迁移了 Partition 2,其余 Partition 在 Rebalance 期间保持消费不中断。 + +### Consumer Rebalance 过程与问题 + +Rebalance 是 Competing Consumers 模式中最关键也最容易出问题的环节。 + +**触发条件**:消费者加入/离开 Group、消费者心跳超时、Topic Partition 数量变化。 + +**Stop-the-World 问题**:传统 Rebalance 期间,Group 内所有消费者必须停止消费,等待 Coordinator 完成重新分配。在分区数量多或消费者数量大时,这个过程可能持续数秒甚至数十秒。 + +**Rebalance 风暴**:当消费者处理消息过慢导致心跳超时,被踢出 Group 触发 Rebalance;Rebalance 期间积压更多消息;消费者重新加入后又因处理不过来被踢出——形成恶性循环。 + +### Static Membership 减少不必要的 Rebalance + +Kafka 2.3 引入 Static Membership 机制:每个消费者配置固定的 `group.instance.id`。消费者短暂断开重连时,只要在 `session.timeout.ms` 内恢复,就不会触发 Rebalance。 + +这在容器化部署中特别有用——Pod 重启时不会引发整个 Consumer Group 的 Rebalance 风暴。 + +> [!question] +> Rebalance 期间消费者会暂停消费,在高并发场景下这会造成什么问题?如何缓解? + +### Go 代码:Kafka Consumer 分区分配配置 + +```go +package main + +import ( + "github.com/segmentio/kafka-go" +) + +func main() { + // 创建 Reader 时指定 Consumer Group 和分配策略 + r := kafka.NewReader(kafka.ReaderConfig{ + Brokers: []string{"localhost:9092"}, + Topic: "orders", + GroupID: "order-service", + // 使用 Cooperative Sticky 分配策略,减少 Rebalance 迁移 + GroupBalancers: []kafka.GroupBalancer{ + kafka.CooperativeGroupBalancer{}, + }, + // 静态成员:Pod 重启时不触发 Rebalance + GroupInstanceID: "order-consumer-pod-0", + }) + defer r.Close() + + for { + msg, err := r.ReadMessage(context.Background()) + if err != nil { + // 处理错误,注意 Rebalance 期间会返回特定错误 + break + } + process(msg) + } +} +``` + +关键配置说明: +- `GroupBalancers` 设置分配策略,`CooperativeGroupBalancer` 对应 Cooperative Sticky。 +- `GroupInstanceID` 设置静态成员 ID,同一个 Pod 重启后保持相同的 ID,避免触发不必要的 Rebalance。 + +## 关联笔记 + +- [[02-消息模型/3-MQ-消息模型|MQ 消息模型]] +- [[07-主流MQ对比/22-Kafka|Kafka]] +- [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] +- [[10-监控与运维/37-MQ-消费积压治理|MQ 消费积压治理]] +- [[12-架构与实战/49-MQ-客户端连接管理|MQ 客户端连接管理]] diff --git a/hhs/MQ/08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing.md b/hhs/MQ/08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing.md new file mode 100644 index 0000000..af721cf --- /dev/null +++ b/hhs/MQ/08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing.md @@ -0,0 +1,174 @@ +--- +tags: [MQ, 消息队列, CQRS, Event Sourcing, 架构模式] +create time: 2026-05-24 19:52 +--- + +# MQ CQRS 与 Event Sourcing + +## 概述 + +CQRS(Command Query Responsibility Segregation)将读写模型分离,Event Sourcing 用事件流代替状态快照。两者结合并通过 MQ 广播事件,可以构建高性能、可审计、可追溯的系统架构。本文详解这两个模式的原理、组合方式、与传统 CRUD 的对比,以及事件存储的核心设计。 + +## 正文 + +### CQRS 概述 + +传统 CRUD 模式下,同一个数据模型既用于写入也用于查询。这在简单场景下工作良好,但随着业务复杂化,读写的性能需求和模型结构会逐渐分化。 + +CQRS 的核心思想:**Command(写)和 Query(读)使用不同的模型**。 + +- **写模型**(Command Side):专注于业务规则校验和状态变更,使用领域模型(Domain Model),可以高度规范化。 +- **读模型**(Query Side):专注于查询性能,使用反规范化的视图模型(View Model),可以针对不同查询场景定制。 + +```mermaid +graph LR + Client["客户端"] --> CmdAPI["Command API"] + Client --> QueryAPI["Query API"] + + CmdAPI --> WriteDB["写模型 (Domain)"] + WriteDB -->|"事件通过 MQ 广播"| MQ["Message Queue"] + MQ --> ReadModel1["读模型 A (列表视图)"] + MQ --> ReadModel2["读模型 B (统计视图)"] + ReadModel1 --> QueryAPI + ReadModel2 --> QueryAPI + + style Client fill:#4A90D9,color:#fff + style CmdAPI fill:#F5A623,color:#fff + style QueryAPI fill:#6EC1E0,color:#fff + style WriteDB fill:#D0021B,color:#fff + style MQ fill:#F5A623,color:#fff + style ReadModel1 fill:#6EC1E0,color:#fff + style ReadModel2 fill:#6EC1E0,color:#fff +``` + +### Event Sourcing + +传统方式存储数据的"当前状态"——每次更新都是覆盖写入。Event Sourcing 反其道而行:**不存储当前状态,存储所有导致状态变更的事件**。 + +以电商订单为例: + +| 传统 CRUD | Event Sourcing | +|-----------|----------------| +| `UPDATE orders SET status='paid'` | 追加事件 `OrderPaid{orderId, amount, time}` | +| 只保留最终状态 | 保留完整的事件历史 | +| 无法追溯"为什么变成这样" | 可以重放任意时间点的状态 | + +Event Sourcing 的优势: +- **完整审计追踪**:每个状态变更都有记录,满足金融、医疗等合规要求。 +- **时间旅行**:通过重放事件,可以重建任意历史时刻的状态。 +- **调试友好**:出 bug 时可以精确回放复现问题。 + +Event Sourcing 的挑战: +- **事件版本兼容**:事件 Schema 演进时需要保证新旧版本兼容。 +- **查询复杂**:获取当前状态需要重放所有事件(可用 Snapshot 优化)。 +- **最终一致性**:读模型通过 MQ 异步更新,存在短暂延迟。 + +> [!question] +> Event Sourcing 让历史可追溯,但也带来事件版本兼容的挑战。你会如何处理事件 Schema 的演进? + +### CQRS + Event Sourcing 的组合 + +CQRS 和 Event Sourcing 天然互补: + +1. **Command Side** 采用 Event Sourcing,将状态变更以事件形式追加到 Event Store。 +2. **Event Store** 通过 MQ 将事件广播给所有关心的消费者。 +3. **Query Side**(Read Model)消费事件,构建针对查询优化的视图。 + +这种架构下,MQ 是连接读写两侧的桥梁——写入端产生事件,MQ 负责分发,读取端消费事件并更新视图。 + +### 与传统 CRUD 的对比 + +| 维度 | 传统 CRUD | CQRS + Event Sourcing | +|------|-----------|----------------------| +| 数据一致性 | 强一致(同一数据库) | 最终一致(读模型异步更新) | +| 查询性能 | 受限于写模型结构 | 读模型可针对查询优化 | +| 审计追踪 | 需要额外设计审计表 | 天然支持,事件即审计日志 | +| 扩展性 | 读写耦合,难以独立扩展 | 读写独立扩展 | +| 复杂度 | 低 | 高(事件设计、版本管理、最终一致性) | +| 适用场景 | 简单 CRUD 应用 | 复杂业务、高并发读、审计要求高 | + +> [!question] +> CQRS 带来最终一致性,用户下单后立刻查询可能看不到最新状态。在你的业务场景中,这种延迟可以接受吗?如何在 UX 层面缓解? + +### 事件存储设计 + +#### 事件版本 + +事件一旦写入就不可修改(Immutable),但业务会演进。处理方式: +- **Upcast**:读取旧版本事件时,在内存中转换为新版本。 +- **多版本共存**:事件中携带版本号,消费者根据版本号分别处理。 + +#### 快照(Snapshot) + +当事件数量很大时,每次重放全部事件来获取当前状态会很慢。Snapshot 在特定时间点保存状态快照,重放时只需从最近的 Snapshot 开始。 + +#### 事件重放 + +重放是 Event Sourcing 的核心能力——从头(或从 Snapshot)依次应用事件,重建状态。读模型的重建、bug 修复后的数据修复、新读模型的初始化,都依赖事件重放。 + +### Go 代码:简化版 Event Sourcing + +```go +package main + +import "time" + +// Event 事件定义:每个事件代表一次状态变更 +type Event struct { + ID string + AggregateID string // 聚合根 ID(如订单 ID) + Type string // 事件类型 + Data []byte // 事件数据 + Version int // 事件版本号 + Timestamp time.Time +} + +// EventStore 事件存储接口 +type EventStore interface { + Save(events []Event) error + Load(aggregateID string) ([]Event, error) +} + +// OrderAggregate 订单聚合根 +type OrderAggregate struct { + ID string + Status string + Amount float64 +} + +// Apply 将单个事件应用到聚合根,更新状态 +func (o *OrderAggregate) Apply(event Event) { + switch event.Type { + case "OrderCreated": + o.Status = "created" + case "OrderPaid": + o.Status = "paid" + case "OrderShipped": + o.Status = "shipped" + } +} + +// ReplayEvents 从事件流重建聚合根状态 +func ReplayEvents(aggregateID string, store EventStore) (*OrderAggregate, error) { + events, err := store.Load(aggregateID) + if err != nil { + return nil, err + } + + agg := &OrderAggregate{ID: aggregateID} + for _, event := range events { + agg.Apply(event) // 依次应用每个事件 + } + return agg, nil +} +``` + +核心逻辑:`ReplayEvents` 从 Event Store 加载指定聚合根的所有事件,依次调用 `Apply` 重建当前状态。这就是 Event Sourcing 的精髓——状态是事件的函数,`State = f(events)`。 + +## 关联笔记 + +- [[02-消息模型/3-MQ-消息模型|MQ 消息模型]] +- [[09-流处理与事件驱动/34-事件驱动架构-EDA|事件驱动架构 EDA]] +- [[12-架构与实战/47-MQ-与微服务|MQ 与微服务]] +- [[06-高级特性/20-MQ-Schema-管理与演进|MQ Schema 管理与演进]] +- [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]] diff --git a/hhs/MQ/08-消息设计模式/30-MQ-Claim-Check-与消息瘦身.md b/hhs/MQ/08-消息设计模式/30-MQ-Claim-Check-与消息瘦身.md new file mode 100644 index 0000000..ea5b1bf --- /dev/null +++ b/hhs/MQ/08-消息设计模式/30-MQ-Claim-Check-与消息瘦身.md @@ -0,0 +1,168 @@ +--- +tags: [MQ, 消息队列, 设计模式, 性能优化] +create time: 2026-05-24 19:52 +--- + +# MQ Claim Check 与消息瘦身 + +## 概述 + +当消息体包含图片、文件、富文本等大体积数据时,直接放入 MQ 会严重影响 Broker 的吞吐和存储性能。Claim Check(存根/提货单)模式的核心思路是:**消息体外置到对象存储,MQ 中只传递一个轻量的引用(Key)**。消费端按需根据 Key 拉取完整数据,从而实现"消息瘦身"。 + +## 正文 + +### 问题:大消息的代价 + +MQ 的设计哲学是"快速转发小消息"。当消息体积增大时,会引发一系列连锁问题: + +- **网络带宽**:Broker 需要在生产者和消费者之间转发完整消息,大消息占用大量带宽。 +- **存储压力**:Kafka 的 Partition Log、RocketMQ 的 CommitLog 都是顺序写入,大消息导致磁盘 IO 放大。 +- **内存占用**:Broker 和 Consumer 的缓冲区需要加载完整消息,GC 压力增大。 +- **延迟增加**:大消息的序列化、反序列化和网络传输时间更长,端到端延迟上升。 + +一个典型的例子:电商系统中,用户上传的商品图片(几 MB)如果直接塞进消息体,MQ 的吞吐量可能下降一个数量级。 + +> [!question] +> 如果你的系统每天产生 100 万条消息,每条消息包含一张 2MB 的图片,MQ 需要额外存储 2TB 数据。这对 Broker 集群的磁盘和网络意味着什么? + +### Claim Check 模式 + +Claim Check 模式的灵感来自衣帽间:你把大衣(消息体)存起来,只拿一张小票(Key)。消费时凭小票取回大衣。 + +核心流程: + +1. **Producer 端**:将消息体上传到对象存储(S3/MinIO/OSS),获取 Storage Key。 +2. **MQ 传递**:Producer 只将 Storage Key 和必要的元数据发送到 MQ。 +3. **Consumer 端**:消费者收到消息后,根据 Storage Key 从对象存储拉取完整数据。 + +```mermaid +graph LR + P["Producer"] -->|"1. 上传大消息体"| OS["Object Storage (S3/MinIO)"] + OS -->|"2. 返回 Storage Key"| P + P -->|"3. 发送轻量消息 (Key + 元数据)"| MQ["Message Queue"] + MQ -->|"4. 转发消息"| C["Consumer"] + C -->|"5. 根据 Key 拉取完整数据"| OS + + style P fill:#4A90D9,color:#fff + style OS fill:#F5A623,color:#fff + style MQ fill:#6EC1E0,color:#fff + style C fill:#D0021B,color:#fff +``` + +### 权衡分析 + +Claim Check 不是免费午餐,它引入了一个权衡: + +| 维度 | 直接发送大消息 | Claim Check 模式 | +|------|--------------|-----------------| +| MQ 性能 | 差(大消息拖慢 Broker) | 好(消息体极小) | +| 网络开销 | 单次大传输 | 多次小传输(MQ + 对象存储) | +| 消费延迟 | 低(数据已在消息中) | 略高(需要额外一次 IO) | +| 运维复杂度 | 低 | 中(需要管理对象存储) | +| 存储成本 | MQ 磁盘成本高 | 对象存储成本低(通常更便宜) | + +大多数场景下,MQ 性能的提升远大于额外一次对象存储 IO 的开销。对象存储(如 S3)本身就是为高吞吐、低延迟的读取设计的。 + +### 实现方式 + +**消息存 S3/MinIO/OSS**:Producer 先将消息体 PUT 到对象存储,拿到 Key 后封装成轻量消息发送到 MQ。 + +**消费端按需拉取**:Consumer 收到消息后,根据 Key 从对象存储 GET 完整数据。如果某些消费者只需要元数据而不需要完整内容(如路由、过滤),就可以跳过拉取步骤。 + +**生命周期管理**:对象存储中的数据需要设置过期策略(TTL),避免无限增长。可以与消息的消费确认(ACK)联动——消息被 ACK 后,对象存储中的数据保留 N 天后自动清理。 + +### 变体方案 + +#### 消息压缩 + +如果消息体不算特别大(几百 KB 到几 MB),可以先压缩再发送。Snappy、LZ4、Zstd 等压缩算法可以在几乎不增加延迟的前提下,将消息体积减少 50%-80%。 + +#### 消息分片 + +超大消息拆成多个小消息发送,消费端组装。这种方式实现复杂,需要处理分片丢失、乱序等问题,一般只在极端场景下使用。 + +> [!question] +> Claim Check 引入了对象存储这个外部依赖,如果对象存储不可用怎么办? + +### Go 代码:Claim Check 模式实现 + +```go +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/minio/minio-go/v7" + "github.com/segmentio/kafka-go" +) + +// ClaimCheckMessage 轻量消息:只包含引用,不包含实际数据 +type ClaimCheckMessage struct { + StorageKey string `json:"storage_key"` // 对象存储中的 Key + Bucket string `json:"bucket"` // 存储桶名 + Metadata map[string]string `json:"metadata"` // 业务元数据 +} + +// ========== Producer 端 ========== + +// PublishWithClaimCheck 大消息外置存储,MQ 只传引用 +func PublishWithClaimCheck(ctx context.Context, minioClient *minio.Client, writer *kafka.Writer, bucket string, data []byte, metadata map[string]string) error { + // 1. 生成唯一的 Storage Key + key := fmt.Sprintf("msg/%s", generateUUID()) + + // 2. 将消息体上传到对象存储 + _, err := minioClient.PutObject(ctx, bucket, key, bytes.NewReader(data), int64(data.Length()), minio.PutObjectOptions{}) + if err != nil { + return fmt.Errorf("upload to object storage: %w", err) + } + + // 3. 构造轻量消息,只包含引用 + msg := ClaimCheckMessage{ + StorageKey: key, + Bucket: bucket, + Metadata: metadata, + } + payload, _ := json.Marshal(msg) + + // 4. 将轻量消息发送到 MQ + return writer.WriteMessages(ctx, kafka.Message{Value: payload}) +} + +// ========== Consumer 端 ========== + +// ConsumeWithClaimCheck 消费消息时按需拉取完整数据 +func ConsumeWithClaimCheck(ctx context.Context, minioClient *minio.Client, reader *kafka.Reader) { + for { + msg, _ := reader.ReadMessage(ctx) + + // 1. 解析轻量消息,获取引用 + var claim ClaimCheckMessage + json.Unmarshal(msg.Value, &claim) + + // 2. 根据 Key 从对象存储拉取完整数据 + obj, err := minioClient.GetObject(ctx, claim.Bucket, claim.StorageKey, minio.GetObjectOptions{}) + if err != nil { + continue // 拉取失败,可以重试或进死信队列 + } + + // 3. 处理完整数据 + buf := new(bytes.Buffer) + buf.ReadFrom(obj) + processFullData(buf.Bytes(), claim.Metadata) + } +} +``` + +Producer 端的逻辑分两步:先把大消息体上传到 MinIO(步骤 1-2),再把包含 Storage Key 的轻量消息发送到 Kafka(步骤 3-4)。Consumer 端反向操作:先从 Kafka 读取轻量消息,再根据 Key 从 MinIO 拉取完整数据。 + +注意 Consumer 端的错误处理——如果对象存储暂时不可用,消息可以重试或进入死信队列,而不是直接丢弃。 + +## 关联笔记 + +- [[06-高级特性/21-MQ-消息压缩与批处理|MQ 消息压缩与批处理]] +- [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] +- [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] +- [[08-消息设计模式/31-MQ-背压与流控|MQ 背压与流控]] diff --git a/hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md b/hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md new file mode 100644 index 0000000..30baf76 --- /dev/null +++ b/hhs/MQ/08-消息设计模式/31-MQ-背压与流控.md @@ -0,0 +1,158 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 背压与流控 + +## 概述 + +当生产速率持续超过消费速率时,消息队列会面临内存溢出、磁盘满甚至消息丢失的风险。背压(Backpressure)机制让上游感知下游的处理能力,流控则是系统在过载时保护自身的手段。本文从 Broker 端和 Consumer 端两个维度,剖析主流 MQ 的背压与流控策略。 + +## 正文 + +### 问题:生产太快,消费太慢 + +想象一个场景:大促期间订单量暴增,Producer 疯狂写入消息,而下游 Consumer 处理能力有限。如果不加控制,会发生什么? + +1. **内存溢出**:Broker 将消息堆积在内存中,最终 OOM +2. **磁盘满**:持久化消息写满磁盘,Broker 宕机 +3. **消息丢失**:触发淘汰策略(TTL / 队列满丢弃),消息悄无声息地消失 + +> [!question] +> 如果 MQ 天生就是一个缓冲区,消息堆积不是它的基本能力吗?为什么堆积到一定程度反而会出问题? + +这涉及到一个关键认知:**缓冲区是有限的**。任何系统都有资源上限——内存、磁盘、CPU。无限制的堆积只是把问题延后,而不是解决。 + +### 背压(Backpressure)概念 + +背压的核心思想:**让上游感知下游的处理能力,主动降速**。 + +``` +Producer → Broker → Consumer + ↑ ↓ + └──── 处理能力反馈 ────┘ +``` + +这不是 MQ 的专利。TCP 的滑动窗口、HTTP/2 的流控、Reactive Streams 的 `request(n)` 都是背压的不同实现形式。 + +### Broker 端流控 + +#### RabbitMQ 的信用机制(Credit Flow) + +RabbitMQ 使用 **信用机制** 控制消息流速。Producer 发送消息前需要有足够的 credit,Broker 处理完后归还 credit。当 Broker 积压过多,会暂停归还 credit,从而让 Producer 阻塞。 + +``` +Producer --msg1--> Broker (credit: 10→9) +Producer --msg2--> Broker (credit: 9→8) +...积压严重... +Broker 暂停归还 credit +Producer 阻塞,停止发送 +``` + +#### Kafka 的 Producer 端背压 + +Kafka 通过两个参数实现背压: + +- `buffer.memory`:Producer 端缓冲区大小(默认 32MB) +- `max.block.ms`:缓冲区满时,`send()` 方法的阻塞时间 + +当缓冲区满且超过 `max.block.ms`,Producer 抛出 `TimeoutException`。这是硬性的背压信号。 + +```go +// Kafka Producer 配置中的背压参数 +config := sarama.Config{} +config.Producer.RequiredAcks = sarama.WaitForAll +config.Producer.Return.Successes = true +// 缓冲区满时阻塞 1 秒,超时则报错 +// 这就是 Kafka 的背压触发点 +``` + +#### 内存告警与磁盘告警 + +多数 Broker 有内置的资源监控: + +- **RabbitMQ**:内存高水位触发 flow control,阻塞所有连接 +- **Kafka**:`log.retention.bytes` 限制分区大小,磁盘满时拒绝写入 +- **RocketMQ**:`diskMaxUsedSpaceRatio` 触发磁盘保护 + +### Consumer 端反压 + +Consumer 端同样需要流控,核心手段包括: + +**拉取速率控制**:Pull 模式天然支持背压——Consumer 按自己的节奏拉取,拉多少处理多少。RabbitMQ 的 `basicQos(prefetchCount)` 就是限制未 ACK 消息数的典型手段。 + +**处理能力反馈**:Consumer 可以动态上报自己的处理延迟或队列深度,上游据此调整推送速率。 + +**动态调整消费并发**:根据处理延迟自动扩缩 Consumer 实例数。Kubernetes HPA 基于队列深度的自动伸缩是常见方案。 + +```mermaid +graph LR + P["Producer"] -->|"生产消息"| B["Broker"] + B -->|"推送/拉取"| C["Consumer"] + C -->|"ACK / 处理反馈"| B + B -->|"Credit / 阻塞信号"| P + style P fill:#4CAF50,color:#fff + style B fill:#2196F3,color:#fff + style C fill:#FF9800,color:#fff +``` + +### 限流降级策略 + +当背压来不及响应时,需要更积极的流控手段: + +**令牌桶(Token Bucket)**:以恒定速率产生令牌,请求必须持有令牌才能通过。允许一定的突发流量(桶内预存令牌)。 + +**漏桶(Leaky Bucket)**:请求进入桶中,以恒定速率流出。严格平滑流量,但不允许突发。 + +**动态调整生产速率**:根据 Broker 的健康指标(队列深度、内存使用率)动态调整 Producer 的发送速率。 + +```go +// 带背压控制的 Producer 示例 +// 利用 channel 的天然阻塞特性实现背压 +func producer(ch chan<- string, done <-chan struct{}) { + for { + select { + case <-done: + return + case ch <- "message": + // channel 满时自动阻塞,实现背压 + // 上游感知到下游处理不过来,自然降速 + } + } +} + +func consumer(ch <-chan string) { + for msg := range ch { + // 模拟慢消费 + time.Sleep(100 * time.Millisecond) + _ = msg + } +} + +func main() { + // 有界 channel 就是一个天然的背压缓冲区 + ch := make(chan string, 100) + done := make(chan struct{}) + + go producer(ch, done) + go consumer(ch) + + // 当 consumer 处理不过来时, + // channel 满后 producer 自动阻塞 + time.Sleep(5 * time.Second) + close(done) +} +``` + +这段代码的精髓在于 `make(chan string, 100)`——有界 channel 就是一个天然的背压装置。当缓冲区满时,发送方自动阻塞,不需要额外的信号传递。 + +> [!question] +> 令牌桶和漏桶看起来很像,它们的核心区别是什么?什么场景下该用哪个? + +## 关联笔记 + +- [[32-MQ-请求-回复模式]] +- [[33-MQ-与流处理]] +- [[34-事件驱动架构-EDA]] diff --git a/hhs/MQ/08-消息设计模式/32-MQ-请求-回复模式.md b/hhs/MQ/08-消息设计模式/32-MQ-请求-回复模式.md new file mode 100644 index 0000000..07f6468 --- /dev/null +++ b/hhs/MQ/08-消息设计模式/32-MQ-请求-回复模式.md @@ -0,0 +1,140 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 请求-回复模式 + +## 概述 + +请求-回复模式(Request-Reply)是通过 MQ 实现同步 RPC 语义的方式:Producer 发送请求消息,Consumer 处理后将响应发送到 Reply-To 队列,Producer 通过 CorrelationID 匹配响应。这种模式在异步通道上模拟了同步调用,适用于跨网络不可靠、需要消息持久化或遗留系统集成的场景。 + +## 正文 + +### 基本原理 + +传统 RPC 是一问一答:客户端发请求,服务端返回响应。请求-回复模式把这个过程搬到了 MQ 上: + +1. Producer 发送请求消息,携带 `ReplyTo`(回复队列名)和 `CorrelationID`(请求标识) +2. Consumer 从请求队列消费消息,处理业务逻辑 +3. Consumer 将响应发送到 `ReplyTo` 队列,携带相同的 `CorrelationID` +4. Producer 从 ReplyTo 队列消费响应,用 `CorrelationID` 匹配请求 + +```mermaid +sequenceDiagram + participant P as Producer + participant BQ as "Broker Request Queue" + participant C as Consumer + participant RQ as "Broker Reply Queue" + + P->>BQ: "发送请求 (CorrelationID=abc123, ReplyTo=reply-queue)" + BQ->>C: "投递请求" + C->>C: "处理业务逻辑" + C->>RQ: "发送响应 (CorrelationID=abc123)" + RQ->>P: "投递响应" + P->>P: "匹配 CorrelationID, 返回结果" +``` + +关键在于 `CorrelationID`——它是请求和响应之间的"凭证"。没有它,当多个请求并发时,Producer 无法知道哪个响应对应哪个请求。 + +### 与直接 RPC 的对比 + +| 维度 | gRPC / HTTP | MQ 请求-回复 | +|------|------------|-------------| +| 耦合度 | 直连,需要知道服务地址 | 通过 Broker 中转,天然解耦 | +| 削峰能力 | 无,服务端过载直接拒绝 | Broker 缓冲,削峰填谷 | +| 延迟 | 低(一次网络跳转) | 高(至少两次 Broker 中转) | +| 持久化 | 无 | 消息可持久化,故障可恢复 | +| 复杂度 | 低 | 高(CorrelationID 匹配、超时管理) | + +> [!question] +> 请求-回复模式看起来像用 MQ 模拟 RPC,什么情况下这比直接用 gRPC 更好? + +答案藏在"代价"里:你付出的是延迟和复杂度,换来的是解耦、削峰和可靠性。当这些特性比低延迟更重要时,请求-回复模式就是合理的选择。 + +### 适用场景 + +**跨网络不可靠的远程调用**:网络不稳定时,MQ 的持久化和重试机制比直连更可靠。消息不会因为网络抖动而丢失。 + +**需要消息持久化的 RPC**:某些金融场景要求每一次请求都有据可查。MQ 天然支持消息持久化,可以作为审计日志。 + +**遗留系统集成**:老系统可能只暴露 MQ 接口(比如 IBM MQ),新系统通过请求-回复模式与其交互,避免大规模改造。 + +**异步长任务**:某些请求处理耗时很长(分钟级),用直连 RPC 会超时。通过 MQ,Producer 发完请求就可以去做别的,异步接收结果。 + +### 注意事项 + +**超时处理**:Producer 不能无限等待回复。需要设置超时,超时后清理本地的 pending 请求映射。超时的响应如果后续到达,应该被丢弃或记录日志。 + +**Reply-To 队列的生命周期管理**: + +- **持久队列**:一个 Producer 固定使用一个回复队列,适合长期运行的服务 +- **临时队列**:每次请求创建一个临时队列,用完即删。适合短生命周期的客户端,但创建和销毁队列有额外开销 + +**消息确认**:响应消息也需要 ACK 机制,否则 Broker 会反复投递,导致重复处理。 + +### Go 实现 + +下面是通过 RabbitMQ 实现请求-回复模式的 Producer 和 Consumer 示例。 + +**Producer 端**:发送请求并等待回复 + +```go +func rpcClient(ch *amqp.Channel, request string) (string, error) { + // 声明一个独占的临时回复队列 + replyQ, err := ch.QueueDeclare("", false, false, true, false, nil) + if err != nil { + return "", err + } + + corrID := uuid.New().String() + + // 发送请求,附带 ReplyTo 和 CorrelationID + ch.PublishWithContext(ctx, "", "rpc_queue", false, false, + amqp.Publishing{ + ContentType: "text/plain", + CorrelationId: corrID, + ReplyTo: replyQ.Name, + Body: []byte(request), + }) + + // 等待匹配的回复 + msgs, _ := ch.Consume(replyQ.Name, "", true, false, false, false, nil) + for msg := range msgs { + if msg.CorrelationId == corrID { + return string(msg.Body), nil + } + } + return "", fmt.Errorf("timeout") +} +``` + +**Consumer 端**:处理请求并发送回复 + +```go +func rpcServer(ch *amqp.Channel) { + msgs, _ := ch.Consume("rpc_queue", "", false, false, false, false, nil) + for msg := range msgs { + // 处理请求 + result := processRequest(msg.Body) + + // 将回复发送到 ReplyTo 队列,携带相同的 CorrelationID + ch.PublishWithContext(ctx, "", msg.ReplyTo, false, false, + amqp.Publishing{ + ContentType: "text/plain", + CorrelationId: msg.CorrelationId, + Body: []byte(result), + }) + msg.Ack(false) + } +} +``` + +Producer 用一个临时队列接收回复,通过 `CorrelationID` 精确匹配。Consumer 只需要把处理结果发回 `ReplyTo` 队列即可——这就是请求-回复模式的全部核心逻辑。 + +## 关联笔记 + +- [[31-MQ-背压与流控]] +- [[33-MQ-与流处理]] +- [[34-事件驱动架构-EDA]] diff --git a/hhs/MQ/09-流处理与事件驱动/33-MQ-与流处理.md b/hhs/MQ/09-流处理与事件驱动/33-MQ-与流处理.md new file mode 100644 index 0000000..7274249 --- /dev/null +++ b/hhs/MQ/09-流处理与事件驱动/33-MQ-与流处理.md @@ -0,0 +1,156 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 与流处理 + +## 概述 + +消息队列处理的是离散的消息,消费即删;流处理平台处理的是连续的数据流,持久保留、可回溯。本文对比两者的差异,深入流处理的核心概念(事件时间、窗口、Watermark),并介绍 Kafka Streams、Flink、Spark Streaming 三大流处理方案的特点与适用场景。 + +## 正文 + +### 消息队列 vs 流处理平台 + +很多人把 Kafka 叫"消息队列",但它其实更接近一个流处理平台。两者的本质区别在于: + +| 维度 | 消息队列 | 流处理平台 | +|------|---------|-----------| +| 数据模型 | 离散消息,消费即删 | 连续流,持久保留 | +| 消费语义 | 一条消息只被消费一次 | 同一数据可被多次回溯 | +| 处理方式 | 单条处理 | 窗口聚合、流式计算 | +| 典型代表 | RabbitMQ, RocketMQ | Kafka, Pulsar, Flink | + +> [!question] +> Kafka 到底是消息队列还是流处理平台?这个争论有意义吗? + +其实没有意义。Kafka 是一个分布式日志系统,你可以把它当消息队列用,也可以当流处理平台用。关键不在于它"是什么",而在于你怎么用它。 + +### 流处理的核心概念 + +#### 事件时间 vs 处理时间 + +这是流处理中最容易混淆的概念: + +- **事件时间(Event Time)**:事件实际发生的时间,由消息自身携带 +- **处理时间(Processing Time)**:事件被流处理引擎处理的时间 + +两者可能相差毫秒,也可能相差小时(网络延迟、积压、重放)。正确使用事件时间是保证结果准确性的前提。 + +#### 窗口(Window) + +流数据是无限的,但业务计算需要有限的数据集。窗口把无限流切分成有限的"片段": + +- **滚动窗口(Tumbling)**:固定大小,不重叠。每 5 分钟一个窗口 +- **滑动窗口(Sliding)**:固定大小,可重叠。窗口大小 10 分钟,每 1 分钟滑动一次 +- **会话窗口(Session)**:按活跃度切分,超时即关闭窗口 + +#### Watermark + +Watermark 解决的是"迟到数据"问题。它是一个时间戳,表示"在这个时间之前的数据,我认为已经到齐了"。 + +当 Watermark 推过窗口的结束时间,窗口就会触发计算并关闭。但如果数据迟到超过 Watermark,就只能靠 **允许迟到(Allowed Lateness)** 或 **侧输出(Side Output)** 来补救。 + +### Kafka Streams + +Kafka Streams 是一个**轻量级流处理库**,不需要独立集群,直接嵌入应用中运行。 + +核心抽象: + +- **KStream**:无界的、追加式的记录流(类似日志) +- **KTable**:可更新的 changelog 流(类似数据库表) + +```go +// Kafka Streams 的 DSL 思路(伪代码) +// 从 topic 读取流 -> 按 key 分组 -> 聚合 -> 写回 topic +stream := builder.Stream("input-topic") +stream.GroupByKey(). + WindowedBy(TimeWindows.OfSize(5 * time.Minute)). + Count(). + ToStream(). + To("output-topic") +``` + +Kafka Streams 的优势在于运维简单——不需要 Flink 那样的独立集群,应用启动就自动处理,应用停止就自动释放资源。 + +### Flink + Kafka + +Flink 是真正的分布式流处理引擎,与 Kafka 配合是最常见的组合: + +- **Source/Sink Connector**:Flink 原生支持 Kafka 作为数据源和输出目标 +- **Exactly-Once 保障**:Flink 的 checkpoint 机制 + Kafka 的事务消费,端到端 exactly-once +- **状态后端**:RocksDB 状态后端支持超大状态(TB 级别),适合复杂聚合 + +Flink 的核心优势是 **低延迟 + 高吞吐 + 强一致性**。适合对延迟和正确性要求极高的场景。 + +### Spark Streaming + Kafka + +Spark Streaming 使用**微批处理模型**:把流数据切成小批次(通常秒级),每个批次用 Spark 引擎处理。 + +严格来说这不是"真正的流处理",但在很多场景下足够用。优势是复用了 Spark 生态(ML、SQL、GraphX),适合批流一体的需求。Structured Streaming 在此基础上提供了更接近真正流处理的 API。 + +### 流处理适用场景 + +- **实时聚合**:实时 PV/UV 统计、实时 GMV 计算 +- **实时风控**:检测异常登录、欺诈交易,毫秒级响应 +- **实时推荐**:根据用户实时行为更新推荐模型 +- **IoT 数据处理**:传感器数据实时清洗、聚合、告警 + +```mermaid +graph LR + MQ["消息队列"] -->|"数据流"| SP["流处理器"] + SP -->|"聚合结果"| DB["数据库"] + SP -->|"实时告警"| A["告警系统"] + SP -->|"写回"| MQ + style MQ fill:#4CAF50,color:#fff + style SP fill:#2196F3,color:#fff + style DB fill:#FF9800,color:#fff + style A fill:#F44336,color:#fff +``` + +### Go 代码:简化版窗口聚合 + +```go +// 简化版窗口聚合逻辑 +type Event struct { + Timestamp time.Time + Value float64 +} + +func windowAggregate(events <-chan Event, windowSize time.Duration) { + window := make([]Event, 0) + ticker := time.NewTicker(windowSize) + defer ticker.Stop() + + for { + select { + case e := <-events: + window = append(window, e) + case <-ticker.C: + // 窗口关闭,触发聚合计算 + sum := 0.0 + for _, e := range window { + sum += e.Value + } + avg := sum / float64(len(window)) + fmt.Printf("Window closed: count=%d, avg=%.2f\n", len(window), avg) + window = window[:0] // 清空窗口 + } + } +} +``` + +这段代码展示了窗口聚合的核心思想:在一个时间窗口内收集事件,窗口关闭时触发计算。真实场景中还需要处理迟到数据、状态持久化、checkpoint 等问题,但基本思路是一样的。 + +> [!question] +> Kafka Streams 和 Flink 都能做流处理,什么时候选哪个? + +简单说:**Kafka Streams 适合轻量级场景**(日均百万级、逻辑简单、不想运维额外集群),**Flink 适合重量级场景**(日均十亿级、复杂窗口逻辑、需要强一致性保证)。 + +## 关联笔记 + +- [[31-MQ-背压与流控]] +- [[32-MQ-请求-回复模式]] +- [[34-事件驱动架构-EDA]] diff --git a/hhs/MQ/09-流处理与事件驱动/34-事件驱动架构-EDA.md b/hhs/MQ/09-流处理与事件驱动/34-事件驱动架构-EDA.md new file mode 100644 index 0000000..50f4417 --- /dev/null +++ b/hhs/MQ/09-流处理与事件驱动/34-事件驱动架构-EDA.md @@ -0,0 +1,216 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# 事件驱动架构(EDA) + +## 概述 + +事件驱动架构(Event-Driven Architecture, EDA)是系统间通过事件进行通信的架构风格。服务不直接调用对方,而是发布事件、订阅事件,实现松耦合和可扩展性。本文涵盖事件风暴、编排 vs 协同、Saga 模式,以及 EDA 的优势与挑战。 + +## 正文 + +### EDA 定义 + +在传统架构中,服务 A 需要调用服务 B,直接发起 RPC 就行。但这也意味着 A 必须知道 B 的存在、地址和接口——**紧耦合**。 + +EDA 换了一种思路:服务 A 只发布一个事件 "订单已创建",至于谁关心这个事件、怎么处理,A 完全不关心。服务 B、C、D 各自订阅感兴趣的事件,独立响应。 + +```go +// 事件:描述"发生了什么" +type OrderCreatedEvent struct { + OrderID string + UserID string + Amount float64 + CreatedAt time.Time +} + +// 发布事件的服务不需要知道谁会处理它 +func (s *OrderService) CreateOrder(req CreateOrderReq) error { + order := s.repo.Save(req) + // 发布事件,不关心谁订阅 + s.eventBus.Publish(OrderCreatedEvent{ + OrderID: order.ID, + UserID: order.UserID, + Amount: order.Amount, + CreatedAt: time.Now(), + }) + return nil +} +``` + +### 事件风暴(Event Storming) + +事件风暴是一种协作式的需求分析方法论,由 Alberto Brandolini 提出。核心操作很简单: + +1. 找一面大墙,用橙色便利贴写出所有**领域事件**(过去式的动词,如"订单已创建"、"库存已扣减") +2. 按时间线排列事件 +3. 识别触发事件的**命令**(蓝色便利贴)和负责处理的**聚合**(黄色便利贴) +4. 发现**策略**(当某事件发生时自动触发的命令)和**读模型** + +事件风暴的价值不在于产出完美的设计文档,而在于**让业务和技术人员用同一种语言对话**。一面墙上的便利贴比任何 UML 图都直观。 + +### 编排 vs 协同 + +EDA 中服务间的协作方式有两种模式: + +#### 编排(Orchestration) + +中心协调者指挥各服务做事,像乐队指挥。 + +```mermaid +graph LR + OC["编排器"] -->|"1 创建订单"| OS["订单服务"] + OC -->|"2 扣减库存"| IS["库存服务"] + OC -->|"3 发起支付"| PS["支付服务"] + OC -->|"4 安排发货"| SS["发货服务"] + style OC fill:#F44336,color:#fff + style OS fill:#2196F3,color:#fff + style IS fill:#2196F3,color:#fff + style PS fill:#2196F3,color:#fff + style SS fill:#2196F3,color:#fff +``` + +优点:流程清晰可见,容易调试和监控。缺点:编排器是单点,可能成为瓶颈,服务间仍然有一定耦合。 + +#### 协同(Choreography) + +各服务自主响应事件,像舞蹈演员各自跳舞但配合默契。 + +```mermaid +graph LR + OS["订单服务"] -->|"OrderCreated"| IS["库存服务"] + IS -->|"StockReserved"| PS["支付服务"] + PS -->|"PaymentDone"| SS["发货服务"] + style OS fill:#4CAF50,color:#fff + style IS fill:#2196F3,color:#fff + style PS fill:#FF9800,color:#fff + style SS fill:#9C27B0,color:#fff +``` + +优点:真正松耦合,没有单点瓶颈,各服务可独立演进。缺点:流程分散在各服务中,难以全局理解,调试困难。 + +> [!question] +> 编排式 Saga 和协同式 Saga 各有什么优缺点?你在项目中会如何选择? + +### Saga 模式 + +分布式事务的经典问题是:如何跨多个服务保证数据一致性?两阶段提交(2PC)性能差、可用性低。Saga 模式提供了一种更实用的方案:**把长事务拆成多个本地事务,每个本地事务有对应的补偿操作**。 + +举例:创建订单涉及三个步骤—— + +1. 订单服务:创建订单(补偿:取消订单) +2. 库存服务:扣减库存(补偿:恢复库存) +3. 支付服务:发起扣款(补偿:退款) + +如果步骤 2 失败,执行步骤 1 的补偿操作(取消订单)。如果步骤 3 失败,执行步骤 2 和 1 的补偿操作。 + +**编排式 Saga**:由一个 Saga 协调者集中管理流程和补偿逻辑。协调者知道每一步该做什么、失败了怎么回滚。 + +**协同式 Saga**:没有中心协调者,每个服务监听事件,完成后发布新事件。失败时发布补偿事件,上游服务监听到后自行回滚。 + +### 事件驱动的优势 + +**松耦合**:生产者不依赖消费者,服务可独立开发、部署、扩展。新增一个消费者不需要修改生产者。 + +**可扩展**:消费者可以水平扩展,消息队列天然支持分区和并行消费。 + +**天然支持审计**:事件就是操作日志。任何时候都可以回放事件流,重建系统状态。这对金融、医疗等合规要求高的场景非常有价值。 + +### 事件驱动的挑战 + +**最终一致性**:事件驱动天然是异步的,不同服务看到的状态可能有短暂的不一致。需要在业务上接受"最终一致性",而不是强一致。 + +**调试困难**:请求链路被事件拆散,排查问题时需要在多个服务间追踪 CorrelationID。分布式链路追踪(Jaeger、Zipkin)是必备工具。 + +**事件风暴(过多事件)**:如果事件粒度太细、订阅关系太复杂,系统会变成"事件风暴"——满天飞的事件让人难以理解业务流程。需要在事件设计上有纪律。 + +### Go 代码:协同式 Saga 实现 + +```go +// 事件定义 +type Event struct { + Type string // 事件类型 + Payload interface{} // 事件数据 + TraceID string // 链路追踪ID +} + +// 事件总线 +type EventBus struct { + subscribers map[string][]func(Event) +} + +func (eb *EventBus) Subscribe(eventType string, handler func(Event)) { + eb.subscribers[eventType] = append(eb.subscribers[eventType], handler) +} + +func (eb *EventBus) Publish(event Event) { + for _, handler := range eb.subscribers[event.Type] { + go handler(event) // 异步处理 + } +} + +// 库存服务:监听订单创建事件,扣减库存 +func InventoryService(bus *EventBus) { + bus.Subscribe("OrderCreated", func(e Event) { + order := e.Payload.(OrderCreatedEvent) + err := deductStock(order.Items) + if err != nil { + // 扣减失败,发布补偿事件 + bus.Publish(Event{ + Type: "StockDeductFailed", + Payload: order, + TraceID: e.TraceID, + }) + return + } + // 扣减成功,发布下一步事件 + bus.Publish(Event{ + Type: "StockReserved", + Payload: order, + TraceID: e.TraceID, + }) + }) +} + +// 支付服务:监听库存预留事件 +func PaymentService(bus *EventBus) { + bus.Subscribe("StockReserved", func(e Event) { + order := e.Payload.(OrderCreatedEvent) + err := chargePayment(order.UserID, order.Amount) + if err != nil { + // 支付失败,触发库存恢复 + bus.Publish(Event{ + Type: "PaymentFailed", + Payload: order, + TraceID: e.TraceID, + }) + return + } + bus.Publish(Event{ + Type: "PaymentDone", + Payload: order, + TraceID: e.TraceID, + }) + }) +} + +// 订单服务:监听支付失败事件,取消订单(补偿) +func OrderCompensation(bus *EventBus) { + bus.Subscribe("PaymentFailed", func(e Event) { + order := e.Payload.(OrderCreatedEvent) + cancelOrder(order.OrderID) + fmt.Printf("Order %s compensated\n", order.OrderID) + }) +} +``` + +这个简化实现展示了协同式 Saga 的核心:每个服务只关心自己监听的事件和需要发布的事件。失败时通过补偿事件触发回滚链。`TraceID` 贯穿整个链路,方便追踪和调试。 + +## 关联笔记 + +- [[31-MQ-背压与流控]] +- [[32-MQ-请求-回复模式]] +- [[33-MQ-与流处理]] diff --git a/hhs/MQ/09-流处理与事件驱动/35-MQ-与-CDC.md b/hhs/MQ/09-流处理与事件驱动/35-MQ-与-CDC.md new file mode 100644 index 0000000..9e65cea --- /dev/null +++ b/hhs/MQ/09-流处理与事件驱动/35-MQ-与-CDC.md @@ -0,0 +1,141 @@ +--- +tags: + - MQ + - CDC + - Kafka + - 数据同步 +create time: 2026-05-24 19:52 +--- + +# MQ 与 CDC + +## 概述 + +CDC(Change Data Capture)是一种捕获数据库变更事件并将其发布到消息队列的技术模式。它解耦了数据变更的感知与消费,是构建实时数据管道的基石。 + +## 正文 + +### 什么是 CDC + +传统架构中,当一个服务写入数据库后,其他系统想拿到这份数据,往往依赖定时轮询或者应用层双写。这两种方式要么延迟高,要么耦合重。CDC 的思路完全不同——它直接从数据库的变更日志中"读"出发生了什么变化,然后以事件的形式推送到消息队列。 + +简单来说,CDC 让数据库的每一次 INSERT、UPDATE、DELETE 都变成一条消息,下游系统按需消费即可。 + +### CDC 的三种实现方式 + +**基于触发器(Trigger-based)**:在数据库上创建触发器,每次数据变更时触发逻辑将变更写入一张中间表,再由外部程序读取。优点是实现简单,缺点是触发器会增加写操作的延迟,对高写入量的系统不太友好。 + +**基于日志解析(Log-based)**:直接读取数据库的 WAL(Write-Ahead Log)或 binlog。这是目前最主流的方式,对源库几乎没有侵入性。MySQL 的 binlog、PostgreSQL 的 WAL、Oracle 的 redo log 都可以被解析。缺点是需要处理日志格式的兼容性问题,不同数据库版本可能有差异。 + +**基于时间戳(Timestamp-based)**:每张表维护一个 `updated_at` 字段,定期查询变更数据。这种方式最简单,但只能捕获新增和更新,无法捕获删除操作,而且存在时间窗口内的延迟。 + +> [!question] +> 这三种方式各有取舍。如果你的系统对延迟要求在秒级,而且不能接受对源库有任何性能影响,你会选择哪种?如果还需要捕获 DELETE 操作呢? + +### Debezium + Kafka Connect + +**Debezium** 是目前最流行的开源 CDC 框架,底层基于日志解析。它以 Kafka Connect 插件的形式运行,支持 MySQL、PostgreSQL、MongoDB、SQL Server、Oracle 等主流数据库。 + +Kafka Connect 的架构分为两种角色: + +- **Source Connector**:负责从外部系统(如数据库)读取数据,写入 Kafka Topic。Debezium 就是一个 Source Connector。 +- **Sink Connector**:负责从 Kafka Topic 读取数据,写入目标系统(如 Elasticsearch、Redis、另一个数据库)。 + +两者配合,就形成了一条完整的数据管道: + +```mermaid +graph LR + MySQL["MySQL"] -->|"binlog"| Debezium["Debezium Source Connector"] + PG["PostgreSQL"] -->|"WAL"| Debezium + Debezium -->|"变更事件"| Kafka["Kafka"] + Kafka -->|"消费"| ES["Elasticsearch"] + Kafka -->|"消费"| DW["Data Warehouse"] + Kafka -->|"消费"| Cache["Redis Cache"] +``` + +这里有个关键设计:Debezium 将每张表的变更发布到独立的 Topic(默认命名规则为 `serverName.databaseName.tableName`),下游消费者可以按表订阅,互不干扰。 + +### 典型应用场景 + +**数据库实时同步到 ES**:MySQL 作为主存储,ES 作为搜索索引。通过 CDC 将 MySQL 的变更实时同步到 ES,避免了双写带来的数据一致性问题。 + +**数据仓库实时 ETL**:传统 ETL 是 T+1 的批处理模式,引入 CDC 后可以做到分钟级甚至秒级的数据入仓,大幅缩短数据时效性。 + +**缓存一致性**:数据库变更时,通过 CDC 事件通知缓存层失效或更新,比应用层主动删除缓存更可靠,因为它不依赖业务代码的正确性。 + +### CDC vs 应用层双写 + +很多团队在做数据同步时,第一反应是在应用代码里"写完 A 库再写 B 库"。这种双写看似简单,实则问题重重: + +1. **一致性难保证**:如果第一个写成功、第二个写失败,数据就不一致了。引入分布式事务又太重。 +2. **代码侵入**:每个写操作都要额外写同步逻辑,业务代码和数据同步耦合在一起。 +3. **维护成本**:新增一个下游系统,就要改一次业务代码。 + +CDC 完全没有这些问题。它在数据库层面捕获变更,业务代码完全无感知。新增下游系统只需要加一个消费者,不改一行业务代码。 + +> [!question] +> CDC 虽然解决了双写的一致性问题,但 CDC 本身的事件投递是"至少一次"(at-least-once)语义,下游消费者如何保证幂等? + +### Go 代码示例:消费 CDC 事件 + +```go +package main + +import ( + "context" + "encoding/json" + "log" + + "github.com/segmentio/kafka-go" +) + +// CDCEvent Debezium 产出的 CDC 事件结构 +type CDCEvent struct { + Before map[string]interface{} `json:"before"` // 变更前的数据 + After map[string]interface{} `json:"after"` // 变更后的数据 + Source struct { + Table string `json:"table"` // 源表名 + } `json:"source"` + Op string `json:"op"` // 操作类型: c=create, u=update, d=delete +} + +func main() { + reader := kafka.NewReader(kafka.ReaderConfig{ + Brokers: []string{"localhost:9092"}, + Topic: "myserver.mydb.orders", // Debezium 默认 Topic 命名 + GroupID: "cdc-consumer", + }) + defer reader.Close() + + for { + msg, err := reader.ReadMessage(context.Background()) + if err != nil { + log.Printf("read error: %v", err) + continue + } + + var event CDCEvent + if err := json.Unmarshal(msg.Value, &event); err != nil { + log.Printf("unmarshal error: %v", err) + continue + } + + // 根据操作类型分发处理 + switch event.Op { + case "c", "u": // 创建或更新 → 同步到 ES + log.Printf("sync to ES: table=%s, id=%v", event.Source.Table, event.After["id"]) + case "d": // 删除 → 清理缓存 + log.Printf("invalidate cache: table=%s, id=%v", event.Source.Table, event.Before["id"]) + } + } +} +``` + +这段代码消费 Debezium 产出的 CDC 事件,根据操作类型(create/update/delete)分发到不同的处理逻辑。实际生产中,你还需要处理 Schema Registry 的反序列化、错误重试、死信队列等问题。 + +## 关联笔记 + +- [[30-MQ-核心概念与选型]] +- [[33-MQ-Kafka-架构与核心机制]] +- [[36-MQ-监控指标与告警]] +- [[38-MQ-消息轨迹与链路追踪]] diff --git a/hhs/MQ/10-监控与运维/36-MQ-监控指标与告警.md b/hhs/MQ/10-监控与运维/36-MQ-监控指标与告警.md new file mode 100644 index 0000000..51ada49 --- /dev/null +++ b/hhs/MQ/10-监控与运维/36-MQ-监控指标与告警.md @@ -0,0 +1,178 @@ +--- +tags: + - MQ + - 监控 + - Prometheus + - Grafana +create time: 2026-05-24 19:52 +--- + +# MQ 监控指标与告警 + +## 概述 + +消息队列作为系统间的"黑盒"中间件,一旦出问题往往影响全局。本文梳理 MQ 监控的核心指标体系,涵盖 Broker、Producer、Consumer 三个维度,并介绍基于 Prometheus + Grafana 的监控告警实践。 + +## 正文 + +### 为什么 MQ 需要专门的监控 + +消息队列不像 Web 服务那样有直观的 HTTP 状态码,它的健康状况藏在各种内部指标里。一个 Topic 的消费延迟可能已经到了几小时,但表面上看 Producer 和 Consumer 都在正常运行——只是 Consumer 跟不上了。等到下游业务发现数据不一致时,往往已经积重难返。 + +MQ 监控的核心目标就三个字:**看得见**。看得见消息有没有堆积,看得见 Broker 有没有过载,看得见消费者有没有掉队。 + +### Broker 核心指标 + +| 指标 | 含义 | 关注点 | +|------|------|--------| +| 消息入队速率 (MessagesIn/s) | 每秒写入的消息数 | 突增可能表示上游流量异常 | +| 消息出队速率 (BytesOut/s) | 每秒读出的字节数 | 与入队速率对比判断消费是否跟得上 | +| 磁盘使用率 | 日志段占用的磁盘空间 | 超过 80% 就该警惕 | +| 网络 IO | 网卡吞吐量 | 高负载时容易成为瓶颈 | +| 连接数 | 当前活跃的客户端连接 | 突增可能是连接泄漏 | +| ISR 数量 | In-Sync Replicas 数量 | ISR 收缩意味着有 Follower 掉队 | + +> [!question] +> ISR(In-Sync Replicas)数量减少时,消息的可靠性会受到什么影响?如果你设置了 `acks=all`,ISR 缩减到 1 会发生什么? + +### Producer 核心指标 + +Producer 侧最需要关注的是**发送成功率**和**发送延迟**。 + +- **发送成功率**:失败的发送请求占比。如果持续有失败,说明 Broker 端有问题(磁盘满、网络分区)。 +- **发送延迟 P99**:99 分位的发送耗时。正常情况下应该在毫秒级,如果飙升到秒级,说明 Broker 压力过大或者网络抖动。 +- **重试率**:发送失败后重试的比例。高重试率意味着消息可能乱序(Kafka 中同一 Partition 内的消息顺序靠 offset 保证,但重试可能导致后发的消息先到)。 +- **批大小(Batch Size)**:Producer 端的批量发送大小。批越大吞吐越高,但延迟也越大。 + +### Consumer 核心指标 + +Consumer 侧最核心的指标是 **Consumer Lag**——消费者当前的消费位置与最新消息之间的差距。 + +Lag 本质上衡量的是"消费者落后了多远"。如果 Lag 持续增长,说明消费者处理不过来,消息正在积压。 + +- **消费速率**:每秒处理的消息数,应与 Producer 的入队速率基本持平。 +- **Rebalance 次数**:Consumer Group 发生 Rebalance 的频率。频繁 Rebalance 会导致消费暂停,通常是 Consumer 不稳定(频繁重启、处理超时)引起的。 +- **处理耗时**:单条消息从业务处理的平均耗时。如果处理耗时接近 `max.poll.interval.ms`,就有触发 Rebalance 的风险。 + +### Prometheus + Grafana 监控搭建 + +监控架构分四层: + +```mermaid +graph LR + MQ["MQ Cluster"] -->|"指标暴露"| Exporter["MQ Exporter"] + Exporter -->|"pull /metrics"| Prometheus["Prometheus"] + Prometheus -->|"查询"| Grafana["Grafana"] + Prometheus -->|"告警规则"| AlertManager["AlertManager"] + AlertManager -->|"通知"| Notify["DingTalk / PagerDuty"] +``` + +**Exporter 配置**:Kafka 可以用 `kafka_exporter` 或 JMX Exporter 暴露指标。`kafka_exporter` 轻量级,适合快速接入;JMX Exporter 功能更全,但配置更复杂。 + +```yaml +# Prometheus 配置示例 +scrape_configs: + - job_name: "kafka" + static_configs: + - targets: ["kafka-exporter:9308"] + scrape_interval: 15s +``` + +**核心 Dashboard 设计**:一个实用的 MQ Dashboard 通常包含以下几个面板: + +1. **总览面板**:集群消息入队/出队速率、总 Lag 数、Broker 存活数。 +2. **Broker 面板**:每个 Broker 的磁盘使用率、网络 IO、ISR 数量、请求队列深度。 +3. **Topic 面板**:每个 Topic 的消息速率、Partition 分布、Lag 趋势。 +4. **Consumer Group 面板**:每个 Group 的 Lag、消费速率、Rebalance 次数。 + +### 告警策略 + +好的告警策略不是"什么都报",而是"报了就要行动"。三种常用的告警模型: + +**基于阈值**:最直观,比如 `Consumer Lag > 10000` 触发告警。适合有明确 SLO 的场景。 + +**基于趋势**:Lag 的绝对值不重要,重要的是它是否在持续增长。比如"Lag 连续 10 分钟单调递增"就该告警,即使当前 Lag 只有 100。 + +**基于异常检测**:用统计方法检测指标是否偏离正常范围。比如消费速率突然降到 0,但 Producer 侧没有任何变化,这很可能是 Consumer 挂了。 + +> [!question] +> 你能设计一个既不会频繁误报、又不会漏报关键问题的 Consumer Lag 告警规则吗?阈值设多少合适? + +### Go 代码示例:自定义 Consumer Lag 监控 + +```go +package main + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/IBM/sarama" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // consumerLag 每个 Partition 的消费延迟 + consumerLag = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "myapp_consumer_lag", + Help: "Consumer lag per partition", + }, + []string{"topic", "partition", "group"}, + ) +) + +func init() { + prometheus.MustRegister(consumerLag) +} + +// collectLag 定期采集 Consumer Lag 并暴露给 Prometheus +func collectLag(brokers []string, group, topic string) { + config := sarama.NewConfig() + client, err := sarama.NewClient(brokers, config) + if err != nil { + log.Fatalf("create client: %v", err) + } + defer client.Close() + + ticker := time.NewTicker(10 * time.Second) + for range ticker.C { + partitions, _ := client.Partitions(topic) + for _, p := range partitions { + // 获取最新 offset + newest, _ := client.GetOffset(topic, p, sarama.OffsetNewest) + // 获取消费者组当前 offset(实际需通过 Admin API) + // 这里简化为从外部获取 + groupOffset := getGroupOffset(group, topic, p) + lag := float64(newest - groupOffset) + consumerLag.WithLabelValues(topic, + string(rune(p+'0')), group).Set(lag) + } + } +} + +func getGroupOffset(group, topic string, partition int32) int64 { + // 实际实现中通过 Kafka Admin API 获取 + return 0 +} + +func main() { + go collectLag([]string{"localhost:9092"}, "my-group", "orders") + + http.Handle("/metrics", promhttp.Handler()) + log.Println("metrics server on :2112") + log.Fatal(http.ListenAndServe(":2112", nil)) +} +``` + +这段代码通过 Sarama 客户端定期查询每个 Partition 的最新 offset 和消费者组当前 offset,计算差值作为 Lag,然后通过 Prometheus Gauge 暴露。Grafana 中可以直接用 `myapp_consumer_lag` 指标配置 Dashboard 和告警规则。 + +## 关联笔记 + +- [[30-MQ-核心概念与选型]] +- [[33-MQ-Kafka-架构与核心机制]] +- [[37-MQ-消费积压治理]] +- [[35-MQ-与-CDC]] diff --git a/hhs/MQ/10-监控与运维/37-MQ-消费积压治理.md b/hhs/MQ/10-监控与运维/37-MQ-消费积压治理.md new file mode 100644 index 0000000..03f160f --- /dev/null +++ b/hhs/MQ/10-监控与运维/37-MQ-消费积压治理.md @@ -0,0 +1,192 @@ +--- +tags: + - MQ + - 消费积压 + - 性能优化 + - Kafka +create time: 2026-05-24 19:52 +--- + +# MQ 消费积压治理 + +## 概述 + +消息积压是 MQ 使用中最常见的"事故"之一:Producer 疯狂写入,Consumer 跟不上节奏,Lag 越堆越高,下游业务开始告警。本文从积压的成因、紧急处理到根本解决方案,系统性地梳理治理思路。 + +## 正文 + +### 消息积压是怎么产生的 + +消息积压的本质很简单:**生产速度 > 消费速度**。但具体原因千差万别: + +1. **Consumer 消费速度跟不上**:业务逻辑变重了(比如新增了一次远程调用),单条消息处理耗时从 10ms 涨到 500ms,消费速率直接腰斩。 +2. **Consumer 故障**:Consumer 实例挂了、OOM 了、被 K8s 驱逐了,但你不知道——因为消息队列不会主动告诉你"你的消费者已经死了"。 +3. **Rebalance 风暴**:Consumer Group 频繁 Rebalance,每次 Rebalance 期间所有 Consumer 都暂停消费。如果 Consumer 处理超时触发 Rebalance,然后 Rebalance 又导致更多超时,就会进入恶性循环。 +4. **流量突增**:大促、秒杀、爬虫攻击,Producer 端流量瞬间翻几倍,但 Consumer 还是原来那点资源。 + +### 积压的影响 + +积压不是"慢一点"那么简单,它会引发连锁反应: + +- **消息延迟增大**:用户下单后要等几分钟才能收到确认通知,体验极差。 +- **触发过期删除**:Kafka 的日志保留策略(比如 7 天),如果积压的消息超过了保留时间,还没被消费就被删了,数据丢失。 +- **下游业务受影响**:如果下游依赖 CDC 事件更新缓存或索引,积压会导致缓存长时间不更新,数据不一致。 + +### 紧急处理方案 + +发现积压后,第一步不是优化代码,而是先止血。 + +**方案一:快速扩容 Consumer 实例** + +这是最直接的办法。Consumer Lag 了 10 万条?加 Consumer 实例就行。但有个前提:**Partition 数量要够**。 + +Kafka 中,一个 Partition 最多只能被同一个 Consumer Group 中的一个 Consumer 消费。如果你的 Topic 只有 4 个 Partition,那最多只能有 4 个 Consumer 同时消费,加再多实例也没用。 + +> [!question] +> 你的 Topic 有 8 个 Partition,当前跑了 3 个 Consumer 实例,Lag 在持续增长。这时候你决定加到 10 个 Consumer,会发生什么?多出来的 2 个在干嘛? + +**方案二:临时 Topic 转发** + +如果 Consumer 的消费逻辑很重(比如涉及数据库写入、远程调用),而且短时间内改不了,可以这样做: + +1. 写一个"轻量消费者",只做一件事:从积压的 Topic 读消息,批量转发到一个临时 Topic。 +2. 启动一批新的 Consumer 消费临时 Topic,这些 Consumer 的消费逻辑可以是更轻量的版本(比如只写入一个临时表,不做完整业务处理)。 +3. 积压消化完后,再慢慢回补业务逻辑。 + +这个方案的核心思想是:**先快速消费完,再慢慢处理**。 + +**方案三:降级消费** + +如果积压的消息中有关键消息和非关键消息(比如订单消息是关键的,日志消息是非关键的),可以临时修改 Consumer 逻辑,跳过非关键消息,优先处理核心业务。 + +```go +// 降级消费:跳过非关键消息 +func handleMsg(msg *kafka.Message) { + priority := msg.Headers.Get("priority") + if priority == "low" && isBacklogHigh() { + // 积压严重时,低优先级消息直接丢弃 + log.Printf("skip low priority msg: offset=%d", msg.Offset) + return + } + // 正常处理关键消息 + processBusinessLogic(msg) +} +``` + +> [!question] +> 降级消费意味着丢弃部分消息。在什么业务场景下这是可以接受的?你能想到哪些场景绝对不能降级? + +### 根本解决:优化消费逻辑 + +紧急处理只是止血,要根治得从消费逻辑入手。 + +**减少 IO 操作**:消费逻辑中的数据库写入、远程调用是最常见的瓶颈。检查一下是不是每条消息都触发一次 DB 写入?能不能改成批量写入? + +**异步处理**:消费逻辑中如果有非关键步骤(比如发通知、写日志),可以异步化。消费者只做最关键的操作(比如写主库),其余的丢到 goroutine 或者另一个队列。 + +**批量消费**:攒一批消息一起处理,减少网络往返和事务开销。 + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/segmentio/kafka-go" +) + +func main() { + reader := kafka.NewReader(kafka.ReaderConfig{ + Brokers: []string{"localhost:9092"}, + Topic: "orders", + GroupID: "order-processor", + MinBytes: 1, // 最少 1 字节就返回 + MaxBytes: 10e6, // 最多 10MB + CommitInterval: 0, // 手动提交 + }) + defer reader.Close() + + batch := make([]kafka.Message, 0, 100) + ticker := time.NewTicker(500 * time.Millisecond) // 每 500ms 刷一次 + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if len(batch) == 0 { + continue + } + // 批量写入数据库(一个事务搞定) + if err := batchInsert(batch); err != nil { + log.Printf("batch insert error: %v", err) + continue + } + // 手动提交 offset + if err := reader.CommitMessages(context.Background(), batch[len(batch)-1]); err != nil { + log.Printf("commit error: %v", err) + } + log.Printf("processed batch of %d messages", len(batch)) + batch = batch[:0] // 清空批次 + + default: + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + msg, err := reader.ReadMessage(ctx) + cancel() + if err != nil { + continue // 超时,继续攒消息 + } + batch = append(batch, msg) + if len(batch) >= 100 { + // 批次满了,立即处理 + ticker.Reset(0) // 触发立即刷入 + } + } + } +} + +func batchInsert(msgs []kafka.Message) error { + // 批量插入数据库的逻辑 + return nil +} +``` + +这段代码展示了批量消费的核心思路:攒够 100 条或者等 500ms,然后一次性写入数据库。相比逐条消费逐条写入,批量处理可以将数据库写入次数降低一到两个数量级。 + +**增加 Partition**:如果优化消费逻辑后还是不够,那就增加 Partition 数量,从根本上提升消费并行度。但这需要新建 Topic、迁移数据、修改 Producer 配置,成本不低,应该提前做好容量规划。 + +### 预防措施 + +与其事后救火,不如事前防火: + +- **容量规划**:根据历史流量峰值,预留 2-3 倍的消费能力。 +- **压力测试**:上线前用压测工具(如 Kafka 的 `kafka-producer-perf-test`)验证消费能力。 +- **告警阈值**:设置 Lag 告警,比如 Lag > 5000 触发 P2 告警,Lag > 50000 触发 P1 告警,Lag 连续 5 分钟单调递增触发紧急告警。 + +### 积压处理决策流程 + +```mermaid +flowchart TD + A["发现消费积压"] --> B{"积压原因?"} + B -->|"Consumer 实例挂了"| C["重启/扩容 Consumer"] + B -->|"消费逻辑太慢"| D{"能否快速优化?"} + B -->|"流量突增"| E{"Partition 数量够?"} + D -->|"能"| F["异步化/批量消费"] + D -->|"不能"| G["临时 Topic 转发"] + E -->|"够"| C + E -->|"不够"| H["降级消费 + 后续扩容 Partition"] + C --> I["观察 Lag 趋势"] + F --> I + G --> I + H --> I + I -->|"Lag 恢复"| J["复盘 + 优化预案"] + I -->|"Lag 未恢复"| B +``` + +## 关联笔记 + +- [[30-MQ-核心概念与选型]] +- [[33-MQ-Kafka-架构与核心机制]] +- [[36-MQ-监控指标与告警]] +- [[35-MQ-与-CDC]] diff --git a/hhs/MQ/10-监控与运维/38-MQ-消息轨迹与链路追踪.md b/hhs/MQ/10-监控与运维/38-MQ-消息轨迹与链路追踪.md new file mode 100644 index 0000000..3d12a50 --- /dev/null +++ b/hhs/MQ/10-监控与运维/38-MQ-消息轨迹与链路追踪.md @@ -0,0 +1,188 @@ +--- +tags: + - MQ + - 链路追踪 + - 可观测性 + - 分布式系统 +create time: 2026-05-24 19:52 +--- + +# MQ 消息轨迹与链路追踪 + +## 概述 + +"消息发了,但对方说没收到。"这句话是分布式系统调试中最让人头疼的开场白。消息轨迹与链路追踪就是为了解决这个问题——让你清楚地知道一条消息从生产到消费的完整生命周期。 + +## 正文 + +### 为什么需要消息轨迹 + +在微服务架构中,一条消息从 Producer 发出,经过 Broker 存储,最终被 Consumer 消费处理。这个链路上任何一个环节出问题,排查起来都非常困难: + +- 消息到底发出去了吗?Broker 收到了吗? +- 消息被哪个 Consumer Group 消费了? +- 消费成功了还是失败了?失败的原因是什么? +- 一条业务消息从 A 服务到 B 服务到 C 服务,整条链路耗时多少? + +传统的日志排查方式效率极低——你需要登录多台机器,grep 关键字,然后手动拼凑时间线。消息轨迹就是把这些信息结构化记录下来,让排查变成"查一下就知道"。 + +### 消息唯一标识设计 + +要追踪一条消息,首先得能唯一标识它。这里有三个关键标识: + +**MessageID**:消息在 Broker 端的唯一标识。Kafka 用 `Topic + Partition + Offset` 三元组定位一条消息,RocketMQ 有独立的 MessageID。这是技术层面的唯一标识。 + +**业务 Key**:业务层面的唯一标识,比如订单号 `order_id`。一条消息可能因为重试产生多个 MessageID,但业务 Key 是不变的。排查问题时,通常从业务 Key 入手。 + +**TraceID**:链路追踪层面的标识,贯穿整个调用链。一个 TraceID 可以关联"HTTP 请求 → 消息发送 → 消息消费 → 数据库写入"这条完整链路。 + +三者的关系:MessageID 定位消息,业务 Key 定位业务实体,TraceID 串联链路。排查问题时,通常先用业务 Key 找到消息,再用 TraceID 看完整链路。 + +> [!question] +> 如果一条消息因为消费失败被重试了 3 次,最终成功。从消息轨迹的角度看,这 3 次重试应该怎么记录?是 3 条独立轨迹还是一条轨迹的 3 个消费记录? + +### 消息轨迹的三个阶段 + +一条消息的完整生命周期可以分为三个阶段,每个阶段都需要记录关键信息: + +**生产轨迹**:Producer 发送消息时记录。包括发送时间、发送结果(成功/失败)、目标 Topic、消息 Key、消息大小、耗时。 + +**存储轨迹**:Broker 接收消息时记录。包括入库时间、分配的 Partition、分配的 Offset、Broker 节点地址。 + +**消费轨迹**:Consumer 消费消息时记录。包括消费时间、消费结果(成功/失败/重试)、消费耗时、Consumer 实例标识、处理异常信息。 + +```mermaid +graph LR + Producer["Producer"] -->|"1. 发送消息"| Broker["Broker"] + Broker -->|"2. 存储消息"| Storage["Storage"] + Broker -->|"3. 投递消息"| Consumer["Consumer"] + Producer -.->|"生产轨迹"| TraceDB["Trace Storage"] + Broker -.->|"存储轨迹"| TraceDB + Consumer -.->|"消费轨迹"| TraceDB + TraceDB -->|"查询"| UI["Trace UI"] +``` + +### RocketMQ 消息轨迹原生支持 + +RocketMQ 内置了消息轨迹功能,这是它相比 Kafka 的一个差异化特性。启用方式非常简单: + +```go +// Producer 端启用消息轨迹 +producer, _ := rocketmq.NewProducer( + producer.WithNameServer([]string{"127.0.0.1:9876"}), + producer.WithTrace(&primitive.TraceConfig{ + TraceTopic: "RMQ_SYS_TRACE_TOPIC", // 轨迹数据写入的 Topic + Access: primitive.Local, + }), +) +``` + +RocketMQ 的轨迹数据结构大致如下: + +```go +type TraceTransferBean struct { + TimeStamp int64 // 时间戳 + TraceType string // 生产/消费 + MsgId string // 消息 ID + Topic string // Topic + MsgType string // 消息类型 + GroupName string // 消费组 + Keys string // 业务 Key + CostTime int64 // 处理耗时 + Success bool // 是否成功 + TraceBeans []TraceBean // 轨迹详情 +} +``` + +轨迹数据本身也被当作一条消息,写入一个专门的 Trace Topic。这样做的好处是轨迹记录不会影响主业务流程,轨迹数据也可以像普通消息一样被消费和分析。 + +### 链路集成:与分布式追踪系统对接 + +消息轨迹解决的是 MQ 维度的问题,但在微服务架构中,一条业务链路往往跨越 HTTP 调用、消息队列、数据库操作等多个维度。这时候需要将 MQ 轨迹集成到分布式追踪系统中。 + +主流的分布式追踪系统(Jaeger、Zipkin、SkyWalking)都支持通过 TraceID 串联跨服务的调用链。集成的关键是:**在消息 Header 中传播 TraceID**。 + +Producer 发送消息时,将当前 Span 的 TraceID 写入消息 Header: + +```go +package main + +import ( + "context" + "log" + + "github.com/segmentio/kafka-go" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +func sendMessage(ctx context.Context, topic string, value []byte) { + // 从当前 Context 提取 TraceID,注入到消息 Header + propagator := otel.GetTextMapPropagator() + headers := make(map[string]string) + propagator.Inject(ctx, propagation.MapCarrier(headers)) + + // 将 TraceID 作为消息 Header 发送 + kafkaHeaders := make([]kafka.Header, 0, len(headers)) + for k, v := range headers { + kafkaHeaders = append(kafkaHeaders, kafka.Header{Key: k, Value: []byte(v)}) + } + + writer := &kafka.Writer{ + Addr: kafka.TCP("localhost:9092"), + Topic: topic, + } + defer writer.Close() + + err := writer.WriteMessages(ctx, kafka.Message{ + Value: value, + Headers: kafkaHeaders, + }) + if err != nil { + log.Printf("send error: %v", err) + } +} + +func consumeMessage(ctx context.Context, msg kafka.Message) { + // 从消息 Header 中提取 TraceID,恢复 Trace 上下文 + propagator := otel.GetTextMapPropagator() + headers := make(map[string]string) + for _, h := range msg.Headers { + headers[h.Key] = string(h.Value) + } + ctx = propagator.Extract(ctx, propagation.MapCarrier(headers)) + + // 在这条 Trace 下记录消费 Span + tracer := otel.Tracer("mq-consumer") + _, span := tracer.Start(ctx, "consume-message") + defer span.End() + + // 业务处理... +} +``` + +这段代码展示了 TraceID 的传播机制:Producer 端通过 OpenTelemetry 的 Propagator 将 TraceID 注入消息 Header,Consumer 端从 Header 中提取 TraceID 并恢复 Trace 上下文。这样,一次 HTTP 请求触发的消息发送和消费,就能在 Jaeger 中看到完整的链路视图。 + +### 消息审计日志 + +除了技术排查,消息轨迹还有一个重要用途:**审计**。 + +在金融、电商等场景中,你需要知道"谁在什么时候发了什么消息"、"谁在什么时候消费了什么消息"。这些信息不仅是排查工具,也是合规要求。 + +审计日志通常记录以下信息: + +- 操作人/操作服务的标识 +- 操作时间 +- 操作类型(生产/消费/删除) +- Topic 和消息 Key +- 消息摘要(不是完整内容,通常是前 256 字节 + Hash) + +> [!question] +> 消息轨迹会增加额外的存储和性能开销。如果你的系统每天产生 1 亿条消息,每条消息记录 3 条轨迹(生产/存储/消费),每天就有 3 亿条轨迹数据。生产环境应该如何平衡追踪粒度和性能?采样是一个好策略吗? + +## 关联笔记 + +- [[30-MQ-核心概念与选型]] +- [[33-MQ-Kafka-架构与核心机制]] +- [[35-MQ-与-CDC]] +- [[36-MQ-监控指标与告警]] diff --git a/hhs/MQ/10-监控与运维/39-MQ-容器化与-K8s-部署.md b/hhs/MQ/10-监控与运维/39-MQ-容器化与-K8s-部署.md new file mode 100644 index 0000000..39e3cdd --- /dev/null +++ b/hhs/MQ/10-监控与运维/39-MQ-容器化与-K8s-部署.md @@ -0,0 +1,200 @@ +--- +tags: + - MQ + - Kubernetes + - 容器化 + - 运维 +create time: 2026-05-24 19:52 +--- + +# MQ 容器化与 K8s 部署 + +## 概述 + +将消息队列部署到 Kubernetes 上,是有状态服务容器化的核心挑战之一。本文从持久存储、网络标识、有序部署三个维度分析难点,介绍 StatefulSet、Operator 模式、Helm Chart 等主流方案,并以 Strimzi Kafka Operator 为例展示生产级部署实践。 + +## 正文 + +### 为什么 MQ 上 K8s 是个难题 + +Kubernetes 天生为无状态服务设计——Pod 可以随时被杀掉重建、IP 随时变化、多个副本之间没有区别。但 MQ 作为有状态服务,恰恰对这三点都很敏感: + +- **持久存储**:消息必须落盘,Pod 重建后数据不能丢 +- **网络标识**:Broker 之间需要通过固定地址互相发现(如 Kafka 的 `broker.id` 对应固定端点) +- **有序部署**:集群启动/扩容时需要按顺序进行,不能所有 Pod 同时拉起 + +> [!question] +> 想象一下 Kafka 的 3 个 Broker 同时启动并互相注册,会发生什么?为什么有序启动很重要? + +### StatefulSet:有状态应用的基石 + +StatefulSet 是 K8s 专门为有状态服务设计的控制器,解决了 Deployment 的两个核心问题: + +1. **稳定的网络标识**:每个 Pod 拥有固定名称(`kafka-0`、`kafka-1`、`kafka-2`),配合 Headless Service 可以通过 DNS 直接访问 +2. **稳定的持久存储**:每个 Pod 绑定独立的 PVC(PersistentVolumeClaim),Pod 重建后自动挂载原来的卷 + +```yaml +# StatefulSet 核心配置片段 +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: kafka +spec: + serviceName: kafka-headless # 关联 Headless Service + replicas: 3 + template: + spec: + containers: + - name: kafka + volumeMounts: + - name: data + mountPath: /var/lib/kafka/data + volumeClaimTemplates: # 每个 Pod 自动创建独立 PVC + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: fast-ssd + resources: + requests: + storage: 100Gi +``` + +但 StatefulSet 只解决了基础设施层面的问题。**什么时候该扩容、扩容后 Partition 怎么重分配、配置变更怎么滚动生效**——这些业务运维知识它一概不管。于是 Operator 登场了。 + +### Operator 模式:把运维知识编码 + +Operator = Custom Resource Definition(CRD)+ Custom Controller。核心思想是:**将人类运维专家的知识编码为程序**,让 K8s 自动执行运维操作。 + +对于 MQ 来说,Operator 能做到: +- 一键部署完整集群(含 ZooKeeper/KRaft) +- 滚动升级 Broker 版本(保证零停机) +- 自动处理 Partition 重分配 +- 证书自动签发和轮转 + +主流 MQ Operator: + +| MQ | Operator | 维护方 | +|----|----------|--------| +| Kafka | Strimzi | CNCF | +| RocketMQ | rocketmq-operator | Apache | +| RabbitMQ | cluster-operator | VMware | +| Pulsar | pulsar-helm-chart | Apache | + +### Strimzi Kafka Operator 实战 + +Strimzi 是 CNCF 孵化项目,通过 CRD 将 Kafka 集群声明式管理。核心 CRD 有三个: + +- **Kafka**:定义 Kafka 集群拓扑(Broker 数量、存储、配置) +- **KafkaTopic**:声明式管理 Topic +- **KafkaUser**:声明式管理用户和 ACL 权限 + +```yaml +# Strimzi Kafka CR 示例:3 节点集群 +apiVersion: kafka.strimzi.io/v1beta2 +kind: Kafka +metadata: + name: my-cluster +spec: + kafka: + version: 3.7.0 + replicas: 3 + listeners: + - name: plain + port: 9092 + type: internal + tls: false + - name: tls + port: 9093 + type: internal + tls: true + config: + offsets.topic.replication.factor: 3 + transaction.state.log.replication.factor: 3 + transaction.state.log.min.isr: 2 + default.replication.factor: 3 + min.insync.replicas: 2 + storage: + type: jbod + volumes: + - id: 0 + type: persistent-claim + size: 100Gi + class: fast-ssd + zookeeper: + replicas: 3 + storage: + type: persistent-claim + size: 20Gi +``` + +Kafka 集群的完整部署架构如下: + +```mermaid +graph TB + subgraph K8s["Kubernetes Cluster"] + subgraph NS["namespace: kafka"] + Operator["Strimzi Operator"] + CR["Kafka CR"] + Operator -->|"watch & reconcile"| CR + CR -->|"create"| STS["StatefulSet"] + STS -->|"manage"| Pod0["kafka-0"] + STS -->|"manage"| Pod1["kafka-1"] + STS -->|"manage"| Pod2["kafka-2"] + Pod0 ---|"PVC"| PV0["PV-0 100Gi SSD"] + Pod1 ---|"PVC"| PV1["PV-1 100Gi SSD"] + Pod2 ---|"PVC"| PV2["PV-2 100Gi SSD"] + HS["Headless Service"] -->|"DNS: kafka-0.kafka-headless"| Pod0 + HS -->|"DNS: kafka-1.kafka-headless"| Pod1 + HS -->|"DNS: kafka-2.kafka-headless"| Pod2 + LB["LoadBalancer Service"] -->|"external access"| Pod0 + LB --> Pod1 + LB --> Pod2 + end + end + Client["Client App"] -->|"produce/consume"| LB +``` + +### Helm Chart 部署 + +对于不想引入 Operator 的轻量场景,Helm Chart 是更简单的选择。Bitnami 提供了完善的 Kafka Helm Chart,核心优势是参数化配置和环境隔离: + +```bash +# 安装 Kafka 集群 +helm install my-kafka bitnami/kafka \ + --namespace kafka --create-namespace \ + --set replicaCount=3 \ + --set persistence.size=100Gi \ + --set persistence.storageClass=fast-ssd \ + --set resources.requests.memory=4Gi \ + --set resources.requests.cpu=2 \ + --set resources.limits.memory=6Gi \ + --set resources.limits.cpu=4 +``` + +Helm 适合快速搭建和开发/测试环境,但在生产环境中,Operator 的自动化运维能力(升级、扩容、故障恢复)是 Helm 无法替代的。 + +### 资源规划建议 + +MQ 在 K8s 上的资源规划需要特别注意: + +- **CPU/Memory**:Kafka Broker 建议至少 2C4G,生产环境推荐 4C8G。请求值和限制值都要设置,避免被驱逐或抢占资源 +- **存储**:SSD 用于高吞吐场景(低延迟随机读写),HDD 用于大容量归档。通过 StorageClass 区分 +- **JVM 堆**:Kafka Broker 堆不宜过大(4-6GB 足够),留更多内存给页缓存。推荐 G1GC 或 ZGC +- **磁盘调度**:容器内使用 `none`(noop)调度算法,避免与宿主机调度冲突 + +### 网络方案 + +| 方式 | 适用场景 | 特点 | +|------|----------|------| +| Headless Service | Broker 间内部通信 | Pod 直连,无负载均衡 | +| ClusterIP | 集群内客户端访问 | 默认方式 | +| NodePort | 开发测试外部访问 | 端口范围 30000-32767 | +| LoadBalancer | 生产外部访问 | 云厂商 LB,费用较高 | +| Ingress | HTTP 协议暴露 | MQ 一般不走 HTTP,适用 REST Proxy | + +## 关联笔记 + +- [[40-MQ-性能调优]] +- [[42-MQ-认证与授权]] +- [[43-MQ-加密与审计]] diff --git a/hhs/MQ/10-监控与运维/40-MQ-性能调优.md b/hhs/MQ/10-监控与运维/40-MQ-性能调优.md new file mode 100644 index 0000000..f34e795 --- /dev/null +++ b/hhs/MQ/10-监控与运维/40-MQ-性能调优.md @@ -0,0 +1,180 @@ +--- +tags: + - MQ + - 性能调优 + - Kafka + - JVM +create time: 2026-05-24 19:52 +--- + +# MQ 性能调优 + +## 概述 + +MQ 的性能调优是一个系统工程,涉及 Producer、Broker、Consumer、JVM、操作系统多个层面。本文以 Kafka 为主线,从性能指标出发,逐层拆解调优策略,帮助你建立完整的调优方法论。 + +## 正文 + +### 性能指标三角 + +调优之前先明确目标。MQ 的核心性能指标有三个: + +- **吞吐量(Throughput)**:每秒处理的消息数(msgs/sec)或字节数(MB/sec) +- **延迟(Latency)**:消息从发送到被确认/消费的时间,关注 P50、P99、P999 +- **可用性(Availability)**:系统可正常服务的时间比例 + +> [!question] +> 吞吐量和延迟往往是矛盾的——批处理提升吞吐但增加延迟。你的业务场景更看重哪个?为什么? + +### Producer 调优 + +Producer 是性能瓶颈的第一个关口。核心参数和调优思路: + +**batch.size**(默认 16KB):批量发送的消息大小上限。增大可以提升吞吐(减少网络往返),但增加内存占用和首次延迟。生产环境建议设为 64KB-1MB。 + +**linger.ms**(默认 0):等待凑满 batch 的时间。默认 0 表示来一条发一条,设为 5-100ms 可以显著提升吞吐。和 batch.size 配合使用——达到任一条件即发送。 + +**compression.type**(默认 none):压缩算法。`lz4` 性价比最高(压缩率和速度均衡),`zstd` 压缩率更好但 CPU 开销略高。压缩能显著减少网络带宽和磁盘占用。 + +**acks**:可靠性级别。 +- `acks=0`:不等确认,最快但可能丢消息 +- `acks=1`:Leader 确认,平衡选择 +- `acks=all`:ISR 全部确认,最安全但最慢 + +**buffer.memory**(默认 32MB):Producer 端发送缓冲区大小。高吞吐场景下如果 Broker 响应慢,缓冲区可能打满导致阻塞,可适当增大到 64-128MB。 + +### Broker 调优 + +Broker 是整个集群的性能中枢。 + +**刷盘策略**:Kafka 依赖页缓存和顺序写,刷盘策略直接影响性能。 +- `flush.messages` 和 `flush.ms` 控制刷盘频率 +- 生产环境建议**关闭主动刷盘**(设为超大值),依赖操作系统的页缓存和后台刷盘,配合多副本保证数据安全 + +**页缓存**:Kafka 的性能秘诀在于大量利用 OS 页缓存。消息写入先进页缓存,异步刷盘。消费者如果跟得上生产者,直接从页缓存读取,接近内存速度。 + +**网络线程和 IO 线程**: +- `num.network.threads`:处理网络请求的线程数,建议设为 CPU 核数 +- `num.io.threads`:处理磁盘 IO 的线程数,建议设为 CPU 核数的 2 倍 +- `num.replica.fetchers`:副本同步线程数,高吞吐场景可增大到 2-4 + +### Consumer 调优 + +Consumer 的调优重点在于批量拉取和并发处理。 + +**fetch.min.bytes**(默认 1):一次 fetch 请求的最小数据量。增大可以减少网络往返次数,但增加延迟。建议设为 1KB-64KB。 + +**fetch.max.wait.ms**(默认 500ms):等待凑满 fetch.min.bytes 的最大时间。配合 fetch.min.bytes 使用,在吞吐和延迟之间找平衡。 + +**max.poll.records**(默认 500):单次 poll 返回的最大消息数。如果消费逻辑较重(如写数据库),适当减小避免处理超时。 + +**并发消费**:单个 Consumer 的吞吐有限,通常通过增加 Consumer 数量(不超过 Partition 数量)来提升并行度。 + +> [!question] +> Consumer 数量超过 Partition 数量会发生什么?如何在不增加 Partition 的情况下提升消费能力? + +### JVM 调优 + +Kafka Broker 运行在 JVM 上,JVM 调优至关重要。 + +**堆大小**:不宜过大!Kafka Broker 推荐 4-6GB。堆越大 GC 停顿越长,而且 Kafka 的性能很大程度依赖页缓存——堆占了太多内存,页缓存就被挤压了。 + +**GC 选择**: +- **G1GC**:成熟稳定,适合 4-8GB 堆。关键参数 `-XX:MaxGCPauseMillis=20` +- **ZGC**:超低停顿(<10ms),适合大堆场景,JDK 15+ 生产可用 + +**直接内存**:Kafka 的网络传输使用堆外内存(DirectBuffer),需要通过 `-XX:MaxDirectMemorySize` 预留足够空间。 + +### 操作系统调优 + +操作系统层面的优化往往被忽视,但影响显著: + +- **文件描述符**:Kafka 每个 Partition 对应多个日志段文件,需要大量 fd。建议设置 `ulimit -n 100000` 以上 +- **TCP 参数**:增大缓冲区 `net.core.rmem_max=16777216`、`net.core.wmem_max=16777216`,启用 TCP 快速打开 +- **vm.swappiness**:设为 1(非 0),避免系统过度 swap 导致 Broker 性能急剧下降 +- **磁盘调度算法**:SSD 使用 `none`(noop)或 `mq-deadline`,避免 `cfq` 的高开销 + +### 基准测试工具 + +调优前后都要用数据说话。常用工具: + +- **kafka-producer-perf-test**:Producer 吞吐和延迟测试 +- **kafka-consumer-perf-test**:Consumer 吞吐测试 +- **JMeter**:支持自定义场景的压测框架 + +### 性能瓶颈定位决策树 + +```mermaid +graph TD + Start["性能不达标"] --> CheckWhere{"瓶颈在哪?"} + CheckWhere -->|"Producer 端"| P["Producer 调优"] + CheckWhere -->|"Broker 端"| B["Broker 调优"] + CheckWhere -->|"Consumer 端"| C["Consumer 调优"] + P --> P1["增大 batch.size"] + P --> P2["增大 linger.ms"] + P --> P3["开启压缩"] + P --> P4["acks=1"] + B --> B1["关闭主动刷盘"] + B --> B2["增大网络/IO线程"] + B --> B3["JVM 调优"] + B --> B4["OS 参数调优"] + C --> C1["增大 fetch.min.bytes"] + C --> C2["增加并发消费者"] + C --> C3["增大 max.poll.records"] +``` + +### Go 性能测试示例 + +```go +package main + +import ( + "fmt" + "sync/atomic" + "time" + + "github.com/IBM/sarama" +) + +func main() { + config := sarama.NewConfig() + config.Producer.RequiredAcks = sarama.WaitForLocal + config.Producer.Compression = sarama.CompressionLZ4 // 开启压缩 + config.Producer.Flush.Bytes = 64 * 1024 // batch.size 64KB + config.Producer.Flush.Frequency = 10 * time.Millisecond // linger.ms 10 + + producer, _ := sarama.NewAsyncProducer([]string{"localhost:9092"}, config) + defer producer.Close() + + var count int64 + msg := &sarama.ProducerMessage{ + Topic: "benchmark", + Value: sarama.StringEncoder("test message payload"), + } + + // 10 个 goroutine 并发发送 + for i := 0; i < 10; i++ { + go func() { + for { + producer.Input() <- msg + atomic.AddInt64(&count, 1) + } + }() + } + + // 每秒输出吞吐量 + ticker := time.NewTicker(time.Second) + for range ticker.C { + n := atomic.SwapInt64(&count, 0) + fmt.Printf("Throughput: %d msgs/sec\n", n) + } +} +``` + +这段代码展示了 Sarama 异步 Producer 的基准测试:开启 LZ4 压缩、64KB 批量、10ms 等待时间,10 个 goroutine 并发发送,每秒统计吞吐量。通过调整参数组合对比不同配置下的性能表现。 + +## 关联笔记 + +- [[39-MQ-容器化与-K8s-部署]] +- [[41-MQ-测试策略]] +- [[42-MQ-认证与授权]] diff --git a/hhs/MQ/10-监控与运维/41-MQ-测试策略.md b/hhs/MQ/10-监控与运维/41-MQ-测试策略.md new file mode 100644 index 0000000..414e57c --- /dev/null +++ b/hhs/MQ/10-监控与运维/41-MQ-测试策略.md @@ -0,0 +1,189 @@ +--- +tags: + - MQ + - 测试 + - 集成测试 + - 契约测试 +create time: 2026-05-24 19:52 +--- + +# MQ 测试策略 + +## 概述 + +MQ 的测试面临异步性、消息顺序、环境依赖等独特挑战。本文从单元测试到混沌工程,逐层介绍 MQ 测试策略,重点讲解 Testcontainers 集成测试、Pact 契约测试和影子流量等实践方法。 + +## 正文 + +### MQ 测试的挑战 + +和同步的 HTTP API 相比,MQ 测试难在哪里? + +1. **异步性**:消息发送后不立即得到结果,如何验证消息被正确处理? +2. **消息顺序**:测试时如何保证消息的顺序和幂等性? +3. **环境依赖**:没有真实的 Broker,很多行为无法验证(如 Partition 路由、Consumer Group Rebalance) +4. **分布式一致性**:跨多个服务的事件流转,如何端到端验证? + +> [!question] +> 传统 API 测试可以用 `assert response.status == 200`。但 MQ 中,消息发出去后"成功"意味着什么?是 Broker 收到?还是被消费者处理完? + +### 测试金字塔在 MQ 场景的应用 + +```mermaid +graph TD + subgraph Pyramid["MQ 测试金字塔"] + E2E["E2E 测试 - 完整集群 + 多服务"] + Integration["集成测试 - Testcontainers 真实 Broker"] + Contract["契约测试 - Pact 消息契约"] + Unit["单元测试 - Mock Producer/Consumer"] + end + Unit -->|"快速, 大量"| Contract + Contract -->|"契约保证兼容"| Integration + Integration -->|"真实环境验证"| E2E +``` + +### 单元测试:Mock 接口,验证逻辑 + +单元测试的核心是将消息处理逻辑与 Broker 解耦。在 Go 中,通过接口抽象 Producer 和 Consumer: + +```go +// 定义接口,方便 Mock +type MessageProducer interface { + Send(ctx context.Context, topic string, key, value []byte) error +} + +type OrderHandler struct { + producer MessageProducer +} + +// 处理订单创建事件的业务逻辑 +func (h *OrderHandler) HandleOrderCreated(ctx context.Context, event OrderEvent) error { + if event.Amount <= 0 { + return fmt.Errorf("invalid amount: %d", event.Amount) + } + // 发送支付请求事件 + payment := PaymentEvent{OrderID: event.ID, Amount: event.Amount} + data, _ := json.Marshal(payment) + return h.producer.Send(ctx, "payment-requests", []byte(event.ID), data) +} + +// 单元测试:Mock Producer 验证业务逻辑 +func TestOrderHandler_HandleOrderCreated(t *testing.T) { + mock := &MockProducer{} // 实现 MessageProducer 接口 + handler := &OrderHandler{producer: mock} + + event := OrderEvent{ID: "order-1", Amount: 100} + err := handler.HandleOrderCreated(context.Background(), event) + + assert.NoError(t, err) + assert.Equal(t, "payment-requests", mock.LastTopic()) + assert.Equal(t, "order-1", mock.LastKey()) +} +``` + +单元测试快速且无外部依赖,但只能验证业务逻辑,无法保证消息格式与下游兼容。 + +### 集成测试:Testcontainers 启动真实 Broker + +Mock 测试不到的问题——Partition 路由、序列化、Consumer Group 行为——需要真实 Broker。Testcontainers 在测试中启动真实的 Docker 容器: + +```go +package main + +import ( + "context" + "testing" + "time" + + "github.com/IBM/sarama" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/kafka" +) + +func TestKafkaIntegration(t *testing.T) { + ctx := context.Background() + + // 启动真实的 Kafka 容器 + kafkaContainer, err := kafka.Run(ctx, + "confluentinc/confluent-local:7.5.0", + ) + if err != nil { + t.Fatal(err) + } + defer kafkaContainer.Terminate(ctx) + + // 获取 Broker 地址 + brokers, _ := kafkaContainer.Brokers(ctx) + + // 创建 Producer 发送消息 + config := sarama.NewConfig() + config.Producer.Return.Successes = true + producer, _ := sarama.NewSyncProducer(brokers, config) + defer producer.Close() + + msg := &sarama.ProducerMessage{ + Topic: "test-topic", + Value: sarama.StringEncoder("hello kafka"), + } + partition, offset, err := producer.SendMessage(msg) + if err != nil { + t.Fatalf("send failed: %v", err) + } + + // 创建 Consumer 消费并验证 + config2 := sarama.NewConfig() + config2.Consumer.Offsets.Initial = sarama.OffsetOldest + consumer, _ := sarama.NewConsumer(brokers, config2) + defer consumer.Close() + + pc, _ := consumer.ConsumePartition("test-topic", partition, offset) + select { + case msg := <-pc.Messages(): + if string(msg.Value) != "hello kafka" { + t.Errorf("unexpected message: %s", msg.Value) + } + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for message") + } +} +``` + +这段测试用 Testcontainers 自动拉起 Kafka 容器,发送一条消息后立即消费验证。整个过程在 CI 中无需预置环境,测试结束自动清理容器。 + +### 消息契约测试 + +在事件驱动架构中,Producer 和 Consumer 通过消息格式(Schema)解耦。但如果 Producer 改了字段名,Consumer 就会崩溃。**契约测试**解决这个问题。 + +Pact 框架的工作流程: +1. **Consumer 端**定义期望的消息格式(契约) +2. **Producer 端**验证自己产生的消息满足所有 Consumer 的契约 +3. 契约存储在 Pact Broker 中,CI 中自动验证 + +> [!question] +> 消息契约测试和传统的 API 测试有什么本质区别?为什么在事件驱动架构中更重要? + +关键区别在于:API 测试验证的是"请求-响应"的一对一关系,而消息契约验证的是"事件-消费者"的一对多关系。一个事件可能被 5 个服务消费,任何格式变更都必须向后兼容。 + +### 影子流量(Shadow Testing) + +将生产流量复制到测试环境,验证新版本的消息处理逻辑是否正确,而不影响真实业务。 + +实现方式: +- Producer 端使用拦截器,将消息副本发送到 Shadow Topic +- Shadow Consumer 消费并处理,对比结果 +- 关键:Shadow 消费者的处理结果**不会写入生产数据库** + +### 故障注入 + +Chaos Engineering 在 MQ 场景的应用: + +- **Kill Broker**:随机杀掉一个 Broker,验证 Producer/Consumer 的自动故障转移 +- **网络分区**:模拟 Broker 之间网络隔离,验证脑裂防护 +- **消息延迟注入**:人为增加 Broker 响应延迟,验证超时和重试机制 +- **磁盘满**:模拟磁盘空间不足,验证告警和降级策略 + +## 关联笔记 + +- [[40-MQ-性能调优]] +- [[42-MQ-认证与授权]] +- [[39-MQ-容器化与-K8s-部署]] diff --git a/hhs/MQ/11-安全与多租户/42-MQ-认证与授权.md b/hhs/MQ/11-安全与多租户/42-MQ-认证与授权.md new file mode 100644 index 0000000..a111d70 --- /dev/null +++ b/hhs/MQ/11-安全与多租户/42-MQ-认证与授权.md @@ -0,0 +1,216 @@ +--- +tags: + - MQ + - 安全 + - 认证 + - 授权 + - Kafka +create time: 2026-05-24 19:52 +--- + +# MQ 认证与授权 + +## 概述 + +MQ 作为数据流转的核心枢纽,安全防护至关重要。本文梳理 MQ 面临的安全威胁,对比 SASL/PLAIN、SASL/SCRAM、mTLS、OAuth2 四种认证机制,讲解 ACL 授权模型,并以 Kafka 为例展示完整的安全配置实践。 + +## 正文 + +### MQ 安全威胁分析 + +一个没有安全防护的 MQ 集群就像一栋没有门锁的大楼: + +- **未授权访问**:任何人都能连接 Broker,读取或写入任意 Topic +- **消息篡改**:中间人攻击修改消息内容,下游消费者收到错误数据 +- **消息窃听**:网络嗅探获取敏感消息(如用户订单、支付信息) +- **DoS 攻击**:恶意客户端大量发送消息耗尽 Broker 资源 + +> [!question] +> 如果你的 MQ 集群部署在内网,是否还需要认证授权?内网就一定安全吗? + +### 认证机制对比 + +#### SASL/PLAIN + +最简单的用户名密码认证。密码以明文传输(除非配合 TLS),适合开发测试环境。 + +- 优点:配置简单,无需额外基础设施 +- 缺点:密码明文传输、不支持动态添加用户(需重启 Broker) + +#### SASL/SCRAM + +基于挑战-响应的认证协议,密码不在网络上传输。Kafka 支持 SCRAM-SHA-256 和 SCRAM-SHA-512。 + +- 优点:密码加密存储、支持动态添加用户、无需重启 Broker +- 缺点:性能比 mTLS 略低 + +#### mTLS(双向 TLS) + +客户端和服务端互相验证证书。这是安全性最高的方案。 + +- 优点:双向认证、无需密码管理、证书可自动轮转 +- 缺点:证书管理复杂(签发、分发、续期、吊销) + +#### OAuth2/OIDC + +基于令牌的认证,适合云原生和微服务场景。客户端通过 OAuth2 Server 获取 Token,携带 Token 连接 Broker。 + +- 优点:与企业身份系统集成、支持细粒度权限、令牌可过期 +- 缺点:依赖外部 OAuth2 Server、配置复杂 + +### 认证授权流程 + +```mermaid +sequenceDiagram + participant Client as "Client App" + participant Broker as "Kafka Broker" + participant Authn as "Authentication Module" + participant Authz as "Authorization Module (ACL)" + Client->>Broker: "Connect with credentials" + Broker->>Authn: "Verify identity" + Authn-->>Broker: "Identity: user-service-order" + Client->>Broker: "Produce to order-topic" + Broker->>Authz: "Check ACL: can user-service-order WRITE order-topic?" + Authz-->>Broker: "ALLOW" + Broker-->>Client: "Produce success" + Client->>Broker: "Consume payment-topic" + Broker->>Authz: "Check ACL: can user-service-order READ payment-topic?" + Authz-->>Broker: "DENY" + Broker-->>Client: "Authorization failed" +``` + +### ACL 授权模型 + +ACL(Access Control List)提供 Topic 级别的细粒度权限控制。Kafka 的 ACL 由五元组定义: + +- **Principal**:认证身份(如 `User:alice`) +- **Resource**:资源类型和名称(如 `Topic:order-events`) +- **Operation**:操作类型(Read/Write/Create/Delete/Describe) +- **Permission**:Allow/Deny +- **Host**:来源 IP(可选) + +常见权限模式: +- 最小权限原则:每个服务只授予需要的 Topic 权限 +- 生产者只有 Write 权限,消费者只有 Read 权限 +- 管理操作(Create/Delete)只授予运维账号 + +### Kafka 完整安全配置 + +下面展示 SASL/SCRAM + ACL 的完整配置流程。 + +Broker 端配置(`server.properties`): + +```properties +# 启用 SASL/SCRAM 认证 +listeners=SASL_SSL://:9093 +security.inter.broker.protocol=SASL_SSL +sasl.mechanism.inter.broker.protocol=SCRAM-SHA-256 +sasl.enabled.mechanisms=SCRAM-SHA-256 + +# 启用 ACL +authorizer.class.name=kafka.security.authorizer.AclAuthorizer +super.users=User:admin +allow.everyone.if.no.acl.found=false +``` + +```bash +# 创建用户 +kafka-configs.sh --bootstrap-server localhost:9093 \ + --alter --add-config 'SCRAM-SHA-256=[iterations=8192,password=secret]' \ + --entity-type users --entity-name order-service + +# 授权:允许 order-service 写 order-topic +kafka-acls.sh --bootstrap-server localhost:9093 \ + --add --allow-principal User:order-service \ + --producer --topic order-topic + +# 授权:允许 payment-service 读 order-topic +kafka-acls.sh --bootstrap-server localhost:9093 \ + --add --allow-principal User:payment-service \ + --consumer --topic order-topic --group payment-group +``` + +### Go 代码:SASL/SCRAM 认证配置 + +```go +package main + +import ( + "crypto/sha256" + "crypto/sha512" + "hash" + + "github.com/IBM/sarama" + "github.com/xdg-go/scram" +) + +// SCRAM 客户端实现 +type SCRAMClient struct { + *scram.Client + *scram.ClientConversation + hashGenerator func() hash.Hash +} + +func (c *SCRAMClient) Begin(userName, password, authzID string) error { + client, err := c.hashGenerator().NewClient(userName, password, authzID) + if err != nil { + return err + } + c.Client = client + c.ClientConversation = client.NewConversation() + return nil +} + +func (c *SCRAMClient) Step(challenge string) (string, error) { + return c.ClientConversation.Step(challenge) +} + +func (c *SCRAMClient) Done() bool { + return c.ClientConversation.Done() +} + +func main() { + config := sarama.NewConfig() + config.Net.SASL.Enable = true + config.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256 + config.Net.SASL.User = "order-service" + config.Net.SASL.Password = "secret" + config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &SCRAMClient{hashGenerator: sha256.New} + } + config.Net.TLS.Enable = true // SCRAM 通常配合 TLS 使用 + + producer, err := sarama.NewSyncProducer([]string{"localhost:9093"}, config) + if err != nil { + panic(err) + } + defer producer.Close() + + // 发送消息,自动完成 SCRAM 认证握手 + msg := &sarama.ProducerMessage{ + Topic: "order-topic", + Value: sarama.StringEncoder(`{"order_id": "12345"}`), + } + partition, offset, _ := producer.SendMessage(msg) + _ = partition + _ = offset +} +``` + +这段代码实现了 SASL/SCRAM-SHA-256 认证的完整流程:客户端发送用户名,Broker 返回挑战(salt + iteration),客户端计算哈希响应,Broker 验证通过后建立连接。 + +### mTLS 的简化方案 + +> [!question] +> mTLS 安全性最高,但管理证书很麻烦。在微服务场景下如何简化证书管理? + +几种主流方案: +- **Service Mesh(Istio)**:Sidecar 代理自动处理证书签发和轮转,应用层完全无感 +- **cert-manager**:K8s 上的证书管理控制器,自动签发和续期 +- **SPIFFE/SPIRE**:标准化的服务身份框架,跨平台证书管理 + +## 关联笔记 + +- [[43-MQ-加密与审计]] +- [[40-MQ-性能调优]] +- [[39-MQ-容器化与-K8s-部署]] diff --git a/hhs/MQ/11-安全与多租户/43-MQ-加密与审计.md b/hhs/MQ/11-安全与多租户/43-MQ-加密与审计.md new file mode 100644 index 0000000..496a1bb --- /dev/null +++ b/hhs/MQ/11-安全与多租户/43-MQ-加密与审计.md @@ -0,0 +1,272 @@ +--- +tags: + - MQ + - 安全 + - 加密 + - 审计 + - 合规 +create time: 2026-05-24 19:52 +--- + +# MQ 加密与审计 + +## 概述 + +认证授权解决了"谁在访问"的问题,加密和审计则解决"数据是否安全"和"操作是否可追溯"。本文覆盖传输加密、消息级加密、密钥管理、审计日志、配额管理和多租户隔离,构建 MQ 安全的完整防护体系。 + +## 正文 + +### 传输加密:TLS/SSL + +传输加密是最基础的安全措施,防止网络嗅探和中间人攻击。MQ 场景下有两类通信需要加密: + +1. **Client-Broker 通信**:Producer/Consumer 与 Broker 之间的数据传输 +2. **Broker-Broker 通信**:集群内部副本同步、Controller 通信 + +TLS 配置要点: +- 使用 TLS 1.2+ 版本,禁用弱加密套件 +- 生产环境必须双向认证(mTLS),至少服务端验证 +- 证书有效期不宜过长(建议 90 天),配合自动轮转 + +```mermaid +graph LR + subgraph "加密层次" + TLS["传输加密 TLS - 加密传输通道"] + MsgEnc["消息级加密 - 加密消息体"] + AtRest["静态加密 - 加密磁盘存储"] + end + Producer["Producer"] -->|"TLS 加密通道"| Broker["Broker"] + Broker -->|"磁盘加密"| Disk["Storage"] + Broker -->|"TLS 加密通道"| Consumer["Consumer"] + Producer -.->|"应用层加密消息体"| MsgEnc +``` + +### 消息级加密(Envelope Encryption) + +TLS 只保护传输通道,消息在 Broker 端以明文存储。如果 Broker 被入侵或存储介质被盗,消息就暴露了。消息级加密在应用层加密消息体,Broker 只存储密文。 + +Envelope Encryption 工作流程: + +1. Producer 用 **数据密钥(DEK)** 加密消息体 +2. DEK 用 **密钥加密密钥(KEK)** 加密后随消息一起发送 +3. Broker 存储密文和加密后的 DEK +4. Consumer 获取消息后,先用 KEK 解密 DEK,再用 DEK 解密消息 + +```go +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "io" + + "github.com/IBM/sarama" +) + +// EnvelopeMessage 包含加密后的数据和加密后的 DEK +type EnvelopeMessage struct { + EncryptedData []byte `json:"encrypted_data"` + EncryptedDEK []byte `json:"encrypted_dek"` +} + +// encryptMessage 使用 AES-GCM 加密消息体 +func encryptMessage(plaintext []byte, kek []byte) (*EnvelopeMessage, error) { + // 生成随机 DEK + dek := make([]byte, 32) + rand.Read(dek) + + // 用 DEK 加密消息 + block, _ := aes.NewCipher(dek) + gcm, _ := cipher.NewGCM(block) + nonce := make([]byte, gcm.NonceSize()) + io.ReadFull(rand.Reader, nonce) + encryptedData := gcm.Seal(nonce, nonce, plaintext, nil) + + // 用 KEK 加密 DEK + kekBlock, _ := aes.NewCipher(kek) + kekGCM, _ := cipher.NewGCM(kekBlock) + kekNonce := make([]byte, kekGCM.NonceSize()) + io.ReadFull(rand.Reader, kekNonce) + encryptedDEK := kekGCM.Seal(kekNonce, kekNonce, dek, nil) + + return &EnvelopeMessage{ + EncryptedData: encryptedData, + EncryptedDEK: encryptedDEK, + }, nil +} + +func main() { + kek := []byte("0123456789abcdef0123456789abcdef") // KEK 从 KMS 获取 + + plaintext := []byte(`{"user_id": "u123", "action": "purchase"}`) + envelope, _ := encryptMessage(plaintext, kek) + + data, _ := json.Marshal(envelope) + msg := &sarama.ProducerMessage{ + Topic: "sensitive-events", + Value: sarama.ByteEncoder(data), + } + _ = msg // 发送到 Kafka +} +``` + +> [!question] +> 消息级加密会让 Broker 端的消息过滤失效,如何解决这个矛盾? + +这是一个经典的取舍问题。几种应对方案: +- **部分加密**:只加密敏感字段,保留用于过滤的元数据在 Header 中明文传输 +- **Token 化**:用 Token 替代敏感数据,Broker 按 Token 过滤 +- **Consumer 端过滤**:接受 Broker 无法过滤的现实,在 Consumer 端做过滤(增加带宽消耗) + +### 密钥管理 + +密钥管理是加密体系的核心。密钥泄露等于加密形同虚设。 + +**KMS(Key Management Service)**:云厂商提供的密钥管理服务(AWS KMS、Azure Key Vault、HashiCorp Vault),核心能力: +- 密钥生成和存储(HSM 硬件保护) +- 密钥轮转(自动更换 KEK,旧密钥保留用于解密历史数据) +- 访问审计(谁在什么时候访问了哪个密钥) + +**密钥轮转策略**: +- KEK 定期轮转(如每 90 天) +- 轮转后旧 KEK 不立即删除,保留用于解密历史消息 +- DEK 不需要轮转(每条消息用不同的 DEK) + +### 审计日志 + +审计日志记录所有管理操作,用于事后追溯和合规审查。需要记录的事件: + +- **Topic 管理**:创建、删除、配置变更 +- **ACL 变更**:权限授予、撤销 +- **用户管理**:用户创建、密码变更、证书签发 +- **集群操作**:Broker 上下线、配置变更、滚动升级 + +审计日志要求: +- 不可篡改(写入独立存储,与 Broker 日志分离) +- 包含操作者身份、时间戳、操作详情、来源 IP +- 保留期限符合合规要求(通常 1-3 年) + +### 配额管理 + +防止单个 Producer/Consumer 独占资源,影响其他租户: + +- **生产者带宽配额**:限制每秒发送字节数(`producer_byte_rate`) +- **消费者带宽配额**:限制每秒拉取字节数(`consumer_byte_rate`) +- **请求百分比配额**:限制 CPU 时间占比(`request_percentage`) +- **连接数限制**:限制单个客户端 IP 的最大连接数 + +```bash +# 为 user-order-service 设置生产者带宽配额:10MB/s +kafka-configs.sh --bootstrap-server localhost:9093 \ + --alter --add-config 'producer_byte_rate=10485760' \ + --entity-type clients --entity-name order-service +``` + +### 多租户隔离 + +当多个团队或业务共用一个 MQ 集群时,隔离至关重要: + +| 隔离维度 | 方案 | 效果 | +|----------|------|------| +| 命名空间 | Topic 前缀(如 `team-a.orders`) | 逻辑隔离,防止命名冲突 | +| 资源配额 | 带宽/连接数限制 | 防止资源争抢 | +| ACL 权限 | 按租户授予 Topic 权限 | 访问隔离 | +| 网络隔离 | 网络策略/专用 Listener | 防止跨租户网络访问 | +| 物理隔离 | 独立集群 | 最强隔离,成本最高 | + +### 多租户安全架构 + +```mermaid +graph TB + subgraph TenantA["Tenant A"] + AppA["Service A"] + end + subgraph TenantB["Tenant B"] + AppB["Service B"] + end + subgraph MQCluster["MQ Cluster"] + ACL["ACL Engine"] + Quota["Quota Manager"] + Audit["Audit Logger"] + BrokerA["Broker 0"] + BrokerB["Broker 1"] + BrokerC["Broker 2"] + end + subgraph Storage["Key Storage"] + KMS["KMS / Vault"] + end + AppA -->|"TLS + SASL"| ACL + AppB -->|"TLS + SASL"| ACL + ACL -->|"check permission"| Quota + Quota -->|"enforce limits"| BrokerA + Quota --> BrokerB + Quota --> BrokerC + ACL -->|"log"| Audit + AppA -.->|"fetch DEK"| KMS + AppB -.->|"fetch DEK"| KMS +``` + +### 合规要求 + +在金融、医疗、出海场景下,MQ 还需满足合规要求: + +- **数据驻留(Data Residency)**:特定数据必须存储在指定地域(如欧盟用户数据不出欧盟) +- **消息保留策略**:按数据分类设定不同的保留时长,过期自动删除 +- **GDPR**:支持数据主体的"被遗忘权"——收到删除请求后,必须能从 MQ 中彻底删除相关消息(这在不可变日志中很难实现,通常通过加密密钥销毁来实现"逻辑删除") + +### Go 代码:TLS 连接配置 + +```go +package main + +import ( + "crypto/tls" + "crypto/x509" + "os" + + "github.com/IBM/sarama" +) + +func main() { + // 加载 CA 证书 + caCert, _ := os.ReadFile("ca.pem") + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + // 加载客户端证书(mTLS 场景) + cert, _ := tls.LoadX509KeyPair("client.pem", "client-key.pem") + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + } + + config := sarama.NewConfig() + config.Net.TLS.Enable = true + config.Net.TLS.Config = tlsConfig + + producer, err := sarama.NewSyncProducer([]string{"broker1:9093"}, config) + if err != nil { + panic(err) + } + defer producer.Close() + + // 通过 TLS 加密通道发送消息 + msg := &sarama.ProducerMessage{ + Topic: "secure-topic", + Value: sarama.StringEncoder("encrypted in transit"), + } + producer.SendMessage(msg) +} +``` + +这段代码展示了 Kafka TLS 连接的完整配置:加载 CA 证书验证服务端身份,加载客户端证书实现双向认证,强制 TLS 1.2 最低版本。 + +## 关联笔记 + +- [[42-MQ-认证与授权]] +- [[40-MQ-性能调优]] +- [[39-MQ-容器化与-K8s-部署]] diff --git a/hhs/MQ/12-架构与实战/44-MQ-高可用架构.md b/hhs/MQ/12-架构与实战/44-MQ-高可用架构.md new file mode 100644 index 0000000..dcf0784 --- /dev/null +++ b/hhs/MQ/12-架构与实战/44-MQ-高可用架构.md @@ -0,0 +1,160 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 高可用架构 + +## 概述 + +消息队列作为分布式系统的核心基础设施,其高可用性直接影响整个业务链路的稳定性。本文从可用性指标、Broker 集群模式、无单点设计、跨机房容灾、灰度发布等维度,系统梳理 MQ 高可用架构的设计要点。 + +## 正文 + +### 高可用指标 + +衡量高可用有两个核心维度:**可用性指标**和**恢复指标**。 + +可用性用"几个 9"来衡量: + +| 级别 | 可用性 | 年停机时间 | 典型场景 | +|------|--------|-----------|---------| +| 3 个 9 | 99.9% | 8.76 小时 | 内部工具 | +| 4 个 9 | 99.99% | 52.6 分钟 | 核心业务 | +| 5 个 9 | 99.999% | 5.26 分钟 | 金融交易 | + +恢复指标有两个: +- **RTO(Recovery Time Objective)**:从故障到恢复的时间目标,比如 RTO = 5 分钟意味着故障后 5 分钟内必须恢复。 +- **RPO(Recovery Point Objective)**:可容忍的最大数据丢失量。RPO = 0 表示零数据丢失,这通常需要同步复制。 + +> [!question] +> 你的业务能接受多长的 MQ 不可用时间?如果 RPO = 0,同步复制带来的性能下降你能接受吗? + +### Broker 集群模式 + +MQ 的高可用核心在于 Broker 层的集群化。不同产品有不同的实现路径,但本质都是**数据冗余 + 故障切换**。 + +**主从复制(Leader-Follower)** + +最经典的模式。Leader 处理读写请求,Follower 同步数据作为备份。 + +- **同步复制**:Producer 发送消息后,Leader 等待 Follower 确认才返回 ACK。数据不丢,但延迟增加。 +- **异步复制**:Leader 写入本地即返回 ACK,Follower 异步拉取。性能好,但主宕机可能丢数据。 + +**多副本 ISR(In-Sync Replicas)** + +Kafka 的核心机制。ISR 是一组与 Leader 保持同步的副本集合。如果某个 Follower 落后太多,会被踢出 ISR。只有 ISR 中的副本才有资格被选为新 Leader。 + +```go +// ISR 维护的核心逻辑(简化示意) +type ISRManager struct { + leader int32 + isr map[int32]bool + lagThresh int64 // 允许的最大滞后字节数 +} + +// Follower 拉取数据后更新 LEO,Leader 检查是否还在 ISR 中 +func (m *ISRManager) UpdateISR(replicaID int32, logEndOffset int64, leaderLEO int64) { + if leaderLEO-logEndOffset > m.lagThresh { + delete(m.isr, replicaID) // 落后太多,踢出 ISR + } +} +``` + +**Raft 共识** + +Raft 是强一致性协议,天然适合做 MQ 的副本同步。Kafka 从 3.3 开始用 KRaft(基于 Raft 的 Controller)替代 ZooKeeper;RocketMQ 5.0 引入 DLedger(也是 Raft)实现多副本。 + +```go +// Raft 核心:多数派确认后才提交 +type RaftLog struct { + term uint64 + index uint64 + data []byte +} + +// 只有超过半数节点写入成功,日志才算 committed +func (r *RaftNode) AppendEntries(entries []RaftLog) bool { + ackCount := 1 // Leader 自己 + for _, peer := range r.peers { + if r.sendAppendRPC(peer, entries) { + ackCount++ + } + } + return ackCount > len(r.peers)/2 +} +``` + +### 无单点设计 + +一个真正的高可用集群不允许任何单点故障。 + +| 产品 | 传统单点 | 无单点方案 | +|------|---------|-----------| +| RocketMQ | NameServer | NameServer 本身就是无状态集群,任意节点宕机不影响服务 | +| Kafka | ZooKeeper | KRaft 模式下 Controller 使用 Raft 选出 Leader,不再依赖 ZK | +| RabbitMQ | 队列所在节点 | Quorum Queue 基于 Raft 多副本,队列数据分布在多个节点 | + +> [!question] +> NameServer 是无状态的,那它和 ZooKeeper 的设计思路有什么根本区别? + +### 跨机房容灾 + +单机房再怎么高可用,也挡不住机房级故障(断电、光纤被挖断、自然灾害)。跨机房容灾有几种常见模式: + +- **同城双活**:两个机房距离 50km 以内,网络延迟 < 2ms。MQ 集群可以同步复制,RPO = 0。 +- **异地多活**:多地部署独立集群,各自处理就近流量,异步复制数据。RPO > 0,但抗灾能力最强。 +- **灾备切换**:主机房写入,备机房冷备或温备。故障时手动或自动切换,RTO 较长。 + +### 灰度发布 + +MQ 作为基础设施,升级必须谨慎。一次糟糕的滚动升级可能让整个集群瘫痪。 + +- **滚动升级**:逐台替换 Broker,每次只升级一台,确认无异常后再继续。Kafka 和 RocketMQ 官方文档都推荐这种方式。 +- **蓝绿部署**:部署一套全新版本的集群,流量验证通过后一次性切换。 +- **金丝雀发布**:先将少量流量导入新版本集群,观察指标稳定后再全量切换。 + +```mermaid +graph TB + subgraph "机房A - 主集群" + B1["Broker1 Leader"] + B2["Broker2 Follower"] + B3["Broker3 Follower"] + end + subgraph "机房B - 备集群" + B4["Broker4 Leader"] + B5["Broker5 Follower"] + B6["Broker6 Follower"] + end + subgraph "机房C - 观察节点" + B7["Broker7 Observer"] + end + P["Producer Group"] + C["Consumer Group"] + P --> B1 + P --> B4 + B1 -->|"同步复制"| B2 + B1 -->|"同步复制"| B3 + B4 -->|"异步复制"| B5 + B4 -->|"异步复制"| B6 + B1 -->|"跨机房复制"| B4 + B4 -->|"观察同步"| B7 + B1 --> C + B4 --> C +``` + +### 一致性与性能的权衡 + +高可用不是免费的。更多的副本、更强的一致性意味着更高的延迟和更低的吞吐。生产实践中需要根据业务特点选择合适的方案: + +- 订单、支付类:同步复制,宁可慢也不能丢。 +- 日志、埋点类:异步复制,允许少量丢失,追求高吞吐。 +- 通知、推送类:异步复制 + 重试,最终一致即可。 + +## 关联笔记 + +- [[45-MQ-跨集群复制与容灾]] +- [[46-MQ-分布式事务实践]] +- [[49-MQ-客户端连接管理]] +- [[50-MQ-设计与实现]] diff --git a/hhs/MQ/12-架构与实战/45-MQ-跨集群复制与容灾.md b/hhs/MQ/12-架构与实战/45-MQ-跨集群复制与容灾.md new file mode 100644 index 0000000..7e2dee1 --- /dev/null +++ b/hhs/MQ/12-架构与实战/45-MQ-跨集群复制与容灾.md @@ -0,0 +1,128 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 跨集群复制与容灾 + +## 概述 + +当单一 MQ 集群无法满足灾备、合规或多地域低延迟访问的需求时,跨集群复制成为必选项。本文深入分析 Kafka 和 RocketMQ 的跨集群复制方案,探讨 MirrorMaker 2.0 的工作原理,以及异地多活架构的实现思路。 + +## 正文 + +### 跨集群复制的需求 + +为什么不能只靠一个集群?四类典型需求驱动了跨集群复制: + +1. **灾备**:主机房不可用时,备集群接管流量,RTO/RPO 需要可控。 +2. **数据迁移**:从一个集群迁移到另一个(比如跨云迁移),要求业务无感知。 +3. **就近访问**:全球业务需要各区域就近接入,降低网络延迟。 +4. **合规要求**:某些数据法规(如 GDPR)要求数据不得离开特定区域,但仍需全局分析。 + +> [!question] +> 如果你的 MQ 集群需要同时服务中国和欧洲用户,你会选择一个全球集群还是两个区域集群?各自的 Trade-off 是什么? + +### Kafka 跨集群方案 + +**MirrorMaker 2.0** + +MirrorMaker 2.0(简称 MM2)是 Kafka 官方的跨集群复制工具,基于 Kafka Connect 构建。它的核心流程很直观: + +1. 从远端集群消费消息 +2. 写入本地集群的对应 Topic +3. 维护 Offset 映射关系 + +```mermaid +graph LR + subgraph "源集群 DC-A" + TA["Topic-A"] + TB["Topic-B"] + end + subgraph "MirrorMaker 2.0" + C1["Source Connector"] + T["转换层 - Topic重命名, Offset映射"] + C2["Sink Connector"] + end + subgraph "目标集群 DC-B" + TA2["dc-A.Topic-A"] + TB2["dc-A.Topic-B"] + end + TA --> C1 + TB --> C1 + C1 --> T + T --> C2 + C2 --> TA2 + C2 --> TB2 +``` + +MM2 的几个关键设计点: + +- **Topic 名称转换**:默认在目标集群加前缀(如 `dc-A.topic-A`),避免命名冲突。 +- **Offset 映射**:MM2 会创建一个 `__consumer_offsets` 的映射 Topic,记录源集群和目标集群的 Offset 对应关系。这样消费者在故障切换后能从正确的位置继续消费。 +- **自动同步配置**:Topic 分区数、副本因子等配置变更也会自动同步。 + +```go +// MM2 Offset 映射的核心概念(简化示意) +type OffsetSync struct { + UpstreamOffset int64 // 源集群的 Offset + DownstreamOffset int64 // 目标集群的 Offset + Timestamp int64 +} + +// 故障切换时,根据映射找到目标集群的对应 Offset +func TranslateOffset(syncs []OffsetSync, upstreamOffset int64) int64 { + // 二分查找最近的映射点 + for i := len(syncs) - 1; i >= 0; i-- { + if syncs[i].UpstreamOffset <= upstreamOffset { + return syncs[i].DownstreamOffset + } + } + return 0 +} +``` + +**Confluent Replicator** + +Confluent 的商业方案,相比 MM2 更成熟,支持自动 Topic 创建、配置同步、Offset 翻译,但需要 Confluent Platform 授权。 + +**Cluster Linking(Confluent 7.0+)** + +这是 Confluent 最新的方案,直接在 Broker 层面建立集群间的链接,不需要额外的 Connect 集群。性能更好,延迟更低,但仅限 Confluent 平台。 + +### RocketMQ 跨集群方案 + +RocketMQ 的跨集群方案相对简单: + +- **DLedger 多副本**:基于 Raft 协议的副本同步,适合同城双活场景,RPO = 0。 +- **RocketMQ Bridge**:类似 Kafka MM2 的桥接组件,支持跨集群消息转发。 + +### 跨地域同步的挑战 + +跨集群复制不是简单的消息转发,面临几个硬核挑战: + +**网络延迟**:跨地域网络延迟通常 50-200ms,同步复制会严重影响吞吐。大多数场景只能用异步复制,这意味着 RPO > 0。 + +**消息顺序**:源集群保证了分区内的顺序,但复制到目标集群后,由于网络抖动和并行复制,可能出现乱序。解决方案是按 Key 哈希到同一分区,牺牲并行度换取顺序。 + +**冲突解决**:双向复制(Active-Active)场景下,两端可能同时修改同一份数据。常见策略有 Last-Write-Wins(LWW)和基于版本号的冲突检测。 + +**带宽成本**:跨地域带宽昂贵。可以通过压缩、批量发送、限速等手段控制成本。 + +### 异地多活架构 + +异地多活是最复杂的跨地域方案,核心思想是每个区域都能独立提供完整服务: + +- **写本地读本地**:每个区域的 Producer 和 Consumer 都连接本地集群,避免跨地域调用。 +- **全局 Topic 路由**:通过全局路由层,将请求导向用户所在的区域集群。 +- **冲突检测与合并**:对于需要全局一致的数据,采用 CDC(Change Data Capture)+ 冲突解决机制。 + +> [!question] +> 异地多活下,如果用户 A 在中国下单,同时用户 B 在美国查看库存,如何保证库存数据的一致性? + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[46-MQ-分布式事务实践]] +- [[51-云消息服务]] diff --git a/hhs/MQ/12-架构与实战/46-MQ-分布式事务实践.md b/hhs/MQ/12-架构与实战/46-MQ-分布式事务实践.md new file mode 100644 index 0000000..fa2aee5 --- /dev/null +++ b/hhs/MQ/12-架构与实战/46-MQ-分布式事务实践.md @@ -0,0 +1,223 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 分布式事务实践 + +## 概述 + +在微服务架构下,一个业务操作往往涉及多个服务的数据变更,分布式事务成为绕不开的难题。本文介绍三种基于 MQ 的分布式事务方案:本地消息表、RocketMQ 事务消息、Saga 模式,并给出 Go 代码实现。 + +## 正文 + +### 分布式事务问题 + +以电商下单为例,一个"创建订单"操作至少涉及三个服务: + +1. **订单服务**:创建订单记录 +2. **库存服务**:扣减库存 +3. **支付服务**:发起支付 + +这三个操作需要保证最终一致性——不能出现订单创建了但库存没扣、或者库存扣了但支付没发起的情况。传统的 2PC/XA 方案在微服务架构下性能差、可用性低,MQ 成了更实用的选择。 + +> [!question] +> 为什么 2PC 在微服务架构下不受欢迎?它的问题仅仅是性能差吗? + +### 方案一:本地消息表 + MQ + +这是最经典、最可靠的方案,核心思想是**利用本地事务保证业务操作和消息写入的原子性**。 + +```mermaid +graph TD + A["开始本地事务"] --> B["写入业务数据 - 订单表"] + B --> C["写入消息表 - 同一事务"] + C --> D["提交事务"] + D --> E["后台任务轮询消息表"] + E --> F["发送消息到MQ"] + F --> G["消费端处理 - 扣减库存"] + G --> H["消费端幂等校验"] + H --> I["更新消息状态为已完成"] +``` + +核心逻辑分三步: + +1. **写入阶段**:在同一个数据库事务中,写入业务数据和消息表。这保证了两者的原子性。 +2. **发送阶段**:后台任务(定时器或 binlog 监听)扫描消息表中未发送的记录,投递到 MQ。 +3. **消费阶段**:消费端处理消息并做幂等校验(比如用消息 ID 去重),处理成功后回调更新消息状态。 + +```go +// 本地消息表方案的核心实现 +type MessageRecord struct { + ID string + Topic string + Payload string + Status string // pending, sent, completed + RetryCount int + CreatedAt time.Time +} + +// 1. 本地事务:写业务数据 + 消息表 +func CreateOrder(db *sql.DB, order Order, msg MessageRecord) error { + tx, _ := db.Begin() + defer tx.Rollback() + + // 写入订单 + _, err := tx.Exec("INSERT INTO orders ...", order.ID, order.Amount) + if err != nil { return err } + + // 写入消息表,同一事务 + _, err = tx.Exec("INSERT INTO outbox_messages ...", + msg.ID, msg.Topic, msg.Payload, "pending") + if err != nil { return err } + + return tx.Commit() // 原子提交 +} + +// 2. 后台任务:轮询发送 +func PollAndSend(db *sql.DB, producer MQProducer) { + rows, _ := db.Query( + "SELECT id, topic, payload FROM outbox_messages WHERE status='pending' LIMIT 100") + for rows.Next() { + var msg MessageRecord + rows.Scan(&msg.ID, &msg.Topic, &msg.Payload) + if err := producer.Send(msg.Topic, msg.Payload); err == nil { + db.Exec("UPDATE outbox_messages SET status='sent' WHERE id=?", msg.ID) + } + } +} + +// 3. 消费端:幂等处理 +func HandleInventoryMsg(ctx context.Context, msg Message) error { + // 幂等检查:消息是否已处理过 + exists, _ := db.Query("SELECT 1 FROM processed_messages WHERE id=?", msg.ID) + if exists { return nil } // 已处理,跳过 + + // 扣减库存 + err := inventoryService.Deduct(msg.ProductID, msg.Quantity) + if err != nil { return err } + + // 记录已处理 + db.Exec("INSERT INTO processed_messages (id) VALUES (?)", msg.ID) + // 回调更新消息状态 + db.Exec("UPDATE outbox_messages SET status='completed' WHERE id=?", msg.ID) + return nil +} +``` + +### 方案二:事务消息(RocketMQ) + +RocketMQ 原生支持事务消息,比本地消息表更优雅。核心流程分三个阶段: + +**阶段一:发送半消息** + +Producer 先发送一条"半消息"(Half Message)到 Broker。这条消息对 Consumer 不可见。 + +**阶段二:执行本地事务** + +Producer 收到半消息发送成功的确认后,执行本地业务逻辑(比如创建订单)。 + +**阶段三:提交或回滚** + +根据本地事务的执行结果,向 Broker 发送 Commit(消息对 Consumer 可见)或 Rollback(丢弃消息)。 + +如果 Broker 没有收到 Commit/Rollback(比如 Producer 宕机),Broker 会主动**回查**Producer 的本地事务状态。这个回查机制是 RocketMQ 事务消息的精髓。 + +```go +// RocketMQ 事务消息流程 +func CreateOrderWithTxMsg(producer TxProducer, order Order) error { + // 1. 发送半消息 + msg := mq.NewMessage("order-topic", order.ToJSON()) + result, err := producer.SendMessageInTransaction(msg, func(localMsg *mq.Message) mq.LocalTransactionState { + // 2. 执行本地事务 + err := orderService.Create(order) + if err != nil { + return mq.RollbackMessage // 回滚,消息丢弃 + } + return mq.CommitMessage // 提交,消息可见 + }) + return err +} + +// 3. 事务回查(由 Broker 触发) +func CheckLocalTransaction(msg *mq.Message) mq.LocalTransactionState { + orderID := extractOrderID(msg) + order, err := orderService.Get(orderID) + if err != nil { + return mq.Unknown // 状态未知,稍后再查 + } + if order.Status == "created" { + return mq.CommitMessage + } + return mq.RollbackMessage +} +``` + +### 方案三:Saga 模式 + MQ + +Saga 适用于长事务场景,核心思想是将一个大事务拆成一系列小事务,每个小事务有对应的**补偿操作**。 + +Saga 有两种协调方式: + +- **编排式(Orchestration)**:由一个中心协调器控制流程,按顺序调用各服务。 +- **协同式(Choreography)**:各服务通过事件驱动协作,没有中心协调器,通过 MQ 传递事件。 + +```go +// Saga 协同式:通过事件驱动 +type OrderSagaEvent struct { + Type string // OrderCreated, InventoryReserved, PaymentCompleted + OrderID string + Data interface{} +} + +// 订单服务:创建订单后发布事件 +func CreateOrder(order Order) { + db.Save(order) + mq.Publish("order-events", OrderSagaEvent{ + Type: "OrderCreated", OrderID: order.ID, Data: order, + }) +} + +// 库存服务:监听订单事件,扣减库存 +func OnOrderCreated(event OrderSagaEvent) { + err := inventory.Reserve(event.Data.(Order).Items) + if err != nil { + // 库存不足,发布失败事件触发补偿 + mq.Publish("order-events", OrderSagaEvent{ + Type: "InventoryReservationFailed", OrderID: event.OrderID, + }) + return + } + mq.Publish("order-events", OrderSagaEvent{ + Type: "InventoryReserved", OrderID: event.OrderID, + }) +} + +// 订单服务:监听库存失败事件,补偿取消订单 +func OnInventoryReservationFailed(event OrderSagaEvent) { + orderService.Cancel(event.OrderID) // 补偿操作 + mq.Publish("order-events", OrderSagaEvent{ + Type: "OrderCancelled", OrderID: event.OrderID, + }) +} +``` + +### 三种方案对比 + +| 维度 | 本地消息表 | RocketMQ 事务消息 | Saga + MQ | +|------|-----------|------------------|-----------| +| 一致性保证 | 最终一致 | 最终一致 | 最终一致 | +| 性能影响 | 中(轮询 + DB 写入) | 低(Broker 端处理) | 低(事件驱动) | +| 实现复杂度 | 中 | 低(SDK 支持) | 高(需设计补偿链) | +| 适用场景 | 通用场景 | RocketMQ 技术栈 | 长事务、跨多服务 | +| 数据一致性边界 | 同一 DB 事务 | 本地事务 + MQ | 跨多个独立服务 | + +> [!question] +> 本地消息表模式下,如果消息发送成功但消费端处理失败,如何实现最终一致性?提示:想想消息表的状态管理和重试机制。 + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[47-MQ-与微服务]] +- [[48-MQ-客户端-SDK-最佳实践]] diff --git a/hhs/MQ/12-架构与实战/47-MQ-与微服务.md b/hhs/MQ/12-架构与实战/47-MQ-与微服务.md new file mode 100644 index 0000000..dd72aa2 --- /dev/null +++ b/hhs/MQ/12-架构与实战/47-MQ-与微服务.md @@ -0,0 +1,133 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 与微服务 + +## 概述 + +MQ 是微服务架构中实现服务间异步通信、解耦和削峰的核心组件。本文探讨同步与异步通信的取舍、事件驱动架构、服务网格对 MQ 的影响、背压传播机制以及 Saga 在微服务中的实践。 + +## 正文 + +### 微服务间通信:同步 vs 异步 + +微服务之间的通信本质上是两种模式的博弈: + +**同步通信(HTTP/gRPC)**:调用方发起请求,阻塞等待响应。优点是简单直观,适合查询类操作和需要即时响应的场景。缺点是强依赖——被调用方不可用时,调用方也会失败。 + +**异步通信(MQ)**:发送方发出消息后立即返回,不关心消费方何时处理。优点是解耦、削峰、提高系统弹性。缺点是增加了复杂度,调试困难,且不适合需要即时响应的场景。 + +选择原则很简单:**需要即时响应用同步,不需要即时响应用异步**。一个典型的电商系统,查询商品详情用 gRPC,下单后发扣库存消息用 MQ。 + +> [!question] +> 一个用户注册流程需要:创建账号、发欢迎邮件、初始化用户配置。这三步应该用同步还是异步?如果邮件发送失败怎么办? + +### 事件驱动微服务 + +事件驱动架构(EDA)是异步通信的极致形态。在 EDA 中,每个服务通过**领域事件**与其他服务交互,没有直接的 API 调用依赖。 + +```mermaid +graph TD + OS["订单服务"] -->|"OrderCreated"| MQ["MQ Broker"] + MQ -->|"OrderCreated"| IS["库存服务"] + MQ -->|"OrderCreated"| PS["支付服务"] + MQ -->|"OrderCreated"| NS["通知服务"] + IS -->|"InventoryReserved"| MQ + MQ -->|"InventoryReserved"| PS + PS -->|"PaymentCompleted"| MQ + MQ -->|"PaymentCompleted"| OS + MQ -->|"PaymentCompleted"| NS + NS -->|"OrderCompleted"| MQ +``` + +EDA 的关键好处: +- **松耦合**:订单服务不需要知道有哪些服务消费了 `OrderCreated` 事件,新增消费者不需要改订单服务。 +- **可扩展**:要加一个新的"积分服务",只需订阅 `OrderCreated` 事件,无需改动任何现有服务。 +- **弹性**:某个消费者宕机,消息在 MQ 中堆积,恢复后继续处理,不影响生产者。 + +但 EDA 也有代价:**调试困难**(请求链路不直观)、**数据一致性复杂**(最终一致而非强一致)、**事件风暴**(一个事件触发一连串事件)。 + +### 服务发现与 MQ + +微服务需要知道 MQ Broker 的地址。通常有几种方式: + +- **配置中心**:将 Broker 地址放在 Nacos/Apollo 等配置中心,客户端动态获取。 +- **环境变量**:简单直接,适合容器化部署。 +- **DNS SRV 记录**:通过 DNS 解析 Broker 地址,天然支持负载均衡。 + +多集群场景下,还需要**路由策略**——根据 Topic、地域或负载情况选择目标集群。 + +### 服务网格与 MQ + +服务网格(Istio/Linkerd)通过 Sidecar 代理拦截服务间的网络流量,实现流量管理、安全和可观测性。但 MQ 的流量是否应该走服务网格? + +答案通常是**不走**。原因: + +1. **协议不匹配**:服务网格的 Sidecar 代理(Envoy)擅长处理 HTTP/gRPC,对 Kafka、RocketMQ 等自定义协议支持有限。 +2. **性能开销**:每条消息都经过 Sidecar 代理会增加不必要的延迟。 +3. **长连接**:MQ 客户端通常维护长连接,Sidecar 代理的连接管理可能与之冲突。 + +不过,**mTLS**(双向 TLS 认证)是可以应用于 MQ 流量的。可以在客户端 SDK 层面集成 mTLS,而不是依赖 Sidecar。 + +### 背压传播 + +背压(Backpressure)是分布式系统中经常被忽视的问题。当下游处理速度跟不上上游生产速度时,压力会沿链路反向传播: + +**MQ 堆积** → **Consumer 处理不过来** → **数据库连接池耗尽** → **数据库响应变慢** → **更多请求超时** → **系统雪崩** + +端到端的背压管理需要每一层都参与: + +```go +// 带背压控制的消费者 +func ConsumeWithBackpressure(consumer MQConsumer, db *sql.DB) { + semaphore := make(chan struct{}, 100) // 最多 100 个并发处理 + + for msg := range consumer.Messages() { + semaphore <- struct{}{} // 获取信号量,满了就阻塞 + go func(m Message) { + defer func() { <-semaphore }() // 释放信号量 + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := processMessage(ctx, db, m); err != nil { + // 处理失败,不 ACK,消息会重新投递 + consumer.Nack(m) + } else { + consumer.Ack(m) + } + }(msg) + } +} +``` + +核心手段包括:**限流**(控制消费速率)、**降级**(非核心处理逻辑延后)、**熔断**(下游不可用时快速失败)。 + +### Saga 在微服务中的实践 + +前面分布式事务篇提到的 Saga 模式,在微服务中有两种实现方式: + +**编排式 Saga(Orchestration)**:由一个 Saga 编排器(Orchestrator)集中控制流程。编排器通过 MQ 向各服务发送命令,等待执行结果,再决定下一步。 + +- 优点:流程清晰,易于监控和调试。 +- 缺点:编排器成为单点和瓶颈。 + +**协同式 Saga(Choreography)**:各服务通过事件自主协作,没有中心控制点。每个服务监听自己关心的事件,处理后发布新事件。 + +- 优点:完全去中心化,服务间松耦合。 +- 缺点:流程分散在各服务中,难以理解和调试。 + +> [!question] +> 微服务架构中,如果一个服务下线了,它订阅的事件会堆积。除了扩 Partition 还有什么解法? + +生产实践中,大多数团队会混合使用两种方式:核心业务流程用编排式(可控制),辅助流程用协同式(松耦合)。 + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[46-MQ-分布式事务实践]] +- [[48-MQ-客户端-SDK-最佳实践]] +- [[49-MQ-客户端连接管理]] diff --git a/hhs/MQ/12-架构与实战/48-MQ-客户端-SDK-最佳实践.md b/hhs/MQ/12-架构与实战/48-MQ-客户端-SDK-最佳实践.md new file mode 100644 index 0000000..27a2bc0 --- /dev/null +++ b/hhs/MQ/12-架构与实战/48-MQ-客户端-SDK-最佳实践.md @@ -0,0 +1,188 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 客户端 SDK 最佳实践 + +## 概述 + +一个好的 MQ SDK 能让开发者专注于业务逻辑,而不是陷入连接管理、重试策略、序列化等底层细节。本文从 SDK 设计原则、连接管理、优雅关闭、序列化策略、错误处理、可观测性等维度,介绍生产级 MQ SDK 的设计要点,并给出完整的 Go 封装示例。 + +## 正文 + +### SDK 设计原则 + +一个优秀的 MQ 客户端 SDK 应该遵循四个原则: + +1. **抽象接口**:隐藏底层 MQ 实现细节,业务代码不直接依赖 Kafka/RocketMQ/RabbitMQ 的原生 API。这样切换 MQ 产品时只改 SDK 实现,不改业务代码。 +2. **可配置性**:超时、重试次数、批次大小等参数应该可配置,而不是硬编码。 +3. **重试与容错**:网络抖动、Broker 短暂不可用时自动重试,而不是让业务代码处理。 +4. **可观测性**:内置 TraceID 注入、Metrics 埋点、结构化日志,方便排查问题。 + +```go +// 抽象接口设计 +type MQProducer interface { + Send(ctx context.Context, topic string, message *Message) error + SendBatch(ctx context.Context, topic string, messages []*Message) error + Close() error +} + +type MQConsumer interface { + Subscribe(topic string, handler func(ctx context.Context, msg *Message) error) error + Close() error +} + +type Message struct { + Key string + Value []byte + Headers map[string]string + Timestamp time.Time +} +``` + +### 连接管理 + +MQ 客户端通常维护**长连接**而非短连接。建立连接的开销(TCP 三次握手 + MQ 协议握手)很大,频繁创建销毁会严重影响性能。 + +连接池设计要点: +- 初始连接数和最大连接数可配置。 +- 连接空闲超时后自动回收。 +- 健康检查:定期发送心跳探测连接是否存活。 +- 自动重连:检测到连接断开后,后台自动重建连接。 + +### 优雅关闭(Graceful Shutdown) + +优雅关闭是生产环境最容易被忽略、最容易出问题的环节。关闭顺序至关重要: + +```mermaid +graph LR + A["收到 SIGTERM"] --> B["停止接受新消息"] + B --> C["等待当前消息处理完成"] + C --> D["提交 Offset - 消费者"] + D --> E["刷新缓冲区 - 生产者"] + E --> F["关闭连接"] +``` + +核心代码: + +```go +type Client struct { + producer MQProducer + consumer MQConsumer + shutdown chan struct{} + wg sync.WaitGroup +} + +func (c *Client) GracefulShutdown(ctx context.Context) error { + close(c.shutdown) // 1. 通知所有 goroutine 停止接受新任务 + + // 2. 等待所有进行中的任务完成(带超时) + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + + select { + case <-done: + // 正常完成 + case <-ctx.Done(): + // 超时,强制关闭 + log.Warn("graceful shutdown timeout, forcing close") + } + + // 3. 关闭连接 + c.producer.Close() + c.consumer.Close() + return nil +} +``` + +### 序列化策略 + +消息体的序列化方式直接影响性能和可调试性: + +| 方式 | 优点 | 缺点 | 适用场景 | +|------|------|------|---------| +| JSON | 人类可读、调试方便 | 体积大、序列化慢 | 业务消息、日志 | +| Protobuf | 体积小、速度快 | 需要预定义 Schema | 高性能内部通信 | +| Avro | Schema 演进支持好 | 生态相对小 | 数据湖、大数据场景 | +| MessagePack | 类 JSON 但更紧凑 | 生态不如 JSON | 性能敏感 + 跨语言 | + +### 错误处理 + +SDK 必须区分**可重试错误**和**不可重试错误**: + +- **可重试**:网络超时、Broker 暂时不可用、限流(429)。这些错误过一会儿重试可能就成功了。 +- **不可重试**:消息格式错误、Topic 不存在、权限不足。重试多少次都不会成功。 + +不可重试的消息应该进入**死信队列(Dead Letter Queue)**,而不是无限重试或丢弃。同时触发告警通知运维人员。 + +```go +func (c *Client) SendWithRetry(ctx context.Context, topic string, msg *Message) error { + for i := 0; i <= c.maxRetries; i++ { + err := c.producer.Send(ctx, topic, msg) + if err == nil { + return nil + } + if !isRetryable(err) { + c.sendToDLQ(topic, msg, err) // 不可重试,进死信 + c.alertManager.Notify("unrecoverable mq error", err) + return err + } + // 指数退避 + time.Sleep(c.backoff(i)) + } + c.sendToDLQ(topic, msg, errors.New("max retries exceeded")) + return errors.New("send failed after retries") +} +``` + +### 可观测性集成 + +生产级 SDK 必须内置可观测性: + +- **TraceID 注入**:在消息 Headers 中注入 TraceID,实现跨服务链路追踪。 +- **Metrics 埋点**:发送成功率、消费延迟、队列堆积量等关键指标。 +- **结构化日志**:JSON 格式日志,包含 Topic、Partition、Offset 等上下文信息。 + +```go +func (c *Client) Send(ctx context.Context, topic string, msg *Message) error { + start := time.Now() + + // 注入 TraceID + if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() { + msg.Headers["trace-id"] = span.SpanContext().TraceID().String() + } + + err := c.producer.Send(ctx, topic, msg) + + // Metrics 埋点 + duration := time.Since(start) + c.metrics.Histogram("mq.send.duration").Observe(duration.Seconds()) + c.metrics.Counter("mq.send.total").Inc() + if err != nil { + c.metrics.Counter("mq.send.error").Inc() + } + + // 结构化日志 + c.logger.Info("message sent", + zap.String("topic", topic), + zap.String("key", msg.Key), + zap.Duration("latency", duration), + zap.Error(err), + ) + return err +} +``` + +> [!question] +> SDK 应该默认开启自动重试吗?重试次数和间隔如何确定?提示:考虑幂等性和消费者的处理能力。 + +## 关联笔记 + +- [[47-MQ-与微服务]] +- [[49-MQ-客户端连接管理]] +- [[50-MQ-设计与实现]] diff --git a/hhs/MQ/12-架构与实战/49-MQ-客户端连接管理.md b/hhs/MQ/12-架构与实战/49-MQ-客户端连接管理.md new file mode 100644 index 0000000..5054468 --- /dev/null +++ b/hhs/MQ/12-架构与实战/49-MQ-客户端连接管理.md @@ -0,0 +1,197 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 客户端连接管理 + +## 概述 + +连接是 MQ 客户端与 Broker 之间的通信通道,连接管理的好坏直接影响消息收发的稳定性和性能。本文深入探讨长连接与心跳机制、重连策略、Consumer Rebalance、客户端容错以及多数据中心路由等话题。 + +## 正文 + +### 长连接 vs 短连接 + +MQ 客户端几乎都使用**长连接**,原因有三: + +1. **建立连接成本高**:TCP 三次握手 + MQ 协议握手 + 认证鉴权,一次连接建立可能耗时几十到几百毫秒。如果每发一条消息都建一次连接,性能无法接受。 +2. **连接状态维护**:消费者需要维持 Offset、订阅关系等状态,这些状态绑定在连接上。 +3. **推送模式依赖长连接**:Broker 需要通过已有连接向 Consumer 推送消息(或通知拉取),短连接无法实现。 + +短连接的唯一优势是不需要保活,适合极低频的调用场景。但 MQ 显然不是这种场景。 + +### 心跳机制 + +长连接需要心跳来维持。心跳有两个作用: + +- **保活检测**:防止中间设备(NAT、防火墙、LB)因连接空闲超时而断开。 +- **故障发现**:Broker 通过心跳超时检测客户端是否存活,及时清理资源。 + +```go +// 心跳管理器 +type HeartbeatManager struct { + interval time.Duration + timeout time.Duration + conn net.Conn + lastAck time.Time + mu sync.RWMutex +} + +func (h *HeartbeatManager) Start(ctx context.Context) { + ticker := time.NewTicker(h.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // 发送心跳 + if err := h.sendPing(); err != nil { + log.Warn("heartbeat failed", zap.Error(err)) + h.onHeartbeatFailed() + } + } + } +} + +func (h *HeartbeatManager) sendPing() error { + h.conn.SetWriteDeadline(time.Now().Add(h.timeout)) + _, err := h.conn.Write([]byte{0x01}) // 心跳包 + if err == nil { + h.mu.Lock() + h.lastAck = time.Now() + h.mu.Unlock() + } + return err +} +``` + +典型的配置: +- 心跳间隔:3-30 秒 +- Broker 超时:通常为心跳间隔的 3 倍(比如心跳 10 秒,超时 30 秒) +- Kafka 默认心跳间隔 3 秒,session timeout 45 秒 + +### 重连策略 + +网络不可避免会抖动,客户端必须有健壮的重连机制。核心是**指数退避 + 抖动**: + +```go +type ReconnectPolicy struct { + BaseDelay time.Duration // 初始延迟,如 1s + MaxDelay time.Duration // 最大延迟,如 60s + MaxRetries int // 最大重试次数,0 表示无限 + Multiplier float64 // 退避倍数,通常 2.0 + JitterFactor float64 // 抖动因子,通常 0.1-0.3 +} + +func (p *ReconnectPolicy) NextDelay(attempt int) time.Duration { + // 指数退避:delay = base * multiplier^attempt + delay := float64(p.BaseDelay) * math.Pow(p.Multiplier, float64(attempt)) + if delay > float64(p.MaxDelay) { + delay = float64(p.MaxDelay) + } + // 加入抖动:避免多客户端同时重连造成"惊群效应" + jitter := delay * p.JitterFactor * (rand.Float64()*2 - 1) + return time.Duration(delay + jitter) +} +``` + +**为什么需要抖动?** 如果 1000 个客户端同时断线,没有抖动的话会在同一时刻全部重连,形成"惊群效应",直接把 Broker 打崩。抖动让重连时间分散开,平滑了压力。 + +### Consumer Rebalance 深入 + +Rebalance 是 Consumer Group 中最重要的机制之一。当 Group 成员变化时(新加入、离开、心跳超时),需要重新分配 Partition。 + +**触发条件:** +1. 新 Consumer 加入 Group +2. Consumer 主动离开(关闭) +3. Consumer 心跳超时(被认为宕机) +4. Topic 的 Partition 数量变化 + +**分配策略:** +- **Range**:按 Topic 分区连续分配,简单但容易不均。 +- **RoundRobin**:轮询分配,更均匀但实现复杂。 +- **Sticky**:尽量保持原有分配不变,只调整必要的部分,减少 Rebalance 开销。 +- **CooperativeSticky**(Kafka 2.4+):增量式 Rebalance,不需要 Stop-The-World。 + +**静态成员(Static Membership)**:Kafka 2.3 引入。给每个 Consumer 分配一个固定的 `group.instance.id`,短暂离线后重新加入时保持原来的 Partition 分配,避免不必要的 Rebalance。 + +```go +// 静态成员配置 +config := sarama.NewConfig() +config.Consumer.Group.InstanceId = "consumer-pod-1" // 固定 ID +config.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategySticky +config.Consumer.Group.Rebalance.Timeout = 60 * time.Second +``` + +> [!question] +> Consumer Rebalance 期间消费会暂停,如何实现"零停顿"的 Rebalance?提示:想想 CooperativeSticky 策略的工作方式。 + +### 客户端容错 + +客户端需要感知 Broker 层的变化: + +- **Broker 故障转移**:Producer 连接的 Broker 宕机时,需要自动切换到其他 Broker。 +- **分区 Leader 迁移**:某个 Partition 的 Leader 变了,客户端需要感知并更新路由。 +- **元数据刷新**:定期从 Broker 拉取最新的元数据(Topic 列表、分区信息、Leader 位置)。 + +```go +// 元数据自动刷新 +type MetadataRefresher struct { + client KafkaClient + interval time.Duration + topics []string +} + +func (m *MetadataRefresher) Start(ctx context.Context) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + metadata, err := m.client.RefreshMetadata(m.topics) + if err != nil { + log.Error("metadata refresh failed", zap.Error(err)) + continue + } + m.updateRoutingTable(metadata) + } + } +} +``` + +### 多数据中心客户端路由 + +全球化部署时,客户端需要智能路由: + +- **就近连接**:优先连接同区域的 Broker,降低延迟。 +- **故障转移**:本地 Broker 不可用时,自动切换到远端 Broker。 +- **读写分离**:本地集群负责读写,远端集群只作为灾备。 + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Connecting: "发起连接" + Connecting --> Connected: "握手成功" + Connecting --> Reconnecting: "连接失败" + Connected --> Heartbeating: "启动心跳" + Heartbeating --> Connected: "心跳正常" + Heartbeating --> Reconnecting: "心跳超时" + Connected --> Reconnecting: "连接断开" + Reconnecting --> Connecting: "退避结束" + Reconnecting --> Closed: "超过最大重试" + Connected --> Closing: "优雅关闭" + Closing --> Closed: "资源释放完成" + Closed --> [*] +``` + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[48-MQ-客户端-SDK-最佳实践]] +- [[50-MQ-设计与实现]] diff --git a/hhs/MQ/12-架构与实战/50-MQ-设计与实现.md b/hhs/MQ/12-架构与实战/50-MQ-设计与实现.md new file mode 100644 index 0000000..5d058a3 --- /dev/null +++ b/hhs/MQ/12-架构与实战/50-MQ-设计与实现.md @@ -0,0 +1,237 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# MQ 设计与实现 + +## 概述 + +理解 MQ 的最好方式是自己设计一个。本文串联前面所有知识,从零设计一个简化版 MQ,涵盖存储层、网络层、生产者、消费者、高可用等核心模块,帮助读者建立对 MQ 内部机制的完整认知。 + +## 正文 + +### 设计目标 + +在动手之前,先明确设计目标。一个实用的 MQ 需要平衡四个指标: + +| 指标 | 目标 | 说明 | +|------|------|------| +| 高吞吐 | 10 万+ msg/s | 单节点能力,通过分区横向扩展 | +| 低延迟 | p99 < 10ms | 端到端的生产-消费延迟 | +| 消息不丢失 | At-Least-Once | 通过副本复制和 ACK 机制保证 | +| 发布订阅 | 支持多消费组 | 每个消费组独立消费全量消息 | + +> [!question] +> 如果让你从零设计一个 MQ,你会优先保证哪个指标:吞吐、延迟,还是可靠性?为什么? + +### 存储层设计 + +存储是 MQ 的基石。现代 MQ 几乎都采用 **Append-Only Log** 作为核心存储结构——消息只能追加写入,不能修改和删除(删除通过过期清理实现)。 + +为什么用 Log?三个原因: +1. **顺序写磁盘比随机写快 1000 倍**:即使是 SSD,顺序写的吞吐也远高于随机写。 +2. **天然支持发布订阅**:Consumer 只需要记住读到哪个 Offset,每次从 Offset 位置顺序读即可。 +3. **实现简单**:不需要复杂的 B+Tree 索引,追加写 + 文件分段就够了。 + +**分段存储(Segment)**:单个日志文件会无限增长,需要按大小或时间分段。每个 Segment 对应一个数据文件和一个索引文件。 + +**索引文件**:存储消息 Offset 到文件物理位置的映射。索引采用稀疏索引(Sparse Index),不是每条消息都建索引,而是每隔一定字节建一条。查找时先在索引中二分查找,再在文件中顺序扫描。 + +```go +// 简化版 Log 存储 +type Segment struct { + baseOffset uint64 + dataFile *os.File + indexFile *os.File + currentSize int64 + maxBytes int64 +} + +// 追加写入消息 +func (s *Segment) Append(offset uint64, data []byte) error { + // 写数据文件:[length][data] + buf := make([]byte, 4+len(data)) + binary.BigEndian.PutUint32(buf[:4], uint32(len(data))) + copy(buf[4:], data) + + pos, _ := s.dataFile.Seek(0, io.SeekCurrent) + s.dataFile.Write(buf) + + // 写索引:[offset][position](稀疏索引,每 4KB 写一条) + if s.currentSize%4096 == 0 { + idxBuf := make([]byte, 16) + binary.BigEndian.PutUint64(idxBuf[:8], offset) + binary.BigEndian.PutUint64(idxBuf[8:], uint64(pos)) + s.indexFile.Write(idxBuf) + } + + s.currentSize += int64(len(buf)) + return nil +} + +// 按 Offset 读取消息 +func (s *Segment) Read(offset uint64) ([]byte, error) { + // 1. 在索引文件中二分查找 + position := s.findIndexPosition(offset) + // 2. 在数据文件中从 position 开始顺序读 + s.dataFile.Seek(int64(position), io.SeekStart) + // 3. 读取 length + data + lenBuf := make([]byte, 4) + s.dataFile.Read(lenBuf) + length := binary.BigEndian.Uint32(lenBuf) + data := make([]byte, length) + s.dataFile.Read(data) + return data, nil +} +``` + +### 网络层设计 + +MQ 的网络层需要处理大量并发连接,**Reactor 模式**是最佳选择: + +```mermaid +graph TB + subgraph "Reactor 网络模型" + A["Acceptor 线程"] -->|"接受连接"| EP["EventPoller - epoll/kqueue"] + EP -->|"可读事件"| W1["Worker 线程 1"] + EP -->|"可读事件"| W2["Worker 线程 2"] + EP -->|"可读事件"| W3["Worker 线程 3"] + W1 -->|"解析协议"| R["请求路由器"] + W2 -->|"解析协议"| R + W3 -->|"解析协议"| R + R -->|"Produce请求"| PH["ProduceHandler"] + R -->|"Fetch请求"| FH["FetchHandler"] + end +``` + +**协议编解码**:MQ 需要自定义二进制协议。一个简单的协议格式: + +``` +[4字节长度] [2字节请求类型] [4字节CorrelationID] [变长Body] +``` + +```go +// 简单的协议编解码 +type Request struct { + RequestType uint16 + CorrelationID uint32 + Body []byte +} + +func DecodeRequest(conn net.Conn) (*Request, error) { + // 读取长度 + lenBuf := make([]byte, 4) + io.ReadFull(conn, lenBuf) + length := binary.BigEndian.Uint32(lenBuf) + + // 读取完整请求 + payload := make([]byte, length) + io.ReadFull(conn, payload) + + return &Request{ + RequestType: binary.BigEndian.Uint16(payload[:2]), + CorrelationID: binary.BigEndian.Uint32(payload[2:6]), + Body: payload[6:], + }, nil +} +``` + +### 生产者设计 + +Producer 的核心流程: + +1. **序列化**:将业务对象转为字节数组。 +2. **分区路由**:根据 Key 的哈希值选择目标 Partition。 +3. **批量发送**:攒一批消息一起发送,减少网络往返。 +4. **ACK 等待**:根据配置等待 Broker 确认(acks=0/1/all)。 + +```go +// 批量发送 +type Producer struct { + buffer map[string][]*Message // key: topic-partition + batchSize int + linger time.Duration + mu sync.Mutex +} + +func (p *Producer) Send(msg *Message) { + p.mu.Lock() + partition := hashKey(msg.Key) % p.partitionCount + key := fmt.Sprintf("%s-%d", msg.Topic, partition) + p.buffer[key] = append(p.buffer[key], msg) + + if len(p.buffer[key]) >= p.batchSize { + msgs := p.buffer[key] + p.buffer[key] = nil + p.mu.Unlock() + p.flush(msgs) // 攒够一批,发送 + return + } + p.mu.Unlock() +} +``` + +### 消费者设计 + +消费者的核心是 **Pull 模式 + Offset 管理**: + +- **Pull 模式**:消费者主动从 Broker 拉取消息,而非 Broker 推送。这样消费者可以按自己的速率消费,天然支持背压。 +- **Offset 管理**:每个消费组在每个 Partition 上维护一个 Offset,记录消费到的位置。 +- **Consumer Group 协调**:同一组内的多个消费者通过 Rebalance 机制分配 Partition。 + +### 高可用设计 + +单节点 MQ 不够可靠,需要副本复制: + +- **Leader-Follower**:每个 Partition 有一个 Leader 和多个 Follower。Leader 处理读写,Follower 同步数据。 +- **Leader 选举**:Leader 宕机后,从 ISR 中选出新 Leader。基于 Epoch(任期)避免脑裂。 +- **数据一致性**:通过 HW(High Watermark)机制,只有被所有 ISR 副本确认的消息才对外可见。 + +### 整体架构 + +```mermaid +graph TB + subgraph "Producer 集群" + P1["Producer 1"] + P2["Producer 2"] + end + subgraph "Broker 集群" + subgraph "Broker 1" + L1["Partition 0 Leader"] + F1["Partition 1 Follower"] + end + subgraph "Broker 2" + L2["Partition 1 Leader"] + F2["Partition 0 Follower"] + end + subgraph "存储层" + S1["Segment + Index"] + S2["Segment + Index"] + end + end + subgraph "Consumer Group A" + C1["Consumer 1 - P0"] + C2["Consumer 2 - P1"] + end + subgraph "Consumer Group B" + C3["Consumer 3 - P0+P1"] + end + P1 --> L1 + P2 --> L2 + L1 -->|"同步复制"| F2 + L2 -->|"同步复制"| F1 + L1 --> S1 + L2 --> S2 + L1 --> C1 + L2 --> C2 + L1 --> C3 + L2 --> C3 +``` + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[48-MQ-客户端-SDK-最佳实践]] +- [[49-MQ-客户端连接管理]] diff --git a/hhs/MQ/13-云消息服务/51-云消息服务.md b/hhs/MQ/13-云消息服务/51-云消息服务.md new file mode 100644 index 0000000..313b492 --- /dev/null +++ b/hhs/MQ/13-云消息服务/51-云消息服务.md @@ -0,0 +1,149 @@ +--- +tags: + - MQ +create time: 2026-05-24 19:52 +--- + +# 云消息服务 + +## 概述 + +随着云计算的成熟,越来越多企业选择托管消息服务而非自建 MQ 集群。本文梳理主流云厂商的消息服务产品(AWS、Azure、GCP、阿里云),对比自建与托管的 Trade-off,并展望云原生 MQ 的发展趋势。 + +## 正文 + +### 为什么选择托管服务 + +自建 MQ 集群需要运维 Broker、监控告警、扩容缩容、版本升级、故障恢复,这些工作既繁琐又需要深厚的专业知识。托管服务的核心价值在于: + +- **免运维**:云厂商负责集群的部署、维护、升级和故障恢复。 +- **弹性扩缩**:按需自动扩展,不需要提前规划容量。流量高峰自动扩容,低谷缩容省钱。 +- **SLA 保障**:大厂通常承诺 99.95%+ 的可用性,如果达不到会赔偿。 +- **开箱即用**:IAM 集成、监控告警、日志分析等能力与云平台深度集成。 + +> [!question] +> 你的团队有多少人?如果只有 2-3 个后端开发,自建 Kafka 集群的运维成本能承受吗? + +### AWS 消息服务方案 + +AWS 提供了完整的消息服务产品矩阵: + +**SQS(Simple Queue Service)** + +AWS 最老牌的消息队列服务,标准队列模式。 + +- **标准队列**:最大努力排序,支持至少一次投递,吞吐无上限。适合对顺序要求不高的场景。 +- **先进先出队列(FIFO)**:严格顺序 + 去重,但吞吐受限(默认 300 msg/s,批量模式 3000 msg/s)。 + +**SNS(Simple Notification Service)** + +发布订阅服务,一条消息可以投递给多个订阅者(Fan-out 模式)。SNS + SQS 组合是最经典的 AWS 消息架构:SNS 作为消息分发中心,多个 SQS 队列作为订阅者。 + +**MSK(Managed Streaming for Apache Kafka)** + +AWS 托管的 Kafka 服务。与原生 Kafka 100% 兼容,但免去了 ZooKeeper 管理、Broker 升级等运维工作。MSK Serverless 进一步简化,按实际使用量计费,不需要预配置集群。 + +### Azure 消息服务方案 + +**Azure Service Bus** + +企业级消息服务,支持队列(点对点)和主题/订阅(发布订阅)两种模式。特色功能包括:消息会话(Message Sessions)、死信队列、消息延期投递、重复检测。 + +**Event Hubs** + +高吞吐的事件流式服务,类似 Kafka 的设计——分区 + Consumer Group + Offset。适合日志采集、IoT 数据流、实时分析等大数据场景。标准层吞吐可达每秒数百万事件。 + +### Google 消息服务方案 + +**Google Pub/Sub** + +全球分布式的消息服务,最大的特点是**自动扩缩容**和**全球消息路由**。消息自动在多个区域复制,任何区域的订阅者都能消费。默认 At-Least-Once 投递,支持 Exactly-Once(通过消息去重)。 + +计费模式按吞吐量(消息数量 + 数据量),没有预配置成本,对突发流量友好。 + +### 阿里云消息服务方案 + +**云消息队列(RocketMQ 版)** + +基于 Apache RocketMQ 的托管服务,与开源版完全兼容。支持普通消息、顺序消息、事务消息、定时/延时消息。适合国内业务场景。 + +**云消息队列(Kafka 版)** + +基于 Apache Kafka 的托管服务,兼容 Kafka 开源协议。适合大数据场景和已有的 Kafka 生态。 + +### 自建 vs 托管对比 + +| 维度 | 自建 | 托管 | +|------|------|------| +| 初始成本 | 低(开源免费) | 低(按量付费) | +| 规模成本 | 人力成本高 | 使用量大时费用可观 | +| 灵活性 | 完全可控 | 受限于云厂商功能 | +| 运维负担 | 重(团队需有专人) | 几乎为零 | +| 数据主权 | 数据完全自控 | 数据在云厂商处 | +| 性能 | 可深度调优 | 黑盒,调优空间有限 | + +> [!question] +> 自建 Kafka 集群 vs AWS MSK,在什么规模下自建更划算?提示:计算人力成本 + 机器成本 vs MSK 的按量计费。 + +### 云原生 MQ 趋势 + +云消息服务正在向更"原生"的方向演进: + +**Serverless MQ** + +不需要预配置集群,按实际消息量计费。SQS、Google Pub/Sub、MSK Serverless 都是这个方向。对于流量波动大的业务特别友好。 + +**事件总线(EventBridge)** + +AWS EventBridge 是事件驱动架构的基础设施。它不只是消息队列,而是一个完整的事件路由中心——支持事件过滤、转换、重放,以及与 30+ AWS 服务的原生集成。 + +**MQ + Functions 集成** + +消息触发云函数(Lambda/Cloud Functions),实现真正的事件驱动计算。消息到达 → 自动触发函数 → 处理完成 → 释放资源。完全不需要管理消费者进程。 + +```mermaid +graph TB + subgraph "云消息服务产品矩阵" + subgraph "AWS" + SQS["SQS - 标准队列"] + SNS["SNS - 发布订阅"] + MSK["MSK - 托管Kafka"] + EB["EventBridge - 事件总线"] + end + subgraph "Azure" + ASB["Service Bus - 企业级"] + EH["Event Hubs - 高吞吐流"] + end + subgraph "GCP" + GPS["Pub/Sub - 全球分布式"] + end + subgraph "阿里云" + RMQ["RocketMQ版"] + AK["Kafka版"] + end + end + subgraph "核心能力" + Q["队列模式"] + PS["发布订阅"] + STR["流式处理"] + EVT["事件路由"] + end + SQS --> Q + SNS --> PS + MSK --> STR + EB --> EVT + ASB --> Q + ASB --> PS + EH --> STR + GPS --> PS + GPS --> STR + RMQ --> Q + RMQ --> PS + AK --> STR +``` + +## 关联笔记 + +- [[44-MQ-高可用架构]] +- [[45-MQ-跨集群复制与容灾]] +- [[47-MQ-与微服务]] diff --git a/hhs/MQ/README.md b/hhs/MQ/README.md new file mode 100644 index 0000000..aab50e9 --- /dev/null +++ b/hhs/MQ/README.md @@ -0,0 +1,294 @@ +--- +tags: [MQ, 消息队列, 索引] +create time: 2026-05-24 19:52 +--- + +# 消息队列(Message Queue) + +## 概述 + +消息队列是分布式系统中最核心的中间件之一,用于解耦、削峰、异步通信。本目录从基础概念出发,逐层深入到协议标准、存储引擎、设计模式、流处理集成、消息追踪、K8s 部署、测试策略、安全运维与架构实战,构建完整的 MQ 知识体系。 + +## 知识地图 + +```mermaid +graph TD + A["消息队列"] --> B["基础概念"] + A --> C["消息模型"] + A --> D["协议与标准"] + A --> E["存储引擎"] + A --> F["可靠性保障"] + A --> G["高级特性"] + A --> H["主流 MQ 对比"] + A --> I["消息设计模式"] + A --> J["流处理与事件驱动"] + A --> K["监控与运维"] + A --> L["安全与多租户"] + A --> M["架构与实战"] + + B --> B1["什么是 MQ"] + B --> B2["核心术语"] + B --> B3["适用场景与选型原则"] + + C --> C1["队列模型 vs 发布订阅模型"] + C --> C2["Consumer Group 与 Rebalance"] + C --> C3["Push vs Pull"] + + D --> D1["AMQP 协议"] + D --> D2["MQTT 协议"] + D --> D3["JMS 与 OpenMessaging"] + + E --> E1["Kafka 存储设计"] + E --> E2["RocketMQ CommitLog"] + E --> E3["RabbitMQ 消息存储"] + + F --> F1["消息确认与持久化"] + F --> F2["Exactly-Once 语义"] + F --> F3["消息幂等性"] + F --> F4["顺序性保障"] + F --> F5["死信队列与消息回溯"] + + G --> G1["延迟消息与定时消息"] + G --> G2["事务消息"] + G --> G3["消息过滤与路由"] + G --> G4["Schema 管理与演进"] + G --> G5["消息压缩与批处理"] + + H --> H1["Kafka"] + H --> H2["RabbitMQ"] + H --> H3["RocketMQ"] + H --> H4["Pulsar"] + H --> H5["NATS / NSQ / Redis Streams"] + H --> H6["选型对比"] + + I --> I1["Competing Consumers"] + I --> I2["CQRS 与 Event Sourcing"] + I --> I3["Claim Check 与消息瘦身"] + I --> I4["背压与流控"] + I --> I5["请求-回复模式"] + + J --> J1["Kafka Streams"] + J --> J2["流处理框架集成"] + J --> J3["事件驱动架构"] + J --> J4["MQ 与 CDC"] + + K --> K1["监控指标与告警"] + K --> K2["消费积压治理"] + K --> K3["消息轨迹与链路追踪"] + K --> K4["容器化与 K8s 部署"] + K --> K5["性能调优"] + K --> K6["测试策略"] + + L --> L1["认证与授权"] + L --> L2["加密与审计"] + + M --> M1["高可用架构"] + M --> M2["跨集群复制与容灾"] + M --> M3["分布式事务实践"] + M --> M4["MQ 与微服务"] + M --> M5["客户端 SDK 最佳实践"] + M --> M6["客户端连接管理"] + M --> M7["MQ 设计与实现"] + + style A fill:#4A90D9,color:#fff + style B fill:#6EC1E0,color:#fff + style C fill:#6EC1E0,color:#fff + style D fill:#6EC1E0,color:#fff + style E fill:#F5A623,color:#fff + style F fill:#F5A623,color:#fff + style G fill:#F5A623,color:#fff + style H fill:#D0021B,color:#fff + style I fill:#D0021B,color:#fff + style J fill:#D0021B,color:#fff + style K fill:#D0021B,color:#fff + style L fill:#D0021B,color:#fff + style M fill:#D0021B,color:#fff +``` + +--- + +## 目录 + +### 一、基础概念 + +由浅入深,先搞清楚 MQ 是什么、为什么需要它。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 1 | [[01-基础概念/1-MQ-基础概念|MQ 基础概念]] | 什么是消息队列、核心术语(Producer / Broker / Consumer / Topic / Queue)、为什么需要 MQ | +| 2 | [[01-基础概念/2-MQ-适用场景与选型原则|MQ 适用场景与选型原则]] | 解耦、异步、削峰三大经典场景;何时该用 MQ,何时不该用 | + +### 二、消息模型 + +理解消息如何从生产者流向消费者。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 3 | [[02-消息模型/3-MQ-消息模型|MQ 消息模型]] | 队列模型 vs 发布订阅模型、Consumer Group 机制、推模式 vs 拉模式 | + +### 三、协议与标准 + +了解底层通信协议,才能理解不同 MQ 的设计取舍。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 4 | [[03-协议与标准/4-MQ-消息协议总览|MQ 消息协议总览]] | AMQP / MQTT / STOMP / JMS / OpenMessaging 横向对比与适用场景 | +| 5 | [[03-协议与标准/5-AMQP-协议|AMQP 协议]] | AMQP 0-9-1 核心概念(Exchange / Binding / Virtual Host)、AMQP 1.0 与 0-9-1 的差异 | +| 6 | [[03-协议与标准/6-MQTT-协议|MQTT 协议]] | IoT 场景下的轻量级协议、QoS 等级(0/1/2)、遗嘱消息、会话保持 | +| 7 | [[03-协议与标准/7-JMS-与-OpenMessaging|JMS 与 OpenMessaging]] | Java 消息服务规范、点对点与发布订阅接口、OpenMessaging 标准化尝试 | + +### 四、存储引擎 + +消息如何落地存储,决定了 MQ 的吞吐与可靠性上限。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 8 | [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]] | 顺序写盘、零拷贝、Page Cache、内存映射等通用高性能存储原理 | +| 9 | [[04-存储引擎/9-Kafka-存储设计|Kafka 存储设计]] | Partition Log 分段存储、稀疏索引、日志压缩(Log Compaction)、时间轮(Timing Wheel) | +| 10 | [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]] | CommitLog + ConsumeQueue + IndexFile 三层存储模型、MappedFile 内存映射 | +| 11 | [[04-存储引擎/11-RabbitMQ-消息存储|RabbitMQ 消息存储]] | Erlang Mnesia 表、持久化队列(durable / lazy queue)、内存告警与流控 | + +### 五、可靠性保障 + +消息不能丢、不能重复、最好还能保序——生产环境的核心诉求。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 12 | [[05-可靠性保障/12-MQ-消息确认与持久化|MQ 消息确认与持久化]] | ACK 机制、同步/异步刷盘、主从同步、消息投递语义(At-Most-Once / At-Least-Once / Exactly-Once) | +| 13 | [[05-可靠性保障/13-MQ-Exactly-Once-语义|MQ Exactly-Once 语义]] | 幂等生产者、事务性生产者、Consumer 端幂等消费、Exactly-Once 的边界与代价 | +| 14 | [[05-可靠性保障/14-MQ-消息幂等性|MQ 消息幂等性]] | 为什么会产生重复消息、幂等实现方案(唯一 ID + 去重表、乐观锁、Token 机制) | +| 15 | [[05-可靠性保障/15-MQ-顺序性保障|MQ 顺序性保障]] | 全局有序 vs 分区有序、Partition/Queue 级别的有序实现、多消费者场景下的顺序挑战 | +| 16 | [[05-可靠性保障/16-MQ-死信队列与消息回溯|MQ 死信队列与消息回溯]] | 死信队列的产生原因与处理策略、消息重试机制、消息回溯(按时间/偏移量重放) | + +### 六、高级特性 + +MQ 在复杂业务场景下提供的进阶能力。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 17 | [[06-高级特性/17-MQ-延迟消息与定时消息|MQ 延迟消息与定时消息]] | 延迟级别、定时投递的实现原理(时间轮、定时任务)、典型应用(订单超时取消) | +| 18 | [[06-高级特性/18-MQ-事务消息|MQ 事务消息]] | 半消息机制、事务状态回查、分布式事务的最终一致性方案 | +| 19 | [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]] | Tag / SQL 表达式过滤、Topic 路由策略、消息 Schema 演进 | +| 20 | [[06-高级特性/20-MQ-Schema-管理与演进|MQ Schema 管理与演进]] | Schema Registry、Avro/Protobuf/Thrift 编码、前向/后向/完全兼容性策略 | +| 21 | [[06-高级特性/21-MQ-消息压缩与批处理|MQ 消息压缩与批处理]] | Snappy / LZ4 / Zstd 压缩算法对比、批量发送与拉取策略、压缩对吞吐的影响 | + +### 七、主流 MQ 对比 + +了解主流产品的设计差异,才能做出合理的技术选型。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 22 | [[07-主流MQ对比/22-Kafka|Kafka]] | 架构设计(Partition / Replica / ISR)、高吞吐原理(零拷贝、顺序写、批量拉取)、Kafka 生态 | +| 23 | [[07-主流MQ对比/23-RabbitMQ|RabbitMQ]] | AMQP 协议、Exchange 类型(Direct / Fanout / Topic / Headers)、插件生态 | +| 24 | [[07-主流MQ对比/24-RocketMQ|RocketMQ]] | NameServer / Broker / Producer / Consumer 架构、事务消息原生支持、延迟消息实现 | +| 25 | [[07-主流MQ对比/25-Apache-Pulsar|Apache Pulsar]] | 计算存储分离架构、Segment 存储(BookKeeper)、多租户原生支持、与 Kafka 的架构对比 | +| 26 | [[07-主流MQ对比/26-NATS-NSQ-Redis-Streams|NATS / NSQ / Redis Streams]] | NATS JetStream、NSQ 无中心架构、Redis Streams 底层 Radix Tree、轻量级场景选型 | +| 27 | [[07-主流MQ对比/27-MQ-选型对比|MQ 选型对比]] | Kafka vs RabbitMQ vs RocketMQ vs Pulsar vs NATS 多维度对比(吞吐、延迟、可靠性、生态、运维) | + +### 八、消息设计模式 + +用好 MQ 不只是选产品,更需要掌握经过验证的架构模式。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 28 | [[08-消息设计模式/28-MQ-Competing-Consumers-模式|MQ Competing Consumers 模式]] | 多消费者竞争消费、负载均衡策略、分区分配算法(Range / Round-Robin / Sticky / Cooperative) | +| 29 | [[08-消息设计模式/29-MQ-CQRS-与-Event-Sourcing|MQ CQRS 与 Event Sourcing]] | 命令查询分离、事件作为数据源、事件存储与重放、与传统 CRUD 的对比 | +| 30 | [[08-消息设计模式/30-MQ-Claim-Check-与消息瘦身|MQ Claim Check 与消息瘦身]] | 大消息拆分存储、消息体外置到对象存储、按需拉取减少网络开销 | +| 31 | [[08-消息设计模式/31-MQ-背压与流控|MQ 背压与流控]] | 生产速率超过消费能力时的保护机制、Broker 端流控、Consumer 端反压、限流降级策略 | +| 32 | [[08-消息设计模式/32-MQ-请求-回复模式|MQ 请求-回复模式]] | RPC over MQ、关联 ID(CorrelationID)、Reply-To 队列、与 gRPC/HTTP 调用的对比取舍 | + +### 九、流处理与事件驱动 + +MQ 不只是"传消息",更是实时数据流的基础设施。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 33 | [[09-流处理与事件驱动/33-MQ-与流处理|MQ 与流处理]] | 消息队列 vs 流处理平台的区别与融合、Kafka Streams / Flink / Spark Streaming 集成 | +| 34 | [[09-流处理与事件驱动/34-事件驱动架构-EDA|事件驱动架构 EDA]] | 事件风暴(Event Storming)、事件编排 vs 事件编排(Orchestration vs Choreography)、Saga 模式 | +| 35 | [[09-流处理与事件驱动/35-MQ-与-CDC|MQ 与 CDC]] | Change Data Capture 原理、Debezium + Kafka Connect、数据库变更实时同步方案 | + +### 十、监控与运维 + +线上 MQ 出问题,如何快速发现、定位与修复。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 36 | [[10-监控与运维/36-MQ-监控指标与告警|MQ 监控指标与告警]] | Broker / Producer / Consumer 核心指标、Prometheus + Grafana 看板搭建、告警阈值设计 | +| 37 | [[10-监控与运维/37-MQ-消费积压治理|MQ 消费积压治理]] | 积压根因分析、紧急扩容消费者、临时队列转发、监控告警策略 | +| 38 | [[10-监控与运维/38-MQ-消息轨迹与链路追踪|MQ 消息轨迹与链路追踪]] | 消息唯一标识(TraceID)传递规范、端到端链路可视化、消息审计日志、排查"消息去哪了" | +| 39 | [[10-监控与运维/39-MQ-容器化与-K8s-部署|MQ 容器化与 K8s 部署]] | K8s Operator(Strimzi / RocketMQ Operator)、Helm Chart 部署、StatefulSet 有状态服务管理、资源规划与持久卷 | +| 40 | [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]] | Producer 批量与压缩、Consumer 并发与预取、Broker 刷盘与页缓存策略、网络参数优化 | +| 41 | [[10-监控与运维/41-MQ-测试策略|MQ 测试策略]] | 集成测试(Testcontainers)、影子流量(Shadow Topic)、故障注入(Chaos Engineering)、消息契约测试(Pact) | + +### 十一、安全与多租户 + +生产环境不可或缺的安全基础设施。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 42 | [[11-安全与多租户/42-MQ-认证与授权|MQ 认证与授权]] | SASL / mTLS / OAuth2 认证机制、ACL 细粒度授权、Kerberos 集成 | +| 43 | [[11-安全与多租户/43-MQ-加密与审计|MQ 加密与审计]] | 传输加密(TLS)、消息级加密、审计日志、配额管理与多租户隔离 | + +### 十二、架构与实战 + +面向真实生产环境的问题与解决方案。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 44 | [[12-架构与实战/44-MQ-高可用架构|MQ 高可用架构]] | Broker 集群与副本机制、无单点设计、灰度发布 | +| 45 | [[12-架构与实战/45-MQ-跨集群复制与容灾|MQ 跨集群复制与容灾]] | MirrorMaker 2.0、Confluent Replicator、Dledger 多副本、跨地域同步、异地多活架构 | +| 46 | [[12-架构与实战/46-MQ-分布式事务实践|MQ 分布式事务实践]] | 本地消息表 + MQ、事务消息 + 状态回查、Saga 模式与 MQ 结合 | +| 47 | [[12-架构与实战/47-MQ-与微服务|MQ 与微服务]] | 服务间通信模式(同步 vs 异步)、事件驱动微服务、服务网格(Istio/Linkerd)与 MQ 集成、背压传播 | +| 48 | [[12-架构与实战/48-MQ-客户端-SDK-最佳实践|MQ 客户端 SDK 最佳实践]] | 多语言 SDK 设计范式、连接池管理、优雅关闭(Graceful Shutdown)、序列化策略、错误处理规范 | +| 49 | [[12-架构与实战/49-MQ-客户端连接管理|MQ 客户端连接管理]] | 长连接与心跳、重连与重试策略、分区分配策略(Consumer Rebalance)、客户端容错 | +| 50 | [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]] | 从零设计一个简易 MQ:存储引擎、网络模型、消费者管理、高可用方案 | + +### 附录:云消息服务 + +了解云厂商托管方案,拓宽技术视野。 + +| 序号 | 主题 | 说明 | +|:---:|------|------| +| 51 | [[13-云消息服务/51-云消息服务|云消息服务]] | AWS SQS / SNS / MSK、Azure Service Bus、Google Pub/Sub、阿里云 RocketMQ 与 Kafka、自建 vs 托管对比 | + +--- + +## 学习路径建议 + +```mermaid +graph LR + S1["1 基础概念"] --> S2["2 消息模型"] + S2 --> S3["3 协议与标准"] + S3 --> S4["4 存储引擎"] + S4 --> S5["5 可靠性保障"] + S5 --> S6["6 高级特性"] + S6 --> S7["7 主流 MQ"] + S7 --> S8["8 设计模式"] + S8 --> S9["9 流处理与 EDA"] + S9 --> S10["10 监控运维"] + S10 --> S11["11 安全"] + S11 --> S12["12 架构实战"] + + style S1 fill:#6EC1E0,color:#fff + style S2 fill:#6EC1E0,color:#fff + style S3 fill:#6EC1E0,color:#fff + style S4 fill:#F5A623,color:#fff + style S5 fill:#F5A623,color:#fff + style S6 fill:#F5A623,color:#fff + style S7 fill:#D0021B,color:#fff + style S8 fill:#D0021B,color:#fff + style S9 fill:#D0021B,color:#fff + style S10 fill:#D0021B,color:#fff + style S11 fill:#D0021B,color:#fff + style S12 fill:#D0021B,color:#fff +``` + +> [!tip] 建议 +> - **初学者**:按 1 → 2 → 3 → 5 顺序阅读,打牢基础后再进入存储引擎和高级特性。 +> - **有经验者**:可直接跳到 7(主流 MQ)和 8(设计模式),以问题驱动学习。 +> - **架构师**:重点关注 8 设计模式、9 流处理、10 监控运维、12 架构实战四个模块。 +> - 每篇笔记都包含思考题,建议先独立思考再看解答。 + +## 关联笔记 + +- [[分布式系统]]