系统间的耦合高怎么办,我们如何不让一个服务过于庞大,一个好的方式就是依据具体的功能模块拆分服务,降低服务的耦合度,服务间的交互可以通过消息传递数据来实现,除此之外Kafka非常适合在线日志收集等高吞吐场景,kafka有更好的吞吐量,内置分区,副本和故障转移,这有利于处理大规模的消息,所以kafka被各大公司广泛运用于消息队列的构建:
适合人群:不了解Kafka的新手,对Kafka的实现机制感兴趣的技术人员
本文的全部内容来自我个人在Kafka学习过程中整理的博客,是该博客专栏的精华部分。在书写过程中过滤了流程性的上下文,例如部署环境、配置文件等,而致力于向读者讲述其中的核心部分,如果读者有意对过程性内容深入探究,可以移步MaoLinTian的Blog,在这篇索引目录里找到答案分布式技术相关专栏索引
在正式介绍Kafka之前,先来了解下什么是生产者消费者模式,以及什么是消息队列,再由消息队列聊到Kafka。
生产者消费者模型具体来讲,就是在一个系统中,存在生产者和消费者两种角色,他们通过内存缓冲区进行通信,生产者生产消费者需要的资料,消费者把资料做成产品。可以理解为生产者不停的生产馒头,然后把馒头扔到筐里,消费者不停的从筐里拿出馒头吃。具体到消息系统可以这么理解:
其中最重要的环节就是这个管道容器,其实就是一个消息队列
消息队列具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能,成为异步RPC的主要手段之一。那么为什么要使用队列呢?我这么理解:
第一个场景下,例如有个业务:下班打车,企业滴滴,晚上8点半上地这边爆满,各种打车订单同时涌入系统,系统要爆,加上消息队列后,订单进入消息队列,然后反馈给用户订单在处理,然后按照入队顺序一个个处理该消息,直到消费到该消息订单后才将订单入库,当然这个时候才能反馈给用户说打车成功。这样有效的抵抗了峰值时间的冲击。
第二个场景下,系统的不同模块之间各自有各自的实现。模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,所以如果有个消息系统在中间作为中转,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。
消息队列目前有两种协议:JMS和AMQP,JMS轻量不能跨平台,AMQP性能强但是复杂,感觉就是美图秀秀和PS的区别,但是大家用的多的还是美图秀秀,那么就以JMS为主吧,咱就好好用轮子吧。
1 点到点(P2P)模型
使用队列(Queue)作为消息通信载体:满足生产者与消费者模式,一条消息只能被一个消费者使用,未被消费的消息在队列中保留直到被消费或超时。
假如我们存在这样⼀种情况:我们需要将⽣产者产⽣的消息分发给多个消费者,并且每个消费者都能接收到完全的消息内容。这种情况,队列模型就不好解决了
2 发布/订阅(Pub/Sub)模型
发布订阅模型(Pub/Sub) 使用主题(Topic)作为消息通信载体,类似于广播模式,发布者发布一条消息,该消息通过主题传递给所有的订阅者,一条消息可以被多个消费者使用。
在一条消息广播之后才订阅的用户则是收不到该条消息的。在发布 - 订阅模型中,如果只有⼀个订阅者,那它和队列模型就基本是⼀样的了。所以说,发布 - 订阅模型在功能层⾯上是可以兼容点到点(P2P)模型的
系统里平白无故加入一个消息队列服务器当然会影响系统的整体性能,增加系统的复杂性,还需要担心异步消息发送的一致性问,消息服务器的稳定性等问题:
其实这些问题就是作为一个消息队列中间件所要面对的挑战,这个消息中间件该如何设计才能解决消息框架可能遇到的一系列问题:故障转移恢复、数据一致性保证、数据可靠性保证
了解了生产者-消费者模型以及消息队列模型后,我们知道如果系统需要解耦、削峰,一定要使用消息队列,但是消息队列存在诸多问题,所以需要一个功能完备的中间件来解决这些问题。
Kafka 是⼀个分布式流式处理平台,与大多数消息系统比较,kafka有更好的吞吐量,内置分区,副本和故障转移。kafka流平台具有三个关键功能:
所以Kafka 主要有两⼤应⽤场景:首先基于传递和存储消息的有效和可靠性,kafka可以建⽴实时流数据管道,以可靠地在系统或应⽤程序之间获取数据。其次基于处理消息数据流的完备能力,kafka可以构建实时的流数据处理程序来转换或处理数据流。
看了那么多的Kafka的优点,那么相信各位对Kafka的实现架构有了一些好奇,下图为一个Kafka的基本架构图,接下来我会对其中的概念分点详述并举例说明
1 Producer(生产者):负责发布消息到Kafka broker,就是向kafka broker发消息的客户端。
2 主题和分区:一个主题包含一个或多个Partition
3 Kafka集群和Kafka服务器:一个Kafka集群包含多个Kafka服务器
4 Consumer(消费者)和 Consumer Group(消费者集群):一个消费者集群包含多个消费者
综合而言我理解就是,Kafka集群和Kafka服务器属于物理机器上的概念,而主题和分区属于发出去的消息的分类,一个纵向,一个横向,一个broker上可以有很多主题的分区,一个主题也可以在很多broker上放置分区,是多对多的关系。由于这部分比较难理解,这里我举个例子:
我在有三台机器【broker的id分别为101、102、103】的Kafka集群上创建了一个主题为tml-kafka的集群,指定副本数为3,查看详情时第一个行显示所有partitions的一个总结,以下每一行给出一个partition中的信息,如果我们只有一个partition,则只显示一行。
了解了Kafka的基本架构和示例后我们来了解下Kafka到底是怎么工作的,以及消息是如何在Kafka持久化存储的。
通过基础概念的学习可以知道kafka的消息分为Topic,而Topic从逻辑上又可以划分为Partion,关于Topic&Partion需要注意以下几点:
下图为一个消息写入过程,Producer向同一主题的不同分区写消息,也即不停的在各个append log文件后顺序追加消息,每追加一个append log文件偏移量加一,只有单append log文件中有序
整体消息的生产传递和消费的的流程如下图所示,注意这里偏移量数字从consumer消费的视角来看,无论是生产者还是消费者对消息的处理都是偏移量从小到大的:
消息由生产者生产,并发往Kafka集群,Kafka集群获取到消息后再发往消费者,以下是Pub-Sub消息的逐步工作流程 :
以上流程将重复,直到消费者停止请求。消费者可以随时回退/跳到所需的主题偏移量,并阅读所有后续消息
我们上文提到每个partion是一个append log 文件,虽然我们已经把Topic物理上划分为多个Partion用来负载均衡,但即使是对一个Partition 而言,如果消息量过大的话也会有堵塞的风险,所以我们需要定期清理消息。
清理消息时如果只有一个Partion,那么就得全盘清除,这将对消息文件的维护以及已消费的消息的清理带来严重的影响。所以我们需要在物理上进一步细分Partition
这种特性也方便 old segment 的删除,即方便已被消费的消息的清理,提高磁盘的利用率,每个 partition 只需要支持顺序读写就行。
接下来我们发送一条消息,看看在物理存储上是什么样的,在机器上我们可以看到,一组index和log,这就是一个segment的内容:
segment 文件由两部分组成,分别为 “.index” 文件和 “.log” 文件,分别表示为 segment 索引文件和数据文件。这两个文件的命令规则为:partition 全局的第一个 segment 从 0 开始,后续每个 segment 文件名为上一个 segment 文件最后一条消息的 offset 值,数值大小为 64 位,20 位数字字符长度,没有数字用 0 填充,我这里只有一条数据,所以是从0开始的,整体的存储架构如下:
通过以上对Segment落盘文件的了解,我们基本搞清楚了Segment的结构,当然我这里是单segment,看不出来。这里从网上找了一个大量文件的示例:
//第一段segment,起始位置为0
00000000000000000000.index
00000000000000000000.log
//第二段segment,起始位置为170410
00000000000000170410.index
00000000000000170410.log
//第三段segment,起始位置为239430
00000000000000239430.index
00000000000000239430.log
以上面的 segment 文件为例,展示出 segment:00000000000000170410 的 “.index” 文件和 “.log” 文件的对应的关系,如下图:
如上图,“.index” 索引文件存储大量的元数据,“.log” 数据文件存储大量的消息,索引文件中的元数据指向对应数据文件中 message 的物理偏移地址。其中以 “.index” 索引文件中的元数据 [3, 348] 为例,在 “.log” 数据文件表示第 3 个消息,即在全局 partition 中表示 170410+3=170413 个消息,该消息的segementoffset为3,全局offset为170413,物理偏移地址为 348(注意此物理偏移地址不是消息数量的offset,而是消息的内存存储偏移量 )
既然消息在Partion中被分为了一段段的segment,那么我们如何快速定位消息的位置,来精准的对消息进行操作呢?以上图为例,读取 offset=170418 的消息:
以上就是定位消息的详细方法,通过索引的方式,可以在kafka顺序写磁盘的基础上仍然能快速的找到对应的消息。
前面提到消息系统的三大问题: 系统可用性降低,系统复杂性提高,数据一致性问题,为了解决这些问题,Kafka支持一些机制,例如:
接下来就这三个机制以及其附加的策略来进行详细的讨论。
Kafka 每个 topic 的 partition 有 N 个副本(replicas),其中 N(大于等于 1)是 topic 的复制因子(replica fator)的个数。Kafka 通过多副本机制实现故障自动转移,当 Kafka 集群中出现 broker 失效时,副本机制可保证服务可用。对于任何一个 partition,它的 N 个 replicas 中,其中一个 replica 为 leader,其他都为 follower,leader 负责处理 partition 的所有读写请求,follower 则负责被动地去复制 leader 上的数据。
为什么要分区呢?对于分布式系统的三高我们已经很熟悉了,我们再来强调一下:
如上图所示展示的,我们分布式集群的特性才能体现出来,其实不光是Kafka,所有的分布式中间件,都需要满足以上的特性。
producer 采用 push 模式将消息发布到 broker,每条消息都被 append 到 patition 中,属于顺序写磁盘(顺序写磁盘效率比随机写内存要高,保障 kafka 吞吐率)。producer 发送消息到 broker 时,既然分区了,我们怎么知道生产者的消息该发往哪个分区呢?producer 会根据分区算法选择将其存储到哪一个 partition。
从代码结构里我们可以看到实际上可以归纳为三种方法,也就是三种路由机制,决定消息被发往哪个分区,分别是:
可以说分区策略对于Kafka来说是三高机制的基础,有了分区才能实现Kafka的高可扩展,在这样的构建模型之上我们来看看基于分区机制,Kafka如何实现数据可靠性【高并发】和故障转移【高可用】。
为保证 producer 发送的数据,能可靠的发送到指定的 topic,topic 的每个 partition 收到producer 发送的数据后,都需要向 producer 发送 ack(acknowledgement 确认收到),如果producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。
那么基于此,因为在Kafka集群中一个分区会存在多个副本,leader负责读写,但同时需要把消息可靠的同步,防止消息丢失和遗漏或者重复。基于性能考虑,同时存在多种ACK应答机制。
Kafka 为用户提供了三种可靠性级别,用户根据对可靠性和延迟的要求进行权衡,当 producer 向 leader 发送数据时,可以通过 request.required.acks 参数来设置数据可靠性的级别:
request.required.acks = 0,producer 不停向leader发送数据,而不需要 leader 反馈成功消息,这种情况下数据传输效率最高,但是数据可靠性确是最低的。可能在发送过程中丢失数据,可能在 leader 宕机时丢失数据。【传输效率最高,可靠性最低】
request.required.acks = 1,这是默认情况,即:producer 发送数据到 leader,leader 写本地日志成功,返回客户端成功;此时 ISR 中的其它副本还没有来得及拉取该消息,如果此时 leader 宕机了,那么此次发送的消息就会丢失。【传输效率中,可靠性中】
request.required.acks = -1(all),producer 发送数据给 leader,leader 收到数据后要等到 ISR 列表中的所有副本都同步数据完成后(强一致性),才向生产者返回成功消息,如果一直收不到成功消息,则认为发送数据失败会自动重发数据。这是可靠性最高的方案,当然,性能也会受到一定影响。【传输效率低,可靠性高】,同时如果在 follower 同步完成后,broker 发送 ack 之前,leader 发生故障,那么会造成数据重复
当 request.required.acks = -1时需要注意,如果要提高数据的可靠性,在设置 request.required.acks=-1 的同时,还需参数 min.insync.replicas
配合,如此才能发挥最大的功效。min.insync.replicas 这个参数用于设定 ISR 中的最小副本数,默认值为1,当且仅当 request.required.acks 参数设置为-1时,此参数才生效。当 ISR 中的副本数少于 min.insync.replicas 配置的数量时,客户端会返回异常:org.apache.kafka.common.errors.NotEnoughReplicasExceptoin: Messages are rejected since there are fewer in-sync replicas than required
。通过将参数 min.insync.replicas 设置为 2,当 ISR 中实际副本数为 1 时(只有leader),将无法保证可靠性,因为如果发送ack后leader宕机,那么此时该条消息就会被丢失,所以应该拒绝客户端的写请求以防止消息丢失。在-1策略下有三个问题单独讨论一下:
我们知道当 request.required.acks=-1时需要ISR中的全部副本都同步完成,才返回ACK,但是其实在最终确定这个方案之前还有一些别的方案,讨论核心是那么到底多少foller副本同步完成,才发送ack呢?
现有的两种方案我们选择了第二种,第一种占用的机器资源过多,造成了大量的数据冗余,而网络延迟对于Kafka的影响并不大。
采用全量副本同步方案后,我们发送ack的时机确定如下:leader 收到数据,所有 follower 都开始同步数据,但是设想如下情况:有一个 follower,因为某种故障,迟迟不能与 leader 进行同步,那 leader 就要一直等下去,直到它完成同步,才能发送 ack。这个问题怎么解决呢?我们引入ISR的概念
在这种机制下,ISR始终是动态保持稳定的集群,消息来了之后,leader先读取,然后推送到各个follwer里,保证ISR中各个副本处于同步状态,只要所有ISR中的follwer都同步完成即可发布ACK,leader挂掉后,立即能从ISR中选举新的leader来处理消息。
对于一些非常重要的信息,消费者要求数据既不重复也不丢失,即 Exactly Once 语义。其实以上讨论的三种策略可以如此归类语义:
顾名思义,我们一定是需要在不丢数据的基础上去去重,在 0.11 版本以前的 Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。
0.11 版本的 Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指 Producer 不论向 Server 发送多少次重复数据,Server 端都只会持久化一条。幂等性结合 At Least Once 语义,就构成了 Kafka 的 Exactly Once 语义。即:At Least Once + 幂等性 = Exactly Once
要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true 即可。Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的 Producer 在初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带 Sequence Number。而Broker 端会对
为了实现跨分区跨会话的事务以及防止PID重启造成的数据重复,需要引入一个Topic全局唯一的 Transaction ID,并将 Producer获得的PID和Transaction ID绑定。这样当Producer重启后就可以通过正在进行的TransactionID 获得原来的 PID。为了管理 Transaction,Kafka 引入了一个新的组件 Transaction Coordinator。Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态TransactionCoordinator 还负责将事务所有写入 Kafka 的一个内部 Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。
在数据可靠性保障策略中我们了解到如何通过分区和副本,以及动态的ISR和ACK机制来确保消息的可靠,那么接下来深入探讨下,故障发生的时候,我们如何将集群恢复正常?首先需要明确两个概念:LEO和HW:
HW和LEO在消息流转的层面上过程如下。
在了解故障转移机制前,我们先来看看标准的写入流程是什么样的,这样在故障的时候我们可以看到故障发生在哪些节点影响标准写入流程,以及故障转移机制如何处理使其恢复正常:
整个过程分为如下几个步骤
实质上,Leader的HW是所有LEO最短的offset,并且是消费者需要认定的offset,Follower的HW则是Leader的HW和自身LEO取最小值,也就是长度不能超过消费者认定的offsetKafka 的复制机制既不是完全的同步复制,也不是单纯的异步复制。
而 Kafka 使用request.required.acks = -1 + ISR
的策略则在可靠性和吞吐率方面取得了较好的平衡,同步复制并干掉复制慢的副本,只同步ISR中的Follwer,
当不同的机器宕机故障时来看看ISR如何处理集群以及消息,分为 follower 故障和leader故障:
总而言之,要以所有副本都同步好的最新的HW为准(这样可以保证follower的消息永远是小于等于leader的)。但这只是处理方法,并不能保证数据不重复或者不丢失,我们来看一种数据重复的案例: Leader宕机:考虑这样一种场景:acks=-1,部分 ISR 副本完成同步,此时leader挂掉,如下图所示:
整个过程分为如下几个步骤
这样就出现了数据重复的现象,所以HW&LEO机制只能保证副本之间保持同步,并不能保证数据不重复或不丢失,要想都保证,需要结合ACK机制食用
在可能发生的故障中,当Leader挂了的时候我们需要选举新的leader,遵循如下策略:Kafka 在 ZooKeeper 中为每一个 partition 动态的维护了一个 ISR,这个 ISR 里的所有 replica 都与 leader 保持同步,只有 ISR 里的成员才能有被选为 leader 的可能。
当然也有 极端情况:当 ISR 中至少有一个 follower 时(ISR 包括 leader),Kafka 可以确保已经 commit 的消息不丢失,但如果某一个 partition 的所有 replica 都挂了,自然就无法保证数据不丢失了。这种情况下如何进行 leader 选举呢?通常有两种方案:
默认情况下,Kafka 采用第二种策略,即 unclean.leader.election.enable=true,也可以将此参数设置为 false 来启用第一种策略
Kafka是如何保证高效读写数据的呢,有三点支持:分布式读写、顺序写磁盘以及零拷贝技术,其实前两点在之前的blog中也有提到
通过以上这几种技术可以实现Kafka的高并发读写
聊完了生产者策略,知道了消息是如何发送到Kafka集群并且保证不重不漏,以及在故障时如何保证多个副本的数据一致性之后,我们再从消费端来看下,消费者是如何消费消息的。
消息有两种方式被投递,一种是broker推给消费者,一种是消费者从broker拉。这两种方式各自有优缺点:
Kafka 采取的是pull 模式,它可简化 broker 的设计,consumer 可自主控制消费消息的速率,同时 consumer 可以自己控制消费:
通过pull以及一定的策略可以满足Kafka的消费诉求。需要注意:
这三点需要注意,消息的消费和分区个数的关系。
一个 consumer group 中有多个 consumer,一个 topic 有多个 partition,所以必然会涉及到 partition 的分配问题,即确定那个 partition 由哪个 consumer 来消费。Kafka 有三种分配策略: RoundRobin, Range,Sticky。。
无论是哪种策略,当消费者组里的消费者个数的变化【增多或减少】或者订阅主题分区的增加都会触发重新分配,这种将分区的所有权从一个消费者移到另一个消费者称为重新平衡(rebalance)**
Range分配策略是面向每个主题的,首先会对同一个topic里面的分区按照序号进行排序,并把消费者线程按照字母顺序进行排序。然后用分区数除以消费者线程数量来判断每个消费者线程消费几个分区。如果除不尽,那么前面几个消费者线程将会多消费一个分区。当然,这样的缺点就是对每个组内的每个消费者分布不均匀。举例如下:
这样ConsumerA承受的压力会越来越大。局部打散【计算后顺序整体分,不是轮询分】,每个topic的局部压力都会压向消费者组中的某个消费者
RoudRobin策略也即轮询策略,RoundRobin策略的原理是将消费组内所有消费者以及消费者所订阅的所有topic的partition按照字典序排序,然后通过轮询算法逐个将分区以此分配给每个消费者:
这样的好处是,分配较为均衡。
当然前提是同一个消费者组里的每个消费者订阅的主题必须相同,当然也一定是相同的,如果不同也就没必要放到一个消费组里了。
这样的分区策略是从0.11版本才开始引入的,它主要有两个目的
举例进行分析:比如有3个消费者(C0,C1,C2),都订阅了2个主题(T0 和 T1)并且每个主题都有 3 个分区(p0、p1、p2),那么所订阅的所有分区可以标识为T0p0、T0p1、T0p2、T1p0、T1p1、T1p2。此时使用Sticky分配策略后,得到的分区分配结果和RoudRobin相同:
但如果这里假设C2故障退出了消费者组,然后需要对分区进行再平衡操作,如果使用的是RoundRobin分配策略,它会按照消费者C0和C1进行重新轮询分配,再平衡后的结果如下:
但是如果使用的是Sticky分配策略,再平衡后的结果会是这样:
虽然触发了再分配,但是记忆了上一次C0和C1的分配结果。这样的好处是发生分区重分配后,对于同一个分区而言有可能之前的消费者和新指派的消费者不是同一个,对于之前消费者进行到一半的处理还要在新指派的消费者中再次处理一遍,这时就会浪费系统资源。而使用Sticky策略就可以让分配策略具备一定的“粘性”,尽可能地让前后两次分配相同,进而可以减少系统资源的损耗以及其它异常情况的发生
在现实情况下,消费者在消费数据时可能会出现各种会导致宕机的故障问题,这个时候,如果消费者后续恢复了,它就需要从发生故障前的位置开始继续消费,而不是从头开始消费。所以消费者需要实时的记录自己消费到了哪个offset,便于后续发生故障恢复后继续消费。Kafka 0.9版本之前,consumer默认将offset保存在Zookeeper中,从0.9版本开始,consumer默认将offset保存在Kafka一个内置的topic中,该topic为 __consumer_offsets
:
同一个组里的,当动态扩展分区分配时新进入的消费者接着消费分区消息而不是重新消费。offset是按照:goup+topic+partion来划分的,这样保证组内机器有问题时能接着消费
在基于 Kafka 的分布式消息队列中,ZooKeeper 的作用有:Producer端注册及管理、Consumer端注册及管理以及Kafka集群策略管理 等。
在Producer端Zookeeper能够实现:注册并动态调整broker,注册并动态调整topic,Producers负载均衡。
broker是注册在zookeeper中的,还记得在分布式集群搭建的时候,我们在zk的配置文件中添加的服务节点,就是用来注册broker的。
/brokers/ids/{broker.id}
; 创建完节点后,Kafka 会将该 broker 的 broker.name 及端口号记录到该节点;当然注册完Broker还需要注册Topic
在 Kafka 中,所有 topic 与 broker 的对应关系都由 ZooKeeper 进行维护,在 ZooKeeper 中,建立专门的节点来记录这些信息,其节点路径为 /brokers/topics/{topic_name}
前面说过,为了保障数据的可靠性,每个 Topic 的 Partitions 实际上是存在备份的,并且备份的数量由 Kafka 机制中的 replicas 来控制。
对于同一个 topic 的不同 partition,Kafka会尽力将这些 partition 分布到不同的 broker 服务器上,这种均衡策略实际上是基于 ZooKeeper 实现的。
当broker变化以及topic变化的时候,zookeeper能监听到,并控制消息和分区的分布。
除了生产者涉及的管理行为,在我们前面提到的故障转移机制以及分区策略等内容中相关的其它管理行为也是由Zookeeper完成的
所有的这些操作都是Zookeeper做的。
在Consumer端Zookeeper能够实现:注册并动态调整Consumer,Consumer负载均衡。
在消费者端ZooKeeper 做的工作有那些呢?
/consumers/{group_id}
,其节点下有三个子节点,分别为 [ids, owners, offsets]。
/consumers/[group_id]/owners/[topic]/[broker_id-partition_id]
,其中,[broker_id-partition_id]
就是一个消息分区的标识,节点内容就是该 消息分区上消费者的Consumer ID,这样分区和消费者就能关联起来了。关联分区和消费者/consumers/[group_id]/offsets/[topic]/[broker_id-partition_id]
节点内容就是Offset的值,记录消费者offset,当然新版本的不记录在zookeeper中/consumers/{group_id}/ids
节点下创建临时子节点,并记录相关信息。/consumers/{group_id}/ids
下子节点的变化。一但发现消费者新增或减少,就会触发消费者的负载均衡。其实不光是注册consumer,还包括对消费者策略的管理,例如Consumer负载均衡
Consumer在启动时会到 ZooKeeper下以自己的 Consumer-id 创建临时节点 /consumer/[group-id]/ids/[conusmer-id]
,并对 /consumer/[group-id]/ids
注册监听事件:
然后按照我们之前提到的策略进行排序和消费
好了,在了解了这么多基础知识以及核心原理之后,我们再来看看真正的实战场景是如何操作的。我们有如下场景,假设我们要从当前站点发送一个导出消息给导出ESB,由导出ESB处理业务逻辑来达到解耦的目标该怎么通过Kafka实现呢?
我们发送Kafka消息的时候,外层的封装方法如下,需要传递一个Kafka的topic、一个用来计算Partition【路由转发】的标识key【tenantId】,以及需要传递的消息。
public static bool SendKafkaExportData(
string appName,
int tenantId,
int userId,
string metaObjName,
string viewName,
string exportFileName,
SearchCondition condition,
string version = null,
int total = -1,
ExportFileType fileType = ExportFileType.Xlsx,
string applicationContext = null,
string msgTemplate = null)
{
Common.HelperObjects.ArgumentHelper.AssertNotEmpty(appName, nameof (appName));
Common.HelperObjects.ArgumentHelper.AssertNotEmpty(metaObjName, nameof (metaObjName));
Common.HelperObjects.ArgumentHelper.AssertNotEmpty(viewName, nameof (viewName));
Common.HelperObjects.ArgumentHelper.AssertNotEmpty(exportFileName, nameof (exportFileName));
Common.HelperObjects.ArgumentHelper.AssertPositive(tenantId, nameof (tenantId));
Common.HelperObjects.ArgumentHelper.AssertPositive(userId, nameof (userId));
Common.HelperObjects.ArgumentHelper.AssertNotNull<SearchCondition>(condition, nameof (condition));
bool flag = true;
try
{
ExportRequestDataModel exportRequestData = ExportRequestDataModel.GetExportRequestData(appName, tenantId, userId, metaObjName, viewName, exportFileName, condition, version, total, fileType, applicationContext, msgTemplate);
long num = KafkaProducer.Send<ExportRequestDataModel>("TMLSent", tenantId, exportRequestData);
ExportRequestDataModel.logger.Debug((object) string.Format("{0}-{1}-{2}发送Kafka消息{3}成功", (object) appName, (object) tenantId, (object) userId, (object) num));
}
catch (Exception ex)
{
ExportRequestDataModel.logger.Error((object) string.Format("{0}-{1}-{2}发送Kafka消息异常", (object) appName, (object) tenantId, (object) userId), ex);
flag = false;
}
return flag;
}
而其中的核心方法: long num = KafkaProducer.Send
的实现逻辑如下,将kafka携带的消息序列化为二进制数组:
/// Send a message to a topic.
/// The name of the topic to send the message to.
/// The id of the tenant the message belongs to.
/// The message content.
/// The offset of the message.
public static long Send<T>(string topic, int tenant, T value) where T : IBinarySerializable
{
ArgumentHelper.AssertNotEmpty(topic, nameof (topic));
ArgumentHelper.AssertPositive(tenant, nameof (tenant));
return KafkaProducer.Send(topic, tenant, (object) value == null ? (byte[]) null : BigEndianEncoder.Encode<T>(value));
}
消息发送机制如下,获取到需要的topic,用于计算Partition的标识tenantId以及序列化后可以直接发送的二进制字符串消息:
/// Send a message to a topic.
/// The name of the topic to send the message to.
/// The id of the tenant the message belongs to.
/// The message content.
/// The offset of the message.
public static long Send(string topic, int tenant, byte[] value)
{
ArgumentHelper.AssertNotEmpty(topic, nameof (topic));
ArgumentHelper.AssertPositive(tenant, nameof (tenant));
try
{
return KafkaProtocol.Produce(topic, tenant, value);
}
catch (ConnectionPoolException ex)
{
return KafkaProtocol.Produce(topic, tenant, value);
}
catch (KafkaException ex)
{
if (ex.Error == ErrorCode.NotLeaderForPartition || ex.Error == ErrorCode.LeaderNotAvailable)
return KafkaProtocol.Produce(topic, tenant, value);
throw;
}
}
核心的发送方法为:
public static long Produce(string topic, int tenant, byte[] value)
{
TopicConfig topicConfig = BaseConfig<KafkaMapping>.Instance.GetTopicConfig(topic);
int num = tenant % KafkaProtocol.GetTopicPartitionCount(topic); //计算要发往哪个分区
int partitionLeader = KafkaProtocol.GetPartitionLeader(topic, num); //获取分区leader
try
{
using (KafkaSession kafkaSession = new KafkaSession(topicConfig.Cluster, partitionLeader)) //创建一个kafka消息发送实例
{
Message message = new Message(value, TimeUtil.CurrentTimestamp);
ProduceRequest request = new ProduceRequest((IDictionary<TopicAndPartition, MessageSet>) new Dictionary<TopicAndPartition, MessageSet>()
{
{
new TopicAndPartition(topic, num), //将设置好的topic和partition传入参数
new MessageSet(topicConfig.Codecs, (IList<Message>) new List<Message>()
{
message
})
}
}); //设置要发送的消息
ProduceResponse produceResponse = kafkaSession.Issue<ProduceRequest, ProduceResponse>(request); //发送Kafka消息并
KafkaProtocol.CheckErrorCode(produceResponse.Error, topic, new int?(num), new int?(tenant));
return produceResponse.Offset;
}
}
catch (Exception ex)
{
KafkaProtocol.RefreshPartitionMetadata(topic);
throw;
}
}
这样一个我们需要传递的消息就发送到对应的topic和对应的partition上了(不同的partition可以存放在不同 的机器上,这样取同样余数的租户的数据会被放置到相同分区),无需再自己封装消息分发。
在消费者端,机器需要预热并开启消息消费服务,当然也要有关闭消息服务的方法,开启消费服务意味着开启消息接收和开启消息处理线程,关闭消息服务同理表示关闭消息接收和关闭消息处理线程。
///
/// 接收导出消息的服务
///
public class ReceiveMsgProvider : IReceiveMsgProvider
{
#region 日志、构造方法以及单例
protected static readonly LogWrapper Logger = new LogWrapper();
private ReceiveMsgProvider()
{
}
public static ReceiveMsgProvider Instance {
get; } = new ReceiveMsgProvider();
#endregion 日志、构造方法以及单例
#region 开启消息接收服务
public bool _ActivateService()
{
// 预热
Cloud.Plugins.Helper.ESBProxy.WarmUp();
//开启消息接收服务
StartMessageService();
//开始处理ExportQueue队列中的消息
ExportConsumer.Instance.BeginImportData();
Logger.Debug("_ActivateService was called.");
return true;
}
protected void StartMessageService()
{
try
{
//开始消费消息
ExportConsumer.Instance.Start();
}
catch (Exception ex)
{
Logger.Error(ex);
}
}
#endregion 开启消息接收服务
#region 关闭消息接收服务
public bool _UnActivateService()
{
//关闭消息接收服务
StopMessageService();
//关闭处理queue的线程
ExportConsumer.CloseQueueThreads();
Logger.Debug("_UnActivateService was called.");
return true;
}
protected void StopMessageService()
{
try
{
//停止消费消息
ExportConsumer.Instance.Stop();
}
catch (Exception ex)
{
Logger.Error(ex);
}
}
}
其中,开启和关闭消息接收服务的核心方法如下:
///
/// ESB服务调用入口:启动
///
public void Start()
{
_loggging.Debug("ESB服务调用入口:启动");
//开启一个消费者组实例,这里设置启用了消费者组来接收消息,相当于启动了一个消费者组实例,在OnMessage里去具体写接收到消息之后的代码处理逻辑
_consumer = new KafkaGroupConsumer(ExportKafkaConst.ExportKafkaConsumerGroup, ExportKafkaConst.ExportKafkaTopic, OnMessage);
_consumer .Start();
}
///
/// ESB服务调用入口:停止
///
public void Stop()
{
_loggging.Debug("ESB服务调用入口:停止");
if (_consumer != null && _consumer .IsRunning)
{
_consumer .Stop();
}
}
其中消费者的核心实现方法如下:
public KafkaGroupConsumer(string consumerGroup, string topic, Func<Message, bool> handler)
{
ArgumentHelper.AssertNotEmpty(consumerGroup, "consumerGroup"); //消费者组
ArgumentHelper.AssertNotEmpty(topic, "topic"); //消费主题
ArgumentHelper.AssertNotNull(handler, "handler"); //消息处理函数
_consumerGroup = consumerGroup; //设置消费者组
_topic = topic; //设置topic
_consumerId = GenerateConsumerId(consumerGroup); //按照自定义规则给消费者组内生成一个消费者id
_handler = handler;
ConsumerConfig consumerConfig = BaseConfig<KafkaMapping>.Instance.GetConsumerConfig(consumerGroup);//获取消费者组配置,例如该组内消费者的重试机制、reblance原则等等消费者组的配置
_context = new ConsumerContext(consumerGroup, topic, _consumerId, consumerConfig, Trace.GetTraceHandler(topic, consumerGroup, handler, newConsumer: true)); //整个消费者的上下文,包括组的设置,当前id以及调用trace链路[便于排查问题]
_zooKeeperStateManager = new ZooKeeperStateManager(_context); //将该上下文注册到zookeeper中
BaseConfig<KafkaMapping>.ConfigChanged += ReloadConfig; //将消费者变更注册到Zookeeper,当消费者发生变化时,同一 group 的其余消费者会得到通知
}
使用Kafka实现生产者消费者系统的整体流程就是这样。
行文至此,已洋洋洒洒3万5千言,希望能让你对Kafka有个整体的认知,大家共同进步,与诸君共勉