Kafka这个项目,入门容易,跑起来也容易,但真正把它用明白,靠的是把链路里那些概念串起来。前面两篇我们把环境搭好、把基本的生产消费跑通了,这篇作为系列第三篇,我按自己的理解把Kafka的核心知识点重新捋一遍。不是把官方文档里的名词再贴一遍,而是沿着一条消息从生产端到消费端走过的路径,把分区、副本、ISR、offset、再均衡这些机制一个个拆开讲清楚。适合已经会敲Kafka命令、但面对深浅概念还有点对不上号的读者,也适合准备系统性梳理知识体系的人。
这篇我更想聊的其实是“概念之间的关系”。比如你知道副本是什么,但可能不清楚ISR为什么要把慢副本踢出去;你知道acks=all比acks=1更可靠,但可能没想过如果ISR只剩一个副本,acks=all也会丢消息。这种细节才是线上真正决定成败的地方。所以这篇不会省步骤,也不会只给结论,每个点我都会尽量说明背后的取舍逻辑。
1. 先沿着消息链路把概念串起来
1.1 生产端:序列化、分区与批处理
一条消息从Producer发出,第一步是序列化。Producer端的key和value分别经过序列化器,默认的StringSerializer很简单,但如果在公司里用了Avro或Protobuf,这里就多了一层Schema管理的成本。Schema不匹配会导致反序列化失败,这也是很多团队从JSON切换到Avro后踩的第一个坑。序列化完成后,消息并不会立刻发到Broker,而是先进入Producer内部的缓冲区。
这个缓冲区按分区维度组织,每个分区攒了一批消息后,由后台的Sender线程统一发出去。很多人一开始不理解这个“攒批”的设计,但Kafka吞吐高,本质就是减少网络往返次数。单条发送不是不行,只是延迟高、吞吐上不去。你想想,发100条消息和发一个包含100条消息的批次,网络开销完全不是一个量级。
这里有两个参数经常被放在一起调整:batch.size默认16KB,linger.ms默认0。理论上有数据就发,但实测下来把linger.ms调到5~10ms,因为批次更容易攒满,整体吞吐明显提升,延迟也不会劣化太多。不过我不建议把linger.ms调到50ms以上,除非业务真的能接受这种延迟,否则线上会频繁出现“消息迟到了几十毫秒”的告警,排查起来压力全在你这边。
key的分区逻辑也值得一提。如果key是null,新版Producer默认用StickyPartitioner,让消息尽量集中在一个分区,方便攒批;如果key不为null,就按murmur2哈希取模,保证同一个key始终进入同一个分区,这是实现局部顺序性的基础。这个设计背后的逻辑很简单:凡是要保证顺序,就必须把相关消息固定到同一分区;但分区一旦固定,并行度就有上限。所以一定要想清楚业务里哪些消息真正需要顺序,不值得为所有消息统一牺牲分区并发。
1.2 Broker端:leader写入,follower同步
消息到达Broker之后,并不是所有副本同时收到这份数据。每个分区会选出一个leader副本负责接收生产请求,follower副本主动从leader拉取数据,然后写入自己的本地日志。这个设计跟很多人对“主从同步”的直觉有点不一样:不是主节点往从节点推数据,而是从节点自己来拉。
在这个环节你会持续遇到三个缩写:LEO、HW、ISR。LEO是日志末端偏移量,每条副本都有自己的LEO;ISR是当前与leader保持同步的副本集合;HW是所有ISR副本都同步到的位置,也就是“已提交”的位置。消费者只能读到HW之前的消息,这个约束是Kafka一致性的基石。
为什么消费者不能读HW之后?假设leader先收到一条消息,但还来不及同步给follower就宕机了,新leader从ISR里产生,它并没有这条消息。如果消费者已经把这条消息读走了,那这条消息就从用户视角消失了,等于消息被“吞”了。所以Kafka宁可让消费者晚读到一部分消息,也不允许读到那些可能丢失的尾部消息。
理解了这个逻辑,你就能明白为什么acks=all不是万能的。它只保证ISR内副本都写成功才行,但如果没有min.insync.replicas做下限,ISR完全可能收缩到只剩leader一个副本。这时候“所有ISR都写成功”等于“leader自己写成功”,可靠性跟acks=1没有区别。
1.3 消费端:拉模型与offset的起点
消费端的核心是拉模型。Consumer主动向Broker拉取消息,而不是Broker推给Consumer。为什么不用推?因为推模型没法解决消费速率差异。Broker如果一股脑推过来,Consumer处理不过来就积压,要么强制丢弃,要么要求Broker降速,整个设计会复杂很多。拉模型把速率控制权完全交给Consumer,处理得快就多拉,处理得慢就少拉,简单直接。
每个分区里的消息都有一个offset,相当于消息在日志里的编号。Consumer消费一批消息后,要提交offset,记录“我处理到哪了”。这个动作是消费端最核心的权衡点:提交早了,进程崩溃会丢消息;提交晚了,崩溃后会重复消费。很多刚接触Kafka的人以为offset是Broker维护的,其实Broker只是负责保存offset提交的记录,真正的提交时机完全由Consumer代码决定。
Consumer还有一个重要的组织方式:消费组。同一消费组里的Consumer共同消费一个topic,但一个分区只会分配给组内的一个Consumer。这个并行模型是Kafka支撑大数据量消费的关键,也引出了后面要重点讲的再均衡机制。
2. 存储与分区机制:Kafka高吞吐的底盘
2.1 分区数是并行度,但不是越大越好
分区是Kafka并行度的最小单位。Producer可以并行写多个分区,Consumer组内不同成员分别处理不同分区,所以分区数越多,并行度的上限越高。但分区不是免费的。每个分区在Broker端都有日志目录、文件句柄、索引文件,还要参与leader选举和副本同步。分区数太多,内存和文件句柄会先报警,GC也会变频繁。
还有一个很多人想不到的问题:Broker宕机后,分区leader的恢复时间跟分区数量正相关。分区越多,Controller需要重新分配的元数据越多,故障恢复越慢。我见过有人把单个topic设成128个分区,集群只有3台机器,最后每次重启都要花很久。分区数真的不是拍脑袋定的,可以从两个方向倒推:一是按吞吐量,估算单分区能扛多少写入,用业务峰值除以单分区能力,再留1.5倍冗余;二是按消费者线程数,分区数至少要大于等于消费者总线程数,不然会有消费者闲着没事干。
比较常见的建议是分区总数控制在Broker总数的10倍以内,线上很多团队用3个Broker、30~60个分区,已经能支撑很大的吞吐。别一上来就追求数字大,后续想要减少分区数,Kafka是不支持的,只能新建topic重新迁移数据,那是相当麻烦的事。
2.2 日志分段与稀疏索引的配合
Kafka的日志不是一个大文件,而是按大小切成多个segment。每个segment是一个物理文件,默认大小1GB,由log.segment.bytes控制。当前正在写的segment叫active segment,写满后自动滚动生成新的segment,并把前一个segment变成不可写的旧段。这个设计最大的好处是日志清理方便,删除过期数据时直接删整个segment文件,不需要在大文件里做随机删除。
有了segment,怎么快速定位一条消息?Kafka为每个segment维护了offset索引和时间戳索引,两者都是稀疏索引。所谓稀疏,是不必为每条消息记录索引位置,大约每隔几千字节才落一条索引项。查找消息时,先在内存里定位到具体segment,再通过稀疏索引做二分查找,定位到目标offset附近,最后从物理文件顺序扫描。用“稀疏索引加顺序扫描”的组合,既能控制索引文件体积,又能保证定位速度。
排查看消息内容时,有两条命令很实用。一条是bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files /路径/xxx.log --deep-iteration,可以打印segment里的消息明细,包括offset、时间戳、消息体;另一条是查看Consumer的消费位置,用bin/kafka-consumer-groups.sh --describe --group 组名,能看到每个分区的LogEndOffset、ConsumerOffset和Lag。这两个命令配合下来,消息丢没丢、卡在哪,基本一眼能定位。
2.3 保留策略:delete和compact该选谁
日志保留策略直接影响磁盘占用和消息可用时间。默认delete策略按时间删旧数据,log.retention.hours=168正好是7天。到达保留时间后,后台任务会把包含过期消息的segment删除。注意,它删的是整个segment,不是单条消息。所以消息的实际保留时间可能比7天略长,最长会多出一个segment的生命周期,这对多数应用没什么影响。
compact策略则是按key做合并,每个key只保留最新的一条消息。它适合存用户画像、配置信息、设备状态这类“只要最新值”的数据。但很多人对compact有误解,以为设置之后旧版本key立刻消失。实际上Log Cleaner线程是周期性扫描日志,构建key到offset的映射,然后copy存活消息到新segment,整个清理过程可能持续几十分钟甚至更久,期间磁盘占用和IO都会上升。
还有一点比较冷门:compact策略下,一个key如果一直不更新,它不会被清理掉,会一直留在日志里。想删掉某个key,需要写入一条value为null的墓碑消息,cleaner看到墓碑消息才会把它前面同一key的历史消息删掉。如果业务要删数据,必须把这个机制设计进去,不是删除消息就能释放空间的。
3. 副本、ISR与leader选举:可靠性的核心
3.1 AR、ISR、OSR与副本同步判定
每个分区可以有多个副本,所有副本合起来叫AR。但AR里的副本不都处于正常同步状态。Kafka维护了一个ISR集合,表示当前跟leader保持同步的副本。如果某个副本跟不上,会被移出ISR,变成OSR。这个“跟不上”的判定标准不是落后多少条消息,而是看它是否在replica.lag.time.max.ms时间内向leader发过复制请求,默认是30秒。
只要副本还在继续拉数据,哪怕它落后了几万条,ISR也暂时不会踢它出去。这个设计我觉得很聪明。同步快慢本来就是波动的,偶尔落后是正常的,真正要防的是副本“不干活了”。如果副本卡死,还留在ISR里,HW就会被它卡住,所有正常副本的已提交位置都推不上去,消费者的可见消息都会停滞。把它移出ISR后,剩余副本可以继续推进HW,系统不至于被一个慢副本拖垮。
replica.lag.time.max.ms可以调短,比如15秒,追求更快的故障感知。但要注意,Broker发生Full GC时,所有副本都会暂停,如果GC时间超过这个阈值,正常的副本也可能被误判为不活跃,移出ISR后再重新同步回来,反复横跳反而制造抖动。所以生产中我看过不少团队把这个值调大而不是调小,因为误判的代价比慢同步更难受。
3.2 acks参数:三个挡位如何选
acks是Producer端最重要的可靠性参数,默认值是1。
- acks=0,发出去就不管了,不关心Broker有没有收到。适合日志采集、监控指标这类丢几条完全无所谓的场景,吞吐最高。
- acks=1,leader写本地成功就算成功。这是性能和可靠性的折中,但leader如果只写完自己就宕机,数据可能来不及同步给follower,消息就丢了。
- acks=all,等ISR里所有副本都写成功才返回。可靠性最高,延迟也最高。
生产环境的可靠配置,我一般建议acks=all加min.insync.replicas=2。前者保证写成功时ISR都同步了,后者保证ISR至少有两个副本,防止缩到单个副本后静默丢数据。这个组合的逻辑很简单:如果集群连两个活副本都凑不齐,宁可写入失败,也不接受数据可能丢失的状态。
但引入min.insync.replicas=2后,有一个常见的坑:副本故障或滚动重启时,ISR只剩一个副本,这时Producer写入会抛NotEnoughReplicasException。如果业务代码没做重试和告警,你会看到明明集群没挂,消息却写不进去。更隐蔽的是,很多团队只设置了acks=all,没设置min.insync.replicas,ISR缩到1后依然正常写入,线上数据就在“最可靠配置”的伪装下悄悄丢掉。
3.3 选举与Controller:故障时的决策逻辑
正常情况下,每个分区的leader是AR里排在最前面的副本,也叫优先副本。如果优先副本挂了,Kafka会从ISR里选一个新的leader。选择算法不复杂:ISR列表里按顺序选第一个存活的副本。因为ISR里的副本都已经同步了最新数据,选谁当leader都不会丢已提交的消息。
真正的难题是ISR全部不可用时怎么办。Kafka提供了unclean.leader.election.enable参数,默认false。为false时,分区只能等待ISR副本恢复,期间分区不可用;为true时,允许选一个OSR副本当leader,好处是服务快速恢复,坏处是这个副本可能缺数据,消费者会读到旧数据,并且在它缺失的那段消息重新被覆盖,造成数据“回退”。绝大多数业务场景都应该保持false,毕竟宁可短暂不可用,也不能读到已经丢失的消息。
再往上一层是Controller。Kafka集群里有一个Controller节点,负责分区leader选举、副本分配、topic元数据变更等全局操作。Controller通过内部元数据机制维护自己的身份,并依靠epoch机制防止出现两个Controller同时做决定的脑裂场景。如果Controller节点本身出问题,整个集群的元数据操作都会卡住,所以监控里一定要有Controller存活状态,别只盯着broker的CPU和内存。很多集群“看起来活着但操作什么都超时”,很可能就是Controller已经不正常了。
4. 消费端机制:offset、rebalance与精确一次
4.1 消费组与再均衡的触发逻辑
消费组是Kafka实现并行消费的基本单位。组内的消费者会瓜分topic的所有分区,每个分区只被一个消费者处理。分区分给谁,由分区分配策略决定。老版本默认的RangeAssignor在topic分区数增加时变动较大,StickyAssignor会尽量保持现有分配不变,CooperativeStickyAssignor则支持增量式再均衡,不会全组停止消费。
再均衡的触发条件有三个:消费者加入或退出、消费者超时被判定下线、订阅的topic或分区数变化。再均衡期间,整个消费组会短暂停止消费,等分配完成后再继续。分区多、消费者多的时候,这个停顿少则几百毫秒,多则几秒。线上如果有频繁再均衡,用户体验就是消费时不时中断一下。
排查再均衡频率,最直接的方式是看kafka-consumer-groups.sh --describe --group 组名的输出,观察memberid是否频繁变化。再联合服务端日志里的“rebalance started”和“rebalance completed”记录,基本能定位到是哪个消费者在频繁加入退出。很多时候根因是消费者处理消息太慢,超过max.poll.interval.ms默认5分钟,被判定为卡死踢出分组,然后下一个轮回又开始消费,形成恶性循环。
这里提醒一句:session.timeout.ms和max.poll.interval.ms是两回事。前者控制心跳超时,默认45秒,后者控制两次poll之间的最大间隔,默认5分钟。如果你的业务确实需要长时间处理消息,不能只调大session.timeout.ms,要同时调整max.poll.interval.ms,否则消费者还是会因为poll超时被踢出去。
4.2 offset提交:自动、同步、异步的坑
offset提交是消费端最容易出问题的地方。自动提交enable.auto.commit=true时,Consumer每5秒自动提交一次本批消费的offset。它的问题在于:如果消费者在两次提交之间崩溃,重启后会从上次提交的位置重新消费,出现重复数据。自动提交看着省事,但它在“不小心重复消费”和“可能跳过消息”之间没有给你任何控制力,不适合严谨的业务。
手动提交分两种。commitSync()是同步阻塞的,Broker返回提交结果后才知道成功,失败也能立刻感知并重试,但每次提交都要等待网络往返,吞吐受影响。commitAsync()是异步的,提交动作在后台完成,不阻塞消费主流程,但失败时不会自动重试。
异步提交有个隐蔽的坑:如果提交失败后你忽略了异常,下一次异步提交的是更大的offset,那么这个失败的提交就被跳过了。重启后,消费者会从那个更大的offset继续消费,结果就是丢失了上次没提交成功的那一批消息。我的做法是:正常处理逻辑里用commitAsync(),在进程关闭钩子里用commitSync()兜底,强行把最后一批offset提交干净。两者配合,既能保证常规提交性能,又能保证退出时安全。
还有一种更精准的提交方式,是提交指定分区和offset的Map,即commitSync(Map<TopicPartition, OffsetAndMetadata>)。当你需要精确控制某个分区的提交进度,或者只提交已处理成功的消息位置时,用这个能避开整体提交带来的重复消费区间。
4.3 从幂等到事务:精确一次的路有多远
重复消费本身不可怕,可怕的是下游处理不是幂等的。如果同样的消息被处理两次,数据库多了一条记录,那问题就严重了。解决重复问题可以从下游入手,比如给数据加唯一键,利用数据库唯一索引去重,或者先查再写。这种方案简单直接,也很有效。
Kafka层面也提供了自己的机制。开启enable.idempotence=true后,Producer会为消息分配序列号,Broker端根据生产者标识、分区和序列号做去重,保证同一个生产者在一个分区内不会写入重复消息。这个特性默认在新版本客户端中是打开的,性能开销很小,建议没有特殊理由都保持开启。
幂等只解决单分区内不重复。如果要跨多个分区写入,且要求这些写入要么全部成功要么全部不成功,就需要事务API了。事务能把多个分区的提交变成一个原子操作,配合read_committed隔离级别,消费者也不会读到未提交的事务消息。但事务会带来额外开销和复杂度,我的建议是:没有强需求不要轻易上事务。大多数线上系统使用“Producer幂等+Consumer手动提交+下游幂等控制”的组合,已经能满足业务需求,这已经是被验证过无数次的可靠方案。
5. 实战中总结的参数与排查经验
5.1 高频故障排查速查表
这部分内容大多是从实际故障里复盘得来的,列成一个速查表,方便遇到问题时直接对号入座。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 消费组频繁rebalance | 消费者处理太慢,超过max.poll.interval.ms | 查看组内成员变化,埋点统计单条处理耗时 |
| 消息积压严重但消费者CPU不高 | 单分区能力上限或消费者拉取配置过小 | 算单分区吞吐,检查fetch.min.bytes、max.poll.records |
| 生产者偶尔报NotEnoughReplicas | min.insync.replicas大于ISR成员数 | 检查副本状态和最近是否有Broker重启 |
| 某分区消费者一直没消息 | 分区分配不均衡或该分区leader异常 | 用describe查看分区leader和ISR |
| 消息延迟突然升高 | 批次参数设置过大,或Broker磁盘IO瓶颈 | 检查linger.ms、batch.size,看磁盘等待 |
| 磁盘占用居高不下 | 保留时间配置过长或compact未执行 | 查看retention.ms和cleaner线程状态 |
| 生产者吞吐上不去 | 缓冲区太小或压缩算法不匹配 | 检查buffer.memory、compression.type,压测验证 |
这张表不能覆盖所有场景,但每个问题都是线上真实遇到过的。排查时可以按这个思路:先确认是生产端、消费端还是Broker端,再缩小到参数、资源、代码中的某一个维度,不要一上来就动参数。
5.2 一套线上相对稳妥的参数组合
我在多个线上项目里常用这样一套组合,不能说适合所有场景,但起点比较稳:
- Producer:
acks=all,enable.idempotence=true,max.in.flight.requests.per.connection=5,linger.ms=5,compression.type=lz4。如果对延迟极其敏感,把linger.ms调成2或0。 - Broker:
min.insync.replicas=2,unclean.leader.election.enable=false,replica.lag.time.max.ms=30000,log.retention.hours=168。追求更可靠可以调短,但别低于15000。 - Consumer:
enable.auto.commit=false,手动提交异步为主、关闭前同步兜底,max.poll.records=500,max.poll.interval.ms根据实际最慢处理耗时设置。
这套组合的核心理念是:宁可写入或消费失败,也不要静默丢数据。配合消费监控,能够尽早发现问题。刚开始跑业务时先保守一点,稳定后再根据压测结果放开限制。
参数调整后一定要做对比验证。我见过有人把max.in.flight.requests.per.connection从5改成1,以为能提高可靠性,结果吞吐直接掉了将近一半。真实环境的性能瓶颈往往不在这个参数上,盲调还不如不调。
5.3 日常监控里最值得看的几个指标
监控指标不必追求多,关键是选对。我平时最常看这几个:
UnderReplicatedPartitions:小于0就说明有分区副本没跟上,这是最直接的副本健康信号,应该长期保持在0。RequestQueueSize和ResponseQueueSize:持续上涨说明Broker处理不过来,可能CPU或磁盘已经瓶颈。BytesInPerSec和BytesOutPerSec:看吞吐趋势,对比历史基线能提前发现流量异常。ConsumerLag:这是最直观的业务健康指标,滞后持续增长说明消费端处理能力跟不上生产速度。- 消费组成员变化次数:配合日志看是否频繁rebalance,这是消费端隐藏问题的主要线索。
监控的价值不在于能看到数字,而在于每个数字变差时你知道下一步干什么。UnderReplicatedPartitions上升了,我会先看follower副本在哪个Broker上,再看那块磁盘的IO是否异常,最后看是不是刚做过滚动重启。把数字和动作对应起来,监控才算真正落地。
我个人在实际调优里最看重的三个参数,反而不是那些“热门”的:Broker端的num.replica.fetchers、Producer端的max.in.flight.requests.per.connection,以及Consumer端的max.poll.interval.ms。前两个决定了副本同步和主链路能跑多快,第三个决定了消费端稳定性的上限。把它们调顺了,整个集群的“手感”就不一样。这些参数没有统一最优解,每个环境都要通过压测和观察慢慢找到自己的平衡点。