说实话,Kafka这个题目我已经被问过太多次了。每次带新人或者帮朋友排查线上问题,只要聊到消息中间件,Kafka几乎是绕不开的那个名字。很多人一开始觉得它就是一个"性能很好的消息队列",但真正上手之后才发现,它的架构里藏着大量和传统队列完全不同的设计思路。如果你只是把Kafka当RabbitMQ用,那大概率会在消费顺序、堆积处理、分区扩容这些环节吃大亏。
这篇东西我不会去贴官方文档,那玩意已经够全了。我按照自己从零搭建集群、压测、排查线上故障的经验,把Kafka的基础架构拆开揉碎讲一遍。适合两类人看:一是刚接触分布式消息系统、想系统搞懂Kafka核心概念的开发者;二是已经在用Kafka、但遇到分区、副本、Rebalance、积压等问题时只能靠猜的运维和后端同学。看完你能建立一张完整的架构地图,再用这张地图去对照实际代码和参数,很多困惑会迎刃而解。
1. 为什么选型时最终落在了Kafka身上
先把Kafka摆在它该在的位置。它不是一个普通的消息队列,而是一个分布式流处理平台。这个定位决定了它的一切架构设计。传统消息队列的核心诉求是把生产者和消费者解耦,消息被消费完就删除,完了。Kafka则把"消息"当成不可变的日志流,写入之后按序追加,消费者不是"取出"一条消息,而是"读取"到某个位置的数据。同一个主题的数据可以反复读、按需读,由消费者自己维护进度。
这个差异带来的连锁反应是:吞吐量上限完全不同,因为Kafka可以做顺序写磁盘;消费者天然支持多组并行,因为彼此互不干扰;消息堆积成了家常便饭而不是事故,因为Kafka本来就是为"大日志"场景设计的。
我见过不少团队从RabbitMQ迁到Kafka,最大的感受是心智模型要换一遍。在RabbitMQ里你考虑的是队列、exchange、routing key;在Kafka里你考虑的是主题(Topic)怎么分片、分区(Partition)怎么分布、副本怎么冗余、消费者组怎么分配。这不是语法层面的区别,是架构哲学的区别。
选型建议也比较直接:如果你需要的是"每秒钟几万条消息的推送,单条消息延迟毫秒级,且允许一定的批量缓冲",Kafka非常适合;如果你需要复杂的路由规则、按优先级处理、每一条消息都必须准确投递到指定业务方,那RabbitMQ这类传统队列更顺手。我自己做技术方案时的判断标准是看"消息流是否线性、是否会被多个下游重复消费"——只要这两个问题的答案偏向"是",Kafka基本就是最优解。
2. Topic、Partition、Offset这套"物理世界观"
很多资料喜欢把Topic叫"主题",但把它理解为"一类事件的集合"更准确。比如订单系统里有订单创建、支付完成、退款成功三类事件,那就建order-created、order-paid、order-refunded三个Topic。Topic里面存的不是结构化表格,而是一个个带时间戳、带key的字节数组消息,没人在这一层去约束字段结构。
真正撑起Kafka架构的是Partition。一个Topic会被分割成若干个分区,每个分区是一个有序的、不可变更的日志文件。消息写入分区后,在分区内部按顺序排列,位置由Offset(偏移量)来标识。Offset是一个只增不减的64位整数,它从0开始,每条新消息都会拿到当前分区内最新的Offset值。
2.1 Partition到底在物理上是什么
如果只从逻辑上理解分区,你可能会觉得"分区就是一堆消息文件"。实际在磁盘上,一个分区对应一个目录,目录名规则是主题名-分区编号,比如order-paid-0、order-paid-1。目录里存放的是一组分段的日志文件,每个segments文件大小默认到1GB就会滚动生成新的。文件命名很有意思,命名规则是该segment消息的最小Offset作为文件名,比如文件名叫00000000000000000000.log,意味着这个文件里的第一条消息Offset是0。
分区内消息顺序有保证,但Topic整体没有顺序。如果你需要全局顺序,最粗暴的做法是让整个Topic只有一个分区——但这样吞吐量会被限制在一个分区的写入能力上。Kafka给出的折中是按key路由:同一条业务链路上的消息,比如同一个订单ID对应的所有事件,通过指定key哈希到同一个分区内,这样就能保证"这个订单的事件顺序是全局有序的"。这在实际业务里已经覆盖90%的场景,没必要追求整个Topic的全局有序。
2.2 Offset:消费者的"进度书签"
每个消费者组在消费某个分区时,会记录自己已经消费到的Offset。你可以把它理解为一本书的书签:生产者不断在末尾写新页,消费者从书签位置继续往后读,读完再更新书签。这个设计的关键好处是消费者可以随时回退——比如你写了一个bug把数据消费错了,只要把Offset调回去,就能重新消费一遍,数据不会因为"被读走"就消失。
Offset的存储经历了变化。老版本里它被存在ZooKeeper中,但ZK不是为高频写设计的,消费者一多就会出现性能瓶颈。所以后来的版本把Offset提交到了一个叫__consumer_offsets的内部Topic里,默认50个分区,副本数跟随broker数量。这个Topic不在业务层面暴露,但排查堆积问题时经常需要关注它。我第一次排查大集群消费异常时,就是在这个内部Topic上发现某个segment文件所在的磁盘快满了,才定位到问题源头。
2.3 消息可以重复读,这才是"日志"的魅力
说一个和传统队列体验差异最大的点:Kafka删消息不是"消费完就删",而是按时间保留。默认log.retention.hours=168,也就是消息保留7天。在这7天内,任何消费者组都可以从任意Offset开始重新读数据,哪怕它之前已经消费到了最新的位置。
有一次某公司的数据团队做数仓重算,需要重新拉取过去三天某个用户行为Topic的全部数据。如果是传统队列,这个消息早就被消费删除了,根本没法补数。但在Kafka里,只需要给消费者组指定一个"初始Offset"或者直接用一个全新的消费者组去消费,数据就随便读。"日志可回放"这个特性,对于离线和准实时场景来说价值极高,这是Kafka和大量传统MQ最本质的功能区别之一。
| 概念 | 逻辑含义 | 物理对应 | 关键属性 |
|---|---|---|---|
| Topic | 事件的类别 | 多个分区目录的集合 | 可通过partitions参数分区 |
| Partition | 消息的有序日志流 | 一个目录下的segments文件 | 分区内有序,跨分区无序 |
| Offset | 消息在分区内的序号 | 持久化于__consumer_offsets | 支持手动重置以实现重放 |
| Replica | 分区的冗余副本 | 同一分区的多个副本目录 | 一个Leader,多个Follower |
这段"物理世界观"是整个Kafka的基础,后续所有细节——写入路径、高可用、消费组、存储清理——都是围绕它展开的。
3. Producer写入链路:一条消息是如何落盘的
看完了存储模型,现在沿着一条消息的生产链路走一遍。这个路径上有三个最容易踩坑的环节:分区器选分区、缓冲区攒批量、发送确认与重试。
3.1 分区器:为什么你的消息去了它该去的地方
Producer在发送一条消息时,会先经过分区器决定"这条消息放进哪个Partition"。默认的分区策略是:
- 如果消息指定了
partition字段,直接用指定分区; - 如果没有指定分区但指定了
key,对key做哈希取模落到某个分区; - 如果key也没指定,使用粘性分区策略,在同一个批次内随机选一个分区,用完一轮再换。
第一项和第二项比较好懂,重点说第三项。早期的Kafka是每次发送都做一次随机分区,这样每条消息都单独一个分区,批次零散,吞吐量上不去。后来改进成Sticky Partition:一批消息先落在同一个分区的缓冲区里,攒满了或者到时间了再一起发出去,这样batch利用率高,吞吐量提升非常明显。
实测下来,如果你的业务消息没有天然key,但又想尽量均匀分布,粘性分区已经足够好。真有全局均衡的强迫症,可以自己做分区器,但绝大多数场景没必要。
3.2 batch.size和linger.ms:一对容易顾此失彼的参数
Kafka的高吞吐密码是批量发送,而不是单条送达。Producer内存里有一块缓冲区(buffer.memory,默认32MB),消息先放进缓冲区,由发送线程按批次调起发送。batch.size默认16KB,表示一个批次占满16KB就立即发送;linger.ms默认0,表示即使批次没满,也立即发送。
这两参数放在一起看很有意思。linger.ms=0时,消息进缓冲区就马上发,延迟低但batch通常很小,吞吐上不去。linger.ms=5时,消息会在缓冲区多等5毫秒,让更多消息凑成批次再发,吞吐提升明显,代价是延迟多了几毫秒。
生产经验是:高吞吐场景优先调大batch.size到32KB~64KB,linger.ms设到5~10毫秒,这个组合在绝大多数场景下效果好。有一个误区我得提一下:如果单条消息本身就很大(比如超过1MB),batch.size设置再大也没用,这种场景应该先考虑压缩,或者调整消息体设计,而不是调Batch参数。我见过一个团队把batch.size调到5MB,结果OOM了,问题是他们单条消息有800KB。
3.3 acks、重试与幂等:数据不丢也不能乱序
发送确认有三级:acks=0发出去就算成功,有可能丢;acks=1Leader写入就算成功;acks=all所有ISR副本都写入才算成功。默认值是acks=all,在不能丢数据的场景下建议保持这个配置。
但acks=all也有代价。如果ISR只有一个Leader没有Follower,那all和1没区别。所以需要把min.insync.replicas设为2,意思是至少有两个副本确认成功才返回。这样即使Leader宕机,Follower手上还有完整数据。这个参数组合看起来简单,但很多生产事故都是因为只配了acks=all,却没配min.insync.replicas,等于白配。
重试机制要特别小心。retries默认是一个较大的值(比如INT_MAX附近),而enable.idempotence决定是否开启幂等。强烈建议开启幂等,它保证Producer在重试时不会产生重复数据。而且开启幂等之后,Producer内部会自动把acks调整为all,把max.in.flight.requests.per.connection调整为<=5,这些参数是"配套"的,我第一次手动配置时没注意这些联动关系,导致重复消息暴增。
重试还有个隐藏问题:重试可能导致乱序。比如你连着发了两条消息到同一个分区,第一条发送超时,第二条已经发出去了,此时第一条重试,顺序就变成2、1。解决方案就是开启幂等,它会保证单个Producer对单个分区的消息顺序不乱。理解了这一点,你就知道为什么在很多生产配置里enable.idempotence=true是缺省推荐。
4. 副本机制与Broker高可用:从多副本到ISR的演进
Broker层的高可用,本质上是把分区数据复制多份,当某台物理机挂了,另一台上还有数据可用。这部分如果理解不到位,你会遇到"明明配置了3副本,数据还是丢了"的诡异情况。
4.1 Leader与Follower不是主从复制,而是追日志
每个分区有一个Leader副本和多个Follower副本。所有生产者和消费者的读写都走Leader,Follower只是从Leader那里拉取数据、跟上进度。这里关键点有两个:
第一,Follower拉取数据不是靠Leader推送,而是Follower主动发请求去拉。拉取的频率由replica.fetch.wait.max.ms控制,默认500毫秒一次。这个设计让Follower的负载变成可控的自适应节奏,不会像推模式那样Leader耗尽全力。
第二,Leader处理Follower拉请求的方式是"返回从Leader当前写到的位置开始的数据"。如果Follower一直跟不上,它和Leader的Offset差距越来越大。什么时候判定一个Follower跟不上?由replica.lag.time.max.ms控制,默认10秒。如果Follower超过10秒没有向Leader请求数据,或者请求的Offset一直落后太多,它就会被移出ISR。
4.2 ISR:只有跟上节奏的副本才有资格做后备
ISR全称是"In-Sync Replicas",指和Leader保持同步的副本集合。这个概念很多人第一次看会忽略,但它是Kafka可靠性模型的核心。不要求所有副本都同步,只要求ISR内同步。
举个实际场景。一个Topic有3个副本,分别落在机器A、B、C上,Leader在A。如果C机器发生了长时间GC(Full GC了5分钟),它的Follower线程暂停了5分钟拉取,此时Leader的replica.lag.time.max.ms超时判定生效,C被移出ISR。此时ISR只有A和B。如果A在这时宕机,Kafka会从B中选出新Leader,B上的数据是完整的,消息不丢。
但如果连B也宕机了,ISR里只剩一个不可用的A,那这个时候还能不能继续写?取决于unclean.leader.election.enable。默认为false,表示宁可分区不可用,也不允许不同步的副本上位成为Leader。这个参数不要轻易改成true,一旦改成true,C这种落后大截的副本就能被选成Leader,所有在故障期间写入A的数据全部丢失,这在金融、结算类业务里是致命的。
我曾在生产环境遇到过ISR收缩的事故。某个大Topic的写入量翻了三倍,Follower拉取线程跟不上,ISR从3缩成了1。当时数据没丢,但可用性已变成单点风险,好在排查及时,把Topic的分区数从12扩到24,同时给Follower所在的机器加了带宽限制,ISR才稳定回到3。日常巡检一定盯着ISR数量,只要小于配置副本数,就得往"Follower拉取跟不上"这个方向去查。
4.3 从ZooKeeper到KRaft:控制面的简化
在Kafka 2.8之前,Controller的角色由ZooKeeper承担。Broker的上下线、Leader选举、元数据变更都要经过ZK。ZK本身也是一个独立系统,维护它等于集群里多养一个"调度中心",运维成本不低。
从3.3版本引入、在3.x版本开始快速成熟的KRaft模式,把Controller和元数据直接内置进Kafka自身,不再依赖ZK。KRaft模式下的Controller还是那个Controller,但不再需要独立的ZK集群,元数据通过内部日志复制协议同步。我自己的建议是:如果是新项目,Kafka版本在3.5以上就直接用KRaft模式,少一套组件,少一些故障点。已经在用ZK的老集群,如果业务稳定可以慢慢迁移,不急着动,但新集群完全没必要再引入ZK。
5. Consumer消费链路:幂等的推进器和Rebalance风暴
消费端是一个大坑密集区,几乎所有"消息堆积"和"重复消费"的故障都出在这一层。先看Consumer的基础模型:以消费者组为单位,组内成员共同消费一个Topic的所有分区,每个分区在同一时刻只能被组内的一个Consumer消费。
5.1 Consumer如何决定自己消费哪些分区
同一组内的多个Consumer在启动时会触发一次分区分配,分配算法默认是RangeAssignor,按分区范围和Consumer数量均分。后来有了RoundRobinAssignor和更推荐的StickyAssignor。
我当时带团队迁移时,把默认的RangeAssignor改成了StickyAssignor,因为Range策略在Consumer数量变化时会导致全量分区重新分配。比如原来4个Consumer各拿3个分区,其中一个Consumer挂了,Range会触发所有分区全部重新分配,3个存活的Consumer要把6个分区全部重新认领一遍,这个过程就叫Rebalance。StickyAssignor会尽量保有原来的分配关系,只把挂掉Consumer的分区匀给其他存活成员,触发面小很多。
Rebalance的破坏性可能超出你的直觉。在Rebalance期间,整个消费组的所有Consumer都会暂停消费,直至分配完成。如果集群很大、分区数很多,Rebalance耗时可能拉长到几十秒甚至分钟级。我曾经在3000个分区的Topic上触发过错误类型的Rebalance,消费暂停了近3分钟,业务侧立刻报警。
5.2 两个"心跳"参数决定了你多久被判定死亡
session.timeout.ms和heartbeat.interval.ms是一对容易设置失衡的参数。心跳就是Consumer定时告诉Broker"我还活着"。如果Broker在session.timeout.ms内没有收到心跳,就判定该Consumer失效,触发Rebalance。heartbeat.interval.ms建议设为session.timeout.ms的三分之一,比如timeout是10秒,heartbeat设为3秒左右,这样能在最大时间内及时发现故障。
但后来Kafka加了一个更重要的参数:max.poll.interval.ms。它和心跳没有关系,它管的是Consumer实际处理消息有没有超时。比如默认值是5分钟,如果Consumer处理一批消息的时间超过了5分钟(比如这批消息里有大JSON需要调用远程服务),即使Consumer心跳正常,也会被判定"失联",踢出消费组,触发Rebalance。很多"消费堆积但日志显示Consumer还在跑"的怪现象,根源就在这里——处理慢被误判为失联,分区被分给别人,自己的Consumer还在独立处理旧批次,然后两边开始重复消费。
处理方案有两条路:一是调大max.poll.interval.ms,给业务处理留够时间;二是配合使用max.poll.records,把单次poll返回的消息数量从默认的500条往下降。实践里我更推荐后者,把max.poll.records调成100左右,既控制单次处理时长,又不至于让poll频率过高。很多人宁愿堆线程,也懒得拉这两个参数,但真相是这两个参数直接决定消费端稳定性。
5.3 Offset提交位置:自动提交的坑和手动提交的优雅
消费完一条消息后,Consumer需要把Offset提交给Broker。自动提交(enable.auto.commit=true)默认每5秒提交一次(auto.commit.interval.ms=5000)。自动提交的问题在于:它是定时提交,不是处理完就提交。如果在两次提交之间Consumer宕机,重启后它会从上一次提交的位置重新消费,这一段必然产生重复消息。
在大多数允许"至少一次"语义的业务里,重复消费可以靠幂等消费来解决——也就是"消费逻辑本身具备处理重复数据的能力"。但如果业务对重复敏感(比如扣款、发券),就需要手动提交Offset,并且严格保证"拉取一批消息→处理完这批→再提交这批的Offset"。这里最容易被忽略的细节是:提交的Offset应该是本批最后一条消息的下一条位置,而不是当前已经拉取到的位置。因为如果消费者在处理中途挂了,下一次从已提交位置开始会重复整个未完成批次,而如果你提交得太靠前,会造成丢失;提交得太靠后,会造成跳过。实际代码里我用的是"处理完本批后,将offset参数设为nextOffset"的方式来避免这个坑。
6. 存储层的时间轮盘:Segment、保留策略与Compact Compaction
Kafka的存储层设计是被很多人忽略但性价比极高的一块。你把Topic当成一个"永远增长的大目录",但物理上它不能无限长,必须有一套清理机制。Kafka提供两种清理策略:delete(按时间或大小删除)和compact(按key保留最新值)。
6.1 从文件名看懂Segment的滚动机制
分区目录里那些xxx.log文件门口都有一个xxx.index(稀疏索引)和xxx.timeindex(时间索引)。index文件记录Offset和物理文件位置的映射关系,用于快速定位;timeindex则用于按时间戳查找消息。这两个索引不是逐条建立,而是稀疏索引,每隔固定字节数建立一条映射,节省内存。
日志文件的滚动由log.segment.bytes和log.roll.hours两个参数控制:达到1GB或超过一定时长就滚动生成新Segment。滚动的好处是,旧的Segment可以被安全清理,不会影响正在写入的最新Segment。当你执行"删除Topic数据"之类的操作,本质上是让Kafka删除整个分区目录,而不是逐条删消息,这也是它能快速清理海量数据的原因。
6.2 retention策略:7天不是唯一答案,按场景调
默认的log.retention.hours=168对大部分业务够用,但要注意两组场景的取舍。一是高吞吐日志型Topic,比如用户行为埋点,数据量极大,长时间保留会占满磁盘,可以调到24小时甚至6小时。二是金融交易、审计链路,合规要求保留一年以上,就得把保留时间拉长,同时确保磁盘容量按峰值写入量乘以保留天数来规划。
我在某团队做容量评估时,他们一个埋点Topic每天写入1.5TB,保留7天,需要至少11TB磁盘空间,而他们集群单节点只有2TB。最后方案是Topic按天分区、平时只保留3天、离线任务在消息落入Kafka后同步把数据转存到对象存储里。保留策略要的是"够用+留一件备份副本在便宜存储",而不是一味拉长Kafka内的保留时间。
6.3 compact策略:只留每个key的最新值
如果你维护的不是事件流,而是状态快照(比如用户画像的当前版本、配置项的最新值),那么delete策略就不合适了,因为旧版本消息已经没用了,留着只是占空间。这时启用log.cleanup.policy=compact。
压缩的过程是由后台的Cleaner线程完成的:它扫描日志,对于相同key的消息,只保留最新的一条。注意压缩是惰性的,不是实时清理;而且压缩不能精确控制某条消息什么时候被清理。所以如果你需要"按key唯一且最新",通常还得配合外部的状态存储(比如用Kafka Streams的RocksDB状态存储)来解决,而不是指望Kafka的Compact能给你实时秒查的效果。
我第一次用Compact时指望它做"实时配置中心",结果发现配置变更后旧值还残留了好几分钟。后来才明白:Compact只保证压缩完成之后的状态,不保证压缩的时间点。用它做长期存储的尾部优化可以,但实时一致性还是要靠应用层自己设计。
7. 性能神话背后:顺序写、页缓存与零拷贝
Kafka单Broker能支撑每秒几十万甚至上百万条消息的写入,很多人都听说过这个数字,但少有人真正理解它是怎么做到的。拆开来看,主要是三个设计共同撑起了这个性能天花板。
7.1 顺序写磁盘:SSD都未必干得过顺序写盘
传统消息队列把消息随机落到内存或者随机写文件,随机写性能极差。Kafka把每个分区在磁盘上的目录设计成一个顺序追加的日志,新消息永远写到文件末尾。顺序写盘在机械硬盘上可以达到每秒200MB以上,在SSD上更是能轻松跑满接口带宽,而随机写的速率可能只有它的十分之一甚至更低。
这里有个反直觉的点:很多人觉得"外加一层内存缓存"才是高性能方案,Kafka却反其道而行之,直接写磁盘。因为顺序写,没有随机寻道,写盘不但不慢,反而规避了Java堆内存的管理与GC开销。用日志做存储介质,让Kafka不需要复杂的随机读写索引,简化了整体设计。
7.2 页缓存:操作系统替你缓存了"热点数据"
Kafka写入不直接调用fsync刷盘,而是先写入操作系统页缓存(Page Cache),由操作系统在合适时机异步刷盘。消费者读数据时,如果数据还在页缓存中,根本不会发生磁盘I/O,直接走内存。
这个特性解释了一个常见的现象:你刚生产了一批消息,马上消费它,速度极快,几乎感觉不到磁盘的存在;但隔了一天再去消费旧数据,速度就明显降下来了,因为页缓存已经淘汰掉了那些老数据,真正触发了磁盘读。这套机制和"Redis缓存热数据"是一样的思路,只是Kafka把这一层完全交给了操作系统。
7.3 零拷贝Sendfile:消费者读取时省掉两次内存复制
普通的数据读流程:磁盘→内核Buffer→用户空间Buffer→socket内核Buffer→网卡。这里涉及四次上下文切换和两次内存复制。Kafka在消费者读取时使用sendfile系统调用,数据直接从内核Buffer进入到网卡发送队列,用户空间完全碰不到数据。这就叫零拷贝。
零拷贝带来的性能提升,在消费大消息体时非常明显。用同样的Topic、同样的分区数对比压测,打开零拷贝的consumer吞吐量通常提升15%~25%,CPU占用率也明显更低。这个机制不需要业务侧改任何代码,开箱即得。
7.4 一份够用的生产配置基线
最后给一份最基础的参数基线,覆盖了小规模集群(3~5个Broker)到中等规模(10~20个Broker)的生产环境。YMMV,但大致方向不会错。
| 参数 | 推荐设置 | 说明 |
|---|---|---|
num.partitions | 业务写入峰值的1/2左右 | 分区数不是越多越好,过多会放大Rebalance成本 |
replication.factor | 3 | 保证一个Broker挂掉不影响可用性 |
min.insync.replicas | 2 | 和acks=all配合使用才真正可靠 |
log.retention.hours | 业务合规要求 | 默认168小时,金融场景可提高到720+ |
batch.size | 32KB~64KB | 高吞吐场景调大 |
linger.ms | 5~10 | 批量攒发,调吞吐 |
enable.idempotence | true | Producer幂等,防重防乱序 |
max.poll.records | 100~200 | 控制单次处理时长,防误判 |
max.poll.interval.ms | 与单批处理耗时的3倍 | 设太小会踢出组,设太大Rebalance感知慢 |
session.timeout.ms | 10s左右配套心跳间隔3s | 心跳和会话超时的黄金比例 |
这些参数你不需要永远不变,但它是你知道"该往哪个方向调"的起点。调整之后务必用生产流量或压测脚本验证,不要只靠感觉。
我在实际项目中最大的体会是:Kafka的基础架构并不复杂,复杂的是每一个机制都互相牵连——改了Producer的linger.ms会影响吞吐,改了Consumer的max.poll.records会影响Rebalance频率,改了Topic的partitions会影响历史消息的顺序保证,动了任何一个开关都牵一发动全身。给你的最后一条建议:别急着改参数,先确保你的脑海里有一张完整的存储模型图,再去动任何配置。如果你现在只能记住一件事,那就记住——Partition是有序日志,Offset是进度书签,ISR是可靠底线的守卫,其他的,翻文档就行。