kafka技术内幕
前置概念理解
在开始介绍kafka细节之前,我们先对一些比较重要的概念做出解释
ISR集合HW和LEO
ISR集合
ISR是In-Sync-Replica的简写, ISR集合中的的副本保持和leader的同步,当然leader本身也在ISR中。初始状态所有的副本都处于ISR中,当一个消息发送给leader的时候,leader会等待ISR中所有的副本告诉它已经接收了这个消息(当Acks为all的时候),如果一个副本落后太多,达到一个阈值,比如500个消息差异的时候,那么它会被移除ISR。下一条消息来的时候,leader就会将消息发送给当前的ISR中节点了。当这个异常的副本恢复后,并且追上当前当前Leader的ISR阈值,它将会被重新纳入到ISR集合中。
- 满足ISR集合的两个条件
1、副本所在的节点必须维持着与zookeeper的连接
2、副本最后一条消息的offset与Leader副本最后一条消息offset之前的差距不能超过阈值
HW&LEO
-
HW(High Watermark)与上面的ISR集合紧密相关。HW标记了一个特殊的offset,当消费者处理消息的时候,只能拉取到HW之前的消息,HW之后的消息对消费者不可见。与ISR类似HW由Leader管理,当ISR集合中全部Follower都拉取到HW指定消息进行同步后,Leader副本才会递增HW值。kafka官方之前称呼HW为commit,其含义是即使leader损坏,也不会出现数据丢失的情况 (HW可以理解为当前ISR可见的最高消息水位) 。
-
LEO(Log End Offset)是所有的副本都会有的一个offset标记,它指向追加到当前副本的最后一个消息的offset。当生产者向Leader副本追加消息的时候,Leader副本的LEO标记会递增,当Follower从Leader同步数据后Follower的LEO也会递增
交付语义保证
kafka由于采用了offset的方式进行消息管理,所以生产者增加offset和消费者提交offset的时机就显得格外重要
目前kafka支持语义有三种:
At most once:最多一次,消息可能会丢失但是绝不会重复消费
At least once:至少一次,消息不会丢失,但是可能会重复消费
Exactly once:精准一次,消息不会丢失,也不会重复消息(事务模式)
幂等性和事务
为了满足交付语义保证,kafka通过两种方式去实现,一个是broker支持的消息幂等性,另一个是kafka集群提供事务保障,幂等性是提供有限事务支持,当跨broker跨parition的时候幂等特性就会失效,但是事务不会。但是幂等性比事务能承受更高的TPS,一般来说幂等性接口是通过消息的key来实现的,每个消息的key都需要是唯一的。
服务端
kafka客户端会与服务端的多个broker创建网络连接,在这些网络连接上流转着各种请求及其响应,从而实现客户端与服务端之间的交互。客户端一般情况下不会碰到大数据量访问、高并发的场景,所以客户端使用NetworkClient组件管理这些网络足矣。kafka服务端与客户端运行场景不同,面对高并发,低延时的需要,kafka服务端使用Reactor模型进行网络层管理。kafka客户端的连接不但有来自client的连接也有来自Broker的连接。
服务的模型
- 图二来源极客时间《kafka专栏》
kafka是最典型的高并发低延迟的模型,而在java里面最成熟的莫过于 Netty 实现的 Reactor,所以从图中可以看出kafka的服务端本身也是使用了Reactor的方式实现的。
图中Acceptor单独运行在一个线程中,用来处理客户端发起的连接请求,Reader在Selector中注册OP_READ事件,负责服务端读取请求逻辑,也是一个线程处理多个Socket连接。Reader ThreadPool中的线程成功读取请求后,将请求放入MessageQueue这个共享队列中,Handler ThreadPool会从这个队列中读取出请求然后进行业务处理,这种模式下即便处理某个请求阻塞了,线程池也会有其它线程继续从队列中获取请求继续处理,从而避免了整个服务端的阻塞。当处理完成后,Handler负责发送响应给客户端,这就需要Handler ThreadPool中的线程在Selector中注册OP_WRITE事件,实现发送响应的功能。
除此之外MessageQueue还能提供缓存的功能,所以MessageQueue的队列长度的设计就显得格外的重要了
图二中有一个叫做 Purgatory 的组件,叫做炼狱,这里面也是一个队列,专门用来保存那些同步发送的消息,当acks值是 all 的时候全部follower同步完成响应才会从 Purgatory 进入到响应队列里面
(下图是broker - topic - partition之间的关系)
kafka集群很有特色,通过partition对topic进行分块从而让topic支持负载均衡,不同的partition分摊了同一个topic的压力,但是也由于不同的partition导致消息只能在同一个partition中保证顺序,不同的partition顺序无法保证。所以当需要保证消息顺序的情况下,生产者发送消息的时候选择partition就显得格外重要了 (当需要保证业务消息顺序的时候,建议指定partition的方式发送,这样可以保证需要保证顺序的一类消息在同一个partition里面) 。
向Zookeeper说不
在旧版的Kafka中,将元数据,consumer数据,producer数据都维护在zookeeper里面通过Watcher实现,但是这样会有两个严重的问题:
-
1、羊群效应
当一个被Watcher的Zookeeper节点发生变化,会导致大量的Watcher需要发送通知给客户端,导致在通知期间发送网络风暴,同时通知Consumer之后会直接导致Rebalance,就出现了羊群效应。 -
2、脑裂
每个Consumer都是通过Zookeeper中保存的这些元数据判断Consumer Group状态、Broker的状态以及Rebalance结果,由于Zookeeper只保证“最终一致性”,不保证"Simultaneously Consistent Cross-Client Views"(同时一致的跨客户端视图),不同Consumer在同一时刻可能连接到Zookeeper不同的集群的服务器,看到的元数据可能就不一样,这就会造成不正确的Rebalance。(简而言之就是由于网络异常,导致Zookeeper被分成了两个规模差不多的集群,并且两个集群同时对外提供服务,导致Zookeeper集群上面的数据不一致,同时可能会发生集群同时存在两个控制器的场景) -
3、元数据压力大
经常会有一些提交,例如consumer的offset或者HW和LEO的信息记录在Zookeeper上面,这样会大致Zookeeper承受着强大的压力,当Zookeeper崩溃的时候所有的Kafka Topic都无法正常运行。
所以在新版的kafka中逐渐摒弃了对zookeeper的依赖,例如:新版的zookeeper Consumer Group 的 offset 不再记录在 zookeeper上面,通过 _consumer_offsets 这个内部Topic去维护这类信息
kafka选举
控制器选举
控制器其实就算一个broker,只不过它除了具有一般broker的功能之外,还负责分区首领的选举。集群里第一个启动的broker通过在zookeeper抢占/controller节点让自己成为控制器。其它broker在启动也会尝试创建这个节点,不过他们会收到一个节点已经创建的异常。然后意识到控制器节点已经存在,其它节点继续watch这个节点,以便后续controller奔溃后抢占。
副本Leader选举
控制器的目的就是选举partition的Leader,每个Topic会有多个partition进行负载均衡,每个partition会有多个副本保证数据高可用,既然有多个副本必然就会有Leader和Follower,Leader进行接收来自客户端的请求,Follower负责同步Leader的数据,当Leader挂掉的时候,控制器一般情况下会在当前 ISR 集合的同步副本中选取一个follower当成leader。
日志写入以及零拷贝技术
kafka日志写入以及索引
kafka为了提高消息的可靠,消息在写入topic后会写入到文件中,以保证消息的落地从而不会导致消息丢失的情况,如图就是kafka每个partition写入消息的方式。
在服务端,会生成指定的目录文件进行数据落地
-
目录命名规则
<topic名>-<partition_id>
-
文件的命名规则
日志文件: [文件的第一个消息offset].log
索引文件: [文件的第一个消息offset].index (二进制)
索引文件: [文件的第一个消息offset].timeindex(二进制)
备份进度文件:leader-epoch-checkpoint
入下图:
消息在写入kafka后会在磁盘生成多个文件 【.log】文件和 【.index】 文件,其中为了提高查询消息效率每个日志文件都会有一个索引文件,这个文件没有为每条消息建立起索引,而是使用稀疏索引的方式为日志文件建立起索引。
其中文件 leader-epoch-checkpoint 保存了每一任leader开始写入消息时的offset 会定时更新,如果发生了leader变更,那么新的 ISR 集群的HW会基于这份文件来定义。前面三个文件,log index timeindex 是一起生成的,其中index 一个是顺序索引,一个是时间索引。
- leader-epoch-checkpoint的内容
索引文件和日志文件关联
kafka 零拷贝技术
- 首先要声明的一点kafka的零拷贝并不是由Java实现的,是通过Java调用底层操作系统的DMA(Direct Memory Access)方式实现的。那么操作系统层面的零拷贝技术是如何实现的呢?
首先我们看看传统意义的零拷贝技术是怎么样实现的吧,传统的文件拷贝实现如下图:
- 如果这个过程不是很理解可以看看如下的代码:
String fileName = "aaa.file";
InputStream inputStream = new FileInputStream(fileName);
OutputStream outputStream = new DataOutputStream(socket.getOutputStream());
//用户态缓冲空间
byte[] buffer = new byte[4096];
long read = 0, total = 0;
while ((read = inputStream.read(buffer)) >= 0) {
total = total + read;
outputStream.write(buffer);
}
-
从上面可以看到我们开辟了一个buffer用来从输入流读取数据,然后通过输出流将缓冲区输出,从中可以看出buffer就是那多余的一步,那么如果我们能直接控制,CPU将读取的页缓存发送给socket buffer 这样就能大大提高效率,少了两次拷贝。
-
于是使用了DMA技术后就如同下图:
由于DMA是Linux提供的内核操作,那么作为Java只能使用内核提供的接口,在Java中涉及到DMA的就是 java.nio.channels.FileChannel中的transferTo方法 【注意不是:transferFrom】
String fileName = "aaa.file";
long fileSize = 123456789L , sendSize = 4096;
FileChannel fc = new FileInputStream(fileName).getChannel();
FileChannel fos = new FileOutputStream(dstPath).getChannel();
long nsent = 0,current =0;
current = fc.transferTo(0,fileSize,fos);
这里代码写的比较粗糙但是不难看出其中的不同
- kafka中使用到DMA的位置:
- kafka虽然是使用Scala写的,但是在源码中,创始人还是大量使用JDK里面 java 包实现的SDK,换句话来说,kafka其实是可以被改成java的毕竟都是JVM语言,正如RocketMQ很多地方都有借鉴kafka的思想,但是RocketMQ却是完全使用Java实现的。
生产者
kafka在实际应用中,经常被用做高性能,高扩展的消息中间件。kafka目前定义了一套网络协议,只要遵循这套协议格式,就可以向kafka发送消息,也可以从kafka拉取消息
生产者模型
- 执行顺序:
1、ProducerInterceptors 对消息进行拦截
2、Serializer 对消息的key 和 value 进行序列化
3、Partitioner 为消息选择合适的 Partition
4、RecordAccumulator 收集消息,实现批量发送
5、Sender 从 RecordAccumulator 获取消息
6、构造 ClientRequest
7、将ClientRequest 交给 NetworkClient,准备发送
8、NetworkClient 将请求放入KafkaChannel 的缓存(网络请求缓存)
9、执行网络I/O,发送请求
10、收到响应,调用ClientRequest 的回调函数
11、调用 RecordBatch 的回调函数,最终调用每个消息上面注册的回调函数
这里比较重要的应该就是RecordAccumulator,在kafka_client里面RecordAccumulator使用ConcurrentMap维护着一个个发送缓冲区,Key是TopicPartition,value是Deque< RecordBatch > ,其中消息放在RecordBatch里面并且压缩好准备发送。除此之外,使用者可以通过重写Serializer 和 Partitioner 去实现消息的指定序列化方式,以及消息发送给哪个Partitioner ,其中Serializer kafka默认提供了 StringSerializer 和 StringDeserializer 等基本数据结构序列化器 实现序列化和反序列化,Partitioner 在 kafka中也实现了 RoundRobinPartitioner 默认使用轮询的方式发送给指定的partition
InFlightRequests 队列主要作用是缓存了已经发出去但没有收到响应的ClientRequest。其底层是通过一个Map<String,Deque< ClinentRequest >> 对象实现的,key是NodeId,value是发送到对应Node的ClientRequest对象集合。InFlightRequests提供了很多管理这个缓存队列的方法,还通过配置参数,限制了每个连接最多缓存的ClientRequest个数。
- 这里有一个细节,如果生产者的压缩模式和broker的topic指定的压缩模式不一致,broker会解压后再重新压缩
所以在配置的时候,尽量保持生产者和broker的压缩模式一致,不然broker会耗费比较高的cpu去进行解压重压的操作。
同步和异步发送
同步发送,示例
由于这里的示例我进行了封装(使用ThreadLocal),所以大致了解一下流程就好了
通过调用.get(),等待服务器响应,那么每次同步发送都会走完上面流程图中的所有过程,并且等待respon响应
异步发送,示例
异步调用,将Message放入缓存区里面,等待唤起Sender去执行发送
唤起方式:
1、多个或者第一个RecordBatch已满
2、有其它线程等待缓冲区空间
3、显示调用KafkaProducer的flush方法
4、Sender准备关闭
消费者
kafka_client不仅仅实现生产者KafkaProducer也实现了KafkaConsumer,通过KafkaConsumer使用者无需关心网络协议等底层因素,通过API的方式让开发者轻松使用Kafka API ,其中 KafkaConsumer 实现了 心跳机制,重试机制,网络管理等操作。推荐使用java api提供的KafkaConsumer 旧版的Scala的consumer将会被逐渐废弃。(此处图片来源:https://www.cnblogs.com/huxi2b/p/7453543.html)
- HW图解
消费者模型
从图中可以看出来,同一个组的消费者的消费状态会根据当前的Broker状态进行动态变化,当消费者多于partition的时候可能会出现有消费者消费不到消息的情况,所以合理的配置消费者数量是非常有必要的,当然你自己也可以采取多线程消费的方式,不同的消费组之间是广播的关系,但是经过我的测试kafka貌似会有一个主消费者组的概念(主消费者主能保证消息交付语义保证,其它消费者)
Rebalance
Rebalance 也叫做从平衡,为了满足上面的消费者模型必然会产生Rebalance 。在同一个Consumer Group中,同一个Topic的不同分区会分配给不同的消费者进行消费,那么当这个Consumer Group 成员发生变化的时候,例如有consumer加入组会发起 JoinGropRequest,每个Consumer 对应消费的partition也会随之发生改变,那么就需要 Rebalance 的辅助
- Rebalance时机:
条件1:有新的 consumer 加入
条件2:旧的 consumer 挂了
条件3:coordinator 挂了,集群选举出新的 coordinator
条件4:topic 的 partition 增加
条件5:consumer 调用 unsubscrible(),取消topic的订阅
方案一:
通过把同一consumer group的信息维护在zookeeper上面,然后注册Watche去监听各个consumer节点的状态,当节点发生变化的时候Watche通知节点触发Rebalance,重新分配消费模式。
方案二:
将全部Consumer Group 分成多个子集,每个Consumer Group 子集在 服务端 对应一个GroupCoordinator 对其进行管理,GroupCoordinator 是 kafkaServer 中用于管理Consumer Group 的组件,其具体内容在第4章中详细介绍。消费者不再依赖Zookeeper。而只有GroupCoordinator 在Zookeeper上添加Watcher。消费者在加入或退出Consumer Group 时会修改Zookeeper中保存的元数据,这点与上下文描述的方案类似,此时会触发GroupCoordinator 设置的Watcher通知GroupCoordinator 开始Rebalance 减少Watcher通知的量级。在Consumer加入Group也会先请求KafkaServer获取相应GroupCoordinator 的位置
方案三:
在kafka 0.9版本后,对Rebalance重新设计了,将分区分配放到consumer这端进行依然使用 GroupCoordinator 处理。在之前的协议基础上进行了修改,将 JoinGropRequest 拆分成了两个阶段,分别是 Join Group 和 Synchronizing Group State 阶段。
当消费者找到GroupCoordinator之后,就会进入Join Group 阶段,Consumer 首先向 GroupCoordinator 发送 JoinGropRequest 后会暂存消息,收集到全部消费者后,根据JoinGropRequest中的信息来确定Consumer Group 中可用的消费者,从中选取一个消费者成为Group Leader ,同时还会指定分区策略,最后将这些信息封装成功 JoinGroupResponse返回给消费者。
补充一下GroupCoordinator实际关系
总结
kafka吞吐量高的原因
1、生产者支持批量异步发送
2、消费者支持主动批量拉取消费,同时支持异步提交
3、Partition支持Topic的负载均衡(允许横向拓展)
4、broker使用顺序写入与零拷贝技术
由于制作的时候信息来源多样,再将知识点整合的时候难免会发生缺漏,如果有问题请及时指出,并且后续也会进一步完善这篇文章