7.6 KiB
tags, create time
| tags | 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)。
// 同步发送:阻塞等待 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 的典型实现——宁可重复处理,也不能丢失消息。