4. 消息可靠性(下):Broker端持久化机制与消费者端确认机制,如何做到Exactly-Once语义?

好,咱们接着聊消息可靠性。上一节我们把生产端的可靠性讲透了,这一节重点落在Broker端和消费端。

说白了,消息从生产者发出,到消费者真正处理完,中间有两个关键环节容易丢数据:

  • Broker收到消息后,怎么保证不丢?——持久化机制
  • 消费者处理完消息后,怎么告诉Broker“我搞定了”?——确认机制

这两个环节配合不好,Exactly-Once就是空谈。我当年在量化交易系统里踩过这个坑,今天咱们一次性讲透。

4.1 Broker端持久化:消息落盘的艺术

先问一个问题:Broker收到消息后,如果立刻返回ACK给生产者,但还没来得及写磁盘就宕机了,消息去哪了?

嗯,丢了。所以持久化机制的核心就是:消息必须安全落盘,才能算“已接收”

4.1.1 同步刷盘 vs 异步刷盘

这是最基础的两种策略。我直接给对比表格,你一看就明白:

策略 原理 可靠性 性能 典型场景
同步刷盘 每条消息写入后,立即调用fsync强制刷盘 极高,几乎不丢数据 低,每次写入都有磁盘IO等待 金融交易、订单系统
异步刷盘 消息先写入PageCache,后台线程批量刷盘 较高,宕机可能丢失几毫秒的数据 高,吞吐量可提升10倍以上 日志收集、监控系统
我的经验:在交易系统中,我坚持用同步刷盘。虽然性能差一些,但“丢单”的代价远大于性能损耗。有一次压测时发现同步刷盘导致TPS上不去,后来通过批量写入+预分配文件解决了,而不是降级成异步。

4.1.2 预写日志(WAL)与文件结构

大多数消息队列(Kafka、RocketMQ、Pulsar)都采用WAL机制。什么意思?

消息先顺序写入一个日志文件(CommitLog),再异步更新索引。顺序写磁盘的速度,比随机写快几个数量级。你想想看,SSD的顺序写能达到GB/s级别,随机写可能只有几十MB/s。

RocketMQ的存储结构大致是这样:

CommitLog(顺序写,所有消息都写在这里)
    ↓
ConsumeQueue(逻辑队列,基于CommitLog构建的索引)
    ↓
IndexFile(可选,用于按Key查询)

这样做的好处很明显:写入路径极简,没有随机IO。即使Broker突然宕机,重启后通过回放CommitLog就能恢复所有消息。

避坑指南:我曾经遇到过CommitLog所在磁盘写满,导致消息写入卡死。后来我强制给CommitLog和ConsumeQueue分配独立磁盘,并设置磁盘使用率告警(80%预警,90%禁止写入)。这个习惯一直保留到现在。

4.2 消费者端确认机制:ACK的艺术

Broker把消息持久化好了,接下来消费者来拉取。消费者处理完消息后,需要告诉Broker:“这条消息我处理完了,你可以标记为已消费了。”

这个“告诉”的动作,就是ACK(确认)。

4.2.1 自动ACK vs 手动ACK

这是两种截然不同的策略,我直接说结论:

  • 自动ACK:消费者拉取到消息后,Broker立即标记为已消费。如果消费者在处理过程中宕机,消息就丢了。
  • 手动ACK:消费者处理完业务逻辑后,主动调用ACK方法。如果消费者宕机,消息会重新投递。

在交易系统里,我从来不用自动ACK。为什么?因为自动ACK相当于“我还没吃呢,就说我吃饱了”。

注意:手动ACK也有陷阱。如果你在ACK之前就返回了处理结果,但ACK因为网络原因没送到Broker,消息会被重复消费。所以手动ACK必须保证幂等性

4.2.2 ACK的三种模式(以RocketMQ为例)

模式 行为 适用场景
CONSUME_SUCCESS 消息处理成功,Broker标记为已消费 正常业务处理
RECONSUME_LATER 处理失败,稍后重试 临时性错误(如数据库锁冲突)
SUSPEND_CURRENT_QUEUE_A_MOMENT 暂停当前队列消费,避免阻塞其他队列 严重错误,需要人工介入

我个人习惯:所有业务逻辑都放在try-catch里。正常处理完就CONSUME_SUCCESS,遇到可重试异常就RECONSUME_LATER,遇到不可恢复异常就记录日志并SUSPEND。

4.3 Exactly-Once语义:终极目标

好,前面铺垫了这么多,终于到核心了。Exactly-Once到底怎么实现?

说白了,就是生产者不重复发 + Broker不丢 + 消费者不重复处理。三个环节缺一不可。

4.3.1 生产端:幂等生产者 + 事务消息

生产者发送消息时,带上全局唯一ID(比如UUID)。Broker根据ID去重,重复的消息直接丢弃。这就是幂等生产者。

如果业务需要“发送消息”和“本地事务”保持原子性,就用事务消息。先半提交,本地事务成功后再确认提交。

4.3.2 Broker端:同步刷盘 + 多副本

同步刷盘保证单机不丢,多副本(比如Kafka的ISR机制)保证集群不丢。我建议至少3副本,且min.insync.replicas设为2。

4.3.3 消费端:手动ACK + 幂等消费

消费者处理完业务后,先执行幂等逻辑(比如去重表、版本号),再手动ACK。这样即使消息重复投递,也不会影响业务。

核心公式:Exactly-Once = 幂等生产者 + 同步刷盘 + 多副本 + 手动ACK + 幂等消费

4.4 一张图看懂消息可靠性全链路

下面这张SVG图,把整个链路串起来了。从生产者到Broker到消费者,每个环节的可靠性措施一目了然:

消息可靠性全链路:Exactly-Once实现 生产者 幂等发送 + 事务消息 发送消息 Broker 同步刷盘 多副本(ISR) WAL + CommitLog 拉取消息 消费者 手动ACK + 幂等消费 ACK确认 Exactly-Once 核心条件 生产者不重复发 + Broker不丢 + 消费者不重复处理 幂等ID + 同步刷盘 + 多副本 + 手动ACK + 去重表

4.5 实战中的避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  • 坑1:同步刷盘导致性能瓶颈——我曾经在高峰期发现TPS上不去,排查后发现是同步刷盘太慢。解决方案:预分配文件 + 批量写入,把多次小IO合并成一次大IO。
  • 坑2:手动ACK超时导致重复消费——消费者处理时间过长,Broker认为消息超时未ACK,重新投递。解决方案:延长ACK超时时间,或者把长任务拆分成多个短任务。
  • 坑3:幂等性设计不彻底——只做了数据库去重,没考虑缓存。结果消息重复时,缓存里写了两份。解决方案:所有写操作都带上业务幂等键,包括缓存、MQ、数据库。
我的建议:不要一开始就追求Exactly-Once。先做到At-Least-Once(至少一次),再通过幂等性升级到Exactly-Once。步子迈太大,容易扯着蛋。

好了,消息可靠性的内容就到这里。记住一句话:没有绝对的可靠,只有相对的权衡。根据你的业务场景,选择合适的策略组合,才是正道。


公众号:蓝海资料掘金营,微信deep3321