Topic:
在 kafka 中,topic 是一个存储消息的逻辑概念,可以认为是一个消息集合。每条消息发送到 kafka 集群的消息都有一个类别。物理上来说,不同的 topic 的消息是分开存储的,每个 topic 可以有多个生产者向它发送消息,也可以有多个消费者去消费其中的消息。
Partition:
每个 topic 可以划分多个分区(每个 Topic 至少有一个分区),同一 topic 下的不同分区包含的消息是不同的。每个消息在被添加到分区时,都会被分配一个 offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka 通过 offset保证消息在分区内的顺序,offset 的顺序不跨分区,即 kafka只保证在同一个分区内的消息是有序的。下图中,对于名字为 test 的 topic,做了 3 个分区,分别是p0、p1、p2.
➢ 每一条消息发送到 broker 时,会根据 partition 的规则选择存储到哪一个 partition。如果 partition 规则设置合理,那么所有的消息会均匀的分布在不同的partition中,这样就有点类似数据库的分库分表的概念,把数据做了分片处理。
Partition 的高可用副本机制:
Kafka的每个topic都可以分为多个Partition,并且多个 partition 会均匀分布在集群的各个节点下。虽然这种方式能够有效的对数据进行分片,但是对于每个partition 来说,都是单点的,当其中一个 partition 不可用的时候,那么这部分消息就没办法消费。所以 kafka 为了提高 partition 的可靠性而提供了副本的概念(Replica),通过副本机制来实现冗余备份。每个分区可以有多个副本,并且在副本集合中会存在一个leader 的副本,所有的读写请求都是由 leader 副本来进行处理。剩余的其他副本都做为 follower 副本,follower 副本 会 从 leader 副 本 同 步 消 息 日 志 。 这 个 有 点 类 似zookeeper 中 leader 和 follower 的概念,但是具体的时间方式还是有比较大的差异。所以我们可以认为,副本集会存在一主多从的关系。一般情况下,同一个分区的多个副本会被均匀分配到集群中的不同 broker 上,当 leader 副本所在的 broker 出现故障后,可以重新选举新的 leader 副本继续对外提供服务。通过这样的副本机制来提高 kafka 集群的可用性(这里是借助zookeeper来实现旧partition_leader所在的broker发生单点故障时进行新一轮的leader选举(选举出1个新的Leader_broker,在这个leader_broker上的partition_replica即为对应topic的leader_replica))。
Topic&Partition 的存储:
首先先明白一点:kafka 是使用日志文件的方式来保存生产者和发送者的消息(也就是这里讨论的topic和partition的存储方式都是使用的是日志文件的形式),每条消息都有一个 offset 值来表示它在分区中的偏移。Kafka 中存储的一般都是海量的消息数据,为了避免日志文件过大,一个Topic在实际存储时并不是对应磁盘上的一个(日志)文件,而是对应磁盘上的若干个个目录:其内的每个partition对应一个目录。目录的命名规则是<topic_name>_<partition_id>也就是为topic名称+有序序号,第一个partition目录的序号从0开始,序号最大值为partitions数量减1。
1、存储路径
假设实验环境中Kafka集群只有一个broker,xxx/message-folder为数据文件存储根目录,在Kafka broker中server.properties文件配置(参数log.dirs=xxx/message-folder),例如创建2个topic名称分别为report_push、launch_info, partitions数量都为partitions=4 存储路径和目录规则为: xxx/message-folder
|--report_push-0
|--report_push-1
|--report_push-2
|--report_push-3
|--launch_info-0
|--launch_info-1
|--launch_info-2
|--launch_info-3
如果对于一个 有N个broker的Kafka Cluster ,若存在这么一个topic,其内有多个 partition,每个partition都有若干个replica(包含1个leader副本和若干个follower副本)那么这些partition 是如何在这个具有N个borker的kafka Cluster上分布的呢(也就是这些partition在这个cluster上的存储路径是什么)?
2、副本分配算法如下:
将所有N个 Broker和待分配的i个Partition排序(方便编号?).
将第i个Partition分配到第(i mod n)个Broker上.(在Kafka集群中,每个Broker都有均等分配Partition的Leader机会。)
将第i个Partition的第j个副本分配到第((i + j) mod n)个Broker上.
如何知道那个各个分区中对应的 leader 是谁呢?在 zookeeper 服务器上,通过如下命令去获取对应分区的信息,
比如get /brokers/topics/testCopyTopic/partitions/1/state
这个是获取 testCopyTopic第 1 个分区的状态信息。 如下图,leader 表示当前分区的 leader 是那个 broker-id,这里即表示partition-1,即testCopyTopic-1这个分区的3个副本中,leader副本所处的位置是在broker-0这个节点上。
ISR(in-Sync replicas) 副本同步队列 :包含了 leader 副本和所有与 leader 副本保持同步的 follower 副本,这是某个partition全部副本构成的集合的一个子集”。具体来说,ISR 集合中的副本必须满足两个条件
- 副本所在节点必须维持着与 zookeeper 的连接(心跳机制)
- 副本最后一条消息的 offset 与 leader 副本的最后一条消息的 offset 之 间 的 差 值 不 能 超 过 指 定 的 阈 值(replica.lag.time.max.ms,以及replica.lag.max.messages) :如果该 follower 在此时间间隔内一直没有追上过 leader 的消息,则该 follower 就会被剔除 isr 列表
- 默认情况下,Kafka topic的replica数量为1,即每个partition都有一个唯一的leader,为了确保消息的可靠性,通常应用中将其值(由broker的参数offsets.topic.replication.factor指定)大小设定为1。所有的副本(replicas)统称为AR (Assigned Replicas)。ISR是AR的一个子集,由leader维护ISR列表,follower从Leader同步数据有一些延迟,任意一个超过阈值都会把follower踢出ISR,存入OSR(Outof-Sync Replicas)列表,新加入的follower也会先存放到OSR中。AR = ISR + OSR
- leader会负责维护ISR列表并将ISR的信息反馈到zookeeper的相关节点中:每个partition的replica_leader所在的broker会维护该partition对应的in-sync replicas (ISR)列表,replica_leader所在的broker有单独的线程定期检查该partition的 ISR 中是否有 follower 不满足 ISR 的约束,如果有,把其剔除 ISR 。并把新的 ISR 信息返回到Zookeeper的相关节点中(其follower_replica所在的broker所注册的zk节点上?)。
- Controller维护维护zookeeper相关节点信息:Kafka集群中的其中一个Broker会被选举为Controller,主要负责Partition管理和副本状态管理,也会执行类似于重分配partition之类的管理任务。在符合某些特定条件下,Controller下的LeaderSelector会选举新的leader(比如某个partition的leader_replica所在的broker宕机了,会触发新一轮的leader选举);新一轮的leader选举完成后,将新的 ISR 和新的leader_epoch及controller_epoch写入Zookeeper的相关节点中,同时发起LeaderAndIsrRequest通知该partition的所有replicas。
ISR 的设计原理 :
在所有的分布式存储中,冗余备份是一种常见的设计方式,而常用的模式有同步复制和异步复制,按照 kafka 这个副本模型来说如果采用同步复制,那么需要要求所有能工作的 Follower 副本都复制完,这条消息才会被认为提交成功,一旦有一个follower 副本出现故障,就会导致 HW 无法完成递增,消息就无法提交,消费者就获取不到消息。这种情况下,故障的Follower 副本会拖慢整个系统的性能,甚至导致系统不可用如果采用异步复制,leader 副本收到生产者推送的消息后,就认为次消息提交成功。follower 副本则异步从 leader 副本同步。这种设计虽然避免了同步复制的问题,但是假设所有follower 副本的同步速度都比较慢他们保存的消息量远远落后于 leader 副本。而此时 leader 副本所在的 broker 突然宕机,则会重新选举新的 leader 副本,而新的 leader 副本中没有原来 leader 副本的消息。这就出现了消息的丢失。
kafka 权衡了同步和异步的两种策略,采用 ISR 集合,巧妙解决了两种方案的缺陷:当 follower 副本延迟过高,leader 副本则会把该 follower 副本剔除ISR 集合,消息依然可以快速提交。当 leader 副本所在的 broker 突然宕机,会优先将 ISR 集合中follower 副本选举为 leader,新 leader 副本包含了 HW 之前的全部消息,这样就避免了消息的丢失(因为consumer只能消费各partition HW之前的消息)。
【partition的follower追上leader含义】:
Kafka中每个partition的follower没有“赶上”leader的日志可能会从同步副本列表(ISR)中移除。下面用一个例子解释一下“追赶”到底是什么意思。请看一个例子:主题名称为foo 1 partition 3 replicas。假如partition的replication分布在Brokers 1、2和3上,并且Broker 3消息已经成功提交。同步副本列表中1为leader、2和3为follower。假设replica.lag.max.messages设置为4,表明只要follower落后leader不超过3,就不会从同步副本列表中移除。replica.lag.time.max设置为500 ms,表明只要follower向leader发送请求时间间隔不超过500 ms,就不会被标记为死亡,也不会从同步副本列中移除。
下面看看,生产者发送下一条消息写入leader,与此同时follower Broker 3 GC暂停,如下图所示:
直到follower Broker 3从同步副本列表中移除或追赶上leader log end offset,最新的消息才会认为提交(假设采用的是全同步模式也就是ACK参数 = -1)。注意,因为follower Broker 3小于replica.lag.max.messages= 4落后于leader Broker 1,Kafka不会从同步副本列表中移除。在这种情况下,这意味着follower Broker 3需要迎头追赶上知道offset = 6,如果是,那么它完全“赶上” leader Broker 1 log end offset。让我们假设代理3出来的GC暂停在100 ms和追赶上领袖的日志结束偏移量。在这种状态下,下面partition日志会看起来像这样
2、文件的实际存储方式
2.1、partiton中文件存储方式:
-
每个partion(目录)相当于一个巨型文件被平均分配到多个大小相等segment(段)数据文件中。但每个段segment file消息数量不一定相等,这种特性方便old segment file快速被删除。
-
每个partiton只需要支持顺序读写就行了,segment文件生命周期由服务端配置参数决定。
这样做的好处就是能快速删除无用文件,有效提高磁盘利用率。
2.2、partiton中segment文件存储结构
- segment file组成:由2大部分组成,分别为index file和data file,此2个文件一一对应,成对出现,后缀”.index”和“.log”分别表示为segment索引文件、数据文件.
- segment文件命名规则:partion全局的第一个segment从0开始,后续每个segment文件名为上一个segment文件最后一条消息的offset值(这样设计是为了方便后续进行二分查找)。数值最大为64位long大小,19位数字字符长度,没有数字用0填充。
我们可以通过如下命令去查看这个log文件看看:
sh kafka-run-class.sh kafka.tools.DumpLogSegments --files /tmp/kafka-logs/testTopic-0/00000000000000000000.log --print-data-log(可不加)
#这里 --print-data-log 是表示查看消息内容的,不加此项是查看不到详细的消息内容
2.3、log日志中每一条消息(Message)的物理存储结构如下:
offset: 4964(逻辑偏移量,在parition(分区)内的每条消息都有一个有序的id号,这个id号被称为偏移(offset),它可以唯一确定每条消息在parition(分区)内的位置。即offset表示partiion的第多少message)
position: 75088(物理偏移量)
CreateTime: 1545203239308(创建时间)
isvalid: true(是否有效)
keysize: -1(键大小)
valuesize: 9(值大小)
magic: 2 表示本次发布Kafka服务程序协议版本号
compresscodec: NONE(压缩编码)
producerId: -1
producerEpoch: -1(epoch号)
sequence: -1(序号)
isTransactional: false(是否事务)
headerKeys: []
payload: message_0(消息的具体内容)
通过以下命令查看索引内容
sh kafka-run-class.sh kafka.tools.DumpLogSegments --files /tmp/kafka-logs/testTopic-0/00000000000000000000.index --print-data-log(可不加)
既然是个索引文件,我们先上个图把 index 跟 log 文件的对应关系描述一下。
上述图中索引文件存储大量元数据,数据文件存储大量消息,索引文件中元数据指向对应数据文件中message的物理偏移地址。 其中以索引文件中元数据3,497为例,依次在数据文件中表示第3个message(在全局partiton表示第368772个message)、以及该消息的物理偏移地址(position)为497(position是ByteBuffer 的指针位置)。
2.4 在partition中如何通过offset查找message
例如读取offset=368776的message,需要通过下面2个步骤查找。
-
根据 offset 的值,查找 segment 段中 index 索引文件。由于索引文件命名是以上一个文件的最后一个offset 进行命名的,所以,使用二分查找算法能够根据offset 快速定位到指定的索引文件。
-
找到索引文件后,根据 offset 进行定位,找到索引文件中的符合范围的索引(这里还是需要利用二分查找进行定位,先在.index文件中基于offest二分查找对应的索引项,找到该offest对应的message在磁盘上所位于的存储块的起始position,然后在基于该position进行step3。kafka在设计segment index file采取稀疏索引存储方式(参考上图,并没有对每一条message都建立一个索引项),使用稀疏索引进一步减少索引文件大小,比稠密索引节省了更多的存储空间,但查找起来需要消耗更多的时间(在查找时必须先二分查找找到数据所在的物理块,再在该物理块里进行顺序查找)
-
得到 position 以后,再到对应的 log 文件中,从 position出发顺序查找每条message的offset,将每条消息的 offset 与目标 offset 进行比较,直到找到消息
比如说,我们要查找 offset=3040这条消息,那么先找到00000000000000000000.index, 然后找到[3039,58720]这个索引,再到 log 文件中,根据 58720这个 position 起开始顺序查找(message所在存储块的起始位置),比较每条消息的 offset 是否等于 3040。最后查找到对应的消息以后返回.
Kakfa的副本同步机制:也就是 Kafka中各Follower如何与Leader同步数据
1、数据的大致处理过程是:
Producer 在 发 布 消 息 到 某 个 Partition 时 , 先 通 过ZooKeeper 找到该 Partition 的 Leader 【 get /brokers/topics//partitions/2/state】,然后无论该Topic 的 Replication Factor 为多少(也即该 Partition 有多少个 Replica),Producer 只将该消息发送到该 Partition 的Leader(只有replica_leader才支持消息的读写)。Leader 会将该消息写入其本地 Log。每个 Follower都从 Leader pull 数据(follower所在broker中会新建一个ReplicaFetcherThread 线程 ,主动从leader批量拉取(pull)消息,这极大提高了吞吐量。Follower 在收到该消息并写入其Log 后,向 Leader 发送 ACK。一旦 Leader 收到了 其 ISR 列表中的所有 Replica 的 ACK,该消息就被认为已经 commit 了,Leader 将增加自己的 HW(HighWatermark)并且向 Producer 发送ACK(假设producer的ACK参数设置为-1,也就是数据是要求强一致性的情况下)。
2、Kafka中partition replica复制机制:
- 每个 partition_Follower都主动从 partition_Leader pull 数据(follower_replica所在broker中会新建一个ReplicaFetcherThread 线程 ,主动从leader批量拉取(pull)消息(如果此时leader正好有新数据则直接拉取数据并返回,如果此时leader没有新数据则该线程被阻塞直到有新消息(数据)到来,或者等待超时才会被唤醒),这极大提高了吞吐量
- Kafka中每个Broker启动时都会创建一个副本管理服务(ReplicaManager),该服务负责维护ReplicaFetcherThread与其他Broker链路连接关系。其中Kafka Broker中follower partition与ReplicaFetcherThread对应关系为:该partition 的follower_replica 所在的broker 会创建 N个 ReplicaFetcherThread ,N的数量为 该partition 的leader_partition所在的broker的数量。也就是有多少Broker(leader_replica所位于的) ,(follower_replica所位于的)broker 就会创建多少 ReplicaFetcherThread线程去同步对应partition的leader_replica的数据 。Kafka中partition间复制数据是由follower主动向leader拉(pull)消息,follower每次读取消息都会更新HW状态。每当partition的follower_replica 发生变更导致leader_replica 所在的Broker发生变化时,ReplicaManager就会新建或销毁相应的ReplicaFetcherThread。
- partition三副本情况:
partition两副本情况:
3、当producer生产消息并发送到对应的leader_partition后,leader和follower如何实现同步的具体流程(借助ISR以及LEO和HW来实现)
1)重要名词解释:每个 partition副本(replica)对象(包括replica_leader 和replica_follower)都有两个重要的属性:LEO 和HW
- log end offset (logEndOffset),表示此log中最后(最新接收到的)的message的offset.
- high watermark (HW),表示一个Partition各replicas数据间同步且一致的offset位置,即表示一个partition 的all replicas(所有replicas)都已经commit的消息的位置,当消费者去消费这个partition的消息的时候,只能拉到 该partition_leader 的 HW 之前的消息,HW之后的消息对消费者来说是不可见的。也就是说,取该partition 对应的 ISR 列表中所有partition_replicas中各replica最小的 LEO 作为 HW!!!(一定要注意这里的HW的计算规则),consumer 最多只能消费到 HW 所在的位置。
下图详细说明了当producer生产消息到broker后,ISR及HW和LEO(log end offset)的流转过程:
4、如何保证数据强一致性?
当Producer发送消息到leader partition所在Broker时,首先保证leader commit消息成功,然后创建一个“生产者延迟请求任务”,并判断当前partiton的HW是否大于等于logEndOffset,如果满足条件即表示本次Producer请求partition replicas之间数据已经一致,立即向Producer返回Ack。否则待Follower批量拉取Leader的partition消息时,同时更新Leader ISR中HW,然后检查是否满足上述条件,如果满足向Producer返回Ack。
kafka 高性能的原因:
kafka作为MQ也好,作为存储层也好,无非是两个重要功能,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据;我们把它简化成如下两个过程:
1、网络数据持久化到磁盘 (Producer 到 Broker)
2、磁盘文件通过网络发送(Broker 到 Consumer)
下面,先给出“kafka用了磁盘,还速度快”的结论
1、顺序读写
磁盘顺序读或写的速度400M/s,能够发挥磁盘最大的速度。
随机读写,磁盘速度慢的时候十几到几百K/s。这就看出了差距。
kafka将来自Producer的数据,顺序追加在partition(也就是offest),partition就是一个文件,以此实现顺序写入。
Consumer从broker读取数据时,因为自带了偏移量,接着上次读取的位置继续读,以此实现顺序读。
顺序读写,是kafka利用磁盘特性的一个重要体现。
2、零拷贝 sendfile(in,out):在IO读写过程中,零拷贝并不意味着不需要拷贝,而是减少不必要的拷贝次数。
数据直接在内核完成输入和输出,不需要拷贝到用户空间再写出去。
kafka数据写入磁盘前,数据先写到进程的内存空间,而这块内存空间又和OS内核的内存有直接映射,(在进程 的非堆内存开辟一块内存空间,和OS内核空间的一块内存进行映射,),也就是相当于写在内核内存空间了,且这块内核空间、内核直接能够访问到,直接落入磁盘(内核缓冲区的数据,flush就能完成落盘(由操作系统在适当的时候去调用flush将数据同步到磁盘中))。
3、Memory Mapped Files (mmap)文件映射
简单描述其作用就是:将磁盘文件映射到内存, 用户通过修改内存就能修改磁盘文件。
它的工作原理是直接利用操作系统的Page来实现文件到物理内存的直接映射。完成映射之后你对物理内存的操作会被同步到硬盘上(操作系统在适当的时候去同步)。
通过mmap,进程像读写内存(当然是虚拟机内存)一样读写磁盘,也不必关心内存的大小有虚拟内存为我们兜底。kafka数据写入、是写入这块内存空间,但实际这块内存和OS内核内存有映射(在进程 的非堆内存开辟一块内存空间,和OS内核空间的一块内存进行映射,),也就是相当于写在内核内存空间了,且这块内核空间、内核直接能够访问到,直接落入磁盘(内核缓冲区的数据,flush就能完成落盘(由操作系统在适当的时候去调用flush将数据同步到磁盘中))。
使用这种方式可以获取很大的I/O提升,省去了用户空间到内核空间复制的开销。
【mmap也有一个很明显的缺陷】:不可靠,写到mmap中的数据并没有被真正的写到硬盘,操作系统会在程序主动调用flush的时候才把数据真正的写到硬盘。Kafka提供了一个参数——producer.type来控制是不是主动flush;如果Kafka写入到mmap之后就立即flush然后再返回Producer叫同步(sync);写入mmap之后立即返回Producer不调用flush叫异步(async)。
零拷贝:
1)传统的读取文件数据并发送到网络的步骤如下:
- 操作系统将数据从磁盘文件中读取到内核空间的页面缓存;
- 应用程序将数据从内核空间读入用户空间缓冲区;
- 应用程序将读到数据写回内核空间并放入socket缓冲区;
- 操作系统将数据从socket缓冲区复制到网卡接口,此时数据才能通过网络发送。
2)零拷贝:
在kafka中zero_copy是直接将操作系统的cache中的数据发送到网卡后传输给下游的消费者,直接跳过了两次拷贝数据的步骤,Socket缓存中仅仅会拷贝一个描述符过去,不会拷贝数据到Socket缓存。
ProducerRecord
消息(ProducerRecord<K,V>) 是 kafka 中最基本的数据单元,在 kafka 中,一条消息大致由 key、value 两部分构成(当然还有其他部分见下面的构造器,但注意这些参数中topic和value是必须要有的,其他的均为可选项)
public ProducerRecord(String topic,
Integer partition,
Long timestamp,
K key,
V value,
Iterable<Header> headers)
Creates a record with a specified timestamp to be sent to a specified topic and partition
Parameters:
topic - The topic the record will be appended to
partition - The partition to which the record should be sent
timestamp - The timestamp of the record, in milliseconds since epoch. If null, the producer will assign the timestamp using System.currentTimeMillis().
key - The key that will be included in the record
value - The record contents
headers - the headers that will be included in the record
Producer
// 创建一个KafkaProducer向broker发送消息的demo
// step 1:设置producer的相关属性(全部属性的话可见官方文档,下面几个是常见的重要属性)
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("acks", "all");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// 自己指定消息路由的策略(采用自定义的parationer)
// props.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,"com.gupaoedu.kafka.MyPartition");
//step2: 生成KafkaProducer<K,V>对象
Producer<String, String> producer = new KafkaProducer<>(props);
for (int i = 0; i < 100; i++)
//调用send()方法异步发送消息
producer.send(new ProducerRecord<String, String>("my-topic", Integer.toString(i), Integer.toString(i)));
producer.close();
producer的几个重要属性:
- bootstrap.servers:键值对形式的一个列表:host1:port1,host2:port2,… 用于一开始这个producer_client与kafka的集群cluster建立链接,此时用利用这个指定的bootstrap.server去发现其所在kafka集群中的其他server(broker)所以一般只用写kafaka集群中一个Broker(server)的host:port即可当然如果怕出现单点故障(碰巧你指定的这个server正好挂掉了,导致链接不上cluster)的话,可以多写几个,以防万一)
- acks:有以下4个可以指定的值(均是string类型):
如果设置为0,表示不需要进行ack确认机制也就是producer此时会直接把消息发送到socket缓冲区,并认为该消息已经成功发送了。速度最快,但是没有任何可靠性而言,server并不一定保证能接收到该消息,且此时不会触发重传(retries)机制,也就是retries参数会失效
如果设置为1,此时该消息仅需要partition_leader成功写入时,但不要求其follower(也就是该partition的replica)同样写入成功就会立即返回确认ack,告知producer,该消息我已成功接收并写入,但此时同样会出现问题,如果此时partition_leader突然宕机了,且该消息还未同步到其follower时,此时重新进行leader选举,就会导致该消息丢失**
如果设置为all或-1:此时该消息不仅需要partition_leader成功写入时,还必须等到其所有follower(也就是该partition中的ISR列表中的所有replica)同样全部写入成功时才会返回确认ack,告知producer,该消息我已成功接收并写入。注意仅设置acks=-1或all也不能保证数据不丢失,当Isr列表中只有Leader时,同样有可能造成数据丢失。要保证数据不丢除了设置acks=-1, 还要保证ISR列表的大小大于等于2,具体参数设置:
1.request.required.acks:设置为-1 等待所有ISR列表中的Replica接收到消息后采算写成功;
2.min.insync.replicas: 设置为大于等于2,保证ISR中至少有两个Replica
Producer要在吞吐率和数据可靠性之间做一个权衡
- retries :设置失败重试次数,int值[0,…,2147483647],默认值是2147483647
- key.serializer
- value.serializer
- buffer.memory:设置缓冲区大小(单位是byte),long类型
- partitioner.class:指定分区器,也就是消息的路由规则(发送到该topic下的消息按怎样的规则路由到该topic下的若干个partition),默认是org.apache.kafka.clients.producer.internals.DefaultPartitioner
1、kafka 消息分发策略(由partitioner分区器实现):
Kafka中的每个Topic一般会分配N个Partition,那么生产者(Producer)在将消息记录(ProducerRecord)发送到某个Topic对应的Partition时采用何种策略呢?Kafka中采用了分区器(Partitioner)来为我们进行分区路由的操作(当然如果在生成ProducerRecord时指定了对应的partition参数,则该消息就会被路由到指定的partition上)。Kafka给我们提供的分区器实现DefaultPartitioner,我们也可以实现自定义的分区器,只需要实现Partitioner接口。
【具体实现】:
KafkaProducer类的partition 方法:用于控制生产者发送消息时整个分区路由规则。
/**
* computes partition for given record.
* if the record has partition returns the value otherwise
* calls configured partitioner class to compute the partition.
*/
private int partition(ProducerRecord<K, V> record, byte[] serializedKey, byte[] serializedValue, Cluster cluster) {
Integer partition = record.partition();
return partition != null ?
partition :
partitioner.partition(
record.topic(), record.key(), serializedKey, record.value(), serializedValue, cluster);
}
默认情况下(即采用默认分区器DefaultPartitioner):生产者发送消息时整个分区路由的步骤如下:
- 判断消息(PartitionRecord)中的partition字段是否有值,有值的话即指定了分区,直接将该消息发送到指定的分区就行。
- 如果没有指定分区(partition参数),则使用分区器(默认是DefaultPartitioner,也可用自己指定)进行分区路由,首先判断消息中是否指定了key。
- 如果指定了key,则使用该key进行hash操作,并转为正数,然后对topic对应的分区数量进行取模操作并返回一个分区。
- 如果没有指定key,则通过先产生随机数,之后在该数上自增的方式产生一个数,并转为正数之后进行取余操作。如果该topic有可用分区,则优先分配可用分区,如果没有可用分区,则分配一个不可用分区。这与第3点中key有值的情况不同,key有值时,不区分可用分区和不可用分区,直接取余之后选择某个分区进行分配。
//默认的分区器DefaultPartitioner
public class DefaultPartitioner implements Partitioner {
private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();
/**
* Compute the partition for the given record.
*
* @param topic The topic name
* @param key The key to partition on (or null if no key)
* @param keyBytes serialized key to partition on (or null if no key)
* @param value The value to partition on or null
* @param valueBytes serialized value to partition on or null
* @param cluster The current cluster metadata
*/
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
/* 首先通过cluster从元数据中获取topic所有的分区信息 */
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
//拿到该topic的分区数
int numPartitions = partitions.size();
//如果消息记录中没有指定key
if (keyBytes == null) {
//则获取一个自增的值
int nextValue = nextValue(topic);
//通过cluster拿到所有可用的分区(可用的分区partition这里指的是该分区下的可用的partitioin_leader,因为只有leader支持消息的读写,其replica(follower)不支持消息的读写)
List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
//如果该topic存在可用的分区
if (availablePartitions.size() > 0) {
//那么将nextValue转成正数之后对可用分区数进行取余
int part = Utils.toPositive(nextValue) % availablePartitions.size();
//然后从可用分区中返回一个分区
return availablePartitions.get(part).partition();
} else { // 如果不存在可用的分区
//那么就从所有不可用的分区中通过取余的方式返回一个不可用的分区
return Utils.toPositive(nextValue) % numPartitions;
}
} else { // 如果消息记录中指定了key
// 则使用该key进行hash操作,然后对所有的分区数进行取余操作,这里的hash算法采用的是murmur2算法,然后再转成正数
//toPositive方法很简单,直接将给定的参数与0X7FFFFFFF进行逻辑与操作。
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
}
/**nextValue方法可以理解为是在消息记录中没有指定key的情况下,需要生成一个数用来代替key的hash值
*方法就是最开始先生成一个随机数,之后在这个随机数的基础上每次请求时均进行+1的操作
*/
private int nextValue(String topic) {
//每个topic都对应着一个计数
AtomicInteger counter = topicCounterMap.get(topic);
if (null == counter) { // 如果是第一次,该topic还没有对应的计数
//那么先生成一个随机数
counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
//然后将该随机数与topic对应起来存入map中
AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
if (currentCounter != null) {
//之后把这个随机数返回
counter = currentCounter;
}
}
//一旦存入了随机数之后,后续的请求均在该随机数的基础上+1之后进行返回
return counter.getAndIncrement();
}
我们也可以通过如下代码来实现自己的分片策略:
/**
* Compute the partition for the given record.
*
* @param topic The topic name
* @param key The key to partition on (or null if no key)
* @param keyBytes The serialized key to partition on( or null if no key)
* @param value The value to partition on or null
* @param valueBytes The serialized value to partition on or null
* @param cluster The current cluster metadata
*/
public class MyPartition implements Partitioner {//实现Partitioner接口
private Random random=new Random();
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
//获得分区列表
List<PartitionInfo> partitionInfos=cluster.partitionsForTopic(topic);
int partitionNum=0;
if(key==null){//key为空时进行随机分区
partitionNum=random.nextInt(partitionInfos.size());
}else{
partitionNum=Math.abs((key.hashCode())%partitionInfos.size());//key非空时进行key-hash
}
System.out.println("key ->"+key+"->value->"+value+"->"+partitionNum);
return partitionNum; //返回指定发送的分区值
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
KafkaProducer的send()方法
从上文得知,我们可以通过 KafkaProducer 的 send 方法发送消息,send 方法的声明如下:
Future<RecordMetadata> send(ProducerRecord<K, V> record)
Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)
但是KafkaProducer 的 send 方法,并不会直接向 broker 发送消息,kafka 将消息发送异步化,即分解成两个步骤,send 方法的职责是将消息追加到内存中(分区的缓存队列中),然后会由专门的 Sender 线程异步将缓存中的消息批量发送到 Kafka Broker 中。
1、消息追加入口为 KafkaProducer.send()
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
// intercept the record, which can be potentially modified; this method does not throw exceptions
// 首先利用消息发送拦截器在该消息被push到broker前拦截producer正在发送的消息,拦截器通过 interceptor.classes 指定,类型为 List< String >:其中List的每一个元素为拦截器的全类路径限定名。
// interceptors.onSend(ProducerRecord)方法 允许根据自定义的拦截器的实现来修改拦截到的Record并最终返回被修改后的新Record,此时partitioner分区器的分区算法是依旧这个修改后的新record的key和value来实施
ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
return doSend(interceptedRecord, callback); // 执行 doSend 方法,后续我们需要留意一下 Callback 的调用时机。
}
2、接下来我们来看 doSend 方法。
private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
TopicPartition tp = null;
try {
// first make sure the metadata for the topic is available
// 1.获取 topic 的分区列表,如果本地没有该topic的分区信息,则需要向远端 broker 获取,同时该方法也会返回拉取元数据所耗费的时间。在消息发送时的最大等待时间时会扣除该部分损耗的时间。
ClusterAndWaitTime clusterAndWaitTime = waitOnMetadata(record.topic(), record.partition(), maxBlockTimeMs);
long remainingWaitMs = Math.max(0, maxBlockTimeMs - clusterAndWaitTime.waitedOnMetadataMs);
Cluster cluster = clusterAndWaitTime.cluster;
// 2.序列化 record 的 key 和 value,这里虽然也传入了消息的header和topic但只会对key进行序列化
byte[] serializedKey;
try {
serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
} catch (ClassCastException cce) {
throw new SerializationException("Can't convert key of class " + record.key().getClass().getName() +
" to class " + producerConfig.getClass(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG).getName() +
" specified in key.serializer", cce);
}
byte[] serializedValue;
try {
serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
} catch (ClassCastException cce) {
throw new SerializationException("Can't convert value of class " + record.value().getClass().getName() +
" to class " + producerConfig.getClass(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG).getName() +
" specified in value.serializer", cce);
}
// 3. 获取该 record 的 partition 的值(可以在创建ProducerRecord时指定,也可以根据partitioner分区器的算法计算)
int partition = partition(record, serializedKey, serializedValue, cluster);
tp = new TopicPartition(record.topic(), partition);
// 如果是消息头信息(RecordHeaders),则设置为只读。
setReadOnly(record.headers());
Header[] headers = record.headers().toArray();
// 根据使用的版本号,按照消息协议来计算整个消息(序列化话的key+序列化后的value+header)的长度,并是否超过指定长度,如果超过则抛出异常
int serializedSize = AbstractRecords.estimateSizeInBytesUpperBound(apiVersions.maxUsableProduceMagic(),
compressionType, serializedKey, serializedValue, headers);
ensureValidRecordSize(serializedSize);
// 初始化消息时间戳,如果创建record时指定了timeStamp则使用record的否则使用producer的系统时间
long timestamp = record.timestamp() == null ? time.milliseconds() : record.timestamp();
log.trace("Sending record {} with callback {} to topic {} partition {}", record, callback, record.topic(), partition);
//对传入的 Callable(回调函数) 加入到拦截器链中,主要用于实现异步producer,此时这个回调函数就是用于处理send()方法所返回的future对象(也就是消息发送的结果)
Callback interceptCallback = new InterceptorCallback<>(callback, this.interceptors, tp);
// 如果事务处理器不为空,执行事务管理相关的
if (transactionManager != null && transactionManager.isTransactional())
transactionManager.maybeAddPartitionToTransaction(tp);
// 4. 将消息追加到缓存区(调用accumulator.append()来实现),如果当前缓存区已写满或创建了一个新的缓存区,则唤醒 Sender(消息发送线程),将缓存区中的消息发送到 broker 服务器,最终返回 future
RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
serializedValue, headers, interceptCallback, remainingWaitMs);
// 如果当前缓存区已写满或创建了一个新的缓存区,则唤醒 Sender(消息发送线程)发送数据,最终返回 future
if (result.batchIsFull || result.newBatchCreated) {
log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
this.sender.wakeup();
}
// 一旦将消息追加到缓存区后,doSent()就立即返回追加的结果(result.future)表示是否已经通过sender线程将该消息push到broker。
return result.future;
// handling exceptions and record the errors;
// for API exceptions return them in the future,
// for other exceptions throw directly
} catch (ApiException e) {
log.debug("Exception occurred during message send:", e);
if (callback != null)
callback.onCompletion(null, e);
this.errors.record();
this.interceptors.onSendError(record, tp, e);
return new FutureFailure(e);
} catch (InterruptedException e) {
this.errors.record();
this.interceptors.onSendError(record, tp, e);
throw new InterruptException(e);
} catch (BufferExhaustedException e) {
this.errors.record();
this.metrics.sensor("buffer-exhausted-records").record();
this.interceptors.onSendError(record, tp, e);
throw e;
} catch (KafkaException e) {
this.errors.record();
this.interceptors.onSendError(record, tp, e);
throw e;
} catch (Exception e) {
// we notify interceptor about all exceptions, since onSend is called before anything else in this method
this.interceptors.onSendError(record, tp, e);
throw e;
}
}
3、接下来将重点介绍如何将消息追加到生产者的发送缓存区,其实现类为:RecordAccumulator。
3.1、RecordAccumulator的append()方法
public RecordAppendResult append(TopicPartition tp,
long timestamp,
byte[] key, //序列化后的key数据
byte[] value, //序列化后的value数据
Header[] headers,
Callback callback,
long maxTimeToBlock) throws InterruptedException {
3.2 、append()方法主要源代码分析:
Step1:尝试根据 topic与分区在 kafka 中获取一个双端队列,如果不存在,则创建一个,然后调用 tryAppend 方法将消息追加到缓存中。Kafka 会为每一个 topic 的每一个分区创建一个消息缓存区(一个ArrayDequeue),消息先追加到缓存中,然后消息发送 (send())API 立即返回,然后由单独的线程 Sender 将缓存区中的消息定时发送到 broker 。这里的缓存区的实现使用的是 ArrayDequeue。然后调用 tryAppend 方法尝试将消息追加到其缓存区,如果追加成功,则返回结果。
//为每一个 topic 的每一个分区创建一个消息缓存区
Deque<ProducerBatch> dq = getOrCreateDeque(tp);
synchronized (dq) {
if (closed)
throw new KafkaException("Producer closed while send in progress");
RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callback, dq);
if (appendResult != null)
return appendResult;
}
Step2:如果第一步没有追加成功,说明当前没有可用的 ProducerBatch,则需要创建一个 ProducerBatch,故先从 BufferPool 中申请 batch.size 的内存空间,为创建 ProducerBatch 做准备,如果由于 BufferPool 中未有剩余内存,则最多等待 maxTimeToBlock ,如果在指定时间内未申请到内存,则抛出异常。
int size = Math.max(this.batchSize, AbstractRecords.estimateSizeInBytesUpperBound(maxUsableMagic, compression, key, value, headers));
log.trace("Allocating a new {} byte message buffer for topic {} partition {}", size, tp.topic(), tp.partition());
buffer = free.allocate(size, maxTimeToBlock);
Step3:创建一个新的批次 ProducerBatch,并将消息写入到该批次中,并返回追加结果,这里有如下几个关键点:
- 创建 ProducerBatch ,其内部持有一个 MemoryRecordsBuilder对象,该对象负责将消息写入到内存中,即写入到 ProducerBatch 内部持有的内存,大小等于 batch.size。
- 将消息追加到 ProducerBatch 中。
- 将新创建的 ProducerBatch 添加到双端队列的末尾。
- 将该消息批次(ProducerBatch)加入到 incomplete 容器中,该容器存放还没有发送到 broker 服务器中的消息批次(ProducerBatch),当 Sender 线程将消息发送到 broker 服务端后,会将其移除并释放所占内存。
- 返回追加结果。
synchronized (dq) {//该topic的该partition 可能同一时刻被多个producer访问所以要进行同步控制
// Need to check if producer is closed again after grabbing the dequeue lock.
if (closed)
throw new KafkaException("Producer closed while send in progress");
// 省略部分代码
// 创建 ProducerBatch
MemoryRecordsBuilder recordsBuilder = recordsBuilder(buffer, maxUsableMagic);
ProducerBatch batch = new ProducerBatch(tp, recordsBuilder, time.milliseconds());
// 将消息追加到 ProducerBatch 中
FutureRecordMetadata future = Utils.notNull(batch.tryAppend(timestamp, key, value, headers, callback, time.milliseconds()));
// 将新创建的 ProducerBatch 添加到双端队列的末尾(注意这里,该partition的最新的batch都是位于dequeue的末尾,这样,每次再取batch时也是从该dequeue的末尾取!!!)
dq.addLast(batch);
// 将该消息批次(ProducerBatch)加入到 incomplete 容器中,该容器存放还没有发送到 broker 服务器中的消息批次(ProducerBatch),当 Sender 线程将消息发送到 broker 服务端后,会将其移除并释放所占内存
incomplete.add(batch);
// Don't deallocate this buffer in the finally block as it's being used in the record batch
buffer = null;
// .返回追加结果
return new RecordAppendResult(future, dq.size() > 1 || batch.isFull(), true);
}
3.3、ProducerBatch 的 tryAppend()方法详解
public FutureRecordMetadata tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers, Callback callback, long now) {
if (!recordsBuilder.hasRoomFor(timestamp, key, value, headers)) { // 首先判断 ProducerBatch 是否还能容纳当前消息,如果剩余内存不足,将直接返回 null。如果返回 null ,会尝试再创建一个新的ProducerBatch
return null;
} else {
// 通过 MemoryRecordsBuilder 将消息按照 Kafka 消息格式写入到内存中,即写入到 对应 ProducerBatch 所对应的 ByteBuffer 中
Long checksum = this.recordsBuilder.append(timestamp, key, value, headers);
// 更新 ProducerBatch 的 maxRecordSize、lastAppendTime 属性,分别表示该批次中最大的消息长度与最后一次追加消息的时间。
this.maxRecordSize = Math.max(this.maxRecordSize, AbstractRecords.estimateSizeInBytesUpperBound(magic(),
recordsBuilder.compressionType(), key, value, headers));
this.lastAppendTime = now;
// 构建 FutureRecordMetadata 对象,这里是典型的 Future模式,里面主要包含了该条消息对应的批次的 produceFuture、消息在该批消息的下标,key 的长度、消息体的长度以及当前的系统时间
FutureRecordMetadata future = new FutureRecordMetadata(this.produceFuture, this.recordCount,
timestamp, checksum,
key == null ? -1 : key.length,
value == null ? -1 : value.length,
Time.SYSTEM);
// we have to keep every future returned to the users in case the batch needs to be split to several new batches and resent.
thunks.add(new Thunk(callback, future)); //thunks是一个持有Thunk对象的集合,持有每个返回给用户的future对象和对应的callback回调函数
this.recordCount++;
return future;
}
}
3.4 Kafka 消息追加流程与总结
3.5、 kafka 的消息发送(send())怎么实现异步发送、同步发送的?
KafkaProducer 的 send 方法最终返回的 FutureRecordMetadata ,是 Future 的子类,即 Future 模式。
- 需要使用同步发送的方式:只需要拿到 send 方法的返回结果后,调用其 get() 方法,此时如果消息还未发送到 Broker 上,该方法会被阻塞,等到 broker 返回消息发送结果后该方法会被唤醒并得到消息发送结果。
- 如果需要异步发送:则建议使用 send(ProducerRecord< K, V > record, Callback callback),但不能调用 get 方法即可。Callback 会在收到 broker 的响应结果后被调用,并且支持拦截器。
KafkaProducer 的 sender线程
到目前为止,主线程通过KafkaProducer.send()将消息放入RecordAccumulator中缓存,并没有实际的网络IO操作。网络的IO操作是通过Sender线程统一进行的(在 KafkaProducer 中会启动一个单独的线程(即为sender线程),其名称为 “kafka-producer-network-thread | clientID”,其中 clientID 为生产者的 id 。)。
1.1 类图
1.2、各个属性的含义:
KafkaClient client
kafka 网络通信客户端,主要封装与 broker 的网络通信。
RecordAccumulator accumulator
消息记录累积器,消息追加的入口(RecordAccumulator 的 append 方法)。
Metadata metadata
元数据管理器,即 topic 的路由分区信息。
boolean guaranteeMessageOrder
是否需要保证消息的顺序性。
int maxRequestSize
调用 send 方法发送的最大请求大小,包括 key、消息体序列化后的消息总大小不能超过该值。通过参数 max.request.size 来设置。
short acks
用来定义消息“已提交”的条件(标准),就是 Broker 端向客户端承偌已提交的条件,可选值如下0、-1、1.
int retries
重试次数。
Time time
时间工具类。
boolean running
该线程状态,为 true 表示运行中。
boolean forceClose
是否强制关闭,此时会忽略正在发送中的消息。
SenderMetrics sensors
消息发送相关的统计指标收集器。
int requestTimeoutMs
请求的超时时间。
long retryBackoffMs
请求失败之在重试之前等待的时间。
ApiVersions apiVersions
API版本信息。
TransactionManager transactionManager
事务处理器。
Map< TopicPartition, List< ProducerBatch>> inFlightBatches
正在执行发送相关的消息批次。
2、Sender线程发送消息的流程:
- 从Metadata获取Kafka集群元数据
- 调用RecordAccumulator.ready方法,根据缓存情况返回可以向哪些Node节点(这里的Node节点就可以认为是kafka cluster中的Broker节点)发送信息,返回ReadyCheckResult对象
- 如果ReadyCheckResult中表示有unknownLeadersExist,则调用Metadata的requestUpdate方法,标记需要更新Kafka的集群消息
- 针对ReadyCheckResult的readyNodes集合,循环调用NetworkClient的ready方法,检查网络I/O方面是否符合发送消息的条件,不符合条件的Node将会从readyNodes集合里面删除
- 针对经过第4步处理的readyNodes集合,调用drain方法获取待发送的消息集合
- 调用abortExpireBatches方法处理超时的消息。遍历RecordAccumulator中保存的全部RecordBatch,调用mybeExpire方法进行处理。如果已超时,则调用done方法,其中会触发callback,并将RecordBatch从队列中移除,释放ByteBuffer空间
- 调用Sender.createProduceRequests方法将待发送的消息封装成ClientRequest
- 调用NetWorkClient.send方法,将ClientRequest写入KafkaChannel的send字段。
- 调用NetWorkClient.poll方法,将KafkaChannel.send字段中保存的ClientRequest发送出去,同时,还会处理服务端发回的响应、处理超时的请求、调用用户自定义Callback等
3、Sender线程的run()方法
Sender 类 实现了Runnable接口,所以实际是执行Run方法
/**
* Run a single iteration of sending
*
* @param now The current POSIX time in milliseconds
*/
void run(long now) {
//进行事务相关管理
if (transactionManager != null) {
try {
if (transactionManager.shouldResetProducerStateAfterResolvingSequences())
// Check if the previous run expired batches which requires a reset of the producer state.
transactionManager.resetProducerId();
if (!transactionManager.isTransactional()) {
// this is an idempotent producer, so make sure we have a producer id
maybeWaitForProducerId();
} else if (transactionManager.hasUnresolvedSequences() && !transactionManager.hasFatalError()) {
transactionManager.transitionToFatalError(new KafkaException("The client hasn't received acknowledgment for " +
"some previously sent messages and can no longer retry them. It isn't safe to continue."));
} else if (transactionManager.hasInFlightTransactionalRequest() || maybeSendTransactionalRequest(now)) {
// as long as there are outstanding transactional requests, we simply wait for them to return
client.poll(retryBackoffMs, now);
return;
}
// do not continue sending if the transaction manager is in a failed state or if there
// is no producer id (for the idempotent case).
if (transactionManager.hasFatalError() || !transactionManager.hasProducerId()) {
RuntimeException lastError = transactionManager.lastError();
if (lastError != null)
maybeAbortBatches(lastError);
client.poll(retryBackoffMs, now);
return;
} else if (transactionManager.hasAbortableError()) {
accumulator.abortUndrainedBatches(transactionManager.lastError());
}
} catch (AuthenticationException e) {
// This is already logged as error, but propagated here to perform any clean ups.
log.trace("Authentication exception while processing transactional request: {}", e);
transactionManager.authenticationFailed(e);
}
}
//调用sendProducerData()向Broker发送消息(但这里不涉及网络I/O)
long pollTimeout = sendProducerData(now);
//拉取元数据具体方法
//步骤8、9:NetworkClient的send、poll,最后回调响应给生产者,完成异步的整个流程
client.poll(pollTimeout, now);
}
private long sendProducerData(long now) {
//步骤1:获取kafka cluster的元数据信息(比如borker相关信息、各个partition的leader_replica位于的broker信息等)
Cluster cluster = metadata.fetch();
//步骤2: 判断可以将缓冲区的消息发送到哪些partition上,相当于获取这些partition的Leader Partition对应的Broker(通过readNodes这个set来获取)
RecordAccumulator.ReadyCheckResult result = this.accumulator.ready(cluster, now);
//步骤3:如果还存在没有拉取到元数据的topic(也就是不知道其leader_partition位于哪个broker上的topic,则发送metadata。requestUpdate()请求取更新获取相应的broker信息)
if (!result.unknownLeaderTopics.isEmpty()) {
// The set of topics with unknown leader contains topics with leader election pending as well as
// topics which may have expired. Add the topic again to metadata to ensure it is included
// and request metadata update, since there are messages to send to the topic.
for (String topic : result.unknownLeaderTopics)
this.metadata.add(topic);
log.debug("Requesting metadata update due to unknown leader topics from the batched records: {}", result.unknownLeaderTopics);
this.metadata.requestUpdate();
}
// 移除那些还没有成功与之建立I/O连接的broker(也就是Node)
Iterator<Node> iter = result.readyNodes.iterator();
long notReadyTimeout = Long.MAX_VALUE;
while (iter.hasNext()) {
Node node = iter.next();
//步骤4:检查与要发送的主机的网络是否已经建立好
if (!this.client.ready(node, now)) {
iter.remove();
notReadyTimeout = Math.min(notReadyTimeout, this.client.pollDelayMs(node, now));
}
}
// create produce requests
//步骤5:要发送的partition有很多个,很有可能有一些partition的Leader Partition有可能是在同一台服务器上的
// 当我们的分区的个数大于集群节点个数时,一定会有多个Leader分区在同一台服务器上,
// 它会按照Broker进行分组,同一个Broker的Partition为同一组
Map<Integer, List<ProducerBatch>> batches = this.accumulator.drain(cluster, result.readyNodes,
this.maxRequestSize, now);
if (guaranteeMessageOrder) {
// Mute all the partitions drained
for (List<ProducerBatch> batchList : batches.values()) {
for (ProducerBatch batch : batchList)
this.accumulator.mutePartition(batch.topicPartition);
}
}
//步骤6:放弃超时的batch
List<ProducerBatch> expiredBatches = this.accumulator.expiredBatches(this.requestTimeoutMs, now);
// Reset the producer id if an expired batch has previously been sent to the broker. Also update the metrics
// for expired batches. see the documentation of @TransactionState.resetProducerId to understand why
// we need to reset the producer id here.
if (!expiredBatches.isEmpty())
log.trace("Expired {} batches in accumulator", expiredBatches.size());
for (ProducerBatch expiredBatch : expiredBatches) {
failBatch(expiredBatch, -1, NO_TIMESTAMP, expiredBatch.timeoutException(), false);
if (transactionManager != null && expiredBatch.inRetry()) {
// This ensures that no new batches are drained until the current in flight batches are fully resolved.
transactionManager.markSequenceUnresolved(expiredBatch.topicPartition);
}
}
sensors.updateProduceRequestMetrics(batches);
// If we have any nodes that are ready to send + have sendable data, poll with 0 timeout so this can immediately
// loop and try sending more data. Otherwise, the timeout is determined by nodes that have partitions with data
// that isn't yet sendable (e.g. lingering, backing off). Note that this specifically does not include nodes
// with sendable data that aren't ready to send since they would cause busy looping.
long pollTimeout = Math.min(result.nextReadyCheckDelayMs, notReadyTimeout);
if (!result.readyNodes.isEmpty()) {
log.trace("Nodes with data ready to send: {}", result.readyNodes);
// if some partitions are already ready to be sent, the select time would be 0;
// otherwise if some partition already has some data accumulated but not ready yet,
// the select time will be the time difference between now and its linger expiry time;
// otherwise the select time will be the time difference between now and the metadata expiry time;
pollTimeout = 0;
}
//步骤7:创建发送消息的请求
sendProduceRequests(batches, now);
return pollTimeout;
}
【补充】:RecordAccumulator的ready()方法的源代码
public ReadyCheckResult ready(Cluster cluster, long nowMs) {
// readyNodes用于存放那些已经有数据准备好可以发送到这些broker的broker
Set<Node> readyNodes = new HashSet<>();
long nextReadyCheckDelayMs = Long.MAX_VALUE;
// unknownLeaderTopics 用于存储那些leader_partitioin不知道位于那个Broker的且accumlator缓存中还有待发送给该topic的消息(produce batch非空)的Topic,之后会发送元数据更新请求(updateMetaDataRequest())去 broker 端查找更新该分区的相关信息。
Set<String> unknownLeaderTopics = new HashSet<>();
// 发送者内部缓存区(accumlator缓存区中)是否还有剩余空间
boolean exhausted = this.free.queued() > 0;
//对生产者缓存区 ConcurrentHashMap<TopicPartition, Deque< ProducerBatch>> batches 遍历,从中挑选已准备好的消息批次
for (Map.Entry<TopicPartition, Deque<ProducerBatch>> entry : this.batches.entrySet()) {
TopicPartition part = entry.getKey();
Deque<ProducerBatch> deque = entry.getValue();
Node leader = cluster.leaderFor(part);
synchronized (deque) {
if (leader == null && !deque.isEmpty()) {
// This is a partition for which leader is not known, but messages are available to send.
// Note that entries are currently not removed from batches when deque is empty.
unknownLeaderTopics.add(part.topic());
} else if (!readyNodes.contains(leader) && !isMuted(part, nowMs)) { // isMuted() 与顺序消息有关
ProducerBatch batch = deque.peekFirst();//从队头取,放的时候从队尾放,保证有序
if (batch != null) {
//该 ProducerBatch 已等待的时长,等于当前时间戳 与 ProducerBatch 的 lastAttemptMs 之差,在 ProducerBatch 创建时或需要重试时会将当前的时间赋值给lastAttemptMs
long waitedTimeMs = batch.waitedTimeMs(nowMs);
/**
backingOff :表示当前batch是否已经准备好被发送给Broker
retryBackoffMs:当发生异常时发起重试之前的等待时间,默认为 100ms,可通过属性 retry.backoff.ms 配置。
batch.attempts(): 该batch的重试次数
*/
boolean backingOff = batch.attempts() > 0 && waitedTimeMs < retryBackoffMs;
// 当前batch还需要多少时间才能准备就绪发送给borker
long timeToWaitMs = backingOff ? retryBackoffMs : lingerMs;
boolean full = deque.size() > 1 || batch.isFull();
boolean expired = waitedTimeMs >= timeToWaitMs;
/**
该批次已写满。(full = true)。
已等待系统规定的时长。(expired = true)
发送者内部缓存区已耗尽并且有新的线程需要申请(exhausted = true)。
该发送者的 close 方法被调用(close = true)。
该发送者的 flush 方法被调用。
*/
boolean sendable = full || expired || exhausted || closed || flushInProgress();
if (sendable && !backingOff) {
readyNodes.add(leader);
} else {
// 如果当前batch没有准备就绪,还剩多少时间可以准备就绪
long timeLeftMs = Math.max(timeToWaitMs - waitedTimeMs, 0);
// Note that this results in a conservative estimate since an un-sendable partition may have
// a leader that will later be found to have sendable data. However, this is good enough
// since we'll just wake up and then sleep again for the remaining time.
//更新触发下一轮检查是否有分区准备就绪的触发时间?
nextReadyCheckDelayMs = Math.min(timeLeftMs, nextReadyCheckDelayMs);
}
}
}
}
}
return new ReadyCheckResult(readyNodes, nextReadyCheckDelayMs, unknownLeaderTopics);
}
步骤8:调用Network.send方法,将ClientRequest写入到Kafka Channel的send字段
private void doSend(ClientRequest clientRequest, boolean isInternalRequest, long now) {
String nodeId = clientRequest.destination();
if (!isInternalRequest) {
// If this request came from outside the NetworkClient, validate
// that we can send data. If the request is internal, we trust
// that internal code has done this validation. Validation
// will be slightly different for some internal requests (for
// example, ApiVersionsRequests can be sent prior to being in
// READY state.)
if (!canSendRequest(nodeId, now))
throw new IllegalStateException("Attempt to send a request to node " + nodeId + " which is not ready.");
}
AbstractRequest.Builder<?> builder = clientRequest.requestBuilder();
try {
NodeApiVersions versionInfo = apiVersions.get(nodeId);
short version;
// Note: if versionInfo is null, we have no server version information. This would be
// the case when sending the initial ApiVersionRequest which fetches the version
// information itself. It is also the case when discoverBrokerVersions is set to false.
if (versionInfo == null) {
version = builder.latestAllowedVersion();
if (discoverBrokerVersions && log.isTraceEnabled())
log.trace("No version information found when sending {} with correlation id {} to node {}. " +
"Assuming version {}.", clientRequest.apiKey(), clientRequest.correlationId(), nodeId, version);
} else {
version = versionInfo.latestUsableVersion(clientRequest.apiKey(), builder.oldestAllowedVersion(),
builder.latestAllowedVersion());
}
// The call to build may also throw UnsupportedVersionException, if there are essential
// fields that cannot be represented in the chosen version.
doSend(clientRequest, isInternalRequest, now, builder.build(version));
} catch (UnsupportedVersionException unsupportedVersionException) {
// If the version is not supported, skip sending the request over the wire.
// Instead, simply add it to the local queue of aborted requests.
log.debug("Version mismatch when attempting to send {} with correlation id {} to {}", builder,
clientRequest.correlationId(), clientRequest.destination(), unsupportedVersionException);
ClientResponse clientResponse = new ClientResponse(clientRequest.makeHeader(builder.latestAllowedVersion()),
clientRequest.callback(), clientRequest.destination(), now, now,
false, unsupportedVersionException, null, null);
abortedSends.add(clientResponse);
}
}
步骤9:调用NetworkClient.poll(), 将KafkaChannel.send字段中保存的ClientRequest发送出去,同时处理服务端响应、处理超时的请求,调用用户自定义的CallBack, 完成整个生产者流程
/**
* Do actual reads and writes to sockets.
*
* @param timeout The maximum amount of time to wait (in ms) for responses if there are none immediately,
* must be non-negative. The actual timeout will be the minimum of timeout, request timeout and
* metadata timeout
* @param now The current time in milliseconds
* @return The list of responses received
*/
@Override
//获取元数据方法
public List<ClientResponse> poll(long timeout, long now) {
if (!abortedSends.isEmpty()) {
// If there are aborted sends because of unsupported version exceptions or disconnects,
// handle them immediately without waiting for Selector#poll.
List<ClientResponse> responses = new ArrayList<>();
handleAbortedSends(responses);
completeResponses(responses);
return responses;
}
//步骤一:封装了一个拉取元数据的请求
long metadataTimeout = metadataUpdater.maybeUpdate(now);
try {
//步骤二:发送请求,进行复杂的网络操作,执行网络i/o操作
this.selector.poll(Utils.min(timeout, metadataTimeout, defaultRequestTimeoutMs));
} catch (IOException e) {
log.error("Unexpected error during I/O", e);
}
// process completed actions
long updatedNow = this.time.milliseconds();
List<ClientResponse> responses = new ArrayList<>();
handleCompletedSends(responses, updatedNow);
//步骤三:处理响应,响应里就有我们需要的元数据
handleCompletedReceives(responses, updatedNow);
handleDisconnections(responses, updatedNow);
handleConnections();
handleInitiateApiVersionRequests(updatedNow);
handleTimedOutRequests(responses, updatedNow);
completeResponses(responses);
return responses;
}
private void completeResponses(List<ClientResponse> responses) {
for (ClientResponse response : responses) {
try {
response.onComplete();
} catch (Exception e) {
log.error("Uncaught error in request completion:", e);
}
}
}
【总结:】
1、KafkaProducer的整体架构流程图:
【Tips】:下图中sender线程中执行绑定回调函数的作用是清除(回收掉)RecordAccumulator缓存中已成功被发送的batch所占据的内存空间
1、一个topic 可以拆分为多个partition(这里特别注意,每个partition存储的是不同的消息,producer在生产消息(注意这里,producer发送消息的操作(send())是异步的:大致实现为:producer中有一个发送缓冲区:里面存储了所有经由调用send()方法发送的消息,然后会有一个I/O线程定期读取这个缓冲区中的消息并发送给对应的topic及partition。,所以说producer支持批处理的发送消息)时可以指定消息的路由规则:(如random,key_hash,轮询等)将发送给该topic的消息给具体路由到指定的partition上),且每个消息在被添加到分区时,都会被分配一个 offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka 通过 offset保证消息在分区内的顺序,offset 的顺序不跨分区,即 kafka只保证在同一个分区内的消息是有序的。
2、kafka broker集群内broker之间replica机制(消息同步机制,实现HA)是基于partition,而不是topic;kafka将每个partition数据复制到多个server上(也就是分别在多个broker上做该partition的副本,从partition层面实现主从架构),任何一个partition有一个leader和多个follower(可以没有);
备份的个数可以通过broker配置文件来设定.partition的leader处理所有的read-write请求,其follower仅(必)需要和leader保持同步(注意这里follower不能处理任何read-write请求,跟zookeeper主从的实现有所不同);这里同步有两种方式:同步复制和异步复制(跟mysql那里类似?).
3、producer发送消息的操作send()是异步的:
大致实现为:producer中有一个待发送缓冲区:调用send()方法发送的消息会被存储在该待发送缓冲区中(这些消息会被标记为待发送),然后send()会立即返回,然后会有一个I/O线程定期读取这个缓冲区中的消息并按指定的路由规则将该消息发送到对应的partition上。所以说producer支持批处理:成批的发送消息。
4、Producer生产的每个消息在被添加(发送)到分区时,都会被分配一个 offset(称之为偏移量),它是消息在此分区中的唯一编号,kafka 通过 offset保证消息在分区内的顺序,offset 的顺序不跨分区,即 kafka只保证在同一个分区内的消息是有序的
5、producer和broker之间消息传输的可靠性依靠ACK确认机制来实现。