当前位置: 首页 > 后端技术 > Java

Kafka扫盲——不要让面试官问三个问题

时间:2023-04-01 22:13:43 Java

工作中经常用到Kafka,但是对Kafka的一些内部机制不是很熟悉,所以最近在看Kafka相关的知识,大家知道Kafka是非常经典的新闻引擎,以高性能和高可用着称。那么问题来了,它是如何实现高性能和高可用的呢?它的消息以什么形式保存?既然都写好了,为什么速度还是这么快?它如何保证消息不丢失......?带着这一系列的问题,我们来揭开kafka的面纱。首先我们来思考这样一个问题:为什么我们需要消息引擎?为什么我不能直接进入rpc?以订单系统为例:我们下单时,首先要减少产品库存,然后是用户支付扣款,商户账户加钱……,最后可能会发送推送或短信告诉客户用户下单成功,告诉商家订单来了。如果整个点餐流程都同步阻塞,耗时会增加,用户等待的时间会变长,体验也不会好。同时,下单过程依赖的环节越长,风险就越大。为了加快响应速度,降低风险,我们可以将一些不一定卡在主链路的服务进行拆解,与主服务解耦。下单最关键的核心是保证库存、用户支付、商户支付的一致性,消息的通知可以完全异步。这样整个下单过程就不会因为通知商家或者通知用户屏蔽而被屏蔽,也不会因为他们的失败而提示下单失败。下一步是如何设计消息引擎。从宏观上看,消息引擎支持发送、存储和接收。然后出现一个简单的消息队列模型如上图所示。引擎存储来自发送者的消息,这样当接收者来找引擎请求数据时,引擎将存储中的数据响应给接收者就可以了。由于涉及持久存储,磁盘IO慢是一个问题。也可以有不止一个接收器。以上面的订单为例,下单后,通过消息发送完成事件。这时候负责用户端推送的开发需要消费这条消息,负责商户端推送的开发也需要消费这条消息,我能想到的最简单的方法就是复制两组消息,但这看起来有点浪费吗?高可用也是一个需要考虑的点,那么我们的引擎有没有副本呢?有了副本后,如果某个引擎节点挂了,我们可以选出一个新的副本来工作。仅有一份副本是不够的,而且可能有多个发件人。这个时候如果所有的sender都往一个Leader(primary)节点发送数据就显得不合理了,对单个节点的压力太大了。也许你会说:不是有文案吗?让接收者直接从副本读取消息。这样的话,另一个问题就产生了:副本复制Leader的消息延迟了怎么办?看不到留言再看Leader?如果真是这样的话,发动机的设计就显得更加复杂了,显得有些不合理。那我们就得想个办法,不用经过副本,就可以分散单个节点的压力。答案是分片技术。由于单个Leader节点压力太大,可以拆分成多个Leader节点。我们只需要一个好的负载均衡算法,通过负载均衡把消息均匀的分发到各个分片节点就可以了,所以我们可以设计一套生产者-消费者模型,长这样。但是这些只是简单的想法,具体如何实现还是很复杂的。带着这一系列的问题和思路,我们来看看Kafka是如何实现的。思考与实现首先我们从Kafka的几个名词入手,主要介绍消息、主题、分区和消费组。如何设计消息消息是服务的源头,所有的设计都是将消息从一端发送到另一端,这涉及到消息的结构,消息体不宜太大,太大容易导致为了增加存储成本,网络传输的开销变大,所以消息体只需要包含必要的信息,最好不要冗余。消息最好支持压缩。压缩可以在消息体本身紧凑的情况下使消息体更小,从而进一步减少存储和网络开销。消息需要持久。消费过的消息不能永久保存,或者非常旧的消息不太可能再次消费。需要一种机制来清理旧消息并释放磁盘空间。如何找出旧消息消息是关键,所以每条消息最好在消息产生时携带一个时间戳,通过时间戳计算出旧消息,在合适的时候删除。消息也需要编号。一方面,号码代表了消息所在的位置,另一方面,消费者可以通过号码找到对应的消息。如何存储大量消息也是一个问题。所有这些都存储在一个文件中。查询效率低,不利于清理旧数据。因此,段用于将大的日志文件切割成多个相对较小的日志文件。提高可维护性,这样插入消息时,只需要追加到段末尾即可,但是查找消息时,如果将整个段一个一个地加载到内存中,似乎需要很多ofmemoryoverhead,所以有一套索引机制,通过索引来加速访问对应的Message。总结:Kafka消息包含创建时间,消息序号,支持消息压缩。存储消息的日志被分段和索引。为什么需要Topic来从宏观上看消息引擎呢?有一个问题:生产者A要给消费者B发消息,同时要给消费者C发消息。那么消费者B和C如何只消费自己需要的数据呢?可以想到的简单的方法就是给消息加上一个Tag。消费者可以根据Tag获取自己的消息,跳过不属于自己的消息。然而,这似乎并不优雅,CPU资源被浪费在消息过滤上。所以最有效的办法就是不把给B的消息给C,给C的消息也不给B,这就是Topic。主题用于区分不同的业务。每个消费者只需要订阅自己关心的话题即可。生产者通过约定的主题发送消费者需要的消息。简单的理解就是消息按照主题进行分类。总结:主题是一个逻辑概念。主题可以用于业务划分。每个消费者只需要关注自己的话题。如何保证分区的顺序通过上面我们知道分区的目的是为了分散单个节点的压力。结合Topic和Message,那么消息的大致层次结构就是Topic(主题)->Partition(分区)->Message(消息)。你可能会问,既然分区是为了减轻单个节点的压力,为什么不使用多个主题而不是多个分区。在多机器节点的情况下,我们可以在多个节点上部署多个topic,貌似也可以分布式的。简单想想似乎可行,但仔细想想还是不对。归根结底还是要为业务服务。在这种情况下,一个topic的原有业务不得不拆解成多个topic,反而打散了业务的定义。那么,既然有多个partition,那么消息的分发就成了一个问题。如果topic下的数据过于集中在某个partition上,会造成分布不均。要解决这个问题,一个好的分配算法是非常有用的。必要的。Kafka支持轮询的方式,即在多个分区的情况下,可以通过轮询的方式将消息平均分配到各个分区。这里需要注意的是,每个分区中的数据是有序的,但不能保证整体数据的顺序。如果您的业务强烈依赖于消息的顺序,那么您必须仔细考虑此解决方案。比如生产者依次发送A、B、C三个消息,分布在三个分区中,那么就有可能的消费顺序是B、A、C。那么如何保证消息的顺序呢?从整体上看,只要分区数大于1,消息的顺序就永远无法保证,除非你把分区数设置为1,但那样的话吞吐量就是个问题。从实际业务场景来看,一般我们可能需要某个用户的消息或者某个产品的消息是有序的。用户A和用户B的消息谁先来并不重要,因为他们之间没有关系。但是,我们可能需要保持用户A的消息顺序。比如消息描述了用户的行为,行为的顺序不能乱。这时候我们可以考虑使用keyhash的方法。同一个用户id总是可以通过hash分配给一个partition。我们知道分区内部是有序的,那么在这种情况下,同一个用户的消息一定是有序的,不同的用户可以分配到不同的分区,这也是利用了多分区的特点。总结:Kafka的整体消息不能保证有序,但是可以保证单个partition的消息有序。如何设计合理的消费模型既然是消息模型,消费者是必不可少的。消费者最简单的实现方式就是启动一个进程或者线程,直接去broker那里拉取消息。这是合理的,但如果生产率大于当前消费率怎么办?首先想到的是再创建一个consumer,使用多个consumer来提高消费速度。这里似乎还有一个问题。如果两个消费者消费同一条消息怎么办?加锁是一种解决方法,但是效率会降低。你可能会说,消费的本质是阅读,阅读是可以分享的。只要业务是幂等的,重复消费消息无所谓。在这种情况下,如果10个消费者都竞争同一个消息,那么9个消费者就白白浪费了资源。因此,在需要多个消费者来提高消费能力的同时,也需要保证每个消费者消费的都是未处理的消息。这就是消费者群体。消费者组下可以有多个消费者。我们知道topic是Partitioned的,所以只要consumergroup中的每个consumer订阅不同的partition即可。理想情况下,每个消费者都被分配到同一个数据卷分区。如果一个消费者得到的partition数量不均匀(或多或少),出现数据倾斜,部分消费者会很Busy或很放松,这是不合理的,这就需要一个均衡的分布策略。Kafka消费者分区分配策略主要分为三种:范围:该策略是针对主题的,将主题分区数除以消费者数。如果有余数,说明冗余分区分布不均,这时候前排的消费者会多得到1个partition,乍一看其实挺合理的,毕竟数量不均衡。但是如果消费者订阅了多个topic,每个topic平均多了几个partition,那么前面的consumer就会多消费很多partition。因为是按照topic维度来划分的,最后:c1消费Topic0-p0,Topic0-p1,Topic1-p0,Topic1-p1c2消费Topic0-p2,Topic1-p2,最后可以发现消费者c1是两个full多于consumerc2的partition,完全可以把c1的partition分给c2,这样就可以平衡了。RoundRobin:该策略的原理是将消费者组中所有消费者的分区和消费者订阅的所有主题按字典顺序排序,然后通过轮询算法将分区一一分配给每个消费者。假设有两个主题,每个主题有3个分区,有3个消费者。那么一般的消费情况是这样的:c0消费Topic0-p0,Topic1-p0c1消费Topic0-p1,Topic1-p1c2消费Topic0-p2,Topic1-p2看起来很完美,但是如果现在有3个topic,每个topic分区数不一致。比如topic0只有一个partition,topic1有两个partition,topic2有三个partition,消费者c0订阅topic0,consumerc1订阅topic0和topic1,consumerc2订阅topic0,topic1,topic2,那么一般的消费情况是这样的:c0消费Topic0-p0c1消费Topic1-p0c2消费Topic1-p1,Topic2-p0,Topic2-p1,Topic2-p2这样看来RoundRobin并不是最完美的,不管每个topic吞吐量的差异的分区可以看出c2的消费负担明显很大,可以将Topic1-p1分区分配给消费者c1。Sticky:Range和RoundRobin都有各自的缺点。在某些情况下,它们可以更平衡,但事实并非如此。引入Sticky的目的之一就是尽可能均匀地分布分区。上面RoundRobin的三个topic分别对应partition1,2,3的情况下,因为c1可以完全消费Topic1-p1,但是它没有。针对这种情况,在Sticky模式下,可以将Topic1-p1赋值给c1。引入Sticky的第二个目的是:分区的分配尽量和上次分配保持一致。这里主要解决的是rebalance后分区重分配的问题。假设有三个消费者c0、c1和c2。它们都订阅了topic0、topic1、topic2、topic3,每个topic有两个partition。这个时候,consumption的情况大致是这样的:这种分配方式目前和RoundRobin没什么区别,但是如果此时consumerc1退出,consumergroup中就只剩下c0和c2了。那么需要将c1的分区重新分配给c0和c2。我们先看看RoundRobin是如何重平衡的:可以发现c0原来的topic1-p1赋给了c2,c2原来的topic1-p0赋给了c0。这种情况可能会造成重复消费的问题。消费者还没来得及提交,发现分区已经分配给了新的消费者,新的消费者会产生重复消费。但是从理论上讲,c1退出后,c0和c2的分区就没必要动了。只需要将c1的原分区划分为c0和c2即可。这就是sticky的作用:sticky策略中,如果分区分配尽可能均匀和分配分区尽可能与上次分配相同有冲突,那么先执行第一个。总结:Kafka默认支持以上三种分区分配策略,也支持自定义分区分配。自定义方法需要自己实现。从效果上看,RoundRobin优于Range,Sticky优于RoundRobin。建议您使用版本支持最佳策略。