• Kafka 设计思路


    Kafka 设计思路

    ***如需转载请注明出处,谢谢!!!***

    本文大纲


    一、开篇

    之前我们已经大致阅读完了大数据存储领域的一个著名框架:HDFS。

    今天我们继续阅读另一个大数据存储领域的框架:Kafka。

    做大数据不可能不知道 Kafka,在日志采集、实时计算等领域,都有它的身影。而且 Kafka 的源码是众多开源项目中,代码质量比较高的一个,也比较有观赏性。

    Kafka 的高可用和高性能,是各大公司偏爱它的一个非常重要的理由。能做到如此高性能,作者肯定是花费了一番心力的,不能放过任何一个细节。

    本次,我们来聊一下 Kafka Producer 客户端发送数据的精彩代码。

    二、主流程展示

    首先,需要先对 Kafka Producer 端发送数据的流程有一个大致的印象,高屋建瓴。下面就是 Producer 发送数据的主要流程图。这张图中并没有展示过多的细节,而是对主要流程做了说明。

    1、 首先把要发送的消息封装为 ProducerRecord;

    2、把消息的 key(如果设置了的话) 和 value 序列化;

    3、使用分区器,计算需要把这个消息发送到哪个分区上;

    4、把消息放入 RecordAccumulator (32M 的内存中),然后分批次发送;

    5、Sender 线程取出消息发送到 Kafka 集群上。

     

    三、Kafka 集群元数据信息

    在右边的 Kafka 集群的示意图上,可以看到 每个 Broker 上有元数据信息,那么元数据到底有哪些信息呢?


    如上图,MetaData 就是对于 Kafka 集群元数据信息的封装。

    MetaData 里有对元数据的描述信息,包括更新时间、版本、更新频率等,还包括集群的真正的元数据 Cluster。

    Cluster 对象中有主题、分区,节点,副本,正在同步的副本,以及对于这些信息结合在一起的各种数据结构。

     

    四、Kafka 源码环境搭建

    1、选择 Kafka 版本

    本次我们使用 Kafka 0.10.1.0 版本的代码来阅读,为什么选择这么低的版本呢?现在(2021-04-18) Kafka 的最新版本已经是 2.7.0 版本了。

    因为老一点版本的代码结构比较清晰。这样著名的开源项目,很多人都会去提交一些 patch,但是提交 patch 的开发人员代码质量参差不齐,会给源码阅读带来很多困扰。

    2、环境搭建

    (1)把源代码从 gitee 上 git clone 到本地,并切换到 0.10.0.1 分支

    git clone git@gitee.com:apache/kafka.git
    git checkout 0.10.0.1

    (2)下载 gradle 3.0 ,解压缩到本地某个目录

    https://gradle.org/releases/

    (3)打开 IDEA,直接 open kakfa 源码的文件夹

    修改 gradle 为下载好的 3.0

    等待依赖下载完成就可以了。

     

    五、Kafka 源码走读

    1、从 example 下的 Demo 类开始(当前位置:KafkaConsumerProducerDemo)

    2、打开 Producer 类,看里面的 run 方法,看到异步发送消息的方法(当前位置:Producer)

    3、一直点到 doSend 方法里(当前位置:KafkaProducer)

    4、可以看到主流程代码不是很多,主要是分为下面几步,也就是上面我们的图上描述的

    (1)第一步,等待获取集群元数据

    这里的源码估计需要单独开个文章来写,还是有点复杂的。

    点进去可以看到主线程唤醒了 Sender 线程去拉取元数据

    sender.wakeup();

    然后自己在那等待元数据拉取完毕

    metadata.awaitUpdate(version, remainingWaitMs);

    (2)第二步,对消息进行序列化

    (3)第三步,使用分区器对消息进行分区


    分区之后,就知道每条消息要发往哪个分区了,但是在分区之前,要获得集群的元数据才能知道该发送到哪个分区中。

    (4)第四步:确认消息是否超过了大小

    这里的大小,一个是每条消息不能超过 Kafka 配置的每条消息的大小;

    另外一个是不能超过 accumulator 的大小(32M)

    (5)第五步:根据元数据信息,封装分区对象

    (6)第六步:给每一条消息绑定回调函数

    (7)第七步:把消息放入accumulator 中(32M的一个内存),然后把消息封装为一个个批次发送

    (8)第八步:唤醒 Sender 线程,他才是真正发送数据的线程

    this.sender.wakeup();

    可以看到,主流程非常清晰,只有上面的八个步骤。

     

    六、重点分析往缓存池写消息的过程

    1、缓存的结构和设计思想

    我们重点来分析一下这个缓存结构:

    为什么要设计这样的一个缓存呢?

    原因是如果每一条消息都触发一次发送请求,由于网络 I/O 会很慢,速度会上不去,吞吐量也就成为一个问题。

    所以 Kafka 在这里设计了这样的一个缓存结构。它的设计思想是:有两个线程,一个是 main 线程,直接把消息发送到缓存中就直接返回;一个线程是 Sender 线程,在消息积累到一定大小时,通过 I/O 和 kafka server 交互发送消息。

    这个设计思想,值得我们学习 !!!

     

    2、缓存结构分析

    看一下这个缓存的结构:

    图片

    当消息序列化之后,经过分区器,会计算出来这个消息应该发送到哪个 topic 的哪个分区里面。

    batches 这个 Map,会记录每个分区对应的 Deque,每个 Deque 里面放的是消息的批次。

    这就是这个缓存的结构。

    下面思考一个问题,在高并发每秒百万条消息的情况下,如何在保证最佳性能的情况下,还能维持数据在每个分区有序发送?

     

    3、消息写入缓存源码分析

    下面我们来看 RecordAccumulator 类的 append 方法(158 行)

    首先要注意 batches 这个 结构:

    private final ConcurrentMap<TopicPartition, Deque<RecordBatch>> batches;

    是一个线程安全的Map,key 是每个 topic 的分区,值是一个队列,队列的类型是 RecordBatch。

    (1)假设线程1往缓存结构中写入第一条消息

    首先尝试获取这个分区对应的队列:

    图片

    一开始分区对应的队列肯定是空的,所以创建了一个 ArrayDeque,放入到 batches 里面。

    此时 topic 这个分区里面就有了对应的队列了。

    图片

    获取到队列之后,尝试把数据写入到队列对应的批次中:

    图片

    第一次进来,肯定队列中是没有批次的,所以返回了空:

    图片

    返回了空之后,继续往下执行。

    然后根据批次的大小(默认是 16K)和消息的大小,取一个最大值。防止特殊情况下,一个消息比批次还要大。

    然后申请了一个 16K 的内存。

    图片

    然后继续尝试往队列的批次中写消息

    图片

    这个时候,批次仍然没有,返回空:

    图片

    于是线程1,创建了一个批次,并且把这条消息写入到批次里面,并且放到了队列里面

    图片

    (2)假设线程 1 往缓存结构中写第二条消息

    同样的,获取队列:

    图片

    此时队列已经建好了,所以不为空,然后尝试写入消息:

    图片

    此时,队列中已经放入了一个批次了,所以批次也不为空,直接把消息写进去了,返回值也不为空:

    图片

    直接返回了:

    图片

    (3)现在假设两个线程,并发往这个结构写消息,会不会有问题呢?我们还是从头来一遍:

    假设2个线程都是要往同一个分区里写消息。

    下面的代码由于是 synchronize 加锁的,所以只有线程1能进来:

    图片

    线程1,执行完后,释放了锁,线程2也进来了,发现也没有队列,也要继续执行。

    图片

    可以看到,线程1 在 synchronize 外,申请了一段 16K 的内存,然后获得锁,尝试写消息,由于队列是空,返回空,继续执行。

    然后创建了一个批次,写入消息,并且把批次写到了队列里。释放了锁。

    下面看线程2,进来会怎样。

    图片

    线程2 ,首先申请内存。获得锁,尝试往队列中写消息,发现队列中已经有一个批次了,于是直接把消息写到批次里面,返回值不为空,释放掉了刚刚申请的内存。

    下面再看如果线程3,此时进来会怎样。

    线程3,从方法最开始执行。

    图片

    可以看到线程3,直接就返回了。

    线程1,线程2,线程3,在 synchronize 加锁下,有序执行。

     

    七、精彩代码 - 分段加锁

    回头再看这个 append 方法,难免会有点奇怪,为啥要搞这么多 tryAppend 方法?

    正常思维是,3个线程有序写消息,直接在 append 方法上加一个 synchronize 不就行了。

    这就是它设计高明的地方。

    可以看它的代码结构:

    public xxx append(xxx) {
        // 获得队列
        Deque<RecordBatch> dq = getOrCreateDeque(tp);
        
        // 加锁
        synchronize (dq) {
            xxxx
            tryAppend();
        }
        
        // 申请内存,未加锁
        申请内存(比较耗时,所以没加锁);
            
        // 加锁
        synchronize (dq) {
            xxxx;
            tryAppend();
            xxxx;
        } 
    }

    可以看到上面的这个结构中,对于两段代码分别加了锁,加了锁的代码是完全的内存操作,速度很快。

    但是对于申请内存这种耗时的操作,就没加锁。

    使用这种分段加锁的结构,就能大大提高执行效率,比直接在方法上加一个 synchronize 要好很多。

    然后为什么要搞这么多 tryAppend 呢?就是因为分段加锁,要控制多个线程的执行逻辑合理有序,才这么设计的。

    八、精彩代码 - 写时复制,线程安全

    我们再来看这段代码:

    public xxx append(xxx) {
        // 获得队列
        Deque<RecordBatch> dq = getOrCreateDeque(tp);
        
        // 加锁
        synchronize (dq) {
            xxxx
            tryAppend();
        }
        
        // 申请内存,未加锁
        申请内存(比较耗时,所以没加锁);
            
        // 加锁
        synchronize (dq) {
            xxxx;
            tryAppend();
            xxxx;
        } 
    }

    发现这行代码是没有锁的,那多个线程执行不会有问题吗?

    Deque<RecordBatch> dq = getOrCreateDeque(tp);

    此时要看一下这个 batches 的是什么结构。

    this.batches = new CopyOnWriteMap<>();

    可以看到,这个 Map 结构实质是一个写时复制的 Map 。

    看一下它的实现类的 put 方法:

        @Override
        public synchronized V put(K k, V v) {
            Map<K, V> copy = new HashMap<K, V>(this.map);
            V prev = copy.put(k, v);
            this.map = Collections.unmodifiableMap(copy);
            return prev;
        }

    可以看到在 put 的时候,把原来的 map 拷贝了一份,在拷贝的 map 上写,写完了之后,再把 map 复制给之前那个 map。

    这样就没有并发问题了?确实没有了。

    每一次写都是在一个独立的内存上写,当然没有了。

    读的时候,是没有并发问题的。

    这样不会有性能问题吗?

    有的。但是!这个 batches 结构的 key 是 topic 的分区。相对于千千万万的消息来说,分区数是非常少的。就是说,put 的次数非常少,这点性能也就可以忽略不计了。

     

    九、总结

    本次,我们初步了解了 Kafka Producer 端的写数据流程,并且重点说明了为了提升 Kafka Producer 端的写性能,Kafka 特意设计了一个缓存结构,把消息封装成批次,再批量发送出去。

    最后,我们还赏析了,为了极致的提升性能,Kafka 在往缓存结构写消息的时候,还使用了分段加锁、写时复制的代码技巧,极大的提升了性能。

     

     

     
  • 相关阅读:
    ubuntu15.04下安装配置docker
    在Vmware/VirtualBox虚拟机环境中正确打开虚拟机
    windows下使用cx_freeze将Python程序打包成exe可执行文件
    Python正则表达式学习小结
    Ubuntu环境下利用Python3+PyQt5+Eric6进行GUI编程
    C#学习笔记——控件篇
    商务英语学习第一课(lending(借贷))
    Ajax的请求方式:get和post
    XML就像是扩展应用范围的HTML
    JQ数组=jQuery对象数组
  • 原文地址:https://www.cnblogs.com/lucky-cat233/p/14680307.html
Copyright © 2020-2023  润新知