深入理解Kafka系列(三)--Kafka消费者

系列文章目录

Kakfa权威指南系列文章

前言

本系列是我通读《Kafka权威指南》这本书做的笔录和思考。

正文

Kafka消费者

Kafka消费者和消费者群组

往往我们创建消费者对象,订阅主题并开始接受消息,然后再把他们验证保存起来。若生产者往主题中写入消息的速度远远大于应用程序验证数据的速度,并且只使用单消费者处理消息,那么应用程序会远跟不上消息生成的速度。因此产生了消费者群组的概念。
并且,Kafka消费者属于消费者群组。一个群组里面的消费者订阅的是同一个主题,每个消费者接受主题的一部分分区的消息。

单消费者情况:
在这里插入图片描述

多消费者情况:
在这里插入图片描述

但是如果说,消费者群组里面的消费者数量,超过了主题的分区数量,那么有一部分的消费者就会被闲置,不会接收到任何的消息,如图:
在这里插入图片描述

而Kafka就是通过往消费者群组里面增加消费者,来横向伸缩消费能力的。并且还要注意,每个消费者群组之间是不影响的
在这里插入图片描述


分区再均衡

群组需要注意的点:

  1. 群组里的消费者共同读取同一个主题的分区。
  2. 一个新的消费者加入的时候,他读取的是原本由其他消费者读取的消息。
  3. 当一个消费者关闭or崩溃的时候,代表其离开群组,原本由它本身读取的分区将由同组的其他消费者来读取。
  4. 若主题发生变化,比如增加新的分区,则发生分区再均衡。

再均衡需要注意的点:

扫描二维码关注公众号,回复: 12238544 查看本文章
  1. 再均衡的含义:分区的所有权从一个消费者转移到另外一个消费者。
  2. 再均衡期间,消费者无法读取消息,造成整个群组在短时间内不可用。
  3. 分区被重新分配给另外的分区的时候,消费者当前的读取状态会丢失,导致可能需要去刷新缓存,拖慢应用程序。
  4. 消费者通过向群组协调器发送信条来维持从属关系和消费者对分区的所有权关系。若协调器长时间没有收到消费者的信条,则认为该消费者死亡,触发再均衡。

Kafka消费者API

pom:

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>0.11.0.0</version>
</dependency>
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka_2.12</artifactId>
    <version>0.11.0.0</version>
</dependency>

消费者Demo

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.util.Collections;
import java.util.Properties;


public class MyConsumer {
    
    
    public static void main(String[] args) {
    
    
        Properties properties = new Properties();
        properties.put("bootstrap.servers", "192.168.237.130:9092");
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put("group.id", "test-consumer");
        // 1.创建KafkaConsumer
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
        // 2.订阅主题
        consumer.subscribe(Collections.singletonList("test"));
        // 3.轮询消费
        try {
    
    
            while (true) {
    
    
                ConsumerRecords<String, String> records = consumer.poll(100);
                for (ConsumerRecord<String, String> record : records) {
    
    
                    System.out.println("topic = " + record.topic() +
                            ", partition = " + record.partition() +
                            ", offset = " + record.offset() +
                            ", customer = " + record.key() +
                            ", country = " + record.value());
                }
            }
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            consumer.close();
        }
    }
}

启动后,在终端上输入命令:

./bin/kafka-console-producer.sh --topic test --broker-list 192.168.237.130:9092 

如图:
在这里插入图片描述
结果:
在这里插入图片描述


Kafka消费者参数详解

  1. fetch.min.bytes

1.该属性制定了消费者从服务器当中获取记录的最小字节数。
2.小于该属性大小的数据是不会从broker发送到消费者的。
3.因此如果这个值设置的稍微大一点,可以降低broker的工作负载。

  1. fetch.max.wait.ms

1.我们通过fetch.min.bytes告诉kafka,等到有足够量的数据的时候,才把数据发回给消费者。
2.而fetch.max.wait.ms则用于指定broker的等待时间,默认是500ms。

  1. max.partition.fetch.bytes

1.该属性指定了服务器从每个分区里面返回给消费者数据的最大字节数。
2.默认值是1MB。
3.换句话说:KafkaConsumer.poll()方法从每个分区里面返回的记录最多不超过max.partition.fetch.bytes指定的字节。
4.假设一个topic有20个分区,有5个消费者。那么每个消费者在默认清空下,必须准备至少4MB大小的内存空间去接收记录。

  1. session.timeout.ms

1.该属性制定了消费者在被认定为死亡之前,可以与服务器断开连接的最长时间,默认3s。
2.换句话说:如果消费者在3s内没有发送心跳给群组协调器,则被认为死亡。

  1. auto.offset.reset

1.该属性指定了消费者在读取一个没有偏移量的分区或者偏移量无效的情况下该如何处理。一共有两个值
latest:在偏移量无效的时候,消费者将从最新的记录开始读取数据。
earliest:在偏移量无效的时候,消费者将从起始位置开始读取分区记录。

  1. enable.auto.commit

1.该属性指定了消费者是否自动提交偏移量,默认true。
2.为了尽量避免出现重复数据或者数据丢失(本质其实就是offset没有提交或者提交两次),我们可以把它设置为false。

  1. partition.assignment.strategy

1.Kafka会根据给定的消费者和主题,来决定哪些分区应该被分配给哪些消费者。而这个属性就是指定了对应的分区策略。
2.Kafka有2个默认的分配策略。
Range和RoundRobin(详细的在下面介绍)

  1. client.id

1.该属性可以是任意字符串,broker用它来标识从client发送过来的消息。

  1. max.poll.records

1.该属性用于控制单词调用call()方法能够返回的记录数量。

  1. receive.buffer.bytes和send.buffer.bytes

1.表示socket再读写数据时用到的TCP缓冲区大小。
2.如果被设置为-1,那么使用操作系统的默认值。


Kafka分区解释

  1. Range

1.首先对同一个主题里的分区按照序号进行排序。对于消费者按照字母顺序排序。
例子:
10个分区:0-9号
3个消费者线程:c1-0, c2-0, c2-1
那么每个线程消费多少个分区呢?假设分区数m个,消费者数量n个。
公式:c=m/n,如果有余数,按照消费者顺序依次加一个分区。
那例题中最终的结果为:
c1-0将消费0,1,2,3分区。
c2-0将消费4,5,6分区。
c2-1将消费7,8,9分区。

2.这种分配策略有一个缺点:上面的例子是一个topic情况下的,大家可以看出来,消费者c1-0比别的消费者要多消费一个分区。那如果说topic有1000个(每个topic的分区结果是一样的),那消费者c1-0就比别的消费者多消费1000个分区。会让他的压力增大。

  1. RoundBobin

也就是所谓轮询分配。
该策略有两个前提:
1.同一个Consumer Group中所有Consumer的num.streams需要相等。
2.每个Consumer消费的topic需要相同。

实现的概括:
1.将所有分区的消费者列出来,求hashcode值,并排序。
2.通过轮询的方式分配分区。


提交和偏移量

提交

每次调用poll()方法,他总是返回由生产者写入Kafka但还没有被消费者读取过的记录,换句话说,我们可以跟踪到哪些记录是被群组里的哪个消费者读取过的。

  1. 偏移量(offset)消息在分区里的位置
  2. 提交:跟新分区当前位置的操作

如果提交的偏移量A<客户端处理的最后一个消息的偏移量B(消息是按顺序消费的),那么处于A和B之间的消息就会被重复处理:
在这里插入图片描述

如果提交的偏移量A>客户端处理的最后一个消息的偏移量B,那么处于A和B之间的消息就会被丢失:
在这里插入图片描述

提交的方式

自动提交

1.自动提交也是最简单的方式。需要设置enable.auto.commit=true。那么消费者会自动把poll()方法接收到的最大偏移量提交上去。
2.提交时间间隔由参数auto.commit.interval.ms控制,默认为5s。
3.需要注意:自动提交是在轮询当中进行的。每次轮询会把上一次调用返回的偏移量提交上去,但是他不知道具体哪些消息被处理了。
4.所以在再次调用之前,需要确保所有当前调用返回的消息都已经被处理完毕。

提交当前的偏移量。

try {
    
    
    while (true) {
    
    
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
    
    
            System.out.println("topic = " + record.topic() +
                    ", partition = " + record.partition() +
                    ", offset = " + record.offset() +
                    ", customer = " + record.key() +
                    ", country = " + record.value());
        }
        try {
    
    
            consumer.commitSync();
        } catch (CommitFailedException e) {
    
    
            e.printStackTrace();
        }
    }
} catch (Exception e) {
    
    
    e.printStackTrace();
} finally {
    
    
    consumer.close();
}

1.也就是代码里的consumer.commitSync();他会提交由poll()返回的最新偏移量,所以在使用这种方式的时候,在处理完所有记录后要确保调用了commitSync(),否则还是会有丢失消息的风险。
2.如果发生了再均衡,从最近一批消息到发生再均衡之间的所有消息都会被重复处理。
3.只要没有发生不可恢复的错误,commitSync会一直尝试直至提交偏移量成功。

异步提交

try {
    
    
    while (true) {
    
    
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
    
    
            System.out.println("topic = " + record.topic() +
                    ", partition = " + record.partition() +
                    ", offset = " + record.offset() +
                    ", customer = " + record.key() +
                    ", country = " + record.value());
        }
        try {
    
    
            consumer.commitAsync();
        } catch (CommitFailedException e) {
    
    
            e.printStackTrace();
        }
    }
} catch (Exception e) {
    
    
    e.printStackTrace();
} finally {
    
    
    consumer.close();
}

1.手动提交有一个不足之处,在broker对提交请求做出回应之前,程序会一直堵塞。,这样会限制应用程序的吞吐量,因此我们可以通过降低提交的频率来提升吞吐量。但是如果发生了再均衡,会增加重复消息的数量。
2.使用commitAsync时,我们只管发送提交请求,无需等待broker的相应。
3.但是,commitAsync在成功提交之前,是不会一直重试的。
原因:
1.假设我们发出一个请求,用于提交偏移量2000.但这个时候发生了通讯故障,服务器没收到请求,自然不会做出任何相应。
2.同时,我们处理了另外一批消息,并且成功提交了偏移量3000.
3.那加入我们commitAsync重新尝试提交偏移量2000,他可能发生在提交偏移量3000成功之后。若此时发生再均衡,会出现重复消费。

我们commitAsync也支持回调,在broker做出响应的时候执行回调。

while (true) {
    
    
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> record : records) {
    
    
        System.out.println("topic = " + record.topic() +
                ", partition = " + record.partition() +
                ", offset = " + record.offset() +
                ", customer = " + record.key() +
                ", country = " + record.value());
    }
    try {
    
    
        consumer.commitAsync(new OffsetCommitCallback() {
    
    
            @Override
            public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception e) {
    
    
                   if (e != null) {
    
    
                   // 如果失败,则记录下偏移量和错误信息。
                       log.error("error commit!{}", offsets, e);
                   }
            }
        });
    } catch (CommitFailedException e) {
    
    
        e.printStackTrace();
    }
}

同步和异步组合提交

一般情况下,我们对于偶尔出现的提交失败的情况,不进行重试没有太大的问题。但是如果在关闭消费者之前进行最后一次提交,就要确保能够提交成功。
因此一般会组合使用commitAsync和commitSync。

try {
    
    
    while (true) {
    
    
        ConsumerRecords<String, String> records = consumer.poll(100);
        for (ConsumerRecord<String, String> record : records) {
    
    
            System.out.println("topic = " + record.topic() +
                    ", partition = " + record.partition() +
                    ", offset = " + record.offset() +
                    ", customer = " + record.key() +
                    ", country = " + record.value());
        }
        consumer.commitAsync();
    }
} catch (Exception e) {
    
    
    e.printStackTrace();
} finally {
    
    
    try {
    
    
        consumer.commitSync();
    } finally {
    
    
        consumer.close();
    }
}

这种方式有两个好处:

  1. 如果一切正常,我们使用commitAsync()方法来提交,这样速度更快。即使当前提交失败,也可能下一次提交成功。
  2. 如果直接关闭消费者,就没有所谓的下一次提交了,所以我们在关闭之前,调用一次commitSync(),并且会重复尝试,直至提交成功or报Error。

如何提交特定的偏移量(Demo)

// 改变下while循环的部分,用map去记录offset偏移量的位置。
// 在类当中添加以下的属性:
private HashMap<TopicPartition, OffsetAndMetadata> curOffsets = new HashMap<>();
int count = 0;
// 方法里面的部分代码
while (true) {
    
    
    ConsumerRecords<String, String> records = consumer.poll(100);
    for (ConsumerRecord<String, String> record : records) {
    
    
        System.out.println("topic = " + record.topic() +
                ", partition = " + record.partition() +
                ", offset = " + record.offset() +
                ", customer = " + record.key() +
                ", country = " + record.value());
        curOffsets.put(new TopicPartition(record.topic(), record.partition()),
                new OffsetAndMetadata(record.offset() + 1, "no metadata"));
        if (count % 1000 == 0) {
    
    
            consumer.commitAsync(curOffsets, null);
        }
        count++;
    }
    consumer.commitAsync();
}

意思就是,我们每处理1000条消息,就提交一次偏移量。当然,在实际应用中,我们一般根据时间或者记录的内容进行提交。


如何从特定偏移量处开始处理记录(伪代码)

在上面的例子当中,我们用的都是poll()方法对吧,我还说过,poll()方法是从各个分区的最新偏移量处开始处理消息。 但是有时候,我们也需要从特定的偏移量处开始读取消息。
Demo(伪代码)
自定义的消费者再均衡监听器

public class SaveOffsetsOnRebalance implements ConsumerRebalanceListener {
    
    
	private KafkaConsumer<String, String> consumer;

    public SaveOffsetsOnRebalance(KafkaConsumer<String, String> consumer) {
    
    
        this.consumer = consumer;
    }
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> collection) {
    
    
        // 假设这是个提交数据库事务的方法
        // 我们需要把保存记录和偏移量放到一个事务当中。则记录和偏移量要么都成功提交,要么都不提交。
        // 假设我们把便宜啦保存在数据库里,如Mysql。
        commitDBTransaction();
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
    
    
        for (TopicPartition partition : partitions) {
    
    
            // getOffsetFromDB()方法,参数为partition分区,可以获得对应分区当中偏移量。
            // 使用一个虚构的方法来从数据库中获取偏移量,再分配到新分区的时候,使用了seek方法定位到那些对应的记录。
            consumer.seek(partition,getOffsetFromDB(partition));
        }
    }
}

代码:

Properties properties = new Properties();
    properties.put("bootstrap.servers", "192.168.237.130:9092");
    properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    properties.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
    properties.put("group.id", "test-consumer");
    // 1.创建KafkaConsumer
    KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
    // 2.订阅主题
    consumer.subscribe(Collections.singletonList("test"), new SaveOffsetsOnRebalance(consumer));
    // 订阅主题后,开启消费者,第一次调用poll()方法,让消费者加入到消费者群组(此时发生再均衡),并获取到分配到的分区。
    // 然后马上调用seek方法定位分区的偏移量,
    consumer.poll(0);
    for (TopicPartition partition : consumer.assignment()) {
    
    
        consumer.seek(partition, getOffsetFromDB(partition));
    }
    // 3.轮询消费
    try {
    
    
        while (true) {
    
    
            ConsumerRecords<String, String> records = consumer.poll(100);
            for (ConsumerRecord<String, String> record : records) {
    
    
                System.out.println("topic = " + record.topic() +
                        ", partition = " + record.partition() +
                        ", offset = " + record.offset() +
                        ", customer = " + record.key() +
                        ", country = " + record.value());
                processRecord(record);
                // 往mysql中存储对应的记录,和topic、分区、偏移量
                storeRecordInDB(record);
                storeOffsetInDB(record.topic(), record.partition(), record.offset());

            }
            // 虚构的代码:用于提交数据库事务
            commitDBTransaction();
        }
    } catch (Exception e) {
    
    
        e.printStackTrace();
    } finally {
    
    
        try {
    
    
            consumer.commitSync();
        } finally {
    
    
            consumer.close();
        }
    }
}

小总结(大致思路):

  1. 使用一个虚构的方法来提交数据库事务。在处理完记录后,将记录和偏移量插入到数据库中,在即将失去分区所有权之前提交事务,确保保存数据成功。
  2. 使用另一个虚构的方法来获取数据库中的偏移量,再分配到新分区的时候,使用seek()定位到那些记录。
// 源码
// 给定你一个分区,对应的一个偏移量,seek就可以找到对应的位置。
public void seek(TopicPartition partition, long offset) {
    
    
    if (offset < 0L) {
    
    
        throw new IllegalArgumentException("seek offset must not be a negative number");
    } else {
    
    
        this.acquire();

        try {
    
    
            log.debug("Seeking to offset {} for partition {}", offset, partition);
            this.subscriptions.seek(partition, offset);
        } finally {
    
    
            this.release();
        }

    }
}
  1. 订阅到主题后,调用一次poll()方法,用于让消费者加入到消费者群组(有新的消费者加入到群组时,会发生再均衡),并获取到分配的分区。我们调用seek()方法区定位分区的偏移量。
  2. 每次消费数据时,更新数据库当中的数据(假设更新速度很快),但是提交偏移量速度比较慢,所以只在每个批次的末尾进行提交。

反序列化器

这里的自定义类依旧使用上一篇博客的类Customer

public class Customer {
    
    
    private int customerId;
    private String customerName;

    public Customer(int customerId, String customerName) {
    
    
        this.customerId = customerId;
        this.customerName = customerName;
    }

    public int getCustomerId() {
    
    
        return customerId;
    }

    public void setCustomerId(int customerId) {
    
    
        this.customerId = customerId;
    }

    public String getCustomerName() {
    
    
        return customerName;
    }

    public void setCustomerName(String customerName) {
    
    
        this.customerName = customerName;
    }
}

自定义反序列化器:

import org.apache.kafka.common.serialization.Deserializer;
import java.nio.ByteBuffer;
import java.util.Map;

public class CustomerDeserializer implements Deserializer<Customer> {
    
    
    @Override
    public void configure(Map map, boolean b) {
    
    
        // 不需要任何配置
    }

    @Override
    public Customer deserialize(String topic, byte[] data) {
    
    
        int id, nameSize;
        String name;
        try {
    
    
            if (data == null) {
    
    
                return null;
            }
            if (data.length < 8) {
    
    
                System.out.println("数据太小");
            }
            ByteBuffer buffer = ByteBuffer.wrap(data);
            id = buffer.getInt();
            nameSize = buffer.getInt();
            byte[] nameBytes = new byte[nameSize];
            buffer.get(nameBytes);
            name = new String(nameBytes, "UTF-8");
            return new Customer(id, name);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public void close() {
    
    
        // 什么都不干
    }
}

生产者代码

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import java.util.Properties;

public class Test {
    
    
    public static void main(String[] args) {
    
    
        Properties properties = new Properties();
        // 这里的value,填你的自定义序列化器的引用地址。
        properties.put("bootstrap.servers", "192.168.237.130:9092");
        // 自定义的序列化器
        properties.put("key.serializer", "kafka.CustomerSerializer");
        properties.put("value.serializer", "kafka.CustomerSerializer");
        KafkaProducer<String, Customer> producer = new KafkaProducer<>(properties);
        Customer customer = new Customer(1, "hello");
        ProducerRecord record = new ProducerRecord("test", customer);
        try {
    
    
            producer.send(record);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            producer.close();
        }
    }
}

消费者代码

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.util.Collections;
import java.util.Properties;

public class Test2 {
    
    
    public static void main(String[] args) {
    
    
        Properties properties = new Properties();
        properties.put("bootstrap.servers", "192.168.237.130:9092");
        properties.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        // 自定义的反序列化器
        properties.put("value.deserializer", "kafka.CustomerDeserializer");
        properties.put("group.id", "test-consumer");
        KafkaConsumer<String, Customer> consumer = new KafkaConsumer<>(properties);
        consumer.subscribe(Collections.singleton("test"));
        while (true) {
    
    
            ConsumerRecords<String, Customer> records = consumer.poll(100);
            for (ConsumerRecord<String, Customer> record : records) {
    
    
                System.out.println("current customer id:" + record.value().getCustomerId()
                        + "current customer name:" + record.value().getCustomerName());
            }
        }
    }
}

启动顺序:先启动消费者,再启动生产者。
结果:
在这里插入图片描述

当然,这里还是强调一下,自定义反序列化器和自定义序列化器一样,都不建议大家使用,原因在生产者篇已经解释过了。推荐还是用一些序列化工具,如JSON、Protobuf等。


总结

本文大概从这么几个方面进行概述:

  1. Kafka消费者和消费者群组的相关概念。
  2. 什么是分区和再均衡。
  3. 消费者API和相关的参数详解。
  4. 提交和偏移量的相关概念和不同的提交方式。
  5. 如何从特定的偏移量去读取消息:结合seek()和ConsumerRebalanceListener实现类。
  6. 反序列化器。

下一篇深入的介绍Kafka,从这几个方面去介绍:

  1. Kafka如何进行复制。
  2. Kafka如何处理来自生产者和消费者的请求。
  3. Kafka的存储细节,比如文件格式和索引。

猜你喜欢

转载自blog.csdn.net/Zong_0915/article/details/109443044