Kafka消费者原理解析

文章目录

  • 消费者和消费组
  • 创建Kafka消费者
  • rebalance 分区再均衡
    • rebalance触发时机
    • rebalance 分区分配策略
    • rebalance generatian
    • rebalance协议
    • rebalance流程
  • 消费者配置
    • fetch.min.bytes
    • fetch.max.wait.ms
    • max.partition.fetch.bytes
    • session.timeout.ms
    • auto.offset.reset
    • enable.auto.commit
    • partition.assignment.strategy
  • 提交和偏移量
    • 消息交付语义
      • 精确一次实现原理
        • 幂等性prodecuer
        • 事务
    • 位移类型
    • 提交策略
      • 自动提交
      • 手动提交
      • 异步提交
      • 同步和异步组合提交
      • 再均衡监听器
    • 指定消费消息的特定偏移量
    • 退出消费
  • 参考

消费者和消费组

生产者和消费者往往是一对多的关系,多个消费者可以形成一个消费组来订阅主题消息,对消息进行分类。一个消费组中订阅的都是同一个主题,每个消费者接受主题一部分分区的消息。同一个消费组内的不同消费者只能订阅一个主题下不同分区的消息,不同消费组可以订阅同一个主题的统一分区消息

在同一个消费组下,根据消费者数量的不同,消费者订阅的主题数量也会变化,假设一个主题有4个分区p1,p2,p3,p4,只有唯一的消费者c1,则订阅关系为:

c1->p1
c1->p2
c1->p3
c1->p4

如果有两个消费者c1,c2,则订阅关系会发生变化,可能会变成:

c1->p1
c2->p2
c1->p3
c2->p4

如果消费者数量大于分区数,则会导致部分消费者闲置,不会接收到任何消息。如有5个消费者:

c1->p1
c2->p2
c3->p3
c4->p4
c5 闲置

从上面分析,可以通过适当地增加消费者数量来横向拓展消费能力,这是尤其当消费者需要做一些高延迟处理或发送者发送速率较大的情况下。但不要让消费者数量超过主题分区数。

一个消费组即可保证生产者生产的所有消息都被唯一消费,但如果存在多方对生产的消息感兴趣,可以初始化不同的消费组,不同消费组互不干扰,都分别能对生产者生产的所有消息进行唯一消费。示例如下所示:

对于消费组,有5个状态:

  1. Empty:消费组下没有任何活跃的消费者,可能为消费组刚创建的时刻或工作一段时间后所有消费者离开。
  2. PreparingRebalance:表明group正在准备进行group rebalance。此时group收到部分成员发送的JoinGroup请求,同时等待其他成员发送JoinGroup请求,知道所耦成员都成功加入组或超时。
  3. AwaitingSyc:表明所有成员都已经加入组并等待leader consumer发送分区分配方案。
  4. Stable:表明group开始正常消费,可以响应客户端发送的任何请求
  5. Dead:表明group已经彻底废弃,group内没有任何active成员且group的所有元数据都已被删除。

对应以下生命周期流转模型:

创建Kafka消费者

类似生产者,在创建消费者时,同样需要指定三个最基本的属性:

  1. boostrap.servers:指定kafka集群的连接地址
  2. key.deserializer: 消息中key的反序列化工具类
  3. value.deserializer:消息中value的反序列化工具类

在3个必备配置外,还有一个最基本配置group.id,用来指定消费者属于哪一个消费者群组,如果不指定会分配在默认消费组,但这不太常见。

下面来看一个创建示例:

Properties properties = new Properties();
// 指定broker连接
properties.setProperty("bootstrap.servers", "127.0.0.1:9092");
// 指定key反序列化工具类
properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// 指定value反序列化工具类
properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// 指定消费群组id
properties.setProperty("group.id", "test2");
// 初始化消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
// 指定订阅主题,可以同时订阅多个主题,还可以通过指定test*订阅所有test开头的主题
consumer.subscribe(Collections.singletonList("kafka-topic"));

// 循环消费消息
while (true) {
    // 不断尝试拉取
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> recode : records) {
        // 输出消息内容
        System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
    }
}

默认情况,消费群组会自动根据消费者数量和分区数量来分配消费者消费分区,此外,还可以通过程序手动指定,指定代码如下所示:

// 可选,指定消费分区
List<TopicPartition> partitions = consumer.partitionsFor("kafka-topic") // 获取指定主题下的所有分区
        .stream() // 初始化流
        .map(p -> new TopicPartition(p.topic(), p.partition())) // 遍历根据分区信息初始化主题分区对象
        .collect(Collectors.toList()); // 收集为List
// 给当前消费组分配消费分区
consumer.assign(partitions);

在示例中为当前消费者指定了消费主题的所有分区。

rebalance 分区再均衡

基于一个消费组订阅特定主题的情况下,消费者数量或主题分区数量发生变化都会引起分区再均衡。在再均衡期间,消费者无法读取消息,会造成整个群组一小段时间的不可用。另外,当分区被重新分配到另一个消费者时,消费者当前的读取状态会丢失,可能需要去刷新缓存,在消费者重新恢复状态之间会拖慢应用程序。

rebalance触发时机

消费者通过被指派为群组协调器的broker(不同的群组有不同的协调器)发送心跳来维持他们和群组的从属关系以及他们对分区的所有权。只要消费者以正常的时间间隔发送心跳,就被认为是活跃的,说明还在读取分区的消息。消费者会在轮询尝试获取新消息或提交偏移量时发送心跳,如果消费者停止发送心跳的时间足够长,会话就会过期,群组协调器认为消费者宕机,会触发一次再均衡。另一方面,我们在主动清理消费者时,消费者也会通知协调器它即将离开群组,也会触发一次再均衡。

具体而言,可以分为以下三种情况:

  1. 组成员发生变更:新consumer加入,原有consumer主动离开或崩溃
  2. 组订阅topic数发生变更,基于正则订阅,新主题创建,命中正则规则
  3. 组订阅topic的分区属发生变更,如通过脚本增加订阅topic的分区数

rebalance 分区分配策略

kafka新版本提供了3种分配策略,用于决策归属topic的每个分配会被分配给哪个消费者,具体有:

  1. range策略:基于范围的思想,将单个topic的所有分区按照顺序排列,然后把这些分区划分成固定大小的分区段并依次分配给每个消费者。
  2. round-robin策略:把所有topic的所有分区顺序白开,轮训式地分配给每个消费者
  3. sticky策略:采用“有黏性”策略对所有消费者实例进行分配,可以规避在极端情况下的数据倾斜并在两次rebalance间最大限度维持之前的分配方案。

rebalance generatian

每次触发再均衡后,有一个标志再均衡代数的变量,会在每次触发再均衡后+1。主要用于保护consumer group,尤其是防止无效offset的提交。比如上一代的consumer成员由于某些原因延迟提交了offset.但再均衡后该group产生了新一届的group成员,而这次延迟的offset提交携带的是旧的generation信息,则这次提交会被拒绝。

rebalance协议

rebalance本质是一组协议,由group和coordinator(协调者)共同完成,其中coordinator是每个组的一个协调者,负责对组的状态进行管理,主要职责是再均衡时促成组内所有成员达成新的分区分配方案。再均衡协议包含以下协议请求:

  1. JoinGroup请求:consumer请求加入组
  2. SyncGroup请求:group leader把分配方案同步更新到组内所有成员
  3. Heartbear请求:consumer定期向coordinator汇报心跳表名自己依然存活
  4. LeaveGroup请求:consumer主动通知coordinator该consumer即将离组
  5. DescribeGroup请求:查看组的所有信息,包括成员信息、协议信息、分配方案以及订阅信息等。主要供管理员使用。

在rebalance过程中,coordinator主要处理consumer发来的JoinGroup和SyncGroup请求。当consumer主动离组时会发送LeaveGroup给coordinator。

在成功rebalance后,组内所有consumer定期向coordinator发送Heartbeat请求。而consumer则根据Heartbeat请求的响应中是否包含REBALANCE_IN_PROGRESS来判断当前group是否开启新一轮rebalance。

rebalance流程

再均衡的流程分为以下几步:

  1. 找到coordinator:确定协调者的算法如下:
    1. 计算 Math.abs(groupID.hashCode) % offsets.topic.num.partitions参数值(默认是 50) ,假设是 10。
    2. 寻找一consumer_offsets分区 10的 leader副本所在的 broker,该 broker即为这个group的 coordinator。
  2. 收集consumer,选举Leader并制定分配方案:组内所有consumer向coordinator发送JoinGroup请求,coordinator从中选择一个(通常是第一个)作为leader。并把所有成员信息以及他们的订阅信息发给leader,由leader负责为整个group的所有成员制定分配方案
  3. 同步更新分配方案:所有消费组发送SyncGroup请求给coordinator,但只有leader会将制定的分配方案封装进SyncGroup请求发送给coordinator。coordinator从leader请求中把属于每个consumer的方案单独抽取出来,作为SyncGroup请求的response返还给各自的consumer。

收集consumer,选举Leader并制定分配方案实例:

同步更新分配方案示例:

消费者配置

除了上面几个配置外,消费者还有一些核心配置,通过这些配置有助于我们更好地理解消费者的运行逻辑。

fetch.min.bytes

指定消费者从服务器获取记录的最小字节数。broker在收到消费者的数据请求时,如果可用的数据量小于配置指定的大小,会等有足够的数据再一起返回给消费者,以此降低消费者和broker的工作负载。

fetch.max.wait.ms

指定broker在没有收到足够数据时的最大等待时间,默认500ms,如果没有足够的数据流入broker,即使消费者尝试获取数据,broker也不会立即返回,而会等待离上次拉取数据时间间隔fetch.max.wait.ms才会返回给客户端。这个配置设置过大,会导致数据消费延迟,但可以降低消费者和broker的工作负载

max.partition.fetch.bytes

指定服务器从每个分区里返回给消费者的最大字节数。默认为1MB。这个配置值必须比broker能够接受的最大消息的字节数(max.message.size配置)大,否则消费者可能无法读取过大的消息,导致消费者一直刮起重试。另外还需要考虑消费者处理的时间,如果单词poll数据太多,消费者处理可能无法及时进行下一个轮询来避免会话过期。

session.timeout.ms

该属性指定了消费者在被认为死亡之前可以与服务器断开连接的时间,默认为3s,如果消费者没有在指定时间内发送心跳给群组协调器,会被认为死亡,群组协调器会触发再均衡,把它的分区分配给其他消费者。阈值关联的另一个配置是heartbeat.interval.ms,用来指定poll()方法向协调器发送心跳的频率。因此两个属性一把你需要同步修改,如session.timeout.ms是3s,则heartbeat.interval.ms应该是1s。将session.timeout.ms设置更小一些,可以更快地监测和恢复崩溃节点,但可能会导致非预期的再均衡。

auto.offset.reset

指定消费者在读取一个没有偏移量的分区或者偏移量无效(银消费者长时间失效,包含偏移量的记录已经过期或被删除)的情况下的处理动作,有两个值:

  1. latest,默认值,从最新记录开始读取
  2. earliest,从起始位置读取分区记录

enable.auto.commit

指定消费者是否自动提交偏移量,默认为true。为了尽量避免出现重复数据和数据丢失,可以设为false,有自己控制何时提交偏移量,如果设为true,则可以通过配置auto.commit.intervall.ms来控制提交的频率

partition.assignment.strategy

分区分配策略,决定哪些分区由哪些消费者消费,有两种默认策略:

  1. Range:把主题的若干个连续分区分配给消费者,如有分区p1,p2,p3分配给消费者c1,c2,则分配结果可能为c1->p1,c1->p2,c2->p3
  2. RoundRobin:把主题逐个分配给消费者。如对于上例,分配结果为c1->p1,c2->p2,c1->p3。RoundRobin策略保证所有消费这分配相差0或1个数量的分区。
  3. sticky:采用“有黏性”的策略对所有的consumer实例进行分配,可以规避极端情况下的数据倾斜并且在两次rebalance间最大限度地维持原有的分配方案,相对上面两种方案,有效避免了无视历史分配方案的缺陷。

提交和偏移量

消费者需要更新自己在分区消费的记录偏移量,这个操作叫做提交。通常,偏移量是下一条带消费的消息的位置。消费者提交的偏移量作用在于当消费者发生崩溃或有新消费者加入群组引发分区再均衡时,当分区被分配到新的消费者时,新的消费者可以根据分区记录的偏移量来继续消费消息。这里有两种异常情况:

  1. 如果提交的偏移量小于客户端处理的最后一个消息的偏移量,则两个偏移量之间的消息会被重复处理。
  2. 如果提交的偏移量大于客户端处理的最后一个消息的偏移量,则两个偏移量之间的消息会被丢失。

消息交付语义

  1. 最多一次(at most once):消息可能丢失,但不会被重复处理。如果在消息消费之前提交唯一,则实现的是最多一次交付语义
  2. 最少一次(at least once):消息不会丢失,但可能被多次处理。如果在消息消费之后提交,则实现的是最少一次交付语义。
  3. 精确一次(exactly once):消息一定被处理且只被处理一次。可以基于事务支持精确一次

精确一次实现原理

对于精确一次的时间,依赖于幂等姓Producer,设置producer端参数enable.idempotence=true,同一消息可能被producer发送多次,但在broker端这条消息只会被写入一次。

幂等性prodecuer

幂等性prodecuer发送到broker的每批消息都会标志一个序列号用于消息去重。序列号会和每个producer(pid)建立一一映射,对于接收的每条消息,如果其序号比Broker缓存中序号大于1则接受它,否则将其丢弃。但是,只能保证单个Producer对于同一个的Exactly Once语义。不能保证同一个Producer一个topic不同的partion幂等。

序列号会被保存到底层日志,即使leader副本挂掉,新选出来的leader broker也能执行消息去重工作。

事务

Kafka为实现事务用一个位移idTransactionalId标志事务。当提供了 Transactionalld后, Kafka就能确保:

  1. 跨应用程序会话间的事等发送语义。 具体的做法与新版本 consumer 的 generation 概念 类似,使用具有版本含义的 generation来隔离旧事务的操作。
  2. 支持跨会话间的事务恢复。 如果某个 producer 实例挂掉了, Kafka 能够保证下一个实 例首先完成之前未完成的事务,从而总是保证状态的 一 致性。

位移类型

consumer中存在多种位置信息:

  1. 上次提交位置:consumer最近一次提交的offset值
  2. 当前位置:consumer已读取但尚未提交时的位置
  3. 水位:也被称为高水位。属于分区日志的概念,对于水位之下(更早的消息),consumer都是可以读取的,而水位之上则不行,水位代表所有同步副本已确认的消息位置。
  4. 日志中断位移:也被称为日志最新位置,属于分区日志管辖。表示某个分区副本当前保存消息对应的最大的位移值。

consumer 提交位移的主要机制是通过向所属的coordinator发送位移提交请求来实现的 。 每个位移提交请求都会往_consumer_offsets 对应分区上追加写入一条消息 。 消息的 key 是 group.id、 topic和分区的元组,而 value就是位移值。 如果 consumer为同一个 group的同一个 topic 分区提交了多次位移,那么__consumer_offsets 对应的分区上就会有若干条 key 相同但 value 不同的消息,但显然我们只关心最新一次提交的那条消息。从某种程度来说,只有最新 提交的位移值是有效的,其他消息包含的位移值其实都已经过期了。Kafka 通过压实( compact) 策略来处理这种消息使用模式。

提交策略

kafka提供了多种策略来提交偏移量:

自动提交

自动提交通过两个配置指定:

  1. enable.auto.commit:设为true时,消费者会在经过配置间隔后把从poll()方法收到的最大偏移量提交上去。
  2. auto.commit.interval.ms:控制消费者提交偏移量的间隔时间,默认为5s。

自动提交虽然便利,但存在风险:

  1. 如果在提交后,在配置间隔时间前,如提交后第3秒分区发生再均衡,则意味这3s内处理的消息会被重复处理。虽然可以通过调整更短的提交间隔时间来减少这个风险,但仍有可能发生。
  2. 虽然提交了最新的偏移量,但这不意味最后一批拉取的消息已被正常消费,如果在消费过程消费者宕机,会导致部分消息丢失。

手动提交

可以通过设置auto.commit.offset=false。在每轮消费完调用poll()获取的消息后,手动调用commitSync()来提交最新偏移量。如果在这个过程中,分区发生再均衡,也会有消息被重复消费的可能。调用示例如下所示:

// 循环消费消息
while (true) {
    // 不断尝试拉取
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> recode : records) {
        // 输出消息内容
        System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
    }

    // 手动提交
    consumer.commitSync();
}

手动提交后,会堵塞一直重试,知道提交成功

异步提交

如果不想在提交的时候发生堵塞,影响程序的吞吐量,可以降低提交频率来提高吞吐量,但会由于再均衡而导致增加重复消费消息量的风险。可以异步提交。调用类似同步提交,只是api从consumer.commitSync()变为consumer.commitAsync()。和同步提交不同的是,异步提交可以指定一个callback,来在提交成功或失败的时候回调相关逻辑。示例如下:

consumer.commitAsync(new OffsetCommitCallback() {
    @Override
    public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
        if(exception!=null){
            System.out.println("提交失败");
        }else{
            System.out.println("提交成功");
        }
    }
});

commitAsync在提交失败后不会重试,我们可以在回调中尝试重试提交,但要注意的是,如果已经有一个更大的偏移量提交成功,可能会出现小偏移量覆盖大偏移量的情况。这个可以在重试前,先检查回调的序列号和即将提交的偏移量是否相等来规避。

同步和异步组合提交

可以结合同步和异步提交,在正常轮询消费过程中采用异步提交,当出现异常或消费被中断时,再用同步提交来兜底。示例如下:

// 循环消费消息
try {
    while (true) {
        // 不断尝试拉取
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> recode : records) {
            // 输出消息内容
            System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
        }

        // 异步提交提交
        consumer.commitAsync();
    }
}catch (Exception e){
    e.printStackTrace();
}finally {
    // 最后重步提交
    consumer.commitSync();
    consumer.close();
}

再均衡监听器

在消费者指定订阅主题时,可以传入一个ConsumerRebalanceListener接口实现类,在监听需要分区再均衡时,进行相关的逻辑处理,如提交偏移量,具体示例:

// 存储当前消费偏移量
Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
// 指定订阅主题,同时指定分区再均衡类
consumer.subscribe(Collections.singletonList("kafka-topic"), new ConsumerRebalanceListener() {
    /**
     * 方法在再均衡开始之前和消费者停止读取消息之后被调用,
     * 可以在这里提交偏移量,以便后续接管的消费者找到偏移量继续消息消费
     * @param partitions 当前消费者负责消费的分区
     */
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // 提交偏移量
        consumer.commitSync();
    }

    /**
     * 方法会在分区再均衡后和消费者开始读取消息前被调用
     * @param partitions 当前消费者负责消费的分区
     */
    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        System.out.println("开始监听以下分区:" + partitions);
    }
});

// 循环消费消息
while (true) {
    // 不断尝试拉取
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> recode : records) {
        // 输出消息内容
        System.out.println("recodeOffset = " + recode.offset() + ",recodeValue = " + recode.value() + ",record.key = " + recode.key());
        // 每次消费记录消费偏移量
        currentOffsets.put(new TopicPartition(recode.topic(), recode.partition()), new OffsetAndMetadata(recode.offset() + 1, ""));
    }

    consumer.commitSync(currentOffsets);
}

在上面,我们每次消费消息后,都实时记录消费的偏移量,偏移在任何时刻触发监听器,都会提交有效的偏移量。

指定消费消息的特定偏移量

Kafka提供了三种api操作消费的起始偏移量:

  1. seekToBeginning(Collection partitions):从指定的分区的起始位置读取消息
  2. seekToEnd(Collection partitions):从指定的分区的结束位置读取消息
  3. seek(TopicPartition partition, long offset):从指定分区的指定偏移量读取消息。

退出消费

消费者会在在轮询的死循环里不断尝试拉取消息,如果想退出消费,可以另起一个线程,调用consumer.wakeup()来唤醒消费者。而后消费者会在poll()的时候抛出WakeupException。在退出前,最后调用consume.close()来提交任何还没有提交的东西,同时向群组协调器发送消息,接下来会触发分区再均衡而无需等待当前消费者的会话超时。

参考

  1. Kafka权威指南
  2. Apache Kafka实战

你可能感兴趣的:(kafka)