大前年年底我们准备把用了三年的Kafka 2.8集群升级,当时还在用ZooKeeper做协调,每次扩容ZK节点都要小心翼翼改配置,有一回大促前ZK集群突然抖动,导致整个Kafka Controller选举卡了快2分钟,上游订单系统的消息堆积了十几万条,那次之后我就下定决心要切KRaft。正好Apache Kafka 3.7.0在2024年2月发布,官方已经明确说KRaft模式生产可用,我们就选了这个版本做迁移。
先讲下我们当时的集群情况:3台物理机,每台32核64G内存,12块2T SATA盘,原来跑的是Kafka 2.8 + ZK 3.6.3,单集群日均消息量大概2.3亿条,峰值QPS能到12万。最开始我也不敢直接上,先搭了个3节点的KRaft测试集群,跑了两周的压测,确认没啥问题才动生产的。
KRaft和ZK最大的区别就是把元数据管理收归到Kafka Controller本身,不用单独维护ZK集群了。部署的时候首先要注意server.properties的配置,原来ZK模式的zookeeper.connect要删掉,换成KRaft相关的配置。我这里放一份我们生产环境用的简化版配置,敏感信息打了码:
部署的时候有个细节我踩过坑:一开始我把controller.quorum.voters里的端口写成9092了,结果三个节点启动后互相连不上,日志里一直报Failed to connect to voter 2,排查了快半小时才发现是端口配错,控制器间通信要用单独的端口,不能和客户端监听的9092混用。
迁移的时候我们没敢直接停集群,用的是官方提供的滚动迁移方案。第一步先给所有broker升级到3.7.0,但是还保留ZK模式运行,确认新版本跑了一周没问题,再把broker逐个切换成KRaft模式。切换的时候要先改server.properties,加上上面的KRaft配置,然后重启节点,这时候节点会先从ZK里拉元数据,再同步到本地的KRaft元数据日志里。我们当时有个节点重启后一直卡在Loading metadata from ZK,查了日志发现是原来ZK里存的一个旧topic的元数据有损坏,手动删掉那个废弃topic的记录才启动成功。
迁移完之后最直观的感受是Controller选举快了太多,原来ZK模式下选举要1-2秒,现在KRaft模式下基本100ms内就能完成。还有一次我们做了个测试,把其中一个Controller节点直接断电,新的Controller选举只用了87ms,客户端那边只收到了一次连接重连的日志,业务完全没感知。现在我们这个集群跑了快半年,日均消息量涨到了3.1亿条,峰值QPS到15万,没出过一次元数据相关的问题,运维成本直接降了一半,再也不用盯着ZK的日志看了。
我们公司做电商的,去年之前订单系统、支付系统、库存系统、物流系统都是直接HTTP调用的,大促的时候订单量一上来,支付接口慢了直接导致订单创建失败,用户投诉一堆。后来我牵头把核心链路改成Kafka解耦,用的是Kafka 3.7.0,现在整条链路的可用性从99.2%提到了99.95%,大促峰值订单处理能力从每秒800单涨到了每秒3200单。
先讲下原来的痛点:用户下单之后,订单服务要调支付服务生成支付单,调库存服务扣减库存,调积分服务加积分,调物流系统预占库存,四个同步调用,只要有一个超时,整个下单就失败。去年618的时候支付服务因为机房网络抖动,响应时间从200ms涨到了2秒,导致我们订单成功率掉到了87%,运营找我聊了好几次。
后来我设计的方案是:用户下单成功之后,订单服务只把订单创建的事件发到Kafka的order_created topic,然后直接返回给用户下单成功,后面的支付、库存、积分、物流都订阅这个topic自己处理。这里有个关键问题:怎么保证最终一致性?比如库存扣减失败了怎么办?
我们当时的order_created topic配了3个分区,副本数3,生产者用的是acks=all,保证消息至少落到leader和1个副本才返回。订单服务的生产者代码我们是这么写的,用的是Kafka Java客户端3.7.0的版本:
这里有个细节,我们用订单ID作为消息的key,这样同一个订单的所有消息都会落到同一个分区,消费者那边按key消费的话,能保证同一个订单的处理是有序的,不会出现库存先扣减了,订单创建的消息还没到的情况。
然后消费者那边,比如库存服务,订阅这个topic,处理的时候要做幂等,因为生产者有可能因为重试发重复消息。我们的做法是库存扣减表加一个message_id的唯一索引,message_id就是Kafka消息的offset加上partition拼接的,处理之前先查这个message_id有没有处理过,处理过就直接跳过。
去年双11我们做了压测,模拟峰值每秒3200个下单请求,原来同步调用的方案,下单接口平均响应时间是820ms,成功率只有91%,改成Kafka解耦之后,下单接口平均响应时间降到了110ms,成功率99.97%,消息从发送到被所有消费者处理完的平均延迟是230ms,最慢的一次也没超过500ms。还有个意外收获,原来四个系统耦合在一起,改一个系统的接口所有下游都要跟着改,现在每个系统只关心自己订阅的topic格式,上游改了字段只要兼容,下游完全不用动,迭代速度快了至少30%。
当然也出过问题,有一次大促的时候库存服务的消费者突然挂了,堆积了12万条消息,恢复之后我们本来担心要处理很久,结果Kafka的消费者拉取速度很快,每秒能处理2000条左右,不到7分钟就把堆积的消息处理完了,库存数据也没出错。要是原来同步调用的话,这12万条请求早就全失败了,用户估计要骂街。
去年我们公司要做用户行为日志的收集系统,日均日志量大概120TB,峰值每秒要处理200万条日志,当时技术选型的时候纠结了很久,对比了RocketMQ 5.1、Pulsar 3.0和Kafka 3.7,最后还是选了Kafka,到现在跑了8个月,没出过什么大问题。我这里不是要说Kafka比另外两个好,而是说不同的场景选不同的东西,我们的日志场景Kafka刚好最合适。
先讲下我们的需求:第一,吞吐量要够高,峰值200万条/秒,单条日志大小平均1.2KB;第二,要能存至少7天的日志,方便下游离线分析拉取;第三,要能和Flink、Spark这些计算引擎无缝集成,我们后续要做实时用户行为分析;第四,运维成本不能太高,我们只有2个中间件运维,没精力搞太复杂的架构。
先对比RocketMQ,我们之前用过RocketMQ做业务消息,它的优势是延迟低,事务消息好用,但是日志场景有几个点不太适配。第一,RocketMQ的存储模型是commitlog加consumequeue,单broker的吞吐量上限我们压测过,大概每秒50万条左右,要达到200万的话要扩到4个broker,而Kafka我们3个broker就扛住了,单broker峰值能到70万条/秒。第二,RocketMQ的存储成本比Kafka高,同样的日志量,RocketMQ存7天要占大概180TB的磁盘,Kafka用lz4压缩之后只要110TB,因为我们日志里有很多重复的字段,压缩率能到45%左右。第三,RocketMQ的消费者拉取模型是长轮询,我们日志场景下游是Flink,用Kafka的消费者客户端对接Flink的Kafka connector是官方支持的,适配起来几乎不用改代码,RocketMQ的Flink connector是社区维护的,我们测试的时候出现过几次offset提交失败的问题,不太稳。
再看Pulsar,我们当时也搭了3节点的Pulsar测试集群,它的存算分离架构确实香,扩容计算节点不用动存储,但是我们场景里用不上。第一,Pulsar的部署比Kafka复杂太多了,要单独部署BookKeeper做存储,ZooKeeper做元数据,我们运维同学看了部署文档直接说搞不定,而Kafka 3.7的KRaft模式只要部署broker就行,3个节点半小时就搭完了。第二,Pulsar的吞吐量我们压测下来和Kafka差不多,但是延迟比Kafka高一点,同样的200万条/秒的写入,Kafka的平均写入延迟是12ms,Pulsar是21ms,虽然不算大,但是我们的日志收集 agent 对延迟比较敏感,Kafka的表现更好。第三,Pulsar的生态不如Kafka,我们下游要用Spark读日志数据,Kafka的Spark connector是内置的,直接配个地址就能用,Pulsar的Spark connector要额外引包,还经常出版本兼容的问题,我们当时用的Spark 3.5,Pulsar connector的适配包找了半天才找到合适的版本。
当然Kafka也不是没缺点,比如它的事务消息不如RocketMQ好用,我们的日志场景不需要事务,所以无所谓。还有它的topic多了之后性能会下降,我们现在的日志集群有120个topic,每个topic 12个分区,总共1440个分区,3个broker扛起来毫无压力,监控里看broker的CPU使用率才45%,内存用了32G左右,完全够用。
这里放一段我们日志收集的Flume配置,把各个服务的日志落到Kafka里,用的是Kafka 3.7的客户端:
现在这个日志集群每天处理大概125TB的日志,峰值QPS能到210万条/秒,Kafka的broker负载很稳,磁盘使用率才60%,足够再用半年。之前我们也考虑过用Pulsar,但是算下来运维成本要高30%,而且出了问题我们没人能搞定,不如Kafka用着顺手。不是说Pulsar不好,要是我们的场景是需要多租户、存算分离、跨地域复制,那Pulsar肯定是更好的选择,但是我们的日志场景,Kafka刚好是最合适的那个。
去年双十一大促,我们那个订单中心系统流量直接翻了三倍,峰值 QPS 冲到了 12 万。当时用的还是 Kafka 2.8,虽然也扛住了,但机器负载飙得厉害,磁盘 IO 经常跑满。今年我们借着升级 Apache Kafka 3.7.0 的机会,彻底把性能优化了一遍,现在同样的物理机,单机吞吐直接干到了 30 万 TPS,延迟还从之前的 800ms 降到了 120ms 左右。
很多人以为 Kafka 快是因为它是 Scala 写的或者内存缓存做得好,其实核心就三点:分区并行度、零拷贝(Zero-Copy)和操作系统页缓存(Page Cache)。
先说分区。我们之前有个误区,觉得分区越多越好,结果把某个核心交易 Topic 设了 200 个分区,导致 Controller 节点压力巨大,元数据同步都成了瓶颈。在 3.7 版本里,虽然 KRaft 模式已经很成熟了,但分区数还是得看集群规模。我现在的经验是,单台 Broker 承载的分区总数不要超过 4000 个。对于订单系统,我根据下游消费者的并行能力,把分区数定在了 32 个。为什么是 32?因为我们有 16 台消费者实例,分区数是消费者数的整数倍,这样负载最均衡。
再聊聊零拷贝。Kafka 之所以快,是因为它用到了 sendfile 系统调用。普通的数据传输是:磁盘 -> 内核缓冲区 -> 用户缓冲区 -> Socket 缓冲区 -> 网卡。Kafka 直接跳过用户态,从内核缓冲区到 Socket 缓冲区,这就是零拷贝。在 3.7 版本里,如果你开启了 SSL,零拷贝会失效,性能会掉 30% 左右。所以如果不是强合规场景,内网环境我建议直接走 PLAINTEXT 或者 SASL_PLAINTEXT,别为了那点加密把性能搞没了。
操作系统层面的调优才是真正的硬仗。我们当时遇到一个问题,就是 Broker 的 PageCache 经常失效,导致大量读盘。后来排查发现是 vm.swappiness 设成了 60,操作系统太积极地把页缓存换出到 swap 了。我直接把它改成了 1,强制系统优先使用物理内存。
还有一个是 vm.dirty_ratio 和 vm.dirty_background_ratio。Kafka 是顺序写盘,但如果脏页积累太多,刷盘时会引发长时间的 IO 阻塞。我把 dirty_background_ratio 调到了 5,dirty_ratio 调到了 10,这样系统会频繁地、小批量地刷盘,而不是等到积攒了一大堆再一次性卡死。
下面是我们生产环境针对高吞吐场景的 Java Producer 配置,这个配置帮我们扛过了多次流量洪峰:
这个配置里,linger.ms 是个很玄学的参数。设成 0 的话,吞吐量上不去;设成 100 的话,延迟又太高。我们在 3.7 版本下压测,5ms 是那个甜点值。另外,如果你用的是 3.7 的新特性,比如 Tiered Storage(分层存储),记得把热数据留在本地磁盘,冷数据扔到 S3,这样本地磁盘的 IO 压力会小很多。
那是今年 3 月份的一个周五下午,快下班的时候监控告警炸了。客服反馈说用户支付成功了,但是订单状态一直没更新。我登上后台一看,Kafka 消费者组 order-status-consumer 的 Lag(积压)已经到了 50 万条,而且还在疯涨。
当时我们集群刚从 ZooKeeper 迁移到 KRaft 模式没多久,用的是 3.7.0 版本。我的第一反应是消费者挂了,但看日志,消费者还在跑,只是疯狂报错:OffsetOutOfRangeException。
这就很奇怪了。消费者明明在消费,为什么 Offset 会越界?我赶紧去查 Broker 的日志,发现了一个关键线索:Truncating log due to leader change。原来是有一个 Broker 节点因为内存溢出(OOM)重启了,触发了 Leader 切换。
这里就涉及到 Kafka 副本同步的核心概念了:LEO(Log End Offset) 和 HW(High Watermark)。
问题出在 Leader 重启后,它的 LEO 可能比 Follower 还小(因为没来得及同步最新的数据)。按照协议,新的 Leader 会把自己的 LEO 设为 HW,然后 Follower 过来同步时发现自己的数据比 Leader 多,就会截断(Truncate)自己的日志来对齐 Leader。
在那个瞬间,如果生产者设置了 acks=1(只等 Leader 写入),消息其实已经写入了旧的 Leader,但还没同步给 Follower。旧 Leader 挂了,新 Leader 被选出来,HW 推进了,那几条没同步过去的消息就彻底丢了。更糟糕的是,消费者如果之前已经读到了那些消息(比如消费到了 Offset 100),重启后新 Leader 的 HW 还是 95,消费者就会因为 Offset 100 不存在而报错。
排查过程是这样的:
kafka-console-consumer 去读那个 Topic,发现确实读不到最新的数据。kafka-log-dirs 工具去检查具体分区的日志情况,发现 Leader 和 Follower 的 log-end-offset 确实对不上。unclean.leader.election.enable 这个参数被误设成了 true。这意味着允许非 ISR(In-Sync Replicas)中的副本成为 Leader,这就导致了数据不一致。解决方案:
我立刻把 unclean.leader.election.enable 改成了 false,禁止非同步副本竞选 Leader。但这只能防止以后再丢,已经丢的数据找不回来了。为了修复当前的积压,我不得不重置消费者组的 Offset 到 HW 的位置,也就是丢弃了那部分丢失的消息(后来通过数据库日志补偿修复的)。
那次事故之后,我对副本机制的配置变得极其保守。以下是我们现在的 Broker 端配置,专门用来防止这种脑裂和数据截断:
对应的消费者代码,我也改成了手动提交,并且加了重试机制:
这次事故让我明白,在 3.7 版本里,虽然 KRaft 模式去掉了 ZooKeeper,稳定性提升了,但副本同步的逻辑没变。如果你追求极致性能开了 acks=1,那你一定要做好数据丢失的心理准备,并且要有补偿机制。
现在光把消息传过去已经不够了,业务方经常找过来说:“我能不能实时看到订单状态的变更?能不能在用户下单的一瞬间判断他是不是羊毛党?”
这就引出了 Kafka 3.7 的一个强项:流处理(Kafka Streams) 和 CDC(Change Data Capture) 的结合。我们现在的架构是:MySQL Binlog -> Debezium -> Kafka -> Kafka Streams -> AI 模型 -> Kafka -> 下游业务。
去年我们做实时风控,最开始是想把 Kafka 的数据接给 Flink,但 Flink 集群运维成本太高了,光是调资源就够喝一壶的。后来发现 Kafka Streams 其实就够用了,尤其是 3.7 版本对 Streams 的 API 做了一些优化,写起来比之前顺手很多。
具体的场景是这样的:我们需要实时分析用户的下单行为。用户表在 MySQL,订单表也在 MySQL。当订单创建时,我需要把这两张表的数据 Join 起来,然后提取特征扔给 Python 写的风控模型(通过 gRPC 调用)。
我们用 Debezium 把 MySQL 的 Binlog 实时抓出来,推送到 Kafka。Debezium 会生成这种格式的数据:{"before": null, "after": {"id": 1, "user_id": 101, "amount": 500}, "op": "c"}。
然后我们用 Kafka Streams 做流式处理。这里有个坑,Kafka Streams 默认的时间戳提取器是 LogAppendTime,但 CDC 场景里,我们更关心数据库里的操作时间(source.ts_ms),所以我得自定义一个 TimestampExtractor。
下面是我们用来做实时特征计算的代码,这段代码跑在生产环境,每天处理大概 2 亿条 CDC 事件:
在这个架构里,Kafka 3.7 的 Exactly-Once 语义 至关重要。我们开启了 processing.guarantee="exactly_once_v2",这样即便 Streams 应用重启,也不会重复计算或者丢数据。
还有一个趋势是 AI 与流计算的融合。我们现在的做法是把特征提取放在 Streams 里,推理还是调外部服务。但 3.7 之后,社区在讨论把模型直接嵌入到 Kafka 里,或者利用 KRaft 模式的元数据管理来调度推理任务。对于中小规模的实时 AI 场景,这种轻量级的方案比上 Flink 或者 Spark 要划算得多。
不过要注意,Kafka Streams 的状态存储(State Store)如果开了 RocksDB,磁盘 IO 会很高。我们当时遇到过状态恢复特别慢的问题,后来发现是 RocksDB 的 block.cache.size 没调好。在 3.7 版本里,你可以直接在 StreamsConfig 里配置 RocksDB 的参数,这点比之前方便很多。
这种架构最大的好处是解耦。数据库只管写,Kafka 负责流转,Streams 负责计算。如果以后我想把风控模型换成 Java 写的,或者换成规则引擎,只需要改 Streams 应用,不用动上游的数据库和下游的业务系统。这就是事件驱动架构的魅力,也是 Kafka 3.7 在 2024 年依然能打的核心原因。
去年双十一前,我接了个活儿,给一个日订单量百万级的电商做订单状态同步。当时我脑子一热,觉得新业务嘛,直接上最新的 Kafka 3.7 KRaft 模式,省去 ZooKeeper 的运维,多酷啊。
结果上线第一周,半夜两点告警炸了。订单支付成功,但是发货系统没收到消息,积压了十几万条。我吓得立马爬起来查日志,发现是 HW(High Watermark)和 LEO(Log End Offset) 对不上了。后来复盘才发现,我为了追求所谓的“高性能”,把 acks 设成了 1,且 unclean.leader.election.enable 开着,导致一个落后太多的副本成了 Leader,数据直接丢了。
最后我是怎么救场的?我临时把 acks 改回了 all,强制要求副本同步,并写了一个补偿脚本,把数据库里的订单状态重新扫了一遍灌入 Kafka。那一晚,我盯着屏幕直到天亮,看着积压慢慢消下去,才敢喘口气。
折腾了这么多年,我对 Kafka 的看法其实挺“俗”的:
* 适合谁用:如果你每天日志量巨大,或者像我一样做高吞吐的数据管道,Kafka 绝对是首选。它的生态和吞吐量摆在那儿,RocketMQ 在事务消息上强,但单纯比吞吐和存海量数据,还是 Kafka 稳。
* 别瞎凑热闹:如果你就一个日活几千的小系统,搞个 Kafka 集群,光运维就能把你累死。这时候用个 Redis 队列或者甚至数据库轮询都比这强。
* 避坑点:千万别为了省事忽略副本机制。在 KRaft 模式下,虽然元数据管理简单了,但副本同步策略依然是命根子,别拿数据可靠性换那一点点延迟。
学 Kafka 别光看文档里的那些名词,什么零拷贝、分区器,都是虚的。找个能让你半夜惊醒的故障去复盘,或者亲手去调优一次 JVM 和 OS 的 page cache,你才算真正摸到了它的脾气。技术这东西,纸上谈兵永远不如生产环境的一记耳光来得深刻。