160 lines
7.6 KiB
Markdown
160 lines
7.6 KiB
Markdown
|
|
---
|
|||
|
|
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]]
|