1、导读
笔者在生产交付的项目中使用了 KAFKA,为了更好地掌握 KAFKA,业余时间阅读了部分源码。KAFKA 生产者的代码中有很多的精妙绝伦的设计,非常值得借鉴学习。本文将探讨 KafkaProducer 的消息发送流程、高并发场景下消息的缓冲机制、缓冲机制是如何通过分段加锁和读写分离巧妙提升吞吐和并发的、为减少频繁 FGC 设计的内存池、消息重复发送和消息丢失的场景。笔者水平有限,若有不当之处,请不吝指正。
2、消息发送的流程
KAFKA 生产者客户端分别由主线程和 Sender 线程协调运行,主线程负责由 KafkaProducer 创建消息,经过拦截器、序列化和分区选择后缓存追加至 RecordAccumulator(消息累加器)中,Sender 线程负责从 RecordAccumulator 中批量取出消息并发送至 KAFKA 服务端。
RecordAccumulator(消息累加器)负责缓存生产者客户端产生的消息,按消息的目标分区进行攒批,攒批的大小可以通过生产者客户端参数batch.size配置(默认为 16KB),整个缓存的大小可以通过生产者客户端参数buffer.memory配置(默认为 32 MB)。RecordAccumulator(消息累加器)在内部设计上为每个分区都维护了一个 Deque(双端队列),队列中存放了每个分区的 ProducerBatch(消息攒批),消息是追加到 ProducerBatch 的尾部,因此消息是分区有序的;
消息发送的流程见下图:
※ 消息发送的各关键阶段:
① 用户使用生产者客户端发送消息;
② KafkaProducer 主线程唤醒 Sender 线程(Sender 线程在客户端初始化过程中启动并阻塞),Sender 线程向 KAFKA 服务端请求获取集群元数据并维护在客户端内存 Metadata 对象中;
③ 消息经过拦截器、序列化和分区后,追加至 RecordAccumulator(消息累加器) 中;
④ Sender 线程轮询 RecordAccumulator,待缓冲区数据批次就绪后,取出消息块;
⑤ Sender 线程构造消息发送的请求,并封装为 ClientRequest 后提交给 NetworkClient,准备发送;
⑥ NetworkClient 将请求放入 KafkaChannel 缓存中,执行网络 IO,发送至 KAFKA 服务端;
⑦ NIO Selector 收到请求响应,进行 TCP 拆包后解析成 NetworkReceive 对象放入 Deque 队列中,由 NetworkClient 经过一连串的请求响应处理器后,封装成 ClientResponse;
2.1、消息拦截器
消息拦截器可以在消息发送前对其进行拦截或修改,也可对ACK响应进行预处理,通常可用于监控埋点、消息审计等用途。用户如果自定义消息拦截器,需要实现接口org.apache.kafka.clients.producer.ProducerInterceptor,生产者会按照一个或多个自定义的拦截器的顺序,有序地作用于同一条消息从而形成一条拦截链。
public interface ProducerInterceptor<K, V> extends Configurable {
// 消息在发送前拦截,此拦截器返回的结果将传递至下一个消息拦截器
public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record);
// 消息在发送至KAFKA服务端结束后(成功或失败),在调用用户的Callback前进行拦截
public void onAcknowledgement(RecordMetadata metadata, Exception exception);
// 消息拦截器的关闭
public void close();
}
2.2、消息序列化
客户端和服务端的网络通信是基于 Java NIO 实现的,传输的数据类型是 Byte 数组。KAFKA 生产者在初始化时通过反射方式分别指定了 Key、Value 序列化器,KAFKA 客户端提供了针对各种数据类型的序列化器,包路径位于:org.apache.kafka.common.serialization.*,用户也可通过实现接口org.apache.kafka.common.serialization.Serializer和org.apache.kafka.common.serialization.Deserializer来自定义序列化器和反序列化器。
2.3、元数据加载
用户在使用客户端发送消息时,仅指定了 Topic、部分 Broker IP,而生产者最终是要将消息发送到指定 Topic 下某个分区的 Leader 副本所在的服务节点上的,因此需要知道 KAFKA 集群的元数据信息,包括 Topic 有多少分区、各分区的 Leader 副本分配在哪个服务节点上、Follower 副本分配在哪些服务节点上、哪些副本在ISR集合中、服务节点的地址和端口等信息,这些元数据会在某个合适的时机加载到客户端的 Metadata 对象中,主要成员如下:
public final class Metadata {
// 两次元数据过期刷新之间的时间间隔
private final long refreshBackoffMs;
// 元数据的保留时长
private final long metadataExpireMs;
// 客户端内存中 KAFKA 集群元数据的版本号,每次过期刷新都自增
private int version;
// 上次刷新的时间戳(包括刷新失败的场景)
private long lastRefreshMs;
// 上次成功刷新的时间戳
private long lastSuccessfulRefreshMs;
// KAFKA 集群的元数据
// 1、Broker 节点的ID、Host、IP地址、端口
// 2、KafkaController 的节点信息
// 3、Topic 下各个分区的ID、Leader副本所在的服务节点信息、所有副本所在的节点信息、ISR集合中所有副本所在的节点信息、OSR集合中所有副本所在的节点信息
private Cluster cluster;
// 是否需要强制刷新元数据
private boolean needUpdate;
// Topic 的元数据过期时间,默认5分钟
private final Map<String, Long> topics;
// 是否允许自动创建Topic(Broker配置了auto.create.topics.enable=true,且KAFKA集群不存在指定的Topic时自动创建同名Topic)
private final boolean allowAutoTopicCreation;
...
}
2.3.1、懒加载
KAFKA 生产者客户端在初始化时并不会加载元数据,这是懒加载的编程思想,元数据是由 Sender 线程异步加载的。下图展示了生产者客户端发送消息时加载元数据的时序:
生产者客户端在初始化过程中,Sender 线程首先与 Broker 建立 TCP 连接,随后进入阻塞。加载元数据的关键步骤如下:
1) 【主线程】生产者客户端发送消息时,主线程会唤醒 Sender 线程,随后主线程阻塞,直到 Sender 线程完成元数据的加载。主线程和 Sender 线程的通信是基于 wait/notify 机制;
private ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long maxWaitMs) throws InterruptedException {
...
do {
// 唤醒Sender线程
sender.wakeup();
try {
// 内部调用wait(),主线程阻塞
metadata.awaitUpdate(version, remainingWaitMs);
} catch (TimeoutException ex) {
throw new TimeoutException("Failed to update metadata after " + maxWaitMs + " ms.");
}
...
}
2) 【Sender线程】构造获取元数据的请求,并发送到负载最小的服务端节点;
private long maybeUpdate(long now, Node node) {
// 1、Broker是否可连接
String nodeConnectionId = node.idString();
if (canSendRequest(nodeConnectionId)) {
...
MetadataRequest.Builder metadataRequest;
if (metadata.needMetadataForAllTopics())
metadataRequest = MetadataRequest.Builder.allTopics();
else
metadataRequ