
1. 从一次线上消息丢失说起为什么必须搞懂ISR和HW很多同学第一次接触Kafka的副本机制都是被一个线上问题逼出来的。我印象很深的一次某业务方反馈“消息发出去了消费者也ack了但重启消费者之后那条消息就再也读不到了”。当时排查了半天最后定位到根因生产者用的是acks1而leader副本所在的节点在写入页缓存后、还没来得及同步给follower就宕机了等新的leader被选举出来那条消息自然就“人间蒸发”了。这个案例背后牵扯出的正是Kafka高可用体系里最核心的两个概念ISRIn-Sync Replicas同步副本集合和HWHigh Watermark高水位。前者决定了“哪些副本有资格参与leader选举”后者决定了“消费者到底能看到多少消息”。这两个机制配合起来才让Kafka在保证吞吐的同时还能做到一定程度的数据不丢、不乱。如果你正在做消息中间件选型、正在排查Kafka数据一致性问题或者单纯想把Kafka的副本机制吃透那这篇内容就是写给你的。我会从设计动机讲起把ISR的维护逻辑、HW的推进过程、LEO与HW的关系、副本同步的完整链路一层层拆开再配上可复现的实操验证步骤和踩坑记录。看完之后你应该能自己回答出“为什么消费者只能读到HW之前的消息”“为什么follower宕机再回来可能被踢出ISR”这类面试高频问题也能在实际运维中快速定位副本相关的异常。需要提前说明的是本文涉及的参数和命令都基于社区版Kafka的通用行为不同版本在细节上可能有差异实操时请以你实际部署的版本为准。下面进入正题。2. 副本机制的整体设计ISR和HW到底解决什么问题2.1 从“全同步”和“全异步”的两难说起要理解ISR的设计精妙之处得先看看如果没有它会怎样。Kafka的每个分区Partition可以有多个副本Replica其中一个是leader其余是follower。生产者只往leader写消费者只从leader读follower的唯一任务就是不断从leader拉取消息保持和leader一致。那问题来了leader什么时候能告诉生产者“这条消息写成功了”有两种极端做法。第一种是全同步leader必须等所有follower都确认写入成功才返回ack。这种做法数据最安全但只要有一个follower卡住比如磁盘满了、网络抖动、GC停顿整个分区就写不进去了可用性极差。第二种是全异步leader自己写完就返回ackfollower慢慢同步。这种做法吞吐高、延迟低但一旦leader宕机还没同步的那些消息就丢了。Kafka没有选这两个极端而是走了一条中间路线动态维护一个“跟得上”的副本集合也就是ISR。leader只需要等ISR里的副本都确认就可以返回ack。跟不上进度的副本会被踢出ISR等它追上来了再加回来。这样一来既避免了被慢副本拖死又保证了在ISR范围内的数据不丢。提示ISR是一个动态集合它的成员会随着副本的同步进度实时变化。理解“动态”这两个字是理解后续所有机制的前提。2.2 ISR、LEO、HW三个概念的定位在展开细节之前先把三个核心概念的角色理清楚不然后面容易绕晕。LEOLog End Offset日志末端偏移量每个副本都有自己的LEO表示这个副本下一条要写入的消息的偏移量。注意是“下一条要写”的位置不是“最后一条已写”的位置。比如一个副本写了offset为0、1、2三条消息那它的LEO就是3。HWHigh Watermark高水位这是分区级别的概念由leader维护并同步给所有follower。HW的值等于ISR中所有副本LEO的最小值。消费者只能消费到HW之前的消息HW之后的消息对消费者不可见。ISRIn-Sync Replicas当前与leader保持同步的副本集合包含leader自己。判断一个follower是否“跟得上”主要看它的LEO与leader的LEO差距是否在可接受范围内。这三个概念的关系可以用一句话概括ISR决定了HW怎么算HW决定了消费者能读到哪里LEO决定了副本自己的进度。它们环环相扣缺一不可。2.3 为什么HW要取ISR的最小LEO这里有个很关键的设计动机需要解释清楚。假设一个分区有3个副本leader的LEO是10follower A的LEO是9follower B的LEO是5。如果HW取leader的LEO也就是10那消费者就能读到offset 9这条消息。但问题是follower B还没同步到offset 9万一此时leader宕机B被选为新leader那offset 9这条消息在新leader上根本不存在消费者之前读到的数据就“倒退”了这就是所谓的数据不一致。所以HW必须取ISR中所有副本LEO的最小值。在上面的例子里如果三个副本都在ISR中HW就是5消费者最多读到offset 4。这样即使leader挂了任何一个ISR成员被选为新leader它至少都有offset 0到4的数据消费者读到的内容不会丢失也不会错乱。这个设计本质上是用一定的读取延迟换取了故障切换时的数据一致性。HW之后的消息虽然已经写入了leader但在所有ISR成员都追上之前它们对消费者是不可见的。这就是Kafka“先写后读、读写分离”模型下保证一致性的核心手段。3. ISR的维护机制谁进谁出怎么判断3.1 follower落后的判定标准replica.lag.time.max.msISR不是一成不变的leader需要持续判断每个follower是否还“跟得上”。Kafka用的核心参数是replica.lag.time.max.ms默认值是30000毫秒也就是30秒。这个参数的含义是如果一个follower在30秒内没有向leader发起拉取请求fetch或者虽然发起了请求但一直没有追上leader的LEOleader就会把它从ISR中移除。注意这里判断的是“时间”而不是“消息条数”。早期Kafka确实用过replica.lag.max.messages这种基于消息条数的判定但那个方案在流量突增时容易误判后来就被废弃了现在统一用时间维度。为什么用时间而不是条数因为不同分区的写入速率差异巨大。有的分区每秒写几万条有的分区几分钟才写一条。如果用条数判定高吞吐分区里稍微慢一点就被踢低吞吐分区里落后几千条可能也就几秒钟的事。用时间判定就统一多了不管你写多快只要30秒内能跟上就算同步副本。3.2 follower被踢出ISR的典型场景实际运维中follower被踢出ISR通常有几种原因我按出现频率排个序。第一种是follower所在节点负载过高。比如这台机器上跑了太多分区磁盘IO被打满follower拉取消息的速度跟不上leader的写入速度LEO差距越来越大超过30秒就被踢。这种情况在集群规模扩大、单节点分区数过多时特别常见。第二种是网络抖动或分区。follower和leader之间的网络如果出现延迟或丢包fetch请求超时leader收不到拉取请求自然就判定它落后了。第三种是follower进程GC停顿。JVM如果发生长时间的Full GCfollower线程被挂起期间无法发起fetch超过阈值就被踢。第四种是follower刚重启。重启后follower需要从磁盘恢复日志、重建索引这个过程可能超过30秒期间它不在ISR里等追上leader的LEO后才会被重新加入。注意follower被踢出ISR并不意味着它停止同步。它仍然会继续从leader拉取消息只是暂时不被计入HW的计算。等它追上leader的LEOleader会自动把它加回ISR。3.3 ISR的收缩与扩张过程ISR的变化是leader单方面决定的不需要和其他副本协商。leader在自己的内存里维护着ISR列表同时把这个列表持久化到ZooKeeper老版本或KRaft元数据新版本中以便leader切换后新leader能知道当前的ISR状态。收缩过程leader的副本管理线程定期检查每个follower的lastCaughtUpTimeMs如果发现某个follower超过replica.lag.time.max.ms没有更新这个时间戳就把它从ISR中移除并更新元数据。扩张过程当leader收到follower的fetch请求发现这个follower的LEO已经追上了leader当前的LEO或者差距在允许范围内就会把它重新加入ISR。这里有个细节follower追上LEO的判定是在leader处理fetch请求时做的所以扩张的实时性取决于fetch的频率。整个ISR的维护逻辑可以用下面这个简化的流程来理解阶段触发条件leader动作结果收缩follower超过lag阈值未跟上从ISR移除该followerISR变小HW可能前移稳定所有ISR成员都跟上维持ISR不变HW随最小LEO推进扩张follower追上leader LEO将该follower加回ISRISR变大HW可能后移这里有个容易被忽略的点ISR扩张时HW可能会“后退”。因为新加入的follower LEO可能比原来ISR中的最小LEO还小导致HW取最小值时变小了。不过这种情况通常发生在follower刚追上leader LEO的瞬间实际上它的LEO已经等于leader LEO了所以HW不会真的倒退。真正会导致HW倒退的是另一种情况后面讲HW推进时会详细说。3.4 最小同步副本数min.insync.replicasISR机制本身只保证“ISR内的数据不丢”但如果ISR收缩到只剩leader一个副本那这个保证就形同虚设了。为了防止这种情况Kafka提供了min.insync.replicas参数通常配合生产者的acksall使用。min.insync.replicas的含义是当生产者设置acksall时leader必须等ISR中至少有min.insync.replicas个副本确认才返回ack。如果当前ISR的大小小于这个值leader会直接拒绝写入抛出NotEnoughReplicasException。举个例子假设副本因子是3min.insync.replicas2。正常情况下ISR有3个成员写入没问题。如果两个follower都挂了ISR只剩leader一个此时生产者再写入就会被拒绝。这样做的好处是宁可让写入失败也不让数据写进一个没有冗余的副本里避免后续leader宕机导致数据丢失。这个参数的配置有个经验法则副本因子设为3min.insync.replicas设为2。这样能容忍一个副本故障而不影响写入同时保证至少有两份数据。如果设成3那任何一个follower挂掉都会导致写入不可用可用性太低如果设成1那就退化成单副本了失去意义。4. HW高水位的推进过程消费者到底能读到哪里4.1 HW的计算规则与更新时机HW的计算规则前面提过等于ISR中所有副本LEO的最小值。但具体在什么时机更新这里面有不少细节。leader在每次处理follower的fetch请求时会做几件事首先把follower请求位置之后的消息返回给它然后更新这个follower的LEO记录接着重新计算ISR中的最小LEO如果这个最小值比当前HW大就把HW推进到新的值。注意HW的推进是“单调递增”的正常情况下不会后退。但前面提到ISR扩张时可能引入一个LEO较小的副本理论上会让最小值变小。Kafka的处理方式是HW只在新的最小LEO大于当前HW时才更新否则保持不变。这样就保证了HW不会倒退消费者不会读到“消失”的消息。follower这边也会维护自己的HW。follower在fetch响应中会拿到leader当前的HW然后把自己的HW更新为min(leader HW, 自己的LEO)。这个设计是为了保证follower上的HW不会超过leader的HW避免follower在成为新leader后HW比实际数据还大。4.2 LEO、HW、消费者可见范围的关系用一个具体的例子把这三个量的关系串起来。假设一个分区有leader和follower两个副本初始状态都是空的LEO0HW0。生产者写入第一条消息offset0。leader写入后自己的LEO变成1。此时follower还没来拉取ISR中最小LEO是follower的0所以HW还是0。消费者此时读不到任何消息因为HW0意味着可读范围是offset 0也就是空。follower发起fetchleader把offset0的消息返回给它。follower写入本地日志LEO变成1。follower在下一次fetch请求中带上自己的LEO1。leader收到后更新follower的LEO记录为1此时ISR中最小LEO是1HW推进到1。消费者现在可以读到offset0的消息了。生产者继续写入offset1的消息leader LEO变成2但follower还没拉取HW保持1。消费者仍然只能读到offset0。等follower拉取并确认后HW推进到2消费者才能读到offset1。这个过程说明了一个关键点消息从写入leader到对消费者可见中间隔了一个完整的副本同步周期。这就是Kafka用HW换一致性的代价。在高吞吐场景下这个延迟通常在毫秒级对大多数业务可以接受但在对延迟极度敏感的场景就需要权衡了。4.3 HW推进与副本同步的完整链路把整个链路完整走一遍从生产者发送到消费者可见生产者发送消息到leaderleader写入本地日志LEO加1。follower定期由replica.fetch.wait.max.ms控制默认500ms向leader发送fetch请求请求中携带自己的LEO。leader收到fetch请求从follower的LEO位置开始返回消息同时记录follower的LEO。leader重新计算ISR中的最小LEO如果大于当前HW推进HW。leader在fetch响应中带上当前的HW。follower收到响应写入消息更新自己的LEO同时把HW更新为min(leader HW, 自己的LEO)。消费者从leader拉取消息只能拉取到HW之前的消息。这个链路里follower的fetch频率直接影响HW推进的速度。如果fetch间隔太长HW推进就慢消费者看到的延迟就大。replica.fetch.wait.max.ms这个参数控制的就是follower在没有新数据时等待的最长时间调小它可以加快HW推进但会增加网络请求次数。提示HW的推进是“批量”的不是每写一条消息就推进一次。leader在处理fetch请求时批量更新所以实际延迟取决于fetch的频率和批量大小。5. 副本同步的实操验证亲手观察ISR和HW的变化5.1 环境准备与基础命令光讲理论容易飘咱们动手验证一下。假设你已经有一个Kafka集群在跑至少3个broker节点。如果没有可以用单机多进程的方式模拟或者用容器快速起一个三节点集群。先创建一个测试topic副本因子设为3分区数为1方便观察kafka-topics.sh --bootstrap-server localhost:9092 \ --create --topic isr-test \ --partitions 1 --replication-factor 3创建后查看topic详情确认leader和ISRkafka-topics.sh --bootstrap-server localhost:9092 \ --describe --topic isr-test输出里会显示每个分区的leader、replicas和isr。正常情况isr应该包含全部3个副本。接下来用kafka-topics.sh的--describe配合--under-replicated-partitions可以专门查看副本不同步的分区kafka-topics.sh --bootstrap-server localhost:9092 \ --describe --under-replicated-partitions如果输出为空说明所有分区都同步正常。5.2 模拟follower落后并观察ISR收缩要模拟follower落后最直接的办法是暂停一个follower的副本同步线程或者直接kill掉一个broker。这里用kill broker的方式更贴近真实故障。假设topic的leader在broker 1上ISR是[1,2,3]。现在kill掉broker 3。等超过replica.lag.time.max.ms默认30秒后再查看topic详情kafka-topics.sh --bootstrap-server localhost:9092 \ --describe --topic isr-test你会看到isr变成了[1,2]broker 3被踢出去了。同时--under-replicated-partitions会显示这个分区处于副本不足状态。这时候如果生产者用acksall写入只要ISR大小还大于等于min.insync.replicas写入就能成功。如果ISR收缩到小于min.insync.replicas写入就会被拒绝。5.3 观察HW推进与消费者可见范围要直观看到HW的变化可以用kafka-consumer-groups.sh配合--describe查看消费者的lag或者用kafka-run-class.sh里的GetOffsetShell工具查看当前HW。查看分区当前HW和LEOkafka-run-class.sh kafka.tools.GetOffsetShell \ --bootstrap-server localhost:9092 \ --topic isr-test --time -1这个命令返回的是每个分区的LEO最新offset。要单独看HW需要借助JMX指标或者kafka-log-dirs.sh工具。更简单的办法是启动一个消费者观察它能读到的最大offset。启动控制台消费者kafka-console-consumer.sh --bootstrap-server localhost:9092 \ --topic isr-test --from-beginning --property print.offsettrue然后往topic里发几条消息观察消费者读到的offset和生产者写入的offset之间的差距。在副本同步正常的情况下这个差距很小如果人为制造follower延迟差距会变大。5.4 用JMX指标监控ISR和HW生产环境里靠命令行观察是不够的得靠监控。Kafka暴露了大量JMX指标其中和副本相关的关键指标有这几个指标名称含义关注点kafka.server:typeReplicaManager,nameIsrShrinksPerSecISR收缩速率持续大于0说明有副本频繁掉队kafka.server:typeReplicaManager,nameIsrExpandsPerSecISR扩张速率和收缩速率配合看频繁波动说明同步不稳定kafka.server:typeReplicaManager,nameUnderReplicatedPartitions副本不足的分区数大于0需要立即排查kafka.log:typeLog,nameLogEndOffset分区LEO对比不同副本的LEO差距kafka.server:typeReplicaFetcherManager,nameMaxLagfollower最大落后条数反映同步延迟配置JMX监控后可以设置告警规则UnderReplicatedPartitions持续大于0超过5分钟就告警IsrShrinksPerSec突增时排查节点负载。注意UnderReplicatedPartitions这个指标在Kafka 2.4之后语义有变化它统计的是ISR小于副本因子的分区数。如果你的min.insync.replicas设得比副本因子小这个指标大于0不一定代表有问题要结合具体配置判断。6. 常见问题与排查技巧实录6.1 ISR频繁收缩扩张怎么排查这是运维中最常见的问题之一。ISR像呼吸一样频繁收缩扩张说明副本同步不稳定。排查思路按优先级来第一步看节点负载。登录到频繁掉队的broker用top、iostat、vmstat看CPU、磁盘IO、内存。如果磁盘IO util长期接近100%说明磁盘是瓶颈follower拉取和写入都慢。这种情况要么扩容节点要么把部分分区迁移到负载低的节点。第二步看GC日志。如果磁盘正常但ISR还是频繁变化检查JVM GC日志。长时间的Full GC会导致follower线程停顿超过lag阈值就被踢。调优GC参数或者增加堆内存可以缓解。第三步看网络。用ping和traceroute检查broker之间的网络延迟和丢包。跨机架、跨可用区的部署尤其要注意网络质量。第四步看参数配置。检查replica.lag.time.max.ms是否设得过小。默认30秒对大多数场景够用如果设成几秒稍微有点抖动就会触发收缩。另外检查num.replica.fetchers这个参数控制每个broker上用于副本拉取的线程数默认是1在高吞吐场景下可能不够可以适当调大。6.2 HW不推进导致消费者读不到消息有时候生产者明明写入成功了消费者却迟迟读不到消息。这种情况通常是HW没有推进。原因可能有几个一是ISR中有副本卡住。HW取ISR最小LEO只要有一个副本的LEO不前进HW就卡在那里。用kafka-topics.sh --describe看ISR状态再对比各副本的LEO找出落后的那个。二是follower的fetch线程挂了。检查follower的日志看有没有ReplicaFetcherThread相关的异常。有时候是网络问题导致fetch请求一直失败重试。三是leader切换后HW重建。新leader上任后需要等所有follower上报LEO才能重新计算HW。这个过程中HW可能暂时不推进等同步稳定后就恢复了。排查时可以用kafka-run-class.sh kafka.tools.DumpLogSegments工具查看具体分区的日志文件确认各副本的实际数据位置。6.3 副本因子和min.insync.replicas的配置陷阱这两个参数的配置直接关系到数据安全和可用性配错了后果很严重。我见过几种典型的错误配置陷阱一副本因子设为1。这等于没有冗余broker一挂数据就丢。生产环境至少设为2推荐3。陷阱二acksall但min.insync.replicas1。这种情况下即使ISR只剩leader一个写入也能成功。表面上看acksall很安全实际上因为min.insync.replicas1leader自己确认就算数数据没有任何冗余保障。这个组合是“看起来安全实则危险”的典型。陷阱三min.insync.replicas等于副本因子。比如副本因子3、min.insync.replicas也设3。这样任何一个follower故障都会导致写入不可用可用性太差。除非业务对数据安全的要求极高且能接受写入中断否则不推荐。陷阱四unclean.leader.election.enabletrue。这个参数允许非ISR副本被选为leader虽然提高了可用性但会丢数据。生产环境建议设为false除非业务能容忍数据丢失。6.4 常见问题速查表把上面这些整理成一张速查表方便排查时对照现象可能原因排查方法解决方向ISR频繁收缩扩张节点负载高、GC停顿、网络抖动看IO/GC/网络指标扩容、调GC、调lag阈值HW不推进ISR中有副本卡住、fetch线程异常对比各副本LEO、看follower日志修复卡住的副本、重启fetch线程消费者读不到消息HW未推进、消费者offset越界查HW和LEO、查消费者group offset等HW推进、重置offset写入被拒绝ISR小于min.insync.replicas查ISR大小和参数配置恢复副本、调整参数leader切换后数据不一致unclean选举开启查unclean.leader.election.enable关闭unclean选举6.5 几个容易踩的坑最后分享几个我在实操中踩过的坑都是文档里不太会写的。坑一以为follower被踢出ISR就停止同步了。实际上follower会继续拉取只是不计入HW计算。很多人看到ISR变小就慌其实只要follower在追很快会加回来。坑二用消息条数判断副本是否同步。前面说过现在Kafka用时间判定。如果你还按老资料里的replica.lag.max.messages去调优会发现根本没这个参数。坑三忽略HW对消费者延迟的影响。在跨机房部署时follower同步延迟可能达到几百毫秒甚至秒级HW推进慢消费者看到的延迟就大。这种场景要考虑是否调整部署架构而不是一味调参数。坑四leader切换后立即大量写入。新leader上任后HW需要重新建立此时如果大量写入可能因为ISR还没稳定导致HW推进缓慢。建议在切换后观察一段时间再放开流量。坑五JMX指标采集频率太低。ISR的变化可能很短暂如果采集间隔是1分钟可能错过收缩的瞬间。建议关键指标采集间隔设为10秒以内。7. 从ISR和HW延伸出去的几个思考把ISR和HW搞明白之后很多之前模糊的问题会变得清晰。比如为什么Kafka的acksall不等于“绝对不丢数据”——因为如果ISR收缩到只剩leaderacksall也就退化成acks1了这时候还得靠min.insync.replicas兜底。再比如为什么消费者重启后可能读到重复消息——因为消费者提交的offset和HW之间有时间差HW之后的消息虽然写入了但还没对消费者可见重启后从上次提交的offset重新读就可能重复。还有一个值得琢磨的点是HW机制和Raft协议里commitIndex的相似性。两者都是“多数派确认后才对外可见”的思路只不过Kafka用的是ISR这个动态多数派而Raft用的是固定多数派。理解了这个类比再看Kafka的副本机制会通透很多。实际运维中我个人的经验是ISR和HW相关的告警要设得敏感一些但处理时要冷静。ISR短暂收缩是正常现象只要能在合理时间内恢复就不用干预。真正需要立即处理的是UnderReplicatedPartitions持续不降、HW长时间不推进这类情况。另外定期做副本同步的演练很有必要比如主动kill一个broker观察ISR变化和恢复时间这样真出故障时才不会手忙脚乱。最后再分享一个小技巧如果你不确定某个分区的HW和LEO具体是多少可以用kafka-run-class.sh kafka.tools.GetOffsetShell配合--time -1和--time -2分别查看最新和最早的offset再结合消费者group的offset大致推断出HW的位置。虽然不如JMX精确但在没有监控的环境下应急排查够用了。