Files
cs-note/hhs/MQ/05-可靠性保障/12-MQ-消息确认与持久化.md
T
2026-05-24 20:51:06 +08:00

160 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]]