做大数据这一行,Kafka几乎是一门绕不开的基础课。不管你是搞数据采集、实时数仓、日志平台,还是做事件驱动架构,消息队列这层里Kafka的出场率都是最高的,面试被问的概率同样是最高。我见过不少同学把Spring Boot连Kafka的API写得飞起,可一问到副本机制、ISR、Rebalance这些理论知识,就开始支支吾吾,能说出个大概但经不起追问。这篇就是把Kafka的理论知识体系从头到尾梳理一遍,讲清楚它到底解决什么问题、架构是怎么设计的、数据怎么存储、消息怎么流转、可靠性怎么保证,顺带把容易踩坑和面试高频的几个点一起拆开讲透。适合刚入门的朋友搭建知识框架,也适合有几年经验的人查漏补缺。
1. Kafka在解决什么问题:消息队列的核心价值
1.1 没有消息队列时的系统痛点
先把场景拉回到最朴素的状态。假设你有一个电商系统,用户下单后,后端服务需要做三件事:写订单库、发短信通知、更新积分。最直接的做法就是在下单接口里同步调用这三个逻辑。听起来没什么问题,但用户量一旦上来,痛点立刻暴露。
首先是耦合问题。发短信的供应商今天接口超时,下单接口就得跟着遭殃,用户那边表现为下单按钮一直转圈。积分系统的数据库如果慢查询多了,订单服务也得陪着一起慢。三个系统之间的故障是直接传染的,谁出问题,谁拖垮全家。
其次是流量冲击问题。平时每秒1000个请求的系统,遇到大促瞬间飙到每秒几万。数据库连接池扛不住,CPU直接打满,然后就是一连串的超时、报错、雪崩。但如果你去看这些流量,里面真正需要立刻处理的业务请求其实没那么多,大量都是可以排队慢慢处理的。用户只关心下单成功没成功,至于短信晚半分钟发,积分晚几分钟到账,体验上差异并不大。
Kafka这类消息队列就是在这中间加了一层缓冲。生产者只管把消息丢进队列,消费者按自己的速度去拉取处理。上游和下游不再直接对话,而是通过一个中间件解耦。订单服务不需要知道短信服务谁在消费,也不用等它处理完;大促流量冲进来的时候,消息先堆积在Kafka里,消费者慢慢消费,系统不会瞬间被压垮。这就是消息队列最核心的两个价值:异步解耦和削峰填谷。
1.2 Kafka在消息队列家族中处于什么位置
消息队列不是只有Kafka一个选择。常被拿来对比的还有RabbitMQ、RocketMQ、Pulsar。很多人纠结选型,其实搞清楚几个关键维度就行。
| 特性 | Kafka | RabbitMQ | RocketMQ | Pulsar |
|---|---|---|---|---|
| 吞吐量 | 极高(百万级/秒) | 中等(万级/秒) | 高(十万级/秒) | 高(十万级/秒) |
| 消息模型 | 分区日志模型 | 队列/交换机模型 | 队列模型 | 分区日志模型 |
| 消息顺序 | 分区内有序 | 单队列有序 | 队列内有序 | 分区内有序 |
| 消息堆积 | 非常强 | 较弱 | 较强 | 非常强 |
| 延迟 | 毫秒级 | 微秒级 | 毫秒级 | 毫秒级 |
| 生态成熟度 | 极高 | 高 | 中高 | 中 |
从表里能看出来,Kafka的核心优势是吞吐量和堆积能力。它天生就是为海量日志、海量流式数据设计的。RabbitMQ虽然延迟低、功能灵活,但在大数据量面前扛不住堆积,更适合业务系统内部的消息流转。RocketMQ是阿里开源的产品,金融场景用得多,可靠性设计很完善,但社区和生态不如Kafka活跃。Pulsar是后起之秀,存储计算分离的架构很先进,但普及度还不够。
所以如果你的场景是日志收集、用户行为埋点、实时数仓的数据管道、流式计算的数据源,Kafka就是最稳妥的选择。这也是为什么几乎所有大数据技术栈里,Kafka都是标配。
1.3 Kafka的核心设计理念
Kafka的名字来源于一位作家,但它的设计理念跟名字没什么关系,核心就三个关键词:分布式、分区、顺序写。
分布式没什么好说的,多台机器组成集群,横向扩展,一台挂了其他机器顶上。但Kafka的分布式有个特点,它不是把数据随机散落在各台机器上,而是通过分区(Partition)来实现并行。一个Topic的数据被拆成多个分区,每个分区独立存储、独立被消费者拉取,这样并行度就上去了。
分区这个设计是理解Kafka一切特性的钥匙。消息顺序、负载均衡、数据冗余、消费者的并行消费,全部建立在分区这个基础之上。后面我会专门展开讲。
顺序写这个理念更关键。传统消息队列依赖随机读写磁盘,性能上不去。Kafka发现了一个规律:如果要追求吞吐量,顺序写磁盘比随机写内存还快(在特定场景下)。所以Kafka把所有消息都追加写入日志文件,像写日志一样一条条往后写,不修改、不删除,配合操作系统的页缓存,把磁盘IO的性能榨到了极致。这也是Kafka能扛住百万级每秒吞吐的底层原因。
2. Kafka的整体架构与核心术语
2.1 一条消息从生产到消费的完整链路
Kafka的架构可以用一句话描述:生产者把消息写到Broker上的Topic里,消费者从Topic里拉取消息。但这句话背后牵扯出一堆角色。
Producer:消息生产者,负责把消息发到Kafka集群。 Broker:Kafka集群中的一台服务器,负责接收和存储消息。 Topic:消息的逻辑分类,可以理解成数据库里的表。 Partition:Topic的物理分片,一个Topic可以有多个Partition。 Consumer:消息消费者,负责从Topic拉取消息。 Consumer Group:消费者组,一组消费者协同消费一个或多个Topic。 Offset:消息在Partition内的偏移量,相当于消息的序号。
完整链路是这样的:Producer连接Kafka集群,根据消息的key通过分区器计算出这条消息应该写到哪个Topic的哪个Partition,然后发送到对应Broker。Broker收到消息后写入日志文件,并根据副本配置同步给其他Broker上的副本。Consumer启动后向Kafka请求属于自己的分区,通过维护Offset知道上次消费到哪了,然后继续拉取新消息,处理完之后提交位移。
这个链路里有两个容易被忽视的细节。
第一,Kafka的消费者不是Broker推数据,而是消费者主动拉数据。这跟很多消息队列不一样。推模式的优点是实时性好,缺点是消费者处理不过来的时候Broker不知道,容易把消费者压垮。拉模式让消费者自己掌控消费速度,处理不过来就少拉点,处理得快就多拉点,天然自带背压能力。
第二,Broker并不记录每个消费者消费到哪了,这个信息由消费者自己保存和提交。所以在Kafka里,"消息有没有被消费"这件事本质上是由消费者说了算的,Broker只负责存数据。这个设计大大减轻了Broker的负担,但也带来了一些位移管理上的复杂性,后面讲Rebalance的时候会说。
2.2 Topic、Partition、Offset与消息顺序
先打个比方。Topic像一个文件夹,Partition像是文件夹里的一本本笔记本,Offset就是笔记本上写的页码。Producer往哪个笔记本上写,取决于分区策略;Consumer读的时候,也指定了读哪个笔记本的第几页。
一个Topic有多个Partition,每个Partition在物理上是一个不断增长的日志文件。消息进入Partition后会被分配一个递增的序列号,这就是Offset。注意,Offset只在Partition内有效,不同Partition之间没有可比性。Partition 0的Offset 100和Partition 1的Offset 100,完全不是同一条消息。
这里必须说清楚一个高频混淆点:Kafka的消息顺序保证。很多文章说"Kafka保证消息有序",这个表述是不严谨的。准确的说法是:Kafka保证的是单个Partition内的消息严格有序,不保证跨Partition的有序。生产者按照顺序往分区0写了A、B、C三条消息,消费者读取时一定是A在B前、B在C前。但如果A进了分区0、B进了分区1,那消费者读到的顺序就没有任何保证了。
所以实际工程里,如果业务严格要求消息全局有序,最简单的做法是把这个Topic的分区数设置为1,用消息的key把所有消息路由到同一个分区。代价是并行度受限,一个分区只能被一个消费者消费。大部分场景不需要全局有序,只需要同一类消息有序就行,比如同一个订单的事件必须按顺序处理,那用订单号作为key,所有相同订单号的消息都会进入同一个分区,自然就保证了局部有序。
2.3 Partititon的副本机制与Leader选举
光有分区还不够,分区数据如果只存在一台机器上,机器一挂整个分区就丢了。所以Kafka给每个Partition配置了多个副本(Replica),分布在不同的Broker上。
副本有两种角色:Leader副本和Follower副本。所有读写请求都只走Leader副本,Follower副本从Leader那里同步数据,充当热备。为什么读写都走Leader?因为如果读写分散到多个副本,就会出现数据一致性问题——某个副本还没同步完就被读到了旧数据。Kafka选择牺牲一部分读扩展性,换取"读写一致"这个非常干净的特性。
这里有个面试特别喜欢追问的细节:Leader挂了怎么办?Follower顶上,谁说了算?答案是Controller。Kafka集群里有一个特殊的Broker叫Controller,它负责管理整个集群的元数据,包括分区Leader的选举。Controller通过ZooKeeper(新版本也可以用KRaft模式替代)感知Broker存活状态,一旦发现某个分区的Leader挂了,就从ISR集合里选一个新的Leader。
ISR全称是In-Sync Replicas,指"跟得上Leader节奏的副本集合"。这是一个非常精妙的设计。Leader维护了一份ISR列表,里面的Follower都是跟自己保持同步的。如果某个Follower同步慢或者断了,Leader会把它踢出ISR;等它恢复并且追上进度了,再把它加回来。只有ISR里的副本才有资格被选举为新的Leader。
为什么需要ISR而不直接指定所有副本都有资格当选?因为如果允许慢节点当选Leader,它可能缺少大量数据,当选后消费者读到的数据会明显滞后甚至缺失。ISR机制保证的就是:新Leader至少拥有Leader在某个时间点确认过的数据。
2.4 HW与LEO:副本同步的两个关键水位
讲ISR必然要提到HW和LEO,这两个概念在Kafka数据一致性里非常核心。
LEO(Log End Offset)是日志末端偏移量,表示这个副本当前最后一条消息的下一个位置。HW(High Watermark)是高水位,表示"所有ISR副本都已经同步到了这个位置",消费者只能读到这个位置之前的数据,HW之后的数据对消费者不可见。
我举个例子。Leader副本的LEO是100,Follower A同步到了98,Follower B同步到了100。那么Leader的HW就是98,因为只有98这个位置,所有ISR副本都同步过了。消费者读取的时候,最多只能读到Offset 97的消息,98之后的两条消息虽然Leader已经写入了,但对消费者不可见。
为什么要设置HW这个水位线?为了防止消费者读到"可能会丢失"的数据。假设消费者已经读到了99,这时Leader挂了,新Leader是只同步到98的Follower A,那么99和100这两条消息就丢了。如果之前允许消费者读到99,就相当于消费者读到了"幻影数据"。HW机制通过限制消费者的可见范围,保证了"所有被消费者读到的数据,在发生故障时都不会丢"。这是一个牺牲可用性换一致性的经典设计。
这里有个细节值得深入理解:Producer的acks=-1(全部确认)机制,本质上就是等ISR里所有副本的LEO都推进到这条消息之后才返回成功。也就是说,acks=-1保证的是消息进入了ISR中的所有副本,而不是"进入了HW"。在极端情况下,如果Leader写入了消息但还没来得及等同步就挂了,消息也可能确认成功但最终丢失,这就是所谓的"确认丢失"问题。新版本的Kafka引入了一个特殊机制来兜底这个问题,后面讲生产者可靠性的时候再展开。
3. 生产者工作原理:消息是怎么写进去的
3.1 生产者发送消息的完整流程
很多人对生产者的认知停留在"调用send方法就完事了",其实背后还有一整套流程。一个完整的生产者发送流程是这样的:
第一条消息进来,Producer先经过拦截器和序列化器,把对象转成字节数组。然后进入分区器,根据key计算目标分区。接着消息不会立刻发出去,而是进入一个本地缓冲区(RecordAccumulator),攒批。
攒批是Kafka提升吞吐量的关键手段。默认情况下,把多个消息打包成一个批次(Batch),发送给Broker。这里有两个参数控制攒批行为:batch.size(批次大小,默认16KB)和linger.ms(等待时间,默认0毫秒)。如果消息来得快,一个批次很快攒满就发出去;如果消息来得慢,即使批次没满,到了linger.ms也会发出去。这样可以避免过高的发送延迟。
Batch到达Broker后,Broker把整个批次写入对应分区的日志文件,然后根据acks配置决定如何返回响应。如果acks配置为all,Broker要等ISR里所有副本都同步完成,才会返回成功。Producer收到ack之后,把这条消息从缓冲区移除。
如果发送失败怎么办?Producer有重试机制。retries参数控制重试次数,默认值是Integer.MAX_VALUE,这在2.1版本之后其实是无限重试。重试是Kafka对待"瞬时故障"的兜底方案,但重试也会带来消息重复和乱序的问题。比如Producer发送Batch到分区0,Broker写成功了但ack响应超时了,Producer以为失败了就重发,结果同一个Batch被写了两次,消费者就收到了重复消息。幂等机制就是用来解决这个问题的,后面会说。
3.2 分区器与key:消息是如何选择Partition的
分区器听着高大上,功能其实很朴素:给一条消息决定它应该进哪个分区。默认的分区策略是:如果消息指定了key,对key做哈希,根据哈希值对分区总数取模,映射到具体分区;如果没指定key,就使用粘性分区策略,随机选择一个分区,并尽量把一个批次的消息都发往同一个分区。
为什么默认策略要这么做?如果每条消息都随机选择分区,那么一个批次里的消息可能被发往不同分区,批次就攒不起来,吞吐量下降。粘性分区分区策略优先把一个批次填满,填满了再切换下一个分区,这样能最大化利用批量发送的优势。
理解了分区器,就能明白key的工程意义。key不仅是为了传输数据,更重要的是控制消息的路由。同一个key的所有消息,一定进入同一个分区。这意味着什么?意味着同一个key的消息天然就是有序的。所以如果你的业务有"同一用户的操作必须按顺序处理""同一订单的事件必须串行消费"这类需求,用用户ID或订单ID作为key,问题就解决了。
但用key做分区有一个坑:如果某个key的消息量特别大,会导致个别分区数据倾斜,其他分区闲着。比如某个热点用户疯狂下单,他的所有消息都进了分区0,分区0的积压量就把其他分区甩开几条街。应对办法有很多,最简单的方案是给热点key加随机后缀打散分布,或者自定义分区器实现更均衡的策略。
3.3 acks参数与消息可靠性三档位
acks可以说是Kafka生产者最重要的参数,它决定了一条消息要"确认到什么程度"才算是发送成功。一共三个取值,正好对应三种可靠性级别。
| acks值 | 含义 | 可靠性 | 性能 |
|---|---|---|---|
| 0 | 发送即成功,不等任何确认 | 最弱,可能丢消息 | 最高 |
| 1 | Leader写入成功即确认 | 中等,Leader挂了可能丢数据 | 中 |
| all(-1) | ISR所有副本同步完才确认 | 最强,极少丢数据 | 最低 |
acks=0的场景很少,除非是日志采集类的场景,丢几条监控日志无所谓,但需要极致的写入性能。acks=1是大部分低延迟业务场景的默认选择,Leader确认写入就算数。这里有一个隐患:Leader刚写完还没同步给Follower就宕机了,这条消息在故障恢复后就丢了。用户看到的返回是成功的,数据实际上已经丢失。这就是"部分丢失"的情况。
acks=all是最常用的可靠性配置,但要注意一点:acks=all的语义是"ISR里的副本都同步完成",如果ISR里只剩Leader一个副本(其他副本都掉线了),acks=all实际上退化成acks=1。所以生产环境要配合min.insync.replicas参数使用,它规定了ISR里至少有几个副本才允许写入。比如设置min.insync.replicas=2,那么当ISR只剩Leader一个时,Producer写入会被拒绝,用失败来换取"不丢数据"的确定性。
3.4 幂等机制:消息重复发送的兜底方案
前面说了,重试可能导致重复消息。Kafka从0.11版本开始支持幂等生产者,解决的就是这个"恰好一次"的写入问题。
幂等机制的原理不复杂。Producer启动时向Broker申请一个PID(Produce ID),每发送一个批次的消息,Producer都会给批次编号(Sequence Number),保证同一个PID下每个分区的批次号是单调递增且不中断的。Broker端会记录每个分区收到的最大批次号,如果新来的批次号小于等于已记录的批次号,说明是重复发送,直接丢弃。
这样描述可能有点抽象,换个说法。你去快递站寄包裹,快递员给你一个单号,你的包裹每发一次单号就加一。快递站收到包裹后发现单号比之前记录的小,就知道这是重复投递,直接扔掉。这个机制保证的是:Producer重试多次,Broker只会保留一条消息。
幂等机制的启用配置很简单,设置enable.idempotence=true即可。新版Kafka默认开启。但要注意,幂等只能保证单分区、单会话内的不重复,跨分区或者Producer重启之后(PID会变),幂等就失效了。如果需要跨分区的严格不重不漏,需要用事务API,但事务的代价是性能下降和更高的复杂度,一般场景用幂等就足够了。
4. 消费者与消费组:消息是怎么读出来的
4.1 消费组模型:一个分区只能被一个成员消费
Kafka的消费模型跟大部分消息队列不一样。RabbitMQ是每条消息只被一个消费者消费,Kafka是同一个消费组内的消息只被一个消费者消费,但不同消费组可以各自消费同一批消息。
这么说还是绕,直接上例子。Topic A有4个分区,消费组G1有3个消费者,那么这3个消费者会共同消费Topic A的全部消息,分工可能是:消费者1消费分区0、1,消费者2消费分区2,消费者3消费分区3。注意,同一个分区不能同时分配给两个消费者,这是Kafka的硬性约束。如果一个消费组有7个消费者,但Topic只有4个分区,那就有3个消费者是空闲的。
为什么要有这个约束?核心原因是Offset的维护。如果同一个分区的消息被多个消费者同时处理,那这个分区的位移该由谁提交?两个消费者各自提交各自的进度?那消息就乱套了。所以Kafka干脆做了一个强约束:一个分区同一时刻只能匹配给一个消费者。
这里有一个大坑,也是面试官最喜欢的陷阱题:如果想让同一个消息被多个消费者各自独立处理一遍,怎么办?答案是使用多个不同的consumers group订阅同一个topic。比如一条订单消息产生后,数据仓库团队要消费它来做分析,实时风控团队也要消费它做规则判断,两边用一个topic,groupA和groupB各自消费,达到的语义就有了一次消息被不同团队独立接收的效果。这和RabbitMQ的"广播路由"不同,Kafka的广播是依赖多个组实现的。
4.2 Rebalance:消费组的分区重分配机制
消费组里成员的增减、订阅Topic的变化、分区数的变化,都会触发一次Rebalance,也就是把Topic的全部分区重新分配一遍给当前存活的消费者成员。
我先描述一下Rebalance发生了什么。组内有一个消费者被选为Group Leader(不是Broker里的Controller,别混),它从Broker拉取当前分区的全量列表,再根据几种分配策略(如RangeAssignor、RoundRobinAssignor、StickyAssignor)把分区分给各个成员,最后把分配方案广播给全组。分配完成之前,消费者是拿不到任何新消息的。
3.1版本之前的Rebalance是"停止整个世界"的模式:所有消费者都停止消费,重新协商。这种旧版机制的问题非常明显:如果消费者数量很多,分区数量很大时,每次Rebalance可能产生"惊群效应",消费者频繁停止消费,消费延迟猛涨。
新版本Kafka引入了"增量式再平衡"和"合作式StickyAssignor",允许Rebalance只影响需要调整的分区,而不是全组停摆。所以团队在升级Kafka版本时有一种优化就是从旧版消费者迁移到新消费协议,消费延迟通常会明显改善。
踩过的坑提醒:生产环境最怕出现"Rebalance风暴"。某次线上集群挂了几个Broker,业务方为了快速扩容直接往消费组里加了十几台消费者机器,结果每次新增消费者都触发Rebalance,消费组反复震荡,消息消费延迟从秒级飙到分钟级。所以消费组的扩容建议一次扩到位,而不是一台一台慢慢加;同时尽量保持成员数量和分区数量之间的匹配关系,避免频繁Rebalance。
4.3 位移提交:自动提交与手动提交的取舍
消费者消费完消息后,需要提交Offset,告诉Broker"我消费到这儿了"。这里有两个位置可以提交:自动提交和手动提交。默认配置是enable.auto.commit=true,消费者每5秒自动提交一次当前消费的位移。
自动提交最大的问题是"提交时机不可控"。你消费了Offset 99的消息,还没来得及提交,触发了Rebalance,消费组重新分配后,新消费者可能又从头消费了一遍这100条消息。数据重复处理,幂等性就要靠业务端自己去兜。
生产环境比较推荐的做法是关闭自动提交,使用手动提交。手动提交又分成两种:
同步提交commitSync:提交后等待Broker返回结果,成功才接着消费下一条。优点是不会丢位移,缺点是提交期间阻塞,吞吐量下降。 异步提交commitAsync:提交调用立即返回,不等待结果。优点是吞吐高,缺点是如果提交失败,回调里如果不做处理,位移就丢失了。
我一般建议的做法是:正常消费流程用异步提交保证吞吐,在程序关闭或Rebalance前的钩子里用同步提交做一次兜底,确保退出前位移尽量是新的。至于至少一次(At Least Once)和最多一次(At Most Once)的语义:
至少一次:消息被消费后,位移先提交后消费,或者消费失败不提交位移,下次重新消费。Kafka默认就是至少一次语义,也是最常见的选择,代价是可能产生重复消息,需要业务端幂等。
最多一次:先提交位移再消费,如果消费失败,位移已经提交了,消息就丢了。一般不用,除非业务对丢失不敏感。
精确一次(Exactly Once):需要结合事务和幂等,复杂度高,Kafka在流处理场景里支持,普通的消费场景很少做。
4.4 消费者拉取模型与max.poll.records
消费者通过poll方法主动从Broker拉取数据。每次poll返回一批消息,这批消息的大小由max.poll.records(默认500)控制。
拉取模型带来一个经典问题:如果消费者处理这批消息的时间过长,超过了max.poll.interval.ms(默认300秒),Broker就会认为这个消费者挂了,把它踢出消费组,触发Rebalance,把它的分区分给其他人。我之前遇到过一个坑:消费者拿到500条消息,每条消息要调用一个第三方接口,第三方接口偶尔超时到5秒,500条消息就耗时2500秒,远超300秒的限制。结果消费者被反复踢出,消费组里频繁Rebalance,最终消息全部积压。
解决办法有几个:调小max.poll.records,比如改成100,减少单批消息量;调大max.poll.interval.ms,给业务处理留更多时间;最治本的是把"拉消息"和"处理消息"解耦,poll到消息后丢进本地线程池异步处理,这样poll方法本身永远不会超时,但要注意:异步处理时要保证位移提交的顺序不能乱,不然会有消息丢失的风险。
5. Kafka的存储设计:为什么它能扛住海量数据
5.1 分段日志与稀疏索引:百万消息的存储骨架
前面讲过,Kafka每个Partition在物理上对应一个日志文件。但如果一个分区累积了几百万条消息,全部塞进一个文件里,检索的效率会非常差。日志文件一直在增长,接近无限大,怎么处理?
Kafka用的是分段策略。一个Partition的日志文件会被拆成多个Segment段文件,每个段文件大小通常由log.segment.bytes配置决定,默认1GB。当文件大小达到阈值,Kafka就关闭当前文件,新建一个段文件继续写。每个Segment文件有一个基准偏移量作为文件名,比如00000000000000000000.log、00000000000000368675.log,文件名就是这个段里第一条消息的Offset。
有了段文件,Kafka查找某个Offset时,先根据文件名定位到所在的段,再在段内部进行查找。段文件内部的消息是有序排列的,但Kafka不会为每条消息都建索引,而是稀疏索引,每隔一段字节(默认4096字节)才建立一条索引记录。这个设计大幅缩小了索引占用的内存空间,代价是定位到具体消息时需要做一次小的线性扫描,但4096字节的扫描开销对磁盘IO来说几乎可以忽略。
为什么用稀疏索引而不是全量索引?因为全量索引的空间开销极大,而且对于顺序追加为主的日志场景来说,大部分检索其实用不上索引。Kafka最典型的消费场景是"从上次消费的Offset继续往下读",这种顺序读根本不需要随机查找索引。稀疏索引只要能把"从哪个位置开始读"定位出来就够了。
5.2 顺序写与页缓存:把磁盘的性能榨干
传统的消息中间件追求随机读写的能力,因为消息随时可能会被删除或修改。Kafka从一开始就想明白了:日志就是只追加写(Append-Only),不修改、不删除,所以它只需要顺序写就够了。
顺序写为什么快?普通的机械硬盘,随机写需要频繁移动磁头,每次寻道要8~10毫秒,而顺序写的时候磁头基本不需要移动,一样的时间能写的数据是随机写的几十倍。固态硬盘虽然没有磁头,但顺序写对闪存颗粒的磨损更小,也能充分利用写入缓存带宽。
另一个关键设计是页缓存。Kafka写数据时并不直接落盘,而是写入操作系统的页缓存(Page Cache)。操作系统会在合适的时候把脏页刷到磁盘。读数据的时候,消费者也是优先命中页缓存,而不需要真正读磁盘。对于最新写入的消息,消费者读取的全是内存速度,这就是为什么Kafka对"新消息"的消费延迟能保持在毫秒级。
这里有一个很多人误解的点。Kafka并发高不是因为它在磁盘上做了什么魔法,而是因为它把磁盘和内存的特性利用到了极致。新数据写页缓存,热数据读页缓存,只有冷数据才真正碰磁盘。同时Kafka还做了消息批量写入和批量读取,把多个小IO合并成一个大IO,进一步降低了磁盘IO次数。
5.3 零拷贝:减少数据拷贝的发送路径
消费者从Kafka拉数据,数据的路径是:磁盘 → 页缓存 → 应用内存 → Socket → 网卡。传统实现里,数据从内核态拷贝到用户态,再拷贝回内核态发送,一来一回要经过4次数据拷贝。
Kafka利用操作系统的sendfile系统调用实现了零拷贝。数据从磁盘读到页缓存后,直接通过网卡发送,跳过了两次用户态的拷贝。具体来说,消费者请求数据时,Kafka会先定位到日志文件中的消息区间,然后调用sendfile,让操作系统直接读取这一段文件内容并通过Socket发送出去,全程不经过Kafka应用进程。
零拷贝带来的提升其实也是间接的:它降低了CPU的占用率,释放了CPU去做别的处理,让单台Broker能支撑更多的并发连接。对于追求高吞吐的Kafka来说,这是压榨机器性能的又一个重要手段。
6. 理论知识落到实战:高频问题与排查思路
6.1 消息丢失:到底是在哪个环节丢的
消息丢失是生产环境里最让人头疼的问题,而且大部分情况下不是Kafka丢的,是配置和使用方式不对。我见过一个案例:某团队收集容器日志,生产者配置了acks=1,某天一台Broker机器磁盘损坏,当时Leader正好在这台机器上,消息写完Leader后还没同步就宕机了,大约20万条日志消失,排查了两天才定位到问题。
排查消息丢失问题,要从生产、Broker、消费三个环节挨个过一遍。
生产环节:acks是不是0?如果0则一丢一个准;有没有开启重试?某些报错如果幂等平均配置不合理,Producer会直接抛出异常而放弃发送;分区器有没有可能把消息路由到不存在的分区(比如自定义分区器写错了)。
Broker环节:min.insync.replicas配了没有?是不是单个副本还在持续接收写入?副本复制线程有没有异常?Broker磁盘是不是满了?磁盘满了副本就同步不上去,ISR一直在收缩。
消费环节:位移是不是自动提交?业务逻辑有没有吞掉异常?消费者消费了消息但没处理完就提交了位移,看起来消息没丢,实际上等于丢了。
最容易忽视的一个点:replication.factor设为1。生产环境单个副本就是个巨型炸弹,即使MinISR配得再合理,单副本也没有任何冗余可言。日志分区副本数不满足要求时,配置最好是能在集群维度做强制校验。
消息丢失的三句话排查口诀:先确认Producer已经处理成功,再确认副本数足够有余地,最后确认Consumer提交是对的。
6.2 消息积压:消费速度赶不上生产速度
消息积压是另一个高频问题。积压的直接表现是Consumergroup的Lag(消费者Lag)持续上涨,积压的根源无外乎三个方向:生产太多了、消费太慢了、消费者挂了。
对策第一件事是看消费者是不是真的还活着,如果消费者进程已经挂了,先恢复它,Lag会自然回落。这个听起来很简单,但实际排查中经常发现消费者的机器在跑,但max.poll.interval.ms超时被踢出组了,Rebalance一直没完成,消息在积压但看起来消费组还是一堆消费者。
如果消费者本身是健康的,只是单纯消费能力不足,那优先做的是提升并行度。一个分区只能被一个消费者消费,所以想靠增加消费者数量来提升消费速度,前提是先增加分区数。一个3分区的Topic,哪怕你起了10个消费者,能干活的最多也只有3个。扩容的正确姿势是:把分区数逐级扩大,同时同步扩Consumer,这样才能线性提升消费速度。
但在扩容之前,先看一眼业务逻辑里有没有耗时的点。反复调第三方接口、个别流量异常放大到数据库同步,这些慢操作往往才是瓶颈。我自己处理过的一次积压,是某个消费线程调了外部接口,外部接口内部有个死循环导致单线程卡死,其他线程正常消费但整体还是跟不上。把那个外部调用加上超时熔断之后,积压就缓解了。
如果积压的量实在太大,实在等不及水平扩容了,还有一种临时办法:用一个临时Topic,让原消费者快速把积压的消息转存到临时Topic,然后起一批新的消费者专门消费临时Topic,相当于把积压任务拆给多套消费组并行处理。这个方案治标不治本,但能在分分钟内把Lag压下去。
6.3 消息乱序:罪魁祸首多半出在重试
前文提过:同一个分区的消息遵循FIFO顺序,但如果生产者启用了重试,就可能打破这个顺序。
假设Producer往分区0写了三条消息A、B、C。B发送失败,进入重试;A和C已经正常发送。这时候重试的B如果在这之后才发送成功,Broker里消息的物理顺序就变成了A、C、B,消费者读到的顺序自然也就是A、C、B,跟生产者发送的原始顺序不一致。
解决办法有两个方向:
方向一:设置max.in.flight.requests.per.connection=1。这个参数表示单个连接上最多有多少个未确认的请求。设为1意味着前面的请求没有返回,后面的请求不能发出,从源头避免了重试导致的乱序叠加。但这会严重降低吞吐量。
方向二:开启幂等。幂等机制本身要求同一个分区的消息在Producer和Broker之间按顺序发送,因此Kafka在开启幂等后会默认限制max.in.flight.requests.per.connection不能超过5,并且在Broker端做了序号校验。某种程度上,幂等和顺序保持是一对伴生配置——只要你要求数据不重复,Kafka会顺手保障你这边的顺序安全。
实际应用里经常被问:用户发了一条消息,被重试到不同的分区怎么办?那就是乱序,因为跨分区的顺序Kafka本来就不保证。所以先想清楚:业务上需要的是哪个维度的顺序,用Key保证路由到同一分区,然后开幂等,再把max.in.flight.requests.per.connection调成合理范围,这个组合下来顺序问题基本就能解决。
6.4 理论怎么转化为实战功力
很多人理论知识背了一堆,落地时还是无从下手,是因为缺乏一个"从概念到参数"的映射感。比如你知道ISR机制,但不知道某个Topic的ISR怎么查看,那理论知识就是死知识。
举几个常用命令示例。
查看Topic的分区分布和副本情况:
kafka-topics.sh --describe --bootstrap-server localhost:9092 --topic order-events输出里能看到Topic有几个分区、每个分区的Leader在哪台Broker、AR、ISR都包含了哪些副本。如果ISR列表明显比AR短,说明有副本同步跟不上。
查看消费者的消费进度和Lag:
kafka-consumer-groups.sh --describe --bootstrap-server localhost:9092 --group order-group这个命令能直观看到每个分区的当前Offset、LogEndOffset和Lag,排查积压问题必用。
修改主题的分区数和副本数:
kafka-topics.sh --alter --bootstrap-server localhost:9092 --topic order-events --partitions 8 kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --reassignment-json-file reassign.json --execute扩容前后要关注Rebalance对在线业务的影响,尽量在低峰期操作。
我的建议是边看理论边拿几台机器搭个集群,把上述概念全验证一遍。你亲手把某个分区的副本停掉,再启动,观察ISR的变化;你亲手把消费者的max.poll.records调小,制造超时,观察Rebalance怎么发生。这些实验做完之后,书本上的概念才真正变成你自己的东西。
最后再说一个心得:Kafka理论里的很多设计,本质上都在做"取舍"。它为了吞吐量牺牲了跨分区顺序和全局原子性,用ISR和HW在一致性和可用性之间划定了一条清晰的边界,把消费位移的控制权交给了客户端换取灵活性。理解了取舍,比记住一堆参数名要有用得多。平时排查问题的思路也会清晰很多——先分清当前场景到底优先保吞吐还是保一致性,再去看配置和机制是否匹配,大概率方向就不会跑偏。