Kafka这个技术,这几年几乎成了后端分布式系统的标配。我见过不少团队,上来就把Kafka当成一个“能收能发的消息管道”,结果线上出了问题根本不知道从哪里查起。这篇文章,是我把Kafka从入门到精通的完整路径整理成的一份实操笔记,覆盖核心概念、单机搭建、第一行代码、可靠性调优、积压排查,以及我踩过的那些坑。不管你是第一次听说Kafka,还是已经用了一段但一直没把原理弄透,按着这个顺序走一遍,基本能把Kafka的脉络理清楚。
1. 先搞清楚:Kafka到底在解决什么问题
1.1 从一次“全链路同步调用”的崩溃说起
想象一个最常见的业务场景:用户下单,订单系统要调用库存系统扣库存,调用积分系统加积分,调用消息系统发通知。如果这些调用全部是同步的,只要其中一个系统响应变慢,订单系统就要一直干等,数据库连接池很快被打满,请求越积越多,雪崩就是几分钟的事。
这就是典型的耦合问题。多个系统之间直连,每一次调用都强依赖对方的可用性和性能。更麻烦的是,当大促流量瞬间冲进来的时候,订单系统无法通过调整自身来扛住流量,因为流量峰值是下游系统决定的,不是上游系统决定的。
Kafka在这里的价值,就是充当一个“缓冲层”。订单系统把订单事件写入Kafka,下游系统按自己的节奏去消费。订单系统不需要关心库存系统当前是不是健康的,库存系统也不用为了迎合大促流量去盲目扩容。这种异步解耦加削峰填谷的能力,正是Kafka最核心的价值所在。
1.2 为什么偏偏是Kafka能做到高吞吐
很多人把Kafka当成普通消息队列来用,却忽略了它和传统MQ的本质区别。Kafka的设计目标就是处理海量数据,它能在普通服务器上支撑每秒几十万甚至上百万条消息的写入,靠的并不是什么魔法,而是几个非常朴素的设计决策。
第一个是顺序写磁盘。Kafka的消息写入时,是追加到分区日志文件的末尾。对机械硬盘来说,顺序写的速度可以接近内存,而随机写慢得让人抓狂。Kafka把随机写变成了顺序写,把性能瓶颈直接拉开了一个数量级。第二个是页缓存机制。Kafka读写消息会大量利用操作系统层面的页缓存,生产端写进去的数据,消费端读的时候可能直接从缓存里就拿到了,连磁盘都没碰到。第三个是零拷贝技术。消费端读取消息时,Kafka避免数据在用户态和内核态之间来回拷贝,减少CPU开销,进一步压榨系统性能。
理解了这三点,你就能明白为什么Kafka特别适合日志收集、埋点上报、行为追踪这类海量数据场景,而不太适合只为了“发一条短信通知”这种低频场景。选型的时候,先想清楚数据量,再决定要不要上Kafka。
2. 十分钟理解Kafka核心概念
2.1 用快递分拣仓库理解Topic、Partition、Offset
Kafka的概念,如果只看抽象定义会绕晕,但用快递分拣仓库来类比就非常好懂。
假设你经营一家快递中转仓。所有包裹源源不断送来,你不可能混在一起乱堆,于是按目的地分到不同的传送带上,每一条传送带就是一个Topic。用户下单的消息去“订单Topic”,库存变动的消息去“库存Topic”,互不干扰。
但某一条传送带如果量特别大,一条带子根本处理不过来,怎么办?你把它拆成多条并行的分拣通道。每一条通道就是一个Partition(分区)。Kafka里一个Topic可以划分成多个分区,分区内部是有序的,但不同分区分区之间不保证顺序。
那分区内部怎么保证有序?靠Offset。Offset就是消息在每个分区内的序号,它只增不减,就像传送带上的包裹编号。消费端读完一个包裹,就记住当前的编号,下次从下一个编号继续读。正是因为Kafka用Offset而不是用“删掉消息”的方式来标记消费进度,Kafka的消息才能被多个消费者读取、才能回溯消费历史,这是Kafka和很多“读一条删一条”的MQ最大的不同。
2.2 副本、Broker和消费者组是干什么的
Kafka的服务器节点叫Broker,多个Broker组在一起就是集群。每个主题下的分区,为了不因为某台机器宕机就丢数据,会设置副本(Replica)。副本分为Leader副本和Follower副本。生产者只往Leader写,写成功后Follower异步去同步,Leader挂了之后,系统会从Follower中选举一个新的Leader出来。
这里有一个很容易忽略的细节:读写都走Leader副本,Follower不承担读流量。很多人误以为副本越多读性能越好,其实副本多了反而增加复制开销。副本的意义是冗余,不是水平扩展。
消费者组(Consumer Group)则是把多个消费者编成一个小组,共同消费一个Topic。组内,一个分区同一个时刻只能被一个消费者消费,但一个消费者可以同时消费多个分区。如果消费者数量多于分区数,多出来的消费者就只能闲着。不同消费组之间互不影响,可以各自独立地消费同一份消息,这就实现了“一条消息被多种业务各取所需”的效果。
2.3 一张表快速记住核心概念
| 概念 | 一句话解释 | 形象类比 |
|---|---|---|
| Topic | 按业务划分的消息分类 | 按目的地区分的传送带 |
| Partition | Topic内部的并行分片,分区内有序 | 一条传送带拆成的多条通道 |
| Offset | 分区内消息的递增序号 | 通道上包裹的编号 |
| Broker | 运行Kafka服务的服务器节点 | 快递仓库中的一个仓点 |
| Replica | 分区的冗余副本,Leader负责读写 | 每个通道的备份通道 |
| Consumer Group | 一组协同消费同一Topic的消费者 | 一群各管几个通道的拣货员 |
这部分概念越早吃透越好。后面调优、排故障、设计分区策略,全部是在这些概念的基础上展开的。概念没弄懂,写再多代码都容易出Bug。
3. 本地搭建Kafka:从下载到命令行全流程
3.1 用KRaft模式一条命令跑起单节点Kafka
Kafka 3.x以后已经彻底摘掉了对ZooKeeper的依赖,这件事对新手来说真是巨大的福音。以前要启动Kafka,必须先启动ZooKeeper,再启动Kafka,两个进程,两套日志,配置还得对上,很多新手光倒在这一步。现在用KRaft模式,一个Kafka进程就能搞定元数据管理,本地单节点一条命令就能跑起来。
我的建议是直接下载最新的稳定版,解压后先看一眼bin目录,你会发现里面有kafka-server-start.sh、kafka-topics.sh、kafka-console-producer.sh这些脚本,后面都会用到。
启动之前,先要格式化存储目录。KRaft模式需要一个唯一的集群ID,所以先执行这条命令生成:
cd kafka_2.13-3.6.0 KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)" bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c config/kraft/server.properties格式化成功后,就能直接启动:
bin/kafka-server-start.sh config/kraft/server.properties看到日志里出现“Kafka Server started”这样的字样,就说明单机已经跑起来了。注意默认配置下broker监听的是localhost:9092,如果启动报端口占用,检查一下9092是不是被别的进程占用了。
3.2 用三个脚本走通一条完整消息链路
服务起来之后,立刻用命令行脚本验证一条消息从生产到消费的全过程,这一步能让你对Kafka的工作方式建立起直观感受。
先创建一个Topic,名字就叫demo-topic,带3个分区,1个副本:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic demo-topic --partitions 3 --replication-factor 1创建完可以用describe看一下分区情况:
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic demo-topic然后打开一个终端,启动生产者:
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic demo-topic再打开另一个终端,启动消费者:
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic demo-topic --from-beginning接下来在生产者终端里随便输入几行文本,回车发送,消费者那边会立刻收到同样内容。如果你关掉消费者,再重新加上--from-beginning启动,之前发过的所有消息会全部重新读一遍。这就是Kafka基于Offset的消息回溯能力在命令行层面的直观体现。
这里要提醒一句:--from-beginning在验证环境里很好用,但在生产环境的存量Topic上千万别轻易用,否则消费端会把积压的历史消息全部拉一遍,磁盘和带宽都会受到冲击。
3.3 几个启动阶段容易踩的坑
第一,监听地址不要只写127.0.0.1。如果你想让集群里的其他机器访问,需要把listeners配置成PLAINTEXT://0.0.0.0:9092或具体网卡IP,否则客户端连过来会一脸懵。第二,log.dirs要提前规划好,默认落在/tmp下面,重启可能被系统清理,如果你只是本地练手无所谓,但要想长期使用,最好配置到独立的数据目录。第三,KRaft模式下,server.properties里的配置项和旧版ZooKeeper模式略有不同,网上很多老教程直接抄会报错,所以基于当前版本官方配置改是最稳妥的。
4. 手写第一行Kafka代码:从发送到消费
4.1 Producer端最小可用代码,以及发送逻辑
命令行跑通后,就该写代码了。这里我用Java客户端来演示,其他语言的客户端思路完全一样,只是API不同。
先引入官方Java客户端依赖,项目构建工具不管是Maven还是Gradle,核心依赖就是kafka-clients,版本和你的服务端版本保持一致即可。下面这段代码是Producer最典型的最小用例:
Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.ACKS_CONFIG, "all"); KafkaProducer<String, String> producer = new KafkaProducer<>(props); producer.send(new ProducerRecord<>("demo-topic", "order-001", "{\"orderId\":\"001\",\"amount\":99.9}"), (metadata, exception) -> { if (exception != null) { System.err.println("发送失败: " + exception.getMessage()); } else { System.out.printf("发送成功: topic=%s, partition=%d, offset=%d%n", metadata.topic(), metadata.partition(), metadata.offset()); } }); producer.flush(); producer.close();几个需要理解的点:
BOOTSTRAP_SERVERS配的是Kafka broker的地址,生产者从这个地址找到集群入口,然后拉取到完整的broker元数据,所以这里不需要把集群所有机器的地址都写上。Key和Value都要指定序列化器,因为Kafka网络传输和存储的都是字节数组,对象必须先序列化再发送。ACKS_CONFIG设置为all,表示要等所有ISR副本都写入成功后,broker才向生产者确认。这么做牺牲了一点吞吐,但换来的是不丢消息。
最容易被忽略的是:producer.send()本身是异步的,它只是把消息放进内部的发送队列就立即返回了。真正的网络发送由后台线程完成,所以你要么在send方法里传入回调,在回调里感知发送结果,要么在进程退出前调用flush()和close()。不然很多消息还压在内存队列里,进程就结束了,消息自然就丢了。
4.2 Consumer端消费循环和手动提交位移
Consumer端的入手难度比Producer大不少,因为它有几个核心机制必须理解清楚。先看一段完整的消费代码:
Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ConsumerConfig.GROUP_ID_CONFIG, "demo-group"); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); consumer.subscribe(Arrays.asList("demo-topic")); try { while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000)); for (ConsumerRecord<String, String> record : records) { System.out.printf("key=%s, value=%s, partition=%d, offset=%d%n", record.key(), record.value(), record.partition(), record.offset()); } consumer.commitSync(); } } finally { consumer.close(); }这个代码里最值得反复品的是ENABLE_AUTO_COMMIT_CONFIG=false这一行。默认情况下,Kafka消费者会自动提交位移,但它提交的时机是轮询拉取消息时,和你的业务处理逻辑是解耦的。如果你处理一条消息花了很长时间,但自动提交已经提交了,那一旦进程崩溃重启,Kafka会认为这些消息已经消费过了,你实际上丢了数据。所以稍有点规模的项目,我几乎都建议关掉自动提交,在业务处理全部成功后再commitSync()手动提交位移。
还有一个常见的疑问:subscribe()和assign()有什么区别?subscribe()是让Kafka根据消费组自动分配分区,后续如果有消费者加入或退出,会自动触发再平衡来重新分配分区。assign()则是手动指定消费某个Topic的某些分区,Kafka不会帮你管理。日常开发大部分用subscribe()就够了。
4.3 消费组再平衡到底是怎么发生的
再平衡是Kafka消费端理解起来最费劲的机制。我用一个例子解释:你有12个分区,3个消费者在同一个组里消费,Kafka会把12个分区平均分给这3个人,每人4个。某一天其中一个消费者挂掉了,剩下的两个消费者必须重新接手它负责的4个分区,这个重新分配的过程就叫再平衡。
再平衡期间,整个消费组会短暂停止消费,因为组里的成员要协调新的分配方案。如果频繁发生再平衡,消费速度会直线下降。最常见的原因就是某台机器消费速度太慢,超过了max.poll.interval.ms,被Kafka判定为“卡死”,把它的分区踢出去重新分配。后面第6节会专门讲这个问题。
顺带说一句,消费组是一个逻辑概念,它和业务场景绑定。比如你做订单处理,可以建一个order-service-group消费组;你做数据同步,可以建一个order-sync-group消费组。两个组消费同一个订单Topic,互不干扰。但同一个业务里的多个消费者实例,必须配置相同的组ID,Kafka才能按组来分配分区。
5. 从会用到用好:可靠、顺序、实时性
5.1 一条消息从生产到消费全程不丢,配置怎么组合
很多人说Kafka可能丢消息,Kafka本身确实不保证绝对不丢,但只要你把每一层的参数配置正确,在绝大多数的业务场景下是能做到不丢的。我把整条链路拆成三段来看。
第一段,生产端的可靠性。acks=all是必须的,同时开启幂等生产者(enable.idempotence=true),这样即使发送端重试,也不会造成消息重复写入。重试次数retries别设成0,网络抖动是常态,适当的重试能避免偶发失败。
第二段,Broker端的可靠性。Topic的replication.factor至少设置成2或3,副本数量决定了你能容忍几台机器同时宕机。配合min.insync.replicas设为2,表示“至少要有两个副本写入成功才算写入成功”。如果只剩一个副本在线,生产者写入就会报错,而不是默默地写进一个注定会丢的副本里。还有一点很隐蔽:自动创建Topic的功能(auto.create.topics.enable)在生产环境一定要关掉,不然客户端因为误操作写了一个没人建过的Topic,Kafka自动创建出来全是单副本,数据就直接暴露在丢数据风险下了。
第三段,消费端的可靠性。关掉自动提交,处理完业务再手动提交位移。如果业务处理失败,不提交位移,这条消息会被重新消费,至少不会丢。当然,这引出了另一个问题:可能重复消费。你不丢消息,就必须接受“至少一次”语义,也就是消息可能会重复到达消费端,所以业务处理部分最好设计成幂等操作。
下面是常用配置速查表:
| 阶段 | 配置 | 推荐值 | 作用 |
|---|---|---|---|
| 生产者 | acks | all | 等所有ISR副本确认 |
| 生产者 | enable.idempotence | true | 避免重试导致重复写入 |
| Broker | min.insync.replicas | 2 | 最少同步副本数 |
| Broker | auto.create.topics.enable | false | 避免无意识创建单副本Topic |
| Consumer | enable.auto.commit | false | 业务处理成功后才提交位移 |
5.2 消息顺序:为什么只保证分区内有序
Kafka的顺序性,是很多新人踩坑的重灾区。核心结论先说:Kafka只保证单个分区内的消息顺序,不保证跨分区的全局顺序。原因很简单,同一Topic下消息被分发到多个分区,天然就是并行的,Kafka不可能在不牺牲性能的前提下维护全局序。
那业务上需要严格有序怎么办?答案是让相关消息进同一个分区。Kafka默认的分区策略是:如果消息带了Key,就对这个Key做哈希,同样的Key会始终进同一个分区。比如订单状态变更,用订单ID当Key,那同一个订单的所有状态变更消息都进同一个分区,消费端按顺序消费,就能保证看到的状态变化是连续的。这一步是把无序变成有序的关键设计。
如果你对自定义的顺序要求更细,比如同一个用户的行为事件要按时间戳顺序消费,光靠Key还不够,还得保证同一个Key的分区内乱序到达时不能造成状态错乱。有人会问能不能只用一个分区来解决?可以,但吞吐量骤降,得不偿失。我的建议是:把需要保序的维度拆小,用尽可能细的Key,让每个分区的负载尽量均匀。如果是一个高并发的订单系统,用订单ID做Key,天然就散落到很多分区,既能保序又能保证吞吐,这才是Kafka的正确用法。
5.3 从“能消费”到“低延迟消费”的参数调优
入门阶段可能只在意消息能不能收到,但进入“精通”阶段,你会开始在意一条消息从生产到消费整体延迟是多少、吞吐量能不能再拉高。这时候就需要理解几个参数的配合关系。
生产者端的linger.ms和batch.size。linger.ms表示生产者攒一批消息的等待时间,默认是0,也就是来一条发一条,这样延迟最低但吞吐上不去。如果你愿意多等5毫秒或10毫秒,生产者就能把多条消息打包成一个批次发送,网络开销下降,吞吐量大幅提升。代价是单条消息的延迟增加。所以实时性要求高的场景,linger.ms设小一点;重吞吐的场景,设大一点。
消费者端的max.poll.records同样关键。它控制每次poll()最多返回多少条消息。如果你一次性拉回500条,业务每条处理耗时100毫秒,那这一批就要处理50秒,极容易超过max.poll.interval.ms导致再平衡。反之,如果你把max.poll.records调到50,每次poll返回少一点,处理更快,但网络往返次数会变多。两者之间需要根据实际业务处理耗时去权衡。
还有一组配合使用的参数:fetch.min.bytes和fetch.max.wait.ms。如果当前分区没有足够多的数据,消费者不会立刻返回空,而是等服务端攒到指定大小或等待超时再返回。前者保证了拉取的批次够大,后者保证延迟不会无限拉长。理解这些参数后你会发现,Kafka的调优从来不是单点参数问题,而是吞吐量和实时性之间的平衡问题。
6. 生产环境最常见的四类故障与排查套路
6.1 消费者组频繁rebalance,消费速度上不去
这类问题的现象非常典型:监控里看到消费组的Rebalance次数居高不下,消费速度时不时掉到0,过一会儿又恢复。底层原因十有八九是某个消费者的处理时间超过了max.poll.interval.ms默认的5分钟,Kafka认为它已经“失联”,把它踢出组并触发再平衡。结果这个消费者恢复后,又重新加入消费组,再次触发再平衡,形成恶性循环。
排查步骤我一般这样走。第一步,看消费组日志里有没有“rebalance”关键字,定位是哪个消费者触发的。第二步,看这台机器的GC日志和线程栈,是不是有长时间的Full GC或外部调用阻塞。第三步,检查处理逻辑,如果一条消息要调用下游接口而且还超时重试,那处理时间很容易超限。
解决思路有三条:调大max.poll.interval.ms和session.timeout.ms,给业务处理留出余量;调小max.poll.records,减少单次拉取的消息量,降低单批处理时间;如果消息处理本身太重,考虑把处理逻辑改成异步线程池,让poll线程尽快返回。第三条是治本的办法,但引入异步消费后,你要自己保证线程池里的消息不丢、位移提交的时机正确,复杂度会上一个台阶。
6.2 消费积压严重,Lag持续上涨怎么定位
消费积压,就是生产速度大于消费速度,Lag(滞后量)持续增加。先用一条命令查积压情况:
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group demo-group这个命令会输出当前Topic每个分区的当前Offset和最新Offset,两者之差就是Lag。看到Lag之后,先判断是哪个分区积压量大。如果积压分布均匀,说明是消费者整体吞吐不够,优先考虑增加消费者实例数,但要记住,消费者实例数不能超过分区数,不然多出来的实例只能闲着。如果分布不均,比如只有某一个分区Lag特别大,说明这个分区绑定的Key都打到了同一个分区上,数据倾斜了,这时候加消费者也没用,得从业务侧调整分区策略。
还有一种情况也很常见:消息体特别大,导致反序列化和业务处理消耗大量CPU和内存,整体消费吞吐起不来。这时除了增加分区和消费者,也可以考虑把大对象拆开,或者把耗时的业务处理剥出去,让消费线程只管解析和投递。
6.3 消息体太大,消费者一直拉取失败
Kafka有默认的单条消息大小限制,Broker端的message.max.bytes默认是1MB,也就是说超过1MB的消息默认直接拒绝接收。如果你确实有业务要传递大消息,只改Broker端是不够的,还要同步改三个地方:生产者端的max.request.size、消费者端的fetch.max.bytes、Topic级别的max.message.bytes。五个配置各自的作用域不同,改漏一个,问题就会以另一种形式冒出来。
但我想说的是,Kafka本来就不是设计用来存大文件的。超过1MB的消息,比如图片、视频、日志大块,更适合先传到对象存储或文件系统,再把访问链接作为消息内容发给消费者。这样既保住了Kafka的高吞吐特性,又避免了大消息对内存和磁盘的持续冲击。我见过有人非要把几十MB的JSON塞进Kafka,最后Broker频繁Full GC,整个集群的吞吐都被拖垮,非常不划算。
6.4 磁盘写满和分区数过多
磁盘写满是很多Kafka集群的慢性病。Kafka的日志默认是7天保留期,但如果你把log.retention.hours改得很大,或者消息体很大但保留时间没调,数据量就会一直膨胀。排查时先看kafka-log-dirs.sh脚本的输出,确认每个Broker的分区分布和磁盘水位,然后根据业务诉求调降保留时间,或者做Topic级别的压缩策略(cleanup.policy=compact),只保留每条Key的最新值。
分区数过多则是一个容易超标的隐患。分区数越多,每个分区对应的Leader副本和日志文件越多,文件句柄、内存缓冲、副本同步都会消耗资源。我个人经验是,一个Topic的分区数不要盲目追求多,初始按吞吐量预估,配合消费者并发度来定,一般不超过Broker数量的若干倍。切忌一开始就建上千个分区,后面想缩回来非常痛苦。
7. 最后再分享几条实用建议
一路写到这里,我把自己觉得最值得留意的经验都整理了一遍。最后再补充几条实际操作中的体会,希望能帮你少走弯路。
第一,学习阶段一定要亲手把命令行链路走通。很多人直接跳到写代码,结果理解不了分区和位移,遇到问题手忙脚乱。命令行工具是Kafka的“显影剂”,它能让你直观看到消息进了哪个分区、Offset走到哪了,这些信息在排查问题的时候比代码日志还可靠。
第二,生产配置一定要按上面的可靠性组合来设。最怕的配置就是全默认。Kafka的默认配置是给你“能跑”用的,不是给你“跑得稳”用的。auto.create.topics.enable、acks、以及副本数,这三项我建议在搭建集群的第一天就调整到位。
第三,监控一定要早做。哪怕只是一个单机小集群,至少把消费组的Lag、Broker的磁盘使用率、分区Leader分布这三项监控起来。Kafka很多故障是有前兆的,比如磁盘水位上涨、Lag缓慢爬升,监控到位就能在故障发生前介入。如果你连Lag都没有概念,那监控无从谈起,至少把第2节的概念再过一遍。
第四,遇到性能问题,先分清是生产者慢、Broker慢,还是消费者慢,再动手调参。我见过太多人一上来就把linger.ms调到0、把max.poll.records调大,结果问题没解决,还引入了新的延迟。定位是第一步,参数只是工具。
Kafka这套体系,上手快,精通难。但只要把分区、副本、消费组、位移这四个基础概念真正吃透,后面所有看似复杂的问题,基本都能顺着这条主线找到答案。希望这篇笔记能让你少踩几个坑,用的时候心里更有底。