Kafka消费者剖析

该篇主要介绍Kafka消费者相关一些知识点,以及使用时需要注意的事项;

消费者组

消费者组(Consumer Group):是 Kafka 提供的可扩展且具有容错性的消费者机制。其中可以有多个消费者或者消费者实例,他们共享一个公共ID(Group ID)。
每一个分区只能由同一个消费者组内的一个Consumer实例来消费;

特性

  • Consumer Group 下可以有一个或多个 Consumer 实例;
  • Group ID 是一个字符串,唯一标识一个 Consumer Group;
  • Consumer Group 订阅主题的单个分区,只能分配给组内的某个 Consumer实例消费;
  • Consumer Group 可以订阅多个主题;
  • Consumer Group 之间相互独立,能够订阅相同的一组主题而互不影响;
    Consumer Group 实现了传统消息引擎系统的两大模型: 点对点模型,订阅发布模型。
一个Group下Consumer实例的理想数量:
	Consumer实例的数量等于该Group订阅主题的分区总数;
如果,实例数小于分区数,则一个实例可能会消费多个分区;
如果,实例数大于分区数,则部分实例可能闲置,浪费系统资源;

消费者组位移管理

对于 Consumer Group: 位移(Offset)是一组KV对,K标识分区,V标识对应 Consumer 消费该分区的最新位移。

老版本:
	Consumer Group 把位移保存在Zookeeper中;
	好处:减少了 Kafka Broker 端的状态保存开销;保证服务器节点的无状态,利于自由扩缩容,实现强伸缩性。
	缺点:位移的写操作十分的频繁,这种大吞吐量的写操作会极大的拖慢 Zookeeper 集群的性能。
	Zookeeper是一个分布式协调服务框架,保证其性能及高可用十分重要,因此将位移保存在 Zookeeper中时不合适的做法;
新版本:
	Consumer Group 采用将位移保存在 Kafka 内部主题(__consumer_offsets)的方法来记录位移;

消费者组的重平衡

Rebalance 本质是一种协议,规定一个 Consumer Group下的所有 Consumer 如何达成一致,来分配订阅 Topic 的每个分区。

重平衡时机

  • 组成员数发生变更。比如有新的 Consumer 实例加入组或者离开组,抑或是有 Consumer 实例崩溃被“踢出”组。
  • 订阅主题数发生变更。Consumer Group 可以使用正则表达式的方式订阅主题,比如 consumer.subscribe(Pattern.compile(“t.*c”)) 就表明该 Group 订阅所有以字母 t 开头、字母 c 结尾的主题。在 Consumer Group 的运行过程中,你新创建了一个满足这样条件的主题,那么该 Group 就会发生 Rebalance。
  • 订阅主题的分区数发生变更。Kafka 当前只能允许增加一个主题的分区数。当分区数增加时,就会触发订阅该主题的所有 Group 开启 Rebalance。

重平衡策略

当 Rebalance 发生时,Group下所有的生产者实例都会协调在一起共同参与,而具体的分配情况跟策略有关:详细参见:https://blog.csdn.net/shenshouniu/article/details/84076930

  • Range 分区分配策略: 即平均分配,分区总数 % 实例数, 余数分配给第一个实例;
  • Round-robin 分区分配策略:如果同一个消费组内所有的消费者的订阅信息都是相同的,那么RoundRobinAssignor策略的分区分配会是均匀的。(你一个我一个他一个,轮询)
    使用RoundRobin策略有两个前提条件必须满足:
    1. 同一个Consumer Group里面的所有消费者的num.streams必须相等;
    2. 每个消费者订阅的主题必须相同。
  • StickyAssignor分区分配策略
    1. 分区的分配要尽可能的均匀;
    2. 分区的分配尽可能的与上次分配的保持相同。

当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂很多。

Rebalance注意事项(弊端)

  • Rebalance过程中, 所有 Consumer 实例都会停止消费,等待 Rebalance 完成;这会对Consumer的 TPS影响很大; 整个过程类似JVM的垃圾回收机制–万物静止(stop the world)
  • Rebalance 的设计是所有 Consumer 实例共同参与,全部重新分配所有分区。其实更高效的做法是尽量减少分配方案的变动。
  • Rebalance的效率极低,唯一的解决方案就是:避免Rebalance的发生。

避免 Rebalance

在 Rebalance 过程中,所有 Consumer 实例共同参与,在 协调者组件(Coordinator)的帮助下,完成订阅主题分区的分配;
协调者组件(Coordinator):专门为 Consumer Group服务,负责为 Group 执行 Rebalance 以及提供位移管理和组成员管理等;
Consumer 端应用程序再提交位移时,是向 Coordinator 所在的 Broker 提交位移。
同样地,当 Consumer 应用启动时,也是向 Coordinator 所在的 Broker 发送各种请求,然后由 Coordinator 负责执行消费者组的注册、成员管理记录等元数据管理操作。
所有 Broker 在启动时,都会创建和开启相应的 Coordinator 组件。也就是说,所有 Broker 都有各自的 Coordinator 组件

当 Consumer Group 出现问题时,可以根据以下算法快速定位到正确的 Broker 端,可查看日志:
	1. 确定由位移主题的哪个分区来保存该Group数据:根据groupId的hash值来确定
		partitionId=Math.abs(groupId.hashCode() % offsetsTopicPartitionCount)
	3. 找到该分区 Leader副本所在的 Broker, 该 Broker 即为对应的 Coordinator。

Relalance 在 订阅主题数量和分区数发生变化时发生,大多由运维主动操作产生,这类大多是无法避免的;
能避免的时机:组成员发生变化时

如果 Consumer Group 下的Consumer 实例数量发生变化时,一定会引发 Rebalance;
通常的,对于新增Consumer的操作都是计划内的,可能是出于增加TPS或提高伸缩性的需要;
而在某些情况下, Consumer 实例会被 Coordinator 错误地认为“已停止”从而被“踢出”Group。如果是这个原因导致的 Rebalance,那么是可以避免的;

session.timeout.ms : Consumer端参数,表征最大心跳间隔时间;默认 10秒
每个 Consumer 实例会定期的向 Coordinator 发送心跳请求,表示它还存活;
如果 Consumer没有在以上配置项的时间内发送心跳,Coordinator会认为该Consumer死掉,从而将其从 Group中移除,然后开始新的Rebalance;
heartbeat.interval.ms : Consumer端参数,表示心跳发送频率;频繁发送会额外消耗宽带资源;
max.poll.interval.ms :限定了 Consumer 端应用程序两次调用 poll 方法的最大时间间隔。
默认值是 5 分钟,表示你的 Consumer 程序如果在 5 分钟之内无法消费完 poll 方法返回的消息,
那么 Consumer 会主动发起“离开组”的请求,Coordinator 也会开启新一轮 Rebalance。

Coordinator通知Consumer开启Rebalance的方法:将 REBALANCE_NEEDED 标志封装进心跳请求的响应体中。
不必要的Rebalance分类:

  1. 未能及时发送心跳
设置 session.timeout.ms = 6s。
设置 heartbeat.interval.ms = 2s。
要保证 Consumer 实例在被判定为“dead”之前,能够发送至少 3 轮的心跳请求,
即 session.timeout.ms = 3 * heartbeat.interval.ms。
  1. 消息消费时间太长
设置 max.poll.interval.ms为一个较大的值,保证下游的业务逻辑能够处理完;
  1. 其他
可以检查下Consumer端的 GC 表现,是否是出现频繁的 Full GC 导致的长时间停顿,从而引发的 Rebalance;
 这种情况需要调整 GC设置

位移概述

位移主题

位移主题(Offsets Topic): 主题名:__consumer_offsets,用于记录消费者消费一个主题的进度;
自 0.8.2.x 版本开始修改,并在最终的新版本 Consumer (稳定版本:0.10.2.2及之后版本)中正式推出新的位移管理机制:通过位移主题管理;
位移主题机制:将 Consumer 的位移数据作为一条条普通的Kafka消息,提交到 __consumer_offsets中;
位移主题也是普通的 Kafka 主题,不过他的消息格式是 Kafka 自己定义的,我们可以手动的创建、修改,甚至删除;不过大部分情况下,我们可以不关注他;

位移主题消息格式

位移主题的 Key 由三部分组成:<Group ID, 主题名, 分区号>;
位移主题的 Value,主要保存了位移值;当然还会保存其他一些元数据(时间戳,用户定义的数据),主要用于帮助Kafka执行各种各样的后续操作;

其他格式:

  1. 用于保存 Consumer Group 信息的消息;
该格式非常神秘,几乎无法在搜索引擎中搜到他的信息,主要是用来注册 Consumer Group的
  1. 用于删除 Group 过期位移甚至是删除 Group的信息
专属名:tombstone消息 --- 墓碑消息(delete mark)
这些消息只出现在源码中而不会对外暴露,主要特点是他的消息体是 空消息体(null)
写入时机: 一旦某个 Consumer Group 下的所有Consumer 实例都停止,而且他们的位移数据都已被删除时, 
Kafka 会向位移主题的对应分区写入 tombstone消息,表明要彻底删除这个Group的信息。

位移主题的创建

通常, 当 Kafka 集群中的第一个 Consumer 程序启动时, Kafka会自动创建位移主题。
在位移主题自动创建时,会根据 Broker端参数 offsets.topic.num.partitions来设置分区数,默认值为50;即在不修改配置的情况下,位移主题默认有50个分区;
对于副本,由另一个Broker端参数控制:offsets.topic.replication.factor, 默认值:3; 即每个位移主题的分区有3个副本;

**位移主题也可以手动创建:**在 Kafka 集群尚未启动任何 Consumer 之前,使用 Kafka API创建它;手动创建好处就是,可以根据资源情况自由控制分区副本数量;(不推荐,目前源码中有部分地方硬编码了50分区,因此可能可能出现一些奇怪的问题,该社区bug已修复,但仍在审核)

位移主题的使用

当 Kafka Consumer 提交位移时,会写入该主题; 提交方式有两种:

  • 自动提交
enable.auth.commit : Consumer 端参数,为 true时, Consumer在后台默默地定期提交位移;
auth.commit.interval.ms : Consumer 端参数,控制提交时间间隔;
当启动自动提交时,使用者可以不用关注位移这个概念,但正因为完全交给 Kafka 去完成,
因此无法做到精确把控位移;灵活性和可控性很低;
  • 手动提交
通常,很多与Kafka基层的大数据框架都是禁用自动提交位移的:
	enable.auth.commit = false
此时, Consumer应用开发就需要承担起位移提交的责任。Kafka Consumer API 为你提供了位移提交的方法,如 consumer.commitSync

位移主题消息删除策略

当 Consumer消费到某个主题的最新一条消息时,之后没有新的消息产生;在自动提交位移的情况的,会不断向位移主题写入最新位移的消息,这会导致重复消息存在;之前的消息应该进行清理;否则可能会撑爆磁盘;
Compact 策略:删除位移主题中过期消息的策略

大概原理:对于同一个 Key 的两条消息 M1 和 M2,如果 M1 的发送时间早于 M2,那么 M1 就是过期消息。Compact 的过程就是扫描日志的所有消息,剔除那些过期的消息,然后把剩下的消息整理在一起。在这里贴一张来自官网的图片,来说明 Compact 过程。
在这里插入图片描述
Kafka 提供了专门的后台线程定期地巡检待 Compact 的主题,看看是否存在满足条件的可删除数据。这个后台线程叫 Log Cleaner。很多实际生产环境中都出现过位移主题无限膨胀占用过多磁盘空间的问题,如果你的环境中也有这个问题,我建议你去检查一下 Log Cleaner 线程的状态,通常都是这个线程挂掉了导致的。

位移提交

Consumer 的消费位移:记录 Consumer 要消费的下一跳消息的位移,而不是目前最新消费消费的位移;
Consumer 需要向 Kafka 汇报自己的位移数据,汇报过程被称为提交位移(Commiting Offsets);Consumer 可以同时消费多个分区的数据,所以位移的提交实际上是在分区粒度上进行的(Consumer 需要为分配给他的每个分区提交各自的位移数据);
位移提交时 Kafka 提供的一个工具或语义保障,由使用者维持这个语义保障,如果提交了位移X,那么 Kafka会认为位移值小于 X 的消息均已成功消费;
从用户角度,位移提交分为: 自动提交手动提交
从Consumer端角度,位移提交分为: 同步提交异步提交

自动提交

设置:
enable.auto.commit = true , 默认情况下Kafka自动提交是打开的;
auto.commit.interval.ms = 5000 , 默认情况下该值为 5 秒;表示 Kafka 每5秒回自动提交一次位移;

Properties props = new Properties();
    props.put("bootstrap.servers", "localhost:9092");
    props.put("group.id", "test");
    props.put("enable.auto.commit", "true"); // 开启自动提交
    props.put("auto.commit.interval.ms", "2000"); // 设置指定提交间隔为2秒
    props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
    consumer.subscribe(Arrays.asList("foo", "bar"));
    while (true) {
         ConsumerRecords<String, String> records = consumer.poll(100);
         for (ConsumerRecord<String, String> record : records)
             System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
     }

自动提交开启后,Kafka 会保证在开始调用 poll 方法时,提交上次poll 返回的所有信息。因此保证不出现消息不丢失的情况。但可能存在重复消费:当在时间间隔内发生重平衡时,在上次时间到重平衡时间段的消费消息会再次被消费;

手动提交

设置:enable.auto.commit = false
调用API: KafkaConsumer#commitSync(), 该方法会自动提交 KafkaConsumer#poll() 返回的位移;为同步提交;

while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records); // 处理消息
	try {
		consumer.commitSync();
	} catch (CommitFailedException e) {
	 handle(e); // 处理提交失败异常
	}
}

同步提交缺陷:影响整个应用的 TPS;在任何系统中,因为程序而非自愿限制而导致的阻塞都可能是系统的瓶颈。
异步API: KafkaConsumer#commitAsync(), 调用该方法后会立即返回,不会阻塞;通过回调函数来实现提交后的逻辑;

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
    process(records); // 处理消息
    consumer.commitAsync((offsets, exception) -> {
	if (exception != null)
		handle(exception);
	});
}

异步提交异常重试毫无意义,因为可能重试时已经消费到更大位移处。
手动提交最佳实践:

try {
	while(true) {
		ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
		process(records); // 处理消息
		commitAysnc(); // 使用异步提交规避阻塞
	}
} catch(Exception e) {
	handle(e); // 处理异常
} finally {
	try {
		consumer.commitSync(); // 最后一次提交使用同步阻塞式提交
	} finally {
		consumer.close();
	}
}

在正常处理流程中,我们使用异步提交来提高性能,但最后使用同步提交来保证位移提交成功。
上述方法,都是提交 poll 方法返回的所有消息的位移,即直接提交这一批消息中最新一条消息的位移;
Kafka提供了更细粒度的位移提交API:
commitSync(Map<TopicPartition, OffsetAndMetadata>)
commitAsync(Map<TopicPartition, OffsetAndMetadata>)
它们的参数是一个 Map 对象,键就是 TopicPartition,即消费的分区,而值是一个 OffsetAndMetadata 对象,保存的主要是位移数据。

private Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
int count = 0;
……
while (true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	for (ConsumerRecord<String, String> record: records) {
		process(record);  // 处理消息
		offsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1)if(count % 100 == 0)
			consumer.commitAsync(offsets, null); // 回调处理逻辑是 null
		count++;
	}
}

问题1:对于手动同步和异步提交结合的场景,如果poll出来的消息是500条,而业务处理200条的时候,业务抛异常了,后续消息根本就没有被遍历过,finally里手动同步提交的是201还是000,还是501?
答:如果调用没有参数的commit,那么提交的是500

CommitFailedException异常

Consumer 客户端在提交位移时出现的不可恢复的严重错误或异常;如果异常时可恢复的瞬时错误,API大多会自动错误重试;
异常原因:提交位移失败,原因是消费者组已经开启了 Rebalance 过程,并且将要提交位移的分区分配给了另一个消费者实例。出现这个情况的原因是,你的消费者实例连续两次调用 poll 方法的时间间隔超过了期望的 max.poll.interval.ms 参数值。这通常表明,你的消费者实例花费了太长的时间进行消息处理,耽误了调用 poll 方法。

解决方案:

  • 增加期望的时间间隔 max.poll.interval.ms 参数值。
  • 减少 poll 方法一次性返回的消息数量,即减少 max.poll.records 参数值。

异常场景

  1. 消息处理的总时间超过预设的 max.poll.interval.ms 参数值
    a. 缩短单挑消息处理时间;
    b. 增加 Consumer 端允许下游系统消费一批消息的最大时长, max.poll.interval.ms, 默认值为5分钟; 0.10.1.0之前版本需要设置 session.timeout.ms, 但需要注意该参数还有其他作用;
    c. 减少下游系统一次性消费的消息总数;
    d. 下游系统使用多线程来加速消费;(实现难度大,主要是位移提交)
  2. 独立消费需要指定 group.id才可以手动提交位移;当一个消费者组合独立消费者同时存在时,如果group.id相同,那么当独立消费者手动提交位移时,也会抛出该异常。表明它不是消费者组中合法的成员。

多线程开发

Kafka消费者进行多线程开发,可以大大提高系统下游的处理速度;同时能够更充分的利用系统资源;

Kafka Java Consumer 设计原理

0.10.1.0 之后, KafkaConsumer 包含两个线程:用户主线程, 心跳线程;
心跳线程(Heartbeat Thread)只负责定期给对应的 Broker 机器发送心跳请求,以标识消费者应用的存活性(liveness);同时解耦真实的消息处理逻辑与消费者组成员存活性管理;

对于消息处理来说,Consumer 端是单线程设计,这很好的把消息处理的多线程管理策略从 Consumer 端代码中剥离出去;更有利于其他编程语言移植;

多线程方案

**KafkaConsumer类不是线程安全的,多个线程中不能共享同一个 KafkaConsumer 实例,否则抛出 **ConcurrentModificationException异常。但 KafkaConsumer.wakeup()可以安全的在其他线程中调用,用来唤醒Consumer。

多个线程同时消费 + 逻辑处理

在消费者程序中启动多个线程,每个线程维护专属的 KafkaConsumer 实例,负责完整的消息获取、消息处理流程。

  • 优势:
  1. 实现简单;在每个线程中创建KafkaConsumer实例即可。
  2. 线程间无交互,可减少保障线程安全方面的开销。
  3. 由于同一个消费者组中,一个分区仅会被一个Consumer消费,因此可以很容易可以保障分区内的消息消费顺序。对于有时间先后顺序保证的场景,这尤为重要。
  • 缺点:
  1. 占用更多的系统资源(内存、TCP连接等)。
  2. 受限于Consumer订阅主题的总分区数;同一个消费者组中,一个分区仅会被一个Consumer消费。可以多启动线程,但线程会闲置。
  3. 消费和逻辑在同一线程,当业务阻塞时,消费会被影响,容易出现不必要的 rebalance

单个或多个线程消费 + 多个线程逻辑处理

从Kafka中获取消息的线程是一个或多个,每个线程维护专属的 KafkaConsumer 实例,但 对于逻辑处理部分移交特定线程池来完成, 实现消息消费与业务逻辑的解耦;

  • 优势:
    具有更高的伸缩性,不用考虑业务对消息消费的影响;
  • 缺陷:
  1. 实现难度相对较大;
  2. 无法保证分区内的消费顺序;同一分区的消息可能被多个线程消费;
  3. 消费位移的正确提交异常困难,可能导致消息重复消费;

TCP连接管理

和生产者不同, 构建 KafkaConsumer 实例时不会创建任何TCP 连接,而是在调用 KafkaConsumer.poll 方法时被创建的。(构造函数中启动线程,会造成this指针逃逸)
poll 中创建TCP连接的时机:

  1. 发起 FindCoordinator 请求时
  2. 连接协调者时
  3. 消费数据时
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章