三年前我接手过一个"慢到不能忍"的消息系统。
Kafka 集群,日处理 500 亿条消息,QPS 峰值 120 万。但是隔三差五出现"数据延迟积压",有时候一条消息从生产到消费,竟然要等几十秒。
查了一周,发现跟 Kafka 本身关系不大。问题是使用姿势不对——不懂 ISR 机制就敢上生产的人太多了。
今天这篇把 Kafka 最核心的两个设计——副本机制和 ISR——彻底讲明白。不是"什么是副本"那种入门,而是深入到它能做到"快又可靠"的根本原因。
一、副本为什么是"副本"而不是"备份"
很多人把 Kafka 的副本理解成"数据备份"——怕数据丢了,多存几份。
这个理解不能说错,但很片面。Kafka 的副本设计,本质是为了"可用性",而不是"持久性"。
持久性靠的是刷盘策略和日志结构。副本解决的是"当某个节点挂了,系统还能正常服务"的问题。
副本的两张脸
Kafka 的每个分区有多个副本(Replica),但它们的地位不对等。
- Leader Replica——读写都走它。生产者和消费者默认只跟 Leader 交互。
- Follower Replica——只从 Leader 拉数据,默认不服务外部请求。存在的意义是"Leader 挂了就顶上"。
这就是 Kafka 副本的核心设计哲学:默认读写不分离,但故障转移有备选。
注:Kafka 2.4 引入了 KIP-392,允许消费者从 Follower 读取数据(通过
replica.selector.class配置自定义副本选择器),但这个功能是可选的、默认关闭。大多数场景下,从 Follower 读会读到滞后数据,一致性风险大于性能收益。
为什么默认不做读写分离?很多系统(比如 MySQL)是读写分离的——Leader 负责写,Follower 负责读。Kafka 默认不这样做,原因有两个:
-
消息队列的读跟数据库的读不一样。 数据库的读是随机读,分散在不同的数据上。消息队列的读是顺序读,Consumer 从特定 offset 开始顺序拉取。如果从 Follower 读,Follower 的数据可能还没追上 Leader,Consumer 会读到不完整的数据。
-
Kafka 追求的是顺序一致性。 所有读写都走 Leader,保证了严格的顺序。从 Follower 读的话,顺序一致性就很难保证了。
这个设计不是没有代价的。瓶颈集中在 Leader 上——所有读写压力都在一个节点。但 Kafka 用"分区"解决了这个问题:100 个分区就有 100 个 Leader,分散在不同的 Broker 上。所以从整个集群看,负载是均匀分布的。
二、ISR:Kafka 高可靠的真正秘密
说完了副本,聊聊 ISR。
ISR(In-Sync Replicas)是 Kafka 副本机制里最精妙的设计。它解决了一个非常实际的问题:"等所有副本都同步完再确认写入,太慢了。不等的话,又可能丢数据。"
ISR 的答案是:选一个"足够的副本数"做为确认标准。
ISR 到底是什么
ISR 是一个动态列表,里面是所有"跟得上 Leader 节奏"的副本。
Kafka 的 ISR 维护逻辑在 0.10 版本经历了一次重要变化:从基于 offset 差值(replica.lag.max)改为纯时间判定。
// Kafka 3.x ISR 维护逻辑(简化自 ReplicaManager.scala)
// 核心判断:Follower 的 lastCaughtUpTimeMs 是否在超时窗口内
val now = time.milliseconds()
val currentISR = replicas.filter { replica =>
replica.log.isDefined &&
(now - replica.lastCaughtUpTimeMs) < replicaLagTimeMaxMs
}
每个 Follower 副本有一个 lastCaughtUpTimeMs 字段,记录它最后一次跟 Leader 持平的时间。只要这个时间距离现在不超过 replica.lag.time.max.ms(默认 30 秒,生产环境常配置为 10 秒),它就在 ISR 里。超过这个时间没跟上,就被踢出 ISR。
ISR + ACKS 的配合
Kafka 的生产者有一个 acks 参数,控制写操作的确认条件:
acks=0:发送完就认为成功了。最快的模式,但可能丢数据。acks=1:Leader 写完就认为成功了。权衡模式,Leader 挂了会丢数据。acks=all:ISR 里所有副本都写完才算成功。最安全的模式。
重点来了:acks=all 不是"所有副本都写完",而是"ISR 里所有副本都写完"。
假设你的副本数是 3,ISR 里只有 2 个副本(Leader + 一个 Follower),那 acks=all 只等这 2 个确认就返回了。
这个设计的高明之处在于:ISR 保证的是"当前活跃的副本都写完了",而不是"所有存在的副本都写完了"。
如果某个 Follower 已经掉队了(不在 ISR 里),Kafka 不等它。因为等一个掉队的副本,会拖慢整个系统的写入速度,而且这个掉队的副本可能已经接近挂了——等它等于白等。
ISR 机制的本质,是在"可靠性"和"可用性"之间做动态平衡。掉队的副本不配被等。
min.insync.replicas:兜底的门槛
acks=all 配合 min.insync.replicas 才是完整的安全方案。
min.insync.replicas 是用来兜底的。它规定了 ISR 至少要有多少个副本,写入才能正常进行。
# 副本数 3 的典型配置
min.insync.replicas=2
acks=all
当 ISR 中的副本数低于 min.insync.replicas 时,生产者的写入请求会被拒绝(NotEnoughReplicasException)。
这个配置的意义很明确:如果可用的副本太少,宁可拒绝写入,也不要让数据在极端脆弱的状态下被写进去。
一个真实的生产事故
说一个真实的案例。
某团队配置了 replication.factor=3、acks=all,没有配置 min.insync.replicas。一台 Broker 挂了(2 个副本在线),系统正常运行。
然后第二台 Broker 发生了 Full GC,那个 Broker 上的 Follower 副本长时间无法从 Leader 拉取数据,被踢出了 ISR。
ISR 降到 1(只剩 Leader 自己)。但由于没有配置 min.insync.replicas,写入还在继续。只是此时的数据只有 Leader 一份拷贝——如果有人再重启这台 Broker,数据就会丢。
后来 Full GC 结束后,Follower 重新加入 ISR,但 ISR 为 1 的那段时间里,Leader 和 Follower 之间的数据差距已经很大了。恢复过程中产生了大量的网络传输,又引发了新的 Full GC。
一个 Full GC,引发了一连串的连锁反应。
如果当时配置了 min.insync.replicas=2,在 ISR 降到 1 的时候,写入就会立刻被拒绝。虽然系统不可用了,但数据不会丢。这是一个 trade-off:用短暂的服务不可用,换取数据的完整性。
三、Leader 选举:怎么选出新 Leader
当 Leader 挂了,Kafka 需要从剩下的 Follower 中选一个新的 Leader。
选谁?最简单也最合理的原则:选 ISR 里数据最新的那个。
Kafka 的 Controller(集群的大脑,本质也是一个 Broker 节点)负责这个决策。
具体流程是:
- Controller 检测到 Leader 心跳超时
- Controller 暂停对这个分区的读写
- Controller 从 ISR 列表中选出一个 Follower 作为新 Leader
- Controller 通知所有 Broker 更新元数据
- 新 Leader 开始服务读写请求
整个过程的耗时一般是毫秒级别的(主要取决于 Zookeeper/KRaft 的分布式协调延迟)。
Unclean Leader Election:一个危险的选项
Kafka 有一个配置叫 unclean.leader.election.enable。默认是 false。
如果所有在 ISR 里的副本都挂了(比如 3 台 Broker 全宕机),会怎么样?
答案是:不可用。Kafka 会一直等 ISR 里的副本恢复。
如果把 unclean.leader.election.enable 设为 true,Kafka 会选择 ISR 之外的副本作为 Leader。但是这些副本的数据可能落后很多——选了它,会丢数据。
这是一个非常危险的操作。开启了它,意味着在极端场景下,你选择了"可用"而不是"一致"。
大多数业务场景下,不要开这个选项。宁可不可用,也不要丢数据。
四、Kafka 为什么快:不是"快",而是"流程简单"
很多人喜欢问"Kafka 为什么快",常见的答案有:顺序写、零拷贝、页缓存、批量压缩……
这些都对,但没说到根上。
Kafka 快的根本原因不是某个技术优化,而是它的设计让数据流动的路径非常短。
对比一下传统消息队列(比如 ActiveMQ)和 Kafka 的写入流程:
传统队列写入:
- 网络接收 → 写入内存队列 → 持久化到磁盘 → ACK
- 数据还要经过"索引构建"、"事务管理"、"消费者匹配"等额外步骤
- 路径长,中间环节多
Kafka 写入:
- 网络接收 → 顺序追加到日志文件尾部 → ACK
没了。就这三步。
这就是 Kafka 的哲学:消息队列的核心就是"存-转",不要做多余的事情。
顺序写的威力
传统磁盘随机写 IOPS 很低。但顺序写不一样——顺序写的吞吐量可以达到随机写的几十倍甚至上百倍。
Kafka 的所有写操作都是顺序追加——新消息永远写到日志文件的尾部。这个设计让 Kafka 即使使用普通机械硬盘,也能达到很高的吞吐量。
零拷贝
在消费数据时,Kafka 用 Linux 的 sendfile 系统调用,实现了"数据从磁盘到网卡"的直接传输,中间不用经过用户空间。
传统方式:磁盘 → 内核缓冲区 → 用户缓冲区 → Socket 缓冲区 → 网卡
零拷贝: 磁盘 → 内核缓冲区 → 网卡
少了两次内存拷贝,节省了大量 CPU 资源。
页缓存
Kafka 没有自己实现缓存,而是依赖操作系统的页缓存(Page Cache)。为什么?
因为操作系统的页缓存比任何自己实现的缓存都聪明。
- 它会根据访问频率自动调整哪些数据留在内存
- 它在内存充足时自动缓存热数据
- 它在内存紧张时自动淘汰冷数据
Kafka 只要做一件事:把数据写完就告诉操作系统。"写到哪了?Page Cache 里?还是磁盘上?让 OS 决定吧。"
很多开发者低估了"信任操作系统"的价值。Kafka 的很多性能优势,本质上来自于"不过度管理"——让操作系统做它最擅长的事情。
五、分区数量:不是越多越好
分区是 Kafka 并行度的基础。分区越多,并行度越高。
但分区不是越多越好。原因有三:
1. 每个分区都有元数据开销。
每个分区对应一个日志目录,一个内存索引,一个 ISR 列表。分区数达到几千之后,Controller 的元数据管理压力会明显增大。
2. 每个分区都需要 Leader。
Leader 会占用 CPU 和内存资源。分区太多,每个 Broker 上跑的 Leader 太多,会影响单个分区的性能。
3. 分区重新分配很痛苦。
当你需要增加 Broker 或者重新平衡分区时,分区数越多,迁移时间越长。
行业经验是:一个 Kafka 集群的分区总数建议不超过 1 万个。 单台 Broker 上的分区数建议不超过 2000 个。
六、实际生产配置建议
最后给几条经过验证的生产配置建议。
Broker 配置
# 副本数,3 是生产环境的推荐值
default.replication.factor=3
# 最少同步副本数,配合 acks=all 使用
min.insync.replicas=2
# ISR 超时时间
replica.lag.time.max.ms=10000
# 日志保留策略
log.retention.hours=72
log.segment.bytes=1073741824
Producer 配置
acks=all
retries=3
max.in.flight.requests.per.connection=1
enable.idempotence=true
compression.type=snappy
Consumer 配置
enable.auto.commit=false
auto.offset.reset=earliest
max.poll.records=500
enable.idempotence=true 是 Kafka 0.11+ 引入的幂等生产者。它保证了即使生产者重试,消息也不会重复。建议所有生产环境都开启。
写在最后
Kafka 能成为消息队列领域的事实标准,不是因为它功能多。恰恰相反——它因为功能少而强大。
Kafka 的设计者删掉了很多"看起来有必要"的功能(比如复杂的路由、事务消息、优先级队列),把精力集中在把"存-转"这个核心路径做到极致。
这种"做减法"的能力,比"做加法"难得多。
很多中间件到最后都会变得臃肿,因为每个用户都要求加功能。Kafka 能做到现在这个粒度还很克制,不容易。
对于工程师来说,理解 Kafka 的价值不在于学会用它的 API,而在于理解它为什么选择做这些,又为什么选择不做那些。
这才是最好的架构课。