目录
前言
作为大数据时代背景下,流数据处理的明星组件,kafka的重要性不言而喻,网上解读kafka的文章可以说不计其数,有解释各种概念的,也有各种案例说明的。但是能讲到kafka核心干货的文章,却并不多见。
大家都知道它快,那它为什么快呢?快的原因是什么呢?
对于kafka的一些基本概念和基本知识点,这里不再赘述,本文将从kafka的数据结构和查找算法这个视角,来聊一聊kafka如何做到快的。
kafka的分治思想
任何存储组件,其数据最终都会落到磁盘上,比如我们最熟悉的MySQL,其在磁盘上的存储的文件大致分为数据文件和索引文件两大类。
kafka存储数据(消息本身)的文件的数据结构是数组,数组的特点就是:数据间位置连续,如果按照顺序读取,或者追加写入的话,其时间复杂度为O(1),效率最高。
那按照这个思路来想的话,是不是kafka的数据存储用一个数组的数据结构就能满足要求了呢?
试想,如果kafka设计只用一个数组来存储数据,一来如果数据量特别大时,对这个单一数组的无论是写入压力还是读取压力是不是都特别大;二来如果只用一个数组,那么它后台存储的数据只能用到一块磁盘,对这块磁盘的IO来说将是非常大的一个考验。
那么怎么办?
我们知道,针对大数据量的场景,要想快,有一个核心指导思想就是:分治。
kafka正是采用了这种思想,并将该思想发挥到了极致的典型案例。
01
第1次分治
kafka通过topic给用户提供数据的读写,对于不同的业务来说,可以定义不同的topic来达到数据分治的目的,不同的业务写入或者读取不同的topic,且不同的topic会尽可能分散在不同的broker中,提高数据的IO效率。
一个kafka实例可以创建多个topic
虽然kafka没有限制topic的个数,但是也不要盲目多建,因为越多的topic,代表着越多的数据存储单元,容易导致同一个topic的数据在磁盘存储位置的不连续,从而降低数据读写的IO。
02
第2次分治
对于kafka的topic,我们在创建之初可以设置多个partition来存放数据,对于同一个topic的数据,每条数据的key通过哈希取模被路由到不同的partition中(如果没有设置key,则根据消息本身取模),以此达到分治的目的。
同样,对于每个topic的partition数量来说,也不宜过多,因为partition是kafka管理数据的基本逻辑组织单元,越多的partition意味着越多的数据存储文件(一个partition对应至少3个数据文件),同样容易隔断磁盘数据的连续性,影响数据读写的IO性能。
另外,过多的partition还会导致broker的操作系统内存OOM,即每一个partition文件至少对应2个索引文件(至少1个.index文件和1个.indextime文件),而索引文件是需要常驻内存的,因此partition数量不宜过多。
官网给出的partition不宜过多的理由之一。
03
第3次分治
你以为将topic中的数据均匀分布到各个partition中就完了吗?
当然不是。
试想,如果你作为一个consumer,想从一个特定的位置进行数据消费,比如特定的时间点或者特定的数据偏移量(offset),该怎么办呢?
如果按照一般的思维,就得从存储数据的这个文件从头遍历,每个partition的数据都要遍历一次,直到找到目标时间的数据或者目标偏移量的数据为止,很明显,这样的时间复杂度为O(n),如果数据量很大,这个寻址的时间消耗将是巨大的,且会带来非常高的磁盘IO,效率低下。
那为了快速找到目标数据的位置,可以有哪些办法呢?
索引+分治。
即原本一个partition对应一个文件的情况,变成了一个partition对应多个不同类型的文件,kafka将文件根据不同的功能分成了3大类:
kafka的数据文件类型
-
.index文件:offset索引文件,用来记录log文件中真实消息的相对偏移量和物理位置,为了减少索引文件的大小,这里用了一种比较聪明的做法,叫稀疏索引,即只记录相对offset的范围段(后文详细说明),可用于快速定位目标offset的消息;
其中参数:segment.index.bytes用来控制.index文件的大小,默认最大10MB;
参数:index.interval.bytes用来设置索引文件记录真实消息的消息间隔大小,默认为4KB,即上一条索引记录和下一条索引记录中间会间隔4KB的消息。
-
.timeindex文件:时间索引文件,类比.index文件,用来记录log文件中真实消息写入的时间情况,跟offset索引文件功能一样,只不过这个以时间作为索引,用来快速定位目标时间点的数据;
-
.log文件:用来记录producer写入的真实消息,即消息体本身;
这样一来,consumer如果想要去找到特定偏移量或者时间点的数据,不需要直接去消息文件中找了,而是去.index文件或者.timeidex文件中查找,而这两个作为索引文件,是需要加载到内核态内存中的,这样一来,通过这种索引+分治的手段,就可以快速找到目标数据。
但是,如果partition的数据都写到这3个文件中,随着数据的不断写入,这3个文件就会越来越大,会导致它们的读写IO也会越来越高,负荷越来越重。
那么怎么办?
继续分治...
04
第4次分治
单个文件过大,怎么办?
拆!
kafka对单个.index文件、.timeindex文件、.log文件的大小都有限定(通过不同参数配置),且这3个文件互为一组,当.log文件的大小达到阈值则会自动拆分形成一组新的文件。如图所示:
这样一来,将原本过大的文件进行了分治拆解,减小了单个文件的读写压力。
将数据文件拆分为多个,还会带来其他的一些好处。比如对需要的索引文件的加载,由于索引文件是需要预先加载到内存的,如果单个索引文件过大,会导致对内存的消耗过大;
此外,对过期数据的删除也会有帮助,可以以文件为单位进行数据删除,而不是删除文件中的某一个部分数据,效率更高。
这种将数据拆分成多个的小文件叫做segment,一个log文件代表一个segment。
segment的大小由参数 log.segment.bytes来控制,默认为1073741824 byte(1GB),
以上,就描述完了kafka在数据存储结构上采用的分治思想。
但是,如果想快速定位到某条特定的消息(根据offset或者消息时间),光有分治还不行啊,必须得有路由和索引才可以。
分治的作用只是将大份数据进行打散成多份小数据,而如果想要快速定位到其中的某条数据,还必须要有索引或者路由策略才可以。否则,查找效率一样不佳。
接下来,再看看kafka的查询逻辑...
05
kafka的查询逻辑
分治思想解决了数据集中的问题,将原本一份很大的数据进行了多个层次的拆分,让数据分散到多台服务器(多个broker)的多个不同目录,以及同个目录下的多个文件中。
那么如何解决快速定位到想要开始消费的数据呢?以offset定位为例。
对于消费者来说,想要消费哪个topic的数据,kafka先通过topic名字找到其topic,其时间复杂度为O(1),然后到每个partition中定位具体的offset,如何定位呢?
按顺序查找吗?
当然不是。
前面不是说有索引文件吗?每个消息的segment对应2个索引文件,一个.index文件和一个.timeindex文件,目的就是用来快速找到目标offset的消息。
来看一眼单个partition的文件结构:
单个partition的文件布局
看到的这个文件结构是按照时间进行倒叙排列的,每3个为一组,每一组的文件名都一样,命名方式为当前segment中最小的offset,其中保存最小offset的segment的log文件为:00000000000000000000.log,即offset从0开始的segment。
于是,可以看到图中所有segment的起始offset依次为(其中起始offset为0的的segment因为删除策略被删除了):
单个partition中每个segment的起始offset
那么如果想查找到某一个特定的offset属于哪个segment,用什么办法呢?
很明显:二分查找。
比如我要查找offset为2999999的消息,那么通过二分查找即定位到图中的2890012和3791564之间,而因为2999999<3791564,因此该offset的消息就定位到在2890012这个segment中,即在日志文件00000000000002890012.log中。
但是只定位到00000000000002890012.log这个文件还不够啊,因为这个日志文件本身还是很大的(默认1GB),要想精确定位到想要的消息位置,怎么办呢?
总不能按顺序遍历吧?
这个时候.index文件就派上用场了,上文提到.index文件是用来记录.log文件中消息的相对offset的,那如何进一步精确定位到想要消息的具体位置呢?
先来看看.index文件跟.log文件的映射关系(因为这个两个文件是通过二进制存储的,不能直接查看,下面以示意图表示):
通过.index文件查找.log文件中的消息
可以看到,.index文件中记录的是offset在对应.log文件中实际数据的位置,但是考虑到.index文件的大小,方便读到内存中,所以它不可能记录每一个offset的数据位置,怎么办呢?
于是就用稀疏的记录方式,即有一些记录,有一些不记录,而且是大部分不记录,只记录小部分。
具体的记录方式为:先记录一条offset的位置,然后跨过特定数据量的间隔(默认4KB),再记录下一条,且整个记录过程是顺序的,而这个跨过特定的数据量由参数log.index.interval.bytes来控制,这样一来.index文件就会只占用很少的空间,方便直接读取到内存中,进行offset与实际数据物理位置的映射,方便高效查找。
弄清楚了.index文件与.log文件的对应关系,再回到刚才那个疑问,如何快速查找到offset为2999999的消息呢?
很明显,还是二分查找,通过O(logN)的查找复杂度,在.index文件中定位到其物理位置在相对偏移量为109970(109970+2890012=2999982为小于2999999的最大偏移量)和110028(110028+2890012=3000040 为大于2999999最小的偏移量)这两个索引中间。
即定位到offset为2999999这条消息的具体物理位置在位置244421和位置344498之间。
可还是还没有完全找到啊?
接下来怎么找?
因为kafka中存储的消息是连续的,即按顺序存储的。
所以既然知道了目标消息在位置244421和位置344498之间,那么最后一步的查找,很明显还是还是二分找找,最终定位到位置为344486的消息即为目标消息。
如果想基于时间这个方式对消息进行查找,其查找的方式跟offset查找的思路是一样的,这里不再赘述。
结语
以上,可以看出kafka通过4次分治的理念对数据进行了拆分,然后又通过3次二分查找最终定位到目标消息。
即4次分治,3次二分,就是kakka对应的数据存储结构和查找算法。
(文章转自我的公众号[Anryg是码农],欢迎关注获取更多优质内容)