Kafka在大数据领域消息中间件的位置独占鳌头很多年了,很重要的一个原因就是其能很高效的承载海量数据,这里的高效指读写能做到低延迟、高吞吐。要做到高效,不是特别难,有很多MQ以及Redis之类的组件都可以做到;要做到支撑海量数据且有良好的水平扩展性,也有很多组件,但能同时兼顾二者的,的确就不多了。而Kafka能同时兼顾,主要是在设计上花费了很多心思,核心的一些点包括:

  • 磁盘的连续读写
  • 充分利用操作系统的PageCache(预读、后写)
  • 利用零拷贝技术
  • 端到端的批量压缩

磁盘连续读写

Kafka选择直接使用磁盘做持久化而非内存主要有两方面原因:

  • 和内存相比,磁盘的容量极大,可以支撑海量数据
  • Kafka使用JVM语言开发,内存越大,GC越慢;同时,内存对象的存储往往需要占用额外的内存空间

当然,选择将数据直接写入磁盘,磁盘与内存的性能差距将是一个避不开的问题。众所周知,Kafka通过磁盘连续读写+内核的PageCache解决了该问题。

磁盘之所以慢,主要慢在了寻址(seek)上面,一般而言,磁盘转动磁头做一次寻址平均需要10ms的时间;而寻址完成之后,往磁盘写入数据是比较快的,写入1MB的数据大约需要30ms(随着磁盘技术的发展以及不同转速规格的磁盘,数据稍有不一,但道理是一样的)。对于连续读写,只寻址一次,所以寻址时间几乎可以忽略;但对于随机读写,写一次就需要寻址一次。这里举个极端的例子感受一下差异,假设我们要写总共100MB的数据:

  • 方式一:随机写,每次写1字节,需要写约$100*10^6$次,需要的时间为$T_{随机写}= 100*10^6次寻址时间 + 100MB数据写入时间 ≈ 100*10^6* 10ms + 100*30ms ≈ 11.57天 $
  • 方式二:连续写,一次写100MB,则需要的时间为$T_{连续写}≈1次寻址时间+100MB数据写入时间 ≈ 3秒$

可以看到,差距是非常大的。这也是我们平时拷贝大量小文件非常慢,一般都要先打包甚至压缩成一个大文件在进行拷贝传输的重要原因之一。另外,在之前的文章里面也提到过,有评测磁盘的连续写入速度甚至可以媲美内存的随机读写(见The Pathologies of Big Data)。

既然连续写性能这么高,那如何实现呢?内核并没有提供连续写的API,也就是我们无法直接控制是随机写还是连续写,但一般追加写在底层都会变为连续读写。所以像常见的能接受只是追加写,且对写入性能要求较高的日志、数据库WAL等场景都是追加写。Kafka的数据在后台实际也是以日志文件的形式保存,也就是存储数据的时候是追加写的。追加写除了可以带来连续写的高效外,还有一些其它好处,比如无论数据量多大,所有顺序执行的操作复杂度都为O(1);读和写不会相互阻塞等。当然,为了解决超大文件给后面按照位移(offset)消费以及数据老化带来的问题,Kafka会限制文件的大小,超过后就滚动形成另外一个文件,同时还创建了一些索引文件,以提高后面的查找速度。

消费的时候在一个分区内也是按消息顺序消费的,所以写的时候连续写的数据,在消费的时候几乎也就是连续读了,所以也可以充分享受操作系统的预读特性,提高读的效率。

引申:因为机械盘的连续读写能力和SSD的读写能力其实差异不是特别大,所以尽管Kafka使用磁盘做持久化,但一般磁盘都不是Kafka的性能瓶颈,所以也通常无需使用SSD。

PageCache

虽然磁盘连续写速度已经很快了,但如果特别频繁的写小数据性能自然也无法保证,而且可能还会慢慢演变成随机写,这种情况一般的思路都是搞个buffer缓存一批数据后再写,以提高吞吐。而对于写磁盘这种情况,除了应用自己内部实现缓存外,还有一种选择就是直接利用操作系统自身的PageCache(也称Disk Cache)机制,Kafka选择了后一种。原因也很简单,后一种完全符合场景需求,而且还会带来额外的一些好处:完全由操作系统负责,应用无感知,简化了开发;PageCache不依赖于应用,即使应用重启,PageCache的缓存依然是有效且可用的;应用内不做缓存,可以减少应用的内存,降低GC的负担。

上面讨论的是写,读也能从PageCache中获益不少。如果数据处理及时,读数据的时候,写入的数据很可能还在PageCache中,这样就无需从磁盘加载,直接就从内存读了。这也是使用Kafka应该尽量做到的点。

引申:因为Kafka设计上使用PageCache做缓存,而不是自己在JVM进程中做应用级的缓存,所以Kafka的JVM Heap一般不需要设置很大,即使大数据量场景,通常5~6GB也足矣。但这不代表它不需要大内存,只是我们需要将更多的内存留给系统PageCache,让Kafka去使用。这点和ES非常像,其实所有比较依赖PageCache的进程都是如此。

零拷贝技术

关于零拷贝(zero-copy)技术的细节就不展开了,网上的文章多如牛毛,这里推荐两篇文章,网上绝大部分文章也都是从这两篇文章来的:

其实原理比较简单,一方面省去了内核态和用户态的数据拷贝(随之也省去了多次上下文切换),另一方面省去了内核态内部的数据拷贝。但对于零拷贝我觉得有两个点需要注意:

  1. 所谓的零拷贝是从内核和CPU的角度而言的,并不是说整个过程没有任何数据拷贝。首先,用户态和内核态的拷贝为0(用户态被直接跳过了);整个过程CPU的参与为0,所有数据中转都是通过DMA进行的:DMA负责将数据从磁盘拷贝到内存,然后从内存拷贝到网卡。
  2. 虽然零拷贝效率很高,但仅适用于从磁盘加载,且无需做任何数据改动就可以直接发送的场景。如果数据在发送前需要做处理,那还是必须从内核态buffer拷贝到用户态buffer,修改后再拷贝回去,也就是我们平时用的最多的场景。Kafka在设计上就就考虑到了这些,所以存储的数据是可以直接发送,而无需做处理的。

所以,这里的零拷贝技术主要是提高了Kafka的读能力。另外,Kafka的消费模型可能会存在多个消费者消费同一份数据,这样,当第一次消费数据时数据会从磁盘拷贝到内存,在缓存有效期间,后续消费者再次消费数据的时候就直接从内存读了,这也是一个设计亮点。

端到端的压缩

关于压缩,看着是最好理解的地方,但其中却有很多需要注意的地方。

论述之前,先提一下消息集(Message Set),虽然对于用户来说,生产和消费都是按照一个一个消息进行的,但Kafka内部的很多操作、传输及存储都不是以单个消息为单位的,而是以消息集为单位的。所谓消息集就是包含多个消息的集合,按照消息集处理主要是为了减少大量小IO操作(网络传输、内存分配、磁盘读写),以提高效率。

然后说压缩。压缩这个事情其实应用层可以自己做,但Kafka做的好处是可以“批量压缩”,即以消息集为单位进行压缩,而不是对单条消息进行压缩(注:以消息集为单位进行压缩是Kafka V2版本的消息格式后才有的,V1版本还是单条消息进行压缩的)。所以,通常的情况就是生产者生产的消息会被组装成消息集通过网络发送到服务端(Broker),然后服务端会按照收到的格式写入到文件,消费者消费的时候服务端再从文件读取直接发给消费者,整个过程中不需要改变消息(也就是用户态代码不需要更改从磁盘加载的数据),所以才可以使用零拷贝。

关于压缩,有个注意点就是在生产者(Producer)和服务端(Broker)都可以指定压缩格式,如果两边不一致,那服务端在收到消息后会重新进行压缩,应该要避免出现这种情况。

结语

Kafka之所以高效,是其设计之初就有了明确的场景和目标,然后针对目标,进行了一系列精巧的设计。单就某一个点去看,也没有什么特别的新颖独到之处,都是应用挺广挺成熟的技术,但环环相扣起来,却造就了一个精妙的系统。