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写入消息的方式。
kafka写入消息
在服务端,会生成指定的目录文件进行数据落地

  • 目录命名规则
    <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技术后就如同下图:
    零拷贝技术2
    由于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零拷贝技术
  • 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里面并且压缩好准备发送。除此之外,使用者可以通过重写SerializerPartitioner 去实现消息的指定序列化方式,以及消息发送给哪个Partitioner ,其中Serializer kafka默认提供了 StringSerializerStringDeserializer 等基本数据结构序列化器 实现序列化和反序列化,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 对其进行管理,GroupCoordinatorkafkaServer 中用于管理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 GroupSynchronizing Group State 阶段。
当消费者找到GroupCoordinator之后,就会进入Join Group 阶段,Consumer 首先向 GroupCoordinator 发送 JoinGropRequest 后会暂存消息,收集到全部消费者后,根据JoinGropRequest中的信息来确定Consumer Group 中可用的消费者,从中选取一个消费者成为Group Leader ,同时还会指定分区策略,最后将这些信息封装成功 JoinGroupResponse返回给消费者。
在这里插入图片描述
补充一下GroupCoordinator实际关系
在这里插入图片描述

总结

kafka吞吐量高的原因

1、生产者支持批量异步发送
2、消费者支持主动批量拉取消费,同时支持异步提交
3、Partition支持Topic的负载均衡(允许横向拓展)
4、broker使用顺序写入与零拷贝技术

由于制作的时候信息来源多样,再将知识点整合的时候难免会发生缺漏,如果有问题请及时指出,并且后续也会进一步完善这篇文章

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章