4. Kafka

1. 概述

官网:Apache Kafka

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

新定义: Kafka是一个开源的分布式事件流平台(Event Streaming Platform),被数千家公司用于高性能数据管道、流分析、数据集成和关键任务应用。

传统的消息队列的主要应用场景包括:缓存/消峰、解耦和异步通信。

  • 缓冲/消峰:有助于控制和优化数据流经过系统的速度,解决生产消息和消费消息的处理速度不一致的情况。

  • 允许独立的扩展或修改两边的处理过程,只要确保它们遵守同样的接口约束。

  • 允许用户把一个消息放入队列,并不立即处理它(不需要等待不那么重要的系统流程)。

两种常见的模式:

  1. 点对点模式:消费者主动拉取数据,消息收到后清除数据
  2. 发布/订阅模式:可以有多个topic主题;消费者消费数据之后,不删除数据;每个消费者相互独立,都可以消费到数据

基础架构

  1. 为了方便拓展&提高吞吐量,一个topic分为多个partition
  2. 配合分区的设计,突出消费者组的概念,组内每个消费者并行消费
  3. 为提高可用性,为每个partition增加若干个副本
  4. ZK中记录这Leader的信息(2.8.0版本以后可以不采用ZK)

工作示意图:

f8dc7ddc2e2580bad5a310935a4ba87b.png

  • 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:副本。一个 topic 的每个分区都有若干个副本,一个 Leader 和若干个 Follower。
  • Leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是 Leader。
  • Follower:每个分区多个副本中的“从”,实时从 Leader 中同步数据,保持和 Leader 数据的同步。Leader 发生故障时,某个 Follower 会成为新的 Leader。

安装

清华镜像:Index of /apache/kafka (tsinghua.edu.cn)

# 解压
tar -zxvf kafka_2.13-3.6.0.tgz -C /opt/app/
# 修改名称
mv kafka_2.13-3.6.0/ kafka
# 修改配置文件
vim kafka/config/server.properties
# broker 的全局唯一编号,不能重复,只能是数字。
broker.id=0
# 处理网络请求的线程数量
num.network.threads=3
# 用来处理磁盘 IO 的线程数量
num.io.threads=8
# 发送套接字的缓冲区大小
socket.send.buffer.bytes=102400
# 接收套接字的缓冲区大小
socket.receive.buffer.bytes=102400
# 请求套接字的缓冲区大小
socket.request.max.bytes=104857600
# kafka 运行日志(数据)存放的路径,路径不需要提前创建,kafka 自动帮你创建
# 可以配置多个磁盘路径,路径与路径之间可以用","分隔
log.dirs=/tmp/kafka-logs
# topic 在当前 broker 上的分区个数
num.partitions=1
# 用来恢复和清理 data 下数据的线程数量
num.recovery.threads.per.data.dir=1
# 每个topic创建时的副本数,默认时 1 个副本
offsets.topic.replication.factor=1
# segment文件保留的最长时间,超时将被删除
log.retention.hours=168
# 每个 segment 文件的大小,默认最大 1G
log.segment.bytes=1073741824
# 检查过期数据的时间,默认 5 分钟检查一次是否数据过期
log.retention.check.interval.ms=300000
# 配置连接 Zookeeper 地址(在 zk 根目录下创建/kafka,方便管理,集群使用,分隔)
zookeeper.connect=localhost:2181

配置环境变量:

vim /etc/profile.d/kafka.sh
#KAFKA_HOME
export KAFKA_HOME=/opt/module/kafka
export PATH=$PATH:$KAFKA_HOME/bin
# 刷新环境变量
source /etc/profile

启动

启动Kafka前需要先启动Zookeeper

./zk.sh start
# 启动Kafka
cd /opt/app/kafka
bin/kafka-server-start.sh -daemon config/server.properties
# 关闭
bin/kafka-server-stop.sh

应为Zookeeper和Kafka都是Java开发,查看当前进程:

[root@rabbitmq kafka]# jps
1345 QuorumPeerMain
10916 Jps
3034 Kafka

脚本:

#! /bin/bash
case $1 in
"start"){
 echo " --------启动 $i Kafka-------"
 /opt/app/kafka/bin/kafka-server-start.sh -daemon /opt/app/kafka/config/server.properties
};;
"stop"){
 echo " --------停止 $i Kafka-------"
 /opt/app/kafka/bin/kafka-server-stop.sh
};;
esac

此时连接Zookeeper的客户端:

bin/zkCli.sh

[zk: localhost:2181(CONNECTED) 0] ls /
[admin, brokers, cluster, config, consumers, controller, controller_epoch, feature, isr_change_notification, latest_producer_id_block, log_dir_event_notification, zookeeper]

/brokers/ids/下就绑定着我们在配置文件设定的broker.id

命令行操作

主题

查看命令参数:

bin/kafka-topics.sh
参数 描述
--bootstrap-server <String: server toconnect to> 连接的 Kafka Broker 主机名称和端口号
--topic <String: topic> 操作的 topic 名称
--create 创建主题
--delete 删除主题
--alter 修改主题
--list 查看所有主题
--describe 查看主题详细描述
--partitions <Integer: # of partitions> 设置分区数
--replication-factor <Integer: replication factor> 设置分区副本
--config <String: name=value> 更新系统默认的配置

示例:

# 查看所有topic
bin/kafka-topics.sh --bootstrap-server localhost:9092 --list
# 创建1分区1副本的topic
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --partitions 1 --replication-factor 1 --topic topic1
# 查看 topic1 主题的详情
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic topic1
# 修改分区数(注意:分区数只能增加,不能减少)
bin/kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic topic1 --partitions 3
# 删除主题
bin/kafka-topics.sh --bootstrap-server localhost:9092 --delete --topic topic1

生产者

查看命令参数:

bin/kafka-console-producer.sh
参数 描述
--bootstrap-server <String: server toconnect to> 连接的 Kafka Broker 主机名称和端口号
--topic <String: topic> 操作的 topic 名称

示例:

# 发送消息
bin/kafka-console-producer.sh --bootstrap-server 127.0.0.1:9092 --topic topic1

这里不能识别localhost:DNS resolution failed for loaclhost

消费者

查看命令参数:

bin/kafka-console-consumer.sh
参数 描述
--bootstrap-server <String: server toconnect to> 连接的 Kafka Broker 主机名称和端口号
--topic <String: topic> 操作的 topic 名称
--from-beginning 从头开始消费
--group <String: consumer group id> 指定消费者组名称

示例:

# 从当前时间开始消费
bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 --topic topic1
# 消费所有数据
bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 --from-beginning --topic topic1

2. 生产者

流程讲解

在消息发送的过程中,涉及到了两个线程——main 线程和 Sender 线程。在 main 线程中创建了一个双端队列 RecordAccumulator。main 线程将消息发送给 RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka Broker。

36731856a8c24bd0e7f00aef59ceb1a5.png

是Sender线程主动去拉数据。

batch.size:只有数据积累到batch.size之后,sender才会发送数据。默认16k

linger.ms:如果数据迟迟未达到batch.size,sender等待linger.ms设置的时间 到了之后就会发送数据。单位ms,默认值是0ms,表示没有延迟。

  • ProducerRecord: 消息对象。
  • Interceptor: 拦截器,Kafka一共有两种拦截器:生产者拦截器和消费者拦截器。生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
  • Partitioner: 分区器,消息经过序列化之后确定它发往的分区
  • RecordAccumulator: 主要用来缓存消息以便Sender线程可以批量发送,进而减少网络传输的资源消耗以提升性能。RecordAccumulator缓存的大小可以通过生产者客户端参数buffer.memory配置,默认值为32M。如果生产者发送消息的速度超过发送到服务器的速度,则会导致生产者空间不足,这个时候Producer的send()方法调用要么被阻塞,要么抛出异常,这个取决于参数max.block.ms的配置,此参数的默认值为60秒。主线程中发送过来的消息都会被迫加到RecordAccumulator的某个双端队列(Deque)中,在RecordAccumulator的内部为每个分区都维护了一个双端队列,队列中的内容就是ProducerBatch,即Deque。消息写入缓存时,追加到双端队列的尾部:Sender读取消息时,从双端队列的头部读取。

RecordAccumulator内部还有个BufferPool,主要用来复用特定大小的ProducerBatch块,这个特定大小会通过batch.size指定,默认为16KB,例如说当有一条消息(ProducerRecord)流入RecordAccumulator,会先通过分区号寻找双端队列(如果没有则创建),再从队列的尾部获取一个ProducerBatch(如果没有则新建),查看ProducerBatch是否还可以装的下这个ProducerRecord,如果可以则写入,如果不可以则需创建个新的ProducerBatch。在新建ProducerBatch时会评估这个ProducerRecord的大小是否超过batch.size,也就是可复用ProducerBatch的大小,如果没超过,那么可以从BufferPool中拿个闲置的ProducerBatch来使用。如果超过,那么就按实际的大小创建ProducerBatch,这个ProducerBatch不会再被复用。

  • InFlightRequests: 请求在从Sender线程发往Kafka之前还会保存到InFlightRequests中,InFlightRequests保存对象的具体形式为Map<Nodeld,Deque>,它的主要作用是缓存了已经发出去但还没有收到响应的请求(Nodeld表示broker节点的id编号)。InFlightRequests还提供了许多管理类的方法,并且通过配置参数还可以限制每个连接(也就是客户端与Node之间的连接)最多缓存的请求数。这个配置参数为max.in.flight.requests.per.connection,默认值为5,即每个连接最多只能缓存5个未响应的请求,超过该数值之后就不能再向这个连接发送更多的请求了,除非有缓存的请求收到了响应。InFlightRequests还可以获得leastLoadedNode,即所有Node中负载最小的那一个。选择leastLoadedNode发送请求可以使它能够尽快发出,避免因网络拥塞等异常而影响整体的进度。比如元数据请求,当需要更新元数据时,会先挑选出leastLoadedNode,然后向这个Node发送MetadataRequest请求来获取具体的元数据信息。

应答机制

分区收到生产者发送的数据后,都需要向生产者发送ack参数可设置的值为01all(-1)

  • 0:代表producer往集群发送数据不需要等到集群的返回,不确保消息发送成功。安全性最低但是效率最高。
  • 1:代表producer往集群发送数据只要leader应答就可以发送下一条,只确保leader发送成功。
  • all:默认值。代表producer往集群发送数据需要ISR都完成从leader的同步才会发送下一条,确保leader发送成功和所有的副本都完成备份。安全性最高,但是效率最低

当ACK=all时,Leader和follower(ISR)落盘才会返回ack,会有数据重复现象,如果在leader已经写完成,且follower同步完成,但是在返回ack的出现故障,则会出现数据重复现象;极限情况下,这个也会有数据丢失的情况,比如follower和leader通信都很慢,所以ISR中只有一个leader节点,这个时候,leader完成落盘,就会返回ack,如果此时leader故障后,就会导致丢失数据。

ISR

为了防止一个follower的宕机导致集群的瘫痪(所有follower都同步信息的条件不能达成,导致不能应答),Leader中维护了一个动态的in-sync replica set(ISR),意为和Leader保持同步的Follower+Leader集合(leader:0,isr:0,1,2)。

如果Follower长时间未向Leader发送通信请求或同步数据,则该Follower将被踢出ISR。该时间阈值由replica.lag.time.max.ms参数设定,默认30s。例如2超时,(leader:0, isr:0,1)。这样就不用等长期联系不上或者已经故障的节点。

重试机制

重要参数

  • retry.backoff.ms:两次重试之间的时间间隔,默认是 100ms。
  • retries:重试次数。默认是 int 最大值,2147483647。

当消费者接收数据失败或者响应时间超过了设置的retry.backoff.ms参数时,Selecto会根据设置的retries参数进行重试。

如果设置了重试,同时想要保证消息的有序性,需要设置MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION=1 ,避免在其之后消息的在重试时被发送出去。

  • enable.idempotence:是否开启幂等性,默认 true
  • compression.type:生产者发送的所有数据的压缩方式,默认是 none。支持压缩类型:none、gzip、snappy、lz4 和 zstd。

消息发送

异步发送

异步发送就是非阻塞的,即发送消息后不会等待消息是否成功发送到 Kafka。

代码示例:

<!-- 导入依赖 -->
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>3.5.1</version>
</dependency>
public static void main(String[] args) {
    Properties properties = new Properties();
    // 配置Kafka服务地址
    properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置key的序列化
    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 配置value的序列化
    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 自动关闭
    try (KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties)) {
        ProducerRecord<String, String> producerRecord = new ProducerRecord<>("topic1", "在吗?");
        // 不带回调函数
        kafkaProducer.send(producerRecord);
        // 带回调函数
        kafkaProducer.send(new ProducerRecord<>("topic1", "你好!"), new Callback() {
            @Override
            public void onCompletion(RecordMetadata recordMetadata, Exception e) {
                if (e == null) {
                    System.out.println("主题:" + recordMetadata.topic() + "->" + "分区:" + recordMetadata.partition());
                } else {
                    System.out.println(e.getMessage());
                }
            }
        });
        System.out.println("运行完成!");
    }
}

注意,kafka配置文件中要进行以下设置,否则不能远程连接:

listeners=PLAINTEXT://localhost:9092

可以事先在xShell中开启一个消费者,以便更好的观察效果。

消息发送失败会自动重试,不需要我们在回调函数中手动重试。

同步发送

只需要在异步发送的基础上再调用一下 get()方法即可。

kafkaProducer.send(producerRecord).get();

异步和同步获取RecordMetadata的时机不同:

  • 同步发送的 send 方法会将消息添加到内部的缓冲区,但它不会立即返回,get 调用会一直阻塞,直到消息成功发送到 Kafka 或者发送出错,然后返回 RecordMetadata
  • 异步发送的 send 方法时,它会将消息添加到内部的缓冲区,并立即返回,Kafka 生产者会有一个后台线程,负责将缓冲区中的消息异步发送到 Kafka 服务器。

比如在异步调用的例子中,运行成功的提示是先打印的。

分区

分区的好处:

  • 便于合理使用存储资源,每个Partition在一个Broker上存储,可以把海量的数据按照分区切割成一 块一块数据存储在多台Broker上。合理控制分区的任务,可以实现负载均衡的效果。
  • 提高并行度,生产者可以以分区为单位发送数据;消费者可以以分区为单位进行消费数据。

默认分区策略

默认的分区器 DefaultPartitioner

  1. 指明partition的情况下,直接将指明的值作为partition值;例如partition=0,所有数据写入分区0
  2. 没有指明partition值但有key的情况下,将key的hash值与topic的partition数进行取余得到partition值;
  3. 既没有partition值又没有key值的情况下(此时key默认为空串),Kafka采用Sticky Partition(黏性分区器),会随机选择一个分区,并尽可能一直使用该分区,待该分区的batch已满或者已完成,Kafka再随机一个分区进行使用(和上一次的分区不同)。

简单测试:

for (int i = 0; i < 5; i++) {
    kafkaProducer.send(new ProducerRecord<>("topic1", 1, "test" + i, "test1"), (metadata, e) -> {
        System.out.println("test1" + "->" + "分区:" + metadata.partition());
    });
    kafkaProducer.send(new ProducerRecord<>("topic1", "test" + i, "test2"), (metadata, e) -> {
        System.out.println("test2" + "->" + "分区:" + metadata.partition());
    });
    kafkaProducer.send(new ProducerRecord<>("topic1", "test3"), (metadata, e) -> {
        System.out.println("test3" + "->" + "分区:" + metadata.partition());
    });
}

自定义分区器

public class MyPartitioner implements Partitioner {

    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        if (key.toString().contains("1")) {
            return 1;
        } else if (key.toString().contains("2")) {
            return 2;
        }
        return 0;
    }

    @Override
    public void close() {

    }

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

    }
}

装配:

public static void main(String[] args) {
    Properties properties = new Properties();
    // 配置Kafka服务地址
    properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置key的序列化
    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 配置value的序列化
    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 配置自定义分区器
    properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, MyPartitioner.class.getName());
    try (KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties)) {
        for (int i = 0; i < 5; i++) {
            System.out.println(i);
            // 为了方便观察分区器的效果,这里使用同步发送
            kafkaProducer.send(new ProducerRecord<>("topic1", "test" + i, "test2"), (metadata, e) -> {
                System.out.println("test2" + "->" + "分区:" + metadata.partition());
            }).get();
        }
    } catch (Exception e) {
            throw new RuntimeException(e);
        }
}

参数调整

提高吞吐量

调整吞吐量的重要参数:

  • batch.size:批次大小,默认16k
  • linger.ms:等待时间,修改为5-100ms
  • compression.type:压缩,常用 snappy
  • RecordAccumulator:缓冲区大小,修改为64m
// batch.size:批次大小,默认 16K
 properties.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
// linger.ms:等待时间,单位ms,默认 0;
 properties.put(ProducerConfig.LINGER_MS_CONFIG, 1);
// RecordAccumulator:缓冲区大小,默认 32M:buffer.memory
 properties.put(ProducerConfig.BUFFER_MEMORY_CONFIG,33554432);
// compression.type:压缩,默认 none,可配置值 gzip、snappy、lz4 和 zstd
properties.put(ProducerConfig.COMPRESSION_TYPE_CONFIG,"snappy");

数据可靠

数据完全可靠条件 = ACK级别设置为-1 + 分区副本大于等于2 + ISR里应答的最小副本数量大于等于2

在生产环境中,acks=0很少使用;acks=1,一般用于传输普通日志,允许丢个别数据;acks=-1,可靠性要求比较高的场景。

// 设置 acks
properties.put(ProducerConfig.ACKS_CONFIG, "all");
// 重试次数 retries,默认是 int 最大值,2147483647
properties.put(ProducerConfig.RETRIES_CONFIG, 3);

数据去重

在应答机制中讲到过,达成数据完全可靠条件的同时,会引发数据重复的问题:

  • 至少一次(At Least Once)= ACK级别设置为-1 + 分区副本大于等于2 + ISR里应答的最小副本数量大于等于2
  • 最多一次(At Most Once)= ACK级别设置为0

精确一次(Exactly Once):对于一些非常重要的信息,比如和钱相关的数据,要求数据既不能重复也不丢失。

幂等性

指Producer不论向Broker发送多少次重复数据,Broker端都只会持久化一条,保证了不重复。

精确一次(Exactly Once) = 幂等性 + 至少一次( ack=-1 + 分区副本数>=2 + ISR最小副本数量>=2) 。

重复数据的判断标准:具有<PID, Partition, SeqNumber>相同主键的消息提交时,Broker只会持久化一条。其中PID是Kafka每次重启都会分配一个新的;Partition 表示分区号;Sequence Number是单调自增的。

只能保证单分区单会话内的幂等性,默认开启

生产者事务

事务是建立在幂等性的功能上的,所以开启事务必须开启幂等性

事务流程

501ee1504ab41bf5c02ea90ffdb330eb.png

Producer 在使用事务功能前,必须先自定义一个唯一的 transactional.id。有 了 transactional.id,即使客户端挂掉了, 它重启后也能继续处理未完成的事务

相关API:

// 1 初始化事务
void initTransactions();
// 2 开启事务
void beginTransaction() throws ProducerFencedException;
// 3 在事务内提交已经消费的偏移量(主要用于消费者)
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,String consumerGroupId) throws ProducerFencedException;
// 4 提交事务
void commitTransaction() throws ProducerFencedException;
// 5 放弃事务(类似于回滚事务的操作)
void abortTransaction() throws ProducerFencedException;

示例:

public static void main(String[] args) {
    Properties properties = new Properties();
    // 配置Kafka服务地址
    properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置key的序列化
    properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 配置value的序列化
    properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
    // 设置事务 id(必须),事务 id 任意起名
    properties.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, UUID.randomUUID().toString());
    // 自动关闭
    KafkaProducer<String, String> kafkaProducer = new KafkaProducer<>(properties);
    try {
        kafkaProducer.initTransactions();
        kafkaProducer.beginTransaction();
        for (int i = 0; i < 5; i++) {
            kafkaProducer.send(new ProducerRecord<>("topic1", 1, "test" + i, "test" + i), (metadata, e) -> {
                System.out.println("test1" + "->" + "分区:" + metadata.partition());
            });
            // if (i == 3) {
            //     i /= 0;
            // }
        }
        kafkaProducer.commitTransaction();
    } catch (Exception e) {
        kafkaProducer.abortTransaction();
        throw new RuntimeException(e);
    } finally {
        kafkaProducer.close();
    }
}

数据有序

单分区内,Kafka接收和发送的数据是有序的。

两种设置方式:

  • 未开启幂等性:max.in.flight.requests.per.connection=1
  • 开启幂等性:max.in.flight.requests.per.connection需要设置小于等于5。

启用幂等后,kafka服务端会缓存producer发来的最近5个request的元数据, 故无论如何,都可以保证最近5个request的数据都是有序的。

1.x之前的版本:需要设置:max.in.flight.requests.per.connection=1,即上一条消息确认接收才发下一条

3. Broker

之前浅略地查看了zookeeper中存放的broker的信息,接下来进行详细的讲解。

Zookeaper 中存储的Kafka 信息:

9ded261691166286fcbdefb1162a75c4.png

总体工作流程

Broker 总体工作流程

26a8d1b563d5695f55814dd2325f54fe.png

ISR之前做过介绍,不再赘述。
OSR:表示 FollowerLeader 副本同步时,延迟过多的副本。
AR:指的是分区中的所有副本,所以AR = ISR + OSR

副本

  1. Kafka 副本的作用:提高数据可靠性。
  2. 默认副本 1 个,生产环境一般配置为 2 个,保证数据可靠性;太多副本会增加磁盘存储空间,增加网络上数据传输,降低效率。
  3. 副本分为:Leader 和 Follower。Leader负责消息的接收和发送, Follower 主动从 Leader 同步数据。
  4. Kafka 分区中的所有副本统称为 AR

选举流程

Kafka 集群中有一个 broker 的 Controller 会被选举为 Controller Leader,负责管理集群 broker 的上下线,所有 topic 的分区副本分配和 Leader 选举等工作。

leader选举规则:在isr中存活为前提,按 照AR中排在前面的优先。例如 ar[1,0,2],isr[1,0,2],那么leader 就会按照1,0,2的顺序轮询。

假设上图中的broker1宕机,那么broker0中的Controller会在监听到节点变化后从Zookeeper中获取ISR,通过前面提到的选举规则选举出新的Leader,最后更新Zookeeper中的Leader和ISR。

参数列表

参数名称 描述
replica.lag.time.max.ms ISR 中,如果 Follower 长时间未向 Leader 发送通信请求或同步数据,则该 Follower 将被踢出 ISR。时间阈值默认 30s。
auto.leader.rebalance.enable 默认是 true。 自动 Leader Partition 平衡。
leader.imbalance.per.broker.percentage 默认是 10%。每个 broker 允许的不平衡的 leader 的比率。如果每个 broker 超过了这个值,控制器会触发 leader 的平衡。
leader.imbalance.check.interval.seconds 默认值 300 秒。检查 leader 负载是否平衡的间隔时间
log.segment.bytes Kafka 中 log 日志是分成一块块存储的,此配置是指 log 日志划分成块的大小,默认值 1G。
log.index.interval.bytes 默认 4kb,每当kafka 写入了 4kb 大小的日志(.log),然后就往 index 文件里面记录一个索引。
log.retention.hours Kafka 中数据保存的时间,默认 7 天
log.retention.minutes Kafka 中数据保存的时间,分钟级别,默认关闭
log.retention.ms Kafka 中数据保存的时间,毫秒级别,默认关闭
log.retention.check.interval.ms 检查数据是否保存超时的间隔,默认是 5 分钟。
log.retention.bytes 默认等于-1,表示无穷大。超过设置的所有日志总大小,删除最早的 segment。
log.cleanup.policy 默认是 delete,表示所有数据启用删除策略;如果设置值为 compact,表示所有数据启用压缩策略。
num.io.threads 默认是 8。负责写磁盘的线程数。整个参数值要占总核数的 50%
num.replica.fetchers 副本拉取线程数,这个参数占总核数的 50%的 1/3
num.network.threads 默认为 3。数据传输线程数,这个参数占总核数的 50%的 2/3 。
log.flush.interval.messages 强制页缓存刷写到磁盘的条数,默认是 long 的最大值,9223372036854775807。一般不建议修改,交给系统自己管理。
log.flush.interval.ms 刷数据到磁盘的间隔时间,默认是 null。一般不建议修改,交给系统自己管理。

服役新节点

如果不是新装的kafka,需要删除对应存放的日志(指定的日志目录和目录下的logs文件夹)

# 需要复杂均衡的主题
vim topics-to-move.json
# 让kafka生成负载均衡计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --topics-to-move-json-file topics-to-move.json --broker-list "0,1,2,3" --generate
# 创建副本存储计划,即将生成的计划存储到文件中
vim increase-replication-factor.json
# 执行副本存储计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --reassignment-json-file increase-replication-factor.json --execute
# 查看副本存储计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --reassignment-json-file increase-replication-factor.json --verify
{
    "topics": [
        {"topic": "first"}
    ],
    "version": 1
}

退役节点可以直接停掉服务,或者按照服役新节点的操作手动执行新存储计划(broker-list`少一个节点)

故障处理原理

概念:

  • LEO(Log End Offset):每个副本的最后一个offset,LEO其实就是最新的offset + 1。
  • HW(High Watermark):所有副本中最小的LEO 。

0ce75a9848110d2c4fde91841f28034a.png

Follower

  1. Follower发生故障后会被临时踢出ISR
  2. 期间内Leader和其它Follower继续接收数据
  3. 待该Follower恢复后,Follower会读取本地磁盘记录的上次的HW,并将log文件高于HW的部分删除掉,从HW开始向Leader进行同步。
  4. 等该Follower的LEO大于等于HW,即 Follower追上Leader之后,就可以重新加入ISR了。

Leader

  1. Leader发生故障之后,会从ISR中选出一个新的Leader
  2. 为保证多个副本之间的数据一致性,其余的Follower会先将各自的log文件高于HW的部分截掉,然后从新的Leader同步数据。

只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

分区副本分配

默认Kafka会进行分区以及副本的负载均衡,以下3Broker16分区3副本可以体现(未出现宕机等情况):

5463422f9a4fe32a8beda34ce4a8be99.png

手动调整

每台服务器的配置和性能不一致,但是Kafka只会根据自己的代码规则创建对应的分区副本,就会导致个别服务器存储压力较大。所有需要手动调整分区副本的存储。

比如当前有四台服务器,但希望将将所有分区和副本放在前两个机器中:

# 创建一个新的 topic
bin/kafka-topics.sh --bootstrap-server hadoop102:9092 --create --partitions 4 --replication-factor 2 --topic three
# 查看分区副本存储情况
bin/kafka-topics.sh --bootstrap-server hadoop102:9092 --describe --topic three
# 创建副本存储计划
vim increase-replication-factor.json
# 执行副本存储计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --reassignment-json-file increase-replication-factor.json --execute
# 验证副本存储计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --reassignment-json-file increase-replication-factor.json --verify
# 查看分区副本存储情况
bin/kafka-topics.sh --bootstrap-server hadoop102:9092 --describe --topic three
{
    "version":1,
    "partitions":[{"topic":"three","partition":0,"replicas":[0,1]},
                  {"topic":"three","partition":1,"replicas":[0,1]},
                  {"topic":"three","partition":2,"replicas":[1,0]},
                  {"topic":"three","partition":3,"replicas":[1,0]}]
}

增加副本

# 创建topic
bin/kafka-topics.sh --bootstrap-server hadoop102:9092 --create --partitions 3 --replication-factor 1 --topic four
# 创建副本存储计划
vim increase-replication-factor.json
# 执行副本存储计划
bin/kafka-reassign-partitions.sh --bootstrap-server hadoop102:9092 --reassignment-json-file increase-replication-factor.json --execute
{"version":1,"partitions":[{"topic":"four","partition":0,"replicas":[0,1,2]},{"topic":"four","partition":1,"replicas":[0,1,2]},{"topic":"four","partition":2,"replicas":[0,1,2]}]}

文件存储

数据存储机制

Topic是逻辑上的概念,而partition是物理上的概念,每个partition对应于一个log文件,该log文件中存储的就是Producer生产的数据。

Producer生产的数据会被不断追加到该log文件末端,为防止log文件过大导致数据定位效率低下,Kafka采取了分片和索引机制,将每个partition分为多个segment。

每个segment包括:“.index”文件、“.log”文件和.timeindex等文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号,例如:first-0。

  • .log:日志文件
  • .index:偏移量索引文件
  • .timeindex:时间戳索引文件

index和log文件以当前segment的第一条消息的offset命名

可以通过工具查看index和log信息:

# 查看索引文件
/opt/app/kafka/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files ./00000000000000000000.index
# 查看日志文件
/opt/app/kafka/bin/kafka-run-class.sh kafka.tools.DumpLogSegments --files ./00000000000000000000.log

定位消息:

  1. 根据目标offset定位Segment文件
  2. 找到小于等于目标offset的最大offset对应的索引项
  3. 定位到log文件
  4. 向下遍历找到目标Record

Index文件中保存的offset为相对offset,这样能确保offset的值所占空间不会过大, 因此能将offset的值控制在固定大小

文件清理策略

参数列表处介绍了Kafka文件清理的四个参数:

  • log.retention.hours
  • log.retention.minutes
  • log.retention.ms
  • log.retention.check.interval.ms
# 所有数据启用删除策略
log.cleanup.policy=delete
  1. 基于时间删除:默认打开。以 segment 中所有记录中的最大时间戳作为该文件时间戳。

也就是说文件中的数据全部都过期才会删除

  1. 基于大小:默认关闭。超过设置的日志总大小,删除最早的segment。log.retention.bytes,默认等于-1,表示无穷大
# 所有数据启用删除策略
log.cleanup.policy=compact

对于相同key的不同value值,只保留最后一个版本。

这种策略只适合特殊场景,比如消息的key是用户ID,value是用户的资料,通过这种压缩策略,整个消息集里就保存了所有用户最新的资料。

高效读写数据

原因:

  1. Kafka 本身是分布式集群,可以采用分区技术,并行度高
  2. 读数据采用稀疏索引,可以快速定位要消费的数据
  3. 顺序写磁盘

Kafka 的 producer 生产数据,要写入到 log 文件中,写的过程是一直追加到文件末端,省去了大量磁头寻址的时间

  1. 页缓存 + 零拷贝技术

PageCache页缓存:Kafka重度依赖底层操作系统提供的PageCache功 能。当上层有写操作时,操作系统只是将数据写入 PageCache。当读操作发生时,先从PageCache中查找,如果找不到,再去磁盘中读取。实际上PageCache是把尽可能多的空闲内存都当做了磁盘缓存来使用

零拷贝:Kafka的数据加工处理操作交由Kafka生产者和Kafka消费者处理。Kafka Broker应用层不关心存储的数据,所以就不用走应用层,传输效率高。

零拷贝技术示意图:

9b1c966edc7c54e964838414f8ee2111.png

4. 消费者

Kafka消费方式:

  1. pull(拉)模式:consumer采用从broker中主动拉取数据。Kafka采用
  2. push(推)模式:由broker 决定消息发送速率,很难适应所有消费者的消费速率。没采用

pull模式不足之处是,如果Kafka没有数据,消费者可能会陷入循环中,一直返回空数据。

工作流程

生产者将数据发送到Broker,根据策略发送到多个分区,消费者订阅的分区中拉取数据,同时会在每个broker中维护一个offset信息,用于记录数据消费的偏移量。

消费者组:由一个或多个groupid相同的消费者构成。

  • 消费者组内每个消费者负责消费不同分区的数据,一个分区只能由组内的一个消费者消费。

如果消费者组的消费者数超过主题分区数,那么会有一部分消费者被闲置

  • 消费者组之间互不影响。消费者组是逻辑上的一个订阅者。

消费分配

在 Kafka 中,每个消费者组都有三个主要的 Coordinator:

  1. Group Coordinator(组协调器): 负责管理消费者组的整体信息,处理组的加入和离开,以及进行 Rebalance 操作。每个消费者组有一个对应的 Group Coordinator。
  2. Offset Coordinator(偏移协调器): 负责处理消费者组提交的 offset 信息。每个消费者组中的每个消费者都会有一个 Offset Coordinator。
  3. Transaction Coordinator(事务协调器): 用于支持事务性生产者,负责管理事务的开始、提交和回滚等操作。每个事务性生产者都有一个对应的 Transaction Coordinator。

这里介绍的组协调器,规则:groupid的hashcode值 % 50( __consumer_offsets主题的默认分区数量)

__consumer_offsets 主题是实际存储消费者组的 offset 信息的地方。每个分区对应一个消费者组的 offset 数据。这些 offset 数据包含了消费者组在各个分区中的消费进度。

消费分配流程:

  1. 每个consumer都向Coordinator发送JoinGroup请求
  2. Coordinator选出一个消费者作为消费者组的leader
  3. Coordinator把要消费的topic情况发送给leader 消费者
  4. leader制定好消费方案后将消费方案发送给Coordinator
  5. Coordinator把消费方案下发给各个consumer,consumer根据消费方案拉取数据

每个消费者都会和coordinator保持心跳(默认3s),一旦超时(session.timeout.ms=45s),该消费者会被移除,并触发再平衡;或者消费者处理消息的时间过长(max.poll.interval.ms:5分钟),也会触发再平衡。

参数列表

参数名称 描述
bootstrap.servers 向 Kafka 集群建立初始连接用到的 host/port 列表。
key.deserializer 和 value.deserializer 指定接收消息的 key 和 value 的反序列化类型(全类名)。
group.id 标记消费者所属的消费者组。
enable.auto.commit 默认值为 true,消费者会自动周期性地向服务器提交偏移量。
auto.commit.interval.ms 如果设置了 enable.auto.commit 的值为 true, 则该值定义了消费者偏移量向 Kafka 提交的频率,默认 5s。
auto.offset.reset 当 Kafka 中没有初始偏移量或当前偏移量在服务器中不存在(如,数据被删除了),该如何处理? earliest:自动重置偏移量到最早的偏移量。 latest:默认,自动重置偏移量为最新的偏移量。 none:如果消费组原来的(previous)偏移量不存在,则向消费者抛异常。 anything:向消费者抛异常。
offsets.topic.num.partitions __consumer_offsets 的分区数,默认是 50 个分区。
heartbeat.interval.ms Kafka 消费者和 coordinator 之间的心跳时间,默认 3s。该值必须小于 session.timeout.ms ,且不应该高于 session.timeout.ms 的 1/3。
session.timeout.ms Kafka 消费者和 coordinator 之间连接超时时间,默认 45s。超过该值,该消费者被移除,消费者组执行再平衡
max.poll.interval.ms 消费者处理消息的最大时长,默认是 5 分钟。超过该值,该消费者被移除,消费者组执行再平衡。
fetch.min.bytes 默认 1 个字节。消费者获取服务器端一批消息最小的字节数。
fetch.max.wait.ms 默认 500ms。如果没有从服务器端获取到一批数据的最小字节数。该时间到,仍然会返回数据。
fetch.max.bytes 默认 Default: 52428800(50 m)。消费者获取服务器端一批消息最大的字节数。如果服务器端一批次的数据大于该值 (50m)仍然可以拉取回来这批数据,因此,这不是一个绝对最大值。一批次的大小受 message.max.bytes(broker config) or max.message.bytes (topic config)影响。
max.poll.records 一次 poll 拉取数据返回消息的最大条数,默认是 500 条。

使用示例

指定消费主题:

public static void main(String[] args) {
    Properties properties = new Properties();
    // 给消费者配置对象添加参数
    properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置序列化
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    // 配置消费者组(组名任意起名)
    properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
    try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
        // 注册要消费的主题(可以消费多个主题)
        List<String> topics = new ArrayList<>();
        topics.add("topic1");
        kafkaConsumer.subscribe(topics);
        // 拉取数据打印
        while (true) {
            // 设置 1s 中消费一批数据
            ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record);
            }
        }
    }
}

使用StringDeserializer实现反序列化

指定消费分区:

try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
    // 指定分区消费
    List<TopicPartition> topics = new ArrayList<>();
    topics.add(new TopicPartition("topic1", 0));
    kafkaConsumer.assign(topics);
    // 拉取数据打印
    while (true) {
        // 设置 1s 中消费一批数据
        ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
        for (ConsumerRecord<String, String> record : records) {
            System.out.println(record);
        }
    }
}

分配策略与再平衡

前面介绍到了消费分配,但是消费者Leader是根据什么来指定消费方案呢?

Kafka有四种主流的分区分配策略: RangeRoundRobinStickyCooperativeSticky

可以通过配置参数partition.assignment.strategy来修改分区的分配策略,Kafka可以同时使用多个分区分配策略。默认策略是Range + CooperativeSticky。

Range

Range 是对每个 topic 而言的。

首先对同一个topic中的分区按照序号进行排序,并对消费者按照字母顺序进行排序;通过partitions数/consumer数 来决定每个消费者应该消费几个分区。如果除不尽,那么前面几个消费者将会多消费 1 个分区。

问题:容易产生数据倾斜,因为对于多个topic而言,前面的消费者会多消费一些分区

再平衡策略:

  1. 在45s以内某个消费者宕机,kafka会将其本应该担任的分区整体分配给其它消费者。
  2. 在45s以后,宕机的消费者会被踢出消费者组,消费者的分区进行重新分配

RoundRobin

RoundRobin 针对集群中所有Topic而言。

RoundRobin 轮询分区策略,是把所有的 partition 和所有的 consumer 都列出来,然后按照 hashcode 进行排序,最后通过轮询算法来分配 partition 给到各个消费者。

// 修改分区分配策略
properties.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFI
G, "org.apache.kafka.clients.consumer.RoundRobinAssignor");

再平衡策略:

  1. 45s内,按照 RoundRobin 的方式,分别将分区数据交给剩余的消费者
  2. 45s后,重新按照 RoundRobin 方式分配

Sticky

粘性分区定义:在执行一次新的分配之前,考虑上一次分配的结果,尽量少的调整分配的变动,可以节省大量的开销

首先会尽量均衡的放置分区到消费者上面,在出现同一消费者组内消费者出现问题的时候,会尽量保持原有分配的分区不变化。

再平衡策略:

  1. 45s内,尽可能均衡地随机将分区数据交给其他消费者
  2. 45s后,重新按照粘性方式分配

offset

0.9版本前,consumer默认将offset保存在Zookeeper中;0.9版本之后,consumer默认将offset保存在Kafka的__consumer_offsets主题中。

其中数据的key是group.id+topic+ 分区号,value就是当前offset的值。每隔一段时间,kafka内部会对这个topic进行compact,也就是保留最新数据。

该主题默认是不能消费的,需要在配置文件config/consumer.properties中添加配置exclude.internal.topics=false

自动提交

默认开启,相关参数

  • enable.auto.commit:是否开启自动提交offset功能
  • auto.commit.interval.ms:自动提交offset的时间间隔,默认是5s

手动提交

手动提交offset的方法有两种:分别是commitSync(同步提交)和commitAsync(异步提交)。

  • 相同点:都会将本次提交的一批数据最高的偏移量提交
  • 不同点:同步提交阻塞当前线程,一直到提交成功,并且会自动失败重试(由不可控因素导致,也会出现提交失败);而异步提交则没有失败重试机制,故有可能提交失败。

同步提交必须等待offset提交完毕,才能去消费下一批数据

同步提交:

public static void main(String[] args) {
    Properties properties = new Properties();
    // 给消费者配置对象添加参数
    properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置序列化
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    // 设置手动提交
    properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
    // 配置消费者组(组名任意起名)
    properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
    try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
        // 指定分区消费
        List<TopicPartition> topics = new ArrayList<>();
        topics.add(new TopicPartition("topic1", 0));
        kafkaConsumer.assign(topics);
        // 拉取数据打印
        while (true) {
            // 设置 1s 中消费一批数据
            ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record);
            }
            kafkaConsumer.commitSync();
        }
    }
}

异步提交:

// 异步提交 offset
consumer.commitAsync();

指定Offset消费

public static void main(String[] args) {
    Properties properties = new Properties();
    // 给消费者配置对象添加参数
    properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置序列化
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    // 设置手动提交
    properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
    // 配置消费者组(组名任意起名)
    properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
    try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
        // 指定主题消费
        kafkaConsumer.subscribe(Collections.singletonList("topic1"));

        Set<TopicPartition> assignment= new HashSet<>();
        while (assignment.isEmpty()) {
            kafkaConsumer.poll(Duration.ofSeconds(1));
            // 获取消费者分区分配信息(有了分区分配信息才能开始消费)
            assignment = kafkaConsumer.assignment();
        }
        // 遍历所有分区,并指定 offset 从 200 的位置开始消费
        for (TopicPartition tp: assignment) {
            kafkaConsumer.seek(tp, 200);
        }

        // 拉取数据打印
        while (true) {
            // 设置 1s 中消费一批数据
            ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record);
            }
            kafkaConsumer.commitSync();
        }
    }
}

poll方法不是调用一次就能返回数据,需要多次交互等待消费方案的确定。

指定时间消费

public static void main(String[] args) {
    Properties properties = new Properties();
    // 给消费者配置对象添加参数
    properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.124.105:9092");
    // 配置序列化
    properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
    // 设置手动提交
    properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
    // 配置消费者组(组名任意起名)
    properties.put(ConsumerConfig.GROUP_ID_CONFIG, "test");
    try (KafkaConsumer<String, String> kafkaConsumer = new KafkaConsumer<>(properties)) {
        // 指定主题消费
        kafkaConsumer.subscribe(Collections.singletonList("topic1"));

        Set<TopicPartition> assignment = new HashSet<>();
        while (assignment.isEmpty()) {
            kafkaConsumer.poll(Duration.ofSeconds(1));
            // 获取消费者分区分配信息(有了分区分配信息才能开始消费)
            assignment = kafkaConsumer.assignment();
        }
        Map<TopicPartition, Long> timeMap = new HashMap<>();
        // 得到一周前的时间戳
        Long time = LocalDateTime.now().minusWeeks(1).toEpochSecond(ZoneOffset.UTC);
        // 封装集合存储,每个分区对应一天前的数据
        for (TopicPartition tp : assignment) {
            timeMap.put(tp, time);
        }
        // 获取从 1 周前开始消费的每个分区的 offset
        Map<TopicPartition, OffsetAndTimestamp> offsetMap = kafkaConsumer.offsetsForTimes(timeMap);
        // 遍历每个分区,对每个分区设置消费时间。
        for (TopicPartition tp : assignment) {
            OffsetAndTimestamp offsetAndTimestamp = offsetMap.get(tp);
            // 根据时间指定开始消费的位置
            if (offsetAndTimestamp != null) {
                kafkaConsumer.seek(tp, offsetAndTimestamp.offset());
            }
        }
        // 拉取数据打印
        while (true) {
            // 设置 1s 中消费一批数据
            ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofSeconds(1));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println(record);
            }
            kafkaConsumer.commitSync();
        }
    }
}

漏消费和重复消费

  • 重复消费:已经消费了数据,但是 offset 没提交。

自动提交offset的情况下,如果提交offset后的2s,consumer挂了,再次重启consumer,则从上一次提交的offset处继续消费,导致重复消费

  • 漏消费:先提交 offset 后消费,有可能会造成数据的漏消费。

offset为手动提交,当offset被提交时,数据还在内存中未落盘,此时刚好消费者线程被kill掉,那么offset已经提交,但是数据未处理完成,导致这部分内存中的数据丢失。

消费者事务:

想要Consumer端实现精准一次性消费,需要Kafka消费端将消费过程和提交offset 过程做原子绑定,可以将Kafka的offset保存到支持事务的自定义介质(比 如 MySQL)。

提高吞吐量

  1. 增加Topic的分区数,并且同时提升消费组的消费者数量,消费者数 = 分区数。
  2. 提高每批次拉取的数量

5. Kafka-Eagle 监控

Kafka-Eagle 框架可以监控 Kafka 集群的整体运行情况。Kafka-Eagle 的安装依赖于 MySQL。

配置不足,该章节仅作记录

安装使用

关闭服务后,修改kafka文件bin/kafka-server-start.sh:

# 原设置
if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then
	export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"
fi

# 改为:

if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then
 export KAFKA_HEAP_OPTS="-server -Xms2G -Xmx2G -XX:PermSize=128m -XX:+UseG1GC -		   XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8 -XX:ConcGCThreads=5 -XX:InitiatingHeapOccupancyPercent=70"
 export JMX_PORT="9999"
 #export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"
fi

官网下载安装包:EFAK (kafka-eagle.org)

# 解压完成后改名
mv efak-web-2.0.8/ efak
# 修改配置文件
vim efak/conf/system-config.properties

需要修改的点:

efak.zk.cluster.alias=cluster1
cluster1.zk.list=hadoop102:2181,hadoop103:2181,hadoop104:2181/kafka
# offset 保存在 kafka
cluster1.efak.offset.storage=kafka
# 配置 mysql 连接
efak.driver=com.mysql.jdbc.Driver
efak.url=jdbc:mysql://hadoop102:3306/ke?useUnicode=true&characterEncoding=UT
F-8&zeroDateTimeBehavior=convertToNull
efak.username=root
efak.password=000000

添加环境变量:

vim /etc/profile.d/efak.sh

# kafkaEFAK
export KE_HOME=/opt/module/efak
export PATH=$PATH:$KE_HOME/bin

#刷新配置
source /etc/profile

启动zk以及kafka后启动efak:

bin/ke.sh start
# 停止
bin/ke.sh stop

Kafka-Kraft 模式

此部分没有进行实践,算是对未来架构的窥探,了解即可

在现有结构中,元数据在zookeeper中,运行时动态选举controller,由controller进行Kafka集群管理。

在Kraft模式架构中,是与哦给你三台controller节点代替zookeeper,元数据保存在controller中,由controller直接进行 Kafka 集群管理,不再依赖 zookeeper 集群。

好处:

  • Kafka 不再依赖外部框架,而是能够独立运行;
  • controller 管理集群时,不再需要从 zookeeper 中先读取数据,集群性能上升;
  • 由于不依赖 zookeeper,集群扩展时不再受到 zookeeper 读写能力限制;
  • controller 不再动态选举,而是由配置文件规定。这样我们可以有针对性的加强 controller 节点的配置,而不是对随机 controller 节点的高负载束手无策。

部署

# 解压kafka安装包
tar -zxvf kafka_2.12-3.0.0.tgz -C /opt/module/
mv kafka_2.12-3.0.0/ kafka
# 修改配置文件
vim /opt/module/kafka/config/kraft/server.properties
#kafka 的角色(controller 相当于主机、broker 节点相当于从机,主机类似 zk 功能)
process.roles=broker,controller
#节点 ID
node.id=2
#controller 服务协议别名
controller.listener.names=CONTROLLER
#全 Controller 列表
controller.quorum.voters=2@hadoop102:9093,3@hadoop103:9093,4@hadoop104:9093
#不同服务器绑定的端口
listeners=PLAINTEXT://:9092,CONTROLLER://:9093
#broker 服务协议别名
inter.broker.listener.name=PLAINTEXT
#broker 对外暴露的地址
advertised.Listeners=PLAINTEXT://hadoop102:9092
#协议别名到安全协议的映射
listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,SSL:SSL,SASL_PLAINTEXT:SASL_PLAINTEXT,SASL_SSL:SASL_SSL
#kafka 数据存储目录
log.dirs=/opt/module/kafka/data

node.id需要保证全局唯一,用于controller选举,值需要和 controller.quorum.voters 对应。

初始化集群数据目录:

  1. 生成存储目录唯一 ID
 bin/kafka-storage.sh random-uuid

得到的值为:J7s9e8PPTKOO47PxzI39VA,用于之后指令执行

  1. 格式化 kafka 存储目录(三台节点)
# 分别在三台机器中执行
bin/kafka-storage.sh format -t J7s9e8PPTKOO47PxzI39VA -c
  1. 启动Kafka集群
# 分别在三台机器中执行
bin/kafka-server-start.sh -daemon config/kraft/server.properties
# 停止
bin/kafka-server-stop.sh

启停脚本:

#! /bin/bash
case $1 in
"start"){
 for i in hadoop102 hadoop103 hadoop104
 do
 echo " --------启动 $i Kafka-------"
 ssh $i "/opt/module/kafka2/bin/kafka-server-start.sh -daemon /opt/module/kafka/config/kraft/server.properties"
 done
};;
"stop"){
 for i in hadoop102 hadoop103 hadoop104
 do
 echo " --------停止 $i Kafka-------"
 ssh $i "/opt/module/kafka2/bin/kafka-server-stop.sh "
 done
};;
esac

6. SpringBoot整合

依赖:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

配置文件:

spring:
  application:
    name: kafkaApp
  kafka:
    bootstrap-servers: 192.168.124.105:9092

logging:
  level:
    workspace: debug

启动类:

@SpringBootApplication
public class KafkaApp {
    public static void main(String[] args) {
        SpringApplication.run(KafkaApp.class, args);
    }
}

配置类:

@Configuration
public class KafkaConfig {
    @Value("${spring.kafka.bootstrap-servers}")
    private String bootstrapServers;

    @Bean
    public KafkaProducer<String, String> kafkaProducer() {
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        return new KafkaProducer<>(properties);
    }

    @Bean
    public KafkaConsumer<String, String> kafkaConsumer() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "consumer1");
        return new KafkaConsumer<>(properties);
    }

    @KafkaListener(topics = {"topic1"}, groupId = "group1")
    public void listen(ConsumerRecord<String, String> record) {
        System.out.println(record);
    }
}

当然也可以在配置文件中声明

控制器:

@RestController
@RequestMapping("/kafka")
public class KafkaController {
    @Autowired
    private KafkaProducer<String, String> producer;
    @Autowired
    private KafkaConsumer<String, String> consumer;

    @GetMapping("/{message}")
    public Object sendMessage(@PathVariable String message) {
        return producer.send(new ProducerRecord<>("topic1", message));

    }

    @GetMapping("/get/{topic}")
    public String getMessage(@PathVariable String topic) {
        consumer.subscribe(Collections.singletonList(topic));
        ConsumerRecords<String, String> poll = consumer.poll(Duration.ofSeconds(1));
        StringBuilder result = new StringBuilder();
        for (ConsumerRecord<String, String> record : poll) {
            result.append(record.value()).append("\n");
        }
        return result.toString();
    }
}

7. 性能调优

硬件配置

  1. 服务器台数= 2 * (生产者峰值生产速率 * 副本 / 100) + 1

(生产者峰值生产速率 * 副本 / 100)的值需要进位,比如20*2/100->1

  1. 磁盘:kafka 底层主要是顺序写,建议选择普通的机械硬盘。
  2. Kafka 内存组成:堆内存 + 页缓存
  • Kafka 堆内存建议每个节点:10g ~ 15g

修改kafka-server-start.sh

if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then
  export KAFKA_HEAP_OPTS="-Xmx10G -Xms10G"
fi
  • 查看GC情况:
#查看kafka进程号
jps
#根据进程号查看GC情况
jstat -gc [进程号] 1s 10

参数说明:

参数 解释
S0C 年轻代中第一个survivor (幸存区)的容量(kb)
S1C 年轻代中第二个survivor (幸存区)的容量(kb)
S0U 年轻代中第一个survivor (幸存区)目前已使用空间(kb)
S1U 年轻代中第二个survivor (幸存区)目前已使用空间(kb)
EC 年轻代中Eden(伊甸园)的容量 (kb)
EU 年轻代中Eden(伊甸园)目前已使用空间 (kb)
OC Old代的容量 (kb)
OU Old代目前已使用空间 (kb)
PC Perm(持久代)的容量 (kb)
PU Perm(持久代)目前已使用空间 (kb)
YGC 从应用程序启动到采样时年轻代中gc次数
YGCT 从应用程序启动到采样时年轻代中gc所用时间(s)
FGC 从应用程序启动到采样时old代(全gc)gc次数
FGCT 从应用程序启动到采样时old代(全gc)gc所用时间(s)
GCT 从应用程序启动到采样时gc用的总时间(s)
  • 查看 Kafka 的堆内存
jmap -heap [进程号]
  • 每个节点页缓存大小 =(分区数 * 1g * 25%)/ 节点数

页缓存:页缓存是 Linux 系统服务器的内存。只需要保证 1 个 segment(1g)中 25%的数据在内存中就好。

  1. CPU
    • num.io.threads:负责写磁盘的线程数,整个参数值要占总核数的 50%
    • num.replica.fetchers:副本拉取线程数,这个参数占总核数的 50%的 1/3
    • num.network.threads:数据传输线程数,这个参数占总核数的 50%的 2/3
  2. 网络带宽 = 峰值吞吐量

100Mbps 单位是 bit;10M/s 单位是 byte ; 1byte = 8bit,100Mbps/8 = 12.5M/s。

8. 源码分析

继续学习:83_尚硅谷_Kafka_源码_环境准备_哔哩哔哩_bilibili

posted @ 2024-01-13 11:31  LemonPuer  阅读(93)  评论(0)    收藏  举报