KafkaProducer源码级解析

一、为什么需要消息系统

1.解耦:
  允许你独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。
2.冗余:
  消息队列把数据进行持久化直到它们已经被完全处理,通过这一方式规避了数据丢失风险。许多消息队列所采用的"插入-获取-删除"范式中,在把一个消息从队列中删除之前,需要你的处理系统明确的指出该消息已经被处理完毕,从而确保你的数据被安全的保存直到你使用完毕。
3.扩展性:
  因为消息队列解耦了你的处理过程,所以增大消息入队和处理的频率是很容易的,只要另外增加处理过程即可。
4.灵活性 & 峰值处理能力:
  在访问量剧增的情况下,应用仍然需要继续发挥作用,但是这样的突发流量并不常见。如果为以能处理这类峰值访问为标准来投入资源随时待命无疑是巨大的浪费。使用消息队列能够使关键组件顶住突发的访问压力,而不会因为突发的超负荷的请求而完全崩溃。
5.可恢复性:
  系统的一部分组件失效时,不会影响到整个系统。消息队列降低了进程间的耦合度,所以即使一个处理消息的进程挂掉,加入队列中的消息仍然可以在系统恢复后被处理。
6.顺序保证:
  在大多使用场景下,数据处理的顺序都很重要。大部分消息队列本来就是排序的,并且能保证数据会按照特定的顺序来处理。(Kafka 保证一个 Partition 内的消息的有序性)
7.缓冲:
  有助于控制和优化数据流经过系统的速度,解决生产消息和消费消息的处理速度不一致的情况。
8.异步通信:
  很多时候,用户不想也不需要立即处理消息。消息队列提供了异步处理机制,允许用户把一个消息放入队列,但并不立即处理它。想向队列中放入多少消息就放多少,然后在需要的时候再去处理它们。

二、结构

2.1 拓扑结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eGvuYv9k-1647333164667)(http://doc.uc108.org:8002/server/../Public/Uploads/2022-03-11/622afcb57f064.JPG)]

三、Producer端

Kafka 存储的消息来自任意多被称为 Producer 生产者的进程。数据从而可以被发布到不同的 Topic 主题下的不同 Partition 分区。在一个分区内,这些消息被索引并连同时间戳存储在一起。其它被称为 Consumer 消费者的进程可以从分区订阅消息。Kafka 运行在一个由一台或多台服务器组成的集群上,并且分区可以跨集群结点分布。

Producer端流程图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gOaqrOHn-1647333164668)(http://doc.uc108.org:8002/server/../Public/Uploads/2022-03-11/622afcd704a6d.png)]

3.1 生产端流程

3.1 初始化KafkaProducer

3.1.1、初始化clientid 、transactional.id

组成幂等,保证消息发送的唯一性

client.id
transactional.id
String clientId = config.getString("client.id");
            if (clientId.length() <= 0) {
                clientId = "producer-" + PRODUCER_CLIENT_ID_SEQUENCE.getAndIncrement();
            }
String transactionalId = userProvidedConfigs.containsKey("transactional.id") ? (String)userProvidedConfigs.get("transactional.id") : null;

3.1.2、创建一个ProducerMertrict监控,进行指标的统计

metrics.num.samples:维护用于计算度量metrics的样本数量。
metrics.sample.window.ms:计算度量样本的时间窗口,度量用于kafka监控
public static Sensor throttleTimeSensor(SenderMetricsRegistry metrics) {
        Sensor produceThrottleTimeSensor = metrics.sensor("produce-throttle-time");
        produceThrottleTimeSensor.add(metrics.produceThrottleTimeAvg, new Avg());
        produceThrottleTimeSensor.add(metrics.produceThrottleTimeMax, new Max());
        return produceThrottleTimeSensor;
    }

3.1.3、构造了一个分区器partitioner

3.1.4、初始化内存缓冲RecordAccumulator

核心参数:

max.request.size:单条记录最大;默认值为1048576B,1MB
retry.backoff.ms	重试间隔时间
buffer.memory 缓冲区的大小
enable.idempotence:是否开启幂等
compression.type:NONE,GZIP,SNAPPY,LZ4
batch.size:每批次发送的最大batchsize默认16k
linger.ms:最大等待时间

3.1.5、初始化一个Metadata组件

第一次初始化的时候不会去集群拉取元数据

bootstrap.servers:集群地址
metadata.max.age.ms:元数据最大生命周期(过期时间)5min
max.block.ms:最大阻塞时间
if (metadata != null) {
                this.metadata = metadata;
            } else {
                this.metadata = new Metadata(retryBackoffMs, config.getLong("metadata.max.age.ms"), true, true, clusterResourceListeners);
                this.metadata.update(Cluster.bootstrap(addresses), Collections.emptySet(), this.time.milliseconds());
            }
初始化一个Metadata组件,构建一个Metadata,用来从broker集群拉取元数据  获取topic的leader + follower,ISR,通过Metadata知道有哪些partitions,然后找到对应的partition的leader,每隔一段时间刷新元数据,直接调用Metadata组件的方法去broker上面拉了一次集群的元数据,后面默认5分钟拉一次,但是在发送消息的时候,如果没有找到某个topic的元数据时,也会主动去拉一次

3.1.6、初始化一个Sender(Runnable KafkaThread)

security.protocol:安全协议
sasl.mechanism:认证方式
String ioThreadName = "kafka-producer-network-thread | " + clientId;

3.1.7、将上述配置整合初始化一个Sender(Runnable KafkaThread)

用于从缓冲区将数据处理成batch发送到broker

总结

1、初始化构建唯一ID
2、初始化监控项
3、初始化Partitioner
4、初始化内存缓冲RecordAccumulator
5、初始化一个Metadata组件,构建一个Metadata,用来从broker集群拉取元数据  获取topic的leader + follower,ISR,通过Metadata知道有哪些partitions,然后找到对应的partition的leader,每隔一段时间刷新元数据,直接调用Metadata组件的方法去broker上面拉了一次集群的元数据,后面默认5分钟拉一次,但是在发送消息的时候,如果没有找到某个topic的元数据时,也会主动去拉一次
6、创建一个消息channel

2、
3、初始化一些核心参数,	缓冲区内存大小(32m),每个请求的最大大小(1mb),重试时间间隔(100ms),
4、初始化的时候,
5、核心组件:网络通信的组件,NetworkClient,一个网络连接最多空闲9分钟,每个链接最多有几个request没收到响应(5个),重试连接的时间间隔,scoket发送缓冲区大小(128kb),接收缓冲区大小
6、Sender线程:负责从缓冲区里获取消息进行batch的分配发送到broker

3.2、客户端发送信息

3.2.1、ProducerInterceptor拦截器

对于 producer 而言,interceptor 使得用户在消息发送前以及 producer 回调逻辑前有机会
对消息做一些定制化需求,比如 修改消息等。同时,producer 允许用户指定多个 interceptor
按序作用于同一条消息从而形成一个拦截链(interceptor chain)
onsend:在消息序列化之前执行用户定制的逻辑方法

3.2.2、waitOnMetadata 等待元数据拉取

如果之前从来没有加载过topic的元数据,就会在这一步同步阻塞来等待去broker拉取元数据,如果因为网络问题或者一些异常导致在一段时间还是拉取不到,就会报错
在你把数据放到内存缓冲的时候,如果内存缓冲区满了,此时最多就只能阻塞这么长时间就必须返回了,如果你希望send()方法被阻塞的时间延长,可以手动配置(max.block.ms)

在客户端的方法尝试等待获取topic元数据的过程中,核心逻辑,必须先唤醒sender线程,然后通过一个while循环,直接去wait释放锁,尝试最多就是等待默认60s。

private KafkaProducer.ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long maxWaitMs) throws InterruptedException {
        Cluster cluster = this.metadata.fetch();
        if (cluster.invalidTopics().contains(topic)) {
            throw new InvalidTopicException(topic);
        } else {
            this.metadata.add(topic);
            Integer partitionsCount = cluster.partitionCountForTopic(topic);
            if (partitionsCount != null && (partition == null || partition < partitionsCount)) {
                return new KafkaProducer.ClusterAndWaitTime(cluster, 0L);
            } else {
                long begin = this.time.milliseconds();
                long remainingWaitMs = maxWaitMs;

                long elapsed;
                do {
                    do {
                        if (partition != null) {
                            this.log.trace("Requesting metadata update for partition {} of topic {}.", partition, topic);
                        } else {
                            this.log.trace("Requesting metadata update for topic {}.", topic);
                        }

                        this.metadata.add(topic);
                        int version = this.metadata.requestUpdate();
                        this.sender.wakeup();

                        try {
                            this.metadata.awaitUpdate(version, remainingWaitMs);
                        } catch (TimeoutException var15) {
                            throw new TimeoutException(String.format("Topic %s not present in metadata after %d ms.", topic, maxWaitMs));
                        }

                        cluster = this.metadata.fetch();
                        elapsed = this.time.milliseconds() - begin;
                        if (elapsed >= maxWaitMs) {
                            throw new TimeoutException(partitionsCount == null ? String.format("Topic %s not present in metadata after %d ms.", topic, maxWaitMs) : String.format("Partition %d of topic %s with partition count %d is not present in metadata after %d ms.", partition, topic, partitionsCount, maxWaitMs));
                        }

                        this.metadata.maybeThrowException();
                        remainingWaitMs = maxWaitMs - elapsed;
                        partitionsCount = cluster.partitionCountForTopic(topic);
                    } while(partitionsCount == null);
                } while(partition != null && partition >= partitionsCount);

                return new KafkaProducer.ClusterAndWaitTime(cluster, elapsed);
            }
        }
    }

3.2.3 序列化Key和Value

serializedKey = this.keySerializer.serialize(record.topic(), record.headers(), record.key());
serializedValue = this.valueSerializer.serialize(record.topic(), record.headers(), record.value());

3.2.4 确定发送的分区

producer 发送消息到 broker 时,会根据分区算法选择将其存储到哪一个 partition。其计算机制为:

生产者分区策略是 决定生产者将消息发送到哪个分区的算法,
主要有以下几种:

轮询策略:Round-robin 策略,即顺序分配,
轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略。(默认、常用)
随机策略: Randomness 策略。所谓随机就是我们随意地将消息放置到任意一个分区上。
消息键保序策略:key-ordering 策略,Kafka 中每条消息都会有自己的key,一旦消息被定义了 Key,那么你就可以保证同一个
Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的。
1. 指定了 patition,则直接使用;
2. 未指定 patition 但指定 key,通过对 key 的 value 进行hash 选出一个 patition
3. patition 和 key 都未指定,使用轮询选出一个 patition。
private final AtomicInteger counter = new AtomicInteger(new Random().nextInt());

public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
    List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
    int numPartitions = partitions.size();
    if (keyBytes == null) {
        int nextValue = counter.getAndIncrement();
        List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
        if (availablePartitions.size() > 0) {
            int part = DefaultPartitioner.toPositive(nextValue) % availablePartitions.size();
            return availablePartitions.get(part).partition();
        } else {
            // no partitions are available, give a non-available partition
            return DefaultPartitioner.toPositive(nextValue) % numPartitions;
        }
    } else {
        // hash the keyBytes to choose a partition
        return DefaultPartitioner.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }
}

3.2.5、将消息放入RecordAccumulator缓冲区中

RecordAppendResult result = this.accumulator.append(tp, timestamp, serializedKey, serializedValue, headers, interceptCallback, remainingWaitMs);

3.2.6、Sender线程开始处理缓冲区中的数据

唤醒Sender线程的selector.select()的阻塞,开始处理内存缓冲器中的数据。

如果batch满了或者时间到了就将sender唤醒 然后推送
if (result.batchIsFull || result.newBatchCreated) {
                this.log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
                this.sender.wakeup();
            }
private void sendProduceRequests(Map<Integer, List<ProducerBatch>> collated, long now) {
        for (Map.Entry<Integer, List<ProducerBatch>> entry : collated.entrySet())
            sendProduceRequest(now, entry.getKey(), acks, requestTimeoutMs, entry.getValue());
    }
accumulator:消息发送规则
max.request.size:请求最大的大小
acks:默认1,
	参数0:发出去就不管了,
	参数1:leader写完就不管了
	参数all:leader写完,超过1个follower  replica完成
retries:重试次数,默认0

四、服务端

3.4 存储

.index:元数据文件

.log:数据文件

.timeindex:时间索引文件

snapshot:是kafka对幂等型或者事务型producer所生成的快照文件。

deleted:删除日志段操作时所创建的文件
在这里插入图片描述

V0日志格式

在这里插入图片描述

下面来具体陈述一下消息(Record)格式中的各个字段,从crc32开始算起,各个字段的解释如下:

crc32(4B):crc32校验值。校验范围为magic至value之间。
magic(1B):消息格式版本号,此版本的magic值为0。
attributes(1B):消息的属性。总共占1个字节,低3位表示压缩类型:0表示NONE、1表示GZIP、2表示SNAPPY、3表示LZ4(LZ4自Kafka 0.9.x引入),其余位保留。
key length(4B):表示消息的key的长度。如果为-1,则表示没有设置key,即key=null。
key:可选,如果没有key则无此字段。
value length(4B):实际消息体的长度。如果为-1,则表示消息为空。
value:消息体。可以为空,比如tomnstone消息。

一个log文件的大小由

offset + message size + n*(crc32 + magic + attributes + key length + value length) =12 + n*(14 + key length + value length)

v1版本

kafka从0.10.0版本开始到0.11.0版本之前所使用的消息格式版本为v1,其比v0版本就多了一个timestamp字段,表示消息的时间戳
在这里插入图片描述

v1版本的magic字段值为1。v1版本的attributes字段中的低3位和v0版本的一样,还是表示压缩类型,而第4个bit也被利用了起来:0表示timestamp类型为CreateTime,而1表示tImestamp类型为LogAppendTime,其他位保留。v1版本的最小消息(RECORD_OVERHEAD_V1)大小要比v0版本的要大8个字节,即22B。如果像v0版本介绍的一样发送一条key=“key”,value="value"的消息,那么此条消息在v1版本中会占用42B

v2版本

kafka从0.11.0版本开始所使用的消息格式版本为v2,这个版本的消息相比于v0和v1的版本而言改动很大,同时还参考了Protocol Buffer而引入了变长整型(Varints编码)和ZigZag编码。Varints编码是使用一个或多个字节来序列化整数的一种方法,数值越小,其所占用的字节数就越少。ZigZag编码以一种锯齿形(zig-zags)的方式来回穿梭于正负整数之间,以使得带符号整数映射为无符号整数,这样可以使得绝对值较小的负数仍然享有较小的Varints编码编码值,比如-1编码为1,1编码为2,-2编码为3
回顾一下kafka v0和v1版本的消息格式,如果消息本身没有key,那么key length字段为-1,int类型的需要4个字节来保存,而如果采用Varints编码来编码则只需要一个字节。根据Varints编码的规则可以推导出0-63之间的数字占1个字节,64-8191之间的数字占2个字节,8192-1048575之间的数字占3个字节。而kafka broker的配置message.max.bytes的默认大小为1000012(Varints编码编码占3个字节),如果消息格式中与长度有关的字段采用Varints编码的编码的话,绝大多数情况下都会节省空间,而v2版本的消息格式也正是这样做的。不过需要注意的是Varints编码并非一直会省空间,一个int32最长会占用5个字节(大于默认的4字节),一个int64最长会占用10字节(大于默认的8字节)。

v2版本中消息集谓之为Record Batch,而不是先前的Message Set了,其内部也包含了一条或者多条消息,消息的格式参见下图中部和右部。在消息压缩的情形下,Record Batch Header部分(从first offset到records count字段)是不被压缩的,而被压缩的是records字段中的所有内容。
在这里插入图片描述

先来讲述一下消息格式Record的关键字段,可以看到内部字段大量采用了Varints编码,这样Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、timestamp delta(时间戳增量)、offset delta(位移增量)和headers信息,并且attributes被弃用了,笔者对此做如下分析(对于key、key length、value、value length字段和v0以及v1版本的一样,这里不再赘述):

length:消息总长度。
attributes:弃用,但是还是在消息格式中占据1B的大小,以备未来的格式扩展。
timestamp delta:时间戳增量。通常一个timestamp需要占用8个字节,如果像这里保存与RecordBatch的其实时间戳的差值的话可以进一步的节省占用的字节数。
offset delta:位移增量。保存与RecordBatch起始位移的差值,可以节省占用的字节数。
headers:这个字段用来支持应用级别的扩展,而不需要像v0和v1版本一样不得不将一些应用级别的属性值嵌入在消息体里面。Header的格式如上图最有,包含key和value,一个Record里面可以包含0至多个Header。具体可以参考以下KIP-82。
如果对于v1版本的消息,如果用户指定的timestamp类型是LogAppendTime而不是CreateTime,那么消息从发送端(Producer)进入broker端之后timestamp字段会被更新,那么此时消息的crc值将会被重新计算,而此值在Producer端已经被计算过一次;再者,broker端在进行消息格式转换时(比如v1版转成v0版的消息格式)也会重新计算crc的值。在这些类似的情况下,消息从发送端到消费端(Consumer)之间流动时,crc的值是变动的,需要计算两次crc的值,所以这个字段的设计在v0和v1版本中显得比较鸡肋。在v2版本中将crc的字段从Record中转移到了RecordBatch中。

v2版本对于消息集(RecordBatch)做了彻底的修改,参考上图左部,除了刚刚提及的crc字段,还多了如下字段:

first offset:表示当前RecordBatch的起始位移。
length:计算partition leader epoch到headers之间的长度。
partition leader epoch:用来确保数据可靠性,详细可以参考KIP-101
magic:消息格式的版本号,对于v2版本而言,magic等于2。
attributes:消息属性,注意这里占用了两个字节。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是Control消息,0表示非Control消息,而1表示是Control消息,Control消息用来支持事务功能。
last offset delta:RecordBatch中最后一个Record的offset与first offset的差值。主要被broker用来确认RecordBatch中Records的组装正确性。
first timestamp:RecordBatch中第一条Record的时间戳。
max timestamp:RecordBatch中最大的时间戳,一般情况下是指最后一个Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。
producer id:用来支持幂等性,详细可以参考KIP-98。
producer epoch:和producer id一样,用来支持幂等性。
first sequence:和producer id、producer epoch一样,用来支持幂等性。
records count:RecordBatch中Record的个数。

  • 24
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值