上传一篇,之前看kafka Consumer源码做的笔记。
正文如下:
kafka版本:2.2
首先,kafka消费模型的要点:
- Kafka 的每个 Consumer(消费者)实例属于一个 ConsumerGroup(消费组);
- 在消费时,ConsumerGroup 中的每个 Consumer 独占一个或多个 Partition(分 区);
- 对于每个 ConsumerGroup,在任意时刻,每个 Partition 至多有 1 个 Consumer 在消费;
- 每个 ConsumerGroup 都有一个 Coordinator(协调者)负责分配 Consumer 和 Partition 的对应关系,当 Partition 或是 Consumer 发生变更是,会触发 rebalance(重新分配)过程,重新分配 Consumer 与 Partition 的对应关系;
- Consumer 维护与 Coordinator 之间的心跳,这样 Coordinator 就能感知到 Consumer 的状态,在 Consumer 故障的时候及时触发 rebalance。
掌握并理解 Kafka 的消费模型,对于理解其消费的实现过程是至关重要的。
使用 Consumer 消费的最简代码示例:
这段代码主要的主要流程是:
- 设置必要的配置信息,包括:起始连接的 Broker 地址,Consumer Group 的 ID,自动提交消费位置的配置和序列化配置;
- 创建 Consumer 实例;
- 订阅了 2 个 Topic:foo 和 bar;
- 循环拉取消息并打印在控制台上。
通过上面的代码实例我们可以看到,消费这个大的流程,在 Kafka 中实际上是被分成 了“订阅”和“拉取消息”这两个小的流程。
Kafka 在消费过程中,每个 Consumer 实例是绑定到一个分区上的,那 Consumer 是如何确定, 绑定到哪一个分区上的呢?
这个问题也是可以通过分析消费流程来找到答案的。
所以,我们 分析整个消费流程主要聚焦在三个问题上:
- 订阅过程是如何实现的?
- Consumer 是如何与 Coordinator 协商,确定消费哪些 Partition 的?
- 拉取消息的过程是如何实现的?(消费的主要流程)
了解前两个问题,有助于充分理解 Kafka 的元数据模型,以及 Kafka 是如何在客户端和服务端之间来交换元数据的。
最后一个问题,拉取消息的实现过程,实际上就是消费的主要流程,我们上节课讲过,这是消息队列最核心的两个流程之一,也是必须重点掌握的。
一、订阅过程如何实现
订阅的主流程方法:
在这个代码中,我们先忽略掉各种参数和状态检查的分支代码,订阅的主流程主要更新了两个属性:一个是订阅状态 subscriptions,另一个是更新元数据中的 topic 信息。
订阅状态 subscriptions 主要维护了订阅的 topic 和 patition 的消费位置等状态信息。属性 metadata 中维护了 Kafka 集群元数据的一个子集,包括集群的 Broker 节点、Topic 和 Partition 在节点上分布,以及我们聚焦的第二个问题:Coordinator 给 Consumer 分配的 Partition 信息。
请注意一下,这个 subscribe() 方法的实现有一个非常值得大家学习的地方:就是开始的 acquireAndEnsureOpen() 和 try-finally release(),作用就是保护这个方法只能单线程调用。
Kafka 在文档中明确地注明了 Consumer 不是线程安全的,意味着 Consumer 被并发调用 时会出现不可预期的结果。为了避免这种情况发生,Kafka 做了主动的检测并抛出异常,而不是放任系统产生不可预期的情况。
Kafka“主动检测不支持的情况并抛出异常,避免系统产生不可预期的行为”这种模式,对 于增强的系统的健壮性是一种非常有效的做法。如果你的系统不支持用户的某种操作,正确 的做法是,检测不支持的操作,直接拒绝用户操作,并给出明确的错误提示,而不应该只是 在文档中写上“不要这样做”,却放任用户错误的操作,产生一些不可预期的、奇怪的错误 结果。
具体 Kafka 是如何实现的并发检测,大家可以看一下方法 acquireAndEnsureOpen() 的实现,很简单也很经典,我们就不再展开讲解了。
继续跟进到更新元数据的方法 metadata.setTopics() 里面,这个方法的实现除了更新元数据类 Metadata 中的 topic 相关的一些属性以外,还调用Metadata.requestUpdate() 方法请求更新元数据。
跟进到 requestUpdate() 的方法里面我们会发现,这里面并没有真正发送更新元数据的请 求,只是将需要更新元数据的标志位 needUpdate 设置为 true 就结束了。Kafka 必须确保 在第一次拉消息之前元数据是可用的,也就是说在第一次拉消息之前必须更新一次元数据, 否则 Consumer 就不知道它应该去哪个 Broker 上去拉哪个 Partition 的消息。
分析完订阅相关的代码,我们来总结一下:
在订阅的实现过程中,Kafka 更新了订阅状态 subscriptions 和元数据 metadata 中的相关 topic 的一些属性,将元数据状态置为“需要 立即更新”,但是并没有真正发送更新元数据的请求,整个过程没有和集群有任何网络数据 交换。
那这个元数据会在什么时候真正做一次更新呢?见下。
二、拉取消息的过程如何实现
拉取消息流程的的时序图如下:
我们对着时序图来分析它的实现流程。
在 KafkaConsumer.poll() 方法 (对应源码 1179 行) 的实现里面,可以看到主要是先后调用了 2 个私有方法:
- updateAssignmentMetadataIfNeeded(): 更新元数据。
- pollForFetches():拉取消息。
方法 updateAssignmentMetadataIfNeeded() 中,调用了 coordinator.poll() 方法, poll() 方法里面又调用了 client.ensureFreshMetadata() 方法,在 client.ensureFreshMetadata() 方法中又调用了 client.poll() 方法,实现了与 Cluster 通信,在 Coordinator 上注册 Consumer 并拉取和更新元数据。
至此,“元数据会在什么时 候真正做一次更新”这个问题也有了答案。
类 ConsumerNetworkClient 封装了 Consumer 和 Cluster 之间所有的网络通信的实现, 这个类是一个非常彻底的异步实现。它没有维护任何的线程,所有待发送的 Request 都存 放在属性 unsent 中,返回的 Response 存放在属性 pendingCompletion 中。每次调用 poll() 方法的时候,在当前线程中发送所有待发送的 Request,处理所有收到的 Response。
这种异步设计的优势就是用很少的线程实现高吞吐量,劣势也 非常明显,极大增加了代码的复杂度。
对比 RocketMQ 的代码,Producer 和 Consumer 在主要收发消息流程上功能的复杂度是差不多的,但是你可以很 明显地感受到 Kafka 的代码实现要比 RocketMQ 的代码实现更加的复杂难于理解。
继续分析方法 pollForFetches() 的实现:
这段代码的主要实现逻辑是:
- 如果缓存里面有未读取的消息,直接返回这些消息;
- 构造拉取消息请求,并发送;
- 发送网络请求并拉取消息,等待直到有消息返回或者超时;
- 返回拉到的消息。
在方法 fetcher.sendFetches() 的实现里面,Kafka 根据元数据的信息,构造到所有需要的 Broker 的拉消息的 Request,然后调用 client.Send() 方法将这些请求异步发送出去。并且,注册了一个回调类来处理返回的 Response,所有返回的 Response 被暂时存放在 Fetcher.completedFetches 中。
需要注意的是,这时的 Request 并没有被真正发给各个 Broker,而是被暂存在了 client.unsend 中等待被发送。
然后,在调用 client.poll() 方法时,会真正将之前构造的所有 Request 发送出去,并处理收到的 Response。
最后,fetcher.fetchedRecords() 方法中,将返回的 Response 反序列化后转换为消息列 表,返回给调用者。
综合上面的实现分析,这里给出整个拉取消息的流程涉及到的相关类的类图,在这个类图中,为了便于理解,并没有把所有类都绘制上去,只是把文中两个流程相关的主要
类和这些类里的关键属性画在了图中,可以配合这个类图和上面的时序图进行代码阅读:
总结:
以上就是 Kafka Consumer 消费消息的实现过程。
分析代码过程中, 不仅仅是要掌握 Kafka 整个消费的流程是是如何实现的,更重要的是理解它这种完全异步的设计思想。
发送请求时,构建 Request 对象,暂存入发送队列,但不立即发送,而是等待合适的时机 批量发送。并且,用回调或者 RequestFeuture 方式,预先定义好如何处理响应的逻辑。 在收到 Broker 返回的响应之后,也不会立即处理,而是暂存在队列中,择机处理。那这个 择机策略就比较复杂了,有可能是需要读取响应的时候,也有可能是缓冲区满了或是时间到 了,都有可能触发一次真正的网络请求,也就是在 poll() 方法中发送所有待发送 Request 并处理所有 Response。
这种设计的好处是:不需要维护用于异步发送的和处理响应的线程,并且能充分发挥批量处 理的优势,这也是 Kafka 的性能非常好的原因之一。
这种设计的缺点也非常的明显,就是 实现的复杂度太大了,如果没有深厚的代码功力,很难驾驭这么复杂的设计,并且后续维护的成本也很高。