Kafka详解(一):Kafka基本概念、集群搭建、生产者与消费者

一、初始Kafka

1、基本概念

在这里插入图片描述
一个典型的Kafka体系架构包括若干Producer、若干Broker、若干Consumer以及一个ZooKeeper集群。其中ZooKeeper是Kafka用来负责集群元数据的管理、控制器的选举等操作的。Producer将消息发送到Broker,Broker负责将收到的消息存储到磁盘中,而Consumer负责从Broker订阅并消费消息

Kafka中的消息以主题为单位进行归类,生产者负责将消息发送到特定的主题,而消费者负责订阅主题并进行消费。主题是一个逻辑上的概念,它还可以细分为多个分区,一个分区只属于单个主题。同一主题下的不同分区包含的消息是不同的,分区在存储层面可以看作一个可追加的日志文件,消息在被追加到分区日志文件的时候都会分配一个特定的偏移量(offset)。offset是消息在分区中的唯一标识,Kafka通过它来保证消息在分区内的顺序性,不过offset并不跨越分区,也就是说,Kafka保证的是分区有序而不是主题有序
在这里插入图片描述
上图中,主题中有4个分区,消息被顺序追加到每个分区日志文件的尾部。Kafka中的分区可以分布在不同的服务器上,也就是说,一个主题可以横跨多个broker,以此来提供比单个broker更强大的性能

每一条消息被发送到broker之前,会根据分区规则选择存储到哪个具体的分区。如果分区规则设定得合理,所有的消息都可以被均匀地分配到不同的分区中。如果一个主题只对应一个文件,那么这个文件所在的机器I/O将会成为这个主题的性能瓶颈,而分区解决了这个问题

Kafka为分区引入了多副本机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息,副本之间是一主多从的关系,其中leader副本负责处理读写请求,follower副本只负责与leader副本的消息同步。副本处于不同的broker中,当leader副本出现故障时,从follower副本中重新选举新的leader副本对外提供服务。Kafka通过多副本机制实现了故障的自动转移,当Kafka集群中某个broker失效时仍然能保证服务可用
在这里插入图片描述
上图的Kafka集群中有4个broker,某个主题中有3个分区,且副本因子也是3,如此每个分区便有一个leader副本和2个follower副本。生产者和消费者只与leader副本进行交互,而follower副本只负责消息的同步,很多时候follower副本中的消息相对leader副本而言会有一定的滞后

Consumer使用拉模式从服务端拉取消息,并且保存消费的具体位置,当消费者宕机后恢复上线时可以根据之前保存的消费位置重新拉取需要的消息进行消费,这样就不会造成消息丢失

分区中的所有副本统称为AR。所有与leader副本保持一定程度同步的副本(包括leader副本在内)组成ISR,ISR集合是AR集合中的一个子集。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步,同步期间内follower副本相当于leader副本而言会有一定程度的滞后。与leader副本同步滞后过多的副本(不包括leader副本)组成OSR,AR=ISR+OSR,在正常情况下,所有的follower副本都应该与leader副本保持一定程度的同步,即AR=ISR,OSR集合为空

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

HW是High Watermark的缩写,俗称高水位,它标识了一个特定的消息偏移量(offset),消费者只能拉取这个offset之前的消息

LEO是Log End Offset的缩写,它标识当前日志文件中下一条待写入消息的offset
在这里插入图片描述
假设某个分区的ISR集合中有3个副本,即一个leader副本和2个follower副本,此时分区的LEO和HW都为3。消息3和消息4从生产者发出之后会被先存入leader副本,如上图所示
在这里插入图片描述
在消息写入leader副本之后,follower副本会发送拉取请求来拉取消息3和消息4以进行消息同步
在这里插入图片描述
在同步过程中,不同的follower副本的同步效率也不尽相同,如上图所示,在某一时刻follower1完全跟上了leader副本而follower2只同步了消息3,如此leader副本的LEO为5,follower1的LEO为5,follower2的LEO为4,那么当前分区的HW取最小值4,此时消费者可以消费到offset为0至3之间的消息
在这里插入图片描述
当所有的副本都成功写入了消息3和消息4,整个分区的HW和LEO都变为5,因此消费者可以消费到offset为4的消息了

Kafka的复制机制既不是完全的同步复制,也不是单纯的异步复制。Kafka使用的这种ISR的方式有效地权衡了数据可靠性和性能之间的关系

2、安装Kafka

1)、安装JDK

参考:https://blog.csdn.net/qq_40378034/article/details/86685680

2)、安装ZooKeeper

Kafka通过ZooKeeper来实施对元数据信息的管理,包括集群、broker、主题、分区等内容

ZooKeeper是一个开源的分布式协调服务,分布式应用程序可以基于ZooKeeper实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、配置维护等功能。在ZooKeeper中共有3个角色:leader、follower和observer,同一时刻ZooKeeper集群中只会有一个leader,其他的都是follower和observer。observer不参与投票,默认情况下ZooKeeper只有leader和follower两个角色

下载地址:https://archive.apache.org/dist/zookeeper/

[root@localhost ~]# cd /opt
[root@localhost opt]# ll
总用量 35812
-rw-r--r--. 1 root root 36667596 5月  20 09:01 zookeeper-3.4.12.tar.gz
[root@localhost opt]# tar zxf zookeeper-3.4.12.tar.gz 
[root@localhost opt]# cd zookeeper-3.4.12
[root@localhost zookeeper-3.4.12]# pwd
/opt/zookeeper-3.4.12
[root@localhost zookeeper-3.4.12]# vi /etc/profile

添加如下配置:

export ZOOKEEPER_HOME=/opt/zookeeper-3.4.12
export PATH=$PATH:$ZOOKEEPER_HOME/bin
[root@localhost zookeeper-3.4.12]# source /etc/profile
[root@localhost zookeeper-3.4.12]# cd conf/
[root@localhost conf]# cp zoo_sample.cfg zoo.cfg
[root@localhost conf]# vi zoo.cfg 

修改配置文件:

# ZooKeeper服务器心跳时间,单位为ms
tickTime=2000
# 投票选举新leader的初始化时间
initLimit=10
# leader与follower心跳检测最大容忍时间,响应超过syncLimit*tickTime,leader认为follower死掉,从服务器列表中删除follower
syncLimit=5
# 数据目录
dataDir=/tmp/zookeeper/data
# 日志目录
dataLogDir=/tmp/zookeeper/log
# ZooKeeper对外服务端口
clientPort=2181
[root@localhost conf]# mkdir -p /tmp/zookeeper/data
[root@localhost conf]# mkdir -p /tmp/zookeeper/log

在${dataDir}目录下创建一个myid文件,并写入一个数值,比如0。myid文件里存放的是服务器的编号

启动ZooKeeper服务

[root@localhost conf]# zkServer.sh start
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.12/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED

通过zkServer.sh status命令查看ZooKeeper服务状态

[root@localhost conf]# zkServer.sh status
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper-3.4.12/bin/../conf/zoo.cfg
Mode: standalone

3)、安装Kafka

下载地址:http://kafka.apache.org/downloads

[root@localhost opt]# ll
总用量 90264
-rw-r--r--.  1 root root 55751827 5月  20 09:18 kafka_2.11-2.0.0.tgz
drwxr-xr-x. 10 1000 1000     4096 3月  27 2018 zookeeper-3.4.12
-rw-r--r--.  1 root root 36667596 5月  20 09:01 zookeeper-3.4.12.tar.gz
[root@localhost opt]# tar zxf kafka_2.11-2.0.0.tgz 
[root@localhost opt]# cd kafka_2.11-2.0.0
[root@localhost kafka_2.11-2.0.0]# pwd
/opt/kafka_2.11-2.0.0
[root@localhost kafka_2.11-2.0.0]# vi /etc/profile

添加如下配置:

export KAFKA_HOME=/opt/kafka_2.11-2.0.0
export PATH=$PATH:$KAFKA_HOME/bin
[root@localhost kafka_2.11-2.0.0]# source /etc/profile
[root@localhost kafka_2.11-2.0.0]# cd config/
[root@localhost config]# vi server.properties 

修改配置文件:

# broker的编号,如果集群中有多个broker,则每个broker的编号需要设置的不同
broker.id=0
# broker对外提供的服务入口地址
listeners=PLAINTEXT://192.168.126.158:9092
# 存放消息日志文件的地址
log.dirs=/tmp/kafka-logs
# Kafka需要的ZooKeeper集群地址
zookeeper.connect=localhost:2181/kafka

后台启动Kafka

[root@localhost config]# kafka-server-start.sh -daemon server.properties 

通过jps命令查看Kafka服务进程是否已经启动

[root@localhost config]# jps -l
20643 kafka.Kafka
21267 sun.tools.jps.Jps
20206 org.apache.zookeeper.server.quorum.QuorumPeerMain

3、安装Kafka集群

1)、安装JDK

2)、安装ZooKeeper集群(单机三节点)

三个节点的zoo.cfg配置如下:

tickTime=2000
initLimit=10
syncLimit=5
dataDir=/tmp/zookeeper01/data
dataLogDir=/tmp/zookeeper01/log
clientPort=2181
server.0=192.168.126.158:2888:3888
server.1=192.168.126.158:2889:3889
server.2=192.168.126.158:2890:3890
tickTime=2000
initLimit=10
syncLimit=5
dataDir=/tmp/zookeeper02/data
dataLogDir=/tmp/zookeeper02/log
clientPort=2182
server.0=192.168.126.158:2888:3888
server.1=192.168.126.158:2889:3889
server.2=192.168.126.158:2890:3890
tickTime=2000
initLimit=10
syncLimit=5
dataDir=/tmp/zookeeper03/data
dataLogDir=/tmp/zookeeper03/log
clientPort=2183
server.0=192.168.126.158:2888:3888
server.1=192.168.126.158:2889:3889
server.2=192.168.126.158:2890:3890

分别在三个${dataDir}目录下创建一个myid文件,并分别写入0、1、2。myid文件里存放的是服务器的编号

server.A=B:C:D。其中A是一个数字,代表服务器的编号,就是myid文件里面的值。集群中每台服务器的编号都必须唯一。B代表服务器的IP地址。C表示服务器与集群中的leader服务器交换信息的端口。D表示选举时服务器相互通信的端口

分别在三个节点的bin目录下使用./zkServer.sh start启动

使用./zkServer.sh status查看各自节点的状态,一个leader节点,两个follower节点

3)、安装Kafka集群(单机三个broker)

修改三个broker的server.properties配置:

broker.id=0
listeners=PLAINTEXT://192.168.126.158:9092
log.dirs=/tmp/kafka01-logs
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183
broker.id=1
listeners=PLAINTEXT://192.168.126.158:9093
log.dirs=/tmp/kafka02-logs
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183
broker.id=2
listeners=PLAINTEXT://192.168.126.158:9094
log.dirs=/tmp/kafka03-logs
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183

确保集群中每个broker的broker.id配置的参数的值不一样

分别在三个broker的bin目录下使用./kafka-server-start.sh -daemon ../config/server.properties启动

4、生产与消费

在$KAFKA_HOME/bin目录下,与主题有关的就是kafka-topics.sh脚本,用它创建一个分区数为4、副本因子为3的主题topic-demo

[root@localhost bin]# ./kafka-topics.sh --zookeeper localhost:2181 --create --topic topic-demo --replication-factor 3 --partitions 4

其中–zookeeper指定了Kafka所连接的ZooKeeper服务地址,–topic指定了所要创建主题的名称,–replication-factor指定了副本因子(副本因子不能大于broker个数),–partitions指定了分区个数,–create是创建主题的动作指令

通过–describe展示主题的更多具体信息

[root@localhost bin]# ./kafka-topics.sh --zookeeper localhost:2181 --describe --topic topic-demo
Topic:topic-demo        PartitionCount:4        ReplicationFactor:3     Configs:
        Topic: topic-demo       Partition: 0    Leader: 1       Replicas: 1,2,0 Isr: 1,2,0
        Topic: topic-demo       Partition: 1    Leader: 2       Replicas: 2,0,1 Isr: 2,0,1
        Topic: topic-demo       Partition: 2    Leader: 0       Replicas: 0,1,2 Isr: 0,1,2
        Topic: topic-demo       Partition: 3    Leader: 1       Replicas: 1,0,2 Isr: 1,0,2

$KAFKA_HOME/bin目录提供了两个脚本kafka-console-producer.sh和kafka-console-consumer.sh,通过控制台收发消息

通过kafka-console-consumer.sh来订阅主题topic-demo

[root@localhost bin]# ./kafka-console-consumer.sh --bootstrap-server 192.168.126.158:9092 --topic topic-demo

其中–bootstrap-server指定了连接的Kafka集群地址,–topic指定了消费者订阅的主题

再打开一个shell终端,使用kafka-console-producer.sh脚本发送一条消息Hello,Kafka!至主题topic-demo,发送后能在消费者这一边看到Hello,Kafka!

[root@localhost bin]# ./kafka-console-producer.sh --broker-list 192.168.126.158:9092 --topic topic-demo
>Hello,Kafka!

–broker-list指定了连接的Kafka集群地址,–topic指定了发送消息时的主题

二、生产者

1、客户端开发案例

1)、引入依赖

        <dependency>
            <groupId>org.apache.kafka</groupId>
            <artifactId>kafka-clients</artifactId>
            <version>2.0.0</version>
        </dependency>

2)、一个正常的生产逻辑需要具体以下几个步骤:

  • 配置生产者客户端参数及创建相应的生产者实例
  • 构建待发送的消息
  • 发送消息
  • 关闭生产者实例

3)、代码实现

@RunWith(SpringRunner.class)
@SpringBootTest
public class KafkaProducerTest {
    public static final String brokerList = "192.168.126.158:9092";
    public static final String topic = "topic-demo";

    public Properties initConfig() {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.CLIENT_ID_CONFIG, "producer.client.id.demo");
        return props;
    }

    @Test
    public void sendMsg() {
        //配置生产者客户端参数并创建KafkaProducer实例
        KafkaProducer<String, String> producer = new KafkaProducer<>(initConfig());
        //构建所需要发送的消息
        ProducerRecord<String, String> record = new ProducerRecord<>(topic, "Hello,Kafka!");
        try {
            //发送消息
            producer.send(record);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //关闭生产者客户端
            producer.close();
        }
    }

}

2、客户端开发详解

1)、消息对象ProducerRecord

public class ProducerRecord<K, V> {

    private final String topic;//主题
    private final Integer partition;//分区号	
    private final Headers headers;//消息头部	
    private final K key;//键
    private final V value;//值
    private final Long timestamp;//消息的时间戳

key是用来指定消息的键,它不仅是消息的附加信息,还可以用来计算分区号进而可以让消息发往特定的分区。这个key可以让消息进行二次归类,同一个key的消息会被划分到同一个分区中。有key的消息还可以支持日志压缩的功能。

value是消息体,一般不为空,如果为空则表示特定的消息——墓碑消息

timestamp是指消息的时间戳,它有CreateTime和LogAppendTime两种类型,前者表示消息创建的时间,后者表示消息追加到日志文件的时间

2)、必要的参数配置

  • bootstrap.servers:该参数用来指定生产者客户端连接Kafka集群所需的broker地址清单,具体的内容格式为host1:port1,host2:port2,host3:port3。这里并非需要所有的broker地址,因为生产者会从给定的broker里查找到其他broker的信息。不过建议至少要设置两个以上的broker地址信息,当其中任意一个宕机时,生产者仍然可以连接到Kafka集群上
  • key.serializer和value.serializer:broker端接收的消息必须以字节数组的形式存在。KafkaProducer<String, String>和ProducerRecord<String, String>中的泛型<String, String>对应的就是消息中key和value的类型,在发往broker之前需要将消息中对应的key和value做相应的序列化操作来转换成字节数组。key.serializer和value.serializer这两个参数分别用来指定key和value序列化操作的序列化器

client.id这个参数用来设定KafkaProducer对应的客户端id,如果客户端不设置,则KafkaProducer会自动生成一个非空字符串,内容格式"producer-"与数字的拼接

KafkaProducer是线程安全的,可以在多个线程中共享单个KafkaProducer实例,也可以将KafkaProducer实例进行池化来供其他线程调用

3)、消息的发送

    public ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value, Iterable<Header> headers)
    
    public ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value)
    
    public ProducerRecord(String topic, Integer partition, K key, V value, Iterable<Header> headers)
    
    public ProducerRecord(String topic, Integer partition, K key, V value)
    
    public ProducerRecord(String topic, K key, V value)
    
    public ProducerRecord(String topic, V value)    

发送消息主要有三种模式:发后即忘、同步、异步

1)发后即忘

上面的代码实现中的发送方式就是发后即忘,它只管往Kafka中发送消息而并不关心消息是否正确达到。在某些时候这种发送方式可能导致消息的丢失。这种发送方式的性能最高,可靠性最差

2)同步

要实现同步的发送方式,可以利用send方法返回的Future对象实现。send()方法本身就是异步的,在执行send()方法之后调用get()方法来阻塞等待Kafka的响应,直到消息发送成功,或者发生异常

        try {
            producer.send(record).get();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            producer.close();
        }
        try {
            Future<RecordMetadata> future = producer.send(record);
            RecordMetadata metadata = future.get();
            System.out.println("topic:"+metadata.topic() + ",partition:" + metadata.partition() + ",offset:" + metadata.offset());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            producer.close();
        }

RecordMetadata对象里包含了消息的一些元数据信息,比如当前消息的主题、分区号、分区中的偏移量、时间戳等

Future表示一个任务的生命周期,并提供了相应的方法来判断任务是否已经完成或取消,以及获取任务的结果和取消任务等。也可以通过Future中的get(long timeout, TimeUnit unit)方法实现可超时的阻塞

KafkaProducer中一般会发生两种类型的异常:可重试的异常和不可重试的异常。对于可重试的异常,如果配置了retries参数,那么只要在规定的重试次数内自行恢复了,就不会抛出异常。retries参数的默认值是0,配置方式如下:

        props.put(ProducerConfig.RETRIES_CONFIG, 10);

同步发送的方式可靠性高,要么消息被发送成功,要么发生异常。如果发生异常,则可以捕获并进行相应的处理,而不会像发后即忘的方式直接造成消息的丢失。不过同步发送的方式的性能会差很多,需要阻塞等待一条消息发送完之后才能发送下一条

3)异步发送

异步发送的方式,一般是在send()方法里指定一个Callback的回调函数,Kafka在返回响应时调用该函数来实现异步的发送确认

            producer.send(record, new Callback() {
                @Override
                public void onCompletion(RecordMetadata metadata, Exception exception) {
                    if (exception != null) {
                        exception.printStackTrace();
                    } else {
                        System.out.println("topic:" + metadata.topic() + ",partition:" + metadata.partition() + ",offset:" + metadata.offset());
                    }
                }
            });
            producer.send(record1, callback1);
            producer.send(record2, callback2);

对于同一个分区而言,如果消息record1于record2之前先发送,那么KafkaProducer就可以保证对应的callback1在callback2之前调用,也就是说,回调函数的调用也可以保证分区有序

close()方法会阻塞等待之前所有的发送请求完成后再关闭KafkaProducer

4)、序列化

生产者需要序列化器把对象转换成字节数组才能通过网络发送给Kafka,消费者需要用反序列化器把从Kafka中收到的字节数组转换成相应的对象

所有的序列化器都实现了org.apache.kafka.common.serialization.Serializer接口

public interface Serializer<T> extends Closeable {

    void configure(Map<String, ?> configs, boolean isKey);

    byte[] serialize(String topic, T data);

    @Override
    void close();
}

configure()方法用来配置当前类,serialize()方法用来执行序列化操作,而close()方法用来关闭当前的序列化器(一般情况下close()是一个空方法)

生产者使用的序列化器和消费者使用的反序列化器是需要一一对应的

public class StringSerializer implements Serializer<String> {
    private String encoding = "UTF8";

    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.serializer.encoding" : "value.serializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null)
            encodingValue = configs.get("serializer.encoding");
        if (encodingValue instanceof String)
            encoding = (String) encodingValue;
    }

    @Override
    public byte[] serialize(String topic, String data) {
        try {
            if (data == null)
                return null;
            else
                return data.getBytes(encoding);
        } catch (UnsupportedEncodingException e) {
            throw new SerializationException("Error when serializing string to byte[] due to unsupported encoding " + encoding);
        }
    }

    @Override
    public void close() {
        // nothing to do
    }
}

configure()方法是在创建KafkaProducer实例的时候调用的,主要用来确定编码类型,一般情况下encoding就是默认的UTF8。serialize()方法就会将String类型转为byte[]类型

5)、分区器

消息在通过send()方法发往broker的过程中,有可能需要经过拦截器、序列化器和分区器的一系列操作之后才能被真正地发往broker。拦截器一般不是必需的,而序列化器是必需的。消息经过序列化之后就需要确定它发往的分区,如果消息ProducerRecord中指定了partition字段,那么就不需要分区器的作用,因为partition代表的就是所要发往的分区号

如果消息ProducerRecord中没有指定了partition字段,那么就需要依赖分区器,根据key这个字段来计算partition的值。分区器的作用就是为消息分配分区

Kafka中提供的默认分区器是org.apache.kafka.clients.producer.internals.DefaultPartitioner,它实现了org.apache.kafka.clients.producer.Partitioner接口,这个接口中定义了2个方法

public interface Partitioner extends Configurable, Closeable {

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);

    public void close();

}

partition()方法用来计算分区号,返回值为int类型。partition()方法中的参数分别表示主题、键、序列化后的键、值、序列化后的值,以及集群的元数据信息。close()方法在关闭分区器的时候用来回收一些资源

Partitioner接口还有一个父接口org.apache.kafka.common.Configurable

public interface Configurable {

    void configure(Map<String, ?> configs);

}

Configurable接口中的configure()方法主要用来获取配置信息及初始化数据

在默认分区器DefaultPartitioner的实现中,close()是空方法,而在partition()方法中定义了主要的分区分配逻辑。如果key不为null,那么默认的分区器会对key进行哈希,最终根据得到的哈希值来计算分区号,拥有相同key的消息会被写入同一个分区。如果key为null,那么消息将会以轮询的方式发往主题内的各个可用分区

如果key不为null,那么计算得到的分区号会是所有分区中的任意一个;如果key为null并且有可用分区时,那么计算得到的分区号仅为可用分区中的任意一个

6)、生产者拦截器

Kafka一共有两种拦截器:生产者拦截器和消费者拦截器。生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作

生产者拦截器的使用,主要是自定义实现org.apache.kafka.clients.producer.ProducerInterceptor接口

public interface ProducerInterceptor<K, V> extends Configurable {
    
    public ProducerRecord<K, V> onSend(ProducerRecord<K, V> record);

    public void onAcknowledgement(RecordMetadata metadata, Exception exception);

    public void close();
}

KafkaProducer在将消息序列化和计算分区之间会调用生产者拦截器的onSend()方法来对消息进行相应的定制化操作

KafkaProducer会在消息被应答之前或者消息发送失败时调用生产者拦截器的onAcknowledgement()方法,优先于用户设定的Callback之前执行。这个方法运行在Producer的I/O线程中,所以这个方法中实现的代码逻辑越简单越好,否则会影响消息的发送速度

close()方法主要用于在关闭拦截器时执行一些资源的清理工作

public class ProducerInterceptorPrefix implements ProducerInterceptor {
    private volatile long sendSuccess = 0;
    private volatile long sendFailure = 0;

    @Override
    public ProducerRecord onSend(ProducerRecord record) {
        String modifiedValue = "prefix1-" + record.value();
        return new ProducerRecord<>(record.topic(), record.partition(), record.timestamp(), record.key(), modifiedValue, record.headers());
    }

    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
        if (exception == null) {
            sendSuccess++;
        } else {
            sendFailure++;
        }
    }

    @Override
    public void close() {
        double successRatio = (double) sendSuccess / (sendSuccess + sendFailure);
        System.out.println("发送成功率=" + successRatio * 100 + "%");
    }

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

指定这个拦截器:

        props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName());

此时发送的消息为prefix1-Hello,Kafka!

KafkaProducer中不仅可以指定一个拦截器,还可以指定多个拦截器已形成拦截链。拦截链会按照interceptor.classes参数配置的拦截器的顺序来一一执行(配置的时候,各个拦截器之间使用逗号隔开)

再自定义一个拦截器

public class ProducerInterceptorPrefix2 implements ProducerInterceptor {

    @Override
    public ProducerRecord onSend(ProducerRecord record) {
        String modifiedValue = "prefix2-" + record.value();
        return new ProducerRecord<>(record.topic(), record.partition(), record.timestamp(), record.key(), modifiedValue, record.headers());
    }

    @Override
    public void onAcknowledgement(RecordMetadata metadata, Exception exception) {
    }

    @Override
    public void close() {
    }

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

修改生产者的interceptor.classes配置

        props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName() + "," + ProducerInterceptorPrefix2.class.getName());

此时发送的消息为prefix2-prefix1-Hello,Kafka!

3、原理分析

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

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

主线程中发送过来的消息都会被追加到RecordAccumulator的某个双端队列中。在RecordAccumulator的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即Deque<ProducerBatch>。消息写入缓存时,追加到双端队列的尾部;Sender读取消息时,从双端队列的头部读取。ProducerBatch中可以包含一至多个ProducerRecord。ProducerRecord是生产者中创建的消息,而ProducerBatch是指一个消息批次,ProducerRecord会被包含在ProducerBatch中

消息在网络上都是以字节的形式传输的,在发送之前需要创建一块内存区域来保存对应的消息。在Kafka生产者客户端中,通过java.io. ByteBuffer实现消息内存的创建和释放,在RecordAccumulator的内部有一个BufferPool,它主要用来实现ByteBuffer的复用,以实现缓存的高效利用。BufferPool只针对特定大小的ByteBuffer进行管理,而其他大小的ByteBuffer不会缓存进BufferPool中,这个特定的大小由batch.size参数来指定,默认为16KB

当一条消息流入RecordAccumulator时,会先寻找分区所对应的双端队列(如果没有则新建),再从这个双端队列的尾部获取一个ProducerBatch(如果没有则新建),查看ProducerBatch中是否还可以写入这个ProducerRecord,如果可以则写入,如果不可以则需要创建一个新的ProducerBatch。在新建ProducerBatch时评估这条消息的大小来创建ProducerBatch,这样在使用完这段内存区域之后,可以通过BufferPool的管理来进行复用;如果超过,那么久以评估的大小来创建ProducerBatch,这段内存区域不会被复用

Sender从RecordAccumulator中获取缓存的消息之后,会进一步将原本<分区,Deque<ProducerBatch>>的保存形式转变成<Node,List<ProducerBatch>>的形式,其中Node表示Kafka集群的broker节点。对于网络连接来说,生产者客户端是与具体的broker节点建立的连接,也就是向具体的broker节点发送消息,而并不关心消息属于哪一个分区;而对于KafkaProducer的应用逻辑而言,只关注向哪个分区中发送哪些消息,所以这里需要做一个应用逻辑层面到网络I/O层面的转换

在转换成<Node,List<ProducerBatch>>的形式之后,Sender还会进一步封装成<Node,Request>的形式,这样就可以将Request请求发往各个Node了,这里的Request是指Kafka的各种协议请求,对于消息发送而言就是指具体的ProducerRequest

请求在从Sender线程发往Kafka之前还会保存在InFlightRequests中,InFlightRequests保存对象的具体形式为Map<NodeId,Deque<Request>>,它的主要作用是缓存了已经发出去但还没有收到响应的请求

2)、元数据的更新

InFlightRequests可以获取leastLoadedNode,即所有Node中负载最小的那一个。这里的负载最小是通过每个Node在InFlightRequests中还未确认的请求决定的,未确认的请求越多则负载越大
在这里插入图片描述
上图中三节点Node0、Node1和Node2,Node1的负载最小,也就是说,Node1位当前的leastLoadedNode

选择leastLoadedNode发送请求可以使它能够尽快发出,避免因网络拥塞等异常而影响整体的进度

元数据是指Kafka集群的元数据,这些元数据具体记录了集群中有哪些主题,这些主题有哪些分区,每个分区的leader副本分配在哪个节点上,follower副本分配在哪些节点上,哪些副本在AR、ISR等集合中,集群中有哪些节点,控制器节点又是哪一个等信息

当客户端没有需要使用的元数据信息时,或者超过metadata.max.age.ms(默认300000,即5分钟)时间没有更新元数据都会引入元数据的更新操作。元数据的更新操作是在客户端内部进行的,对客户端的外部使用者不可见,当需要更新元数据时,会先挑选出leastLoadedNode。然后向这个Node发送MetadataRequest请求来获取具体的元数据信息。这个更新操作是由Sender线程发起的,在创建完MetadataRequest之后同样会存入InFlightRequests,之后的步骤就和发送消息时类似。元数据虽然由Sender线程负责更新,但是主线程也需要读取这些信息,这里的数据同步通过synchronized和final关键字来保障

4、重要的生产者参数

1)、acks

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

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

acks参数配置的值是一个字符串类型,而不是整数类型

        props.put(ProducerConfig.ACKS_CONFIG, "1");

2)、max.request.size

这个参数用来限定生产者客户端能发送的消息的最大值,默认值为1048576B,即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

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

6)、linger.ms

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

7)、request.timeout.ms

这个参数用来配置Producer等待请求响应的最长时间,默认值为30000(ms)

三、消费者

1、消费者与消费组

消费者负责订阅Kafka中的主题,并且从订阅的主题上拉取消息。每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者
在这里插入图片描述
上图中,某个主题中共有4个分区:P0、P1、P2、P3。有两个消费组A和B都订阅了这个主题,消费组A中有4个消费者(C0、C1、C2和C3),消费组B中有2个消费者(C4和C5)。按照Kafka默认的规则,最后的分配结果是消费组A中的每一个消费者分配到1个分区,消费组B中的每一个消费者分配到2个分区,两个消费组之间互不影响。每个消费者只能消费所分配到的分区中的消息。每一个分区只能被一个消费组中的一个消费者所消费

消费者与消费组这种模型可以让整体的消费能力具备横向伸缩性,可以增加消费者的个数来提高整体的消费能力。对于分区固定的情况,一味地增加消费者并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的情况,就会有消费者分配不到任何分区

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

消费组是一个逻辑上的概念,它将旗下的消费者归为一类,每一个消费者只隶属于一个消费组。每一个消费组都会有一个固定的名称,消费者在进行消费前需要指定其所属消费组的名称,这个可以通过消费者客户端参数group.id来配置,默认值为空字符串

2、客户端开发案例

1)、一个正常的消费逻辑需要具备以下几个步骤:

  • 配置消费者客户端参数及创建相应的消费者实例
  • 订阅主题
  • 拉取消息并消费
  • 提交消费位移
  • 关闭消费者实例

2)、代码实现

@RunWith(SpringRunner.class)
@SpringBootTest
public class KafkaConsumerTest {
    public static final String brokerList = "192.168.126.158:9092";
    public static final String topic = "topic-demo";
    public static final String groupId = "group.demo";

    public Properties initConfig() {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        props.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
        return props;
    }

    @Test
    public void receiveMsg() {
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        consumer.subscribe(Arrays.asList(topic));
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
                for (ConsumerRecord<String, String> record : records) {
                    System.out.println("topic=" + record.topic() + ",partition=" + record.partition() + ",offset=" + record.offset());
                    System.out.println("key=" + record.key() + ",value=" + record.value());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }
    }
}

3、客户端开发详解

1)、必要的参数配置

  • bootstrap.servers:该参数和生产者客户端KafkaProducer中的相同
  • group.id:消费者隶属的消费组的名称,默认值为""
  • key.deserializer和value.deserializer:与生产者客户端KafkaProducer中的key.serializer和value.serializer参数对应。消费者从broker端获取的消息格式都是字节数组类型,所以需要执行相应的反序列化操作才能还原成原有的对象格式。这两个参数分别用来指定消息中key和value所需反序列化操作的反序列化器

client.id这个参数用来设定KafkaConsumer对应的客户端id,如果客户端不设置,则KafkaConsumer会自动生成一个非空字符串,内容格式"consumer-"与数字的拼接

2)、订阅主题与分区

一个消费者可以订阅一个或多个主题

    public void subscribe(Collection<String> topics, ConsumerRebalanceListener listener)

    public void subscribe(Collection<String> topics)
        
    public void subscribe(Pattern pattern, ConsumerRebalanceListener listener)
        
    public void subscribe(Pattern pattern)    

对于消费者使用集合的方式(subscribe(Collection<String> topics) )来订阅主题而言,订阅了什么主题就消费什么主题的消息。如果前后两次订阅了不同的主题,那么消费者以最后一次的为准

        consumer.subscribe(Arrays.asList(topic1))
        consumer.subscribe(Arrays.asList(topic2))            

上面的示例中,最终消费者订阅的是topic2,而不是topic1,也不是topic1和topic2的并集

如果消费者采用的是正则表达式的方式(subscribe(Pattern pattern) ) 订阅,在之后的过程中,如果有人创建了新的主题,并且主题的名字与正则表达式相匹配,那么这个消费者就可以消费到新添加的主题中的消息。如果应用程序需要消费多个主题,并且可以处理不同的类型,那么这种订阅方式就很有效

        consumer.subscribe(Pattern.compile("topic-.*"));

消费者不仅可以通过KafkaConsumer.subscribe()方法订阅主题,还可以直接订阅某些主题的特定分区,在KafkaConsumer中还提供了一个assign()方法来实现这些功能,此方法的具体定义如下:

    public void assign(Collection<TopicPartition> partitions)

这个方法只接受一个参数partitions,用来指定需要订阅的分区集合

public final class TopicPartition implements Serializable {

    private int hash = 0;
    private final int partition;
    private final String topic;

TopicPartition的topic和partition属性,分别代表分区所属的主题和自身的分区编号

KafkaConsumer中partitionsFor()方法可以用来查询指定主题的元数据信息

    public List<PartitionInfo> partitionsFor(String topic)

PartitionInfo类型即为主题的分区元数据信息

public class PartitionInfo {

    private final String topic;//主题名称
    private final int partition;//分区编号
    private final Node leader;//分区的leader副本所在的位置
    private final Node[] replicas;//代表分区的AR集合
    private final Node[] inSyncReplicas;//代表分区的ISR集合
    private final Node[] offlineReplicas;//代表分区的OSR集合

通过partitionsFor()方法的协助,可以通过assign()方法来实现订阅主题(全部分区)的功能

        List<TopicPartition> partitions = new ArrayList<>();
        List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
        if (partitionInfos != null) {
            for (PartitionInfo tpInfo : partitionInfos) {
                partitions.add(new TopicPartition(tpInfo.topic(), tpInfo.partition()));
            }
        }
        consumer.assign(partitions);

使用KafkaConsumer中的unsubscribe()方法来取消主题的订阅

        consumer.unsubscribe();

如果将subscribe(Collection<String> topics)或者assign(Collection<TopicPartition> partitions)中的集合参数设置为空集合,那么作用等同于unsubscribe()方法

如果没有订阅任何主题或分区,那么再继续执行消费程序的时候会报出异常

集合订阅的方式、正在表达式订阅的方式和指定分区的订阅方式代表了三种不同的订阅状态:AUTO_TOPICS、AUTO_PATTERN和USER_ASSIGNED。这三种状态是互斥的,在一个消费者中只能使用其中的一种,否则会报出异常

通过subscribe()方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区分配策略来自动分配各个消费者与分区的关系。当消费组内的消费者增加或减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移。而通过assign()方法订阅分区时,是不具备消费者自动均衡的功能的

3)、反序列化

所有的序列化器都实现了org.apache.kafka.common.serialization.Deserializer接口

public interface Deserializer<T> extends Closeable {

    void configure(Map<String, ?> configs, boolean isKey);

    T deserialize(String topic, byte[] data);

    @Override
    void close();
}

configure()用来配置当前类。deserialize()用来执行反序列化,如果data为null,那么处理的时候直接返回null而不是抛出一个异常。close()用来关闭当前序列化器

public class StringDeserializer implements Deserializer<String> {
    private String encoding = "UTF8";

    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.deserializer.encoding" : "value.deserializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null)
            encodingValue = configs.get("deserializer.encoding");
        if (encodingValue instanceof String)
            encoding = (String) encodingValue;
    }

    @Override
    public String deserialize(String topic, byte[] data) {
        try {
            if (data == null)
                return null;
            else
                return new String(data, encoding);
        } catch (UnsupportedEncodingException e) {
            throw new SerializationException("Error when deserializing byte[] to string due to unsupported encoding " + encoding);
        }
    }

    @Override
    public void close() {
        // nothing to do
    }
}

deserialize()方法就是把byte[]类型转换为String类型

4)、消息消费

Kafka中的消费是基于拉模式的。消息的消费一般有两种模式:推模式和拉模式。推模式是服务端主动将消息推送给消费者,而拉模式是消费者主动向服务端发起请求来拉取消息

上面的代码实现中,Kafka中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用poll()方法,而poll()方法返回的是所订阅的主题(分区)上的一组消息

对于poll()方法而言,如果某些分区中没有可供消费的消息,那么此分区对应的消息拉取的结果就为空;如果订阅的所有分区中都没有可供消费的消息,那么poll()方法返回为空的消息集合

    public ConsumerRecords<K, V> poll(final Duration timeout)

poll()方法有一个超时时间参数timeout,用来控制poll()方法的阻塞时间,在消费者的缓冲区里没有可用数据时会发生阻塞

消费者消费到的每条消息的类型为ConsumerRecord

public class ConsumerRecord<K, V> {

    private final String topic;//主题
    private final int partition;//分区
    private final long offset;//消息在所属分区的偏移量
    private final long timestamp;//有两种类型:CreateTime(消息创建的时间戳)和LogAppendTime(消息追加到日志的时间戳)
    private final TimestampType timestampType;
    private final int serializedKeySize;//key经过序列化后的大小,如果为空,则值为-1
    private final int serializedValueSize;//value经过序列化后的大小,如果为空,则值为-1
    private final Headers headers;//消息的头部内容
    private final K key;//消息的键
    private final V value;//消息的值

    private volatile Long checksum;//CRC32的校验值

poll()方法的返回值类型是ConsumerRecords,它用来表示一次拉取操作所获得的消息集,内部包含了若各ConsumerRecord,它提供了一个iterator()方法来循环遍历消息集内部的消息

    public Iterator<ConsumerRecord<K, V>> iterator()

还可以按照分区维度进行消费

    public List<ConsumerRecord<K, V>> records(TopicPartition partition)
    public Iterable<ConsumerRecord<K, V>> records(String topic)
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        consumer.subscribe(Arrays.asList(topic));
        try {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            //遍历消息集中的所有分区
            for (TopicPartition tp : records.partitions()) {
                //按照主题维度来进行消费
                for (ConsumerRecord<String, String> record : records.records(tp)) {
                    System.out.println(record.partition() + ":" + record.value());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        List<String> topicList = Arrays.asList(topic);
        consumer.subscribe(topicList);
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
                for (String topic : topicList) {
                    for (ConsumerRecord<String, String> record : records.records(topic)) {
                        System.out.println(record.topic() + ":" + record.value());
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }

5)、位移提交

对于Kafka中的分区而言,它的每条消息都有一个唯一的offset,用来表示消息在分区中对应的位置,称为偏移量。对于消费者而言,也有一个offset,消费者使用offset来表示消费到分区中某个消息所在的位置,称为位移

在每次调用poll()方法时,它返回的是还没有被消费过的消息集,要做到这一点,就需要记录上一次消费时的消费位移。并且这个消费位移必须做持久化保存。消费位移存储在Kafka内部的主题__consumer_offsets中。这里把将消费位移存储起来的动作称为提交,消费者在消费完消息之后需要执行消费位移的提交
在这里插入图片描述
上图中,x表示某一次拉取操作中次分区消息的最大偏移量,假设当前消费者已经消费了x位置的消息,那么就可以说消费者的消费位移为x。但是当前消费者需要提交的消费位移并不是x,而是x+1,对应position,它表示下一条需要拉取的消息的位置

KafkaConsumer类提供了下面两个方法分别获取上面所说的position和committed offset的值

    public long position(TopicPartition partition)
    public OffsetAndMetadata committed(TopicPartition partition)        

在Kafka中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数enable.auto.commit配置,默认值为true。这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数auto.commit.interval.ms配置,默认值为5秒,此参数生效的前提是enable.auto.commit=true

在默认的方式下,消费者每隔5秒会将拉取到的每个分区中最大的消息位移进行提交。自动位移提交的动作是在poll()方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移

自动提交的消费方式可能带来重复消费和消息丢失的问题:

假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次唯一提交的地方重新开始消费,这样便发生了重复消费的现象
在这里插入图片描述
拉取线程A不断地拉取消息并存入本地缓存,比如在BlockingQueue中,另一个处理线程B从缓存中读取消息并进行相应的逻辑处理。假设目前进行到了第y+1次拉取,以及第m次提交,也就是x+6之前的位移已经确认提交了,处理线程B却还在消费x+3的消息。如果此时处理线程B发生了异常,待其恢复后会从第m次位移提交处,也就是x+6的位置开始拉取消息,那么x+3至x+6之前的消息就没有得到相应的处理,这样便发生消息丢失的现象

在Kafka中还提供了手动位移提交的方式,开启手动提交功能的前提是消费者客户端参数enable.auto.commit配置为false

        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

手动提交可以细分为同步提交和异步提交,对应KafkaConsumer中的commitSync()和commitAsync()两种类型的方法

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        List<String> topicList = Arrays.asList(topic);
        consumer.subscribe(topicList);
        final int minBatchSize = 200;
        List<ConsumerRecord> buffer = new ArrayList<>();
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
                for (ConsumerRecord<String, String> record : records) {
                    buffer.add(record);
                }
                if (buffer.size() >= minBatchSize) {
                    consumer.commitSync();
                    buffer.clear();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }

commitSync()方法会根据poll()方法拉取的最新位移来进行提交,只要没有发生不可恢复的错误,它就会阻塞消费者线程直到位移提交完成

如果想要寻求更细力度的、更精确的提交,就需要使用下面这个方法

    public void commitSync(final Map<TopicPartition, OffsetAndMetadata> offsets)
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        List<String> topicList = Arrays.asList(topic);
        consumer.subscribe(topicList);
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
                //按照分区粒度同步提交消费位移
                for (TopicPartition partition : records.partitions()) {
                    List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
                    for (ConsumerRecord<String, String> record : partitionRecords) {

                    }
                    long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                    consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastConsumedOffset + 1)));
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }

异步提交的方式在执行的时候消费者线程不会被阻塞,可能在提交消息位移的结果还未返回之前就开始了新一次的拉取操作。异步提交可以使消费者的性能得到一定的增强

    public void commitAsync()
    public void commitAsync(OffsetCommitCallback callback)
    public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)        
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        List<String> topicList = Arrays.asList(topic);
        consumer.subscribe(topicList);
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {
                
            }
            consumer.commitAsync(new OffsetCommitCallback() {
                @Override
                public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
                    if (exception == null) {
                        System.out.println("提交成功,offsets:" + offsets);
                    } else {
                        System.out.println("提交失败,offsets:" + offsets);
                    }
                }
            });
        }

6)、控制或关闭消费

KafkaConsumer中使用pause()和resume()方法来分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作

    public void pause(Collection<TopicPartition> partitions)
    public void resume(Collection<TopicPartition> partitions)        

KafkaConsumer还提供了一个paused()方法来返回被暂停的分区集合

    public Set<TopicPartition> paused() 

KafkaConsumer提供了close()方法来实现关闭

    public void close()
    public void close(Duration timeout)        

7)、指定位移消费
在这里插入图片描述
在Kafka中每当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数auto.offset.reset的配置来决定从何处开始进行消费,这个参数默认值为"latest",表示从分区末尾开始消费消息。如果将auto.offset.reset参数配置为"earliest",那么消费者会从起始处

auto.offset.reset参数还有一个可配置的值——“none”,配置此值就意味着出现查不到消费位移的时候,既不从最新的位置处开始消费,也不从最早的消息位置处开始消费,此时会报出NoOffsetForPartitionException。如果能够找到消费位移,那么配置为"none"不会出现任何异常

KafkaConsumer中的seek()方法可以从特定的位移处开始拉取消息

    public void seek(TopicPartition partition, long offset)

seek()方法中的参数partition表示分区,而offset参数用来指定从分区的哪个位置开始消费。seek()方法只能重置消费者分配到的分区的消费位置,而分区的分配是在poll()方法的调用过程中实现的。也就是说,在执行seek()方法之前需要先执行一次poll()方法,等到分配到分区之后才可以重置消费位置

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        consumer.subscribe(Arrays.asList(topic));
        Set<TopicPartition> assignment = new HashSet<>();
        //如果不为0,则说明已经成功分配到了分区
        while (assignment.isEmpty()) {
            consumer.poll(Duration.ofMillis(100));
            assignment = consumer.assignment();
        }
        for (TopicPartition tp : assignment) {
            consumer.seek(tp, 0);
        }
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("topic=" + record.topic() + ",partition=" + record.partition() + ",offset=" + record.offset());
                System.out.println("key=" + record.key() + ",value=" + record.value());
            }
        }

如果消费组内的消费者在启动的时候能够找到消费位移,除非发生位移越界,否则auto.offset.reset参数不会奏效,此时如果想指定从头或末尾开始消费,就需要seek()方法的帮助了

使用seek()方法从分区末尾消费:

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        consumer.subscribe(Arrays.asList(topic));
        Set<TopicPartition> assignment = new HashSet<>();
        //如果不为0,则说明已经成功分配到了分区
        while (assignment.isEmpty()) {
            consumer.poll(Duration.ofMillis(100));
            assignment = consumer.assignment();
        }
        Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);
        for (TopicPartition tp : assignment) {
            consumer.seek(tp, offsets.get(tp));
        }
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("topic=" + record.topic() + ",partition=" + record.partition() + ",offset=" + record.offset());
                System.out.println("key=" + record.key() + ",value=" + record.value());
            }
        }
    }

endOffsets()方法用来获取指定分区的末尾的消息位置

    public Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions)
    public Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions, Duration timeout)        

partitions参数表示分区集合,而timeout参数用来设置等待获取的超时时间。如果没有指定timeout参数的值,那么endOffsets()方法的等待时间由客户端参数request.timeout.ms来设置,默认值为30000

beginningOffsets()方法用来获取指定分区的开头的消息位置,一个分区的起始位置是0,但并不代表每时每刻都是0,因为日志清理的动作会清理旧的数据,所以分区的起始位置会自然而然地增加

    public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions)
    public Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions, Duration timeout)        

KafkaConsumer提供了seekToBeginning()和seekToEnd()两个方法实现从分区开头或末尾消费

    public void seekToBeginning(Collection<TopicPartition> partitions)
    public void seekToEnd(Collection<TopicPartition> partitions)        

KafkaConsumer提供了offsetsForTimes()方法,通过timestamp来查询与此对应的分区位置

    public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch)
    public Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout)        

offsetsForTimes()方法的参数timestampsToSearch是一个Map类型,key为待查询的分区,而value为待查询的时间戳,该方法会返回时间戳大于等于待查询时间的第一条消息对应的位置和时间戳,对应于OffsetAndTimestamp中的offset和timestamp字段

8)、再均衡

再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。也就是说,在再均衡发生期间的这一小段时间内,消费组会变得不可用。另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息又被重新消费一边,也就是发生了重复消费

再均衡监听器用来设定发生再均衡动作前后的一些准备或收尾的动作。ConsumerRebalanceListener是一个接口

public interface ConsumerRebalanceListener {

    void onPartitionsRevoked(Collection<TopicPartition> partitions);

    void onPartitionsAssigned(Collection<TopicPartition> partitions);
}

onPartitionsRevoked()方法在再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的重复消费现象的发生。参数partitions表示再均衡前所分配到的分区

onPartitionsAssigned()方法会在重新分配分区之后和消费者开始读取消费之前被调用。参数partitions表示再均衡后所分配到的分区

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(initConfig());
        Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
        consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
            @Override
            public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                consumer.commitSync(currentOffsets);
                currentOffsets.clear();
            }

            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {

            }
        });
        try {
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
                for (ConsumerRecord<String, String> record : records) {
                    currentOffsets.put(new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1));
                }
                consumer.commitAsync(currentOffsets, null);
            }
        } finally {
            consumer.close();
        }

将消费位移暂存到一个局部变量currentOffsets中,这样在正常消费的时候可以通过commitAsync()方法来异步提交消费位移,在发生再均衡动作之前可以通过再均衡监听器的onPartitionsRevoked()回调执行commitSync()方法同步提交消费位移,以尽量避免一些不必要的重复消费

9)、消费者拦截器

消费者拦截器需要自定义实现org.apache.kafka.clients.consumer.ConsumerInterceptor接口

public interface ConsumerInterceptor<K, V> extends Configurable {

    public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);

    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);

    public void close();
}

KafkaConsumer会在poll()方法返回之前调用拦截器的onConsume()方法来对消息进行相应的定制化操作

KafkaConsumer会在提交完消费位移之后调用拦截器的onCommit()方法,可以使用这个方法来记录跟踪所提交的位移消息

需要配置interceptor.classes参数,多个拦截器形成拦截链,按照参数配置的拦截器的顺序来一一执行

10)、多线程实现

KafkaProducer是线程安全的,然而KafkaConsumer却是非线程安全的。KafkaConsumer中定义了一个acquire()方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出ConcurrentModifcationException异常

KafkaConsumer中的每个公用方法在执行所要执行的动作之前都会调用这个acquire()方法,只有wakeup()方法是个例外

    private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);

    private void acquire() {
        long threadId = Thread.currentThread().getId();
        if (threadId != currentThread.get() && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId))
            throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
        refcount.incrementAndGet();
    }

acquire()方法不会造成阻塞等待,仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire()方法和release()方法成对出现,表示相应的加锁和解锁操作

    private void release() {
        if (refcount.decrementAndGet() == 0)
            currentThread.set(NO_CURRENT_THREAD);
    }

KafkaConsumer非线程安全并不意味着我们在消费消息的时候只能以单线程的方式执行。如果生产者发送消息的速度大于消费者处理消息的速度,那么就会有越来越多的消息得不到及时的消费,造成了一定的延迟。除此之外,由于Kafka中消息保留机制的作用,有些消息有可能在被消费之前就被清理了,从而造成消息的丢失

1)线程封闭:为每个线程实例化一个KafkaConsumer对象
在这里插入图片描述
一个线程对应一个KafkaConsumer实例,称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组。这种实现方式的并发度受限于分区的实际个数,当消费线程的个数大于分区数时,就有部分消费线程一直处于空闲的状态

2)多个线程线程同时消费同一个分区

通过assign()、seek()等方法实现,这样可以打破原有的消费线程的个数不能超过分区数的限制,进一步提高了消费的能力。不过这种实现方式对于位移提交和顺序控制的处理就会变得非常复杂

3)将处理消息模块改成多线程的实现方式
在这里插入图片描述

4、重要的消费者参数

1)、fetch.min.bytes

该参数用来配置Consumer在一次拉取请求中能从Kafka中拉取的最小数据量,默认值为1B。Kafka在收到Consumer的拉取请求时,如果返回给Consumer的数据量小于这个参数所配置的值,那么它就需要进行等待,直到数据量满足这个参数的配置大小

2)、fetch.max.bytes

该参数用来配置Consumer在一次拉取请求中从Kafka中拉取的最大数据量,默认值为52428800B,也就是50MB。如果在第一个非空分区中拉取的第一条消息大于该值,那么该消息将仍然返回

3)、fetch.max.wait.ms

该参数用于指定Kafka的等待时间,默认值为500ms。如果Kafka中没有足够多的消息而满足不了fetch.min.bytes参数的要求,那么最终会等待500ms

4)、max.partition.fetch.bytes

该参数用来配置从每个分区里返回给Consumer的最大数据量,默认值为1048576B,即1MB

5)、max.poll.records

该参数用来配置Consumer在一次拉取请求中拉取的最大消息数,默认值500条

6)、connections.max.idle.ms

该参数用来指定在多久之后关闭限制的连接,默认值540000ms,即9分钟

7)、request.timeout.ms

该参数用来配置Consumer等待请求响应的最长时间,默认值为30000ms

8)、metadata.max.age.ms

该参数用来配置元数据的过期时间,默认值为300000ms,即5分钟。如果元数据在此参数所限定的时间范围内没有进行更新,则会被强制更新

9)、reconnect.backoff.ms

该参数用来配置尝试重新连接指定主机之前的等待时间,默认值为50ms

10)、retry.backoff.ms

该参数用来配置尝试重新发送失败的请求到指定的主题分区之间的等待时间,默认值为100ms

  • 6
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

邋遢的流浪剑客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值