ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kafka分区与副本机制:从路由规则到生产端参数的全链路解析

Kafka分区与副本机制:从路由规则到生产端参数的全链路解析

Kafka 的消息模型里,生产者和分区的关系,以及副本之间的配合,几乎是所有刚接触 Kafka 的人最先遇到的两道坎。标题这两个词看着像两个独立的知识点,但实际上它们是连在一起的:消息写入哪个分区,决定了消息被哪个副本承载;而副本怎么同步、怎么选举,反过来又决定了生产者该以什么样的确认级别去写。搞懂这条链路,很多线上问题,比如消息乱序、分区倾斜、副本同步滞后,都能在脑子里自动浮现出排查方向。

这篇文章我会从分区路由规则入手,再把副本机制拆开讲透,中间穿插生产端参数调优和常见故障排查。内容尽量口语化,有计算过程、参数解释,也有我自己踩过的坑。适合刚接触 Kafka 的开发者,也适合那些已经用了一段时间、但对底层原理还比较模糊的运维或后端同学。

1. 分区模型与消息路由

1.1 为什么 Kafka 要把数据拆成分区

先回到最本质的问题:Kafka 号称高吞吐、水平扩展,靠的是什么?靠的就是分区。一个主题(Topic)里的消息不是堆在一个大文件里,而是被拆分到多个分区(Partition)中。每个分区都是一个有序的、不可变的消息序列,新消息不断追加到分区末尾,每个消息在分区内都有一个唯一的偏移量(Offset)。

你可以把分区理解成一条水管,生产者把消息塞进水管,消费者从水管另一头按顺序取水。如果只有一条水管,水流再大也有限,但是如果你有几十条水管并行,吞吐量就能线性往上涨。主题是逻辑上的概念,分区才是物理存储和并行处理的最小单位。

这种设计带来的直接收益有三个:

  • 并行度提升:生产者和消费者都可以以分区为单位并行读写,分区数越多,集群整体的吞吐上限就越高。
  • 有序性变可控:Kafka 只保证分区内的消息有序,不保证跨分区有序。把需要有序的消息路由到同一个分区,就等于获得了全局限定范围内的顺序保证。
  • 存储可扩展:每个分区的数据可以分布在集群不同的 Broker 上,数据总量超过单机磁盘容量时,通过增加 Broker 就能继续扩容。

所以在设计主题时,分区数的选择非常关键。分区数太少,吞吐上不去,消费者也跑不满;分区数太多,每个分区的副本和元数据都会带来额外开销,Broker 端的内存和文件句柄压力也会上升。一个常用的粗略估算是:分区数 = 目标吞吐量 / 单分区实测吞吐,再结合消费者实例数量来定,至少要让每个消费者实例都能分配到分区。

1.2 生产者路由消息的完整判断流程

生产者在发送一条消息时,首先要回答一个问题:这条消息到底放进哪个分区?

这里的路由逻辑是固定的、有先后顺序的,每一层判断都作用于不同的场景:

判断条件路由结果使用场景
消息指定了分区号直接写入该分区需要绝对控制分区归属的业务
消息未指定分区,但 key 不为 null对 key 做哈希,映射到某个分区需要保证相同 key 消息进入同一分区的业务,如订单号、用户 ID
消息未指定分区,key 也为 null走轮询或粘性分区策略没有分区要求,只追求均衡和吞吐的常规日志、埋点类数据

第一种最简单,你明确告诉 Kafka 往哪个分区写,Kafka 不会做任何额外计算。这种方式适合业务上对分区有强约束的场景,比如要把某类数据隔离到指定分区单独处理。

第二种是日常开发中使用最多的:指定 key,由 Kafka 计算分区。Kafka 默认的分区器,也就是DefaultPartitioner,在 key 不为 null 时,会计算 key 的哈希值,然后对分区数取模,得到目标分区号。计算逻辑大致是:

partition = murmur2(key.getBytes()) % numPartitions

这里用的是 MurmurHash 算法,不是字符串原生的hashCode。原因在于hashCode的分布质量一般,而且不同语言的字符串编码处理方式不同,跨语言客户端之间可能算出一致的值,但自定义业务代码很难复现和预测。Kafka 统一用murmur2,保证所有语言客户端的行为一致,也保证分布相对均匀。

第三种情况,key 为 null,Kafka 不会每次都严格轮询所有分区。早期版本是纯轮询(Round Robin),每条消息依次写入每个分区。但这样做的效率并不高:每条消息都单独选一个分区,就可能频繁切换生产者在 Broker 端对应的批次缓冲。Kafka 新版本引入了粘性分区(Sticky Partition)策略,简单说就是一段时间内只往一个分区写,等批次攒够或者达到 linger.ms 时间后,再换下一个分区。这样能增大单分区的连续写入量,提升批量效率,也让底层磁盘顺序写的优势发挥得更充分。

有一点要注意:粘性分区虽然有名字里有“粘性”,但它对消息是否有序不做任何承诺。如果你需要严格有序,哪怕 key 为 null,也不能依赖粘性分区,必须显式指定 key 或分区号。

1.3 key 哈希分区的边界问题与扩展风险

key 哈希分区的思路很简洁,但它有一个天生的隐患:当主题分区数发生变化时,原来映射关系会被完全打乱。

举个例子,一个主题原来有 8 个分区,某个 key 的哈希值对 8 取模落在分区 3。后来业务量增长,把分区数扩到 12,这个 key 再去对 12 取模,结果很可能就落到别的分区了。那原本写入分区 3 的旧数据还在分区 3,新数据去了新分区,同一个 key 的消息被拆散在两个分区,跨分区有序性就丢失了,下游消费再按时间顺序处理时,就会看到乱序。

所以分区数一旦确定,轻易不要扩容,尤其是有依赖 key 路由的有序性要求的场景。这是 Kafka 使用里最容易踩、也最难回头的坑之一。

如果已经预见到分区数会增长,有两种相对稳妥的思路:

  • 对 key 先做一层映射,例如把 key 归类到固定数量的逻辑分组,再用这个分组 ID 作为真正的路由 key,这样底层分区扩了,路由逻辑依然稳定。
  • 接收无序风险,只在应用层做序列号校验,发现乱序后用缓存或状态机制修复。

这两种方案都不是免费的,需要根据业务场景权衡。没有万无一失的方案,只有能接受的代价。

2. 副本机制与故障转移

2.1 副本的基本组成与 Leader 和 Follower

分区解决了吞吐问题,但数据是存放在单台 Broker 上的,一旦机器宕机,分区数据就可能丢失。副本机制就是为这个问题设计的。

Kafka 的每个分区都可以配置多个副本,这些副本分别存放在不同的 Broker 上。副本分为两类角色:

  • Leader 副本:负责处理生产者和消费者的读写请求。所有消息写入都先落到 Leader,消费者读取也只找 Leader。
  • Follower 副本:只负责从 Leader 同步数据,不对外提供读写服务。同步到位的 Follower 会在 Leader 宕机时参与新一轮选举。

这里要留意,Follower 不对外提供读服务,这一点和很多其他分布式存储系统不同。Kafka 选择这种设计,核心是为了保证一致性:如果允许从 Follower 读取,那读到的一定是可能落后于 Leader 的数据,就需要引入类似 Redis 的弱一致性语义。Kafka 直接把读取能力收回到 Leader,用一致的读写视图换取更简单的一致性模型。

一个分区的多个副本,会有一个正式的身份编号叫 AR(Assigned Replica,已分配副本)。AR 可以继续细分为两个集合:

  • ISR(In-Sync Replica,同步中的副本):指与 Leader 保持同步的副本集合,这个集合动态变化。
  • OSR(Out-of-Sync Replica,同步滞后副本):指滞后太多被踢出 ISR 的副本。

Broker 端有一个参数replica.lag.time.max.ms,默认是 30 秒。如果 Follower 超过这个时间没有向 Leader 发起同步请求,或者说 Follower 的本地日志落后 Leader 太多,就会被从 ISR 中移除。消息写入时,只要 ISR 中的副本都写入成功,生产者就会收到成功响应。

2.2 ISR 机制与副本同步的物理过程

深入看一下 ISR 是怎么运作的。Follower 副本去 Leader 同步数据时,并不是 Leader 主动推给 Follower,而是 Follower 主动向 Leader 发起拉取请求。这个模型和消费者拉消息是一样的。

每个副本都维护着两个重要的偏移量:

  • LEO(Log End Offset):日志末端偏移量,表示该副本当前写入到的最新位置,下一条消息要写入的位置就是 LEO。
  • HW(High Watermark):高水位,表示已经被“提交”的消息位置,消费者只能读取 HW 之前的消息。

Leader 的 HW 是怎么确定的?取 ISR 中所有副本的最小 LEO,即为当前分区的 HW。举例说明:一个分区有 Leader 和两个 Follower,某个时刻 Leader LEO 是 100,Follower1 LEO 是 95,Follower2 LEO 是 90,那么 HW = min(100, 95, 90) = 90。也就是说,虽然 Leader 上已经有 100 条消息,但只有前 90 条被认定为“已提交”,消费者最多只能读到偏移量 90 的位置,后面的 10 条属于未提交状态,任何崩溃都可能导致这 10 条丢失。

这个设计的精妙之处在于:如果 Leader 崩溃,新选出的 Leader 一定是 ISR 中的某个副本。新 Leader 的 LEO 可能小于旧 Leader 的 LEO,但旧 Leader 上那些超过 HW 的消息本身就是未提交的,允许丢失也不会违反一致性承诺。消费者只会看到从新 Leader 继续读取,而不会读到旧 Leader 上独有的、未被其他副本确认的数据。

HW 的更新不是瞬时完成的。Leader 收到 Follower 的拉取请求时,会返回给 Follower 一组数据,同时告诉它当前 HW。Follower 刷盘完成后,会更新自己的 HW。这个机制保证了消费者永远只能读到已经被 ISR 多数副本确认的数据。

2.3 Leader 选举与不可用分区恢复

当一个 Broker 宕机,它上面承载的 Leader 分区需要快速转移到其他 Broker。Kafka 不会自动从 AR 里随便挑一个副本当 Leader,而是只从 ISR 中选举。原因很简单:ISR 中的副本包含的数据和旧 Leader 是一致的,选出来的新 Leader 不会丢失已提交数据。

具体流程大致是:Controller 感知到 Broker 下线后,针对该 Broker 上的每个 Leader 分区,从分区 ISR 列表中选一个副本作为新 Leader。如果 ISR 为空,也就是所有副本都挂了或者都滞后到被踢出了 ISR,那么整个分区就暂时不可用,需要人工介入或等待副本恢复。

有一个参数在这里非常关键:unclean.leader.election.enable。如果设为 true,Kafka 允许在 ISR 为空时,从非同步副本中选一个当 Leader。这样做可以让分区快速恢复可用,但代价是可能丢失大量已提交消息,甚至出现消息的分叉和回退。如果设为 false,分区会一直保持不可用,直到原始副本恢复。

真实生产环境里,到底怎么选?我的建议是:优先保证不丢数据,unclean.leader.election.enable保持 false。只有在系统允许丢数据的场景下才考虑开启。日志类、监控类数据丢了还能接受,但订单、支付、库存这类核心链路,任何消息丢失都可能酿成线上事故。

2.4 副本同步与持久化的踩坑点

副本同步机制里有一个经常被忽视的细节:刷盘。Kafka 的副本不是每来一条消息就立刻写磁盘,而是先写入操作系统的页缓存,由操作系统决定什么时候落盘。这本身是 Kafka 高吞吐的关键,但也带来一个问题:如果机器突然断电,页缓存里的数据可能还没落盘就丢了。这就是 Follower 在拉取数据时,即使 Broker 端确认写入成功,也无法保证物理上已经持久化到磁盘的原因。

Broker 端有两个与持久化相关的参数:

  • log.flush.interval.messages:累计多少条消息后强制刷盘,默认非常大,相当于不限制。
  • log.flush.interval.ms:间隔多少毫秒强制刷盘,默认也是不限制。

生产环境一般不建议把刷盘间隔调得太小,因为 Kafka 的高吞吐本身就建立在页缓存的基础之上,每来一批都刷盘会严重降低性能。真实的保障手段应该是依靠副本机制:只要 ISR 中多数副本都保留了数据,单台机器断电丢点页缓存数据,也不会影响整体数据安全。这也就是为什么副本数至少要有 3 个、而且分布在不同的物理机上。

另外,副本同步还受replica.fetch.max.bytes这个参数影响,它控制 Follower 单次从 Leader 拉取的最大字节数。如果单条消息特别大,比如达到几兆甚至十几兆,可能导致 Follower 一直拉取失败,重复重试,最终落后被踢出 ISR。配合生产者端的max.request.size一起看,能快速定位这种问题。

3. 生产端参数与数据一致性的取舍

3.1 acks 级别的本质区别

生产者在发送消息时,最重要的一个参数就是acks,它直接决定了消息写入多少个副本后才算成功。理解了这个参数,你就能理解 Kafka 在性能和一致性之间做了怎样的取舍。

acks有三个可选值:

取值含义数据安全保障性能影响
0不等待任何确认,发出去就算成功最弱,消息可能直接丢失最高
1Leader 写入本地日志后就返回成功中等,Leader 崩溃时可能丢少量消息较高
-1(all)等待 ISR 中所有副本都写入成功后才返回最强,只要 ISR 不全部挂掉就不会丢已确认消息最低

acks=0适合那些丢了完全无所谓的场景,比如某些性能监控指标、实时推荐日志。它的隐患在于:你甚至不知道消息有没有真的到达 Broker。如果网络闪断,消息在客户端就被丢弃了。

acks=1是最常见的默认级别。Leader 收到消息就返回成功,不需要等待 Follower 同步。性能损耗很小,但如果 Leader 写入成功后在 Follower 同步之前崩溃,这条消息就丢了。在大多数非极端场景下,这个配置已经能满足需求。

acks=-1追求的是最强保证。Leader 要确认 ISR 里的所有副本都拉取并写入成功,才会给生产者返回成功。注意,是所有副本,不是多数副本。假如一个分区有 3 个副本,只要有一个 Follower 响应慢了,整体延迟就会明显上升。

3.2 min.insync.replicas 与可用性之间的平衡

既然acks=-1要等所有 ISR 副本都确认,那 ISR 里如果只剩一个副本呢?Leader 自己就是唯一的 ISR 成员,它自己写成功就算所有副本都写成功。这样看来,acks=-1在 ISR 缩到只剩 1 时,实际保障和acks=1是一样的,消息只存在于一台机器,这台机器崩溃数据就没了。

为了堵住这个漏洞,Kafka 提供了min.insync.replicas参数。它规定了一个分区最少要有多少个 ISR 副本,才能接受写入请求。如果当前 ISR 数量低于这个值,生产者写入会直接报NotEnoughReplicasException,消息不会被接受。

通常的建议配置是:acks=-1配合min.insync.replicas=2,这样即使有一个副本挂掉,ISR 里还有两个副本,其中一个是 Leader,另一个是 Follower,消息被至少两台机器保存,安全性大幅提升。从 Kafka 2.0 开始,Broker 端还支持动态调整min.insync.replicas而不需要重启。

这里有个很实际的矛盾:min.insync.replicas=2时,如果集群里只剩一个副本在线,所有生产者写入都会失败,系统可用性直接归零。这也是这种配置的代价——为了保证不丢数据,宁可拒绝写入,不能接受数据在单点上裸奔。从业务角度想清楚这个取舍,比盲目堆参数重要得多。

3.3 重试、幂等与顺序性的连锁反应

生产端还有一组参数影响着消息的可靠性和顺序性:retries、max.in.flight.requests.per.connection和enable.idempotence。

retries控制消息发送失败后的重试次数。网络抖动、Leader 选举期间,Broker 可能短暂返回可重试的错误,比如LeaderNotAvailableException。设置合理的重试次数能解决这些瞬时问题,默认值从 0 改到 3 或更高是比较常见的操作。

max.in.flight.requests.per.connection控制单个连接上最多有多少个请求未收到响应时,还能继续发送新请求。默认值为 5,也就是最多可以同时有 5 个请求在途。如果这个值大于 1,消息发送顺序就可能发生变化。比如第一条消息发送后 Broker 暂时不可用,第二次重试还没成功,而第二条消息已经发送成功了,这时 Kafka 会等第一条重试成功后再继续,但第二条消息已经被写入,最终 Broker 上的顺序可能变成 2、1。

要避免这种情况,有两个办法:

  • 设置max.in.flight.requests.per.connection=1,严格保证单连接只有一个在途请求,这样不会乱序,但也降低了吞吐。
  • 开启幂等生产者,设置enable.idempotence=true。它用序列号解决了两个问题——一是重试时不会重复写入,二是同一分区的消息在有重试的情况下仍然能保证顺序。

从 0.11 版本开始,幂等生产者其实已经变成默认开启的。开启幂等后,Kafka 会自动把max.in.flight.requests.per.connection调成 5,同时保证单分区消息的有序性。这比手动把它改成 1 要科学得多,既保证了顺序,又不牺牲吞吐。

幂等生产者的实现原理其实是在消息里带一个生产者 ID 加自增序列号。Broker 端会为每个生产者维护最近的序列号,如果收到同 ID 且序列号更小的消息,就认为是重复写入,直接丢弃;如果序列号跳号,就说明有消息丢失,会抛出DuplicateSequenceException。

需要特别说明的是:幂等只保证单分区内的严格顺序和去重。如果你想让一批消息跨多个分区都有序,光靠幂等是不行的,必须把 key 路由,让这些消息进同一个分区。

3.4 缓冲区参数对写入行为的影响

生产者不是来一条发一条,而是先在内存里攒批。batch.size和linger.ms共同决定了批次的攒集策略。batch.size是每个批次最大的字节数,默认 16KB;linger.ms是批次创建后最多等多久再发送,默认 0,也就是不等待,立即发送。

很多人对linger.ms=0有误解,以为这样延迟最低。但实际上就算设置成 0,只要批次没满,也会立即发出去;而如果你把linger.ms调成 5~20ms,生产者会给批次一个短暂的等待窗口,让更多消息凑成一个大批次,网络往返次数减少,吞吐反而会提高。延迟可能增加几毫秒,但对大多数业务来说完全无感。

buffer.memory控制生产者内存缓冲区的总量,默认 32MB。如果发送速度超过 Broker 处理速度,缓冲区耗尽,send()就会阻塞,而不是丢消息。阻塞时间由max.block.ms控制,默认 60 秒,超时会抛异常。开发时要留意这个异常,它往往是下游 Broker 性能出现瓶颈的信号,而不是生产者代码的问题。

4. 常见故障排查与经验总结

4.1 消息分区倾斜,某几个分区数据量远超其他分区

一个主题的分区数据量忽高忽低,监控图上看到某个分区的消息量是其他分区的几十倍,这通常是因为路由逻辑里 key 的分布有问题。

比如按用户 ID 哈希,但用户 ID 里有大量相同的默认值,比如空字符串、0、-1 这些。这些默认值算出来的哈希都落在同一个分区,就把那个分区打爆了。排查时先看生产日志里 key 的分布,确认是不是有大量相同 key。解决思路一般有两种:一是把这些默认值统一改写成一个随机后缀再路由;二是先做一层预处理,把热点 key 单独路由到多个分区,再在消费端聚合。

另外,主题分区数不均匀也可能造成数据倾斜,比如新扩容的分区没有收到历史数据。这种情况相对好解决,因为它通常发生在扩容之后的前几个小时内,旧分区的数据是历史数据,新分区只有增量,长期看会自然均衡。

4.2 消息乱序,消费者拿到的顺序和发送时不一致

这个问题的根源几乎都在生产端的重试机制和 key 路由上。

如果确认单分区内乱序,先看enable.idempotence是否开启。默认开启的版本乱序概率很低,但如果你显式把它关了,或者设置了acks=0,那乱序就随时可能出现。

如果跨分区乱序,原因大概率是多个 key 被路由到了不同分区,而消费者是多线程并发消费的。Kafka 只保证单分区有序,同一个分区内不同线程消费,顺序也会乱。解决办法是让需要有序的消息都进同一个分区,并且消费时按分区做串行处理。

还有一个隐蔽的场景:生产者设置了retries,但没有开启幂等,同时max.in.flight.requests.per.connection大于 1。这种情况下,即使所有消息都进了同一个分区,也完全可能出现乱序。排查时重点确认这三个参数是否配置正确。

我遇到过一个实际案例:某公司数据同步任务每天凌晨都会出现几分钟的乱序告警,排查后发现是某条消息偶发触发重试,但重试成功后又把缓存里后面的一条消息发出去了。开启幂等后,这个现象彻底消失。

4.3 副本同步滞后,ISR 频繁收缩

监控面板上 ISR 的数量经常在抖动,某个分区的 ISR 一直少一个,而且同步日志里频繁出现ReplicaFetcherThread的告警。这种问题的原因通常是网络带宽不足、Follower 所在 Broker 磁盘性能太差,或者消息体过大导致单次拉取数据超时。

三步排查法:

  1. 查看该分区的消息平均大小,如果接近replica.fetch.max.bytes的默认值,说明消息体可能过大。
  2. 查看 Follower 所在 Broker 的磁盘 latency(延迟)和网络 inbound(入站流量),如果磁盘利用率长期超过 80%,同步速度肯定会拖后腿。
  3. 查看 Broker 端kafka.server:type=FetcherStats指标,确认 Follower 拉取请求的耗时分布。

解决思路也分三步:上调replica.fetch.max.bytes;给 Follower 所在 Broker 换更快磁盘,比如从机械盘换成 SSD;如果是因为消息体积特别大,还可以考虑在业务侧做压缩,Kafka 本身支持压缩,对吞吐帮助也很明显。

4.4 常用运维脚本与查询命令

这部分分享几个我平时排查时用得比较多的命令,基于 Kafka 自带的命令行工具。不同版本参数名可能略有差异,但大体一致。

# 查看某个主题的分区副本分布和 ISR 情况 bin/kafka-topics.sh --describe --bootstrap-server localhost:9092 --topic test-topic # 查看某个消费者组的消费进度和分区分配 bin/kafka-consumer-groups.sh --describe --bootstrap-server localhost:9092 --group test-group # 查看某个分区最新的 LEO 和 HW bin/kafka-run-class.sh kafka.tools.GetOffsetShell --broker-list localhost:9092 --topic test-topic --time -1 # 查看 Broker 端副本拉取请求的监控指标 bin/kafka-run-class.sh kafka.tools.JmxTool --object-name kafka.server:type=FetcherStats,name=BytesPerSec

kafka-topics.sh --describe的输出里,Leader列显示当前 Leader 所在 Broker,Replicas列显示 AR,Isr列显示当前 ISR。如果Isr里的列表比Replicas短,说明有副本已经滞后了。如果Leader是-1,说明分区当前没有 Leader,大概率是 ISR 为空且unclean.leader.election.enable为 false,需要人工介入。

4.5 从设计层面规避故障的经验

最后说几点从架构上规避问题的心得。

第一,主题副本数固定为 3。两个副本在单副本故障时会直接失去冗余能力,而四个副本及以上在绝大多数业务场景下并没有额外收益,反而增加了同步开销。

第二,分区数设计需要留出余量。根据当前峰值流量估算出的分区数,再乘以 1.5~2 作为缓冲,避免流量自然增长后频繁扩分区。

第三,生产端参数尽量使用官方推荐的组合:acks=-1配合min.insync.replicas=2,开启幂等。这套组合可以在合理的性能损耗下,提供非常强的可靠性保证。需要极端低延迟且能接受少量数据丢失的场景,再把acks降级到 1。

回看整个链路,Kafka 的分区写入规则和副本机制,其实是一对配合紧密的组件:副本的存在是为了给分区数据提供冗余,而生产者的参数选择,本质上就是告诉你愿意为这份冗余付出多少性能代价。理解了这些,再回头看生产环境里的各种异常,很多问题都不用再靠猜。建议你拿到一个 Kafka 集群时,先打开kafka-topics.sh --describe,把每个主题的分区分布、ISR 状态、Leader 分布扫一遍,再结合生产者的acks配置,整个集群的可靠性水平心里就有数了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进