1、概述
Kafka是大数据领域无处不在的消息中间件,目前广泛使用在企业内部的实时数据管道,并帮助企业构建自己的流计算应用程序。Kafka虽然是基于磁盘做的数据存储,但却具有高性能、高吞吐、低延时的特点,其吞吐量动辄几万、几十上百万,这其中的原由值得我们一探究竟,让我们一起探究Kafka各种精巧的设计。
2、Kafka高性能分析
1、Kafka系统架构
上图是Kafka的架构图,Producer生产消息,以Partition的维度,按照一定的路由策略,提交消息到Broker集群中各Partition的Leader节点,Consumer以Partition的维度,从Broker中的Leader节点拉取消息并消费消息。
Producer生产消息会涉及大量的消息网络传输,如果Producer每生产一个消息就发送到Broker会造成大量的网络消耗,严重影响到Kafka的性能。为了解决这个问题,Kafka使用了批量发送的方式。 Broker在持久化消息、读取消息的时候,如果采用传统的IO读写方式,会严重影响Kafka的性能,为了解决这个问题,Kafka采用了顺序写+零拷贝的方式。
下面分别从批量发送消息、持久化消息、零拷贝三个角度介绍Kafka如何提高性能。
2、批量发送消息
Producer生成消息发送到Broker,涉及到大量的网络传输,如果一次网络传输只发送一条消息,会带来严重的网络消耗。为了解决这个问题,Kafka采用批量发送的方式。下面介绍Producer生产消息发送到Broker的过程。
2.1 Parition
Kafka的消息是一个一个的键值对,键可以设置为默认的null。键有两个用途,可以作为消息的附加信息,也可以用来决定该消息被写入到哪个Partition。Topic的数据被分成一个或多个Partition,Partition是消息的集合,Partition是Consumer消费的最小粒度。
Kafka通过将Topic划分成多个Partition,Producer将消息分发到多个本地Partition的消息队列中,每个Partition消息队列中的消息会写入到不同的Leader节点。如上图所示,消息经过路由策略,被分发到不同的Partition对应的本地队列,然后再批量发送到Partition对应的Leader节点。
2.2. 消息路由
Kafka中Topic有多个Partition,那么消息分配到某个Partition的策略称为路由策略。Kafka的路由策略主要有三种:
- Round Robin:Producer将消息均衡地分配到各Partition本地队列上,是最常用的分区策略。
- 散列:Kafka对消息的key进行散列,根据散列值将消息路由到特定的Parttion上,键相同的消息总是被路由到相同的Partition上。
- 自定义分区策略:Kafka支持自定义分区策略,可以将某一系列的消息映射到相同的Partition。
2.3 发送流程
上图是Producer生产消息到发送到Broker的主流程。Producer先生产消息、序列化消息并压缩消息后,追加到本地的记录收集器(RecordAccumulator),Sender不断轮询记录收集器,当满足一定条件时,将队列中的数据发送到Partition Leader节点。Sender发送数据到Broker的条件有两个:
- 消息大小达到阈值
- 消息等待发送的时间达到阈值
Producer会为每个Partition都创建一个双端队列来缓存客户端消息,队列的每个元素是一个批记录(ProducerBatch),批记录使用createdMs表示批记录的创建时间(批记录中第一条消息加入的时间), topicPartion表示对应的Partition元数据。当Producer生产的消息经过序列化,会被先写入到recordsBuilder对象中。一旦队列中有批记录的大小达到阈值,就会被Sender发送到Partition对应的Leader节点;若批记录等待发送的时间达到阈值,消息也会被发送到Partition对应的Leader节点中。
追加消息时首先要获取Partition所属的队列,然后取队列中最后一个批记录,如果队列中不存在批记录或者批记录的大小达到阈值,应该创建新的批记录,并且加入队列的尾部。这里先创建的批记录最先被消息填满,后创建的批记录表示最新的消息,追加消息时总是往队列尾部的批记录中追加。记录收集器用来缓存客户端的消息,还需要通过Sender才能将消息发送到Partition对应的Leader节点。
Sender读取记录收集器,得到每个Leader节点对应的批记录列表,找出准备好的Broker节点并建立连接,然后将各个Partition的批记录发送到Leader节点。Sender的核心代码如下:
//Sender读取记录收集器,按照节点分组,创建客户端请求,发送请求
public void run(long now) {
Cluster cluster = metadata.fetch();
//获取准备发送的所有分区
ReadCheckResult result = accumulator.ready(cluster, now);
//建立到Leader节点的网络连接,移除还没有准备好的节点
Iterator iter = result.readyNodes.iterator();
while(iter.hasNext()) {
Node node = iter.next();
if (!this.client.read(node, now)) {
iter.remove();
}
//读取记录收集器,返回的每个Leader节点对应的批记录列表,每个批记录对应一个分区
Map> batches = accumulator.drain(cluster, result.readyNodes,
this.maxRequestSize, now);
//以节点为级别的生产请求列表,即每个节点只有一个客户端请求
List requests = createProduceRequests(batches, now);
for (ClientRequest request : requests) {
client.send(request, now);
}
//这里才会执行真正的网络读写,比如将上面的客户端请求发送出去
this.client.poll(pollTimeout, now);
}
}
具体的步骤如下:
消息被记录收集器收集,并按照Partition追加到队列尾部一个批记录中。
Sender通过ready()从记录收集器中找出已经准备好的服务端节点,规则是Partition等待发送的消息大小和等待发送的时间达到阈值。
节点已经准备好,如果客户端还没有和它们建立连接,通过connect()建立到服务端的连接。
Sender通过drain()从记录收集器获取按照节点整理好的每个Partition的批记录。
Sender得到每个节点的批记录后,为每个节点创建客户端请求,并将消息发送到服务端。
3、消息持久化
3.1 随机IO和顺序IO
上图是磁盘的简易模型图。磁盘上的数据由柱面号、盘片号、扇区号标识。当需要从磁盘读数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。
为了实现读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点:
- 首先必须找到柱面,即磁头需要移动对准相应磁道,这个过程叫做寻道或定位;
- 盘面确定以后,盘片开始旋转,将目标扇区旋转到磁头下
因此一次读数据请求完成过程由三个动作组成: - 寻道:磁头移动定位到指定磁道,这部分时间代价最高,最大可达到0.1s左右;
- 旋转延迟:等待指定扇区旋转至磁头下。与硬盘自身性能有关,xxxx转/分;
- 数据传输:数据通过系统总线从磁盘传送到内存的时间。
对于从磁盘中读取数据的操作,叫做IO操作,这里有两种情况: - 假设我们所需要的数据是随机分散在磁盘的不同盘片的不同扇区中的,那么找到相应的数据需要等到磁臂通过寻址作用旋转到指定的盘片,然后盘片寻找到对应的扇区,才能找到我们所需要的一块数据,一次进行此过程直到找完所有数据,称为随机IO,读取数据速度较慢。
- 假设我们已经找到了第一块数据,并且其他所需的数据就在这一块数据后边,那么就不需要重新寻址,可以依次拿到我们所需的数据,称为顺序IO。
顺序IO相对于随机IO,减少了大量的磁盘寻址过程,提高了数据的查询效率。
3.2 Broker写消息
Broker中需要将大量的消息做持久化,而且存在大量的消息查询场景,如果采用传统的IO操作,会带来大量的磁盘寻址,影响消息的查询速度,限制了Kafka的性能。为了解决这个问题,Kafka采用顺序写的方式来做消息持久化。
Producer传递到Broker的消息集中的每条消息都会分配一个顺序值,用来标记Producer所生产消息的顺序,每一批消息的顺序值都从0开始。下图给出一个例子,Producer写到Partition的消息有3条消息,对应的顺序值是[0,1,2]。
Producer创建的消息集中每条消息的顺序值只是相对于本批次的序号,所以这个值不能直接存储在日志文件中。服务端会将每条消息的顺序值转换成绝对偏移量(Broker从Partition维度来标记消息的顺序,用于控制Consumer消费消息的顺序)。Kafka通过nextOffset(下一个偏移量)来记录存储在日志中最近一条消息的偏移量。
Message | 绝对偏移量 | 顺序值 |
---|---|---|
Message0 | 900 | 0 |
Message1 | 901 | 1 |
Message2 | 902 | 2 |
如上表所示,消息写入前,nextOffset是899,Message0、Message1、Message2是连续写入的三条消息,消息被写入后其绝对偏移量分别是900、901、902,对应的顺序值分别是0、1、2,nextOffset变成902。
Broker将每个Partition的消息追加到日志中,是以日志分段(Segment)为单位的。当Segment的大小达到阈值(默认是1G)时,会新创建一个Segment保存新的消息,每个Segment都有一个基准偏移量(baseOffset,每个Segment保存的第一个消息的绝对偏移量),通过这个基准偏移量,就可以计算出每条消息在Partition中的绝对偏移量。 每个日志分段由数据文件和索引文件组,数据文件(文件名以log结尾)保存了消息集的具体内容,索引文件(文件名以index结尾)保存了消息偏移量到物理位置的索引。如下图所示:
Broker中通过下一个偏移量元数据(nextOffsetMetaData),指定当前写入日志的消息的起始偏移值,在追加消息后,更新nextOffsetMetaData,作为下一批消息的起始偏移量。核心代码如下所示:
@volatile var nextOffsetMetadata = new LogOffsetMetadata(activeSegment.nextOffset(),
activeSegment.baseOffset, activeSegment.size.toInt);
def append(messages:ByteBufferMessageSet, assignOffsets:Boolean) = {
//LogAppendInfo对象,代表这批消息的概要信息,然后对消息进行验证
var appendInfo = analyzeAndValidateMessageSet(messages)
var validMessages = trimInvalidBytes(messages, appendInfo)
//获取最新的”下一个偏移量“作为第一条消息的绝对偏移量
appendInfo.firstOffset = nextOffsetMetadata.messageOffset
if (assignOffsets) { //如果每条消息的偏移量都是递增的
//消息的起始偏移量来自于最新的”下一个偏移量“,而不是消息自带的顺序值
var offset = new AtomicLong(nextOffsetMetadata.messageOffset);
//基于起始偏移量,为有效的消息集的每条消息重新分配绝对偏移量
validMessages = validMessages.validateMessagesAndAssignOffsets(offset);
appendInfo.lastOffset = offset.get - 1 //最后一条消息的绝对偏移量
}
var segment = maybeRoll(validMessages.sizeInBytes) //如果达到Segment大小的阈值,需要创建新的Segment
segment.append(appendInfo.firstOffset,validMessages) //追加消息到当前分段
updateLogEndOffset(appendInfo.lastOffset + 1) //修改最新的”下一个偏移量“
if (unflushedMessages >= config.flushInterval) {
flush() //如果没有刷新的消息数大于配置的,那么将消息刷入到磁盘
}
}
//更新日志的”最近的偏移量“,传入的参数一般是最后一条消息的偏移量加上1
//使用发需要获取日志的”最近的量“时,就不需要再做加一的操作了
private def updateLogEndOffset(messageOffset:Long) {
nextOffsetMetadata = new LogOffsetMetadata(messageOffset, activeSegment.baseOffset,activeSegment.size.toInt)
}
nextOffsetMetaData的读写操作发生在持久化和读取消息中,具体流程如下所示:
1、Producer发送消息集到Broker,Broker将这一批消息追加到日志;
2、每条消息需要指定绝对偏移量,Broker会用nextOffsetMetaData的值作为起始偏移值;
3、Broker将每条带有偏移量的消息写入到日志分段中;
4、Broker获取这一批消息中最后一条消息的偏移量,加1后更新nextOffsetMetaData;
5、Consumer根据这个变量的最新值拉取消息。一旦这个值发生变化,Consumer就能拉取到新写入的消息。
由于写入到日志分段中的消息集,都是以nextOffsetMetaData作为起始的绝对偏移量。因为这个起始偏移量总是递增,所以每一批消息的偏移量也保持递增,而且每一个Partition的所有日志分段中,所有消息的偏移量都是递增。如下图所示,新创建日志分段的基准偏移量,比之前的分段的基准偏移量要大,同一个日志分段中,新消息的偏移量也比之前消息的偏移量要大。
建立索引文件的目的:快速定位指定偏移量消息在数据文件中的物理位置。其中索引文件保存的是一部分消息的相对偏移量到物理位置的映射,使用相对偏移量而不是绝对偏移量是为了节约内存。
3.3 基于索引文件查询
Kafka通过索引文件提高对磁盘上消息的查询效率。
如上图所示:假设有1000条消息,每100条消息写满了一个日志分段,一共会有10个日志分段。客户端要查询偏移量为938的消息内容,如果没有索引文件,我们必须从第一个日志分段的数据文件中,从第一条消息一直往前读,直到找到偏移量为999的消息。有了索引文件后,我们可以在最后一个日志分段的索引文件中,首先使用绝对偏移量999减去基准偏移量900得到相对偏移量99,然后找到最接近相对偏移量99的索引数据90,相对偏移量90对应的物理地址是1365,然后再到数据文件中,从文件物理位置1365开始往后读消息,直到找到偏移量为999的消息。
Kafka的索引文件的特性:
- 索引文件映射偏移量到文件的物理位置,它不会对每条消息都建立索引,所以是稀疏的。
- 索引条目的偏移量存储的是相对于“基准偏移量”的“相对偏移量” ,不是消息的“绝对偏移量” 。
- 偏移量是有序的,查询指定的偏移量时,使用二分查找可以快速确定偏移量的位置。
- 指定偏移量如果在索引文件中不存在,可以找到小于等于指定偏移量的最大偏移量。
- 稀疏索引可以通过内存映射方式,将整个索引文件都放入内存,加快偏移量的查询。
由于Broker是将消息持久化到当前日志的最后一个分段中,写入文件的方式是追加写,采用了对磁盘文件的顺序写。对磁盘的顺序写以及索引文件加快了Broker查询消息的速度。
4、零拷贝
Kafka中存在大量的网络数据持久化到磁盘(Producer到Broker)和磁盘文件通过网络发送(Broker到Consumer)的过程。这一过程的性能直接影响到Kafka的整体性能。Kafka采用零拷贝这一通用技术解决该问题。
零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,减少用户应用程序地址空间和操作系统内核地址空间之间因为上下文切换而带来的开销,从而有效地提高数据传输效率。
以将磁盘文件通过网络发送为例。下面展示了传统方式下读取数据后并通过网络发送所发生的数据拷贝:
- 一个读操作发生后,DMA执行了一次数据拷贝,数据从磁盘拷贝到内核空间;
- cpu将数据从内核空间拷贝至用户空间
- 调用send(),cpu发生第三次数据拷贝,由cpu将数据从用户空间拷贝至内核空间(socket缓冲区)
-
send()执行结束后,DMA执行第四次数据拷贝,将数据从内核拷贝至协议引擎
Linux 2.4+内核通过sendfile系统调用,提供了零拷贝。数据通过DMA拷贝到内核态Buffer后,直接通过DMA拷贝到NIC Buffer,无需CPU拷贝,这也是零拷贝这一说法的来源。除了减少数据拷贝外,因为整个读文件-网络发送由一个sendfile调用完成,整个过程只有两次上下文切换,没有cpu数据拷贝,因此大大提高了性能。零拷贝过程如下图所示。
- sendfile()通过DMA将文件内容拷贝到一个读取缓冲区,然后由内核将数据拷贝到与输出套接字相关联的内核缓冲区。
从具体实现来看,Kafka的数据传输通过TransportLayer来完成,其子类PlaintextTransportLayer通过Java NIO的FileChannel的transferTo()和transferFrom()方法实现零拷贝。transferTo()和transferFrom()并不保证一定能使用零拷贝,实际上是否能使用零拷贝与操作系统相关,如果操作系统提供sendfile这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝。
- sendfile()通过DMA将文件内容拷贝到一个读取缓冲区,然后由内核将数据拷贝到与输出套接字相关联的内核缓冲区。
参考文献
《Kafka技术内幕:图文详解Kafka源码设计与实现》
《Kafka权威指南》
Kafka的高性能磁盘读写实现原
Kafka日志存储