4. 消息可靠性(下):Broker端持久化机制与消费者端确认机制,如何做到Exactly-Once语义?
好,咱们接着聊消息可靠性。上一节我们把生产端的可靠性讲透了,这一节重点落在Broker端和消费端。
说白了,消息从生产者发出,到消费者真正处理完,中间有两个关键环节容易丢数据:
- Broker收到消息后,怎么保证不丢?——持久化机制
- 消费者处理完消息后,怎么告诉Broker“我搞定了”?——确认机制
这两个环节配合不好,Exactly-Once就是空谈。我当年在量化交易系统里踩过这个坑,今天咱们一次性讲透。
4.1 Broker端持久化:消息落盘的艺术
先问一个问题:Broker收到消息后,如果立刻返回ACK给生产者,但还没来得及写磁盘就宕机了,消息去哪了?
嗯,丢了。所以持久化机制的核心就是:消息必须安全落盘,才能算“已接收”。
4.1.1 同步刷盘 vs 异步刷盘
这是最基础的两种策略。我直接给对比表格,你一看就明白:
| 策略 | 原理 | 可靠性 | 性能 | 典型场景 |
|---|---|---|---|---|
| 同步刷盘 | 每条消息写入后,立即调用fsync强制刷盘 | 极高,几乎不丢数据 | 低,每次写入都有磁盘IO等待 | 金融交易、订单系统 |
| 异步刷盘 | 消息先写入PageCache,后台线程批量刷盘 | 较高,宕机可能丢失几毫秒的数据 | 高,吞吐量可提升10倍以上 | 日志收集、监控系统 |
4.1.2 预写日志(WAL)与文件结构
大多数消息队列(Kafka、RocketMQ、Pulsar)都采用WAL机制。什么意思?
消息先顺序写入一个日志文件(CommitLog),再异步更新索引。顺序写磁盘的速度,比随机写快几个数量级。你想想看,SSD的顺序写能达到GB/s级别,随机写可能只有几十MB/s。
RocketMQ的存储结构大致是这样:
CommitLog(顺序写,所有消息都写在这里)
↓
ConsumeQueue(逻辑队列,基于CommitLog构建的索引)
↓
IndexFile(可选,用于按Key查询)
这样做的好处很明显:写入路径极简,没有随机IO。即使Broker突然宕机,重启后通过回放CommitLog就能恢复所有消息。
4.2 消费者端确认机制:ACK的艺术
Broker把消息持久化好了,接下来消费者来拉取。消费者处理完消息后,需要告诉Broker:“这条消息我处理完了,你可以标记为已消费了。”
这个“告诉”的动作,就是ACK(确认)。
4.2.1 自动ACK vs 手动ACK
这是两种截然不同的策略,我直接说结论:
- 自动ACK:消费者拉取到消息后,Broker立即标记为已消费。如果消费者在处理过程中宕机,消息就丢了。
- 手动ACK:消费者处理完业务逻辑后,主动调用ACK方法。如果消费者宕机,消息会重新投递。
在交易系统里,我从来不用自动ACK。为什么?因为自动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。这样即使消息重复投递,也不会影响业务。
4.4 一张图看懂消息可靠性全链路
下面这张SVG图,把整个链路串起来了。从生产者到Broker到消费者,每个环节的可靠性措施一目了然:
4.5 实战中的避坑指南
最后,分享几个我踩过的坑,希望能帮你少走弯路:
- 坑1:同步刷盘导致性能瓶颈——我曾经在高峰期发现TPS上不去,排查后发现是同步刷盘太慢。解决方案:预分配文件 + 批量写入,把多次小IO合并成一次大IO。
- 坑2:手动ACK超时导致重复消费——消费者处理时间过长,Broker认为消息超时未ACK,重新投递。解决方案:延长ACK超时时间,或者把长任务拆分成多个短任务。
- 坑3:幂等性设计不彻底——只做了数据库去重,没考虑缓存。结果消息重复时,缓存里写了两份。解决方案:所有写操作都带上业务幂等键,包括缓存、MQ、数据库。
好了,消息可靠性的内容就到这里。记住一句话:没有绝对的可靠,只有相对的权衡。根据你的业务场景,选择合适的策略组合,才是正道。
公众号:蓝海资料掘金营,微信deep3321