Kafka复习(一):基本概念、生产者、消费者

一、基本概念

1、Producer+Consumer+Broker

Kafka复习(一):基本概念、生产者、消费者

Producer(生产者)将消息发送到Broker,Broker将收到的消息存储到磁盘中,而Consumer(消费者)负责从Broker订阅并消费消息,Consumer使用拉(Pull)模式从服务端拉取消息

ZooKeeper是负责集群元数据的管理、控制器的选举

2、Topic+Partition

在Kafka中,发布订阅的对象是主题(Topic),生产者负责将消息发送到特定的主题(每一条消息都要指定一个主题),而消费者负责订阅主题并进行消费

Kafka中的分区机制指的是将每个主题划分成多个分区(Partition),每个分区是一组有序的消息日志,同一主题下的不同分区包含的消息是不同的。生产者生产的每条消息只会被发送到一个分区中,Kafka的分区编号是从0开始的,如果向一个两个分区的主题发送一条消息,这条消息要么在分区0中,要么在分区1中

分区在存储层面可以看作一个可追加的日志(Log)文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量(offset)。offset是消息在分区中的唯一标识,Kafka通过它来保证消息在分区内的顺序性,不过offset并不跨越分区,也就是说,Kafka保证的是分区有序而不是主题有序

Consumer Group(消费者组)指的是多个消费者实例共同组成一个组来消费一组主题。这组主题中的每个分区都只会被组内的一个消费者实例消费,其他消费者实例不能消费它

3、Replica

Kafka复习(一):基本概念、生产者、消费者

Kafka为分区引入了多副本(Replica)机制,同一分区的不同副本中保存的是相同的消息,通过增加副本数量可以提升容灾能力

副本分为领导者副本(Leader Replica)和追随者副本(Follower Replica)。Leader副本负责处理读写请求(生产写入消息总是向Leader副本写消息;消费者总是从Leader副本读消息),Follower副本只负责与Leader副本的消息同步

副本处于不同的Broker中,当Leader副本出现故障时,从Follower副本中重新选举新的Leader副本对外提供服务,通过多副本机制实现了故障的自动转移

4、ISR、OSR、AR

分区中所有副本统称为AR( Assigned Replicas)。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步。所有与leader副本保持一定程度同步的副本(包括leader副本)组成ISR(In-Sync Replicas),与leader副本滞后过多的副本组成OSR(Out-of-Sync Replicas)。AR=ISR+OSR

leader副本负责维护和跟踪ISR集合中所有follower副本的滞后状态,当follower副本落后太多或失效时,leader副本会把它从ISR集合中剔除。如果OSR集合中有follower副本追上了leader副本,那么leader副本会把它从OSR集合中转移至ISR集合。默认情况下,当leader副本发生故障时,只有在ISR集合中的副本才有资格被选举为新的leader

5、HW、LEO

HW(High Watermark,高水位)表示了一个特定的消息偏移量(offset),消费者只能拉取到这个offset之前的消息

Kafka复习(一):基本概念、生产者、消费者

上图代表一个日志文件,这个日志文件中有9条消息,第一条消息的offset为0,最后一条消息的offset为8,offset为9的消息用虚线框表示,代表下一条待写入的消息。日志文件的HW为6,表示消费者只能拉取到offset在0至5之间的消息,而offset为6的消息对消费者而言是不可见的

LEO(Log End Offset)标识当前日志文件中下一条待写入消息的offset,LEO的大小相当于当前日志分区中最后一条消息的offset值+1

案例

Kafka复习(一):基本概念、生产者、消费者

假设某个分区的ISR集合中有3个副本,即一个leader副本和2个follower副本,此时分区的LEO和HW都为3。消息3和消息4从生产者发出之后会被先存入leader副本

Kafka复习(一):基本概念、生产者、消费者

在消息写入leader副本之后,follower副本会发送拉取请求来拉取消息3和消息4以进行消息同步

Kafka复习(一):基本概念、生产者、消费者

在某一时刻follower1完全跟上了leader副本而follower2只同步了消息3,此时leader副本的LEO为5,follower1的LEO为5,follower2的LEO为4,那么当前分区的HW取最小值4,此时消费者可以消费到offset为0至3之间的消息

Kafka复习(一):基本概念、生产者、消费者

当所有的副本都成功写入了消息3和消息4,整个分区的HW和LEO都变为5,因此消费者可以消费到offset为4的消息了

二、生产者

1、分区器

1)、为什么分区?

Kafka的消息组织方式是三级结构:主题-分区-消息。主题下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份

Kafka复习(一):基本概念、生产者、消费者

分区的作用就是提供负载均衡的能力,实现系统的高伸缩性。不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。还可以通过添加新的节点机器来增加整体系统的吞吐量

2)、分区策略

如果生产者在发送消息的时候指定了分区就直接发送到该分区,如果没有指定分区就由分区策略是决定生产者将消息发送到哪个分区

如果要自定义分区策略,需要实现org.apache.kafka.clients.producer.Partitioner接口,然后显示地配置生产者端的参数partitioner.class为该实现类的全限定名

public interface Partitioner extends Configurable, Closeable {

    //计算给定记录的分区
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

1)默认生产者分区策略

Java客户端默认生产者分区策略的实现类为org.apache.kafka.clients.producer.internals.DefaultPartitioner,在kafka-clients 2.4.0版本默认生产者分区策略有了较大改动,所以这里我们分别讲解kafka-clients版本<2.4.0和=2.4.0的具体实现

kafka-clients版本<2.4.0

如果指定了key,就按照key的hash值选择分区;如果没有指定key就使用轮询策略。而且如果指定了key,那么计算得到的分区号会是所有分区中的任意一个;如果没有指定key并且有可用分区时,那么计算得到的分区号仅为可用分区中的任意一个

public class DefaultPartitioner implements Partitioner {

    private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();

    public void configure(Map<String, ?> configs) {}

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (keyBytes == null) {
            int nextValue = nextValue(topic);
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                // no partitions are available, give a non-available partition
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            // hash the keyBytes to choose a partition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    private int nextValue(String topic) {
        AtomicInteger counter = topicCounterMap.get(topic);
        if (null == counter) {
            counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
            AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
            if (currentCounter != null) {
                counter = currentCounter;
            }
        }
        return counter.getAndIncrement();
    }

    public void close() {}

}

kafka-clients版本=2.4.0

如果没有指定partition但是指定了key,就按照key的hash值选择分区;如果partition和key都没有指定会使用黏性分区策略

public class DefaultPartitioner implements Partitioner {

    private final StickyPartitionCache stickyPartitionCache = new StickyPartitionCache();

    public void configure(Map<String, ?> configs) {}

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        if (keyBytes == null) {
            return stickyPartitionCache.partition(topic, cluster);
        } 
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        // hash the keyBytes to choose a partition
        return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }

    public void close() {}
    
    public void onNewBatch(String topic, Cluster cluster, int prevPartition) {
        stickyPartitionCache.nextPartition(topic, cluster, prevPartition);
    }
}

2)黏性分区策略

Producer端的延时通常被定义为:Producer发送消息到Kafka响应消息之间的时间间隔

每个Kafka Topic包括若干个分区。当Producer给一个Topic发送消息时,首先需要确认这条消息要发送到哪个分区上。如果同时给同一个分区发送多条消息,那么Producer可以将这些消息打包成批(batch)发送。小batch会导致Producer端产生更多的请求,由于一个KafkaProducer只有一个sender线程,造成更严重的队列积压效果,从而整体上推高了延时

当batch大小达到了阈值(batch.size,默认值是16KB)或batch积累消息的时间超过了linger.ms(默认值是0毫秒)时就会立即发送batch

决定batch如何形成的一个因素是分区策略。如果多条消息不是被发送到相同的分区,它们就不能被放入到一个batch中。kafka-clients 2.4.0之前,如果partition和key都没有指定时,会将消息以轮询的方式发送到每一个分区上。这种策略打包效果很差,在实际使用中会增加延时

由于小batch可能导致延时增加,之前对于无Key消息的分区策略效率很低。Kafka社区于2.4版本引入了黏性分区策略(Sticky Partitioning Strategy),解决了无Key消息分散到小batch的问题,能够显著地降低给消息指定分区过程中的延时

黏性分区策略是选择单个分区发送所有的无Key消息。一旦这个分区的batch已满或处于已完成状态,黏性分区器会随机地选择另一个分区并会尽可能地坚持使用该分区——即所谓的粘住这个分区。拉长整个运行时间,消息还是能均匀地发布到各个分区上,避免出现分区倾斜,同时Producer还能降低延时,因为这个分配过程中始终能确保形成较大的batch,而非小batch

Kafka复习(一):基本概念、生产者、消费者

Kafka Producer发送分区策略小结

  1. 如果指定分区,则发送消息至指定分区
  2. 如果未指定分区但指定了key,会按照key的hash值发送消息至对应分区
  3. 如果未指定分区也没指定key,kafka-clients版本<2.4.0会按照轮询策略发送到每个分区;kafka-clients版本=2.4.0会采用黏性分区策略

2、实现原理

1)、整体架构

Kafka复习(一):基本概念、生产者、消费者

整个生产者客户端由主线程和Sender线程(发送线程)这两个线程协调运行。在主线程中由KafkaProducer创建消息,然后通过可能的拦截器、序列化器和分区器作用之后缓存到RecordAccumulator(消息累加器或消息收集器)中。Sender线程负责从RecordAccumulator中获取消息并将其发送到Kafka中

1)消息累加器RecordAccumulator

RecordAccumulator主要用来缓存消息以便Sender线程可以批量发送,从而减少网络传输的资源消耗以提升性能。RecordAccumulator缓存的大小可以通过参数buffer.memory配置,默认32MB。如果生产者发送消息的速度超过发送到服务器的速度,则会导致生产者空间不足,这个时候KafkaProducer的send()方法调用要么被阻塞,要么抛出异常,这个取决于参数max.block.ms,默认为60秒

主线程中发送过来的消息都会被追加到RecordAccumulator的某个双端队列中,在RecordAccumulator的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即Deque<ProducerBatch>。消息写入缓存时,追加到双端队列的尾部;Sender读取消息时,从双端队列的头部读取

ProducerRecord是生产者中创建的消息,而ProducerBatch是指一个消息批次。ProducerBatch中可以包含一至多个ProducerRecord,将较小的ProducerRecord拼凑成一个较大的ProducerBatch可以减少网络请求的次数以提升整体的吞吐量。ProducerBatch的大小可以通过参数batch.size配置,默认16KB

当一条ProducerRecord流入RecordAccumulator时,先会寻找到与消息分区所对应的双端队列(如果没有则新建),再从这个双端队列的尾部获取一个ProducerBatch(如果没有则新建),查看ProducerBatch中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可以则需要创建一个新的ProducerBatch

2)Sender线程

Sender从RecordAccumulator中获取缓存的消息之后,会进一步将原本<分区,Deque<ProducerBatch>>的保存形式转变成<Node,List<ProducerBatch>>的形式,其中Node表示Kafka集群的broker节点。之后还会进一步封装成<Node,Request>的形式,这样就可以将Request请求发往各个Node了

请求在从Sender线程发往Kafka之前还会保存到InFlightRequests中,InFlightRequests保存对象的具体形式为Map<NodeId, Deque<Request>>,它的主要作用是缓存了已经发出去但还没有收到响应的请求。InFlightRequests可以限制每个连接最多缓存的请求数,max.in.flight.requests.per.connection默认值为5,即每个连接最多只能缓存5个未响应的请求,超过该数值之后不能再向这个连接发送更多的请求了,除非有缓存的请求收到了响应

2)、元数据的更新

InFlightRequests可以获得leastLoadedNode,即所有Node中负载最小的那个,这里的负载最小是通过每个Node在InFlightRequests中还未确认的请求决定的,未确认的请求越多则认为负载越大

Kafka复习(一):基本概念、生产者、消费者

对于上图的InFlightRequests来说,Node1的负载最小,Node1为当前的leastLoadedNode。选择leastLoadedNode发送请求可以使它能够尽快发出,避免网络拥塞等异常而影响整体的进度

当需要更新元数据时,会先挑选出leastLoadedNode,然后向这个Node发送MetadataRequest请求来获取具体的元数据信息。这个更新操作是由Sender线程发起的,在创建完MetadataRequest之后同样会存入InFlightRequests,之后的步骤和发送消息时的类似。元数据虽然由Sender线程负责更新,但是主线程也需要读取这些信息

3、其他重要参数配置

1)、acks

这个参数用来指定分区中必须有多个副本收到这条信息,之后生产者才会认为这条消息是成功写入的

  • acks=1。默认值即为1。生产者发送消息之后,只要分区的leader副本成功写入消息,那么它就会收到来自服务端的成功响应。acks设置为1,是消息可靠性和吞吐量之间的折中方案
  • acks=0。生产者发送消息之后不需要等待任何服务端的响应。acks设置为0可以达到最大的吞吐量
  • acks=-1acks=all。生产者在消息发送之后,需要等待ISR中的所有副本都成功写入消息之后才能够收到来自服务端的成功响应。acks设置为-1可以达到最强的可靠性

2)、max.request.size

这个参数用来限定生产者客户端能发送的消息的最大值,默认值为1MB

3)、retriesretry.backoff.ms

retries参数用来配置生产者重试的次数,默认值为0,即在发生异常的时候不进行任何重试动作

retry.backoff.ms参数的默认值为100,它用来设定两次重试之间的时间间隔,避免无效的频繁重试

4)、compression.type

这个参数用来指定消息的压缩方式,默认值为none,即在默认情况下,消息不会被压缩。该参数还可以配置为gzip、snappy和lz4。对消息进行压缩可以极大地减少网络传输量、降低网络I/O,从而提升整体的性能

5)、connections.max.idle.ms

这个参数用来指定在多久之后关闭闲置的连接,默认值为9分钟

6)、linger.ms

生产者客户端会在ProducerBatch被填满或等待时间超过linger.ms(默认值0)值时发送出去

7)、request.timeout.ms

这个参数用来配置Producer等待请求响应的最长时间,默认为30秒

三、消费者

1、消费者组

1)、什么是Consumer Group

Consumer Group是Kafka提供的可扩展且具有容错性的消费者机制,主要特性如下:

  1. 一个消费者组内可以有多个消费者实例
  2. 消费者组的唯一标识被称为Group ID,组内的消费者共享这个公共的ID
  3. 消费者组订阅主题,主题的每个分区只能被组内的一个消费者消费

2)、两种消息投递模型

对于消息中间件而言,有两种消息投递模型:点对点(P2P)模型和发布/订阅(Pub/Sub)模型

  • 点对点模型:基于队列的,消息生产者发送消息到队列,消息消费者从队列中接收消息。每条消息只能只会被一个消费者消费
  • 发布/订阅模型:有主题的概念,主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,消息订阅者从主题中订阅消息。发布/订阅模型在消息的一对多广播时采用

Kafka同时支持两种消息投递模型:

  • 如果所有的消费者都属于同一个消费者组,那么所有的消息都会被均衡地投递给每一个消费者,即每条消息只会被一个消费者处理,这就相当于点对点模型
  • 如果所有的消费者都属于不同的消费者组,那么所有的消息都会被广播给所有的消费者,即每条消息会被所有的消费者处理,这就相当于发布/订阅模型

3)、消费组中的实例与分区的关系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fFRRRANO-1624800534340)(./images/消费组中的实例与分区的关系.png)]

如上图,消费者组内有3个消费者,订阅了一个包含7个分区的主题,3个消费者按照上图方式各自负责消费所分配到的分区

理想情况下,消费者实例的数量应该等于该消费者组订阅主题的分区总数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gFWNBuaq-1624800534341)(./images/消费组中的实例与分区的关系2.png)]

如果消费者实例数量大于分区总数的话,有的消费者将会永远处于空闲状态。上如图一个有8个消费者,7个分区,那么最后的消费者C7由于分配不到任何分区而无法消费任何消息

2、位移主题

1)、什么是位移主题

Kafka老版本消费者的位移管理是依托于Zookeeper的,它会自动或手动地将位移数据提交给Zookeeper中保存。当消费者重启后,它能自动从Zookeeper中读取位移数据,从而在上次消费截止的地方继续消费。这样设计的好处是减少了Broker端状态保存开销,但Zookeeper其实并不适用于这种高频的写操作,这种大吞吐量的写操作极大的拖慢了Zookeeper集群的性能

新版本消费者的位移管理是将消费者的位移数据作为一条普通的Kafka消息,提交到位移主题__consumer_offsets中。__consumer_offsets的主要作用是保存Kafka消费者的唯一信息。使用Kafka主题的特性实现了消费者的位移提交过程需要的高持久性和高频写操作

2)、特点

位移主题是一个普通主题,同样可以被手动创建、修改、删除

位移主题的消息格式是Kafka自己定义的,不能随意地向这个主题写消息,一旦写入的消息不满足Kafka规定的格式,就会造成Broker的崩溃。Kafka Consumer有API帮助提交位移,也就是向位移主题写消息

Kafka复习(一):基本概念、生产者、消费者

消费位移对应的内容格式如上图,key保存了Group ID、主题名、分区号,value中保存了offset

3)、何时创建

当Kafka集群中的第一个Consumer程序启动时,Kafka会自动创建位移主题,默认该主题的分区数是50(通过offsets.topic.num.partitions参数配置),副本数是3(通过offsets.topic.replication.factor

4)、位移提交

Kafka Consumer提交位移时会写入位移主题,提交位移的方式包括:自动提交和手动提交位移

Consumer端有个参数enable.auto.commit,如果值为true,则Consumer在后台定期提交位移,提交间隔由参数auto.commit.interval.ms来控制

设置enable.auto.commit为false时,需要调用consumer.commitSync等API进行位移提交

自动提交位移存在一个问题:只要Consumer一直启动着,就会无限期地向位移主题写入消息

假设Consumer当前消费到了某个主题的最新一条消息,位移是100,之后该主题没有任何新消息产生,故Consumer无消息可消费了,所以位移永远保持在100。由于是自动提交位移,位移主题中会不停地写入位移=100的消息。显然Kafka只需要保留这类消息中的最新一条就可以了,之前的消息都是可以删除的。这就要求Kafka必须要有针对位移主题消息特点的消息删除策略,否则这种消息会越来越多,最终撑爆整个磁盘

5)、Compact

Kafka使用Compact策略来删除位移主题中的过期消息,避免该主题无限期膨胀。对于同一个Key的两条消息M1和M2,如果M1的发送时间早于M2,那么M1就是过期消息。Compact的过程就是扫描日志的所有消息,剔除那些过期的消息,然后把剩下的消息整理在一起

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9lSXLBiO-1624800534342)(./images/Compact.png)]

上图中位移0、2、3的消息的Key都为K1。Compact之后,分区只需要保存位移为3的消息,因为它是最新发送的

Kafka提供了专门的后台线程Log Cleaner定期地巡检待Compact的主题,看看是否存在满足条件的可删除数据

3、消费者组的重平衡

1)、什么是重平衡

Rebalance就是让一个Consumer Group下的所有Consumer实例就如何消费订阅主题的所有分区达成共识的过程

Kafka复习(一):基本概念、生产者、消费者

如上图,假设目前某个Consumer Group下有两个Consumer,比如A和B,当第三个成员C加入时,Kafka会触发Rebalance,并根据默认的分配策略重新为A、B、C分配分区。Rebalance之后的分配依然是公平的,每个Consumer实例都获取了2个分区的消费权

在Rebalance过程中,所有Consumer实例共同参与,在协调者组件的帮助下,完成主体分区的分配。但是,在整个Rebalance过程中,所有实例都不能消费任何消息,因此它对Consumer的TPS影响很大

2)、协调者

Coordinator(协调者)是专门为Consumer Group服务,负责为Group执行Rebalance以及提供位移管理和组成员管理等

Consumer端在提交位移时其实是向Coordinator所在的Broker提交位移。当Consumer应用启动时,也是向Coordinator所在的Broker发送各种请求,然后由Coordinator负责执行消费者组的注册、成员管理记录等元数据管理操作。所有Broker都有各自的Coordinator组件

Kafka为某个Consumer Group确定Coordinator所在的Broker的算法分为两步:

  1. 确定由位移主题的哪个分区来保存该Group数据:partitionId=Math.abs(groupId.hashCode() % offsetsTopicPartitionCount),groupId的哈希值和位移主题__consumer_offsets的分区数(默认50个分区)取模
  2. 找出该分区Leader副本所在的Broker,该Broker即为对应的Coordinator

3)、重平衡的时机

Rebalance的触发条件有3个:

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

4)、消费者分区分配策略

Kafka提供了消费者客户端参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。默认为org.apache.kafka.clients.consumer.RangeAssignor分配策略,此外,Kafka还提供了另外两种分配策略:RoundRobinAssignor和StickyAssignor

1)RangeAssignor分配策略

RangeAssignor是对每个Topic而言的(即一个Topic一个Topic分),首先对同一个Topic里面的分区按照序号进行排序,并对消费者按照字母顺序进行排序。然后用Partitions分区的个数除以消费者的总数来决定每个消费者线程消费几个分区。如果除不尽,那么前面几个消费者线程将会多消费一个分区。

假设n=分区数/消费者数量,m=分区数%消费者数量,那么前m个消费者每个分配n+1个分区,后面的(消费者数量-m)个消费者每个分配n个分区

案例

假设消费者组内有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有4个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:

消费者C0:t0p0、t0p1、t1p0、t1p1
消费者C1:t0p2、t0p3、t1p2、t1p3

假设2个主题都只有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:

消费者C0:t0p0、t0p1、t1p0、t1p1
消费者C1:t0p2、t1p2

对于每个topic而言,消费者C0比消费者C1多消费1个分区。如果有N多个topic,消费者C0会比消费者C1多消费N个分区。这就是RangeAssignor分配策略的一个很明显的弊端了

2)、RoundRobinAssignor分配策略

RoundRobinAssignor策略的原理是将消费组内所有消费者以及消费者所订阅的所有topic的partition按照字典序排序,然后通过轮询方式逐个将分区以此分配给每个消费者

如果同一个消费者组内所有的消费者的订阅信息都是相同的,那么RoundRobinAssignor分配策略的分区分配会是均匀的

案例

假设消费者组中有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终分配结果为:

消费者C0:t0p0、t0p2、t1p1
消费者C1:t0p1、t1p0、t1p2

如果同一个消费者组内的消费者订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询分配,有可能导致分区分配得不均匀

案例

假设消费者组内有3个消费者,它们共同订阅了3个主题,这3个主题分别有1、2、3个分区,即整个消费者组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。消费者C0订阅的是主题t0,消费者C1订阅的是主题t0和t1,消费者C2订阅的是主题t0、t1和t2,那么最终的分配结果为:

消费者C0:t0p0
消费者C1:t1p0
消费者C2:t1p1、t2p0、t2p1、t2p2

3)、StickyAssignor分配策略

StickyAssignor分配策略,sticky翻译为黏性的,Kafka引入这种分配策略,主要有两个目的:

  1. 分区的分配要尽可能均匀
  2. 分区的分配尽可能与上次分配的保持相同

当两者发生冲突时,第一个目标优先于第二个

案例

假设消费者组内有3个消费者,都订阅了4个主题,并且每个主题有2个分区。也就是说,整个消费者组订阅了t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1这8个分区。最终的分配结果如下:

消费者C0:t0p0、t1p1、t3p0
消费者C1:t0p1、t2p0、t3p1
消费者C2:t1p0、t2p1

看上去与采用RoundRobinAssignor分配策略所分配的结果相同

假设此时消费者C1脱离了消费者组,那么消费者组就会执行重平衡操作,重新分配消费分区。如果采用RoundRobinAssignor分配策略,分配结果如下:

消费者C0:t0p0、t1p0、t2p0、t3p0
消费者C2:t0p1、t1p1、t2p1、t3p1

使用StickyAssignor分配策略,分配结果如下:

消费者C0:t0p0、t1p1、t3p0、t2p0
消费者C2:t1p0、t2p1、t0p1、t3p1

分配结果中保留上上一次分配中对消费者C0和C2的所有分配结果,并将原来消费者C1的负担分配给了剩余的两个消费者C0和C2,最终C0和C2还保持了平衡

5)、如何避免重平衡

在某些情况下,Consumer实例会被Coordinator错误地认为已停止从而被踢出Group。需要尽量避免由于这个原因导致的Rebalance

当Consumer Group完成Rebalance之后,每个Consumer实例都会定期地向Coordinator发送心跳请求,表明它还存活着。如果某个Consumer实例不能及时地发送这些心跳请求,Coordinator就会认为该Consumer实例已经挂了,从而将其从Group中移除,然后开启新一轮Rebalance

Consumer端的相关参数

  • session.timeout.ms:默认是10秒,即如果Coordinator在10秒内没有收到Group下某Consumer实例的心跳,就会认为这个Consumer实例已经挂了
  • heartbeat.interval.ms:控制心跳请求频率的参数,默认是3秒。这个值设置得越小,Consumer实例发送心跳请求的频率就越高。频繁地发送心跳请求会额外消耗带宽资源,但好处是能够更加快速地知晓当前是否开启Rebalance,因为目前Coordinator通知各个Consumer实例开启Rebalance的方法,就是将REBALANCE_NEEDED标志封装到心跳请求的响应体中
  • max.poll.interval.ms:限定了Consumer端两次调用poll方法的最大时间间隔,默认是5分钟。表示Consumer程序如果在5分钟之内无法消费完poll方法返回的消息,那么Consumer会主动发起离开组的请求,Coordinator也会开启新一轮Rebalance

第一类非必要Rebalance是因为未能及时发送心跳,导致Consumer被踢出Group而引发的。推荐优化以下参数:

  • 设置session.timeout.ms=6s
  • 设置heartbeat.interval.ms=2s
  • 要保证Consumer实例在被判定为挂掉之前,能够发送至少3轮的心跳请求,即session.timeout.ms>=3*heartbeat.interval.ms

第二类非必要Rebalance是Consumer消费时间过长导致的。将max.poll.interval.ms参数的值调大一些

4、位移提交

Consumer需要向Kafka汇报自己的位移数据,这个汇报过程被称为提交位移。因为Consumer能够同时消费多个分区的数据,所以位移的提交实际上是在分区粒度上进行的,即Consumer需要为分配给它的每个分区提交各自的位移数据

从用户的角度来说,位移提交分为自动提交和手动提交;从Consumer端的角度来说,位移提交分为同步提交和异步提交

1)、自动提交

自动提交是指Kafka Consumer在后台默默地提交位移,手动提交是指需要用户自己提交位移

Consumer参数enable.auto.commit,默认值为true,默认就是自动提交位移的。参数auto.commit.interval.ms默认是5秒,Kafka每5秒会自动提交一次位移

自动提交可能出现重复消费。在默认情况下,Consumer每5秒自动提交一次位移。假设提交位移之后的3秒发生了Rebalance操作。在Rebalance之后,所有Consumer从上一次提交的位移处继续消费,但该位移已经是3秒前的位移数据了,所以在Rebalance发生前3秒消费的所有数据都要重新再消费一次

2)、手动提交

手动提交需要显示设置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); //处理提交失败异常
            }
        }

在调用commitSync()时,Consumer程序会处于阻塞状态,直到远端的Broker返回提交结果,这个状态才会结束,会影响整个应用程序的TPS

3)、异步提交

KafkaConsumer.commitAsync()是一个异步操作。调用commitAsync()之后,它会立即返回,不会阻塞

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

commitAsync()的问题在于,出现问题时它不会自动重试。因为它是异步操作,如果提交失败后自动重试,那么它重试时提交的位移可能早已经过期或不是最新值了

如果是手工提交,需要将commitSync()commitAsync()组合使用:

  1. 利用commitSync()的自动重试来规避那些瞬时操作,比如网络的瞬时抖动、Broker端GC等
  2. 我们不希望程序总处于阻塞状态,影响TPS
        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();
            }
        }

4)、批次提交

如果poll方法一次返回5000条,不想把这5000条消息都处理完之后再提交位移,因为一旦中间出现差错,之前处理的全部都要重来一遍。每处理完100条消息就提交一次位移,这样能够避免大批量的消息重新消费

Kafka Consumer API为手动提交提供了这样的方法:commitSync(Map)commitAsync(Map)。它们的参数是一个Map对象,键就是TopicPartition,即消费的分区,而值是一个OffsetAndMetadata对象,保存的主要是位移数据

        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++;
            }
        }
上一篇:Kafka生产消费应用


下一篇:并发编程四(4) 线程同步 - Condition