Kafka消息分发、主题分区与消费组的概念

本文主要从Kafka与传统JMS消息系统的对比中挖掘Kafka在消息分发和主题分区上的独特特点,Kafka通过主题topic、分区和消费组这三个概念灵活适应各种消息场合,业务设计关键是如何用好这三个概念,当然前提是理解这三个概念内在机制和逻辑性。

以下是英文原文混译:

当从其他消息传递系统转向Apache Kafka时,需要首先跨越一个概念性的障碍,那就是 - 消息发送到这个主题Topic上,这个主题到底是什么东东,它内部的消息分发如何工作的?

与普通消息系统不同的是,卡夫卡只有一种消息类型 - 那就是“主题topic”(这里将其称为KTopic,以便将其与JMS主题topic区分开来)。kTopic的基础底层是一个被称为日志的持久数据结构(可以认为它像一个数组),其中有指针(通常是一个数字偏移量)是代表地址的一个索引。消费者与消费者组订阅k主题Topic,消费者组使用group ID表示; 您可以认为group ID是一种命名指针。

如果你不将group ID告诉消息中间件服务器broker,它就会将消费者读取指针指向日志的开头(这样导致broker将其接受到的所有消息发送给了所有的消费者),或者结束(所有消费者从下一条消息开始获取所有消息)。消费一条消息意味着将指针移动到日志中的下一个位置。指针也可以向后移动以消费先前已消费的消息,这与JMS等传统消息系统的消费后消息会被删除的机制非常不同。

这种不同,意味着消费者可以通过代码控制自己的日志指针(偏移量)来定位自己独特的起始位置。而在普通JMS中,消费者是无法自己控制自己的读取消息的位置的,因为这种状态控制被消息中间件服务器内部自己控制了。

(Kafka提供消费组的概念,也就是对消费者进行分组,每个消费组都有自己单独的日志指针,单个消费组内部不同消费者会不断偏移这个指针,导致同一个消费组内多个消费者不会获得相同的消息,但是多个消费组会获得相同的消息。)

能够清晰解释Kafka的kTopic消息分发机制的最佳方式是将其与常规(非Kafka)消息中间件进行对比。让我们看看传统的JMS世界,它有两种消息模型:

1. 队列 - 先进先出
2. 主题 - 发布/订阅

在队列模型中,消息按照它们发送的相同顺序进行分发; 如果有不止一个消费者,这些消费者将以round-robin挨个轮询方式接受消息,确保公平获得消息。对于这种情况有一个问题是,在这种默认情况下,无法保证消息将按顺序处理,因为消息是轮询发送给消费者的,顺序是不确定的(某些消费者可能比其他消费者运行速度慢)。您可以使用诸如JMS 消息组之类的功能来解决这个问题,其中相关消息组被分配给消费者,并且这些组内的消息被发送给分配给该组的消费者。

这种队列模型在卡夫卡中是通过使用kTopic 和消费组(主题topic+组group)来实现的。在这种情况下,如果一个消费组里只有一个消费者,那么这个消费者会收到所有消息。但是当消费者里有两个消费者时会发生什么?在这种情况下,最新的加入消费者从加入点接收所有未消费的消息; 原来繁忙的消费者被暂停。这有点像JMS中的独占消费者功能,最后一位消费者后入为主的霸占,排斥之前先加入的其他消费者;这 与JMS的公平分享特点形成鲜明对比。

卡夫卡的基本设计目标之一是快速生产和消费。这导致了一些有趣的设计权衡。最主要的是使用日志作为基本数据构造 - 它是一个非常简单的数据结构,可以非常快速地读取和写入磁盘。这是因为磁盘的顺序访问非常高效。

对于普通JMS,按照round-robin挨个轮询方式从日记中分发消息,一般是在JMS消息服务器上配置专门的调度线程以协调消息挨个公平地传递给消费者。这会让越来越多的消费者变得越来越慢(尽管大多数时候通常都不会看到消费者这种变慢情况)。卡夫卡则不同,它定位在与消费者的数量成线性关系,这意味着您就减少了协调工作(同时也避免单点风险,因为协调器本身就是一个单点瓶颈)。

但是,请考虑以下几点:

1. 协调是并发的基本属性。
2. 水平缩放应通过使用并行性策略来实现的。
3. 并发性反而会阻碍并行性。(并发是基于单点的,类似千军万马过独木桥,而并行则没有单点)

所以,在队列场景中获得性能水平缩放的唯一方法是有效地并行同时使用多个日志。

卡夫卡通过一个分区一个日志的想法实现了这一点。在Kafka服务器上,您可以定义每个kTopic有多少个分区。一个分区对应于一个日志。如果您想在N个消费者中公平地分发消息,则需要N个分区。

(Kafka每个分区代表一个日志,实现一个主题下多分区,类似数据库的分区一样,与Redis的集群分区原理一样,比如100个数字,0-10在1分区,10-20在2分区,你也可以将偶数分配到一个分区 奇数分配到另外一个分区,也可以根据业务key进行特定分区,某个地区对应一个分区等等)

那么消息如何跨分区发布?当消息发送到kTopic时,它们是通过Producer类的方法发送:

send(ProducerRecord<K,V> record): Future<RecordMetadata>;

ProducerRecord包含一个键(K)和一个值(V)。值是消息的有效负荷(也就是通常业务数据,与JMS中不同,不允许通过消息发送另外的消息元数据)。键K是用来实现跨多个分区进行消息分片的。

客户端有责任决定向哪个分区发送消息,这是通过实现接口Partitioner来对Producer进行配置的。默认对消息进行跨区分片的算法是key.hashCode() % numberOfPartitions(key的哈希值除分区个数)。由于哈希散列的冲突,这种默认情况不会为一组key提供公平的均等的分片机会,这就意味着如果您被分区到较少数量的分区(对于相同数量的并行使用者),那么这些分区可能获得比分区获得更多的消息。甚至在2个分区的情况下,其中一个可能根本得不到任何消息 - 导致消费者饥饿。你能实现你自己的Partitioner子类提供更符合你自己业务特定的分区功能来平衡这一点。

重要的是需要注意:你的Partitioner子类实现必须始终将相关消息放入同一个分区,否则将导致消息失序,而相关消息的顺序性对于我们业务是很重要的,比如订单消息应该在支付消息之前到达,如果没有这种顺序,后续工序如果首先接受到支付消息,它无从知晓这是哪个订单的支付,增加消费者手工代码工作量。

分区被公平分配给消费者,并在新增消费者或删除消费者时再重新将消费者分配给多个分区。假设2个分区:

1. 如果您有1个消费者,它将从这两个分区接受消息。
2. 如果你有2个消费者,每个将单独从一个分区接受消息。
3. 如果你有3个,第三个将会停止,不会收到任何消息。

在JMS主题中,消息分发给所有可能感兴趣的消费者。在kTopics中,这是通过为每个消费者提供他们自己的组group ID来实现的,(也就是说,如果你有N个分区,那么就要设计一个消费组内有N个消费者,这样每个消费者消费一个分区,这分区才真正等同于传统消息系统的队列模型)。每个消费者的分发逻辑将与上面的队列场景完全相同。实际上,这意味着kTopics的行为与ActiveMQ的虚拟目标或JMS 2.0持久主题类似,但不同于纯粹的JMS主题 - 因为后者不会将消息写入磁盘,而kTopics则可以。

希望这能够解释清楚卡夫卡消息分发的基本原理。这是一个非常有趣的消息传递技术,但它的一些行为与传统消息传递系统从根本上发生了90度改变。

总结
在Apache Kafka中,消费者组概念可以实现两种消息模型:

1. 同一消费者组中多个消费者之间是“互相竞争排斥”关系,每个消费者从一个或多个分区(分区是“自动”分配给这个消费者的)接收消息时,其他消费者(被分配到其他不同分区)则不会接收相同的消息。通过这种方式,我们可以将消费者的数量扩展到分区数量(一个消费者只读取一个分区);在这种情况下,加入消费组的新消费者将处于空闲状态而不被分配给任何分区。

2.将消费者作为不同消费组中的一部分意味着提供“发布/订阅”模式,同一主题分区的消息将发送给不同消费者组中的所有消费者。这意味着在同一个消费组中,我们将拥有前面第一个规则,但是位于不同的消费组中多个消费者会收到相同的消息。当不同的应用程序对同一个主题中的消息感兴趣时,这种发布订阅模式将非常有用,这些应用程序位于不同的消费组中,能够接受到相同消息,但是以不同的方式处理这些消息。

为了更清晰理解Kafka的分区与消费组概念,可参看该文github项目

原文:
Message Distribution and Topic Partitioning in Kaf