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

7.6 KiB
Raw Blame History

tags, create time
tags create time
MQ
消息确认
持久化
ACK
可靠性
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)。

// 同步发送:阻塞等待 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 的完整投递链路,标注了每个环节的确认方式:

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 的消费者示例

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 的典型实现——宁可重复处理,也不能丢失消息。

关联笔记