【Free Style】kafka 解密:破除单机topic数多性能下降魔咒 (上)
版权归PUMA项目组所有,转载请声明,多谢。
kakfa大规模集群能力在前面已给大家分享过,kafka作为消息总线,在支撑云千万tps上千节点的集群能力非常出色,本文继续对业界关于单机多topic的性能瓶颈点问题(比如:https://yq.aliyun.com/articles/62832?spm=5176.100239.blogcont25379.8.KMUH1L),国内某云使用RocketMQ,性能对比唱衰kafka 64分区时出现性能拐点,为此我们表示不服,从理论到实测数据,一步步揭开所谓单机性能拐点的秘密及解决之道。
1 简介
本文分析了Kafka分区数量的支持情况,通过一系列的测试和分析需要确认分区的数目是否受到限制,并找到解决方案。
本文读者包含分布式消息总线系统的开发者、测试者,以及应用该系统的客户产品
硬件约束:2285服务器2台
软件约束:无
2 需求背景
为什么要扩展Kafka分区数量呢?主要有以下几点:
1. 分区的数目关系消息队列的并行度,消息发送是往分区里发送的,每个消费者都只能对每个分区启动1个线程获取数据。所以分区是消息队列并行度的最大保证。
2. 消息总线需要多业务接入,每个业务都要创建自己的Topic和分区,必然对队列数目有需求(队列数目包含Topic和分区)
3. 终端有个IM的需求,是做一个类似一个微信和旺旺一样的聊天工具。如果用消息总线来做,那么要求消息队列为每个用户分配一个队列,这样至少要支持几亿的消息队列,这个在目前的消息队列中完全没办法支持。
4. 当消息队列作为公有云的服务提供的时候,我们分析每个用户会独占一个队列(分区或Topic),同样会对分区数量带来要求。
正是这些需求和原因导致我们将分区个数作为了MQ的一个度量指标。同时从网上的一些分析,Kafka最多支持64个分区性能就下降得很厉害,为了验证和解决分区数量限制的问题,我们就有本文档的分析。
3 分析
3.1 原理分析
3.1.1 基本概念
3.1.1.1 分区
分区是Kafka中的最重要的概念之一,消息发送和接收都是按照分区为单位来处理的。
分区的几个主要作用如下:
1、 Producer(消息发送者)的往消息Server的写入并发数与分区数成正比。
2、 Consumer(消息消费者)消费某个Topic的并行度与分区数保持一致,假设分区数是20,那么Consumer的消费并行度最大为20。
3、 每个Topic由固定数量的分区数组成,分区数的多少决定了单台Broker能支持的Topic数量,Topic数量又决定了支持的业务数量。
简单的说,有多少分区,就有多少对应的文件读写,Kafka的每个分区对应一个文件:
每条消息都被append到该Partition中,属于顺序写磁盘,因此效率非常高,经验证,顺序写磁盘效率比随机写内存还要高,这是Kafka高吞吐率的一个很重要的保证。
3.1.1.2 磁盘
在对消息存储和缓存时,Kafka使用了文件系统。
Kafka的设计基于一种非常简单的指导思想:不是要在内存中保存尽可能多的数据,在需要时将这些数据刷新(flush)到文件系统,而是要做完全相反的事情。所有数据都要立即写入文件系统中持久化的日志中,但不进行刷新数据的任何调用。实际中这样做意味着,数据被传输到OS内核的页面缓存中了,OS随后会将这些数据刷新到磁盘。
大家普遍为“磁盘很慢”,因而人们都对持久化(persistent structure)结构能够提供说得过去的性能抱有怀疑态度。实际上,同人们的期望值相比,磁盘可以说是既很慢又很快,这取决决于磁盘的使用方式。设计的很好的磁盘结构可以和网络一样快。在一个由6个7200rpm的SATA硬盘组成的RAID-5磁盘阵列上,线性写入(linear write)的速度大约是600MB/秒,但随机写入却只有100k/秒,其中的差距接近6000倍。
Kafka并没有在内存中创建缓冲区,然后再向磁盘write的方法,而是直接使用了PageCache。
OS在文件系统的读写上已经做了太多的优化,PageCache就是其中最重要的一种方法,详细的说明请看3.1.2章节。
直接使用PageCache有如下几个好处:
1)减少内存开销: Java对象的内存开销(overhead)非常大,往往是对象中存储的数据所占内存的两倍以上。
2)避免GC问题:Java中的内存垃圾回收会随着堆内数据不断增长而变得越来越不明确,回收所花费的代价也会越来越大。
3)简单可靠:OS会调用所有的空闲内存作为PageCache,并在其上做了大量的优化:预读,后写,flush管理等,这些都不用应用层操心,而是由OS自动完成。
由于这些因素,使用文件系统并依赖于PageCache页面缓存要优于自己在内存中维护一个缓存或者什么其他别的结构。
3.1.2 文件读写分析
Kafka借力于Linux内核的Page Cache,不(显式)用内存,胜用内存,完全没有别家那样要同时维护内存中数据、持久化数据的烦恼——只要内存足够,生产者与消费者的速度也没有差上太多,读写便都发生在Page Cache中,完全没有同步的磁盘访问。
3.1.2.1 读写空中接力
Linux总会把系统中还没被应用使用的内存挪来给Page Cache,在命令行输入free,或者cat /proc/meminfo,"Cached"的部分就是Page Cache。
Page Cache中每个文件是一棵Radix树(基树),节点由4k大小的Page组成,可以通过文件的偏移量快速定位Page。
当写操作发生时,它只是将数据写入Page Cache中,并将该页置上dirty标志。
当读操作发生时,它会首先在Page Cache中查找内容,如果有就直接返回了,没有的话就会从磁盘读取文件再写回Page Cache。
可见,只要生产者与消费者的速度相差不大,消费者会直接读取之前生产者写入Page Cache的数据,大家在内存里完成接力,根本没有磁盘访问。
而比起在内存中维护一份消息数据的传统做法,这既不会重复浪费一倍的内存,Page Cache又不需要GC(可以放心使用大把内存了),而且即使Kafka重启了,Page Cache还依然在。
3.1.2.2 后台异步flush的策略
这是大家最需要关心的,因为不能及时flush的话,OS crash(不是应用crash) 可能引起数据丢失,Page Cache瞬间从朋友变魔鬼。
当然,Kafka不怕丢,因为它的持久性是靠replicate保证,重启后会从原来的replicate follower中拉缺失的数据。
内核线程pdflush负责将有dirty标记的页面,发送给IO调度层。内核会为每个磁盘起一条pdflush线程,每5秒(/proc/sys/vm/dirty_writeback_centisecs)唤醒一次,根据下面三个参数来决定行为:
1. /proc/sys/vm/dirty_expire_centiseconds:如果page dirty的时间超过了30秒(单位是10ms),就
会被刷到磁盘,所以crash时最多丢30秒左右的数据。
2. /proc/sys/vm/dirty_background_ratio:如果dirty page的总大小已经超过了10%的可用内存(cat
/proc/meminfo里 MemFree+ Cached - Mapped),则会在后台启动pdflush 线程写盘,但不影响
当前的write(2)操作。增减这个值是最主要的flush策略里调优手段。
3. /proc/sys/vm/dirty_ratio:如果wrte(2)的速度太快,比pdflush还快,dirty page 迅速涨到 10%
的总内存(cat /proc/meminfo里的MemTotal),则此时所有应用的写操作都会被block,各自在自
己的时间片里去执行flush,因为操作系统认为现在已经来不及写盘了,如果crash会丢太多数据,
要让大家都冷静点。这个代价有点大,要尽量避免。在Redis2.8以前,Rewrite AOF就经常导致
这个大面积阻塞,现在已经改为Redis每32Mb先主动flush()一下了。
详细的文章可以看:http://www.westnet.com/~gsmith/content/linux-pdflush.htm
3.1.2.3 主动flush的方式
对于重要数据,应用需要自己触发flush保证写盘。
1. 调用fsync() 和 fdatasync()
fsync(fd)将属于该文件描述符的所有dirty page的写入请求发送给IO调度层。
fsync()总是同时flush文件内容与文件元数据, 而fdatasync()只flush文件内容与后续操作必须
的文件元数据。元数据含时间戳,大小等,大小可能是后续操作必须,而时间戳就不是必须的。
因为文件的元数据保存在另一个地方,所以fsync()总是触发两次IO,性能要差一点。
2. 打开文件时设置O_SYNC,O_DSYNC标志或O_DIRECT标志
O_SYNC、O_DSYNC标志表示每次write后要等到flush完成才返回,效果等同于write()后紧接一个fsync()或fdatasync(),不过按APUE里的测试,因为OS做了优化,性能会比自己调write() + fsync()好一点,但与只是write相比就慢很多了。O_DIRECT标志表示直接IO,完全跳过Page Cache。不过这也放弃了读文件时的Cache,必须每次读取磁盘文件。而且要求所有IO请求长度,偏移都必须是底层扇区大小的整数倍。所以使用直接IO的时候一定要在应用层做好Cache。
Kafka的默认机制中,fsync的间隔时间和消息个数都是最大值,所以基本上都是依赖OS层面的flush。
3.1.2.4 Page Cache的清理策略
当内存满了,就需要清理Page Cache,或把应用占的内存swap到文件去。有一个swappiness的参数(/proc/sys/vm/swappiness)决定是swap还是清理page cache,值在0到100之间,设为0表示尽量不要用swap,这也是很多优化指南让你做的事情,因为默认值居然是60,Linux认为Page Cache更重要。
Page Cache的清理策略是LRU的升级版。如果简单用LRU,一些新读出来的但可能只用一次的数据会占满了LRU的头端。因此将原来一条LRU队列拆成了两条,一条放新的Page,一条放已经访问过好几次的Page。Page刚访问时放在新LRU队列里,访问几轮了才升级到旧LRU队列(想想JVM Heap的新生代老生代)。清理时就从新LRU队列的尾端开始清理,直到清理出足够的内存。
Linux 提供了这样一个参数min_free_kbytes,用来确定系统开始回收内存的阀值,控制系统的空闲内存。值越高,内核越早开始回收内存,空闲内存越高。
3.1.2.5 预读策略
根据清理策略,Apache Kafka里如果消费者太慢,堆积了几十G的内容,Cache还是会被清理掉的。这时消费者就需要读盘了。
内核这里又有个动态自适应的预读策略,每次读请求会尝试预读更多的内容(反正都是一次读操作)。内核如果发现一个进程一直使用预读数据,就会增加预读窗口的大小,否则会关掉预读窗口。连续读的文件,明显适合预读。
3.1.2.6 调度层优化
IO调度层主要做两个事情,合并和排序。
合并是将相同和相邻扇区(每个512字节)的操作合并成一个,比如现在要读扇区1,2,3,那可以合并成一个读扇区1-3的操作。
排序就是将所有操作按扇区方向排成一个队列,让磁盘的磁头可以按顺序移动,有效减少了机械硬盘寻址这个最慢最慢的操作。
排序看上去很美,但可能造成严重的不公平,比如某个应用在相邻扇区狂写盘,其他应用就都干等在那了,pdflush还好等等没所谓,读请求都是同步的,耗在那会很惨。所有又有多种算法来解决这个问题,其中内核2.6的默认算法是CFQ(完全公正排队)。
3.1.3 原理分析结论
1、Kafka使用文件系统来交换消息,性能是否比使用内存来交换消息的系统要低很多?
在Apache Kafka里,消息的读写都发生在内存中(Pagecache),真正写盘的就是那条pdflush内核线程,根本不在Kafka的主流程中,读操作大多数会命中Pagecache,同时由于预读机制存在,所以性能非常好,从原理上有保证的。
2、 每个分区一个文件,那么多个分区会有多个文件同时读写,是否会极大的降低性能?
1) 首先,由于Kafka读写流程是发生在PageCache中,后台的flush不在主流程中触发,所以正常情况下理论上是没有影响的,除非PageCache占用内存过大,或是释放导致读写消耗Kafka进程的CPU时间。
2) 再次,文件都是顺序读写,OS层面有预读和后写机制,即使一台服务器上有多个Partition文件,经过合并和排序后都能获得很好的性能,不会出现文件多了变成随机读写的情况,但是当达到相当多的数量之后,也会存在一定的影响。
3) 当PageCache过大,大量触发磁盘I/O的时候,超过了/proc/sys/vm/dirty_ratio,Flush会占用各个应用自己的CPU时间,会对主流程产生影响,让主流程变慢。
3.2 性能测试
3.2.1 测试环境
同时启动消息发送和消费。
指标说明:
TPS | 在客户端侧由代码统计的每秒发送和接收到的消息个数。 |
wai | 系统因为io导致的进程wait。再深一点讲就是:这时候系统在做io,导致没有进程在干活,cpu在执行idle进程空转,所以说iowait的产生要满足两个条件,一是进程在等io,二是等io时没有进程可运行。 |
%util | 使用iostat测试得到。Percentage of CPU time during which I/O requests were issued to the device (bandwidth utilization for the device). Device saturation occurs when this value is close to 100%。一秒中有百分之多少的时间用于 I/O 操作,或者说一秒中有多少时间 I/O 队列是非空的。如果 %util 接近 100%,说明产生的I/O请求太多,I/O系统已经满负荷,该磁盘可能存在瓶颈。 |
IOPS | 该设备每秒的传输次数(Indicate the number of transfers per second that were issued to the device.)。“一次传输”意思是“一次I/O请求”。多个逻辑请求可能会被合并为“一次I/O请求”。“一次传输”请求的大小是未知的。IOPS=reads+writes |
reads | 在用例运行过程中,每秒读io的个数均值 |
writes | 在用例运行过程中,每秒写io的个数均值 |
从流程来看,影响性能的几个主要点为:客户端,网络,服务器端CPU,内存,文件系统(磁盘)。
3.2.2 单硬盘测试
当系统只有一个硬盘的时候,所有的分区文件集中在一个磁盘上,测试数据如下:
从数据来看,cpu,内存,网络问题都不大。
随着分区数目的增加,吞吐量会下降。从图上来看,2000的分区下,生产者和消费者TPS下降幅度已经较大了。1000左右的分区TPS浮动不大。
备注:Consumer的TPS下降的原因,根据JProfile分析,主要是客户端问题,后面章节有专门分析。
从磁盘的IOPS来看,超过1000之后,会急剧上升,3000之后因为TPS的下降,反而有所下降。
特别注意的是:读操作一直为0,证明基本上所有的Consumer消息都是从PageCache中获取的。
接下来,看一下util和wai,超过1000之后,util会急剧上升,到了3000左右,基本满负荷了。
这个和TPS是能对上的。
根据以上的测试数据,我们可以得到一个初步的结论:
1、 当分区超过了2000之后,util急剧上升,磁盘性能会成为瓶颈,导致Producer TPS下降。
2、 由于Read操作一直为0,证明磁盘对Consumer的影响不大。Consumer的TPS下降是客户端原因。(后面的章节给出证据)
建议:单台服务器单硬盘下,分区数量不超过2000,推荐值在1000以下。
3.2.3 多硬盘测试
启动8个磁盘,1个磁盘跑zookeeper和OS,其他7个磁盘分担所有的分区文件。
Util和IOPS是7个硬盘加起来的值,实际上每个硬盘的负荷是平均分担的。
1、可以看到,3000分区下,Producer的TPS很平稳,实际上还要超过单硬盘下单分区的TPS。
证明追加硬盘的方法可以明显的提高TPS。
2、Consumer TPS,在多硬盘下其实和单硬盘是一致的,超过2000分区之后,一直都是18w左右。证明硬盘并不是Consumer的瓶颈。
磁盘的IOPS和Util是随分区增加而增加的,但是实际上被7个硬盘平均分担,每个硬盘的负荷除以7之后很小了。
当分区达到5000之后,TPS会下降,但是磁盘负荷还远没有达到瓶颈。
CPU,网络,内存也同样没有问题。
- 点赞
- 收藏
- 关注作者
评论(0)