4、Kafka深度实践:分区机制、副本机制、生产者与消费者API、Exactly-Once语义

各位同学,今天我们来啃一块硬骨头——Kafka。说实话,很多做风控的同学对Kafka的理解停留在“能发消息、能收消息”的层面。但真正到了高并发、高可用的风控场景,你会发现,不懂分区和副本,系统随时可能崩给你看。

我个人习惯把Kafka比作一个快递分拣中心。分区就是不同的分拣口,副本就是每个分拣口的备用人员。今天咱们就把这几个核心机制掰开揉碎了讲清楚。

核心要点:Kafka的吞吐量和可靠性,完全取决于你对分区和副本的理解深度。

4.1 分区机制:并行度的基石

分区是什么?说白了,就是一个Topic被切成了多个小队列。每个分区内部是有序的,但分区之间不保证顺序。

我在项目中遇到过一个问题:风控事件必须按用户ID顺序处理,否则会出大乱子。比如用户先下单、后支付,如果顺序乱了,风控规则就会误判。这时候,分区策略就至关重要。

4.1.1 分区策略

Kafka默认的分区策略是轮询(Round-Robin),消息均匀分布到各个分区。但风控场景下,我们通常需要按Key分区。

// 自定义分区器示例
public class RiskPartitioner implements Partitioner {
    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
                         Object value, byte[] valueBytes, Cluster cluster) {
        // 按用户ID哈希,保证同一用户的消息进入同一分区
        String userId = (String) key;
        int partitionCount = cluster.partitionCountForTopic(topic);
        return Math.abs(userId.hashCode()) % partitionCount;
    }
}

嗯,这里要注意:分区数一旦确定,只能增加,不能减少。我见过有人上线后想缩容分区,结果数据全乱了。所以,上线前一定要评估好流量峰值。

避坑指南:我曾经在一个日处理10亿条消息的项目中,把分区数设成了64。后来发现,分区太多导致文件句柄耗尽。建议分区数不超过Broker数量的10倍。

4.1.2 分区与消费者组的关系

消费者组里的每个消费者,会负责一个或多个分区。一个分区只能被组内的一个消费者消费。这就是Kafka实现并行消费的核心机制。

你想想看,如果消费者数量大于分区数,多余的消费者就会闲置。所以,消费者数量最好等于分区数,这样效率最高。

4.2 副本机制:高可用的保障

副本机制,说白了就是给数据做备份。每个分区可以有多个副本,分布在不同的Broker上。副本分为Leader和Follower。

  • Leader副本:负责读写请求,是真正的“干活的人”
  • Follower副本:只负责同步数据,是“备胎”

为什么这样设计?因为如果所有副本都处理读写,数据一致性就难以保证。Leader统一处理,Follower同步,简单又可靠。

4.2.1 ISR机制

ISR(In-Sync Replicas)是Kafka保证数据不丢的核心。只有与Leader保持同步的副本,才会被加入ISR列表。

我记得有一次线上故障:某个Broker磁盘IO飙升,导致Follower同步延迟。Leader等了一会儿,直接把那个Follower踢出了ISR。这时候,如果Leader挂了,只有ISR里的副本才能接替成为新Leader。

重要提醒:如果所有副本都挂了,Kafka会等待ISR中最后一个副本恢复。如果这个副本也丢了数据,那数据就永久丢失了。所以,副本数至少设为3,并且确保Broker分布在不同的物理机上。

4.2.2 ACK参数详解

生产者发送消息时,可以通过acks参数控制可靠性级别:

acks值 含义 可靠性 性能
0 不等待确认 最低 最高
1 Leader确认即可 中等 中等
all 所有ISR副本确认 最高 最低

风控场景下,我建议用acks=all。虽然性能会下降一些,但数据绝对不能丢。你想想看,如果风控规则判断“允许交易”,结果这条消息丢了,用户重复支付了,那责任谁来担?

4.3 生产者与消费者API实战

光说不练假把式。咱们直接上代码。

4.3.1 生产者API

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all");
props.put("retries", 3);
props.put("batch.size", 16384);
props.put("linger.ms", 5);

Producer<String, String> producer = new KafkaProducer<>(props);

// 发送风控事件
producer.send(new ProducerRecord<>("risk-events", userId, eventJson),
    (metadata, exception) -> {
        if (exception != null) {
            // 记录失败日志,后续补偿
            log.error("发送失败: {}", exception.getMessage());
        }
    });

这里有个细节:batch.size和linger.ms要配合使用。batch.size是批次大小,linger.ms是等待时间。如果消息量不大,建议设小一点,避免延迟过高。

4.3.2 消费者API

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "risk-consumer-group");
props.put("enable.auto.commit", "false");
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(Arrays.asList("risk-events"));

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        // 处理风控事件
        processRiskEvent(record.value());
    }
    // 手动提交偏移量
    consumer.commitSync();
}

嗯,这里要注意:一定要手动提交偏移量。自动提交虽然省事,但万一处理到一半挂了,重启后会重复消费或丢失数据。

个人经验:我曾经用自动提交,结果一次Full GC导致处理超时,偏移量自动提交了,但数据没处理完。重启后,那批数据就丢了。从那以后,我再也不敢用自动提交了。

4.4 Exactly-Once语义

这是Kafka最硬核的部分。Exactly-Once,说白了就是“每条消息只被处理一次,不多不少”。

为什么风控场景需要这个?因为重复处理可能导致重复扣款,漏处理可能导致风控失效。在金融场景下,这都不是小事。

4.4.1 幂等生产者

Kafka从0.11版本开始支持幂等生产者。开启后,Kafka会为每条消息生成一个唯一的序列号,即使生产者重试,Broker也能去重。

props.put("enable.idempotence", "true");
props.put("acks", "all");
props.put("max.in.retries", Integer.MAX_VALUE);

注意:开启幂等后,acks必须设为all,否则会报错。

4.4.2 事务性消息

如果生产者需要跨多个分区或Topic保证原子性,就需要用到事务。

producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("topic1", "key1", "value1"));
    producer.send(new ProducerRecord<>("topic2", "key2", "value2"));
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

我建议在风控场景中,事务只用于关键链路。因为事务会带来额外的性能开销,如果所有消息都用事务,吞吐量会下降30%以上。

4.4.3 消费者端Exactly-Once

消费者端实现Exactly-Once,需要将消费结果和偏移量存储在同一个事务中。Kafka提供了读-处理-写模式:

consumer.subscribe(Arrays.asList("risk-events"));
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    producer.beginTransaction();
    for (ConsumerRecord<String, String> record : records) {
        // 处理结果写入输出Topic
        producer.send(new ProducerRecord<>("risk-results", process(record.value())));
    }
    // 将偏移量发送到内部Topic
    producer.sendOffsetsToTransaction(
        getOffsets(records), consumer.groupMetadata().groupId());
    producer.commitTransaction();
}

避坑指南:我曾经在生产环境中遇到一个问题:事务超时导致消费者卡死。后来发现是transaction.timeout.ms设得太短。建议设为60秒以上,给处理逻辑留足时间。

4.5 知识体系总览

下面这张图,是我自己总结的Kafka核心知识体系。建议你保存下来,每次遇到问题都对照着看一遍。

Kafka深度实践知识体系 Kafka核心机制 分区机制 副本机制 生产者API 消费者API 分区策略 消费者组关系 ISR机制 ACK参数 幂等生产者 事务性消息 手动提交偏移量 读-处理-写模式 Exactly-Once 语义 风控场景下,分区保证顺序,副本保证可靠,Exactly-Once保证精确 分区 副本 生产者 消费者

这张图把今天讲的内容串起来了。你仔细看,分区、副本、生产者、消费者这四个模块,最终都指向了Exactly-Once语义。这就是Kafka在风控场景下的核心价值——既要快,又要准,还要稳。

好了,今天的内容就到这里。记住一句话:Kafka不是万能的,但不懂Kafka的风控架构师是万万不能的。下次遇到性能问题,先看看分区数够不够;遇到数据丢失,先查查副本和ACK配置。这些基本功,比什么花哨的框架都管用。


公众号:蓝海资料掘金营,微信deep3321