Kafka 高性能剖析

178 阅读14分钟

Kafka 是分布式发布-订阅消息系统。它最初由 LinkedIn 公司开发,使用 Scala语言编写,之后成为 Apache 项目的一部分。Kafka 是一个分布式的,可划分的,多订阅者,冗余备份的持久性的日志服务。以可水平扩展和高吞吐率而被广泛使用。目前越来越多的开源分布式处理系统如Cloudera、Apache Storm、Spark等都支持与Kafka集成。它主要用于处理活跃的流式数据。
今天我们主要从设计者的角度来理解和分析一下,为什么kafka能够拥有极高的吞吐量。

1. 批处理

消息写入流程:

image.png

消息的路由:

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

  • 指定了partition则直接使用;
  • 未指定partition但指定了key,则通过对key的value进行hash选出一个partition;
  • partition和key都未指定,使用轮询选出一个partition。

消息的写入流程说明:

  1. 确定发送消息到哪个分区;
  2. peroducer先从zookeeper的“/brokers/.../state”节点找到该partition的leader;
  3. producer将消息发送给该leader,leader将消息写入本地log,follower从leader pull消息,写入本地log后向leader发送ACK;
  4. leader收到所有ISR中的replica的ACK后增加HW(high watermark,最后commot的offset),并向producer发送ACK。

kafka producer支持批量发送,可减少通信次数提高网络通信效率。

image.png

producer首先会将消息封装成一个ProducerRecord对象,然后进行发送。在进入KafkaProducer中的第一步,就是将消息进行序列化serializer,接着结合本地缓存的元数据信息建立目标分区partition,最后写入缓存区buffer。在此同时,会有一个专门的Sender I/O线程负责将缓冲池memory中的消息分批次发送给给Kafka broker。

客户端双线程处理 image.png

主要涉及的参数 ,三个条件,满足任一即会批量发送:

  • batch-size :批量发送消息的的等待大小,默认为16k,配置0则禁止批量发送
  • buffer-memory :发送线程缓存消息的内存大小,如果满了,发送线程会阻塞, 默认32M
  • linger.ms :超过收集的时间的最大等待时长,单位:毫秒。

思考:如何平衡网络利用率和时延?(batch.size、linger.ms、fetch.wait.max.ms)

2. 顺序写盘

顺序写代替随机写。Kafka采用了文件追加的方式来写入消息,只能在日志文件的尾部追加新的消 息,且不允许修改已写入的消息,顺序写盘以提高性能。

image.png

3. 页缓存

页缓存是Linux内核中的一种重要的高速磁盘缓存,是计算机随机存取器RAM(内核缓存)中的一块区域,主要是负责用户空间与磁盘文件之间的高效读写。使用操作系统层面的缓存,就是通过将数据首先写入pagecache(页缓存),达到一定的阈值把数据刷新到磁盘。页缓存减少了连续读写磁盘文件的次数,操作系统自动控制文件块的缓存与回收生命周期, 用访问RAM的缓存代替访问磁盘区域的机制,增强查询效率。

image.png

Kafka中大量使用了页缓存,这是Kafka实现高吞吐的重要原因之一。虽然消息都是先被写入页缓存,然后由操作系统负责具体的刷盘任务,但在Kafka中同样提供了同步刷盘及间断性强制刷盘(fsync)的功能,这些功能可以通过log.flush.interval.message、log.flush.interval.ms等参数来控制。同步刷盘可以提高 消息的可靠性,防止由于机器掉电等异常造成处于页缓存而没有及时写入磁盘的消息丢失。不过一般不建议这么做,刷盘任务就应交由操作系统去调配,消息的可靠性应该由多副本机制来保障,而不是由同步刷盘这种严重影响性能的行为来保障。

Kafka服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由系统来负责,这样会比进程内维护更加安全有效。

Elasticsearch和磁盘之间有一层称为FileSystem Cache的系统缓存(OS Cache),正是由于这层cache的存在才使得es能够拥有更快搜索响应能力。

4. 零拷贝

传统文件传输 read + write

6f061d950a7b0208410e50ca1b59e7da562cc85b.jpeg

磁盘文件 ==DMAcopy=> 页缓存 ==CPUcopy=> 用户空间缓存 ==CPUcopy=> Socket缓存 ==DMAcopy=>> 网卡

DMA direct memory access:直接存储器访问,也就是直接访问RAM,不需要依赖CPU的负载
CPU :中央核心处理器,主要用于计算,如果用于拷贝就太浪费资源

传统的网络传输,需要进行4次用户态和内核态切换,4次数据拷贝(2次CPU拷贝,2次DMA拷贝),上下文的切换涉及到操作系统,相对CPU速度是非常耗时的,而且仅仅一次文件传输,竟然需要4次数据拷贝,造成CPU资源极大的浪费。 使用零拷贝的方式,可以减少数据在应用进程和操作系统之间数据复制的次数,和上下文切换,从而达到性能的优化。

mmap + write

mmmap.png

如上图所示,mmap技术传输文件,需要进行4次用户态和内核态切换,3次数据拷贝(1次CPU拷贝、两次DMA拷贝)

相对于传统数据传输,mmap减少了一次CPU拷贝,其具体过程如下:

  1. 应用进程调用 mmap() ,DMA 会把磁盘的数据拷贝到内核的缓冲区里,应用进程跟操作系统内核「共享」这个缓冲区
  2. 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据
  3. 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的

显然仅仅减少一次数据拷贝,依然难以满足要求.

sendfile

sendfile.png

如上图所属,sendfile技术传输文件,需要进行2次用户态和内核态的切换,3次数据拷贝(1次CPU拷贝、两次DMA拷贝)

相对于mmap,其又减少了两次上下文的切换,具体过程如下:

  1. 应用调用sendfile接口,传入文件描述符,应用程序切换至内核态,并通过 DMA 将磁盘上的数据拷贝到内核缓冲区中;
  2. CPU将缓冲区数据拷贝至Socket缓冲区;
  3. DMA将数据拷贝到网卡的缓冲区里,应用程序切换至用户态.

sendfile其实是将原来的两步读写操作进行了合并,从而减少了2次上下文的切换,但其仍然不是真正意义上的“零”拷贝.

sendfile + SG-DMA

sssd.png

从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,如上图所示,sendfile + SG-DMA技术传输文件,需要进行2次用户态和内核态的切换,2次数据拷贝(1次DMA拷贝,1次SG-DMA拷贝)

具体过程如下:

  1. 通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  2. 缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

此种方式对比之前的,真正意义上去除了CPU拷贝,CPU 的高速缓存再不会被污染了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,极大地提升系统性能。

但他的劣势也很明显,强依赖于硬件的支持.

splice

Linux 在 2.6.17 版本引入 splice 系统调用,不再需要硬件支持,同时还实现了两个文件描述符之间的数据零拷贝。

splice 系统调用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了用户缓冲区和Socket缓冲区的 CPU 拷贝操作。

基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2次用户态和内核态的切换,2次数据拷贝(2次DMA拷贝),具体过程如下:

  1. 用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
  2. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
  3. CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。
  4. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
  5. 上下文从内核态(kernel space)切换回用户态(user space),splice 系统调用执行返回。

splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备.

  • 在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是是通过增加新的系统调用来完成的,比如 Linux 中的 mmap(),sendfile() 以及 splice() 等。

Kafka 中存在大量的网络数据持久化到磁盘和磁盘文件通过网络发送的过程,基于kafka组件的特性,写入的数据无需修改,只以追加log的方式写入,Kafka使用了 Sendfile 零拷贝方式,大大提高了其数据传输的性能。

5. 索引

Kafka高效文件存储设计特点

  • Kafka把topic中一个parition大文件分成多个小文件段,通过多个小文件段,就容易定期清除或删除已经消费完文件,减少磁盘占用。
  • 通过索引信息可以快速定位message和确定response的最大大小。
  • 通过index元数据全部映射到memory,可以避免segment file的IO磁盘操作。
  • 通过索引文件稀疏存储,可以大幅降低index文件元数据占用空间大小。

image.png

image.png

上图分别为一个分区下segment文件集合以及一对segment文件下索引文件和数据文件的组织结构。

每个partion(目录)相当于一个巨型文件被平均分配到多个大小相等segment(段)数据文件中, segment file由2大部分组成,分别为index file和data file,此2个文件一一对应,成对出现, partion的第一个segment从0开始,后续每个segment文件名为上一个segment文件最后一条消息的offset值。

在partition中如何通过offset查找message

例如读取offset=368776的message,需要通过下面2个步骤查找。

  • 第一步查找segment file 上述图2为例,其中00000000000000000000.index表示最开始的文件,起始偏移量(offset)为0.第二个文件00000000000000368769.index的消息量起始偏移量为368770 = 368769 + 1.同样,第三个文件00000000000000737337.index的起始偏移量为737338=737337 + 1,其他后续文件依次类推,以起始偏移量命名并排序这些文件,只要根据offset 二分查找文件列表,就可以快速定位到具体文件。 当offset=368776时定位到00000000000000368769.index|log
  • 第二步通过segment file查找message 通过第一步定位到segment file,当offset=368776时,依次定位到00000000000000368769.index的元数据物理位置和00000000000000368769.log的物理偏移地址,然后再通过00000000000000368769.log顺序查找直到offset=368776为止。

从上述图3可知这样做的优点,segment index file采取稀疏索引存储方式,它减少索引文件大小,通过mmap可以直接内存操作,稀疏索引为数据文件的每个对应message设置一个元数据指针,它比稠密索引节省了更多的存储空间,但查找起来需要消耗更多的时间。

6. topic的水平扩展 —— 分区(partition)

通过分区扩展Topic的生产和消费的吞吐量。

image.png

kafka通过将主题分成多个分区的语意来实现并行处理,生产者可以将一批消息分成多个分区,每个分区写入不同的服务端节点,生产者客户端采用这种分区并行发送的方式,从而提升生产者客户端的写入性能。
分区对消费组也有好处,消费组指定获取一个主题的消息,它也可以同时从多个分区读取消息,从而提升消费者客户端的读取性能。

6. 一致性机制之ISR

每个Partition有一个leader与多个follower,producer往某个Partition中写入数据是,只会往leader中写入数据,然后数据才会被复制进其他的Replica中。

数据是由leader push过去还是有flower pull过来?

kafka是由follower周期性或者尝试去pull(拉)过来(其实这个过程与consumer消费过程非常相似),写是都往leader上写,但是读并不是任意flower上读都行,读也只在leader上读,flower只是数据的一个备份,保证leader被挂掉后顶上来,并不往外提供服务。

同步复制:只有所有的follower把数据拿过去后才commit,一致性好,可用性不高。

异步复制:只要leader拿到数据立即commit,等follower慢慢去复制,可用性高,立即返回,一致性差一些。

Commit:是指leader告诉客户端,这条数据写成功了。kafka尽量保证commit后立即leader挂掉,其他flower都有该条数据。

kafka不是完全同步,也不是完全异步,是一种ISR机制:

AR= ISR+OSR AR:所有副本(Assigned Repllicas)
ISR:同步副本(In-Sync Replicas)
OSR:滞后副本(Out-Sync Relipcas)

  • 1、leader会维护一个与其基本保持同步的Replica列表,该列表称为ISR(in-sync Replica),每个Partition都会有一个ISR,而且是由leader动态维护。
  • 2、如果一个flower比一个leader落后太多,或者超过一定时间未发起数据复制请求,则leader将其从ISR中移除。
  • 3、当ISR中所有Replica都向Leader发送ACK时,leader才commit。

简言之,Kakfa通过动态调整ISR集合的副本来实现性能与数据安全之间的平衡,在网络拥塞和部分副本失效时避免对写入性能产生影响(ISR伸缩)。

7. 日志格式编码与压缩

kafka中有 2个概念,不能混淆

  • compress message 使用压缩算法将消息进行压缩,减少内存,网络io,与磁盘的消耗
  • compact message 将日志中的消息进行清理,类似于hbase的compact操作

节约存储和网络带宽。支持多种消息压缩方式(gzip、snappy、lz4)。可有效减少网络传输量、降低网络 I/O,从而提高整体的性能。 如果对时延有一定的要求,则不推荐对消息进行压缩。

总结

从kafka的底层设计原理可以看出它利用了良好的文件存储结构,利用操作系统来提高读写文件的性能,使用partition来水平扩展提高吞吐量,使用副本机制来保证数据的可靠性,并在可靠性和可用性之间做了巧妙的平衡和友好的可配置化使其对各种场景有都有很好的支持, 是一个非常优秀的消息中间件,也可以在很多其高性能的框架中看到类似的设计,它的设计思想也可以给我们对于程序的设计带来很好的启发。