网上得来终觉浅

_φ(❐_❐✧ 人丑就要多读书

导航

kafka

0.介绍

Kafka是一个分布式的基于发布/订阅模式的消息队列(Message Queue),主要应用于大数据实时处理领域。

消息队列模式

  1. 点对点模式(一对一,消费者主动拉取数据,消息收到后消息清除)。
  2. 布/订阅模式(一对多,消费者消费数据之后不会清除消息)。

名词:

  • Producer :消息生产者,就是向 kafka broker 发消息的客户端;
  • Consumer :消息消费者,向 kafka broker 取消息的客户端;
  • Consumer Group (CG):消费者组,由多个 consumer 组成。消费者组内每个消费者负责消费不同分区的数据,一个分区只能由消费者组中的一个消费者消费;消费者组之间互不影响。所有的消费者都属于某个消费者组,即消费者组是逻辑上的一个订阅者;
  • Broker :一台 kafka 服务器就是一个 broker。一个集群由多个 broker 组成。一个 broker可以容纳多个topic;
  • Topic :可以理解为一个队列,生产者和消费者面向的都是一个 topic
  • Partition:为了实现扩展性,一个非常大的 topic 可以分布到多个 broker(即服务器)上,一个 topic 可以分为多个 partition,每个 partition 是一个有序的队列;
  • Replica:副本,为保证集群中的某个节点发生故障时,该节点上的 partition 数据不丢失,且 kafka 仍然能够继续工作,kafka提供了副本机制,一个 topic 的每个分区都有若干个副本,一个 leader 和若干个 follower。
  • leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是 leader。
  • follower:每个分区多个副本中的“从”,实时从 leader 中同步数据,保持和 leader 数据的同步。leader 发生故障时,某个follower 会成为新的follower。
  • offset:对于每一个topic, Kafka集群都会维持一个分区日志,每个分区都是有序且顺序不可变的记录集,并且不断地追加到结构化的commit log文件。分区中的每一个记录都会分配一个id号来表示顺序,我们称之为offset,offset用来唯一的标识分区中每一条记录。Kafka每个分区的数据是严格有序的,但多分区之间不能确保有序。

1.zookeeper安装与配置

Zookeeper是安装Kafka集群的必要组件,Kafka通过Zookeeper来实施对元数据信息的管理,包括集群、主题、分区等内容。

修改配置文件:

将conf/zoo_sample.cfg文件修改为zoo.cfg:

# zk服务器的心跳时间
tickTime=2000
# 投票选举新Leader的初始化时间
initLimit=10
# 数据目录
dataDir=/opt/zookeeper/data
# 日志目录
dataLogDir=/opt/zookeeper/log
# Zookeeper对外服务端口,保持默认
clientPort=2181

启动zookeeper

bin/zkServer.sh start

2.Kafka安装与配置

2.1配置文件:

config/server.properties

#表示broker的编号,如果集群中有多个broker,则每个broker的编号需要设置的不同
broker.id=0 
#brokder对外提供的服务入口地址
listeners=PLAINTEXT://hostname:9092
#设置存放消息日志文件的地址
log.dirs=/opt/kafka/log
#Kafka所需Zookeeper地址,如果zookeeper是集群则以逗号隔开
zookeeper.connect=localhost:2181
#服务器接受单个消息的最大大小,默认1000012 约等于976.6KB。
#message.max.bytes

2.2启动kafka

bin/kafka-server-start.sh config/server.properties
启动成功之后重新打开一个终端,验证启动进程:jps -l

2.3验证使用

创建主题

bin/kafka-topics.sh --zookeeper localhost:2181 --create --topic testtopic --partitions 2 --replication-factor 1
参数:

  • --zookeeper:指定了Kafka所连接的Zookeeper服务地址
  • --topic:指定了所要创建主题的名称
  • --partitions:指定了分区个数
  • --replication-factor:指定了副本因子
  • --create:创建主题的动作指令

查询全部主题

bin/kafka-topics.sh --zookeeper localhost:2181 --list

查询主题详情

bin/kafka-topics.sh --zookeeper localhost:2181 --describe --topic testtopic

生产端发送消息

bin/kafka-console-producer.sh --broker-list localhost:9092 --topic testtopic
参数:

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

消费端消费消息

bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic testtopic
参数:

  • --bootstrap-server 指定了连接Kafka集群的地址
  • --topic 指定了消费端订阅的主题

2.4生产者

消息发送的过程中,涉及到两个线程协同工作:

1.主线程首先将业务数据封装成 ProducerRecord对象,之后调用send()方法将消息放入RecordAccumulator(消息收集器,也可以理解为主线程与Sender线程直接的缓冲区)中暂存

2.Sender线程负责将消息信息构成请求,并最终执行网络I/O的线程,它从RecordAccumulator中取出消息并批量发送出去,

需要注意的是,KafkaProducer是线程安全的,多个线程间可以共享使用同一个KafkaProducer对象

生产者参数

kafka提供了参数配置类:ProducerConfig,例如key序列化器为:ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG

bootstrap-servers:

作用:指定 brokers 的地址清单,格式为 host:port。清单里不需要包含所有的 broker地址, 生产者会从给定的 broker 里查找到其它 broker 的信息。

建议至少提供两个 broker的信息,因为一旦其中一个宕机,生产者仍然能够连接到集群上。

key.serializer:

key的序列化器.

kafka 默认提供了 StringSerializer和 IntegerSerializer、ByteArraySerializer。当然也可以自定义序列化器。

value.serializer:

value的序列化器。

kafka 默认提供了 StringSerializer和 IntegerSerializer、ByteArraySerializer。当然也可以自定义序列化器。

acks:

作用:这个参数用来指定分区中必须有多少个副本收到这条消息生产者才会认为这条消息时写入成功。acks是生产者客户端中非常重要的一个参数,它涉及到消息可靠性和吞吐量之间的权衡。

参数:

  • ack=0 , 生产者在成功写入消息之前不会等待任何来自服务器的相应。

    如果出现问题生产者是感知不到的,消息就丢失了。

    不过因为生产者不需要等待服务器响应,所以它可以以网络能够支持的最大速度发送消息,从而达到很高的吞吐量。

  • ack=1 ,默认值为1,只要集群的首领节点收到消息,生产者就会收到一个来自服务器的成功响应。

    如果消息无法达到首领节点(比如首领节点崩溃,新的首领还没有被选举出来),生产者会收到一个错误响应,为了避免数据丢失,生产者会重发消息。但是,这样还有可能会导致数据丢失,如果收到写成功通知,此时首领节点还没来的及同步数据到follower节点,首领节点崩溃,就会导致数据丢失。

  • ack=-1 , 只有当所有参与复制的节点都收到消息时,生产者才会收到一个来自服务器的成功响应。

    这种模式是最安全的,它可以保证不止一个服务器收到消息。

    注意:acks参数配置的是一个字符串类型,而不是整数类型,如果配置为整数类型会抛出parseType异常

retries:

作用:生产者从服务器收到的错误有可能是临时性的错误(比如分区找不到首领)。在这种情况下,如果达到了 retires 设置的次数,生产者会放弃重试并返回错误。

默认情况下,生产者会在每次重试之间等待 100ms,可以通过 retry.backoff.ms 参数来修改这个时间间隔。

batch-size:

作用:producer将试图批处理消息记录,以减少请求次数,这项配置控制默认的批量处理消息字节数,默认值16384,单位bytes

当有多个消息要被发送到同一分区时,生产者会把它们放在同一个批次里。该参数指定了一个批次可以使用的内存大小,按照字节数计算,而不是消息个数。

当批次被填满,批次里的所有消息会被发送出去。不过生产者并不一定都会等到批次被填满才发送,半满的批次,甚至只包含一个消息的批次也可能被发送。所以就算把 batch.size 设置的很大,也不会造成延迟,只会占用更多的内存而已,如果设置的太小,生产者会因为频繁发送消息而增加一些额外的开销。

max-request-size:

作用:该参数用于控制生产者发送的请求大小,它可以指定能发送的单个消息的最大值,也可以指单个请求里所有消息的总大小。

broker 对可接收的消息最大值也有自己的限制( message.max.size ),所以两边的配置最好匹配,避免生产者发送的消息被 broker 拒绝。

compression-type:

作用:生产者生成的所有数据的压缩类型,此配置接受标准压缩编解码器('gzip','snappy','lz4','zstd'),默认为none

消息发送类型

同步发送

//通过send()发送完消息后返回一个Future对象,然后调用Future对象的get()方法等待kafka响应
//如果kafka正常响应,返回一个RecordMetadata对象,该对象存储消息的偏移量
// 如果kafka发生错误,无法正常响应,就会抛出异常,我们便可以进行异常处理
producer.send(record).get();

异步发送

producer.send(record, new Callback() {
  public void onCompletion(RecordMetadata metadata, Exception exception) {
  if (exception == null) {
   System.out.println(metadata.partition() + ":" + metadata.offset());
  }
 }
});

序列化器

消息要到网络上进行传输,必须进行序列化,而序列化器的作用就是如此。
Kafka 提供了默认的

  • 字符串(org.apache.kafka.common.serialization.StringSerializer)序列化器

  • 整型(IntegerSerializer)序列化器,

  • 字节数组(BytesSerializer)序列化器,

这些序列化器都实现了接口(org.apache.kafka.common.serialization.Serializer),亦可自定义序列化器

分区器

kafka有自己的分区策略,如果未指定,就会使用默认的分区策略:
Kafka根据传递消息的key来进行分区的分配,即hash(key) % numPartitions。如果Key相同的话,那么
就会分配到统一分区。

源码:org.apache.kafka.clients.producer.internals.DefaultPartitioner

public int partition(String topic, Object key, byte[] keyBytes, Object value,byte[] valueBytes, Cluster cluster) {
    //获取topic分区数
   List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
   int numPartitions = partitions.size();
   if (keyBytes == null) {
     int nextValue = this.nextValue(topic);
     List<PartitionInfo> availablePartitions =cluster.availablePartitionsForTopic(topic);
     if (availablePartitions.size() > 0) {
       int part = Utils.toPositive(nextValue) % availablePartitions.size();
       return ((PartitionInfo)availablePartitions.get(part)).partition();
     } else {
       return Utils.toPositive(nextValue) % numPartitions;
     }
   } else {
     return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
   }
 }

自定义分区器:

配置:
props.put(ProducerConfig.PARTITIONER_CLASS_CONFIG,DefinePartitioner.class.getName());

package com.test;

import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.utils.Utils;

import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 自定义分区器
 */
public class DefinePartitioner implements Partitioner {
    private final AtomicInteger counter = new AtomicInteger(0);

    @Override
    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 (null == keyBytes) {
            return counter.getAndIncrement() % numPartitions;
        } else {
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    @Override
    public void close() {
    }

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

拦截器

Producer拦截器(interceptor)是个相当新的功能,它和consumer端interceptor是在Kafka 0.10版本被引入的,主要用于实现clients端的定制化控制逻辑。
生产者拦截器可以用在消息发送前做一些准备工作。
使用场景:

  • 按照某个规则过滤掉不符合要求的消息
  • 修改消息的内容
  • 统计类需求

实现自定义拦截器之后需要在配置参数中指定这个拦截器,此参数的默认值为空,如下:props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG,ProducerInterceptorPrefix.class.getName());

package com.heima.kafka.chapter2;

import org.apache.kafka.clients.producer.ProducerInterceptor;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;

import java.util.Map;

/**
 * 自定义拦截器:
 */
public class ProducerInterceptorPrefix implements ProducerInterceptor<String, String> {
    private volatile long sendSuccess = 0;
    private volatile long sendFailure = 0;

    //消息增加前缀
    @Override
    public ProducerRecord<String, String> onSend(ProducerRecord<String, String> 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 recordMetadata,Exception e) {
        if (e == null) {
            sendSuccess++;
        } else {
            sendFailure++;
        }
    }
	//输出成功率
    @Override
    public void close() {
        double successRatio = (double) sendSuccess / (sendFailure + sendSuccess);
        System.out.println("[INFO] 发送成功率="
                + String.format("%f", successRatio * 100) + "%");
    }

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

2.5消费者

消费者和消费组

Kafka消费者是消费组的一部分,当多个消费者形成一个消费组来消费主题时,每个消费者会收到不同分区的消息。

假设有一个T1主题,该主题有4个分区;同时我们有一个消费组G1,这个消费组只有一个消费者C1。那么消费者C1将会收到这4个分区的消息.

Kafka 一个很重要的特性就是,只需写入一次消息,可以支持任意多的应用读取这个消息。换句话说,每个应用都可以读到全量的消息。

为了使得每个应用都能读到全量消息,应用需要有不同的消费组。对于上面的例子,假如我们新增了一个新的消费组G2,而这个消费组有两个消费者,则这两个消费者每个会读到2个分区的消息

消费者参数

bootstrap-servers:

作用:指定 brokers 的地址清单,格式为 host:port。和KafkaProducer中的相同,制定连接Kafka集群所需的broker地址清单,可以设置一个或者多个

key.deserializer:

key的反序列化器.与KafkaProducer中设置保持一致

value.deserializer:

value的反序列化器。与KafkaProducer中设置保持一致

group.id:

消费者隶属于的消费组,默认为空,如果设置为空,则会抛出异常,这个参数要设置成具有一定业务含义的名称

max.poll.records:

这个参数控制一个poll()调用返回的记录数,这个可以用来控制应用在拉取循环中的处理数据量。

fetch.min.bytes

指定了消费者读取的最小数据量

fetch.max.wait.ms

这个参数则指定了消费者读取时最长等待时间,从而避免长时间阻塞。这个参数默认为500ms

max.partition.fetch.bytes

这个参数指定了每个分区返回的最多字节数,默认为1M

也就是说,KafkaConsumer.poll()返回记录列表时,每个分区的记录字节数最多为1M。如果一个主题有20个分区,同时有5个消费者,那么每个消
费者需要4M的空间来处理消息。实际情况中,我们需要设置更多的空间,这样当存在消费者宕机时,其他消费者可以承担更多的分区。

订阅主题和分区

通过调用subscribe()方法即可,这个方法接收一个主题列表

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(topic));
//也可以使用正则表达式来匹配多个主题,而且订阅之后如果又有匹配的新主题,那么这个消费组会立即对其进行消费。正则表达式在连接Kafka与其他系统时非常有用。
consumer.subscribe(Pattern.compile("testtopic*"));
// 指定订阅的分区 一般不指定分区
consumer.assign(Arrays.asList(new TopicPartition("topic0701", 0)));

//或者通过注解方式,从配置文件读取订阅的主题。指定批量消费。
@KafkaListener(topics = "${topicName.onDeviceOnlineTopic}", containerFactory = "batchFactory")

位移提交

对于Kafka中的分区而言,它的每条消息都有唯一的offset用来表示消息在分区中的位置
当我们调用poll()时,该方法会返回我们没有消费的消息。当消息从broker返回消费者时,broker并不跟踪这些消息是否被消费者接收到;

Kafka让消费者自身来管理消费的位移,并向消费者提供更新位移的接口,这种更新位移方式称为提交(commit)。

自动提交

这种方式让消费者来管理位移,应用本身不需要显式操作。当我们将enable.auto.commit设置为true,那么消费者会在poll方法调用后每隔5秒(由auto.commit.interval.ms指定)提交一次位移

和很多其他操作一样,自动提交也是由poll()方法来驱动的;

在调用poll()时,消费者判断是否到达提交时间,如果是则提交上一次poll返回的最大位移。
需要注意到,这种方式可能会导致消息重复消费。假如,某个消费者poll消息后,应用正在处理消息,在3秒后Kafka进行了重平衡,那么由于没有更新位移导致重平衡后这部分消息重复消费。

同步提交

    // 修改配置:关闭自动提交,即手动提交开启
    ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG设为false
    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);


        TopicPartition tp = new TopicPartition(topic, 0);
        consumer.assign(Arrays.asList(tp));
        long lastConsumedOffset = -1;
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(1000);
            if (records.isEmpty()) {
                break;
            }
            List<ConsumerRecord<String, String>> partitionRecords = records.records(tp);
            lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
            consumer.commitSync();//同步提交消费位移
        }
        System.out.println("comsumed offset is " + lastConsumedOffset);
        OffsetAndMetadata offsetAndMetadata = consumer.committed(tp);
        System.out.println("commited offset is " + offsetAndMetadata.offset());
        long posititon = consumer.position(tp);
        System.out.println("the offset of the next record is " + posititon);
    }

异步提交

手动提交有一个缺点,那就是当发起提交调用时应用会阻塞。

当然我们可以减少手动提交的频率,但这个会增加消息重复的概率(和自动提交一样)。

另外一个解决办法是,使用异步提交的API。

但是异步提交也有个缺点,那就是如果服务器返回提交失败,异步提交不会进行重试。相比较起来,同步提交会进行重试直到成功或者最后抛出异常给应用。

异步提交没有实现重试是因为,如果同时存在多个异步提交,进行重试可能会导致位移覆盖。

举个例子,假如我们发起了一个异步提交commitA,此时的提交位移为2000,随后又发起了一个异步提交commitB且位移为3000;commitA提交失败但
commitB提交成功,此时commitA进行重试并成功的话,会将实际上将已经提交的位移从3000回滚到2000,导致消息重复消费。

package com.test;

import com.heima.kafka.ConsumerClientConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import java.util.Arrays;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 异步提交
 */
@Slf4j
public class OffsetCommitAsyncCallback extends ConsumerClientConfig {

    private static AtomicBoolean running = new AtomicBoolean(true);

    public static void main(String[] args) {
        Properties props = initConfig();
        //创建消费者,订阅主题
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList(topic));

        try {
            while (running.get()) {
                //拉取1000条消息
                ConsumerRecords<String, String> records = consumer.poll(1000);
                for (ConsumerRecord<String, String> record : records) {
                    //do some logical processing.
                }
                // 异步回调
                consumer.commitAsync(new OffsetCommitCallback() {
                    @Override
                    public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets,Exception exception) {
                        if (exception == null) {
                            System.out.println(offsets);
                        } else {
                            log.error("fail to commit offsets {}", offsets, exception);
                        }
                    }
                });
            }
        } finally {
            consumer.close();
        }

        try {
            while (running.get()) {
                consumer.commitAsync();
            }
        } finally {
            try {
                consumer.commitSync();
            } finally {
                consumer.close();
            }
        }
    }
}

指定位移消费

到目前为止,我们知道消息的拉取是根据poll()方法中的逻辑来处理的,但是这个方法对于普通开发人员来说就是个黑盒处理,无法精确掌握其消费的起始位置。
seek()方法正好提供了这个功能,让我们得以追踪以前的消费或者回溯消费。

package com.test;

import com.heima.kafka.ConsumerClientConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.TopicPartition;
import java.time.Duration;
import java.util.Arrays;
import java.util.Properties;
import java.util.Set;

/**
 * 指定位移消费
 */
public class SeekDemo extends ConsumerClientConfig {


    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList(topic));
        // timeout参数设置多少合适?太短会使分区分配失败,太长又有可能造成一些不必要的等待
        consumer.poll(Duration.ofMillis(2000));
        // 获取消费者所分配到的分区 判断是否分配到了分区
        Set<TopicPartition> assignment = new HashSet<>();
        while (assignment.size() == 0) {
            consumer.poll(Duration.ofMillis(100));
            assignment = consumer.assignment();
        }
        
        System.out.println(assignment);
        for (TopicPartition tp : assignment) {
            // 参数1:partition表示分区,参数2:offset表示指定从分区的哪个位置开始消费
            consumer.seek(tp, 10);
        }
//        consumer.seek(new TopicPartition(topic,0),10);
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            //consume the record.
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record.offset() + ":" + record.value());
            }
        }
    }

}

再均衡监听器

再均衡是指分区的所属从一个消费者转移到另外一个消费者的行为,它为消费组具备了高可用性和伸缩性提供了保障,使得我们既方便又安全地删除消费组内的消费者或者往消费组内添加消费者。

不过再均衡发生期间,消费者是无法拉取消息的

package com.test;

import com.heima.kafka.ConsumerClientConfig;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * 再均衡监听器
 */
public class CommitSyncInRebalance extends ConsumerClientConfig {
    public static final AtomicBoolean isRunning = new AtomicBoolean(true);


    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
        consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
            @Override
            public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
                // 劲量避免重复消费
                consumer.commitSync(currentOffsets);
            }

            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
                //do nothing.
            }
        });

        try {
            while (isRunning.get()) {
                ConsumerRecords<String, String> records =  consumer.poll(Duration.ofMillis(1000));
                for (ConsumerRecord<String, String> record : records) {
                    System.out.println(record.offset() + ":" + record.value());
                    // 异步提交消费位移,在发生再均衡动作之前可以通过再均衡监听器的onPartitionsRevoked回调执行commitSync方法同步提交位移。
                    currentOffsets.put(new TopicPartition(record.topic(), record.partition()),
                            new OffsetAndMetadata(record.offset() + 1));
                }
                consumer.commitAsync(currentOffsets, null);
            }
        } finally {
            consumer.close();
        }

    }
}

消费者拦截器

生产者有拦截器,对应的消费者也有相应的拦截器概念,消费者拦截器主要是在消费到消息或者在提交消费位移时进行的一些定制化的操作。

package com.test;

import org.apache.kafka.clients.consumer.ConsumerInterceptor;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 消费者拦截器
 */
public class ConsumerInterceptorTTL implements ConsumerInterceptor<String, String> {
    private static final long EXPIRE_INTERVAL = 10 * 1000;
	
    @Override
    public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
        System.out.println("before:" + records);
        long now = System.currentTimeMillis();
        Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords = new HashMap<>();
        //遍历分区
        for (TopicPartition tp : records.partitions()) {
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
            //遍历读取到的消息,如果消息未超时10秒,则放入newTpRecords
            for (ConsumerRecord<String, String> record : tpRecords) {
                if (now - record.timestamp() < EXPIRE_INTERVAL) {
                    newTpRecords.add(record);
                }
            }
            if (!newTpRecords.isEmpty()) {
                newRecords.put(tp, newTpRecords);
            }
        }
        return new ConsumerRecords<>(newRecords);
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp, offset) ->
                System.out.println(tp + ":" + offset.offset()));
    }

    @Override
    public void close() {
    }

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

配置使用自定义拦截器:

// 指定消费者拦截器
props.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG,ConsumerInterceptorTTL.class.getName());

效果演示
发送端同时发送两条消息,其中一条修改timestamp的值来使其变得超时,如下:

ProducerRecord<String, String> record = new ProducerRecord<>(topic, "Kafka-demo-001", "hello, Kafka!");
ProducerRecord<String, String> record2 = new ProducerRecord<>(topic, 0,System.currentTimeMillis() - 10 * 1000, "Kafka-demo-001", "hello, Kafka!->超时");

启动消费端运行,只收到了未超时的消息.

2.6主题topic

创建主题

bin/kafka-topics.sh --zookeeper localhost:2181 --create --topic heima --partitions 2 --replication-factor 1

  • localhost:2181 zookeeper所在的ip,zookeeper 必传参数,多个zookeeper用 ‘,’分开。
  • partitions 用于设置主题分区数,每个线程处理一个分区数据
  • replication-factor 用于设置主题副本数,每个副本分布在不通节点,不能超过总结点数。如你只有一个节点,但是创建时指定副本数为2,就会报错。

查看topic元数据的方法

topic元数据信细保存在Zookeeper节点中

#连接zookeeper
root@Server-node:/mnt/d/zookeeper-3.4.14$ bin/zkCli.sh -server localhost:2181
Connecting to localhost:2181
...........................................
# 查看主题元数据
[zk: localhost:2181(CONNECTED) 2] get /brokers/topics/testtopic
{"version":1,"partitions":{"1":[0],"0":[0]}}
cZxid = 0x618
ctime = Wed Aug 28 05:51:35 GMT 2019
mZxid = 0x618
mtime = Wed Aug 28 05:51:35 GMT 2019
pZxid = 0x619
cversion = 1
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 44
numChildren = 1
[zk: localhost:2181(CONNECTED) 3]

查看主题列表:

bin/kafka-topics.sh --list --zookeeper localhost:2181

查看某个主题信息

不指定topic则查询所有 通过 --describe

bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic testtopic

修改主题

  • 修改配置 bin/kafka-topics.sh --alter --zookeeper localhost:2181 --topic testtopic --config flush.messages=1

  • 删除配置 bin/kafka-topics.sh --alter --zookeeper localhost:2181 --topic testtopic --delete-config flush.messages

删除主题

  • 若 delete.topic.enable=true  直接彻底删除该 Topic。
  • 若 delete.topic.enable=false  如果当前Topic 没有使用过即没有传输过信息:可以彻底删除。
  • 若当前 Topic 有使用过即有过传输过信息:并没有真正删除 Topic,只是把这个 Topic 标记为删除(marked for deletion),重启 Kafka Server 后删除。

bin/kafka-topics.sh --delete --zookeeper localhost:2181 --topic testtopic

//删除后查看topic列表: 标记为 marked for deletion
root@Server-node:/mnt/d/kafka_2.12-2.2.1$ bin/kafka-topics.sh --list --zookeeper localhost:2181
topic01
testtopic - marked for deletion

增加分区

修改分区数时,仅能增加分区个数。若是用其减少 partition 个数,则会报错:Error while executing topic command : The number of partitions for a topic can only be increased.

bin/kafka-topics.sh --alter --zookeeper localhost:2181 --topic testtopic --partitions 3

KafkaAdminClient应用

我们都习惯使用Kafka中bin目录下的脚本工具来管理查看Kafka,但是有些时候需要将某些管理查看的功能集成到系统(比如Kafka Manager)中,那么就需要调用一些API来直接操作Kafka了。

package com.test;

import org.apache.kafka.clients.admin.*;
import org.apache.kafka.common.config.ConfigResource;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ExecutionException;

/**
 * KafkaAdminClient应用
 */
public class KafkaAdminConfigOperation {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
//        describeTopicConfig();
//        alterTopicConfig();
        addTopicPartitions();
    }

    //获取主题信息
    public static void describeTopicConfig() throws ExecutionException, InterruptedException {
        String brokerList =  "localhost:9092";
        String topic = "testtopic";
		//配置信息
        Properties props = new Properties();
        props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, 30000);
        AdminClient client = AdminClient.create(props);

        ConfigResource resource =  new ConfigResource(ConfigResource.Type.TOPIC, topic);
        DescribeConfigsResult result = client.describeConfigs(Collections.singleton(resource));
        Config config = result.all().get().get(resource);
        System.out.println(config);
        client.close();
    }
	//修改配置
    public static void alterTopicConfig() throws ExecutionException, InterruptedException {
        String brokerList =  "localhost:9092";
        String topic = "testtopic";
		//配置信息
        Properties props = new Properties();
        props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, 30000);
        AdminClient client = AdminClient.create(props);

        ConfigResource resource =
                new ConfigResource(ConfigResource.Type.TOPIC, topic);
        ConfigEntry entry = new ConfigEntry("cleanup.policy", "compact");
        Config config = new Config(Collections.singleton(entry));
        Map<ConfigResource, Config> configs = new HashMap<>();
        configs.put(resource, config);
        AlterConfigsResult result = client.alterConfigs(configs);
        result.all().get();

        client.close();
    }
	//增加分区
    public static void addTopicPartitions() throws ExecutionException, InterruptedException {
        String brokerList =  "localhost:9092";
        String topic = "testtopic";
		//配置信息
        Properties props = new Properties();
        props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, 30000);
        AdminClient client = AdminClient.create(props);

        NewPartitions newPartitions = NewPartitions.increaseTo(5);
        Map<String, NewPartitions> newPartitionsMap = new HashMap<>();
        newPartitionsMap.put(topic, newPartitions);
        CreatePartitionsResult result = client.createPartitions(newPartitionsMap);
        result.all().get();

        client.close();
    }
}

2.7分区

Kafka可以将主题划分为多个分区(Partition),会根据分区规则选择把消息存储到哪个分区中,如果分区规则设置的合理,那么所有的消息将会被均匀的分布到不同的分区中,这样就实现了负载均衡和水平扩展。

另外,多个订阅者可以从一个或者多个分区中同时消费数据,以支撑海量数据处理能力。
顺便说一句,由于消息是追加到分区中的,多个分区顺序写磁盘的总效率要比随机写内存还要高(引用Apache Kafka – A High Throughput Distributed Messaging System的观点),是Kafka高吞吐率的重要保证之一。

副本机制

由于Producer和Consumer都只会与Leader角色的分区副本相连,所以kafka需要以集群的组织形式提供主题下的消息高可用。kafka支持主备复制,所以消息具备高可用和持久性。
一个分区可以有多个副本,这些副本保存在不同的broker上。

每个分区的副本中都会有一个作为Leader。当一个broker失败时,Leader在这台broker上的分区都会变得不可用,kafka会自动移除Leader,再其他副本中选一个作为新的Leader。
在通常情况下,增加分区可以提供kafka集群的吞吐量。然而,也应该意识到集群的总分区数或是单台服务器上的分区数过多,会增加不可用及延迟的风险。

分区Leader选举

可以预见的是,如果某个分区的Leader挂了,那么其它跟随者将会进行选举产生一个新的leader,之后所有的读写就会转移到这个新的Leader上.

在kafka中,不是采用常见的多数选举的方式进行副本的Leader选举,而是会在Zookeeper上针对每个Topic维护一个称为ISR(in-sync replica,已同步的副本)的集合,显然还有一些副本没有来得及同步。只有这个ISR列表里面的才有资格成为leader(先使用ISR里面的第一个,如果不行依次类推,因为ISR里面的是同步副本,消息是最完整且各个节点都是一样的)。

通过ISR,kafka需要的冗余度较低,可以容忍的失败数比较高。假设某个topic有f+1个副本,kafka可以容忍f个不可用,当然,如果全部ISR里面的副本都不可用,也可以选择其他可用的副本,只是存在数据的不一致。

分区重新分配

我们往已经部署好的Kafka集群里面添加机器是最正常不过的需求,而且添加起来非常地方便,我们需要做的事是从已经部署好的Kafka节点中复制相应的配置文件,然后把里面的broker id修改成全局唯一的,最后启动这个节点即可将它加入到现有Kafka集群中。
但是问题来了,新添加的Kafka节点并不会自动地分配数据,所以无法分担集群的负载,除非我们新建一个topic。

但是现在我们想手动将部分分区移到新添加的Kafka节点上,Kafka内部提供了相关的工具来重新分布某个topic的分区。

第一步:我们创建一个有三个节点的集群.

创建topic,指定分区数为3。

bin/kafka-topics.sh --create --zookeeper localhost:2181 --topic testtopic-par --partitions 3 --replication-factor 3

查看topic详情:

#从下面的输出可以看出 testtopic-par这个主题一共有三个分区,有三个副本
root@Server-node:/mnt/d/kafka-cluster/kafka-1$ bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic testtopic-par
Topic:testtopic-par PartitionCount:3    ReplicationFactor:3   Configs:
Topic: testtopic-par    Partition: 0  Leader: 2    Replicas: 2,1,0 Isr: 2,1,0
Topic: testtopic-par    Partition: 1  Leader: 0    Replicas: 0,2,1 Isr: 0
Topic: testtopic-par    Partition: 2  Leader: 1    Replicas: 1,0,2 Isr: 1,0,2

第二步:主题 heima-par再添加一个分区

bin/kafka-topics.sh --alter --zookeeper localhost:2181 --topic testtopic-par --partitions 4

查看topic详情:

#这样会导致 broker2维护更多的分区
root@Server-node:/mnt/d/kafka-cluster/kafka-1$ bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic testtopic-par
Topic:testtopic-par PartitionCount:4    ReplicationFactor:3   Configs:
Topic: testtopic-par    Partition: 0  Leader: 2    Replicas: 2,1,0 Isr:2,1,0
Topic: testtopic-par    Partition: 1  Leader: 0    Replicas: 0,2,1 Isr: 0
Topic: testtopic-par    Partition: 2  Leader: 1    Replicas: 1,0,2 Isr:1,0,2
Topic: testtopic-par    Partition: 3  Leader: 2    Replicas: 2,1,0 Isr:2,1,0

第三步:再添加一个 broker节点

添加节点后查看topic详情:

#从下面输出信息可以看出新添加的节点并没有分配之前主题的分区
root@Server-node:/mnt/d/kafka-cluster/kafka-1$ bin/kafka-topics.sh --describe -zookeeper localhost:2181 --topic testtopic-par
Topic:testtopic-par PartitionCount:4    ReplicationFactor:3   Configs:
Topic: testtopic-par    Partition: 0  Leader: 2    Replicas: 2,1,0 Isr:2,1,0
Topic: testtopic-par    Partition: 1  Leader: 0    Replicas: 0,2,1 Isr: 0
Topic: testtopic-par    Partition: 2  Leader: 1    Replicas: 1,0,2 Isr:1,0,2
Topic: testtopic-par    Partition: 3  Leader: 2    Replicas: 2,1,0 Isr:2,1,0

第四步:重新分配

现在我们需要将原先分布在broker 1-3节点上的分区重新分布到broker 1-4节点上,借助kafka-reassign-partitions.sh工具生成reassign plan,不过我们先得按照要求定义一个文件,里面说明哪些topic需要重新分区,文件内容如下:

root@Server-node:/mnt/d/kafka-cluster/kafka-1$ cat reassign.json
{"topics":[{"topic":"testtopic-par"}],
"version":1
}

然后使用 kafka -reassign-partitions.sh 工具生成reassign plan:
bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --topics-to-move-json-file reassign.json --broker-list "0,1,2,3" --generate

  • --generate 表示指定类型参数
  • --topics-to-move-json-file 指定分区重分配对应的主题清单路径

注意:
命令输出两个Json字符串,第一个JSON内容为当前的分区副本分配情况,第二个为重新分配的候选方案,注意这里只是生成一份可行性的方案,并没有真正执行重分配的动作。

我们将第二个JSON内容保存到名为result.json文件里面(文件名不重要,文件格式也不一定要以json为结尾,只要保证内容是json即可),然后根据这个reassign plan执行分配策略:bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --reassignment-json-file result.json --execute

查看分区重新分配的进度:

#从下面信息可以看出 heima-par-3已经完成,其他三个正在进行中。
bin/kafka-reassign-partitions.sh --zookeeper localhost:2181 --reassignment-json-file result.json --verify
Status of partition reassignment:
Reassignment of partition heima-par-3 completed successfully
Reassignment of partition heima-par-0 is still in progress
Reassignment of partition heima-par-2 is still in progress
Reassignment of partition heima-par-1 is still in progress

分区分配策略

按照Kafka默认的消费逻辑,一个分区只能被同一个消费组(ConsumerGroup)内的一个消费者消费。

假设目前某消费组内只有一个消费者C0,订阅了一个topic,这个topic包含7个分区,也就是说这个消费者C0订阅了7个分区,此时消费组内又加入了一个新的消费者 C1,按照既定的逻辑需要将原来消费者C0的部分分区分配给消费者C1消费,消费者C0和C1各自负责消费所分配到的分区,相互之间并无实质性的干扰。

接着消费组内又加入了一个新的消费者C2,如此消费者C0、C1和C2按各自负责消费所分配到的分区。

如果消费者过多,出现了消费者的数量大于分区的数量的情况,就会有消费者分配不到任何分区。例如一共有8个消费者(C0~C7),7个分区,那么最后的消费者C7由于分配不到任何分区进而就无法消费任何消息。

  • RangeAssignor分配策略
    源码:org.apache.kafka.clients.consumer.RangeAssignor

    原理:按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。

    对于每一个topic,RangeAssignor策略会将消费组内所有订阅这个topic的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。

    假设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
    

    可以明显的看到这样的分配并不均匀,如果将类似的情形扩大,有可能会出现部分消费者过载的情况。

posted on 2022-07-26 17:20  bgtong  阅读(40)  评论(0编辑  收藏  举报