Kafka 系列(七):kafka 对性能的优化
性能问题一般常出现在三个地方:
- 网络
- 磁盘
- 复杂度
在 kafka 中性能的优化主要体现在三个方面:
- Producer
- Consumer
- Borker
kafka 作为一个分布式队列,网络和磁盘更是优化的重中之重。kafka 中的优化手段主要有以下几种:
- 压缩
- 缓存
- 批量
- 并发
- 算法
顺序写
一般来说,完成一次磁盘IO,需要经过 寻道、旋转和数据传输 三个步骤。
影响磁盘 IO 性能的因素也就发生在上面三个步骤上,因此主要花费的时间就是:
- 寻道时间:Tseek 是指将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O 操作越快,目前磁盘的平均寻道时间一般在 3-15ms。
- 旋转延迟:Trotation 是指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的 1/2 表示。比如:7200rpm 的磁盘平均旋转延迟大约为 60*1000/7200/2 = 4.17ms,而转速为 15000rpm 的磁盘其平均旋转延迟为 2ms。
- 数据传输时间:Ttransfer 是指完成传输所请求的数据所需要的时间,它取决于数据传输率,其值等于数据大小除以数据传输率。目前 IDE/ATA 能达到 133MB/s,SATA II 可达到 300MB/s 的接口数据传输率,数据传输时间通常远小于前两部分消耗时间。简单计算时可忽略。
因此,如果在写磁盘的时候省去寻道、旋转可以极大地提高磁盘读写的性能。
Kafka 采用顺序写文件的方式来提高磁盘写入性能。顺序写文件,基本减少了磁盘寻道和旋转的次数。磁头再也不用在磁道上乱舞了,而是一路向前飞速前行。
Kafka 中每个分区是一个有序的,不可变的消息序列,新的消息不断追加到 Partition 的末尾,在 Kafka 中 Partition 只是一个逻辑概念,Kafka 将 Partition 划分为多个 Segment,每个 Segment 对应一个物理文件,Kafka 对 segment 文件追加写,这就是顺序写文件。
为什么 Kafka 可以使用追加写的方式呢?
简单来说,kafka 数据存储在队列中,队列是 FIFO 先进先出模型,保证了数据的有序(同一partition)。正是由于kafka 这种不可变性、有序性使得 kafka 可以使用追加写的方式写文件。
零拷贝
零拷贝的核心思想是:尽量去减少数据的拷贝次数,从而减少拷贝的 CPU 开销,以及用户态和内核态的上下文切换次数,从而优化数据传输的性能。
有关零拷贝的详细介绍请参考: 零拷贝
PageCache
producer 发送消息到 Broker 时,Broker 会使用 write() 系统调用 (对应到 Java NIO 的 FileChannel.write() API),按偏移量写入数据,此时数据都会先写入page cache。consumer 消费消息时,Broker 使用 sendfile() 系统调用 (对应 FileChannel.transferTo() API),以零拷贝 的方式将数据从 page cache 传输到 broker 的 Socket buffer,然后再通过网络传输。
leader 与 follower 之间的同步,与上面 consumer 消费数据的过程是同理的。
page cache中的数据会随着内核中 flusher 线程的调度以及对 sync()/fsync() 的调用写回到磁盘,就算进程崩溃,也不用担心数据丢失。另外,如果 consumer 要消费的消息不在page cache里,才会去磁盘读取,并且会顺便预读出一些相邻的块放入 page cache,以方便下一次读取。
因此如果 Kafka producer 的生产速率与 consumer 的消费速率相差不大,那么就能几乎只靠对 broker page cache 的读写完成整个生产 - 消费过程,磁盘访问非常少。
网络模型
Kafka 自己实现了网络模型做 RPC。底层基于 Java NIO,采用和 Netty 一样的 Reactor 线程模型。
这部分暂时还未细究。。。
批量和压缩
Kafka Producer 向 Broker 发送消息不是一条消息一条消息的发送。Producer 有两个重要的参数:batch.size和linger.ms。这两个参数就和 Producer 的批量发送有关。
在 producer 端,消息在经过拦截器、序列化器、分区器之后会缓存到消息累加器(RecordAccumulator)中,消息累加器主要用来缓存消息,以便 Sender 线程可以批量发送,进而减少网络传输的资源消耗以提升性能。
Kafka 支持多种压缩算法:lz4、snappy、gzip。Kafka 2.1.0 正式支持 ZStandard —— ZStandard 是 Facebook 开源的压缩算法,旨在提供超高的压缩比
Producer、Broker 和 Consumer 使用相同的压缩算法,在 producer 向 Broker 写入数据,Consumer 向 Broker 读取数据时甚至可以不用解压缩,最终在 Consumer Poll 到消息时才解压,这样节省了大量的网络和磁盘开销。
分区并发
Kafka 的 Topic 可以分成多个 Partition,每个 Paritition 类似于一个队列,保证数据有序。同一个 Consumer Group 下的不同 Consumer 并发消费 Paritition,分区实际上是调优 Kafka 并行度的最小单元,因此,可以说,每增加一个 Paritition 就增加了一个消费并发。
Kafka 具有优秀的分区分配算法——StickyAssignor,可以保证分区的分配尽量地均衡,且每一次重分配的结果尽量与上一次分配结果保持一致。这样,整个集群的分区尽量地均衡,各个 Broker 和 Consumer 的处理不至于出现太大的倾斜。
分区数越多越好? 不是
越多的分区需要打开更多的文件句柄
在 kafka 的 broker 中,每个分区都会对照着文件系统的一个目录。在 kafka 的数据日志文件目录中,每个日志数据段都会分配两个文件,一个索引文件和一个数据文件。因此,随着 partition 的增多,需要的文件句柄数急剧增加,必要时需要调整操作系统允许打开的文件句柄数。
客户端 / 服务器端需要使用的内存就越多
客户端 producer 有个参数 batch.size,默认是 16KB。它会为每个分区缓存消息,一旦满了就打包将消息批量发出。看上去这是个能够提升性能的设计。不过很显然,因为这个参数是分区级别的,如果分区数越多,这部分缓存所需的内存占用也会更多。
降低高可用性
分区越多,每个 Broker 上分配的分区也就越多,当一个发生 Broker 宕机,那么恢复时间将很长。
高效的文件数据结构
Kafka 消息是以 Topic 为单位进行归类,各个 Topic 之间彼此独立,互不影响。每个 Topic 又可以分为一个或多个分区。每个分区各自存在一个记录消息数据的日志文件。
Kafka 每个分区日志在物理上实际按大小被分成多个 Segment。
- segment file 组成:每个LogSegment 对应于磁盘上的一个日志文件和两个索引文件,后缀
.index
、.timeindex
、.log
分别表示为 segment 索引文件、数据文件。 - segment 文件命名规则:partion 全局的第一个 segment 从 0 开始,后续每个 segment 文件名为上一个 segment 文件最后一条消息的 offset 值。数值最大为 64 位 long 大小,19 位数字字符长度,没有数字用 0 填充。
Kafka 中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(由 broker 端参数 log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。
稀疏索引通过 mmap 的方式,将 index 文件映射到内存,这样对 index 的操作就不需要操作磁盘 IO,以加快索引的查询速度。mmap的 Java 实现对应 MappedByteBuffer 。
mmap 是一种内存映射文件的方法。即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
mmap 详情可参考 系统调用 mmap
偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量。
时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。
稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。
- 按照二分法找到小于 offset 的 segment 的.log 和.index
- 用目标 offset 减去文件名中的 offset 得到消息在这个 segment 中的偏移量。
- 再次用二分法在 index 文件中找到对应的索引。
- 到 log 文件中,顺序查找,直到找到 offset 对应的消息。
参考文章: