该篇主要介绍Kafka消费者相关一些知识点,以及使用时需要注意的事项;
消费者组(
Consumer Group
):是 Kafka 提供的可扩展且具有容错性的消费者机制。其中可以有多个消费者或者消费者实例,他们共享一个公共ID(Group ID
)。
每一个分区只能由同一个消费者组内的一个Consumer实例来消费;
Group ID
是一个字符串,唯一标识一个 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 的每个分区。
当 Rebalance 发生时,Group下所有的生产者实例都会协调在一起共同参与,而具体的分配情况跟策略有关:详细参见:https://blog.csdn.net/shenshouniu/article/details/84076930
当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂很多。
在 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分类:
设置 session.timeout.ms = 6s。
设置 heartbeat.interval.ms = 2s。
要保证 Consumer 实例在被判定为“dead”之前,能够发送至少 3 轮的心跳请求,
即 session.timeout.ms = 3 * heartbeat.interval.ms。
设置 max.poll.interval.ms为一个较大的值,保证下游的业务逻辑能够处理完;
可以检查下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 由三部分组成:
位移主题的 Value,主要保存了位移值;当然还会保存其他一些元数据(时间戳,用户定义的数据),主要用于帮助Kafka执行各种各样的后续操作;
其他格式:
该格式非常神秘,几乎无法在搜索引擎中搜到他的信息,主要是用来注册 Consumer 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
commitAsync(Map
它们的参数是一个 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
Consumer 客户端在提交位移时出现的不可恢复的严重错误或异常;如果异常时可恢复的瞬时错误,API大多会自动错误重试;
异常原因:提交位移失败,原因是消费者组已经开启了 Rebalance 过程,并且将要提交位移的分区分配给了另一个消费者实例。出现这个情况的原因是,你的消费者实例连续两次调用 poll 方法的时间间隔超过了期望的 max.poll.interval.ms 参数值。这通常表明,你的消费者实例花费了太长的时间进行消息处理,耽误了调用 poll 方法。
解决方案:
max.poll.interval.ms
, 默认值为5分钟; 0.10.1.0之前版本需要设置 session.timeout.ms
, 但需要注意该参数还有其他作用;group.id
才可以手动提交位移;当一个消费者组合独立消费者同时存在时,如果group.id相同,那么当独立消费者手动提交位移时,也会抛出该异常。表明它不是消费者组中合法的成员。Kafka消费者进行多线程开发,可以大大提高系统下游的处理速度;同时能够更充分的利用系统资源;
0.10.1.0 之后, KafkaConsumer 包含两个线程:用户主线程, 心跳线程;
心跳线程(Heartbeat Thread)只负责定期给对应的 Broker 机器发送心跳请求,以标识消费者应用的存活性(liveness);同时解耦真实的消息处理逻辑与消费者组成员存活性管理;
对于消息处理来说,Consumer 端是单线程设计,这很好的把消息处理的多线程管理策略从 Consumer 端代码中剥离出去;更有利于其他编程语言移植;
**KafkaConsumer类不是线程安全的,多个线程中不能共享同一个 KafkaConsumer 实例,否则抛出 **ConcurrentModificationException
异常。但 KafkaConsumer.wakeup()可以安全的在其他线程中调用,用来唤醒Consumer。
在消费者程序中启动多个线程,每个线程维护专属的 KafkaConsumer
实例,负责完整的消息获取、消息处理流程。
rebalance
;从Kafka中获取消息的线程是一个或多个,每个线程维护专属的 KafkaConsumer
实例,但 对于逻辑处理部分移交特定线程池来完成, 实现消息消费与业务逻辑的解耦;
和生产者不同, 构建 KafkaConsumer 实例时不会创建任何TCP 连接,而是在调用 KafkaConsumer.poll 方法时被创建的。(构造函数中启动线程,会造成this指针逃逸)
在poll
中创建TCP连接的时机: