前言
本文是由笔者所原创的 《Kafka》系列文章之一,
本文为作者原创作品,转载请注明出处;
传统消息队列的利弊
传统的消息队列的实现方式分为两种方式,一种是队列的方式,也就是 queue 的方式;一种是发布订阅的方式,也就是 publish-subscribe 的方式;这两种方式各有各自的优缺点,下面分别来分析,
queue
它的一大特性是将消息的“消费状态”记录在 Server 端的,该消费状态主要记录该消息是否已经成功被客户端消费了;
其中细节是比较复杂,涉及到消息消费的事务,客户端已读取,但未必处理成功,因此在消息被客户端读取以后,仍然需要客户端发送一个 confirmation 的消息给 Server 确保消息被成功消费了,然后 Server 端才能采取后续的措施,比如删除该消息或者 archive 该消息,这里不再展开叙述了;
也正是因为这个特性,使得 queue 在设计上比较独特,也就是一个 queue 只能有一个 consumer 客户端,因为只有这样,才能保证 Server 端消息的“消费状态”的完整性;
- 优点
具备良好的纵向扩展性 Vertical Scalibility,可以将一批消息同时发送给多个 queue 来进行扩展并提升效率; - 缺点
不具备横向扩展性 Horizental Scalability,一个 queue 只能有一个 consumer,限制了其横向的可扩展性;如果我有多个 consumers 想要同时消费一个 queue,单纯通过 queue 本身是无法做到的;
publish-subscribe
发布订阅的方式就简单明了,它将接收到的消息直接发布给订阅了该消息所有的订阅者 broadcast to all subsribers,在其实现上没有完整的队列的概念,通常仅仅是内存数组缓存或者数据库缓存来保存当前消息;
- 优点
不具备纵向扩展性 Vertical Scalibility,因为消息都只需要发送给一个 Receiver,因为没有队列的概念,因此不能像 queue 那样,将大量的消息切割刀多个 queues 中; - 缺点
具备良好的横向扩展性 Horizental Scalability,可以有任意多个 subscribers;
以上便是传统的消息队列的不足,为了弥补这些缺陷,Kafka 应运而生;
Kafka
本章节内容,笔者将描绘 Kafka 是如何设计并同时满足纵向扩展性 Vertical Scalibility 和横向扩展型 Horizental Scalability 的。
Topic
Publisher 将消息发送给主题 Topic,Topic 是一个逻辑概念,它由一个或者多个分区 Partitions 所构成,$Topic \to n*Partitions$,Publisher 必须指定将消息发送给哪个 Topic 以及该 Topic 中的哪个 Partition 中去;
- Kafka 消息的构成
Kafka 的消息格式很简单,三部分构成,key、value 和 timestamp;
Partition
Partition 就是 Kafka 的消息队列,它将 kafka 的消息以日志的方式保存在磁盘中,该日志中的消息有这样的特性,消息是有序的,且不重复的(这里的不重复指的是 key、value 和 timestamp 三者不重复);有如下重要的 metadata 元数据属性,
- sequence id $\leftrightarrow$ offset
消息队列中保存有每一个消息的偏移量 offset,kafka 中称作 sequence id,因为消息体在 kafka 的日志中是以二进制存储的,一来使用 offset 从二进制文件中快速的读取消息,二来,通过 offset 保证消息在队列中的顺序; - retention policy/period
设置消息在 partition 中所能够保存的时间;如果超过该时间,Kafka 将从日志中删除该消息以节约磁盘空间,即便是 consumer 还没来得及消费,Kafka 同样会删除该数据;官方建议可以设置足够长的时间,因为 Kafka 在设计上充分考虑到了大日志文件消息队列的读取性能;
Vertical Scalability 纵向扩展性
正是因为一个 Topic 由多个 Partitions 组成的这一设计,使得 Kafka 具备纵向的可扩展性,我们可以将一系列的消息通过 round-bin 的方式轮流的发送给该 Topic 中的每一个 Partition,这样就可以保证每个 partition 的负载是最均衡的,round-bin 也是官方所推荐的方式;
Topic 消息的全局有序性
如果我们要保证发送给某个 Topic 的消息必须是全局有序的,唯一的办法就是,该 Topic 有且只能有一个 Partition,也就是该 Topic 只能有一个队列;
Consumer
首先来谈谈 Consuemr Group,Consumer Group 也是一个抽象概念,它由一个或者多个 Consumer 实例所构成,
$$Consumer\phantom{1}Group \to n*Consumers$$
Kafka 在设计上对其做了一条非常重要的限制,那就是,一个 Partition 只能被同一个 Consumer Group 中的唯一一个 Consumer 实例所消费,也就是说,同一个 Consumer Group 中的两个或者两个以上的 Consumers 不能同时消费同一个 Partition,关联关系如下,
$$Partition_y \leftrightarrow Consumer_{x} (\in Consumer\phantom{1}Group\phantom{1}\alpha)$$
表示,$Partition_y$ 的消费权限在 Consumer Group $\alpha$ 中只能分配给某一个 $Consumer_x$,为什么 Kafka 设计的时候非要有这样的限制呢?笔者将会在后续的文章中给出自己的分析;下面分析 Partition 和 Consumer 之间的范式关系,
- 首先,站在 Partition 的角度,一个 Partition 只能被同一个 Consumer Group 中的唯一一个 Consumer 消费;
- 再次,站在 Consumer 的角度,一个 Consumer 可以同时消费多个不同的 Partitions;
因此,它们之间的范式关系如下,
$$n\times Partition \to 1\times Consumer$$
Horizental Scalability 横向扩展性
不过,一个 Partition 可以被多个不同的 Consumer Group 所消费,且每个 Consumer Group 中有一个 Consumer 来消费该 Partition,该对应关系如下,
$$1\times Partition=\begin{cases}
Consumer\phantom{1}Group_1 \to Consumer\phantom{1}instance_x, & \text{被 Consumer Group 1 中的 Consumer x 所消费 } \\
Consumer\phantom{1}Group_2 \to Consumer\phantom{1}instance_y, & \text{被 Consumer Group 2 中的 Consumer y 所消费 } \\
...... \\
Consumer\phantom{1}Group_n \to Consumer\phantom{1}instance_m, & \text{被 Consumer Group n 中的 Consumer m 所消费 }
\end{cases}$$
其实这也就是 Kafka 的横向的可扩展性 Horizental Scalability;之所以 Kafka 可以有上面这样的对应关系,一个非常重要的原因是,“消费状态”信息是保存在 Consumer 端的,Kafka 的实现很简单,直接在 Consumer 的元数据中保存一个 offset 值,该值其实就是一个 int,来记录自己的消费状态;注意,这和传统 queue 的方式不同,传统 queue 是将消费状态信息保存在 Server 端的;正是因为这样的设计,使得,Kafka 可以很轻易的进行横向的扩展,可以同时被多个 Consumer Group 所消费,并且基本上不丧失任何性能;
Vertical Scalability 纵向扩展性
下面,笔者将结合 Consumer 和 Partition 一起再来谈谈 Kafka 的纵向扩展性,这里谈一下 Kafka 的 Rebalance 的策略,也就是 Kafka Consumer Group 的动态再平衡策略,注意,动态再平衡发生在 Consumer Group 中;假设起初,某个 Topic 中有两个 Partition 分别是 $Partition_1$ 和 $Partition_2$,而目前有一个 Consumer Group 且只有一个 Consumer instance 来对该 Topic 进行消费,
- 最初的状态 $$ \left. \begin{array}{l} Partition_1\\ Partition_2 \end{array} \right\} \to Consumer_1 $$
- 在 Consumer Group 中新增一个 Consumer 实例,这个时候,Kafka 会动态再平衡,如下, $$ \begin{cases} Partition_1 \to Consumer_2 \\ Partition_2 \to Consumer_1 \end{cases} $$ 或者 $$ \begin{cases} Partition_1 \to Consumer_1 \\ Partition_2 \to Consumer_2 \end{cases} $$ 可见,动态再平衡,新增的 Consumer 和已经存在的 Consumer 如何进行重新分配是随机的;
- 再新增一个 Consumer 实例,这个时候,新增的 Consumer 并不会和任何的 Partition 进行关联,并且将会会进入 idle 状态,也就是等待的状态中;若 $Consumer_1$ 或者 $Consumer_2$ 中任意一个突然挂掉了,它将立刻作为替补,来代替该挂掉的 Consumer 实例并开始进行消费;
总体而言,Kafka 的动态再平衡策略就是让 Consumer Group 中的 Consumers 在 Consumer 发生变化的时候,能够均匀的与某个 Topic 中的 Partitions 重新建立起这种对应关系,保持这种关系的平衡性,其实也就能确保性能的最优化;
API
本小节将简单的介绍一下 Kafka 的几个核心的 API
- Producer API
发送消息给 Kafka Topic - Consumer API
- Streams API
流式计算的接口; - Connector API
比如,如果需要时刻记录某个数据库的每张表的变化情况,就可以使用该 API 来连接数据库,数据库将会把自己的变化信息实时的传递给 Kafka 进行保存;Connector API 定义了这样的一个接口标准,任何第三方系统或者数据源可以通过该接口推送信息给 Kafka;
彩蛋
笔者在前文中埋下了一个彩蛋,就是为什么 Kafka 在设计上非要指定这么一条非常重要的限制,那就是,一个 Partition 只能被同一个 Consumer Group 中的唯一一个 Consumer 实例所消费?在不考虑消息顺序的前提下,让同一个 Consumer Group 中的多个 Consumer 来进行消费不是可以更高效吗?其实这个彩蛋也一直是笔者在学习 Kafka 的过程中不断试问自己的地方,如果我来设计,我会不会这么定义?笔者翻遍了 Kafka 的文档,Google 了能够搜索到的内容,也没有找到直接的答案;于是笔者冥思苦想了一晚,认为主要原因还是在 Kafka 所设计的概念上,
- Consumer Group
在 Kafka 的概念中,Consumer Group 其实对应的是一个系统,从概念上而言,一个系统只需要消费一次消息,如果允许 Consumer Group 中的多个 consumers 消费同一个 Partition,势必导致一个消息在同一个系统中被消费多次的情况,这是毫无意义的;因此,有了一个 Partition 只能被一个 Consumer Group 中的唯一一个 Consumer 所消费的定义; 上面从概念设计上进行了回答,其实笔者认为,还有一个更为深层次的原因,那就是如果不加上这个限制,Kafka 的动态再平衡会非常难以实现,道理很简单,因为如果某个 Consumer Group 中的多个 Consumers 可以消费同一个 Partition,
最后,笔者试着按照反向的思维思考了一下,如果允许同一个 Consumer Group 中的多个 consumers 去消费同一个 Partition,发现,这回会导致 Consumer Group 中的“动态再平衡”将会非常难以设计和实现;我想,从纯技术上而言,采用这样的设计也是为了使得 Kafka 的设计简单,且更为健壮!
Reference
https://kafka.apache.org/intro.html
https://www.oreilly.com/library/view/kafka-the-definitive/9781491936153/ch04.html