Kafka 架构讲解:从提交日志到分区副本机制
Kafka 经常被叫作消息队列,但更准确地说,它是一个分布式、分区化、多副本的提交日志系统。这里的日志不是运行日志,而是一种只能追加写入的顺序记录文件,每条消息都会被追加到某个分区日志末尾,并获得一个递增的 offset
比如订单系统中不断发生事件,Kafka 不是去修改某一条旧数据,而是把事件一条条追加保存
text
order-events-0.log
offset=0 订单1001创建成功
offset=1 订单1001支付成功
offset=2 订单1001扣减库存
offset=3 订单1001发送短信
这个模型带来的好处是很明显的:写入是顺序追加,吞吐量高;消费者只需要记录自己读到哪个 offset,消息就可以被重复读取和回放;不同消费者组可以独立消费同一份日志,互不影响
一、Kafka 整体架构是什么样的
1. Kafka 集群由 Broker 组成
一个 Kafka 集群由多台 Broker 组成,Broker 可以理解为 Kafka 的服务器节点。Producer 负责向 Kafka 写消息,Consumer 负责从 Kafka 读消息,而 Topic 用来对消息做逻辑分类,比如 order-events、user-logs、chat-message
下面这张图展示了 Kafka 集群的整体结构,左边是生产者,中间是多个 Broker,右边是消费者组,不同 Topic 的不同 Partition 分布在不同 Broker 上,每个 Partition 又有 Leader 和 Follower 副本

从这张图可以看到,Kafka 的核心不是一个简单队列,而是围绕 Topic -> Partition -> Replica 组织起来的分布式日志系统。Topic 是逻辑概念,Partition 是真实存储和并行读写的单位,Replica 是高可用的基础
2. Topic、Partition、Replica 的关系
Topic 可以理解为消息的逻辑分类,Partition 是 Topic 的物理分片。一个 Topic 可以有多个 Partition,每个 Partition 内部是一份有序日志文件,不同 Partition 之间的 offset 是相互独立的
例如 order_topic 有 3 个分区,那么它的结构可以理解为
text
order_topic
├── partition-0
│ ├── offset 0
│ ├── offset 1
│ └── offset 2
├── partition-1
│ ├── offset 0
│ ├── offset 1
│ └── offset 2
└── partition-2
├── offset 0
├── offset 1
└── offset 2
这里要注意,offset 只在单个 Partition 内有意义。partition-0 的 offset 10 和 partition-1 的 offset 10 不是同一条消息,它们只是各自分区日志中的位置编号
3. 为什么要有 Partition
Partition 的第一个作用是提高并发能力。如果一个 Topic 只有一个 Partition,那么这个 Topic 的读写主要集中在一个分区上,扩展能力有限。把 Topic 拆成多个 Partition 后,不同 Partition 可以分布到不同 Broker 上,多个 Broker 就能共同承担读写压力
Partition 的第二个作用是提供消费并行度。一个消费者组内,一个 Partition 同一时刻只能被一个 Consumer 消费,所以消费者组的最大并行度通常受 Partition 数量限制。如果 Topic 只有 3 个 Partition,即使消费者组里有 5 个 Consumer,也最多只有 3 个 Consumer 真正干活,剩下的 Consumer 会空闲
可以简单记成一句话:Partition 是 Kafka 并行读写和水平扩展的基本单位
二、Kafka 为什么是提交日志系统
1. 写入方式是追加日志
Kafka 写消息时,不是把消息随机插入到某个位置,也不是消费完就立即删除,而是将消息追加到某个 Partition 的日志末尾。每条消息被追加成功后,就会得到一个 offset
这和传统队列最大的区别是:传统队列更强调消息被取走后就消失,而 Kafka 更强调消息被持久化保存,消费者只是移动自己的消费进度
2. 消费进度由消费者维护
假设 Kafka 中已经有下面这些消息
text
offset 0 下单事件
offset 1 支付事件
offset 2 扣库存事件
offset 3 发短信事件
库存服务可能消费到 offset 2,短信服务可能消费到 offset 3,风控服务可能只消费到 offset 1。Kafka 不会因为短信服务读过 offset 3,就把这条消息从日志中删除,因为其他消费者组可能还需要读取它
这就是 Kafka 支持消息回放的关键。只要消息还在保留时间内,消费者就可以把 offset 调回去重新消费历史消息
3. 用订单系统理解提交日志
用户下单以后,订单服务只需要向 Kafka 写入一条 order_created 事件
text
topic = order-events
partition = 0
offset = 1024
message = {
"order_id": "10001",
"user_id": "u001",
"event": "order_created"
}
然后库存服务消费这条消息扣库存,短信服务消费这条消息发短信,积分服务消费这条消息加积分。订单服务不需要直接调用这些下游服务,也不需要关心下游服务有多少个,这就是 Kafka 的解耦能力
三、Producer 如何把消息写入 Kafka
1. Producer 只写 Leader
Kafka 中每个 Partition 可以有多个副本,但只有 Leader 副本负责处理读写请求,Follower 副本只负责从 Leader 同步数据。Producer 写入消息时,永远找目标 Partition 的 Leader,不会直接写 Follower
下面这张图展示了 Producer 写入 Leader,Follower 再从 Leader 同步的过程

写入流程可以概括为 5 步
- Producer 先根据 Topic 和 Partition 信息找到目标 Partition 的 Leader
- Producer 将消息发送给 Leader
- Leader 将消息追加到本地日志文件
- Follower 主动从 Leader 拉取新消息,并写入自己的本地日志
- Leader 根据 ack 配置决定什么时候向 Producer 返回写入成功
2. 消息会进入哪个 Partition
Producer 发送消息时,会封装成 ProducerRecord,常见字段包括 topic、partition、key、value、timestamp、headers
分区规则一般有三种情况
- 指定了
partition,就直接写入指定分区 - 没指定
partition,但指定了key,就通常根据 key 的 hash 结果选择分区 - 既没指定
partition,也没指定key,就通过默认策略在可用分区之间分配
如果业务要求同一个订单的消息有序,就可以把 order_id 作为 key,这样同一个订单的消息会进入同一个 Partition,而单个 Partition 内部天然有序
例如
text
key = order_1001 -> partition-0
key = order_1001 -> partition-0
key = order_2002 -> partition-2
这说明 Kafka 的顺序性不是全局有序,而是分区内有序。如果想保证某类业务事件有序,就要让这类事件进入同一个 Partition
3. 分区带来的吞吐提升
下面这张图展示了一个 Topic 被拆成多个 Partition 后,消息分别追加到不同分区的效果

如果一个 Topic 有 3 个 Partition,那么 Producer 可以把消息分散写入这 3 个 Partition,Broker 也可以把这些 Partition 放在不同机器上。这样 Kafka 的吞吐量不是被单台机器锁死,而是可以随着 Broker 和 Partition 的增加而提升
四、Replica 副本机制如何保证高可用
1. 每个 Partition 有多个副本
Kafka 的副本机制是针对 Partition 的。一个 Partition 可以有多个 Replica,其中一个是 Leader,其余是 Follower。Leader 对外处理读写请求,Follower 从 Leader 同步数据,Leader 挂掉后,Kafka 会从合适的 Follower 中选出新的 Leader
下面这张图展示了 3 台 Broker 上的副本分布情况,同一个分区的不同副本会分散在不同 Broker 上,这样某台 Broker 宕机时,其他 Broker 上仍然有数据副本

这里有一个很重要的点:Kafka 的 Follower 默认不对外提供读服务,它存在的主要目的不是分担读压力,而是做数据冗余和故障切换。这一点和某些数据库的读写分离模型不一样
2. Follower 如何从 Leader 同步数据
Follower 不是等 Leader 主动推数据,而是主动向 Leader 发送 FetchRequest 拉取数据。Follower 会记住自己已经同步到哪个 offset,然后向 Leader 请求后续日志
text
Follower 当前同步到 offset = 100
Follower 向 Leader 请求 offset = 101 之后的数据
Leader 返回新消息
Follower 写入本地日志
Follower 更新自己的同步进度
如果 Leader 暂时没有新数据,Fetch 请求通常不会马上空返回,而是会在 Leader 端等待一段时间。等待期间如果有新消息写入,Leader 就可以立即返回;如果一直没有新数据,超过等待时间后才返回空结果
这个过程很像长轮询。Follower 内部可以理解为不断发起下一次 FetchRequest,但不是 CPU 空转式死循环,因为请求可能会在 Leader 端挂起等待
3. ISR 是什么
ISR 的全称是 In-Sync Replicas,表示当前和 Leader 保持同步的副本集合。只有跟得上 Leader 的 Follower 才会留在 ISR 中,如果某个 Follower 长时间没有同步数据,就会被踢出 ISR
Leader 故障后,Kafka 会优先从 ISR 中选择新的 Leader。这样做的目的很直接:只有 ISR 中的副本才更可能拥有完整数据,从它们里面选 Leader,才能尽量避免消息丢失
下面这张图展示了 Leader、Follower、ISR 和 ACK 之间的关系

五、ACK 机制决定写入可靠性
1. acks=0
acks=0 表示 Producer 发出消息后不等待 Broker 响应。它的延迟最低,吞吐最高,但可靠性最差,因为消息可能还没真正写入 Kafka,Producer 就认为发送完成了
适合日志采样、埋点等允许少量丢失的场景
2. acks=1
acks=1 表示 Leader 写入本地日志后,就返回成功给 Producer,不等待 Follower 同步。这是一个折中方案,性能较好,但如果 Leader 刚写完就宕机,而 Follower 还没同步到这条消息,就可能丢数据
适合普通业务日志、对可靠性有要求但不是极端严格的场景
3. acks=all
acks=all 或 acks=-1 表示 Leader 不仅要自己写入成功,还要等待 ISR 中的副本同步成功,才返回成功给 Producer。注意这里等的是 ISR 中的副本,不是所有 Follower
如果某个 Follower 已经落后太多并被踢出 ISR,那么 acks=all 不会一直等它。这个设计避免了一个慢副本拖垮整个 Partition 的写入
可靠写入通常还会配合 min.insync.replicas 使用。例如副本数为 3,min.insync.replicas=2,再配合 acks=all,就表示至少要有 2 个同步副本参与写入成功判断,可靠性更高
六、Consumer 如何消费消息
1. Consumer 使用 Pull 模式
Kafka 消费者不是等待 Broker 主动推消息,而是主动向 Broker 拉取消息。Pull 模式的好处是消费者可以按照自己的能力消费,处理能力强就多拉一点,处理能力弱就少拉一点,不容易被 Broker 推爆
Pull 模式的问题是,如果 Broker 没有新数据,消费者可能不断拉取空结果。Kafka 通过类似长轮询的方式缓解这个问题:消费者请求数据时可以携带等待时间,如果当前没有数据,就等待一段时间再返回
2. Consumer Group 解决并行消费问题
Consumer Group 是 Kafka 消费模型的核心。一个消费者组可以有多个 Consumer,同一个组内,一个 Partition 同一时刻只能分配给一个 Consumer;不同消费者组之间互不影响,可以各自独立消费同一个 Topic
下面这张图展示了 Producer、Broker、Topic Partition 和 Consumer Group 之间的关系

如果所有 Consumer 都属于同一个 Group,那么 Kafka 表现得像点对点队列,一条消息只会被这个组内的一个 Consumer 处理。如果不同业务使用不同 Group,那么 Kafka 表现得像发布订阅系统,同一条消息可以被多个 Group 分别处理
3. 分区分配和再均衡
当消费者组中 Consumer 数量变化,或者 Topic 的 Partition 数量变化时,Kafka 会重新分配 Partition,这个过程叫 Rebalance
常见分配策略包括 Range、RoundRobin、Sticky、CooperativeSticky。Range 是按 Topic 维度分配,某些情况下容易让前面的 Consumer 多拿分区;RoundRobin 是把所有分区轮询分给消费者,前提是消费者订阅的 Topic 最好一致;Sticky 会尽量保证分配均衡,同时尽量保持上一次分配结果不变;CooperativeSticky 在 Sticky 的基础上支持增量再均衡,减少停止消费的范围
Sticky 策略的重点不是第一次一定按 RoundRobin 分配,而是同时追求两个目标:尽量均衡,尽量少移动。第一次分配时没有历史分配可保持,所以它会先做均衡分配;当 Consumer 离开或加入时,它才体现出黏性,尽量让原来还在组内的 Consumer 保留原有分区
七、用一个下单场景串起 Kafka 架构
1. 没有 Kafka 时的问题
假设用户下单后,订单服务需要同步调用库存、短信、物流、积分服务
text
订单服务
├── 调用库存服务扣库存
├── 调用短信服务发短信
├── 调用物流服务生成物流单
└── 调用积分服务增加积分
这样会有三个问题。第一,订单服务和下游服务耦合很重,下游新增积分服务时订单服务要改代码;第二,短信或物流很慢时,用户下单接口也会变慢;第三,秒杀场景下瞬时流量会直接打到库存服务和数据库上
2. 使用 Kafka 后的流程
订单服务只做核心流程,然后写入 Kafka
text
1. 创建订单
2. 写入订单表
3. 发送 order_created 消息到 Kafka
4. 返回用户下单成功
下游服务各自消费 Kafka
text
库存服务:消费 order_created,扣减库存
短信服务:消费 order_created,发送短信
物流服务:消费 order_created,生成物流单
积分服务:消费 order_created,增加积分
这个过程中,订单服务不需要直接依赖下游服务,这是解耦;用户不用等短信、物流、积分全部完成,这是异步;秒杀瞬时流量先进入 Kafka,下游按照自己的能力慢慢消费,这是削峰
3. 架构层面的完整链路
完整链路可以这样理解
text
Producer
-> 根据 key 或分区策略选择 Partition
-> 找到该 Partition 的 Leader
-> Leader 追加写入本地日志
-> Follower 通过 FetchRequest 从 Leader 拉取日志
-> ISR 中副本同步到位后,根据 acks 返回结果
-> Consumer Group 中的 Consumer 拉取分区数据
-> Consumer 提交 offset,记录消费进度
这条链路把 Kafka 的核心设计都串起来了:Producer 面向 Topic 写消息,Partition 提供并行能力,Leader/Follower 提供高可用,ISR 和 ACK 影响可靠性,Consumer Group 提供并行消费,offset 支持恢复和回放