Kafka漫谈——多角度看Kafka的设计理念

2,177 阅读13分钟

引言

消息队列(Message Queue)是分布式系统中重要的组件。当生产者和消费者的性能或者速度不均衡时,为了保障性能和可靠性,防止两者相互影响,需要在生产者和消费者之间加一个缓冲层,这个缓冲层就是消息队列。消息队列的作用主要有:

  • 异步:将不需要实时返回的逻辑异步化,可以提高总体接口的性能
  • 解耦:生产者和消费者没有直接依赖,一个系统故障不会影响另一个系统,保证系统的稳定性和健壮性
  • 削峰填谷:消息队列可以作为一层缓冲去平滑突发的流量,防止系统因为短时间负载过高影响可用性
  • 顺序性保证:很多情况下,数据的顺序都很重要,消息队列大部分都是有序的,保证数据按照特定顺序进行处理
  • 可靠性保证:消息队列可以提供持久化的功能,必要情况下可以重新消费历史消息。

市面上的消息队列中间件有很多,例如RabbitMQ、Kafka、RocketMQ等,各自有其适用场景。简单来说,RabbitMQ主要用于金融支付领域,有着很高的可靠性和可用性,延迟较低(微妙级),但相应的单机的吞吐量较小(万级)。Kafka设计之初是针对日志领域,最大的特点是吞吐量很大(十万级甚至百万级),时延相对来说高一些(毫秒级)。RocketMQ由阿里巴巴开源,设计思想源自Kafka,保持Kafka大吞吐量的同时,在同等机器下可以支持数目更多的Topic,在阿里巴巴内部广泛使用。

本文主要对Kafka进行讨论,旨在帮助大家解决以下几个问题:

  1. Kafka的架构是怎样设计的?
  2. Kafka的吞吐量这么高,是怎么保证的,都做了哪些优化?
  3. Kafka的顺序性是怎么保证的,能保证严格的全局顺序吗?
  4. Kafka消息的拉取模式是怎样的,为什么这么选择?
  5. Kafka的消息语义是怎样的?
  6. Kafka是如何保证高可用的?

1. Kafka的拓扑结构

1.1 相关概念

  • Producer: 消息的生产者

  • Consumer: 消息的消费者

  • Broker: Kafka集群实例,生产者将消息写入到broker存储,消费者从broker拉取消息并进行处理

  • Topic(主题): 每条发送到Kafka的消息都有一个对应的分类,这个类别称为Topic,Topic是逻辑上对不同消息的区分

  • Partition(分区): 消息的物理分区,一个Topic可以对应一个或多个 partition,每个partition是一个有序的队列,新消息顺序追加到partition末尾。每个消费者都都对应一个partition的偏移量,表示消费的位置

  • Consumer Group: 消费者组,每个消费者归属于特定的消费组,同一个消费组的不同消费者可以并行消费Topic中partition的消息

1.2 Kafka结构简介

Kafka架构图

一个Kafka架构包括若干个Producer(服务器日志、前端埋点、业务数据、数据库变更数据等),若干个Broker组成的Kafka集群,若干个Consumer(下游流程、业务监控、日志分析等),Kafka集群的元数据以及消费者的偏移量信息存储在ZK(Zookeeper)。Producer、Broker和Consumer三者都是分布式的,不存在任何单点,可以很方便地进行水平扩展。

2. Kafka的高吞吐率是怎么实现的

2.1 Producer端消息优化

Kafka支持使用异步批量的方式发送消息。当Producer生产一条消息时,并不会立刻发送到Broker,而是先放入到消息缓冲区,等到缓冲区满或者消息个数达到限制后,再批量发送到Broker。Producer端需要注意以下参数:

Producer消息缓存1

  • **acks参数:**表示Producer发送消息后是否需要等待broker的应答。目前提供三个取值,acks=0 表示发送消息后立即返回,不需要等待broker的确认;acks=1 表示消息被写入到主分区后,broker需给予应答,此时并不保证已写入kafka的复制分区,如果主分区挂掉,消息可能会丢失;acks=-1 是最严格的确认,必须等到消息写入到主分区并且同步复制分区成功后才会应答。
  • **buffer.memory参数:**表示消息缓存区的大小,单位是字节。
  • **batch.size参数:**batch的阈值。当kafka采用异步方式发送消息时,默认是按照batch模式发送。其中同一主题同一分区的消息会默认合并到一个batch内,当达到阈值后就会发送。
  • **linger.ms参数:**表示消息的最大的延时时间,默认是0,表示不做停留直接发送。

通过上述参数可以做可靠性、时延和吞吐量之间的权衡。如果业务对消息丢失有一定容忍,对时延要求不高,例如点击率统计,可以设置较大的batch.size,较长的linger.ms,并且设置acks=0,这样可以达到很高的吞吐率。

2.2 磁盘顺序读写

Kafka本质上是基于磁盘存储的消息队列,虽然在我们固有印象中磁盘的读写速度是非常慢的,但是Kafka巧妙利用了操作系统对IO的优化策略,达到了和内存读写近似的效果。这种优化策略就是磁盘缓存(Page Cache)。Page Cache可以看作磁盘文件在物理内存中的映射,根据局部性原理,如果某块数据被用到,那么存储在它周围的数据也很可能即将被用到。操作系统可以采用预读和后写的方式,对磁盘读写进行优化。

  • **预读:**磁盘顺序读取的效率是很高的(不需要寻道时间,只需要很少的旋转时间)。而在读取磁盘某块数据时,同时会顺序读取相邻地址的数据加载到PageCache,这样在读取后续连续数据时,只需要从PageCache中读取数据,相当于内存读写,速度会特别快。
  • **后写:**数据并不是直接写入到磁盘,而是默认先写入到Page Cache,再由Page Cache刷新到磁盘,刷新频率是由操作系统周期性的sync触发的(用户也可以手动调用sync触发刷新操作)。后写的方式大大减少对磁盘的总写入次数,提高写入效率。

Kafka中的消息存储在partition中,每个partition对应一组物理空间连续的磁盘文件。当有新消息进来时,会以追加的方式写入到磁盘文件末尾。消费者拉取消息时,也是以partition为单位,顺序拉取数据消费。可以看出来Kafka的读写都是顺序的,可以很高效地利用PageCache,解决磁盘读写的性能问题。

2.3 零拷贝技术

零拷贝技术一种是对IO的进一步优化,它的原理是通过减少数据在内存中的拷贝次数,来提高IO性能。从Broker的角度考虑,Kafka的消费对应是从磁盘文件读取数据发送到网卡的过程,介绍零拷贝之前,我们先看如果用传统IO,这个过程是怎么实现的:

Producer消息缓存

  • 应用进程使用系统调用read(),从用户态切换到内核态
  • 操作系统委托DMA(Direct Memory Access 存储器直接访问)将硬盘数据copy到内核缓冲区(DMA copy)
  • read()返回,内核缓存区数据拷贝到用户缓冲区(CPU copy),同时进程从内核态切换到用户态
  • 应用进程取到数据后,进行系统调用write(),再次从用户态切换到内核态
  • 数据从用户缓冲区拷贝到socket缓冲区(CPU copy)
  • 操作系统委托DMA将socket缓冲区数据写入到网卡(DMA copy)
  • write()方法返回,进程从内核态切换到用户态,整个流程结束

传统过程主要依赖于read/write系统调用,一共需要4次上下文切换,2次CPU copy,2次DMA copy,其中上下文切换和CPU copy都要消耗大量系统资源(DMA copy无需CPU介入,相对不消耗资源)。那能否优化这个过程?

Kafka使用sendfile的系统调用代替传统的read/write系统调用。sendfile是非常典型的零拷贝技术,我们再看sendfile是怎么实现磁盘文件读取数据发送到网卡的这个过程:

零拷贝

  • 应用进程进行系统调用sendfile,从用户态进入内核态
  • 操作系统委托DMA将硬盘数据拷贝到内核缓冲区(DMA copy)
  • 操作系统将文件描述符和数据长度发送到socket缓冲区,同时委托DMA拷贝数据,直接从内核缓冲区将数据写入到网卡
  • sendfile方法返回,进程从内核态切换到用户态,流程结束

可见使用sendfile流程后,一共只需要两次上下文切换,0次CPU copy,性能上的提升简直不要太明显。

2.4 End-To-End的压缩方式

在带宽受限的情况下,吞吐量和消息的大小呈反比关系,所以压缩消息能显著提升吞吐量。Kafka支持多种压缩算法,例如GZIP、Snappy和LZ4和Zstd。同时Kafka支持End-To-End的压缩方式,消息在生产端被压缩,Broker端默认不解压,直接存储到磁盘,直到消费端才被解压,这种方式保证了消息在网络传输过程中一直是被压缩的状态。

3. Kafka的顺序性

消息队列的顺序性,主要体现在生产端按照一定顺序生产消息,消费端能够按照同样的顺序接收处理消息。在单机系统中,可以保证严格的顺序性,但是在分布式系统中,严格的顺序性是很难保证的,通常需要在性能和顺序性上做一个取舍。我们先来考虑哪些环节会影响Kafka的顺序性:

  • Producer端:在2.1节中,我们提到过,Kafka生产端的消息默认使用异步批量的方式发送到broker,在异步批量的情况下,如果消息发送失败再重试,就会导致乱序。如果要保证严格有序,生产端就要使用同步的方式发送消息,显而易见性能会受到很大影响
  • Broker端:每条消息对应一个topic,每个topic会对应一个或多个partition,消息在每个partition是有序的,但是在全局上无法保证顺序。如果要保证严格的有序,只能为每个消息分配单个partition,但这样也就牺牲了partition的水平扩展性。
  • Consumer端:Kafka消费端支持批量从Broker拿数据,然后使用多线程的方式处理,但是多线程的消费同样可能导致乱序,若要严格有序,只能使用单线程。

在业务中,我们通常不会为了实现严格有序,而在Kafka的各个环节做性能的取舍。大多数情况下,我们不需要保证全局有序性,只需要局部有序性就够了。例如对商品订单的消费,我们不关心A商品和B商品的消息会不会乱序,但是我们需要保证A商品的所有消息必须有序。Kafka可以自定义Key值进行分区,同时Kafka的每个partition是有序的,可以很容易实现局部有序。

4. Kafka消息推送模式

Kafka消息生产过程采用的push模式,消息消费过程采用的poll模式。

消息拉取模式

  • **push模式:**消息直接发送到下游。好处是消息可以实时传达到下游,中间无延迟。坏处是消息速率由上游决定,如果速率过大,对下游服务的压力会很大,这也限制了上游服务的扩展性。
  • **poll模式:**下游服务通过轮询方式主动从上游拉取数据。好处是下游服务可以根据自身的性能灵活调整接收速率,扩展能力强。坏处是消息的实时性依赖于轮询的间隔,同时可能会有一定性能损耗(即使没消息也需要轮询)。

5. Kafka消息语义

消息传输过程中,难免会遇到各种异常情况,那出现异常后,消息是丢失,还是重试?重试后会不会造成重复消费?消息语义主要讨论的是,在各种异常的情况下,消息能否保证一对一的消费。通常会有三种模式:

  • **At most once:**消息被生产出来,至多被处理一次,这种模式下允许消息丢失,但不允许重复处理,典型的做法是出现异常时丢失消息。2.1节我们提到Kafka生产端的参数,如果设置acks=0,就相当于生产端实现at most once。
  • **At least once:**消息被生产出来,至少被处理一次,这种模式下允许重复处理,但是不允许消息丢失,典型的做法是出现异常时重试。Kafka生产端如果设置acks=1或-1,消息传输异常会进行重试,相当于at least once。
  • **Exactly once:**这种模式下消息被一对一的处理,没有消息丢失,也没有重复处理。严格的exactly once是很难实现的,业务上通常采用幂等+at least once的方式,达到等同于exactly once的效果。例如kafka 0.11版本后,生产端的消息重试会被去重(幂等),近似保证了exactly once的语义。

Kafka的消费端同样通过参数配置,可以选择性的实现不同模式。例如配置消费端自动提交offset,那即使消息处理失败,也会提交offset,保证at most once的语义。At least once的语义实现会比较复杂,一方面需要配置手动提交offset,另一方面需要考虑批量情况下的提交。

6. Kafka高可用保证

分布式系统的高可用几乎都是通过冗余实现的,Kafka同样如此。Kafka的消息存储到partition中,每个partition在其他的broker中都存在多个副本。对外通过主partition提供读写服务,当主partition所在的broker故障时,通过HA机制,将其他Broker上的某个副本partition会重新选举成主partition,继续对外提供服务。

7. 总结

Kafka是一款非常优秀的消息中间件,最大的特点就是高吞吐量。Kafka内部提供了很多参数,用来实现不同程度的顺序性、实时性和消息语义。在工程中我们要根据自己业务的特点,在性能和可用性上做权衡。