Kafka日志存储

1、文件目录布局

Kafka中的消息是以主题为基本单位进行归类的,各个主题在逻辑上相互独立。每个主题又可以分为一个或多个分区,分区的数量可以在主题创建的时候指定,也可以在之后修改。每条消息在发送的时候会根据分区规则被追加到指定的分区中,分区中的每条消息都会被分配一个唯一的序列号,也就是通常所说的偏移量(offset)。

如果分区规则设置得合理,那么所有的消息可以均匀地分布到不同的分区中,这样就可以实现水平扩展。不考虑多副本的情况,一个分区对应一个日志(Log)。为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log切分为多个 LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。事实上,Log和LogSegment也不是纯粹物理意义上的概念,Log在物理上只以文件夹的形式存储,而每个LogSegment对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。

我们知道Log对应了一个命名形式为<topc>-<partition>的文件夹。举个例子,假设有一个名为“topic-log”的主题,此主题中具有4个分区,那么在实际物理存储上表现为“ topic-log-0” “topic-log-1” “topic-log-2”“ topic-log-3”这4个文件夹:

向Log中追加消息时是顺序写入的,只有最后一个 LogSegment才能执行写入操作,在此之前所有的LogSegment都不能写入数据。为了方便描述,我们将最后一个LogSegment称为"activeSegment”,即表示当前活跃的日志分段。随着消息的不断写入,当 activeSegment满足定的条件时,就需要创建新的 activeSegment,之后追加的消息将写入新的 activeSegment为了便于消息的检索,每个 LogSegment中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex”为文件后缀)。每个 LogSegment都有一个基准偏移量 baseOffset,用来表示当前 LogSegment中第一条消息的offset偏移量是一个64位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseoffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。比如第一个 LogSegment的基准偏移量为0,对应的日志文件为00000000000000000000.log。

举例说明,向主题 topic-log中发送一定量的消息,某一时刻 topic-log-0目录中的布局如下所示。

示例中第2个LogSegment对应的基准位移是133,也说明了该 LogSegment中的第一条消息的偏移量为133,同时可以反映出第一个 LogSegment中共有133条消息(偏移量从0至132的消息)。
注意每个 LogSegment中不只包含“.log”“.index”“.timeindex”这3种文件,还可能包含“.deleted”“.cleaned”“.swap”等临时文件,以及可能的“.snapshot”“.txnindex”“leader-epoch-checkpoint”等文件。

从更加宏观的视角上看, Kafka中的文件不只上面提及的这些文件,比如还有一些检查点文件,当一个Kafka服务第一次启动的时候,默认的根目录下就会创建以下5个文件:

我们了解到消费者提交的位移是保存在Kafka内部的主题_consumer__offsets中的,初始情况下这个主题并不存在,当第一次有消费者消费消息时会自动创建这个主题在某一时刻,Kafka中的文件目录布局如图5-2所示。每一个根目录都会包含最基本的4个检查点文件(xxx-checkpoint)和 meta.properties文件。在创建主题的时候,如果当前 broker中不止配置了一个根目录,那么会挑选分区数最少的那个根目录来完成本次创建任务。

2、日志格式的演变

对一个成熟的消息中间件而言,消息格式(或者称为“日志格式”)不仅关系功能维度的扩展,还牵涉性能维度的优化。随着 Kafka的迅猛发展,其消息格式也在不断升级改进,从0.8.x版本开始到现在的2.0.0版本, Kafka的消息格式也经历了3个版本:v0版本、v1版本和v2版本。

每个分区由内部的每一条消息组成,如果消息格式设计得不够精炼,那么其功能和性能都会大打折扣。比如有冗余字段,势必会不必要地增加分区的占用空间,进而不仅使存储的开销变大、网络传输的开销变大,也会使 Kafka的性能下降。反观如果缺少字段,比如在最初的Kafka消息版本中没有 timestamp字段,对内部而言,其影响了日志保存、切分策略,对外部而言,其影响了消息审计、端到端延迟、大数据应用等功能的扩展。虽然可以在消息体内部添加一个时间戳,但解析变长的消息体会带来额外的开销,而存储在消息体(参考图5-3中的value字段)前面可以通过指针偏移量获取其值而容易解析,进而减少了开销(可以査看v1版本),虽然相比于没有 timestamp字段的开销会大一点。由此可见,仅在一个字段的一增一减之间就有这么多门道,那么 Kafka具体是怎么做的呢?本节只针对Kafka0.8.x之上(包含)的版本做相应说明,对于之前的版本不做陈述。

2.1、v0版本

Kafka消息格式的第一个版本通常称为v0版本,在 Kafka0.10.0之前都采用的这个消息格式(在0.8x版本之前,Kafka还使用过一个更古老的消息格式,不过对目前的 Kafka而言,我们也不需要了解这个版本的消息格式)。如无特殊说明,我们只讨论消息未压缩的情形。
图5-3中左边的“RECORD”部分就是v0版本的消息格式,大多数人会把图5-3中左边的整体(即包括 offset和 message size字段)都看作消息,因为每个 RECORD(v0和v1版)必定对应一个 offset和 message size。每条消息都有一个 offset用来标志它在分区中的偏移量,这个offset是逻辑值,而非实际物理偏移值, message size表示消息的大小,这两者在一起被称为日志头部(LOG_OVERHEAD),固定为12B。LOG_OVERHEAD和RECORD一起用来描述一条消息,为了配合陈述的语境,在讲述具体消息格式时会偏向于将单纯的 RECORD看作消息,而在其他地方则偏向于将LOG_OVERHEAD和 RECORD的整体看作消息,读者需要留意其中的区别。与消息对应的还有消息集的概念,消息集中包含一条或多条消息,消息集不仅是存储于磁盘及在网络上传输(Produce&Fetch)的基本形式,而且是 Kafka中压缩的基本单元,详细结构参考图5-3中的右边部分。

下面具体陈述一下消息格式中的各个字段,从crc32开始算起,各个字段的解释如下。

  • crc32(4B):crc32校验值。校验范围为 magic至vaue之间。
  • magic(1B):消息格式版本号,此版本的 magic值为0。
  • attributes(1B):消息的属性。总共占1个字节,低3位表示压缩类型:0表示NONE、1表示GZIP、2表示 SNAPPY、3表示LZ4(LZ4自Kafka0.9.x引入),其余位保留。
  • key length(4B):表示消息的key的长度。如果为-1,则表示没有设置key,即key=null。
  • key:可选,如果没有key则无此字段。
  • value length(4B):实际消息体的长度。如果为-1,则表示消息为空。
  • value:消息体。可以为空,比如墓碑(tombstone)消息。

v0版本中一个消息的最小长度(RECORD_OⅤERHEAD_V0)为crc32+ magic+ attributes+key length+ value length = 4B+1B+1B+4B+4B=14B。也就是说,v0版本中一条消息的最小长度为14B,如果小于这个值,那么这就是一条破损的消息而不被接收。

这里我们来做一个测试,首先创建一个分区数和副本因子都为1的主题,名称为"msg_format_v0”,然后往 msg_format_v0中发送一条key="key"、value="value"的消息,之后查看对应的日志(这里采用Kafka0.8.2.1的版本):

日志的大小(即00000000000000000000.log文件的大小)为34B,其值正好等于LOG_OVERHEAD+ RECORD_OⅤERHEAD_V0+3B的key+5B的 value=12B+14B+3B+5B=34B。

我们再发送一条key=null, value="value"的消息,之后查看日志的大小:

日志大小为65B,减去上一条34B的消息(LOG_OVERHEAD+ RECORD),可以得知本条消息的大小为31B,正好等于 LOG_OVERHEAD+ RECORD_OVERHEAD_V0+5B的 value=12B+14B+5B=31B。

2.2、v1版本

Kafka从0.10.0版本开始到0.11.0版本之前所使用的消息格式版本为v1,比v0版本就多了一个 timestamp字段,表示消息的时间戳。v1版本的消息结构如图5-4所示。

v1版本的 magic字段的值为1。v1版本的 attributes字段中的低3位和v0版本的一样,还是表示压缩类型,而第4个位(bit)也被利用了起来:0表示 timestamp类型为 CreateTime而1表示 timestamp类型为 LogAppendTime,其他位保留。 timestamp类型由 broker端参
数log.message.timestamp.type来配置,默认值为 CreateTime,即采用生产者创建消息时的时间戳。如果在创建 ProducerRecord时没有显式指定消息的时间戳,那么 KafkaProducer也会在发送这条消息前自动添加上。下面是 KafkaProducer与此对应的一句关键代码:

long timestamp = record.timestamp() == null ? time.milliseconds():record.timestamp();

v1版本的消息的最小长度(RECORD_OVERHEAD_V1)要比v0版本的大8个字节,即22B。如果像v0版本介绍的一样发送一条key="key"、value="value"的消息,那么此条消息在v1版本中会占用42B,具体测试步骤参考v0版的相关介绍。

2.3、消息压缩

常见的压缩算法是数据量越大压缩效果越好,一条消息通常不会太大,这就导致压缩效果并不是太好。而 Kafka实现的压缩方式是将多条消息一起进行压缩,这样可以保证较好的压缩效果。在一般情况下,生产者发送的压缩数据在 broker中也是保持压缩状态进行存储的,消费者从服务端获取的也是压缩的消息,消费者在处理消息之前才会解压消息,这样保持了端到端的压缩。

Kafka日志中使用哪种压缩方式是通过参数 compression.type来配置的,默认值为“producer”,表示保留生产者使用的压缩方式。这个参数还可以配置为“gzip” “snappy” “lz4”,分别对应GZIP、SNAPPY、LZ4这3种压缩算法。如果参数 compression.type配置为
“uncompressed”,则表示不压缩。

注意要点:压缩率是压缩后的大小与压缩前的对比。例如:把100MB的文件压缩后是90MB压缩率为90/100×100%=90%,压缩率越小,压缩效果越好。一般口语化陈述时会误描述为压缩率越髙越妤,为了避免混淆,本节不引入学术上的压缩率而引入压缩效果,这样容易达成共识。

以上都是针对消息未压缩的情况,而当消息压缩时是将整个消息集进行压缩作为内层消息(inner message),内层消息整体作为外层(wrapper message)的value,其结构如图5-5所示。

压缩后的外层消息(wrapper message)中的key为null,所以图5-5左半部分没有画出key字段,value字段中保存的是多条压缩消息(inner message,内层消息),其中 Record表示的是从crc32到value的消息格式。当生产者创建压缩消息的时候,对内部压缩消息设置的offset从0开始为每个内部消息分配 offset,详细可以参考图5-6右半部分。

    

其实每个从生产者发出的消息集中的消息 offset都是从0开始的,当然这个 offset不能直接存储在日志文件中,对 offset的转换是在服务端进行的,客户端不需要做这个工作。外层消息保存了内层消息中最后一条消息的绝对位移(absolute offset),绝对位移是相对于整个分区而言的。参考图5-6,对于未压缩的情形,图右内层消息中最后一条的 offset理应是1030,但被压缩之后就变成了5,而这个1030被赋予给了外层的 offset。当消费者消费这个消息集的时候,首先解压缩整个消息集,然后找到内层消息中最后一条消息的 inner offset,根据如下公式找到内层消息中最后一条消息前面的消息的 absolute offset(RO表示 Relative Offset,IO表示 Inner 
Offset,而AO表示 Absolute Offset):

RO = IO_of_ a_message - IO_of_the_last_message
AO = AO_OF_Last_Inner_Message+RO

注意这里的RO是前面的消息相对最后一条消息的IO而言的,所以其值小于等于0,0表示最后一条消息自身。

注意要点:压缩消息,英文是 compress message,Kafka中还有一个 compact message,常常被人们直译成压缩消息,需要注意两者的区别。 compact message是针对日志清理策略而言的。(cleanup.policy= compact),是指日志压缩(Log Compaction)后的消息,这个在《日志清理》中会有相关介绍。本节中的压缩消息单指 compress message,即采用GZIP、LZ4等压缩工具压缩的消息。

在讲述v1版本的消息时,我们了解到v1版本比v0版的消息多了一个 timestamp字段。对于压缩的情形,外层消息的 timestamp设置为:

  • 如果 timestamp类型是 CreateTime,那么设置的是内层消息中最大的时间戳。
  • 如果 timestamp类型是 LogAppendTime,那么设置的是Kafka服务器当前的时间戳。

内层消息的 timestamp设置为:

  • 如果外层消息的 timestamp类型是 CreateTime,那么设置的是生产者创建消息时的时间戳。
  • 如果外层消息的 timestamp类型是 LogAppendTime,那么所有内层消息的时间戳都会被忽略。

对 attributes字段而言,它的 timestamp位只在外层消息中设置,内层消息中的timestamp类型一直都是 CreateTime。

2.4、变长字段

Kafka从0.11.0版本开始所使用的消息格式版本为v2,这个版本的消息相比v0和v1的版本而言改动很大,同时还参考了 Protocol Buffer而引入了变长整型(Variants)和 ZigZag编码。为了更加形象地说明问题,首先我们来了解一下变长整型。

Variants是使用一个或多个字节来序列化整数的一种方法。数值越小,其占用的字节数就越少。 Variants中的每个字节都有一个位于最高位的msb位(most significant bit),除最后一个字节外,其余msb位都设置为1,最后一个字节的msb位为0。这个msb位表示其后的字节是否和当前字节一起来表示同一个整数。除msb位外,剩余的7位用于存储数据本身,这种表示类型又称为Base128。通常而言,一个字节8位可以表示256个值,所以称为Base256,而这里只能用7位表示,2的7次方即128。 Variants中采用的是小端字节序,即最小的字节放在最前面举个例子,比如数字1,它只占一个字节,所以msb位为0:
0000 0001

再举一个复杂点的例子,比如数字300:
1010 1100 0000 0010

300的二进制表示原本为0000 0001 0010 1100=256+32+8+4=300,那么为什么300的变长表示为上面的这种形式?

首先去掉每个字节的msb位,表示如下

0101 1100 0000 0010
         >0101 1100 000 0010
如前所述, variants使用的是小端字节序的布局方式,所以这里两个字节的位置需要翻转

Variants可以用来表示int32、int64、uint32、uint64、sint32、sint64、bool、enum等类型。在实际使用过程中,如果当前字段可以表示为负数,那么对int32/int64和sint32/sint64而言,它们在进行编码时存在较大的区别。比如使用int64表示一个负数,那么哪怕是-1,其编码后的长度始终为10个字节(可以通过下面的代码来测试长度),就如同对待一个很大的无符号长整型数一样。为了使编码更加高效, Variants使用了 ZigZag的编码方式。

ZigZag编码以一种锯齿形(zig-zags)的方式来回穿梭正负整数,将带符号整数映射为无符号整数,这样可以使绝对值较小的负数仍然享有较小的 Variants编码值,比如-1编码为1,1编码为2,-2编码为3,如表5-1所示。

对应的公式为:
( n<<1 ) ^ ( n>>31 )
这是对sint32而言的,sint64对应的公式为
( n<<1 ) ^ ( n>>63 )
以-1为例,其二进制表现形式为1111 1111 1111 1111 1111(补码)。
( n<<1 )
1111 1111 1111 1111 1111 1111 1111 1110
( n>>31 )
1111 1111 1111 1111 1111 1111 1111 1111
( n<<1 ) ^ ( n>>3 1) = 1
最终-1的 Variants编码为0000 0001,这样原本用4个字节表示的-1现在可以用1个字节来表示了。1就显得非常简单了,其二进制表现形式为0000 0000 0000 0000 0000 0000 0000 0001。
( n<<1 )  = 0000 0000 0000 0000 0000 0000 0000 0010
( n>>31 )= 0000 0000 0000 0000 0000 0000 0000 0000
( n<<1 )  ^ ( n>>31 ) = 2
最终1的 Variants编码为0000 0010,也只占用1个字节。
前面说过 Variants中的一个字节中只有7位是有效数值位,即只能表示128个数值,转变成绝对值之后其实质上只能表示64个数值。比如对消息体长度而言,其值肯定是大于等于0的正整数,那么一个字节长度的 Variants最大只能表示64。65的二进制数表示为:

0100 0001

经过 ZigZag处理后为:

1000 0010 ^ 0000 0000 = 1000 0010

每个字节的低7位是有效数值位,所以1000 0010进一步转变为:

000 0001 000 0010

而 Variants使用小端字节序,所以需要翻转一下位置:

000 0010 000 0001

设置非最后一个字节的msb位为1,最后一个字节的msb位为0,最终有:

1000 0010 0000 0001

所以最终65表示为1000 0010 0000 0001,而64却表示为0100 0000。

具体的编码实现如下(针对int32类型):

对应的解码实现如下(针对int32类型):

回顾 Kafka v0和v1版本的消息格式,如果消息本身没有key,那么 key length字段为-1,int类型的需要4个字节来保存,而如果采用 Variants来编码则只需要1个字节。根据 Variants的规则可以推导出0~63之间的数字占1个字节,64~8191之间的数字占2个字节,8192~1048575之间的数字占3个字节。而 Kafka broker端配置 message.max.bytes的默认大小为1000012(Variants编码占3个字节),如果消息格式中与长度有关的字段采用 Variants的编码,那么绝大多数情况下都会节省空间,而v2版本的消息格式也正是这样做的。

不过需要注意的是, Variants并非一直会节省空间,一个int32最长会占用5个字节(大于默认的4个字节),一个int64最长会占用10个字节(大于默认的8个字节)。下面的代码展示了如何计算一个int32占用的字节个数:

2.5、v2版本

v2版本中消息集称为 RecordBatch,而不是先前的 MessageSet,其内部也包含了一条或多条消息,消息的格式参见图5-7的中部和右部。在消息压缩的情形下, RecordBatchHeader部分(参见图5-7左部,从 first offset到 records count字段)是不被压缩的,而被压缩的是 records字段中的所有内容。生产者客户端中的 ProducerBatch对应这里的 RecordBatch而 ProducerRecord对应这里的Record。

先讲述消息格式 Record的关键字段,可以看到内部字段大量采用了 Variants,这样 Kafka可以根据具体的值来确定需要几个字节来保存。v2版本的消息格式去掉了crc字段,另外增加了length(消息总长度)、 timestamp delta(时间戳增量)、 offset delta(位移增量)
和 headers信息,并且 attributes字段被弃用了,笔者对此做如下分析(key、key length、value、 value length字段同v0和v1版本的一样,这里不再赘述)。

  • length:消息总长度。
  • attributes:弃用,但还是在消息格式中占据1B的大小,以备未来的格式扩展。
  • timestamp delta:时间戳增量。通常一个 timestamp需要占用8个字节,如果像这里一样保存与 RecordBatch的起始时间戳的差值,则可以进一步节省占用的字节数。
  • offset delta:位移增量。保存与 RecordBatch起始位移的差值,可以节省占用的字节数。
  • headers:这个字段用来支持应用级别的扩展,而不需要像v0和v1版本一样不得不将一些应用级别的属性值嵌入消息体。 Header的格式如图5-7最右部分所示,包含key和value,一个 Record里面可以包含0至多个 Header。

对于v1版本的消息,如果用户指定的 timestamp类型是 LogAppendTime而不是CreateTime,那么消息从生产者进入 broker后, timestamp字段会被更新,此时消息的crc值将被重新计算,而此值在生产者中已经被计算过一次。再者, broker端在进行消息格式转换时(比如1版转成v0版的消息格式)也会重新计算crc的值。在这些类似的情况下,消息从生产者到消费者之间流动时,crc的值是变动的,需要计算两次erc的值,所以这个字段的设计在v0和v1版本中显得比较“鸡肋”。在v2版本中将crc的字段从 Record中转移到了
RecordBatch中。

v2版本对消息集(RecordBatch)做了彻底的修改,参考图5-7最左部分,除了刚刚提及的crc字段,还多了如下字段。

  • first offset:表示当前 RecordBatch的起始位移。
  • length:计算从 partition leader epoch字段开始到末尾的长度。
  • partition leader epoch:分区 leader纪元,可以看作分区 leader的版本号或更新次数,详细内容请参考《副本的剖析》。
  • magic:消息格式的版本号,对v版本而言, magic等于2。
  • attributes:消息属性,注意这里占用了两个字节。低3位表示压缩格式,可以参考v0和v1;第4位表示时间戳类型;第5位表示此RecordBatch是否处于事务中,0表示非事务,1表示事务。第6位表示是否是控制消息(ControlBatch),0表示非控制消息,而1表示是控制消息,控制消息用来支持事务功能,详细内容请参考《Kafka事务》。
  • last offset delta: RecordBatch中最后一个 Record的offset与 first offset的差值。主要被 broker用来确保 RecordBaich中 Record组装的正确性。
  • first timestamp: RecordBatch中第一条 Record的时间戳。
  • max timestamp: RecordBatch中最大的时间戳,一般情况下是指最后一个 Record的时间戳,和last offset delta的作用一样,用来确保消息组装的正确性。
  • producer id:PID,用来支持幂等和事务,详细内容请参考《Kafka事务》。
  • producer epoch:和 producer id一样,用来支持幂等和事务,详细内容请参考《Kafka事务》。
  • first sequence:和 producer id、 producer epoch一样,用来支持幂等和事务,详细内容请参考《Kafka事务》。
  • records count: RecordBatch中 Record的个数。

为了验证这个格式的正确性,我们往某个分区中一次性发送6条key为“key”、 value为“value”的消息,相应的日志内容如下:

可以看到全部是以16进制数来表示的,未免晦涩难懂,下面对照图5-7来详细讲解每个字节所表示的具体含义,具体参考如下:

这里我们再来做一个测试,在2.0.0版本的 Kafka中创建一个分区数和副本因子数都为1的主题,名称为“msg_format _2”。然后同样插入一条key="key"、value="value"的消息,日志结果如下:

可以看到示例中size字段为76,我们根据图5-7中的v2版本的日志格式来验证一下,Record Batch Header部分共61B。 Record部分中的 attributes占1B; timestamp delta的值为0,占1B; offset delta的值为0,占1B; key length的值为3,占1B;key占3B; value length的值为5,占1B,value占5B; headers count的值为0,占1B;无 headers。 Record部分的总长度=1B+1B+1B+1B+3B+1B+5B+1B=14B,所以 Record的 length字段的值为14,编码变为长整型数之后占1B。最后推导出这条消息的占用字节数61B+14B+1B=76B,符合测试结果。同样再发一条key= null, value="value"的消息,可以计算出这条消息占73B。

这么看上去v2版本的消息好像要比之前版本的消息所占用的空间大得多,的确对单条消息而言是这样的,如果我们连续向主题 msg_format_v2中再发送10条value长度为6、key为nul的消息,可以得到:

本来应该占用740B大小的空间,实际上只占用了191B,在v0版本中这10条消息需要占用320B的空间大小,而v1版本则需要占用400B的空间大小,这样看来v版本又节省了很多空间,因为它将多个消息( Record)打包存放到单个 RecordBatch中,又通过 Variants编码极大地节省了空间。有兴趣的读者可以自行测试一下在大批量消息的情况下,v2版本和其他版本消息占用大小的对比,比如往主题 msg_format_v0(和 msg_format_v2中各自发送100万条1KB的消息。

v2版本的消息不仅提供了更多的功能,比如事务、幂等性等,某些情况下还减少了消息的空间占用,总体性能提升很大。

细心的读者可能注意到前面在演示如何查看日志内容时,既使用了 kafka-run-class.sh kafka.tools.DumpLogSegments的方式,又使用了 kafka-dump-log.sh的方式。而 kafka-dump-log.sh脚本的内容为:

exec $(dirname $0)/kafka-run-class.sh kafka.tools.DumpLogSegments "s@"

两种方式在本质上没有什么区别,只不过在Kafka2.0.0之前并没有 kafka-dump-log.sh脚本,所以只能使用 kafka-run-class.sh kafka.tools.DumpLogSegments的形式,而从Kafka2.0.0开始,可以直接使用 kafka-dump-log.sh脚本来避免书写错误。

3、日志索引

本章开头就提及了每个日志分段文件对应了两个索引文件,主要用来提高查找消息的效率。偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。

Kafka中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。每当写入一定量(由 broker端参数log.index.interval.bytes指定,默认值为4096,即4KB)的消息时,偏移量索引文件和时间戳索引文件分别增加一个偏移量索引项和时间戳索引项,增大或减小log.index.interval.bytes的值,对应地可以增加或缩小索引项的密度。

稀疏索引通过 MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。偏移量索引文件中的偏移量是单调递増的,査询指定偏移量时,使用二分査找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量。时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。

本章开头也提及日志分段文件达到一定的条件时需要进行切分,那么其对应的索引文件也需要进行切分。日志分段文件切分包含以下几个条件,满足其一即可。

(1)当前日志分段文件的大小超过了 broker端参数log.segment.bytes配置的值。log.segment.bytes参数的默认值为1073741824,即1GB。
(2)当前日志分段中消息的最大时间戳与当前系统的时间戳的差值大于log.roll.ms或log.roll.hours参数配置的值。如果同时配置了log.roll.ms和log.roll.hours参数,那么log.roll.ms的优先级高。默认情况下,只配置了log.roll.hours参数,其值为168,即7天。
(3)偏移量索引文件或时间戳索引文件的大小达到 broker端参数log.index.size.max.bytes配置的值。log.index.size.max.bytes的默认值为10485760,即10MB。
(4)追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE,即要追加的消息的偏移量不能转变为相对偏移量(offset - baseoffset > Integer.MAX_VALUE)。

对非当前活跃的日志分段而言,其对应的索引文件内容已经固定而不需要再写入索引项,所以会被设定为只读。而对当前活跃的日志分段(activeSegment)而言,索引文件还会追加更多的索引项,所以被设定为可读写。在索引文件切分的时候,Kafka会关闭当前正在写入的索引文件并置为只读模式,同时以可读写的模式创建新的索引文件,索引文件的大小由 broker端参数log.index.size.max.bytes配置。 Kafka在创建索引文件的时候会为其预分配log.index.size.max.bytes大小的空间,注意这一点与日志分段文件不同,只有当索引文件进行切分的时候,Kafka才会把该索引文件裁剪到实际的数据大小。也就是说,与当前活跃的日志分段对应的索引文件的大小固定为log.index.size.max.bytes,而其余日志分段对应的索引文件的大小为实际的占用空间。

3.1、偏移量索引

偏移量索引项的格式如图5-8所示。每个索引项占用8个字节,分为两个部分。
(1) relativeOffset:相对偏移量,表示消息相对于 baseOffset的偏移量,占用4个字节,当前索引文件的文件名即为 baseOffset的值。
(2) position:物理地址,也就是消息在日志分段文件中对应的物理位置,占用4个字节。

消息的偏移量(offset)占用8个字节,也可以称为绝对偏移量。索引项中没有直接使用绝对偏移量而改为只占用4个字节的相对偏移量(relativeOffset= offset- baseOffset),这样可以减小索引文件占用的空间。举个例子,一个日志分段的 baseOffset为32,那么其文件名就是00000000000000000032.log,offset为35的消息在索引文件中的 relativeOffset的值为35-32=3。

再来回顾一下前面日志分段文件切分的第4个条件:追加的消息的偏移量与当前日志分段的偏移量之间的差值大于 Integer.MAX_VALUE。如果彼此的差值超过了 Integer.MAX_VALUE,那么 relativeOffset就不能用4个字节表示了,进而不能享受这个索引项的设计所带来的便利了。

我们以本章开头 topic-log-0目录下的00000000000000000000.index例来进行具体分析,截取00000000000000000000.index部分内容如下:

虽然是以16进制数表示的,但参考索引项的格式可以知道如下内容:

这里也可以使用前面讲的 kafka-dump-log.sh脚本来解析 index文件(还包括.timeindex、.snapshot、.txnindex等文件),示例如下:

单纯地讲解数字不免过于枯燥,我们这里给出00000000000000000000.index和00000000000000000000.log的对照图来做进一步的陈述,如图5-9所示。

 

如果我们要查找偏移量为23的消息,那么应该怎么做呢?首先通过二分法在偏移量索引文件中找到不大于23的最大索引项,即[22,656],然后从日志分段文件中的物理位置656开始顺序查找偏移量为23的消息。

以上是最简单的一种情况。参考图5-10,如果要查找偏移量为268的消息,那么应该怎么办呢?首先肯定是定位到 baseOffset为251的日志分段,然后计算相对偏移量 relativeOffset=268-251=17,之后再在对应的索引文件中找到不大于17的索引项,最后根据索引项中的 position定位到具体的日志分段文件位置开始查找目标消息。那么又是如何查找 baseOffset为251的日志分段的呢?这里并不是顺序查找,而是用了跳跃表的结构。Kafka的每个日志对象中使用了ConcurrentSkipListMap来保存各个日志分段,每个日志分段的 baseOffset作为key,这样可以根据指定偏移量来快速定位到消息所在的日志分段。

还需要注意的是,Kafka强制要求索引文件大小必须是索引项大小的整数倍,对偏移量索引文件而言,必须为8的整数倍。如果 broker端参数log.index.size.max.bytes配置为67,那么Kafka在内部会将其转换为64,即不大于67,并且满足为8的整数倍的条件。

3.2、时间戳索引

时间戳索引项的格式如图5-11所示。

每个索引项占用12个字节,分为两个部分。
(1) timestamp:当前日志分段最大的时间戳。
(2) relativeOffset:时间戳所对应的消息的相对偏移量。

时间戳索引文件中包含若干时间戳索引项,每个追加的时间戳索引项中的 timestamp必须大于之前追加的索引项的 timestamp,否则不予追加。如果 broker端参数log.message.timestamp.type设置为 LogAppendTime,那么消息的时间戳必定能够保持单调递增;相反,如果是 CreateTime类型则无法保证。生产者可以使用类似 ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value)的方法来指定时间戳的值。即使生产者客户端采用自动插入的时间戳也无法保证时间戳能够单调递增,如果两个不同时钟的生产者同时往一个分区中插入消息,那么也会造成当前分区的时间戳乱序。

与偏移量索引文件相似,时间戳索引文件大小必须是索引项大小(12B)的整数倍,如果不满足条件也会进行裁剪。同样假设 broker端参数log.index.size.max.bytes配置为67,那么对应于时间戳索引文件, Kafka在内部会将其转换为60。

我们已经知道每当写入一定量的消息时,就会在偏移量索引文件和时间戳索引文件中分别增加一个偏移量索引项和时间戳索引项。两个文件增加索引项的操作是同时进行的,但并不意味着偏移量索引中的 relativeOffset和时间戳索引项中的 relativeOffset是同一个值。与上面偏移量索引一节示例中所对应的时间戳索引文件00000000000000000000.timeindex的部分内容如下:

有兴趣的读者可以自行解析上面内容的16进制数据。和讲述偏移量索引时一样,我们画出00000000000000000000.timeindex的具体结构,详细参考图5-12左上角。

如果要查找指定时间戳 targetTimeStamp=1526384718288开始的消息,首先是找到不小于指定时间戳的日志分段。这里就无法使用跳跃表来快速定位到相应的日志分段了,需要分以下几个步骤来完成。

步骤1:将 targetTimeStamp和每个日志分段中的最大时间戳 largestTimeStamp逐一对比,直到找到不小于 targetTimeStamp的 largestTimeStamp所对应的日志分段。日志分段中的largestTimeStamp的计算是先查询该日志分段所对应的时间戳索引文件,找到最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则取该日志分段的最近修改时间。

步骤2:找到相应的日志分段之后,在时间戳索引文件中使用二分查找算法查找到不大于targetTimeStamp的最大索引项,即[1526384718283,28],如此便找到了一个相对偏移量28。

步骤3:在偏移量索引文件中使用二分算法查找到不大于28的最大索引项,即[26,838]。

步骤4:从步骤1中找到日志分段文件中的838的物理位置开始查找不小于 targetTimeStamp的消息。

4、日志清理

Kafka将消息存储在磁盘中,为了控制磁盘占用空间的不断增加就需要对消息做一定的清理操作。Kafka中每一个分区副本都对应一个Log,而Log又可以分为多个日志分段,这样也便于日志的清理操作。 Kafka提供了两种日志清理策略。
(1)日志删除(LogRetention):按照一定的保留策略直接删除不符合条件的日志分段。
(2)日志压缩(LogCompaction):针对每个消息的key进行整合,对于有相同key的不同 value值,只保留最后一个版本。

我们可以通过 broker端参数log.cleanup.policy来设置日志清理策略,此参数的默认值为“delete”,即采用日志删除的清理策略。如果要采用日志压缩的淸理策略,就需要将log.cleanup.policy设置为“compact”,并且还需要将log.cleaner.enable(默认值为true)设定为true。通过将log.cleanup.policy参数设置为“delete, compact”,还可以同时支持日志删除和日志压缩两种策略。日志清理的粒度可以控制到主题级别,比如与log.cleanup.policy对应的主题级别的参数为cleanup.policy,为了简化说明,本节只采用 broker端参数做陈述, topic级别的参数可以查看《主题端参数》。

4.1、日志删除

在 Kafka的日志管理器中会有一个专门的日志删除任务来周期性地检测和删除不符合保留条件的日志分段文件,这个周期可以通过 broker端参数log.retention.check.interval.ms来配置,默认值为300000,即5分钟。当前日志分段的保留策略有3种:基于时间的保留策略、基于日志大小的保留策略和基于日志起始偏移量的保留策略。

4.1.1、基于时间

日志删除任务会检查当前日志文件中是否有保留时间超过设定的阈值(retentionMs)来寻找可删除的日志分段文件集合(deletableSegments),如图5-13所示。 retentionMs可以通过 broker端参数log.retention.hours.log.retention.minutes和log.retention.ms来配置,其中log.retention.ms的优先级最高,log.retention.minutes次之,log.retention.hours最低。默认情况下只配置了log.retention.hours参数,其值为168,故默认情况下日志分段文件的保留时间为7天。

查找过期的日志分段文件,并不是简单地根据日志分段的最近修改时间 lastModifiedTime来计算的,而是根据日志分段中最大的时间戳 largestTimeStamp来计算的。因为日志分段的lastModifiedTime可以被有意或无意地修改,比如执行了 touch操作,或者分区副本进行了重新分配, lastModifiedTime并不能真实地反映出日志分段在磁盘的保留时间。要获取日志分段中的最大时间戳 largestTimeStamp的值,首先要查询该日志分段所对应的时间戳索引文件,查找时间戳索引文件中最后一条索引项,若最后一条索引项的时间戳字段值大于0,则取其值,否则才设置为最近修改时间 last ModifiedTime。

若待删除的日志分段的总数等于该日志文件中所有的日志分段的数量,那么说明所有的日志分段都已过期,但该日志文件中还要有一个日志分段用于接收消息的写入,即必须要保证有个活跃的日志分段 activeSegment,在此种情况下,会先切分出一个新的日志分段作为activeSegment,然后执行删除操作。

删除日志分段时,首先会从Log对象中所维护日志分段的跳跃表中移除待删除的日志分段,以保证没有线程对这些日志分段进行读取操作。然后将日志分段所对应的所有文件添加上“.deleted”的后缀(当然也包括对应的索引文件)。最后交由一个以“delete-file”命名的延迟任务来删除这些以“.deleted”为后缀的文件,这个任务的延迟执行时间可以通过file.delete.delay.ms参数来调配,此参数的默认值为600,即1分钟。

4.1.2、基于日志大小

日志删除任务会检查当前日志的大小是否超过设定的阈值(retentionSize)来寻找可删除的日志分段的文件集合(deletableSegments),如图5-14所示。 retentionSize可以通过 broker端参数log.retention.bytes来配置,默认值为-1,表示无穷大。注意log.retention.bytes配置的是Log中所有日志文件的总大小,而不是单个日志分段(确切地说应该为log日志文件)的大小。单个日志分段的大小由 broker端参数log.segment.bytes来限制,默认值为1073741824,即1GB。

基于日志大小的保留策略与基于时间的保留策略类似,首先计算日志文件的总大小size和retentionSize的差值dif,即计算需要删除的日志总大小,然后从日志文件中的第一个日志分段开始进行查找可删除的日志分段的文件集合 deletableSegments。查找出deletableSegments之后就执行删除操作,这个删除操作和基于时间的保留策略的删除操作相同,这里不再赘述。

4.1.3、基于日志起始偏移量

一般情况下,日志文件的起始偏移量 logStartOffset等于第一个日志分段的 baseOffset,但这并不是绝对的, logStartOffset的值可以通过 DeleteRecordsRequest请求(比如使用KafkaAdminClient的 deleteRecord()方法、使用 kafka-delete-records.sh脚本,具体用法参考《命令行工具》)、日志的清理和截断等操作进行修改。

基于日志起始偏移量的保留策略的判断依据是某日志分段的下一个日志分段的起始偏移量baseOffset是否小于等于 logStartOffset,若是,则可以删除此日志分段。如图5-15所示,假设logStartOffset等于25,日志分段1的起始偏移量为0,日志分段2的起始偏移量为11,日志分段3的起始偏移量为23,通过如下动作收集可删除的日志分段的文件集合 deletableSegments:
(1)从头开始遍历每个日志分段,日志分段1的下一个日志分段的起始偏移量为11,小于 logStartOffset的大小,将日志分段1加入 deletableSegments。
(2)日志分段2的下一个日志偏移量的起始偏移量为23,也小于 logStartOffset的大小,将日志分段2页加入 deletableSegments。

(3)日志分段3的下一个日志偏移量在 logStartoffset的右侧,故从日志分段3开始的所有日志分段都不会加入deletableSegments。

收集完可删除的日志分段的文件集合之后的删除操作同基于日志大小的保留策略和基于时间的保留策略相同,这里不再赘述。

4.2、日志压缩

Kafka中的 LogCompaction是指在默认的日志删除(LogRetention)规则之外提供的一种清理过时数据的方式。如图5-16所示, LogCompaction对于有相同key的不同 value值,只保留最后一个版本。如果应用只关心key对应的最新vaue值,则可以开启Kafka的日志清理功能,Kafka会定期将相同key的消息进行合并,只保留最新的 value值。

Log Compaction执行前后,日志分段中的每条消息的偏移量和写入时的偏移量保持一致。Log Compaction会生成新的日志分段文件,日志分段中每条消息的物理位置会重新按照新文件来组织。 Log Compaction执行过后的偏移量不再是连续的,不过这并不影响日志的查询。

Kafka中的 Log Compaction可以类比于 Redis中的RDB的持久化模式。试想一下,如果个系统使用Kafka来保存状态,那么每次有状态变更都会将其写入Kafka。在某一时刻此系统异常崩溃,进而在恢复时通过读取 Kafka中的消息来恢复其应有的状态,那么此系统关心的是它原本的最新状态而不是历史时刻中的每一个状态。如果 Kafka的日志保存策略是日志删除(Log Deletion),那么系统势必要一股脑地读取Kafka中的所有数据来进行恢复,如果日志保存策略是 Log Compaction,那么可以减少数据的加载量进而加快系统的恢复速度。Log Compaction在某些应用场景下可以简化技术栈,提高系统整体的质量。

我们知道可以通过配置log.dir或log.dirs参数来设置Kafka日志的存放目录,而每一个日志目录下都有一个名为“cleaner-offset-checkpoint”的文件,这个文件就是清理检查点文件,用来记录每个主题的每个分区中已清理的偏移量。通过清理检查点文件可以将Log分成两个部分,如图5-17所示。通过检查点 cleaner checkpoint来划分出一个已经清理过的 clean部分和一个还未清理过的 dirty部分。在日志清理的同时,客户端也可以读取日志中的消息。diry部分的消息偏移量是逐一递增的,而 clean部分的消息偏移量是断续的,如果客户端总能赶上dirty部分,那么它就能读取日志的所有消息,反之就不可能读到全部的消息。

图5-17中的 firstDirtyOffset(与 cleaner checkpoint相等)表示 dirty部分的起始偏移量,而firstUncleanableOffset为diry部分的截止偏移量,整个dity部分的偏移量范围为 [firstDirtyOffset,firstUncleanableOffset,注意这里是左闭右开区间。为了避免当前活跃的日志分段 activeSegment成为热点文件, activeSegment不会参与 Log Compaction的执行。同时Kafka支持通过参数log.cleaner.min.compaction.lag.ms(默认值为0)来配置消息在被清理前的最小保留时间,默认情况下 firstUncleanableOffset等于 activeSegment的 baseOffset。

注意 Log Compaction是针对key的,所以在使用时应注意每个消息的key值不为null。每个 broker会启动log.cleaner.thread(默认值为1)个日志清理线程负责执行清理任务,这些线程会选择“污浊率”最高的日志文件进行清理。用 cleanBytes表示clean部分的日志占用大小, dirtyBytes表示 dirty部分的日志占用大小,那么这个日志的污浊率(dirtyRatio)为:
dirtyRatio = dirtyBytes/(cleanBytes + dirtyBytes)
为了防止日志不必要的频繁清理操作,Kafka还使用了参数log.cleaner.min.cleanable.ratio(默认值为0.5)来限定可进行清理操作的最小污浊率。 Kafka中用于保存消费者消费位移的主题__consumer_offsets使用的就是 Log Compaction策略。

这里我们已经知道怎样选择合适的日志文件做清理操作,然而怎么对日志文件中消息的key进行筛选操作呢? Kafka中的每个日志清理线程会使用一个名为“SkimpyoffsetMap”的对象来构建key与 offset的映射关系的哈希表。日志清理需要遍历两次日志文件,第一次遍历把每个key的哈希值和最后出现的 offset都保存在 SkimpyOffsetMap中,映射模型如图5-18所示。第二次遍历会检查每个消息是否符合保留条件,如果符合就保留下来,否则就会被清理。假设一条消息的oset为O1,这条消息的key在 SkimpyoffsetMap中对应的offset为O2,如果O1大于等于O2即满足保留条件。

默认情况下, SkimpyOffsetMap使用MD5来计算key的哈希值,占用空间大小为16B,根据这个哈希值来从 SkimpyOffsetMap中找到对应的槽位,如果发生冲突则用线性探测法处理。

为了防止哈希冲突过于频繁,也可以通过 broker端参数log.cleaner.io.buffer.load.factor(默认值为0.9)来调整负载因子。偏移量占用空间大小为8B,故一个映射项占用大小为24B。每个日志清理线程的 SkimpyoffsetMap的内存占用大小为 log.cleaner.dedupe.buffer.size / log.cleaner.thread,默认值为=128MB/1=128MB。所以默认情况下 SkimpyOffsetMap可以保存
128MB×0.924B≈5033164个key的记录。假设每条消息的大小为1KB,那么这个SkimpyOffsetMap可以用来映射48GB的日志文件,如果有重复的key,那么这个数值还会增大,整体上来说, SkimpyOffsetMap极大地节省了内存空间且非常高效。

题外话:“SkimpyOffsetMap”的取名也很有意思,“Skimpy”可以直译为“不足的”,可以看出它最初的设计者也认为这种实现不够严谨。如果遇到两个不同的key但哈希值相同的情况,那么其中一个key所对应的消息就会丢失。虽然说MD5这类摘要算法的冲突概率非常小,但根据墨菲定律,任何一个事件,只要具有大于0的概率,就不能假设它不会发生,所以在使用 Log Compaction策略时要注意这一点。

Log Compaction会保留key相应的最新 value值,那么当需要删除一个key时怎么办?Kafka提供了一个墓碑消息(tombstone)的概念,如果一条消息的key不为null,但是其 value为null,那么此消息就是墓碑消息。日志清理线程发现墓碑消息时会先进行常规的清理,并保留墓碑消息一段时间。墓碑消息的保留条件是当前墓碑消息所在的日志分段的最近修改时间lastModifiedTime大于 deleteHorizonMs,如图5-17所示。这个deleteHorizonMs的计算方式为 clean部分中最后一个日志分段的最近修改时间减去保留阈值 deleteRetionMs(通过 broker端参数log.cleaner.delete.retention.ms配置,默认值为864000024小时)的大小,即:

deleteHorizonMs=clean部分中最后一个 LogSegment的lastModifiedTime- deleteRetionMs

所以墓碑消息的保留条件为(可以对照图5-17中的 deleteRetionMs所标记的位置去理解):

所在 LogSegment的lastModifiedTime> deleteHorizonMs
=>所在 LogSegment的lastModifiedTime> clean部分中最后一个 LogSegment的lastModifiedTime - deleteretionMs
=>所在 LogSegment的lastModifiedTime+ deleteRetionMs> clean部分中最后一个LogSegment 的 lastModifiedTime

Log Compaction执行过后的日志分段的大小会比原先的日志分段的要小,为了防止出现太多的小文件, Kafka在实际清理过程中并不对单个的日志分段进行单独清理,而是将日志文件中offset从0至 firstUncleanableOffset的所有日志分段进行分组,每个日志分段只属于一组,分组策略为:按照日志分段的顺序遍历,每组中日志分段的占用空间大小之和不超过 segmentSize(可以通过 broker端参数log.segment.bytes设置,默认值为1GB),且对应的索引文件占用大小之和不超过 maxIndexSize(可以通过 broker端参数log.index.interval.bytes设置,默认值为10MB)。同一个组的多个日志分段清理过后,只会生成一个新的日志分段。

如图5-19所示,假设所有的参数配置都为默认值,在 Log Compaction之前 checkpoint的初始值为0。执行第一次 Log Compaction之后,每个非活跃的日志分段的大小都有所缩减,checkpoint的值也有所变化。执行第二次 Log Compaction时会组队成[04GB,04GB]、[0.3GB,0.7GB]、[0.3GB]、[1GB]这4个分组,并且从第二次 Log Compaction开始还会涉及墓碑消息的清除。同理,第三次 Log Compaction过后的情形可参考图5-19的尾部。 Log Compaction过程中会将每个日志分组中需要保留的消息复制到一个以“.clean”为后缀的临时文件中,此临时文件以当前日志分组中第一个日志分段的文件名命名,例如00000000000000000000.log.clean。Log Compaction过后将“.clean”的文件修改为“.swap”后缀的文件,例如:00000000000000000000.log.swap。然后删除原本的日志文件,最后才把文件的“.swap”后缀去掉。整个过程中的索引文件的变换也是如此,至此一个完整 Log Compaction操作才算完成。

5、磁盘存储

Kafka依赖于文件系统(更底层地来说就是磁盘)来存储和缓存消息。在我们的印象中,对于各个存储介质的速度认知大体同图5-20所示的相同,层级越高代表速度越快。很显然,磁盘处于一个比较尴尬的位置,这不禁让我们怀疑 Kafka采用这种持久化形式能否提供有竞争力的性能。在传统的消息中间件 RabbitMQ中,就使用内存作为默认的存储介质,而磁盘作为备选介质,以此实现高吞吐和低延迟的特性。然而,事实上磁盘可以比我们预想的要快,也可能比我们预想的要慢,这完全取决于我们如何使用它。

有关测试结果表明,一个由6块7200r/min的RAID-5阵列组成的磁盘簇的线性(顺序)写入速度可以达到60MB/s,而随机写入速度只有100KB/s,两者性能相差6000倍。操作系统可以针对线性读写做深层次的优化,比如预读(read-ahead,提前将一个比较大的磁盘块读入内存)和后写(write-behind,将很多小的逻辑写操作合并起来组成一个大的物理写操作)技术。顺序写盘的速度不仅比随机写盘的速度快,而且也比随机写内存的速度快,如图5-21所示。

Kafka在设计时采用了文件追加的方式来写入消息,即只能在日志文件的尾部追加新的消息,并且也不允许修改已写入的消息,这种方式属于典型的顺序写盘的操作,所以就算Kafka使用磁盘作为存储介质,它所能承载的吞吐量也不容小觑。但这并不是让 Kafka在性能上具备足够竞争力的唯一因素,我们不妨继续分析。

5.1、页缓存

页缓存是操作系统实现的一种主要的磁盘缓存,以此用来减少对磁盘IO的操作。具体来说,就是把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问。为了弥补性能上的差异,现代操作系统越来越“激进地”将内存作为磁盘缓存,甚至会非常乐意将所有可用的内存用作磁盘缓存,这样当内存回收时也几乎没有性能损失,所有对于磁盘的读写也将经由统一的缓存。

当一个进程准备读取磁盘上的文件内容时,操作系统会先查看待读取的数据所在的页(page)是否在页缓存(pagecache)中,如果存在(命中)则直接返回数据,从而避免了对物理磁盘的IO操作;如果没有命中,则操作系统会向磁盘发起读取请求并将读取的数据页存入页缓存,之后再将数据返回给进程。同样,如果一个进程需要将数据写入磁盘,那么操作系统也会检测数据对应的页是否在页缓存中,如果不存在,则会先在页缓存中添加相应的页,最后将数据写入对应的页。被修改过后的页也就变成了脏页,操作系统会在合适的时间把脏页中的数据写入磁盘,以保持数据的一致性。

Linux操作系统中的vm.dirty_background_ratio参数用来指定当脏页数量达到系统内存的百分之多少之后就会触发 pdflush/flush/kdmflush等后台回写进程的运行来处理脏页,般设置为小于10的值即可,但不建议设置为0。与这个参数对应的还有一个vm.dirty_ratio参数,它用来指定当脏页数量达到系统内存的百分之多少之后就不得不开始对脏页进行处理,在此过程中,新的IO请求会被阻挡直至所有脏页被冲刷到磁盘中。对脏页有兴趣的读者还可以自行查阅vm.dirty_expire_centisecs、vm.dirty_writeback.centisecs等参数的使用说明。

对一个进程而言,它会在进程内部缓存处理所需的数据,然而这些数据有可能还缓存在操作系统的页缓存中,因此同一份数据有可能被缓存了两次。并且,除非使用 Direct I/O的方式,否则页缓存很难被禁止。此外,用过Java的人一般都知道两点事实:对象的内存开销非常大,通常会是真实数据大小的几倍甚至更多,空间使用率低下;Java的垃圾回收会随着堆内数据的增多而变得越来越慢。基于这些因素,使用文件系统并依赖于页缓存的做法明显要优于维护个进程内缓存或其他结构,至少我们可以省去了一份进程内部的缓存消耗,同时还可以通过结构紧凑的字节码来替代使用对象的方式以节省更多的空间。如此,我们可以在32GB的机器上使用28GB至30GB的内存而不用担心GC所带来的性能问题。此外,即使 Kafka服务重启,页缓存还是会保持有效,然而进程内的缓存却需要重建。这样也极大地简化了代码逻辑,因为维护页缓存和文件之间的一致性交由操作系统来负责,这样会比进程内维护更加安全有效。

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

Linux系统会使用磁盘的一部分作为swap分区,这样可以进行进程的调度:把当前非活跃的进程调入swap分区,以此把内存空出来让给活跃的进程。对大量使用系统页缓存的 Kafka而言,应当尽量避免这种内存的交换,否则会对它各方面的性能产生很大的负面影响。我们可以通过修改vm. swampiness参数(Linux系统参数)来进行调节。vm.swampiness参数的上限为100,它表示积极地使用swap分区,并把内存上的数据及时地搬运到swap分区中;vm.swampiness参数的下限为0,表示在任何情况下都不要发生交换(vm.swampiness=0的含义在不同版本的 Linux内核中不太相同,这里采用的是变更后的最新解释),这样一来,当内存耗尽时会根据一定的规则突然中止某些进程。笔者建议将这个参数的值设置为1,这样保留了swap的机制而又最大限度地限制了它对Kafka性能的影响。

5.2、磁盘IO流程

读者可能对于前面提及的页缓存、 Direct I/O、文件系统等概念的认知比较模糊,下面通过一张磁盘IO的流程图来加深理解,如图5-22所示。
参考图5-22,从编程角度而言,一般磁盘IO的场景有以下四种。
(1)用户调用标准C库进行IO操作,数据流为:应用程序buffer→C库标准 IObuffer→文件系统页缓存→通过具体文件系统到磁盘。
(2)用户调用文件IO,数据流为:应用程序 buffer→文件系统页缓存→通过具体文件系统到磁盘。
(3)用户打开文件时使用 O_DIRECT,绕过页缓存直接读写磁盘。
(4)用户使用类似dd工具,并使用 direct参数,绕过系统 cache与文件系统直接写磁盘。

发起I/O请求的步骤可以表述为如下的内容(以最长链路为例)。

  • 写操作:用户调用 fwrite把数据写入C库标准 IObuffer后就返回,即写操作通常是异步操作;数据写入C库标准 IObuffer后,不会立即刷新到磁盘,会将多次小数据量相邻写操作先缓存起来合并,最终调用wite函数一次性写入(或者将大块数据分解多次write调用)页缓存;数据到达页缓存后也不会立即刷新到磁盘,内核有 pdflush线程在不停地检测脏页,判断是否要写回到磁盘,如果是则发起磁盘IO请求。
  • 读操作:用户调用 fread到C库标准 IObuffer中读取数据,如果成功则返回,否则继续;到页缓存中读取数据,如果成功则返回,否则继续;发起IO请求,读取数据后缓存 buffer和C库标准 IObuffer并返回。可以看出,读操作是同步请求。
  • 请求处理:通用块层根据IO请求构造一个或多个bio结构并提交给调度层;调度器将bio结构进行排序和合并组织成队列且确保读写操作尽可能理想:将一个或多个进程的读操作合并到一起读,将一个或多个进程的写操作合并到一起写,尽可能变随机为顺序(因为随机读写比顺序读写要慢),读必须优先满足,而写也不能等太久。

针对不同的应用场景,I/O调度策略也会影响I/O的读写性能,目前 Linux系统中的I/O调度策略有4种,分别为NOOP、CFQ、DEADLINE和 ANTICIPATORY,默认为CFQ。

1. NOOP

NOOP算法的全写为 No Operation。该算法实现了最简单的FIFO队列,所有IO请求大致按照先来后到的顺序进行操作。之所以说“大致”,原因是NOOP在FIFO的基础上还做了相邻IO请求的合并,并不是完全按照先进先出的规则满足IO请求。
假设有如下的IO请求序列:
100,500,101,10,56,1000
NOOP将会按照如下顺序满足IO请求:
100(101),500,10,56,1000

2.CFQ

CFQ算法的全写为 Completely Fair Queuing。该算法的特点是按照I/O请求的地址进行排序,而不是按照先来后到的顺序进行响应。
假设有如下的IO请求序列
100,500,101,10,56,1000
CFQ将会按照如下顺序满足:
100,101,500,1000,10,56

CFQ是默认的磁盘调度算法,对于通用服务器来说是最好的选择。它试图均匀地分布对IO带宽的访问。CFQ为每个进程单独创建一个队列来管理该进程所产生的请求,也就是说,每个进程一个队列,各队列之间的调度使用时间片进行调度,以此来保证每个进程都能被很好地分配到I/O带宽。I/O调度器每次执行一个进程的4次请求。在传统的SAS盘上,磁盘寻道花去了绝大多数的IO响应时间。CFQ的出发点是对O地址进行排序,以尽量少的磁盘旋转次数来满足尽可能多的IO请求。在CFQ算法下,SAS盘的吞吐量大大提高了。相比于NOOP的缺点是,先来的IO请求并不一定能被满足,可能会出现“饿死”的情况。

3.DEADLINE

DEADLINE在CFQ的基础上,解决了I/O请求“饿死”的极端情况。除了CFQ本身具有的IO排序队列, DEADLINE额外分别为读IO和写IO提供了FIFO队列。读FIFO队列的最大等待时间为500ms,写FIFO队列的最大等待时间为5s。FIFO队列内的IO请求优先级要比CFQ队列中的高,而读FIFO队列的优先级又比写FIFO队列的优先级高。优先级可以表示如下:

FIFO(Read) > FIFO(Write) > CFQ

4.ANTICIPATORY

CFQ和 DEADLINE考虑的焦点在于满足零散IO请求上。对于连续的IO请求,比如顺序读,并没有做优化。为了满足随机IO和顺序IO混合的场景, Linux还支持 ANTICIPATORY调度算法。 ANTICIPATORY在 DEADLINE的基础上,为每个读IO都设置了6ms的等待时间窗口。如果在6ms内OS收到了相邻位置的读IO请求,就可以立即满足。 ANTICIPATORY算法通过增加等待时间来获得更高的性能,假设一个块设备只有一个物理查找磁头(例如一个单独的SATA硬盘),将多个随机的小写入流合并成一个大写入流(相当于将随机读写变顺序读写),通过这个原理来使用读取/写入的延时换取最大的读取/写入吞吐量。适用于大多数环境,特别是读取/写入较多的环境。

不同的磁盘调度算法(以及相应的IO优化手段)对 Kafka这类依赖磁盘运转的应用的影响很大,建议根据不同的业务需求来测试并选择合适的磁盘调度算法。

从文件系统层面分析, Kafka操作的都是普通文件,并没有依赖于特定的文件系统,但是依然推荐使用EXT4或XFS。尤其是对XFS而言,它通常有更好的性能,这种性能的提升主要影响的是 Kafka的写入性能。

5.3、零拷贝

除了消息顺序追加、页缓存等技术, Kafka还使用零拷贝(Zero-Copy)技术来进一步提升性能。所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。零拷贝大大提高了应用程序的性能,减少了内核和用户模式之间的上下文切换。对 Linux操作系统而言,零拷贝技术依赖于底层的 sendfile方法实现。对应于Java语言FileChannel.transferTo()方法的底层实现就是 sendfile方法。

单纯从概念上理解“零拷贝”比较抽象,这里简单地介绍一下它。考虑这样一种常用的情形:你需要将静态内容(类似图片、文件)展示给用户。这个情形就意味着需要先将静态内容从磁盘中复制出来放到一个内存buf中,然后将这个buf通过套接字(Socket)传输给用户,进而用户获得静态内容。这看起来再正常不过了,但实际上这是很低效的流程,我们把上面的这种情形抽象成下面的过程:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
首先调用 read()将静态内容(这里假设为文件A)读取到 tmp_buf,然后调用 write()将 tmp_buf写入 Socket,如图5-23所示。

在这个过程中,文件A经历了4次复制的过程:

(1)调用 read()时,文件A中的内容被复制到了内核模式下的 Read Buffer中。
(2)CPU控制将内核模式数据复制到用户模式下。
(3)调用 write时,将用户模式下的内容复制到内核模式下的 Socket Buffer中。
(4)将内核模式下的 Socket Buffer的数据复制到网卡设备中传送。

  

从上面的过程可以看出,数据平白无故地从内核模式到用户模式“走了一圈”,浪费了2次复制过程:第一次是从內核模式复制到用户模式;第二次是从用户模式再复制回内核模式,即上面4次过程中的第2步和第3步。而且在上面的过程中,内核和用户模式的上下文的切换也是4次。
如果采用了零拷贝技术,那么应用程序可以直接请求内核把磁盘中的数据传输给 Socket,如图5-24所示。

零拷贝技术通过DMA(Direct Memory Access)技术将文件内容复制到内核模式下的Read Buffer中。不过没有数据被复制到 Socket Buffer,相反只有包含数据的位置和长度的信息的文件描述符被加到 Socket Buffer中。DMA引擎直接将数据从内核模式中传递到网卡设备(协议引擎)。这里数据只经历了2次复制就从磁盘中传送出去了,并且上下文切换也变成了2次。零拷贝是针对内核模式而言的,数据在内核模式下实现了零拷贝。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值