副本机制与 ISR 设计:为什么 Kafka 这么快又这么可靠

三年前我接手过一个"慢到不能忍"的消息系统。

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 默认不这样做,原因有两个:

  1. 消息队列的读跟数据库的读不一样。 数据库的读是随机读,分散在不同的数据上。消息队列的读是顺序读,Consumer 从特定 offset 开始顺序拉取。如果从 Follower 读,Follower 的数据可能还没追上 Leader,Consumer 会读到不完整的数据。

  2. 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)改为纯时间判定

scala 复制代码
// 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 至少要有多少个副本,写入才能正常进行。

properties 复制代码
# 副本数 3 的典型配置
min.insync.replicas=2
acks=all

当 ISR 中的副本数低于 min.insync.replicas 时,生产者的写入请求会被拒绝(NotEnoughReplicasException)。

这个配置的意义很明确:如果可用的副本太少,宁可拒绝写入,也不要让数据在极端脆弱的状态下被写进去。

一个真实的生产事故

说一个真实的案例。

某团队配置了 replication.factor=3acks=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 节点)负责这个决策。

具体流程是:

  1. Controller 检测到 Leader 心跳超时
  2. Controller 暂停对这个分区的读写
  3. Controller 从 ISR 列表中选出一个 Follower 作为新 Leader
  4. Controller 通知所有 Broker 更新元数据
  5. 新 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 的写入流程:

传统队列写入:

  1. 网络接收 → 写入内存队列 → 持久化到磁盘 → ACK
  2. 数据还要经过"索引构建"、"事务管理"、"消费者匹配"等额外步骤
  3. 路径长,中间环节多

Kafka 写入:

  1. 网络接收 → 顺序追加到日志文件尾部 → 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 配置

properties 复制代码
# 副本数,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 配置

properties 复制代码
acks=all
retries=3
max.in.flight.requests.per.connection=1
enable.idempotence=true
compression.type=snappy

Consumer 配置

properties 复制代码
enable.auto.commit=false
auto.offset.reset=earliest
max.poll.records=500

enable.idempotence=true 是 Kafka 0.11+ 引入的幂等生产者。它保证了即使生产者重试,消息也不会重复。建议所有生产环境都开启。

写在最后

Kafka 能成为消息队列领域的事实标准,不是因为它功能多。恰恰相反------它因为功能少而强大。

Kafka 的设计者删掉了很多"看起来有必要"的功能(比如复杂的路由、事务消息、优先级队列),把精力集中在把"存-转"这个核心路径做到极致。

这种"做减法"的能力,比"做加法"难得多。

很多中间件到最后都会变得臃肿,因为每个用户都要求加功能。Kafka 能做到现在这个粒度还很克制,不容易。

对于工程师来说,理解 Kafka 的价值不在于学会用它的 API,而在于理解它为什么选择做这些,又为什么选择不做那些。

这才是最好的架构课。

相关推荐
夕除1 小时前
spring boot 9
java·mysql·spring
执明wa1 小时前
从 T 到协变逆变
java·开发语言·数据结构
XiYang-DING1 小时前
【Java EE】 TCP—异常情况处理
java·tcp/ip·java-ee
lianghyan2 小时前
List.stream().min
java·开发语言
爱笑的源码基地2 小时前
小微企业ERP源码,采用SpringBoot+Vue+ElementUI+UniAPP技术架构,支持二次开发及商用授权
java·源码·二次开发·erp·源代码·mrp生产计划
happymaker06262 小时前
Spring学习日记——DAY03(yml文件)
java·spring boot·spring
凯瑟琳.奥古斯特2 小时前
操作系统核心结构解析
java·开发语言·c++·python·职场和发展
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_2:(连接样式表与选择器的实战艺术)
java·前端·css·ui·html·媒体
敖正炀2 小时前
AQS-钩子方法
java