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 为 nullKafka 不会每次都严格轮询所有分区。早期版本是纯轮询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用一致的读写视图换取更简单的一致性模型。一个分区的多个副本会有一个正式的身份编号叫 ARAssigned Replica已分配副本。AR 可以继续细分为两个集合ISRIn-Sync Replica同步中的副本指与 Leader 保持同步的副本集合这个集合动态变化。OSROut-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 发起拉取请求。这个模型和消费者拉消息是一样的。每个副本都维护着两个重要的偏移量LEOLog End Offset日志末端偏移量表示该副本当前写入到的最新位置下一条消息要写入的位置就是 LEO。HWHigh Watermark高水位表示已经被“提交”的消息位置消费者只能读取 HW 之前的消息。Leader 的 HW 是怎么确定的取 ISR 中所有副本的最小 LEO即为当前分区的 HW。举例说明一个分区有 Leader 和两个 Follower某个时刻 Leader LEO 是 100Follower1 LEO 是 95Follower2 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。如果设为 trueKafka 允许在 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 崩溃时可能丢少量消息较高-1all等待 ISR 中所有副本都写入成功后才返回最强只要 ISR 不全部挂掉就不会丢已确认消息最低acks0适合那些丢了完全无所谓的场景比如某些性能监控指标、实时推荐日志。它的隐患在于你甚至不知道消息有没有真的到达 Broker。如果网络闪断消息在客户端就被丢弃了。acks1是最常见的默认级别。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 时实际保障和acks1是一样的消息只存在于一台机器这台机器崩溃数据就没了。为了堵住这个漏洞Kafka 提供了min.insync.replicas参数。它规定了一个分区最少要有多少个 ISR 副本才能接受写入请求。如果当前 ISR 数量低于这个值生产者写入会直接报NotEnoughReplicasException消息不会被接受。通常的建议配置是acks-1配合min.insync.replicas2这样即使有一个副本挂掉ISR 里还有两个副本其中一个是 Leader另一个是 Follower消息被至少两台机器保存安全性大幅提升。从 Kafka 2.0 开始Broker 端还支持动态调整min.insync.replicas而不需要重启。这里有个很实际的矛盾min.insync.replicas2时如果集群里只剩一个副本在线所有生产者写入都会失败系统可用性直接归零。这也是这种配置的代价——为了保证不丢数据宁可拒绝写入不能接受数据在单点上裸奔。从业务角度想清楚这个取舍比盲目堆参数重要得多。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.connection1严格保证单连接只有一个在途请求这样不会乱序但也降低了吞吐。开启幂等生产者设置enable.idempotencetrue。它用序列号解决了两个问题——一是重试时不会重复写入二是同一分区的消息在有重试的情况下仍然能保证顺序。从 0.11 版本开始幂等生产者其实已经变成默认开启的。开启幂等后Kafka 会自动把max.in.flight.requests.per.connection调成 5同时保证单分区消息的有序性。这比手动把它改成 1 要科学得多既保证了顺序又不牺牲吞吐。幂等生产者的实现原理其实是在消息里带一个生产者 ID 加自增序列号。Broker 端会为每个生产者维护最近的序列号如果收到同 ID 且序列号更小的消息就认为是重复写入直接丢弃如果序列号跳号就说明有消息丢失会抛出DuplicateSequenceException。需要特别说明的是幂等只保证单分区内的严格顺序和去重。如果你想让一批消息跨多个分区都有序光靠幂等是不行的必须把 key 路由让这些消息进同一个分区。3.4 缓冲区参数对写入行为的影响生产者不是来一条发一条而是先在内存里攒批。batch.size和linger.ms共同决定了批次的攒集策略。batch.size是每个批次最大的字节数默认 16KBlinger.ms是批次创建后最多等多久再发送默认 0也就是不等待立即发送。很多人对linger.ms0有误解以为这样延迟最低。但实际上就算设置成 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是否开启。默认开启的版本乱序概率很低但如果你显式把它关了或者设置了acks0那乱序就随时可能出现。如果跨分区乱序原因大概率是多个 key 被路由到了不同分区而消费者是多线程并发消费的。Kafka 只保证单分区有序同一个分区内不同线程消费顺序也会乱。解决办法是让需要有序的消息都进同一个分区并且消费时按分区做串行处理。还有一个隐蔽的场景生产者设置了retries但没有开启幂等同时max.in.flight.requests.per.connection大于 1。这种情况下即使所有消息都进了同一个分区也完全可能出现乱序。排查时重点确认这三个参数是否配置正确。我遇到过一个实际案例某公司数据同步任务每天凌晨都会出现几分钟的乱序告警排查后发现是某条消息偶发触发重试但重试成功后又把缓存里后面的一条消息发出去了。开启幂等后这个现象彻底消失。4.3 副本同步滞后ISR 频繁收缩监控面板上 ISR 的数量经常在抖动某个分区的 ISR 一直少一个而且同步日志里频繁出现ReplicaFetcherThread的告警。这种问题的原因通常是网络带宽不足、Follower 所在 Broker 磁盘性能太差或者消息体过大导致单次拉取数据超时。三步排查法查看该分区的消息平均大小如果接近replica.fetch.max.bytes的默认值说明消息体可能过大。查看 Follower 所在 Broker 的磁盘 latency延迟和网络 inbound入站流量如果磁盘利用率长期超过 80%同步速度肯定会拖后腿。查看 Broker 端kafka.server:typeFetcherStats指标确认 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:typeFetcherStats,nameBytesPerSeckafka-topics.sh --describe的输出里Leader列显示当前 Leader 所在 BrokerReplicas列显示 ARIsr列显示当前 ISR。如果Isr里的列表比Replicas短说明有副本已经滞后了。如果Leader是-1说明分区当前没有 Leader大概率是 ISR 为空且unclean.leader.election.enable为 false需要人工介入。4.5 从设计层面规避故障的经验最后说几点从架构上规避问题的心得。第一主题副本数固定为 3。两个副本在单副本故障时会直接失去冗余能力而四个副本及以上在绝大多数业务场景下并没有额外收益反而增加了同步开销。第二分区数设计需要留出余量。根据当前峰值流量估算出的分区数再乘以 1.5~2 作为缓冲避免流量自然增长后频繁扩分区。第三生产端参数尽量使用官方推荐的组合acks-1配合min.insync.replicas2开启幂等。这套组合可以在合理的性能损耗下提供非常强的可靠性保证。需要极端低延迟且能接受少量数据丢失的场景再把acks降级到 1。回看整个链路Kafka 的分区写入规则和副本机制其实是一对配合紧密的组件副本的存在是为了给分区数据提供冗余而生产者的参数选择本质上就是告诉你愿意为这份冗余付出多少性能代价。理解了这些再回头看生产环境里的各种异常很多问题都不用再靠猜。建议你拿到一个 Kafka 集群时先打开kafka-topics.sh --describe把每个主题的分区分布、ISR 状态、Leader 分布扫一遍再结合生产者的acks配置整个集群的可靠性水平心里就有数了。