Producer
工作流程
Kafka Producer的生产流程涉及两个线程一个队列:Main线程负责处理数据并发送至队列,Sender线程负责从队列读取数据并发送到Kafka Broker,中间是消息收集队列RecordAccumulator。
下面是一张网上常见的图:
下面是Producer初始化部分源码。
KafkaProducer(ProducerConfig config,
Serializer<K> keySerializer,
Serializer<V> valueSerializer,
ProducerMetadata metadata,
KafkaClient kafkaClient,
ProducerInterceptors<K, V> interceptors) { // ...
try {
// ...
this.partitioner = config.getConfiguredInstance(
ProducerConfig.PARTITIONER_CLASS_CONFIG,
Partitioner.class,
Collections.singletonMap(ProducerConfig.CLIENT_ID_CONFIG, clientId));
// ...
int batchSize = Math.max(1, config.getInt(ProducerConfig.BATCH_SIZE_CONFIG));
this.accumulator = new RecordAccumulator(logContext,
batchSize,
compression); // ...
// ...
this.sender = newSender(logContext, kafkaClient, this.metadata);
String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
this.ioThread.start();
} catch (Throwable t) { // ... }
}
参数配置
| 参数 | 描述 |
|---|---|
| bootstrap.servers | Broker集群地址 |
| key.serializer/value.serializer | 指定发送消息的key和value的序列化类型 |
| buffer.memory | RecordAccumulator缓冲区总大小,默认32m |
| batch.size | 上图中ProducerBatch大小。多个记录发往同一个分区时组成一批的大小,默认16K。发送请求可有多批次数据,一批次一分区 |
| linger.ms | 数据达到batch.size大小,Sender线程会去拉取数据。如迟迟未达到batch.size,sender等待linger.time之后就会发送数据 |
| compression.type | Producer 生成数据时可使用的压缩类型。默认值是none。 gzip、snappy、lz4 |
| acks | Producer 在确认一个请求发送完成之前需要收到的反馈信息的数量。[all, -1, 0, 1] |
| max.in.flight.requests.per.connection | 允许最多没有返回ack的次数,默认为5 |
| retries | 默认0,若设置大于0的值,则客户端会将发送失败的记录重新发送 |
Main线程
拦截器InterceptorList应该是采用责任链模式,对生产者发送数据做一些前置处理,ProducerInterceptor是接口则应可以自定义拦截器。
序列化是将Java对象转换为字节数组,在网络中传输和在kafka broker中存储都是使用字节数据,不序列化kafka是无法处理的,一般也是用默认。
分区器
分区器将数据映射到一个分区下,每个Topic的每个分区在RecordAccumulator中都有一个对应的缓冲区。默认分区器逻辑如下。也可以自定义分区器,实现Partitioner接口。
-
如果键存在,使用键的哈希值来决定分区。
-
如果键不存在,使用轮询的方式将消息均匀地分配到各个可用的分区上。
Producer<String, String> producer = new Producer<>(props);
// key存在取hash分区
ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, value);
producer.send(record);
那么为什么要将Topic分区呢?
并行处理:分区允许 Kafka 实现水平扩展,多个消费者可以并行处理不同分区的数据,提高吞吐量。
负载均衡:分区可以将数据分布在不同的 Broker 上,均衡负载,避免单个 Broker 成为瓶颈。
数据局部性:分区可以提高数据的局部性,使得相关数据存储在一起,便于高效读取。
容错性:通过为每个分区创建多个副本,Kafka 可以提高数据的容错性和可用性。
有序性:每个分区内的消息是有序的,分区可以保证消息的顺序传递。从上图流程也可以看出,producer发送消息,在单分区内有序,分区之间无序。
Sender线程
sender发送数据的时机ready由参数中batch.size和linger.ms决定。Sender部分源码:
public void run() {
// 主循环
while (running) {
// 发送准备好的 ProducerBatch
sendProducerData(now());
// 其他逻辑...
}
}
private void sendProducerData(long now) {
// 获取准备发送的 ProducerBatch
Map<Integer, List<ProducerBatch>> batches = this.accumulator.ready(now);
// 发送每个 ProducerBatch
for (Map.Entry<Integer, List<ProducerBatch>> entry : batches.entrySet()) {
Node node = this.metadata.fetch().nodeById(entry.getKey());
if (node == null) {
// 处理节点不存在的情况
} else {
// 发送 ProducerBatch
sendProduceRequests(batches.get(entry.getKey()), node);
}
}
}
Sender会缓存一个请求队列,默认每个 Broker 最多可以缓存 5 个请求,可以通过配置 max.in.flight.requests.per.connection 值来改变。
private void sendProduceRequests(List<ProducerBatch> batches, Node node) {
// 构建 ProduceRequest
ProduceRequest.Builder requestBuilder = ProduceRequest.Builder.forCurrentMagic(
(short) 1, acks, requestTimeoutMs, new HashMap<>());
for (ProducerBatch batch : batches) {
MemoryRecords records = batch.records();
requestBuilder.setPartitionRecords(batch.topicPartition, records);
}
// 发送请求
RequestFuture<ProduceResponse> future = client.send(node, requestBuilder);
// 添加请求到 InFlightRequests
InFlightRequest inFlightRequest = new InFlightRequest(requestBuilder.build(), time.milliseconds());
client.inFlightRequests().add(node.id(), inFlightRequest);
// 处理响应
future.addListener(new RequestFutureListener<ProduceResponse>() {
@Override
public void onSuccess(ProduceResponse response) {
handleResponses(response, batches);
}
});
}
发送吞吐量
提高吞吐量首先要做的就是压缩数据。以gzip压缩为例,压缩后数据大小是压缩前的1/12左右。
// MemoryRecordsBuilder.java
public void appendWithOffset(long offset, long timestamp, byte[] key, byte[] value, Header[] headers) {
// 其他逻辑...
if (compressionType != CompressionType.NONE) {
// 应用压缩
compressor.compress(key, value);
}
// 其他逻辑...
}
调整buffer.memroy / batch.size / linger.ms修改缓冲区大小或发送间隔,也可以调整吞吐量。
发送可靠性
在上一篇中已提到kafka producer的ack/ISR确认机制。
ack:0,生产者发送的数据不需要等落盘响应。这种情况下leader节点在数据落盘前任一环节挂掉都会导致数据丢失。
ack:1,生产者发送的数据leader分区落盘响应。follower还没同步新数据的时候leader挂了,同样数据丢失。
ack:-1,all,leader们和ISR队列(与leader们保持同步的部分follower集合)落盘响应。
public void update(Cluster cluster, long now) {
// 更新集群元数据
this.cluster = cluster;
this.lastRefreshMs = now;
this.needUpdate = false;
// 更新主题、分区和副本信息
for (TopicPartition tp : cluster.partitions()) {
PartitionInfo partInfo = cluster.partition(tp);
// 更新 ISR 列表
Set<Node> isr = new HashSet<>(partInfo.inSyncReplicas());
this.isr.put(tp, isr);
}
}
发送不重复
上一篇中提到,数据交付语义一般有三种,结合kafka producer的实现。
AtLeastOnce:ack为-1,保证数据不丢失,但不能保证数据不重复。
AtMostOnce:ack为0,数据肯定不重复,但可能会丢失。
ExactlyOnce:ack为-1 + 幂等性。就一次,不丢也不重。
那么kafka的幂等性是指 每条消息在kafka中只出现一次且消息的顺序在重试时不变,无论出现多少次broker中只会持久化一条。幂等性通过以下机制实现:
唯一标识符:为每个生产者实例分配一个唯一的 Producer ID。
private void sendInitProducerIdRequest() {
InitProducerIdRequest.Builder builder =
new InitProducerIdRequest.Builder(this.transactionalId, this.transactionTimeoutMs);
this.client.send(builder, this.time.milliseconds());
}
序列号:为每个消息批次分配一个唯一的序列号(Sequence Number)。
public RecordAppendResult append(
String topic, Integer partition, Long timestamp, byte[] key, byte[] value,
Header[] headers, Callback callback, long maxTimeToBlock, long nowMs) {
// 累积消息并生成唯一序列号
TopicPartition tp = new TopicPartition(topic, partition);
long baseSequence = nextSequence(tp);
// 其他逻辑
return new RecordAppendResult(future, baseSequence, baseOffset, logAppendTime, nowMs);
}
private long nextSequence(TopicPartition tp) {
long current = sequenceNumbers.get(tp);
sequenceNumbers.put(tp, current + 1);
return current;
}
事务日志:Kafka 使用事务日志来记录每个消息的 Producer ID 和 Sequence Number,以确保消息的唯一性和顺序性。