kafka在效率上做了很多的努力。最初的一个使用场景是处理网页上活跃的数据,它往往有非常大的体量,每个页面都能产生数十条写入。而且我们假设每条消息都会被至少一个消费者消费(通常是多个),因此,我们努力让消费变得更加的简单。

我们发现,从构建和运行一定数量的类似的系统的经验来看,消费的效率是有效的多租户运营的关键。假如下游基础设施服务由于应用系统的略微使用就会很容易达到瓶颈,那么很小的变动通常会产生问题。通过效率变得更高,我们可以确保应用系统在相当的负载下,会比下游基础设施更早出问题。这句话的意思就是,kafka系统作为基础设施,会比应用系统更加能抗压力。当你尝试运行一个能够容纳数十或成百应用的中心化集群服务,这是非常重要的一点,因为由于使用模式的改变几乎每天都在发生。

上一节讨论了磁盘效率的问题,假如磁盘访问效率低的问题已经被消除,那么在这类系统中还有两个常见的低效问题

  • 许多小型的I/O操作
  • 过多的字节拷贝

低效问题解决

小型的IO操作

先来说小型的IO操作,这类问题发生在客户端和服务端(broker)之间,以及服务端自己的持久化操作。为了避免这个问题,我们的协议是去构建一个抽象的“数据集”的概念,用来组织一批消息。这个操作允许将消息放在一起发送网络请求并且分摊网络往返的开销,而不是说同一时间只发送一条消息。这带来的好处就是:broker端也能够一次性将一块块的消息附加到日志文件中去,而消费者端也能够同时拉取一大块连续的消息。

kafka 变慢 kafka效率高的原因_java

上图简单说明了,通过消息集的方式发送,能够大幅度减少网络请求,增加kafka的吞吐。

但针对单条消息来说,转微批的方式,会产生一定的延时。直接影响的两个参数为:linger.ms + batch.size。

这两个参数,这里有个误区,我先解释下这两个参数分别的作用:

  • linger.ms:它是kafka 生产者的一个配置,用来控制批量发送消息的等待时间,就是说,当我收到一条消息后,我会默认等待linger.ms的时间,才会发送这批消息,默认linger.ms这个参数默认值是0。
  • batch.size:这个参数可能熟悉的人比较多,它也是用来控制批量发送消息的,不过linger.ms控制的是时间,而batch.size控制的是消息的大小,一旦在producer端暂存的消息大小超过了batch.size,那么就会执行send动作,这里的批是按照partition分组的,就是说发往每个partition的大小不能超过batch.size的大小,否则就会直接发送。默认值为:16384byte,即16KB。

问题来了,很多人在kafka调优过程中,会配置batch.size的大小,比如说调整到1MB或2MB。但是此时kafka吞吐并没有变化,这是为什么呢?

其实问题就出现在linger.ms。消息转微批发送这个动作,是由linger.ms和batch.size两个参数来控制的,即只要这两个参数满足一个,就会将这一批消息发送出去。而linger.ms的默认值为0,就意味着,只要来一条消息,就会立马发送,此时batch.size是不会生效的。

题主在压测过程中,将linger.ms调整成100ms,此时整体业务的tps由200提升到350,说明微批生效。(数字不具备参考意义)

因此:想提高一批消息的大小,要同时调整linger.ms和batch.size。

转批式发送之后,发送的网络包更大了,针对磁盘的操作也有更大的磁盘顺序操作,在内存中也是更大的连续内存块了。这一个操作,将上游随机的消息写入转成了顺序写入。

字节拷贝

另一个低效的操作是字节拷贝。当消息接受的速率较低时,这不会是一个问题,但是假如负载上来之后,影响就会很大。为了避免这个问题,我们在producer、broker、comsumer端都使用了标准的字节消息格式,这意味着在消息传输过程中,不需要做修改。

在broker端,消息日志就是以文件目录的形式保存的,每个文件由一系列的消息集填充,这些消息集以同样的格式被生产者和消费者使用。保持相同的格式允许一个最重要的优化手段:持久日志块的网络传输。 现代linux操作系统提供了一个高度优化的代码路径,用于将数据从页缓存传输到socket。在操作系统中,这个操作是由sendfile这个系统调用完成的。

在理解sendfile之前,我们先了解一下一般的数据是怎么从文件传输到socket:

  1. 操作系统从磁盘中读取数据并写入到内核空间的页缓存中去
  2. 应用系统将数据从内核区读取到用户空间下的缓冲区
  3. 应用系统将修改后的数据写回到内核空间,并写入到socket缓冲区。
  4. 操作系统将数据从socket 缓冲区拷贝到NIC 缓冲区并发送到网络

下图解释了传统数据的拷贝流程:

kafka 变慢 kafka效率高的原因_kafka 变慢_02

下图描述了文件传输到socket产生的上下文切换:

kafka 变慢 kafka效率高的原因_kafka_03

以上能看出来明显的低效,其中涉及到四次数据拷贝以及两次系统调用。而使用sendfile,避免了重复的拷贝动作,并且允许操作系统直接将数据从页缓存发送到网络中去,在这个优化下,只会存在一次CPU级别的拷贝动作(页缓存-NIC缓冲区)以及一次系统调用(sendfile)。

先粗略介绍一下零拷贝的使用,可以通过调用transferTo()方法,其底层就是使用了sendfile。

public void transferTo(long position, long count, WritableByteChannel target);

下图表示了使用sendfile之后,我们发送文件需要的代价:

kafka 变慢 kafka效率高的原因_大数据_04

细节内容,建议大家看官网的解释:

https://developer.ibm.com/articles/j-zerocopy/

我们期望的用户使用场景为一个topic多个消费者,这种场景下,使用了零拷贝之后,数据只需要被拷贝到页缓存,并且就能够被每个消费者重复使用了,而不是将数据保存在内存中并且每次读取都需要拷贝到用户空间去。这就允许消息的消费速率几乎能够达到网络的速率,也就是说此时网络带宽是我们消息系统的瓶颈(压测调优过程中,往往也是以带宽打满为标准)。

页缓存和sendfile的组合意味着在消费者连接的kafka集群中,你看不到磁盘的读取动作,数据几乎都会存在于缓存中。当然异常场景下也会有一些性能问题,这里我举个栗子:

  • 假如测试利用脚本启动了大量的消费者,并且消费大量无关的topic,导致了broker所在的机器上,cache被大量的占用了,此时,真正有效的topic数据,可能会由于内存淘汰策略,已经被刷入到磁盘中去了,这就导致了真正需要用到的topic的数据,每次消费,都需要从磁盘中读取了。

以上是一个真实的案例。

TLS/SSl操作只能在用户空间,目前kafka还不支持内核态的SSL_sendfile。由于这个限制,假如开启了SSL,那么sendfile就会失效。

可以通过以下两个配置开启SSL:

  • security.protocol
  • security.inter.broker.protocol

端到端批量压缩机制

上面有提到,在压测过程中,往往瓶颈不是cpu或内存,而是带宽。这个对于需要在两个数据中心之间传输的场景尤为明显。当然用户可以一个一个的压缩待发送的消息,但是这就造成很低的压缩比。

因为大部分数据的冗余,都是因为重复性,可能由于相同的类型或者json报文中相同的key。有效的压缩操作,最好需要压缩大量的消息而不是单条单条的压缩。

Kafka支持高效的批处理格式,一批消息可以被归在一起,压缩然后发送给服务端(broker)。服务端为了校验这批消息,会解压这批数据。例如说会校验这批消息的数量是否与请求头中消息数保持一致(验证消息是否丢失)。这批消息会以压缩的格式保存在服务端,在服务器日志中会保留压缩的形式并且以同样的形式发送到消费者端。因此消费者端需要以相同的解压缩协议对消息进行解压。

目前kafka支持的压缩协议有:GZIP、Snappy、LZ4和ZStandard。其中GZIP是比较推荐的。

总结

总结一下本文的大致内容,主要解释了kafka如何提高效率的,主要解决了小型IO和大量的字节拷贝问题。

  • 小型IO的问题,kafka的解法是转微批的方式。
  • 字节拷贝问题,kafka利用了零拷贝技术实现,减少了数据的重复拷贝问题,但目前还没做到真正的“零拷贝”。
  • 利用压缩技术,使的网络带宽能够更高效的使用。
  • 后续会出一个关于零拷贝的文章(先埋个坑)