Kafka读书笔记(二)

  • consumer主要参数:

    • session.timeout.ms:该参数指定了coordinator检测失败的时间。在实际使用中,用户可以为该参数设置一个比较小的值让coordinator能够更快地检测consumer崩溃的情况,从而更快地开启rebalance,避免造成更大的消费滞后(consumer tag);
    • max.poll.interval.ms:该参数指定了consumer进行两次poll操作的最大时间间隔,如果consumer端的处理逻辑是在poll线程中进行的,那么就需要注意业务逻辑处理时长不能超过该参数指定的值;
    • auto.offset.reset:该参数指定了无位移信息或者位移越界时, 当前consumer拉取消息时kafka的应对策略。该参数有三种取值:earliest、latest、none,earliest指定了从当前kafka保存的最早的消息开始消费,latest指定了从最新的消息开始消费,none指定了未发现位移信息时将抛出异常;
    • enable.auto.commit:该参数指定了consumer消费消息时的offset提交策略,如果指定为true,那么consumer在拉取消息完成之后就会自动提交offset,如果指定为false,则需要consumer在处理完相应的业务逻辑之后再手动提交offset。对于有较强的“精确处理一次”语义的consumer而言,尽量将其设置为false,从而根据处理情况手动提交offset;
    • fetch.max.bytes:指定了consumer每次拉取数据的最大字节数;
    • max.poll.records:指定了每次最多拉取消息的条数,默认为500;
    • heartbeat.interval.ms:该参数的作用主要是指定了心跳的间隔时间。需要注意的是,如果发生了rebalance,那么coordinator就会将REBALANCE_IN_PROGRESS的异常放到心跳的响应中,因而尽量将该参数的值指定小一些,这样能够让各个consumer更快的感知到正在进行再平衡。另外,这个参数的值必须设置得比session.timeout.ms要小,这样才能告知coordinator当前consumer的存活状态;
    • connection.max.idle.ms:该参数指定了当前链接的最大空闲时间,默认为9分钟。
  • consumer端的位移指的是其在进行消费的时候,需要记录当前已经消费到哪个位置的偏移量。offset对于consumer非常重要,其主要有三种交付语义:

    • 最多一次:消息可能丢失,但不会重复处理;
    • 最少一次:消息不会丢失,但可能被处理多次;
    • 精确一次:消息一定会被处理且只会被处理一次。
  • 在consumer端,对应位移的管理,在partition中有四个位移需要进行区分:

    • 上次提交的位移:该位移指的是当前consumer在最后一次消费之后所提交的位移;
    • 当前位置:表示consumer本次消费所拉取的消息的最新位移,但是该位移还未提交;
    • 水位:表示所有分区副本都经过同步的消息的位移,也即这之前的消息都是不会丢失的,并且consumer消费消息的时候最多只能消费到水位部分的消息;
    • 日志最新位移:表示当前分区副本所写入的消息的最新位移,也就是说,在水位和最新位移之间的数据都是还未完全写入各个分区副本的消息;

    Kafka读书笔记(二)_第1张图片

  • consumer对位移的管理有两种方式:自动提交和手动提交。所谓的自动提交指的是,consumer会异步定时的提交当前所消费的位移,而提交的时间间隔是auto.commit.interval.ms所指定的值,另外需要注意的是,自动提交需要将参数enable.auto.commit指定为true,很明显,自动提交的方式可能会由于consumer的宕机而提交位移不成功,此时就可能产生消息的重复消费,这种方式能够保证消息“至少消费一次”。所谓的手动提交指的是,用户调用consumer的api来提交当前消费的位移,这种方式能够实现“至少一次”的语义,并且借助于一些外部状态,可以实现“精确一次”的语义。手动提交有两种提交方式:同步和异步。所谓的同步指的是在提交的时候用户主线程会等待提交完成再继续往下执行,而异步提交并不是说用户主线程会新建线程来提交,而是说主线程不会等待提交动作完成,并且在consumer调用poll()方法时,会轮询提交是否完成,只有在完成之后才会继续往下执行。另外,建议在手动提交的时候,可以以更加细粒度的方式提交位移,比如只针对每个消费过的分区进行位移的提交,代码如下:

public class CommitPartitionApp {
  public static void main(String[] args) {
    Properties properties = new Properties();
    properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    KafkaConsumer consumer = new KafkaConsumer<>(properties);

    while (true) {
      ConsumerRecords records = consumer.poll(Duration.ofMillis(1000));
      Set partitions = records.partitions();
      partitions.forEach(partition -> {
        List> partitionRecords = records.records(partition);
        partitionRecords.forEach(record -> {
          System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
        });

        // 这里在提交offset的时候,是根据每个partition的消息进行消费,然后在进行提交的时候,只提交该
        // partition的offset,通过这种方式实现了更细粒度的消息管理
        long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
        consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1)));
      });
    }
  }
}
  • consumer发生再平衡的条件:

    • 组成员发生变更,比如有新的成员加入,或者已有成员宕机离开;
    • 当前topic的分区数发生变化;
    • 组定于的topic数量发生变化,比如使用正则表达式时,某个新的topic符合该表达式而被该组进行消费。
  • rebalance的分区重分配策略主要有三种:

    • range策略:这种分配策略指的是,会将每个topic的各个分区进行均等份,比如有一个topic有10个分区,而某个group有三个consumer消费,那么这10个分区的分配情况如:
    C1: t0 t1 t2 t3
    C2: t4 t5 t6
    C3: t7 t8 t9
    

    这种分区策略有一个问题就是,在topic的分区数无法整除时,可能会导致较小的consumer承载更多的消息消费任务。比如有两个topic,各有10个分区,然后有3个consumer进行消费,那么分区分配情况如:

    C1: t0-0 t0-1 t0-2 t0-3  t1-0 t1-1 t1-2 t1-3
    C2: t0-4 t0-5 t0-6       t1-4 t1-5 t1-6
    C3: t0-7 t0-8 t0-9       t1-7 t1-8 t1-9
    
    • round robin策略:这种策略是轮询,其首先会将当前group订阅的所有topic及其partition组成一个key,然后计算这个key的hash值,最后将所有的topic-partition的hash值从小到大排序,然后将其依次分配给各个partition。
    • sticky策略:这种策略初始分配partition时,分配方式与round robin非常相似,但其区别在于进行重分配的时候,round robin是根据topic、partition和consumer进行一次全量的再次计算,而sticky则不会改变现有的各个还健在的consumer的分区策略,而是将多余的分区重新分配给各个consumer。
  • 在进行Rebalance的时候,有一个generation的概念,这个概念指的是进行Rebalance的次数,consumer在提交位移信息的时候,会带上其所认为的generation,如果某个consumer提交的位移所携带的generation比coordinator中保存的最新的generation要小,那么其是不会处理这一次的offset提交请求的。

  • consumer进行Rebalance的时候有一份协议,该协议如下:

    • JoinGroup请求:consumer请求加入组;
    • SyncGroup请求:group leader把分配方案同步更新到组内所有成员中;
    • Heartbeat请求:consumer定期向coordinator汇报心跳表明自己依然存活;
    • LeaveGroup请求:consumer主动通知coordinator该consumer即将离组;
    • DescribeGroup请求:查看组的所有信息,包括成员信息、协议信息、分配方案以及订阅信息等。该请求类型主要供管理员使用。coordinator不使用该请求进行Rebalance。
  • Rebalance的流程主要包含三个:

    • 首先确认当前group的coordinator所在的broker,具体的确认方式如下:

      • 计算Math.abs(groupId.hashCode()) % offsets.topic.num.partitions的值,这里offsets.topic.num.partitions的值默认为50;
      • 然后查找将上一步计算结果的分区号,将__consumer_offsets的该分区号的分区的leader副本所在的broker作为当前group的coordinator;
    • 然后各个consumer向coordinator发送JoinGroup的请求,当coordinator收集全了JoinGroup的请求之后,coordinator就会选择一个consumer作为group的leader,并把所有的成员及其订阅信息发送给group的leader;

    • leader接收到所有的信息之后,就开始计算分区分配方案,计算完成之后就会将计算结果封装到SyncGroup的请求中发送给coordinator,需要注意的是,所有的consumer都会向coordinator发送SyncGroup的请求,而只有leader的consumer的请求中才包含了分区分配信息,coordinator接收到请求之后,会将各个consumer的分区信息抽离出来,然后封装到对应的consumer的SyncGroup的响应中。

      Kafka读书笔记(二)_第2张图片

      Kafka读书笔记(二)_第3张图片

  • 在consumer进行消费的时候,如果要实现”精确一次“的语义,那么首先需要设置enable.auto.commit为false,然后在调用consumer.subscribe()时,第二个参数需要指定一个ConsumerRebalanceListener,顾名思义,这是一个监听器,用于在consumer发生Rebalance的时候触发相应的事件的。这里主要是要在Rebalance发生之前提交当前的位移信息,然后在Rebalance发生之后重新寻址到提交的位置进行消费。代码如下:

    public class ConsumerRebalanceListenerApp {
      public static void main(String[] args) {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
    
        // 用于保存最新的消息消费位置,以防止其未提交
        Map offsets = new HashMap<>();
        KafkaConsumer consumer = new KafkaConsumer<>(properties);
        consumer.subscribe(Collections.singleton("test-topic"), new ConsumerRebalanceListener() {
          @Override
          public void onPartitionsRevoked(Collection partitions) {
            consumer.commitSync(offsets);
          }
    
          @Override
          public void onPartitionsAssigned(Collection partitions) {
            for (TopicPartition partition : partitions) {
              OffsetAndMetadata offset = consumer.committed(partition);
              consumer.seek(partition, offset.offset());
            }
    
            offsets.clear();
          }
        });
    
        while (true) {
          ConsumerRecords records = consumer.poll(Duration.ofMillis(1000));
          Set partitions = records.partitions();
          partitions.forEach(partition -> {
            List> partitionRecords = records.records(partition);
            partitionRecords.forEach(record -> {
              // 消息处理逻辑
              System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
    
              // 保存消息的位移信息
              OffsetAndMetadata offset = offsets
                  .computeIfAbsent(partition, x -> new OffsetAndMetadata(record.offset()));
              if (offset.offset() < record.offset()) {
                offsets.put(partition, new OffsetAndMetadata(record.offset()));
              }
    
              // 这里实现的是精确一次的语义,即每消费一条消息就提交该消息的位移信息,这里粒度也可以更粗一些,
              // 就是在当前循环外一次性提交当前partition的最新的offset,但是这样有可能出现消费到中间位置断开的情况
              consumer.commitSync(offsets);
            });
          });
        }
      }
    }
    

    需要注意的是,如果使用自动的方式提交位移,那么是不需要设置ConsumerRebalanceListener的,因为在进行再平衡之前,consumer会提交当前已经消费过的消息的位移。

  • 自定义一个解序列化器的方式:

    • 定义或复用serializer的数据对象格式;
    • 创建自定义deserializer类,令其实现org.apache.kafka.common.serialization.Deserializer接口。在deserializer方法中实现deserializer逻辑;
    • 在构造KafkaConsumer的Properties对象中设置key.deserializervalue.deserializer为上一步的实现类。
  • 需要注意的是,KafkaConsumer不是线程安全的,不能在多线程中共享该实例。但是KafkaProducer是线程安全的。

  • 使用standlone的方式消费某个topic的消息,可以使用consumer的assign()方法,示例代码如下:

    public class ConsumerAssignApp extends AbstractConsumer {
    
      @Test
      public void testAssign() {
        KafkaConsumer consumer = new KafkaConsumer<>(properties);
        List topicPartitions = new ArrayList<>();
        List partitions = consumer.partitionsFor("test-topic");
        if (null != partitions) {
          partitions.forEach(partition -> {
            topicPartitions.add(new TopicPartition(partition.topic(), partition.partition()));
          });
    
          consumer.assign(topicPartitions);
    
          while (true) {
            ConsumerRecords records = consumer.poll(Duration.ofMillis(1000));
            records.forEach(this::printRecord);
          }
        }
      }
    }
    

    但是不太建议使用这种方式,如果确实只想要一个consumer消费消息,可以只使用一个consumer,并且指定一个group即可,这样在动态的添加分区的时候,该consumer也是能够感知到的。

  • 当前kafka支持的压缩类型有:GZIP、Snappy和LZ4。

  • kafka如果有新的broker启动,那么其会在zookeeper的/chroot/brokers/ids/上创建一个节点,broker.id是该broker的id号,该节点是一个临时节点,kafka会在该节点上保存当前broker的host、port、启动时间等等信息。并且通过zookeeper的通知机制,集群中的其他broker也能够立即感知到该broker的加入,并且会将其他broker的信息发送给该broker。

  • kafka属于深度依赖zookeeper的,其会在zookeeper的/chroot下注册多个节点,具体的节点作用如下:

    • /brokers:保存了kafka集群的所有信息,包括每台broker的注册信息和集群上的topic信息;
    • /controller:保存了kafka controller组件的注册信息,同时也负责controller的动态选举;
    • /admin:保存管理脚本的输出结果,比如删除topic,对分区进行重分配等操作;
    • /isr_change_notification:保存ISR列表发生变化的分区列表。controller会注册一个监听器实时监控该节点下子节点的变更;
    • /config:保存了kafka集群下各种资源的定制化配置信息,比如每个topic可能有自己专属的一组配置,那么就保存在/config/topics/下;
    • /cluster:保存了kafka集群的简要信息,包括集群的ID信息和集群版本号;
    • /controller_epoch:保存了controller组件的版本号。kafka使用该版本号来隔离无效的controller请求;
  • kafka中,对于每一个分区,其副本有一个ISR的概念,这个ISR指的是当前处于同步状态的副本集合,因为所有的follower副本都会向leader副本发送请求以同步消息,但是有的副本由于各种各样的原因,导致其与leader副本不同步,这样的副本就不属于ISR集合,只有在处于同步状态的副本才属于ISR集合。

  • 关于follower副本与leader副本的同步,这里有几个概念:

    • 起始位移:表示该副本当前所含第一条消息的offset;
    • 高水印值(HW):副本高水印值,表示该副本与其他副本都同步的最新的消息位移值,需要注意的是,每个副本都有相同的高水印值,高水印指针指向的是已经同步的最新的消息的位移;
    • 日志末端位移(LEO):表示每个副本所写入的最新的消息的位移,这些消息很有可能是还未与其他的副本进行同步的。需要注意的是,每个副本都有自己的日志末端位移,因为每个副本都会尝试与leader副本进行同步,而同步的进度不会是完全一样的,这就导致每个副本都有可能有不一样的LEO值。另外,每个副本的日志末端位移一定是比该副本的水位值要高的。事实上,只有ISR中所有的副本都更新了自己的日志末端位移之后,其水位值才会往后移动。日志末端位移指向的是当前分区副本的最新的消息的下一个位移。
  • kafka检测一个副本是否在ISR中的方式是通过replica.lag.max.messages参数来判断的,该参数值默认为10s,判定方式主要为一个副本是否持续性的落后于leader副本,是则判断其处于不同步的状态,从而会将其踢出ISR集合中。

  • kafka中的索引文件有两种,一种是位移索引文件,一种是时间戳索引文件。位移索引文件保存的是位移与该位移所对应的消息记录的物理地址;时间戳索引文件保存的是时间戳与对应于该时间戳的消息的位移数据,也就是说如果要通过时间戳索引文件进行查找,那么还需要将获取到的位移值再到位移索引文件中进行再次查找。需要说明的是,在kafka的日志文件中,每个topic下的分区日志是分段的,默认每个段的大小是1G,日志文件的文件名会以该日志段的消息的最小offset进行命名。同样的,索引文件也是依据日志文件而来的,每个索引文件段都对应了一个日志文件段,并且索引文件名与日志文件名是完全一致的,只是索引文件名的后缀分别是.index和.timeindex,而日志文件名的后缀则是.log。kafka的索引文件大小默认最大为10M。另外需要说明的一点是,位移索引文件并不是将全部的位移都保存下来了,而是kafka分区每写入4KB的数据之后才开始为该位置的消息建立一个索引项,也就是说位移索引文件是分散的。除此之外,kafka还为位移索引文件进行了”压缩“处理,所谓的”压缩“处理指的是,其存储的并不是实际的位移,而是存储的当前位移相对于当前位移索引文件的起始位移的偏移量,通过这种方式能够极大的减少位移索引文件的大小。

  • 日志的清除策略主要有两种:a. 将超过指定时间的日志给删除,参数为log.retention.{hours|minutes|ms},默认是7天以外;b. 将每个超过指定大小的日志给清除,参数为log.retention.bytes,默认为-1,即不进行大小的限制。

  • 日志压实指的是,对于某一个topic,如果设置其数据需要压实,那么在producer发送的多条消息中,如果这几条消息的key是一样的,他们就会被压实,也就是只会保留offset最大的那条消息,也即最新的消息。在压实的时候,kafka有一个专门的定时任务进行检测,不过由于kafka日志文件分为当前活跃文件段和历史文件段,这个定时任务只会检测和清理历史文件段,而不会处理当前活跃文件段。

  • 日志压实的相关参数:

    • log.cleanup.policy:指定了日志留存策略,有delete和compact两种,delete也就是一般的删除策略,而compact就是压实策略,可以同时指定这两个值,表示当前日志同时使用这两种留存策略;
    • log.cleaner.enable:是否启用log Cleaner,如果使用了日志压实策略,那么必须显示的将该值设置为true;
    • log.cleaner.min.compaction.lag.ms:设置进行压实的时间范围,默认为0,表示只对历史日志段文件进行压实,比如设置为10分钟,而现在是下午1点,那么在12:50之后的日志都是不会被压实的。这个参数的意义主要在于保护更加久远的日志文件。
  • 在kafka中consumer提交的位移信息就是保存在__consumer_offsets这个topic中的,而这个topic就是典型的使用日志压实策略的示例。

  • controller的作用主要有如下几点:

    • 更新集群元数据信息;
      • controller更新元数据信息的方式主要是在有元数据更新请求时,其会将变更后的元数据信息封装进UpdateMetadataRequests请求中,然后发送给各个broker;
    • 创建topic;
      • 在clients或者admin创建了一个topic之后,其会在zookeeper的/chroot/brokers/topics下创建一个对应的znode,然后把这个topic的分区以及对应的副本列表写入这个znode中。controller会监听/chroot/brokers/topics下的节点变化,一旦发现有新的节点生成,就会触发创建topic的逻辑,即controller会为每个topic的每个分区确定leader和ISR,然后更新集群的元数据信息。做完这些后,controller还会创建一个新的监听器监听zookeeper的/chroot/brokers/topics/<新增topic>节点内容的变更,这样当topic分区发生变化时controller也能第一时间得到通知。
    • 删除topic;
      • 删除topic的方式主要是,在client发起一个删除topic的请求时,其就会在/chroot/admin/delete_topics下新建一个znode,而controller则会监听该节点下的所有子节点变化,当有新的znode创建时,就会触发删除topic的逻辑。
    • 分区重分配;
      • 分区重分配的操作主要是由管理员来进行的,管理员首先需要自行指定分配方案,然后将其写入到zookeeper的/chroot/admin/reassign_partitions节点下,然后触发controller的分区重分配的事件。分区重分配的过程中,controller首先会按照指定的策略创建各个分区,并且等到新创建的分区与旧的分区数据保持同步之后,再将leader切换到新的分区上,最后删除旧的分区。
    • preferred leader副本选举;
      • preferred leader指的是副本列表中的id最小的broker的副本,在集群运行过程中,可能会由于各种各样的原因,副本的leader不是preferred leader,这个时候可以通过两种方式切换回来:a. 设置broker参数auto.leader.rebalance.enable为true;b. 通过kafka-preferred-replica-election脚本手动触发。这两种方式实际上都是首先向zookeeper的/chroot/admin/preferred_replica_election节点写入数据,然后触发controller的leader调整策略。
    • topic分区扩展;
      • 在某些情况下,随着业务量增加,当前topic的分区数不足以支撑现有的业务量,因而需要对其进行扩展,扩展的方式主要是通过kafka-topics脚本的--alter参数来进行的,与创建topic一样,其会在zookeeper的/chroot/brokers/topics/节点下写入新的分区目录,此时就会触发controller的相关事件,从而实现分区的扩展。
    • broker加入集群;
      • broker加入集群的原理主要是在zookeeper的/chroot/broker/ids节点下创建一个znode,并且会触发controller的创建broker的事件,此时controller就会将集群相关的元数据同步到这个broker中。
    • broker崩溃;
      • broker崩溃与加入broker的逻辑相反,因为broker在zookeeper的/chroot/broker/ids节点下创建的子节点都是临时节点,因而如果broker崩溃,那么就会触发相应的事件,从而通知到集群中的其他broker,此时会触发删除broker的相关事件。
    • 受控关闭;
      • 所谓的受控关闭指的是broker使用kafka-server-stop脚本或kill -15命令来关闭某个broker。在执行该命令后,broker会向controller发送ControlledShutdownRequest,此时其会一直阻塞,直到controller向其返回ControlledShutdownResponse。在这个过程中,controller会进行诸如leader选举和ISR收缩的工作。
    • controller leader选举。
      • 对于controller leader的选举,本质上使用的是zookeeper的master选举机制,也即多个节点同时尝试在zookeeper上创建同一个znode,而zookeeper会保证只有一个节点能创建成功,这个节点就会被当做leader节点,而其他的节点就会被当做从节点,并且这些节点会继续监听被创建的znode,只要leader节点宕机,由于其创建的是临时znode,那么其他的节点就能收到相应的事件,从而再次进行选举,以得到新的leader节点。这里的znode就是/chroot/controller,其上保存了当前leader controller的id。
    • 幂等操作:如果一个操作执行多次的结果与执行一次的结果完全一致,那么就称该操作为幂等性操作。
  • kafka在0.11.0.0版本中开始支持了幂等性producer,将enable.idempotence参数指定为true即可开启该功能。这个功能的主要实现实际上是在每条消息中为该消息设置一个唯一序列号,以协助broker对其进行去重操作。需要注意的是,这个序列号是严格的单调增加的,在broker进行去重操作时,其只需要通过当前消息的序列号是否比当前最新的消息的序列号要小,如果是,则表示其为一条重复消息,就不会对该消息进行处理。

  • 另外需要注意的是,消息的序列号是由producer来生成的,也就是说,序列号是绑定在某个producer对象中的,并且在每个分区中是单调增加的,即作为一个key可以唯一确认一组序列号。那么这里需要强调的就是,如果在一个服务中,使用了多个producer,那么这些producer就各自维护了自己的序列号。这里如果有多个服务,或者想声明多个producer,可以为producer指定id,这样在同一个id下的消息就还是使用共同的序列号。

  • kafka事务使用示例:

    producer.initTransactions();
    try {
      producer.beginTransaction();
      producer.send(record0);
      producer.send(record1);
      producer.sendOffsetToTxn(...);
      producer.sendOffsetsToTransaction();
    } catch(ProducerFencedException e) {
      producer.close();
    } catch(KafkaException e) {
      producer.abortTransaction();
    }
    

转载于:https://my.oschina.net/zhangxufeng/blog/3100467

你可能感兴趣的:(Kafka读书笔记(二))