文章目录
- 什么是MQ?
- 为什么要有MQ?
- 最简单的消息队列
- [Kafka 架构](#Kafka 架构)
- 分区的作用
- 消费顺序
- [Kafka 的 partion 和 RocketMQ 的 queue 有什么区别?](#Kafka 的 partion 和 RocketMQ 的 queue 有什么区别?)
-
- [Kafka 底层存储结构](#Kafka 底层存储结构)
- [RocketMQ 底层存储结构](#RocketMQ 底层存储结构)
- [为什么 Kafka 比 RocketMQ 更快?](#为什么 Kafka 比 RocketMQ 更快?)
- [Kafka 消息压缩](#Kafka 消息压缩)
- [Kafka 如何保证消息不丢失?](#Kafka 如何保证消息不丢失?)
- [Kafka 如何保证消息不重复消费](#Kafka 如何保证消息不重复消费)
- 核心思想
什么是MQ?
MQ,Message Queue,是一种提供消息队列服务的中间件,也称为消息中间件,是一套提供了消息生产、存储、消费全过程API的软件系统。消息即数据。一般消息的体量不会很大。
为什么要有MQ?
限流
MQ 可以将系统的超量请求暂存其中,以便系统后期可以慢慢进行处理,从而避免了请求的丢失或系统被压垮。
例如: 12306 抢票的时候购买后会返回给用户一个处理中的状态, 待后端存储或处理数据完成后显示购票成功。
异步解耦
上游系统对下游系统的调用若为同步调用,则会大大降低系统的吞吐量与并发度,且系统耦合度太高。而异步调用则会解决这些问题。所以两层之间若要实现由同步到异步的转化,一般性做法就是,在这两层间添加一个MQ层。
例如: 假如说有个用户购买了某个游戏列表中的游戏,这时需要去存储两处数据(用户表、订单表),如果这里同步去调用时间就是存储用户信息的时间 + 订单下单的时间, 所以这里可以通过mq去异步调用,并且可以单独将用户数据存储的方法和订单下单的方法各自写到用户服务和订单服务上进行解耦 (当然这个功能也可以靠别的方式实现),满足了实现功能的异步调用和服务解耦。
数据收集
在业务侧处理完成之后, 会讲一些日志信息、监控数据、用户行为等进行存储落库,若直接进行同步调用存储数据则会降低接口性能,此时就可以考虑使用mq去发送消息到下游数据存储服务进行存储或一些大数据组件sink数据(比如说kafka -> clickhouse)
最简单的消息队列
go
package main
import (
"fmt"
"time"
)
var queue = make(chan string, 100)
// 生产者
func producer(name string) {
for i := 1; i <= 5; i++ {
msg := fmt.Sprintf("%s-消息-%d", name, i)
queue <- msg
fmt.Printf("[生产者 %s] 发送: %s\n", name, msg)
time.Sleep(500 * time.Millisecond)
}
}
// 消费者
func consumer(name string) {
for {
msg := <-queue
fmt.Printf("[消费者 %s] 收到: %s\n", name, msg)
time.Sleep(1 * time.Second)
}
}
func main() {
go producer("P1")
go consumer("C1")
time.Sleep(100 * time.Second)
}
上述这个简单的 MQ 系统存在什么问题 ?
- 消息无法持久化, 宕机了消息全部丢失。
- 消费者没有办法维护各自的 offset,消息一旦消费就会从队列中删除。
- 单节点的机器才可以使用,无法跨集群。
Kafka 架构

名称 | 解释 |
---|---|
Broker | 消息中间件处理节点,⼀个Kafka节点就是⼀个broker,⼀个或者多个Broker可以组成⼀个Kafka集群(其实就是机器) |
Topic | Kafka根据topic对消息进行归类,发布到Kafka集群的每条消息都需要指定⼀个topic |
Producer | 消息生产者,向Broker发送消息的客户端 |
Consumer | 消息消费者,从Broker读取消息的客户端 |
ConsumerGroup | 每个Consumer属于⼀个特定的Consumer Group,⼀条消息可以被多个不同的 Consumer Group消费,但是⼀个Consumer Group中只能有⼀个Consumer能够消费该消息 |
Partition | 物理上的概念,⼀个topic可以分为多个partition,每个partition内部消息是有序的(其实就是队列queue) |
- 分区支持副本机制,即一个分区可以在多台机器上复制数据。topic 中每一个分区会有 Leader 与 Follower。Kafka 的内部机制可以保证 topic 某一个分区的 Leader 与 Followr 不在同一台机器上,并且每一台 Broker 会尽量均衡地承担各个分区的 Leader。当然,在运行过程中如果 Leader 不均衡,也可以执行命令进行手动平衡。
- Leader 节点承担一个分区的读写,Follower 节点只负责数据备份。
既然有 Topic 了, 只需要把消息发送到 Topic 里, 对应的 consumer 去消费就可以, 为什么还需要一个 Partition?
分区的作用
- 可以分布式存储
- 可以并行写
那么问题又来了, 有这么多partion, 消费者怎么消费呢?
消费顺序
- 1个消费者 + 3个 partion
- 1个消费者去拉取 3 个分区的消息。
- 单个 Partition 内有序(如 Key a 的消息按发送顺序消费)。
- 不同 Partition 之间无序(Key a 和 Key b 的消息可能交替消费)。
- 3个消费者 + 3个 partion
- 每个消费者独立处理 1 个 Partition,完全并行。
- 单个 Partition 内对应的消费有序
所以这里如果需求要求消费顺序必须严格有序, 有两种解决思路
- 一个 topic 里只设置一个 partion (损失了并发消费消息的能力)
- 在写入消息时指定 key, 相同 key 的消息会路由到一个 partion 中
那 rocketmq 又是怎么做的?
https://rocketmq.apache.org/zh/docs/featureBehavior/07messagefilter/#tag标签过滤
Kafka 的 partion 和 RocketMQ 的 queue 有什么区别?

Kafka 中的 Partion 中的消息存放完整的消息的信息,而在 RocketMQ 中 Queue 只存放简要信息 (比如 offset),真正的消息内容会存放在 commitLog 中。
为什么 RocketMQ 要采用这样的结构?
Kafka 底层存储结构

Kafka 每个Topic下都有一个 segment, 在Topic下写入消息会在对应的segment上存储消息, 并且在相同Topic下是顺序写的方式, 在不同Topic中写入数据会是随机写。
RocketMQ 底层存储结构

RocketMQ 将一台机器(borker) 下的所有 Topic 中的消息内容都放在了 commitLog 中, 这样就算在不同分区中写入消息时也是顺序写。
既然 RocketMQ 写入是顺序写, 那为什么在速度方面还是略逊色于Kafka?
为什么 Kafka 比 RocketMQ 更快?
零拷贝
零拷贝是指在数据传输过程中,尽量减少数据在内存中的拷贝次数,甚至完全避免数据的拷贝操作。通过直接操作数据所在的内存区域,或者利用操作系统提供的特殊机制,零拷贝可以显著提高数据传输的效率。
从定义可以看出来,零拷贝并不是一次都不拷贝,而是尽量去避免拷贝。
数据拷贝流程

上图流程发生:
- 4次数据拷贝
- 4次空间切换
mmap()
mmap()直接将磁盘文件映射到应用程序的地址空间,使得应用程序可以直接在内存中读取和写入文件数据,这样一来,对映射内容的修改就是直接的反应到实际的文件中,避免需要从用户控件拷贝到内核空间。
上图流程发生:
- 3次数据拷贝
- 4次空间切换
sendfile()
sendfile函数的作用是直接在两个文件描述符之间传递数据。由于整个操作完全在内核中(直接从内核缓冲区拷贝到socket缓冲区),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝。
上图流程发生:
- 3次数据拷贝
- 2次空间切换
所以为什么 Kafka 比 RocketMQ 更快?
这是因为 Kafka 在消息发送给 consumer 时使用的是 sendfile() , 而 RocketMQ 使用的是 mmap()
那为什么 RocketMQ 不用 sendfile()?
c
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
参数:
out_fd : 待写入内容的文件描述符
in_fd : 待读出内容的文件描述符
offset : 文件的偏移量
count : 需要传输的字节数
返回值:
成功:返回传输的字节数
失败:返回-1并设置errno
c
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
addr : 内存的起始地址,如果设置为空则系统会自动分配
length : 指定内存段的长度
prot : 内存段的访问权限,通过按位与或可以取以下几种值
flag : 选项
fd : 被映射文件对应的文件描述符
offset : 文件的偏移量
返回值:
成功:成功时返回指向内存区域的指针
失败:返回MAP_FAILED并设置errno
RocketMQ 使用 mmap() 来实现零拷贝, 成功时返回指向内存区域的指针(也就是消息内容)
Kafka 使用 sendfile() 来实现零拷贝, 成功时返回传输的字节数
而因为 RocketMQ 可以获取到消息的内容, 就可以实现一些 Kafka 没有的功能, 比如说 (消息过滤(tag), 事物(半消息), 死信队列, 延时队列)
而Kafka 因为采用了 sendfile() 实现零拷贝, 应用层无法获取消息的内容, 也就无法实现这些功能。(不过可以通过别的方式实现,比如说在业务中处理)
Kafka 消息压缩
我们先来看看官网: https://cwiki.apache.org/confluence/display/KAFKA/Compression
也就是说,Kafka 的消息压缩是指将消息本身采用特定的压缩算法进行压缩并存储,待消费时再解压。
四种压缩类型
目前 Kafka 共支持四种主要的压缩类型:Gzip、Snappy、Lz4 和 Zstd。
https://github.com/facebook/zstd
那么问题来了,什么是压缩比?
压缩比(Compression Ratio) 是衡量数据压缩效率的指标,表示压缩前数据大小与压缩后数据大小值。
压缩比=原始数据大小 / 压缩后数据大小
- 压缩比 > 1:数据被压缩(例如 2:1 表示压缩后体积减半)。
- 压缩比 = 1:数据未被压缩
- 压缩比越高:压缩率越高,节省的存储/带宽越多,但通常需要更多计算资源(CPU)。
消息压缩的流程
- Producer 端压缩
- Producer 发送消息时,可以配置压缩算法(如 compression.type=lz4),对 批量消息(Batch) 进行整体压缩。
- 批量压缩比单条压缩更高效(因为数据冗余更多,压缩率更高)。
- Broker 端存储
- Broker 直接存储压缩后的数据,不会重复压缩或解压,减少 CPU 开销。
- Consumer 拉取数据时,Broker 直接返回压缩后的数据块。
- Consumer 端解压
- Consumer 读取数据后,根据压缩算法自动解压,获取原始消息。
压缩就是用时间换空间,其基本理念是基于重复,将重复的片段编码为字典,字典的 key 为重复片段,value 为更短的代码,比如序列号,然后将原始内容中的片段用代码表示,达到缩短内容的效果,压缩后的内容则由字典和代码序列两部分组成。解压时根据字典和代码序列可无损地还原为原始内容。
- Kafka 消息压缩通过 Producer 批量压缩 + Broker 零拷贝 + Consumer 快速解压 实现高效数据传输。
- LZ4 是默认推荐,适合大多数场景;Zstd/Snappy 适合高压缩比需求。
- 压缩能显著减少网络带宽和磁盘占用,但会轻微增加 CPU 开销。
Kafka 如何保证消息不丢失?

一条消息从发送到消费,分为三个流程:
- 消息从 producer 处发送至 broker
- Broker 接收到消息后持久化
- 消息被 consumer 拉取并消费
消息丢失可能会发生在以上的三个流程中, 那么 Kafka 是怎么去保证消息不会丢失呢?
Producer
我们先看看消息发送的流程:
- Producer 端是直接与 Broker 中的 Leader Partition 交互的,所以在 Producer 端初始化中就需要通过 Partitioner 分区器从 Kafka 集群中获取到相关 Topic 对应的 Leader Partition 的元数据 。
- 待获取到 Leader Partition 的元数据后直接将消息发送过去。
- Kafka Broker 对应的 Leader Partition 收到消息会先写入 Page Cache,定时刷盘进行持久化(顺序写入磁盘)。
- Follower Partition 拉取 Leader Partition 的消息并保持同 Leader Partition 数据一致,待消息拉取完毕后需要给 Leader Partition 回复 ACK 确认消息。
- 待 Kafka Leader 与 Follower Partition 同步完数据并收到所有 ISR 中的 Replica 副本的 ACK 后,Leader Partition 会给 Producer 回复 ACK 确认消息。
什么是 ISR ?
ISR(In-Sync Replicas,同步副本集)是 Kafka 中用于实现数据可靠性的重要机制。ISR 是与 Leader 副本保持同步的所有副本的集合。每个分区都有一个 ISR 集合,其中包含了与 Leader 副本保持数据同步的所有副本。
ISR 的主要作用是确保数据同步和高可用性。当消息被 Leader 副本确认接收后,ISR 中的副本也会逐渐复制消息,确保所有副本之间的数据一致性。此外,当 Leader 副本发生故障时,ISR 中的副本可以立即接管分区的服务,而无需等待数据复制完成。
Kafka 配置 acks 应答方式一共有三个参数, 分别是:
- acks = 0 只管发送消息,不管消息是否发送成功,只要发出去就算成功。(性能最好, 但容易丢失数据)
- acks = 1 代表 Broker 中 Leader 接收到消息后立即响应,不管有没有同步到 Follwer。(性能还行,只有 Leader 挂了且没有及时同步给Follwer 才会丢失数据)
- acks = -1 代表 Broker 中 所有的 ISR 成员接收到消息后才会响应(性能一般,但不会丢失数据)。
Broker
Kafka Broker 集群接收到数据后会将数据进行持久化存储到磁盘,为了提高吞吐量和性能,采用的是「异步批量刷盘的策略」,也就是说按照一定的消息量和间隔时间进行刷盘。首先会将数据存储到 「PageCache」 中,至于什么时候将 Cache 中的数据刷盘是由「操作系统」根据自己的策略决定或者调用 fsync 命令进行强制刷盘,如果此时 Broker 宕机 Crash 掉,且选举了一个落后 Leader Partition 很多的 Follower Partition 成为新的 Leader Partition,那么落后的消息数据就会丢失。
所以这里需要保证消息不丢失就需要通过 fsync() 强制刷盘, kafka 可以通过配置 flush.messsages 和 flush.ms 来控制刷盘的消息数量和间隔时间。
Consumer
消费者通过在消费后提交 commit 给Broker 来确定本次消费消息是否成功,即提交 Offset 消费位移进度记录。
这里如果出现了消息丢失的情况,无非就两种情况:
- 消费者使用了 auto commit (自动提交)
- 消费者端在接收到消息后先提交 Offset,后处理消息, 如果此时处理消息的时候异常宕机,由于 Offset 已经提交了, 待 Consumer 重启后,会从之前已提交的 Offset 下一个位置重新开始消费, 之前未处理完成的消息不会被再次处理,对于该 Consumer 来说消息就丢失了。
那这里如何保证消费完成的时候能正常提交offset不会丢失呢?
在消息处理完成之后, 我们是在消费完消息后去手动提交的。
这里有一句话:
业务需求可靠消费的话一般都要用外部系统记录消费状态(例如用PG表去重)
这句话是啥意思?别急,先说说 Kafka 如何保证不重复消费
Kafka 如何保证消息不重复消费
重复消费的可能原因有2种:
- 生产者重复发送:比如说我们的业务在发送消息的时候,收到了一个超时响应,这个时候我们很难确定这个消息是否真的发送出去了,那么我们就会考虑重试,重试就可能导致同一个消息发送了多次。
- 消费者重复消费:比如说我们在处理消息完毕之后,准备提交了。这个时候突然宕机了,没有提交。等恢复过来,我们会再次消费同一个消息。
避免重复消费的原则就是:一定要把消费逻辑设计成幂等的。我们的微服务也要尽可能设计成幂等的,这样上游就可以利用重试来提高可用性了。
据我浏览一些资料发现,目前并没有一个消息队列能真正做到有且只有一次(也就是生产者肯定能发送成功一次,消费者肯定能消费成功一次),都需要依赖外部业务去依靠别的逻辑去实现。
这里我们再来看一下之前这句话:
业务需求可靠消费的话一般都要用外部系统记录消费状态(例如用PG表去重
这句话的意思是说, 如果业务需要一定保证消息不会丢失并且不去重复消费的话,需要用数据库(或者别的缓存) 来保证幂等性,例如在发送消息时,发送成功可以在数据库创建一个kafka的消息表, 记录一个唯一的业务类型的ID,然后在这里消费的时候,去数据库中查询这个业务类型的ID,如果发现消息没有被消费,就去处理业务逻辑然后再去这张表的消费状态上改为已消费。如果发现消息已经被消费了(比如说状态已经变成了已消费),直接跳过即可,这样就可以保证消息不会被重复消费。
那么问题来了,如果在数据库中消费者大量处理失败,比如说在数据库中有这条数据但是他却是没有消费成功的,那我的业务中如果要求消息不丢失,也就是必须每一条都要被消费,怎么做?
这里我的解决方案是,可以写一个 cronjob 任务去定时扫描这张 Kafka 的记录消息表,扫描出所有已经发出消息但是没有被消费的消息,然后再去重复去提交给Kafka去消费,来保证消费不会丢失也不会去重复投递。
核心思想
- 遇事不决加一层!
- 没有哪个更厉害,只有哪个更适合。