4. 生产者原理

生产者是消息管道的源头。你想想看,如果源头就出了问题,后面再好的管道设计也是白搭。这一章我带你深入生产者的内部机制,从客户端开发到消息发送模式,再到那些容易踩坑的配置项。

核心要点:生产者的本质就是一个数据泵,它要把业务数据可靠地送到Kafka集群。但“可靠”这两个字,背后藏着不少门道。

4.1 生产者客户端开发基础

先看一个最基础的生产者代码。我习惯用Java来写,但原理是通用的。

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");

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

ProducerRecord<String, String> record = 
    new ProducerRecord<>("my-topic", "key1", "Hello Kafka");

producer.send(record);
producer.close();

这段代码看起来简单,但有几个关键点我要提醒你:

  • bootstrap.servers:不是只写一个broker地址就完事了。我建议至少写两个,防止单点故障。
  • 序列化器:Kafka只存字节数组,所以任何消息都要序列化。我个人习惯用Avro或Protobuf,而不是Java原生序列化。
  • ProducerRecord:包含topic、partition(可选)、key、value。key的作用很大,后面会讲。

小技巧:生产者的创建是重量级操作,里面会初始化网络连接、元数据拉取等。别在每次发送消息时都new一个,要复用。

4.2 消息发送模式:同步 vs 异步

send()方法返回的是一个Future对象。这就引出了两种发送模式。

4.2.1 同步发送

try {
    RecordMetadata metadata = producer.send(record).get();
    System.out.println("消息发送成功,offset: " + metadata.offset());
} catch (Exception e) {
    e.printStackTrace();
}

调用.get()会阻塞当前线程,直到broker返回确认。这样做的好处是能立即知道发送结果,但吞吐量会大打折扣。我在做日志采集时试过同步发送,TPS直接掉了一个数量级。

4.2.2 异步发送

producer.send(record, new Callback() {
    @Override
    public void onCompletion(RecordMetadata metadata, Exception exception) {
        if (exception != null) {
            // 处理发送失败
            exception.printStackTrace();
        } else {
            // 发送成功
            System.out.println("offset: " + metadata.offset());
        }
    }
});

异步发送才是生产环境的主流。send()立即返回,后台线程负责实际发送。回调函数会在发送完成后被调用。

注意:回调函数是在生产者内部的IO线程中执行的。不要在回调里做耗时操作,否则会阻塞其他消息的发送。我曾经见过有人把数据库写入放在回调里,结果生产者的吞吐量直接崩了。

4.3 acks与retries配置

这两个配置直接决定了消息的可靠性。我见过太多人在这上面栽跟头。

acks值 含义 可靠性 性能
0 不等待任何确认 最低 最高
1 等待leader确认 中等 中等
all(或-1) 等待所有ISR副本确认 最高 最低

说说我的经验:

  • acks=0:适合对数据丢失不敏感的场景,比如监控指标采集。丢几条数据无所谓。
  • acks=1:默认值。大多数场景够用,但leader挂了的话,未同步到follower的数据会丢失。
  • acks=all:金融、交易类场景必须用这个。我曾经帮一个支付系统调优,他们一开始用acks=1,结果一次broker宕机丢了十几笔订单数据。

再来看retries。这个参数控制发送失败时的重试次数。

props.put("retries", 3);
props.put("retry.backoff.ms", 100);

重试不是越多越好。重试次数太多会导致消息堆积,而且可能造成消息乱序。为什么?因为重试的消息可能比后面发送的消息先到达broker。

避坑指南:我曾经把retries设成10,结果生产者的发送队列被重试消息塞满了,新的消息根本发不出去。后来我改成3次重试,配合幂等性来解决乱序问题。

4.4 幂等性与事务

这两个概念很多人搞混。我简单说清楚。

4.4.1 幂等性

幂等性解决的是“消息重复”问题。开启方式很简单:

props.put("enable.idempotence", true);

开启后,生产者会给每条消息分配一个唯一的序列号(Producer ID + 序列号)。broker会检查这个序列号,如果重复就丢弃。

幂等性的前提条件:

  • 必须设置acks=all
  • retries必须大于0
  • max.in.flight.requests.per.connection必须小于等于5

说白了,幂等性保证的是“单分区内、单会话内”的不重复。跨分区或重启后就不管用了。

4.4.2 事务

事务比幂等性更强。它保证“跨分区、跨会话”的原子性写入。

props.put("transactional.id", "my-transactional-id");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
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();
}

事务的典型场景是“精确一次语义”(Exactly-Once Semantics, EOS)。比如从Kafka读数据,处理后写回Kafka,事务能保证整个过程要么全部成功,要么全部回滚。

警告:事务的开销不小。它会引入额外的协调和提交步骤,吞吐量会下降20%-30%。不是所有场景都需要事务,别滥用。

4.5 核心流程总结

下面这张图展示了生产者发送消息的完整流程:

生产者消息发送核心流程 应用代码 序列化器 分区器 Record Accumulator Sender 线程 关键配置:acks、retries、enable.idempotence、transactional.id

流程其实不复杂:应用代码创建消息 → 序列化成字节 → 分区器决定去哪个分区 → 放入累加器缓冲 → Sender线程批量发送到broker。

我的建议:生产环境至少开启幂等性(enable.idempotence=true),配合acks=all和适当的retries。这样能在性能和可靠性之间取得较好的平衡。事务只在需要跨分区原子写入时才用。

嗯,生产者这块的内容就讲到这里。记住一个原则:先保证数据不丢,再考虑性能优化。很多线上事故都是因为一开始就追求高性能,忽略了可靠性配置。