对Kafka的探索

1.kafka名词解释

Broker:一个kafka节点就是一个broker,多个kafka节点可以组成一个kafka集群

Topic:每条kafka消息都有它的topic,kafka根据topic做消息的分类

Producer:消息的生产者,负责向broker发送消息的客户端

Consumer:消息的消费者,负责从broker拉取消息来进行消费(解析消息然后执行自己的逻辑)

Consumer Group:一个group里可以有一个或多个consumer,一种topic可以制定哪个group能消费这类消息,这条消息发出来之后,指定的group中只能有一个consumer去消费它

Partition:物理上的概念,一种topic的消息发出来之后会经过hash操作落到不同的partition中

offset:用来标记消息顺序的序号,每个partition之内会根据offset按顺序执行消息的操作

一个topic对应多个partition,partition分布在多broker上,多broker一起提供kafka服务

 

2.kafka的设计

持久化:从大学上操作系统课时,老师一直在强调的一个观点就是,cpu最快,内存其次,磁盘最慢,导致很多人印象中磁盘很慢很慢,再加上现在一些技术,像redis之类的是运行在内存中,在内存中做实时的数据增删改查,异步持久化到磁盘,更加印证了人们的观点。但是实际上,正确地去使用磁盘,速度将会比我们通常认为的要快的多:

 

从kafka官方文档中的这段话可以看出,在磁盘中的随机写确实很慢,但是如果是顺序写,其实是很快的,结合之前大学时候上的课也可以知道这是因为磁盘顺序写可以节省寻道时间,大大提升磁盘使用效率。

基于这些来考虑,kafka使用了文件来存储消息,这个文件的名字是Appender Log,同时,它没有使用我们在数据库索引中或是别的场景中使用的B/B+树之类的数据结构(时间复杂度O(logN)),而是直接使用队列,对日志文件进行读取并附加的操作,这样能使得操作的时间复杂度是O(1),而且由于是附加操作,也就是顺序写,从而省去了我上文提到的磁盘寻道的时间,大大提升时间效率。

这样设计之后,因为磁盘非常的廉价,所以如果有足够大的磁盘,我们可以近乎无限地去存储消息的记录,然后对消息存档做定期删除,kafka适合用来做大数据、日志系统这个就是原因之一。

 

消息整合发送:对磁盘效率上的考虑除了顺序写之外,还有多次IO的问题,kafka在这一点上的设计很好理解,一个形象的比喻就是,一次拉一车苹果去卖和一次拿一个苹果去卖,当然是前者比较合适,可以类比的像tcp分组发送,当然这种设计理念也会造成一些问题,比如正是tcp这样做,我们在使用NIO开发的时候才需要考虑半包和拆包的问题,kafka当然不用考虑半包和拆包(因为kafka就是基于tcp协议,而且从逻辑上考虑,粒度再小也不会小于一条消息的粒度,总不可能给别人发半条消息过去,这里只是和tcp分组做个类比),kafka需要考虑的是消息的即时性问题,也就是说,有可能会出现producer发了一条消息,这条消息需要等到一整个分组都发过去的时候才能发过去,但是其实这个分组发送是非常快的,而且消息队列本身就不是为了即时操作而生的,如果想要服务之间通信的即时性,还是使用RPC框架更合适。

这样设计之后,kafka不仅能够减少网络上的开销,同时还能一次性向文件中写入这一整个分组的消息,这样也是减少了磁盘上的开销。

 

使用sendfile优化字节复制:由于我们是将消息存储在文件当中,因此也需要考虑这个过程中是否有可以优化性能的点,传统的操作是这样的:

1、调用read函数,文件数据被copy到内核缓冲区
2、read函数返回,文件数据从内核缓冲区copy到用户缓冲区
3、write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区。
4、数据从socket缓冲区copy到相关协议引擎。

(最后一步在kafka的操作中是:操作系统将数据从套接字缓冲区复制到通过网络发送的NIC缓冲区)

这样是比较低效的,因为会有4个副本、2个系统调用、4次上下文切换:

1、系统调用 read() 产生一个上下文切换:从 user mode 切换到 kernel mode,然后 DMA 执行拷贝,把文件数据从硬盘读到一个 kernel buffer 里。
2、数据从 kernel buffer 拷贝到 user buffer,然后系统调用 read() 返回,这时又产生一个上下文切换:从kernel mode 切换到 user mode。
3、系统调用 write() 产生一个上下文切换:从 user mode 切换到 kernel mode,然后把步骤2读到 user buffer 的数据拷贝到 kernel buffer(数据第2次拷贝到 kernel buffer),不过这次是个不同的 kernel buffer,这个 buffer 和 socket 相关联。
4、系统调用 write() 返回,产生一个上下文切换:从 kernel mode 切换到 user mode(第4次切换了),然后 DMA 从 kernel buffer 拷贝数据到协议栈(第4次拷贝了)。

而通过sendfile函数,我们可以直接通过允许OS将数据从页面缓存直接发送到网络来避免这种重新复制。因此,在此优化路径中,仅需要最终复制到NIC缓冲区:

1、sendfile系统调用,文件数据被copy至内核缓冲区
2、再从内核缓冲区copy至内核中socket相关的缓冲区
3、最后再socket相关的缓冲区copy到协议引擎

 

压缩消息:

在某些情况下,瓶颈实际上不是CPU或磁盘,而是网络带宽。对于需要通过广域网在数据中心之间发送消息的数据管道而言,尤其如此。当然,用户总是可以一次压缩其消息,而无需Kafka的任何支持,但这可能导致非常糟糕的压缩率,因为大量冗余是由于相同类型消息之间的重复(例如, Web日志中的JSON或用户代理或通用字符串值)。高效压缩需要将多个消息压缩在一起,而不是分别压缩每个消息。

 

Kafka以有效的批处理格式支持此操作。一批消息可以压缩在一起,然后以这种形式发送到服务器。这批消息将以压缩形式写入,并保持压缩在日志中,并且仅由使用者解压缩。

 

Kafka支持GZIP,Snappy,LZ4和ZStandard压缩协议。

Pull和Push:在消息队列中比较避不开的一个问题就是pull还是push,也就是消息是被主动地推送给消费者,还是被消费者主动地去拉取,下面是Pull和Push的区别:

push方式

消息保存在服务端。容易造成消息堆积。
服务端需要维护每次传输状态,遇到问题需要重试
非常实时
服务端需要依据订阅者消费能力做流控(流转机制)


pull方式

保存在消费端。获取消息方便。
传输失败,不需要重试
默认的端短询方式的实时性依赖于pull间隔时间,间隔越大,实时性越低,长轮询方式和push一致
消费端可以根据自身消费能力决定是否pull(流转机制)
 

从中可以看到,push方式还是有不少的缺陷的,kafka也是推崇使用pull模式,只不过消息是被推到broker,然后消费者从broker拉取:

基于拉取的系统的另一个优点是,它有助于对发送给使用者的数据进行积极的批处理。基于推送的系统必须选择立即发送请求或累积更多数据,然后在不知道下游使用者是否能够立即处理请求的情况下稍后发送。如果针对低延迟进行了调整,这将导致每次仅发送一条消息,以使传输最终无论如何都被缓冲,这很浪费。基于拉取的设计可解决此问题,因为使用者始终将所有可用消息拉至其在日志中的当前位置之后(或达到某些可配置的最大大小)。这样一来,您可以在不引入不必要延迟的情况下获得最佳批处理。

不过pull模式也不是没有缺陷,当消费者通过轮询拉取消息的时候,如果没有消息被发送过来,就会造成拉取操作会一直“空转”,比较简单的解决方案就是在发现空转的时候及时制止拉取,直到有新的消息到达,也就是先把消费者拉取操作阻塞,kafka也是这么做的:

基于拉取的系统的不足之处在于,如果broker没有数据,则消费者可能会在紧密的循环中进行轮询,从而实际上忙于等待数据到达。为避免这种情况,我们在拉取请求中有一些参数,这些参数允许消费者请求阻塞在“长时间轮询”中,直到数据到达为止(并可选地等待直到给定数量的字节可用以确保较大的传输大小)。

kafka对消息的追踪:如果希望消息被消费者消费后不会被重复消费,那么有两种选择:1.在消息被从broker中拉取的时候,在broker中做下记录,或者干脆从broker中删除该条消息;2.在消息被消费者拉取消费了之后,让消费者对broker进行一步确认操作,broker收到确认之后才删除消息。

如果使用第一种,可能会出现的问题就是,消费者拉取了消息,broker就把消息删除了,但是消费者在消费消息的过程中出现了异常,没有消费成功,这时候如果再想执行一些类似重新消费的操作的时候会发现broker中的消息已经被删除了,也就是丢掉了这条消息;如果使用第二种,可能出现的情况就是,消费者消费成功了,但是在发回确认结果的时候出现了异常,导致broker没有收到消费者的确认,这时候消费者再去broker中拿消息的时候,会重复消费之前明明已经消费过的消息,想杜绝这种现象只能在broker中为消息加上“已发送”和“已消费”这样的状态,但是这样又会使逻辑变得复杂,同时影响了性能。

kafka并没有选择这样两种方式,它用了上文名词解释中提到的offset,如果把broker看成消息组成的数组,那么offset就相当于数组的索引下标,kafka在每个consumer中存储了下次要消费的消息的offset,当从broker中拉取并消息成功的时候,直接递增offset然后再用offset从broker中拉取对应的消息,如果消费失败了需要重试,那就不递增offset重新从broker中拉取上次的消息就可以了。同时,broker中不会删除消息(是一直不删除还是定期删除这点不太清楚,推测是可以进行配置,欢迎指正),这样可以根据offset随时取出之前的消息

 

3.kafka的高可用性

kafka消息语义:

  • 最多一次-消息可能会丢失,但永远不会重新发送。
  • 至少一次-消息永不丢失,但可以重新传递。
  • 恰好一次 -人们真正想要的是,每条消息只传递一次,也只有一次。

kafka的replica副本机制:

 

如图所示,kafka通过副本机制保证可用性,一个topic的消息会落到不同的partition中,每个partition对应多个broker,其中一个broker是leader,另外的broker是follower,follower会从leader中拉取消息到自己这里,来完成和leader的同步,消费者消费消息和生产者发送消息的时候,都是和leader打交道,一旦发生宕机问题,某个leader挂了,那么就会通过选举从follower中重新选出一个leader来,由于之前leader的消息已经同步到follower中,因此现在的follower是leader的replica(副本),所有的消息和之前leader中的一致。kafka判断节点“活着”的标准是:

  1. 节点必须能够与ZooKeeper保持会话(通过ZooKeeper的心跳机制)
  2. 如果是追随者,则必须复制在领导者上发生的写入,并且不要落后太远

kafka的选举机制:通常的分布式多选举机制像zab、raft、paxos之类的使用的多选票机制对于kafka来说并不完全适合(日后会整理一篇关于选举机制的详细文章),因为传统的机制下,容忍一个故障需要数据的三个副本,而容忍两个故障则需要数据的五个副本。kafka认为对于一个实际的系统而言,仅具有足够的冗余度来容忍单个故障是不够的,对于大容量数据问题而言,每次写入五次(磁盘空间需求为5倍,吞吐量为1/5)并不十分实际,所以kafka的选举有一些区别。

上面说到,follower会从leader拉取消息做同步,但是这个拉取是有滞后的,也就是不可能每时每刻每台follower都和leader的消息完全一样,kafka把这些滞后的follower叫做OSR,把相对来说同步比较及时的follower叫做ISR,把所有的follower统称为AR,也就是说:AR = ISR + OSR

在kafka选举过程中,只有ISR才有资格被选举为leader,如果ISR中的一个follower落后了太多,将会被leader从ISR中剔除;同时,在写入消息的时候,只有当ISR中所有follower都向leader发送了ack之后,才算是写入完成,leader才会告诉客户端消息写入完成了。

这里可能有个前后矛盾的问题,既然leader要等ISR中的follower都写入完成,那ISR中怎么还会有因为滞后太多被剔除的follower?实际上,因为之前提到的消息是被分组批量发送,所以leader一次性接受的实际上是多条消息,当leader接收了1000条消息,发现ISR中有一个follower只同步了500条,其他的follower都同步了999条的时候,会先把500条的follower剔除掉,然后再等待剩下的999条follower都同步完发送了ack之后,才向客户端返回成功结果。

常见的导致follower同步跟不上的原因主要是下面几个:

1、加入新的副本(每个新的副本加入都需要一段信息同步的追赶时期) 2、网络IO等原因,某些机器IO处理速度变慢所导致持续消费落后。 3、进程卡住(Kafka 是Java 写出来的,Java 进程容易出现Full GC过多问题,及高频次GC)

ISR是如何伸缩的:

Kafka在启动的时候会开启两个与ISR相关的定时任务,名称分别为“isr-expiration"和”isr-change-propagation".。isr-expiration任务会周期性的检测每个分区是否需要缩减其ISR集合。这个周期和“replica.lag.time.max.ms”参数有关。大小是这个参数一半。默认值为5000ms,当检测到ISR中有是失效的副本的时候,就会缩减ISR集合。如果某个分区的ISR集合发生变更, 则会将变更后的数据记录到ZooKerper对应/brokers/topics//partition//state节点中。节点中数据示例如下:

{“controller_cpoch":26,“leader”:0,“version”:1,“leader_epoch”:2,“isr”:{0,1}}

其中controller_epoch表示的是当前的kafka控制器epoch.leader表示当前分区的leader副本所在的broker的id编号,version表示版本号,(当前版本固定为1),leader_epoch表示当前分区的leader纪元,isr表示变更后的isr列表。

除此之外,当ISR集合发生变更的时候还会将变更后的记录缓存到isrChangeSet中,isr-change-propagation任务会周期性(固定值为2500ms)地检查isrChangeSet,如果发现isrChangeSet中有ISR 集合的变更记录,那么它会在Zookeeper的/isr_change_notification的路径下创建一个以isr_change开头的持久顺序节点(比如/isr_change_notification/isr_change_0000000000), 并将isrChangeSet中的信息保存到这个节点中。kafka控制器为/isr_change_notification添加了一个Watcher,当这个节点中有子节点发生变化的时候会触发Watcher动作,以此通知控制器更新相关的元数据信息并向它管理的broker节点发送更新元数据信息的请求。最后删除/isr_change_notification的路径下已经处理过的节点。频繁的触发Watcher会影响kafka控制器,zookeeper甚至其他的broker性能。为了避免这种情况,kafka添加了指定的条件,当检测到分区ISR集合发生变化的时候,还需要检查一下两个条件:

(1).上一次ISR集合发生变化距离现在已经超过5秒,

(2).上一次写入zookeeper的时候距离现在已经超过60秒。

满足以上两个条件之一者可以将ISR写入集合的变化的目标节点。

有缩减就会有补充,那么kafka何时扩充ISR的?

随着follower副本不断进行消息同步,follower副本要拉取的消息的offset也会逐渐后移,并且最终赶上leader副本,此时follower副本就有资格进入ISR集合,追赶上leader副本的判定准则是此副本的offset是否小于leader副本HW(HW是high watermark,下面会介绍这个参数的含义),这里并不是和leader副本LEO相比。ISR扩充之后同样会更新ZooKeeper中的/broker/topics//partition//state节点和isrChangeSet,之后的步骤就和ISR收缩的时候相同。

 

 

如果所有节点都宕机了怎么办:一种最极端的情况就是,无论leader还是follower都挂了,这时候kafka提供了两种恢复方案:1.等待ISR中任意一个follower恢复,选它当leader,这样会等待时间较长,而且如果ISR中的所有Replica都无法恢复或者数据丢失,则该Partition将永不可用,但是这样会让消息的一致性较高 2.选择第一个恢复的Replica为新的Leader,无论它是否在ISR中,这样如果选择了OSR中的副本,可能会造成比较大量的消息丢失,但是这样会让可用性比较高。默认情况下,kafka使用第一种策略,这个策略是可以通过配置更改的。

 

kafka的水位:

HW是High Watermark的缩写, 俗称高水位,它表示了一个特定消息的偏移量(offset),消费者之只能拉取到这个offset之前的消息;

LEO是Log End Offset的缩写,它表示了当前日志文件中下一条待写入消息的offset

 

当ISR集合发生增减时,或者ISR集合中任一副本LEO发生变化时,都会影响整个分区的HW。

 

4.使用Kafka时可能会出现的问题

消息顺序性:虽然kafka提供了offset的概念对消息做顺序性处理,但是在一些情况下依然会出现消费顺序乱掉的问题,例如:想让mysql服务对一条商品订单数据做增、改、删三步操作,于是给对应的服务发送了消息,发过去的消息顺序是增改删顺序,如果没有使用唯一id例如这个商品的id作为hash依据的话,三条消息可能会落到不同的partition中,造成取出的时候消息顺序错乱,如果使用唯一id作为hash凭证,到同一个partition中offset的顺序也是增改删的顺序没问题,但是取出的时候如果消费者使用多线程去处理消息执行逻辑,顺序就不可控了,也许会出现删、增、改这样的消费顺序,这样本来删掉的数据被保留,由于没有报错还不容易查出这样的bug。

解决方法有两种,一种是用唯一id,一个partition,一个线程去消费,但是这样相当于串行化了,少量消息操作不太耗时还可以,消息多了之后一定会出现消息挤压;另一种方法是,使用多个内存queue,从partition中取出消息的时候,将有顺序的三条消息根据id散列到同一个内存queue里,每个处理线程对应一个内存queue进行消息处理逻辑操作,如图:

 

消息积压:如果消费端发生了异常,导致消息无法消费,一直积压在mq中怎么办?一种方法是把消费端恢复正常,然后慢慢等它消费完成,但是如果消费的速率比增长的速率要慢,而且如果算算时间,在业务高峰期之前无法消费完积压的消息或者干脆问题就是发生在业务高峰期的时候,那就要考虑 第二种办法,就是让消息加速消费,首先要修复消费端的问题,然后新建一个topic,topic的partition是原来的十倍,然后建立十倍于原来的内存queue,写一个临时分发消息的consumer程序,程序什么逻辑操作都不做,只是单纯地消费积压的消息然后把消息均匀灌到十倍queue中,然后再临时征用十倍于原来的consumer机器,每批consumer用和正常consumer一样的逻辑去消费对应queue中的消息,这样可以做到以十倍的速度消费积压的消息,等到消费全部完成之后,恢复原有架构即可。(不清楚有没有更好的办法)

 

消息重复消费:尽管kafka在设计上尽力为我们保证不会重复消费消息,但是在一些极端情况下还是有可能出现这种情况,例如直接终止程序运行,有可能会导致消费完了消息之后offset还没有来的及更新,当重新启动程序的时候,就会出现重复消费之前消费过的消息的情况,这种情况比较简单的解决方式就是保证消息的幂等性就好了,而且是从业务的层面上去保证,例如,消费消息做数据库插入操作,如果再插入之前判一下重,即使重复消费了,也没有关系。

 

Topic多了kafka性能会显著下降:因为Kafka的每个Topic、每个分区都会对应一个物理文件。当Topic数量增加时,消息分散的落盘策略会导致磁盘IO竞争激烈成为瓶颈。而一些别的mq例如RocketMQ所有的消息是保存在同一个物理文件中的,Topic和分区数对RocketMQ也只是逻辑概念上的划分,所以Topic数的增加对RocketMQ的性能不会造成太大的影响。

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章