告别ZooKeeper:Kafka 3.7 KRaft模式生产环境部署与迁移实录

大前年年底我们准备把用了三年的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相关的配置。我这里放一份我们生产环境用的简化版配置,敏感信息打了码:

# 节点ID,每个节点唯一 node.id=1 # 控制器相关配置,3个节点都配成控制器候选 process.roles=broker,controller # Controller quorum投票节点,所有控制器节点都要列这里 controller.quorum.voters=1@192.168.1.101:9093,2@192.168.1.102:9093,3@192.168.1.103:9093 # 监听地址,9092给客户端用,9093是控制器间通信 listeners=PLAINTEXT://192.168.1.101:9092,CONTROLLER://192.168.1.101:9093 # advertised监听地址,客户端拿到的连接地址 advertised.listeners=PLAINTEXT://192.168.1.101:9092 # 控制器监听名称 controller.listener.names=CONTROLLER # 日志目录,我这里配了4块盘,分散IO压力 log.dirs=/data1/kafka-logs,/data2/kafka-logs,/data3/kafka-logs,/data4/kafka-logs # 默认分区数,我们业务一般单topic 12分区 num.partitions=12 # 副本数,生产环境至少2个,我们配3个 default.replication.factor=3 # 自动创建topic关掉,避免乱建 auto.create.topics.enable=false # 生产者ack默认配置,我们业务要求至少leader和1个副本落盘 default.replication.factor=3

部署的时候有个细节我踩过坑:一开始我把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的版本:

import org.apache.kafka.clients.producer.*; import org.apache.kafka.common.serialization.StringSerializer; import com.alibaba.fastjson2.JSON; import java.util.Properties; import java.util.concurrent.ExecutionException; public class OrderEventProducer { private final KafkaProducer<String, String> producer; private final String topic = "order_created"; public OrderEventProducer() { Properties props = new Properties(); // Kafka集群地址,我们3个broker props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.1.101:9092,192.168.1.102:9092,192.168.1.103:9092"); // key序列化器 props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // value序列化器,我们用JSON转字符串 props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.getName()); // 保证消息可靠性,所有副本都落盘才返回 props.put(ProducerConfig.ACKS_CONFIG, "all"); // 重试次数,网络抖动的时候自动重试 props.put(ProducerConfig.RETRIES_CONFIG, 3); // 批量发送大小,默认16k,我们调大到32k,减少请求次数 props.put(ProducerConfig.BATCH_SIZE_CONFIG, 32 * 1024); // linger.ms,最多等10ms就发一批,不用等batch满了 props.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 压缩类型,用lz4,压缩率高,CPU消耗低 props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); // 幂等性,避免网络重发导致重复消息 props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); this.producer = new KafkaProducer<>(props); } public void sendOrderCreatedEvent(Order order) { String orderId = order.getOrderId(); String orderJson = JSON.toJSONString(order); // 用订单ID作为key,保证同一个订单的消息发到同一个分区,消费者处理的时候有序 ProducerRecord<String, String> record = new ProducerRecord<>(topic, orderId, orderJson); // 同步发送,确保消息发成功再往下走,也可以根据业务场景用异步回调 try { RecordMetadata metadata = producer.send(record).get(); log.info("订单{}消息发送成功,分区:{},偏移量:{}", orderId, metadata.partition(), metadata.offset()); } catch (InterruptedException | ExecutionException e) { log.error("订单{}消息发送失败", orderId, e); // 发送失败的话,本地落盘,后续补偿 saveFailedOrderEvent(order); } } public void close() { producer.close(); } }

这里有个细节,我们用订单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万条请求早就全失败了,用户估计要骂街。

深度对比RocketMQ与Pulsar:为何高吞吐日志场景我们依然选Kafka?

去年我们公司要做用户行为日志的收集系统,日均日志量大概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的客户端:

# flume配置文件,收集nginx日志发到Kafka agent.sources = nginx-source agent.channels = kafka-channel agent.sinks = kafka-sink # source配置,监听nginx日志文件 agent.sources.nginx-source.type = TAILDIR agent.sources.nginx-source.filegroups = f1 agent.sources.nginx-source.filegroups.f1 = /var/log/nginx/access.log agent.sources.nginx-source.positionFile = /data/flume/taildir_position.json agent.sources.nginx-source.batchSize = 1000 # channel配置,内存channel,批量发到Kafka agent.channels.kafka-channel.type = memory agent.channels.kafka-channel.capacity = 10000 agent.channels.kafka-channel.transactionCapacity = 1000 # sink配置,发到Kafka的nginx_log topic agent.sinks.kafka-sink.type = org.apache.flume.sink.kafka.KafkaSink agent.sinks.kafka-sink.kafka.bootstrap.servers = 192.168.1.101:9092,192.168.1.102:9092,192.168.1.103:9092 agent.sinks.kafka-sink.kafka.topic = nginx_access_log # 用日志的request_id作为key,保证同一个请求的日志有序 agent.sinks.kafka-sink.kafka.key.serializer = org.apache.kafka.common.serialization.StringSerializer agent.sinks.kafka-sink.kafka.value.serializer = org.apache.kafka.common.serialization.StringSerializer # 批量发送配置 agent.sinks.kafka-sink.flumeBatchSize = 2000 agent.sinks.kafka-sink.kafka.producer.acks = 1 agent.sinks.kafka-sink.kafka.producer.linger.ms = 10 agent.sinks.kafka-sink.kafka.producer.compression.type = lz4 # 绑定source和sink到channel agent.sources.nginx-source.channels = kafka-channel agent.sinks.kafka-sink.channel = kafka-channel

现在这个日志集群每天处理大概125TB的日志,峰值QPS能到210万条/秒,Kafka的broker负载很稳,磁盘使用率才60%,足够再用半年。之前我们也考虑过用Pulsar,但是算下来运维成本要高30%,而且出了问题我们没人能搞定,不如Kafka用着顺手。不是说Pulsar不好,要是我们的场景是需要多租户、存算分离、跨地域复制,那Pulsar肯定是更好的选择,但是我们的日志场景,Kafka刚好是最合适的那个。

4. 性能压榨:百万级TPS下的分区策略、零拷贝与操作系统参数调优

去年双十一大促,我们那个订单中心系统流量直接翻了三倍,峰值 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_ratiovm.dirty_background_ratio。Kafka 是顺序写盘,但如果脏页积累太多,刷盘时会引发长时间的 IO 阻塞。我把 dirty_background_ratio 调到了 5,dirty_ratio 调到了 10,这样系统会频繁地、小批量地刷盘,而不是等到积攒了一大堆再一次性卡死。

下面是我们生产环境针对高吞吐场景的 Java Producer 配置,这个配置帮我们扛过了多次流量洪峰:

Properties props = new Properties(); props.put("bootstrap.servers", "kafka-broker1:9092,kafka-broker2:9092"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.ByteArraySerializer"); // 核心性能参数 // 批量发送大小,16KB,凑够一批再发,减少网络开销 props.put("batch.size", 16384); // 延迟 5ms 发送,给批量凑更多消息的机会,别死等 batch.size 满了 props.put("linger.ms", 5); // 缓冲区大小,32MB,防止生产过快把内存撑爆 props.put("buffer.memory", 33554432); // 开启压缩,LZ4 速度比 GZIP 快很多,压缩比也够用 props.put("compression.type", "lz4"); // acks=1 平衡了性能和可靠性,Leader 写入即成功,不等待所有副本 props.put("acks", "1"); // 单分区内的消息重试,保证顺序性 props.put("max.in.flight.requests.per.connection", 1); KafkaProducer<String, byte[]> producer = new KafkaProducer<>(props); // 发送逻辑 for (int i = 0; i < 1000000; i++) { String orderId = "ORDER_" + i; byte[] payload = ("Order details for " + orderId).getBytes(); ProducerRecord<String, byte[]> record = new ProducerRecord<>("trade_orders", orderId, payload); producer.send(record); }

这个配置里,linger.ms 是个很玄学的参数。设成 0 的话,吞吐量上不去;设成 100 的话,延迟又太高。我们在 3.7 版本下压测,5ms 是那个甜点值。另外,如果你用的是 3.7 的新特性,比如 Tiered Storage(分层存储),记得把热数据留在本地磁盘,冷数据扔到 S3,这样本地磁盘的 IO 压力会小很多。

5. 生死救援:一次因HW和LEO不一致引发的消息积压与数据丢失排查

那是今年 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 不存在而报错。

排查过程是这样的:

解决方案

我立刻把 unclean.leader.election.enable 改成了 false,禁止非同步副本竞选 Leader。但这只能防止以后再丢,已经丢的数据找不回来了。为了修复当前的积压,我不得不重置消费者组的 Offset 到 HW 的位置,也就是丢弃了那部分丢失的消息(后来通过数据库日志补偿修复的)。

那次事故之后,我对副本机制的配置变得极其保守。以下是我们现在的 Broker 端配置,专门用来防止这种脑裂和数据截断:

# 禁止不完全的 Leader 选举,宁可不可用,也不能丢数据 unclean.leader.election.enable=false # 最小同步副本数,我们设为 2,确保至少有一个 Follower 和 Leader 保持同步 min.insync.replicas=2 # 关闭 Kafka 自动管理 Offset,由消费者手动提交,确保业务处理完再提交 # 这是消费者代码里的配置,不是 Broker 的 enable.auto.commit=false

对应的消费者代码,我也改成了手动提交,并且加了重试机制:

Properties props = new Properties(); props.put("bootstrap.servers", "kafka-broker1:9092"); props.put("group.id", "order-status-consumer"); props.put("enable.auto.commit", "false"); // 手动提交 props.put("auto.offset.reset", "earliest"); // 如果 Offset 失效,从最早开始读 props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); consumer.subscribe(Collections.singletonList("trade_orders")); try { while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); for (ConsumerRecord<String, String> record : records) { try { // 1. 处理业务逻辑,更新订单状态 processOrderStatus(record.value()); // 2. 只有业务成功了,才提交 Offset consumer.commitSync(); } catch (Exception e) { // 3. 如果业务失败,记录日志,不提交 Offset,等待下次重试 log.error("Failed to process record, will retry later: {}", record, e); // 这里可以引入死信队列(DLQ)机制,防止无限重试导致阻塞 } } } } finally { consumer.close(); }

这次事故让我明白,在 3.7 版本里,虽然 KRaft 模式去掉了 ZooKeeper,稳定性提升了,但副本同步的逻辑没变。如果你追求极致性能开了 acks=1,那你一定要做好数据丢失的心理准备,并且要有补偿机制。

6. 进阶架构:基于Kafka Streams与CDC构建实时数据管道与AI推理

现在光把消息传过去已经不够了,业务方经常找过来说:“我能不能实时看到订单状态的变更?能不能在用户下单的一瞬间判断他是不是羊毛党?”

这就引出了 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 事件:

Properties props = new Properties(); props.put(StreamsConfig.APPLICATION_ID_CONFIG, "realtime-risk-control-v1"); props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-broker1:9092"); props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass()); props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass()); // 自定义时间戳提取器,使用 CDC 事件中的数据库时间 props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG, CustomCDCTimestampExtractor.class.getName()); StreamsBuilder builder = new StreamsBuilder(); // 1. 读取订单 CDC 流 KStream<String, String> orderStream = builder.stream("dbserver1.shop.orders"); // 2. 读取用户 CDC 流 KStream<String, String> userStream = builder.stream("dbserver1.shop.users"); // 3. 将 JSON 解析并转换为对象(这里省略了 JSON 解析器的细节,实际用 Jackson) // 假设我们已经转成了 UserOrder 对象 KStream<String, UserOrder> orderParsed = orderStream.mapValues(v -> parseOrder(v)); KStream<String, UserProfile> userParsed = userStream.mapValues(v -> parseUser(v)); // 4. 按 user_id 进行 KeyBy,准备 Join KStream<String, UserOrder> orderKeyed = orderParsed.selectKey((k, v) -> v.getUserId()); KStream<String, UserProfile> userKeyed = userParsed.selectKey((k, v) -> v.getUserId()); // 5. 窗口 Join,1分钟内的订单和用户变更进行关联 KStream<String, EnrichedOrder> enrichedStream = orderKeyed.join( userKeyed, (order, user) -> new EnrichedOrder(order, user), // ValueJoiner JoinWindows.of(Duration.ofMinutes(1)), // 窗口大小 StreamJoined.with(Serdes.String(), Serdes.String(), Serdes.String()) // 这里需要自定义 Serde ); // 6. 调用 AI 推理服务 KStream<String, RiskResult> riskResultStream = enrichedStream.mapValues(enrichedOrder -> { // 这里调用 Python 模型,实际生产环境会用 gRPC 或者 HTTP 连接池 boolean isRisk = callRiskModel(enrichedOrder); return new RiskResult(enrichedOrder.getOrderId(), isRisk); }); // 7. 将结果写回 Kafka riskResultStream.to("risk-control-results"); KafkaStreams streams = new KafkaStreams(builder.build(), props); streams.start();

在这个架构里,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 年依然能打的核心原因。

站长实战手记

一次差点让我背P0故障的“优化”

去年双十一前,我接了个活儿,给一个日订单量百万级的电商做订单状态同步。当时我脑子一热,觉得新业务嘛,直接上最新的 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,你才算真正摸到了它的脾气。技术这东西,纸上谈兵永远不如生产环境的一记耳光来得深刻。