sheng的学习笔记-kafka框架原理

目录

环境搭建

下载安装包

修改配置文件

启动服务

 创建topic

生产消息

消费消息

代码

maven配置

kafka的生产者代码

 消费者代码

基础知识

主题TOPIC:

单播和多播

单播消息:

多播消息:

 示意图

 查看消费组的详细信息

设计知识-生产者发消息和消费者接受消息

生产者生产消息

 生产者的同步/异步发送

生产者发送的缓冲区

消费者消费消息

消费者拉取(poll)消息

消费者自动提交

消费者手动提交

新消费组的消费offset规则

设计知识-分区Partition和重平衡rebalance

 分区Partition

什么是分区

分区的原因

什么是rebalance

Rebalance 的触发条件

分区策略

自定义分区:

轮询策略( Round-robin )

Range 范围分区

随机策略(Randomnes)

按消息键保序策略

StickyAssignor

重平衡触发与通知

协调者组件(Coordinator)

消费者组状态机

消费者端重平衡流程

JoinGroup

SyncGroup

Broker 端重平衡场景剖析

场景一:新成员入组。

场景二:组成员主动离组

场景三:组成员崩溃离组。

场景四:重平衡时协调者对组内成员提交位移的处理

设计知识-副本机制

副本概念:

kafka的副本作用

ISR-追随者与leader数据同步机制

什么是ISR(In-sync Replicas)

进入ISR的条件

ISR作用

高水位

什么是高水位

高水位作用

高水位更新机制

从leader角度看高水位更新机制

从Follower 副本角度看高水位更新机制

副本同步机制解析

Leader Epoch

定义

作用

单纯依赖高水位是怎么造成数据丢失的

通过Epoch避免数据丢失处理

持久化

持久化原理

 消息的偏移量和顺序消费原理

持久化文件

分区文件

索引文件

log数据文件

消费者如何通过offset查找message

 _consumer_offsets

设计知识-请求处理流程

Kafka 用reactor接收请求

逻辑处理的部分

控制类请求和数据类请求分离

集群

搭建集群

创建多副本的TOPIC

集群中多副本发送和消费消息

关于分区消费组消费者的流程

控制器组件(Controller)

什么是Controller Broker

Controller Broker是如何被选出来的

Controller Broker的具体作用是什么

选举

Controller选举机制

对partition的leader重新选举的机制

kafka优化问题

如何防止消息丢失

 如何防止重复消费

 如何做到顺序消费

解决消息积压问题

参考文章


环境搭建

官网地址:Apache Kafka

下载安装包

注意:

  • 下载安装包,注意下载bin的,别下载src的
  • 下载路径别有空格或者“-”等标识符,否则启动会报错
  • 最好下2.8.1版本的,3.0.0版本的在windows会报错

修改配置文件

在config中修改server.properties

listeners=PLAINTEXT://127.0.0.1:9092

常见的需要修改的配置如下:

启动ZK

bin/zookeeper-server-start.sh config/zookeeper.properties

或者自己搭个ZK,我是自己搭建的

启动服务

linux命令:

bin/kafka-server-start.sh config/server.properties &

windows命令,注意,我是用Bash启动的,如果是cmd,要换成\符号:

kafka-server-start.bat ../../config/server.properties

启动成功

 创建topic

.\kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 1 --partitions 1 --topic test

创建好之后,可以通过运行以下命令,查看已创建的topic信息

.\kafka-topics.bat --describe --topic test --bootstrap-server localhost:9092

查看所有的topic信息

 ./kafka-topics.bat --list --zookeeper 127.0.0.1:2181

生产消息

.\kafka-console-producer.bat --broker-list localhost:9092 --topic test

消费消息

从头开始消费,如果没有from beginning,在comsumer没有启动之前发送的消息会收不到

.\kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test --from-beginning

从最后一条消息的偏移量+1开始消费

.\kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test 

ZK信息查询

看下ZK数据,KAFKA在ZK上新建了一些节点

至此基础搭建完成

代码

maven配置

注意,版本最好和kafka版本一致,否则可能出现一些问题(因版本不一致导致的问题)。另外下面我直接写的2.8.1,如果用变量,最好别写kafka-version之类的名字,否则可能会报错

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

kafka的生产者代码

下面代码的brokerlist是kafka的broker服务器地址,我写的是集群,非集群的只要配置一个IP和端口就行

发送消息时写了同步和异步发送消息代码

package example.simple;

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;


public class MySimpleProducer {
    private final static String TOPIC_NAME = "my-replication-test";
    public static final String BROKER_LIST = "127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094";

    public static void main(String[] args) {
        // 1.设置参数
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BROKER_LIST);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        // 配置,ACK的配置
        //properties.put(ProducerConfig.ACKS_CONFIG, "1");
        // 失败之后会重试,重试配置,如果没有收到ACK,就开始重试
        //properties.put(ProducerConfig.RETRIES_CONFIG, 3);
        //properties.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);
        //创建KafkaProducer 实例
        KafkaProducer<String, String> producer = new KafkaProducer<String, String>(properties);

        //构建待发送的消息
        ProducerRecord<String, String> record = new ProducerRecord<String, String>(TOPIC_NAME, "mykey", "hello Kafka!");
        try {
            //同步发送消息
            RecordMetadata metadata = producer.send(record).get();
            //打印发送成功
            System.out.println("send success from producer" + "topic:" + metadata.topic() +
                    "|partition:" + metadata.partition() + "|offset:" + metadata.offset());


            // 异步发送消息
            producer.send(record, new Callback() {
                @Override
                public void onCompletion(RecordMetadata recordMetadata, Exception e) {
                    if (e != null) {
                        System.out.println("发送消息失败" + e.getStackTrace());
                    }
                    if (recordMetadata != null) {
                        System.out.println("异步发送消息结束");
                        System.out.println("send success from producer" + "topic:" + recordMetadata.topic() +
                                "|partition:" + recordMetadata.partition() + "|offset:" + recordMetadata.offset());
                    }
                }
            });

            Thread.sleep(5000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //关闭生产者客户端实例
            producer.close();
        }

    }
}

 消费者代码

package example.simple;

import org.apache.kafka.clients.consumer.ConsumerConfig;
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.clients.producer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;

import java.time.Duration;
import java.util.Arrays;
import java.util.Properties;


public class MySimpleConsumer {
    private final static String TOPIC_NAME = "my-replication-test";
    public static final String BROKER_LIST = "127.0.0.1:9092,127.0.0.1:9093,127.0.0.1:9094";
    public static final String CONSUMER_GROUP_NAME = "testGroup";

    public static void main(String[] args) {
        // 1.设置参数
        Properties properties = new Properties();

        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, BROKER_LIST);
        //消费分组名
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_NAME);
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());


        //创建KafkaProducer 实例
        KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(properties);
        consumer.subscribe(Arrays.asList(TOPIC_NAME));

        while (true) {
            // poll API是拉去消息的长轮训
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {
                System.out.println("收到消息,partition = " + record.partition() + "|offset = " + record.offset()
                        + "|key = " + record.key() + "|value = " + record.value());
            }
        }
    }
}

基础知识

关于基础知识概念,topic,broker,producer,consumer可以看我另一个笔记sheng的学习笔记-activeMQ框架原理_coldstarry的专栏-CSDN博客

或者自己上网搜

zk:

KAFKA将元数据(比如TOPIC,配置等)放到ZK存放,但ZK不适合频繁的修改数据等一些原因,KAFKA后续的版本正在逐渐的脱离ZK

主题TOPIC:

  • 主题topic在kafka中是一个逻辑的概念,通过topic将消息进行分离,不同的topic会被订阅改topick的消费者消费
  • Topic是Kafka数据写入操作的基本单元,是逻辑概念,可以指定副本
  • KAFKA是重topic的设计
  • 如果topic的消息非常多,需要几个T来存放,因为消息是会被存放到log日志中,为了解决文件过大的问题,kafka提出了partition分区的概念,一个Topic包含一个或多个Partition,建Topic的时候可以手动指定Partition个数,个数与服务器个数相当
  • 每条消息属于且仅属于一个Topic
  • Producer发布数据时,必须指定将该消息发布到哪个Topic
  • Consumer订阅消息时,也必须指定订阅哪个Topic的信息

单播和多播

单播消息:

  • 在一个kafka的topic中,启动两个消费者,如果多个消费者在同一个消费组,那么只有一个消费者可以收到订阅的topic中的消息。
  • 同一个消费组中只能有一个消费者收到一个topic中的消息。

./kafka-console-consumer.sh --bootstrap-server bigdata1:9092 --topic test --from-beginning --consumer-property group.id=testgroup1

多播消息:

  • 不同的消费组订阅同一个topic,那么不同的消费组中只有一个消费者能收到消息。
  • 多个消费者组中的多个消费者收到了同一个消息。

./kafka-console-consumer.sh --bootstrap-server bigdata1:9092 --topic test --from-beginning --consumer-property group.id=testgroup1

./kafka-console-consumer.sh --bootstrap-server bigdata1:9092 --topic test --from-beginning --consumer-property group.id=testgroup2

 示意图

 查看消费组的详细信息

 重点关注以下信息:

current-offset:最后被消费的消息偏移量

log-end-offset:消息总量(最后一条消息的偏移量)

lag:积压了多少个消息(log-end-offset偏移量  -  current-offset偏移量),数值越大,说明没有消费的消息越多

设计知识-生产者发消息和消费者接受消息

生产者生产消息

 生产者的同步/异步发送

  • 同步发送消息:生产者发送消息给MQ,MQ接到消息返回ACK后生产者才能往下走
  • 异步发送消息:生产者发送消息给MQ,没有等待MQ返回的ACK后就继续往下走,此时要接回调代码,才知道MQ(此处指broker,而不是消费者)真的接到了消息

 对ack的设置,可以调整生产者的同步/异步发送策略:

 对ACK和重试的代码设置

// 配置,ACK的配置
properties.put(ProducerConfig.ACKS_CONFIG,"1");
// 失败之后会重试,重试配置,如果没有收到ACK,就开始重试
properties.put(ProducerConfig.RETRIES_CONFIG, 3);
properties.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);

生产者发送的缓冲区

生产者先将消息放到缓冲区,默认缓冲区32M,每次拉满16K数据,如果数据没到16K在10ms后也会发送数据,

消费者消费消息

消费者先拉取消息,然后进行提交(自动/手动)offset到broker中,提交的内容是所属的消费组+消费的主题+消费的分区和消费的偏移量,消息提交到集群的_consumer_offset主题中

消费者拉取(poll)消息

  • 消费者建立与broker的长连接,开始poll消息,默认一次poll500条消息

  • 代码中设置长轮训(poll)的时间间隔是1s,逻辑是:
    1. 如果一次poll到500条,就直接执行for循环
    2. 如果一次没有poll500条,且时间在1秒内,长轮训继续poll,要么到500条,要么到1s
    3. 如果多次poll都没有到500条,且1秒时间到了,退出poll执行for循环 

  • 可以根据消费速度的快慢设置,如果两次poll的时间超出了30s的时间间隔,kafka会认为其消费能力很弱,将其踢出消费组,将分区分给其他消费者,触发rebalance机制触发性能开销,可以通过设置参数让每次poll的消息条数少一点

消费者自动提交

消费者poll消息后会自动提交,注意,消费者拉取消息后自动提交,但此时可能没有消费完成消息,消费者挂了,消费者再启动的时候会从已提交的offset继续消费消息,但之前的消息其实没有消费成功

代码配置自动/手动提交

消费者手动提交

更改配置如下,改为手动提交

 手动提交分为两种:

  • 手动同步提交

在消费完消息后调用同步提交的方法,当集群返回ack前一直阻塞,返回ack后表示提交成功,执行之后的逻辑

  •  手动异步提交

在消息消费完成后,不需要等待集群的ACK,直接执行之后的逻辑,可以设置一个回调方法,供集群调用

新消费组的消费offset规则

新消费组的消费者在启动后,默认从当前分区的最后一条消息offset+1开始消费(消费新消息),可以通过修改配置从第一条消息开始消费

 入下图,在消费组C启动后,不修改配置,已经有的10条消息无法消费(消费组C无法消费)

设计知识-分区Partition和重平衡rebalance

 分区Partition

分区是实现负载均衡以及高吞吐量的关键,故在生产者这一端就要仔细盘算合适的分区策略,避免造成消息数据的“倾斜”

什么是分区

首先,从数据组织形式来说,kafka有三层形式,kafka有多个主题,每个主题有多个分区,每个分区又有多条消息。

而每个分区可以分布到不同的机器上,这样一来,从服务端来说,分区可以实现高伸缩性,以及负载均衡,动态调节的能力。
 

分区的原因

为了性能考虑,如果不分区每个topic的消息只存在一个broker上,那么所有的消费者都是从这个broker上消费消息,那么单节点的broker成为性能的瓶颈,如果有分区的话生产者发过来的消息分别存储在各个broker不同的partition上,这样消费者可以并行的从不同的broker不同的partition上读消息,实现了水平扩展。

有了partition还需要segment原因:通过上面目录显示存在多个segment的情况,既然有分区了还要存多个segment干嘛?如果不引入segment,那么一个partition只对应一个文件(log),随着消息的不断发送这个文件不断增大,由于kafka的消息不会做更新操作都是顺序写入的,如果做消息清理的时候只能删除文件的前面部分删除,不符合kafka顺序写入的设计,如果多个segment的话那就比较方便了,直接删除整个文件即可保证了每个segment的顺序写入

什么是rebalance

Rebalance 本质上是一种协议,规定了一个 Consumer Group 下的所有 Consumer 如何达成一致,来分配订阅 Topic 的每个分区

比如某个 Group 下有 20 个 Consumer 实例,它订阅了一个具有 100 个分区的 Topic。正常情况下,Kafka 平均会为每个 Consumer 分配 5 个分区。这个分配的过程就叫 Rebalance。

另外注意在rebalance的时候,consumer会停止消费,等待rebalance完成,并且这个过程可能会很慢

Rebalance 的触发条件

重平衡触发条件有 3 个:

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

Rebalance 发生时,Group 下所有的 Consumer 实例都会协调在一起共同参与。每个 Consumer 实例通过分配分区策略知道应该消费订阅主题的哪些分区。具体策略看本文的分区策略部分

分区策略

所谓分区策略是决定生产者将消息发送到哪个分区的算法

Producer如何把消息发送给对应分区

  • 当key为空时,消息随机发送到各个分区(各个版本会有不同,有的是采用轮询的方式,有的是随机,有的是一定时间内只发送给固定partition,隔一段时间后随机换一个)
  • 用key的ha’sh值对partion个数取模,决定要把消息发送到哪个partition上
     

自定义分区:

Kafka 为我们提供了默认的分区策略,同时它也支持你自定义分区策略。如果要自定义分区策略,你需要显式地配置生产者端的参数partitioner.class。


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

package kafka;

import org.apache.kafka.clients.producer.KafkaProducer;

import java.util.Properties;

public class KafkaProduce {

    public void kafkaProducer() throws Exception {
     
        Properties pro = new Properties();     ......// 其他配置
        pro.put("partitioner.class", "kafka.KafkaPartitioner");
        KafkaProducer config = new KafkaProducer(pro);
    }
}

轮询策略( Round-robin )

轮询策略也称 Round-robin 策略,即顺序分配。比如一个主题下有 3 个分区,那么第一条消息被发送到分区 0,第二条被发送到分区 1,第三条被发送到分区 2,以此类推。当生产第 4 条消息时又会重新开始,即将其分配到分区 0,就像下面这张图展示的那样。这就是所谓的轮询策略。轮询策略是 Kafka Java 生产者 API 默认提供的分区策略。如果你未指定partitioner.class参数,那么你的生产者程序会按照轮询的方式在主题的所有分区间均匀地“码放”消息。.

这就是所谓的轮询策略。轮询策略是kafka java生产者API默认提供的分区策略。轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是平时最常用的分区策略之一。

Range 范围分区

假如有10个分区,3个消费者,把分区按照序号排列0,1,2,3,4,5,6,7,8,9;消费者为C1,C2,C3,那么用分区数除以消费者数来决定每个Consumer消费几个Partition,除不尽的前面几个消费者将会多消费一个 
最后分配结果如下

C1:0,1,2,3 
C2:4,5,6 
C3:7,8,9

如果有11个分区将会是:

C1:0,1,2,3 
C2:4,5,6,7 
C3:8,9,10

假如我们有两个主题T1,T2,分别有10个分区,最后的分配结果将会是这样:

C1:T1(0,1,2,3) T2(0,1,2,3) 
C2:T1(4,5,6) T2(4,5,6) 
C3:T1(7,8,9) T2(7,8,9)

在这种情况下,C1多消费了两个分区

这就是消费的策略! 就是用总的分区数/消费者线程总数=每个消费者线程应该消费的分区数。当还有余数的时候就将余数分别分发到另外的消费组线程中。  

在这里我们不难看出来。C1消费者线程比其他消费者线程多消费了两个分区,这就是Range Strategy的一个明显的弊端当分区很多的时候,会有个别的线程压力巨大

随机策略(Randomnes)

所谓随机就是我们随意地将消息放置到任意一个分区上,如下面这张图所示。

先计算出该主题总的分区数,然后随机地返回一个小于它的正整数。本质上看随机策略也是力求将数据均匀地打散到各个分区,但从实际表现来看,它要逊于轮询策略,所以如果追求数据的均匀分布,还是使用轮询策略比较好。事实上,随机策略是老版本生产者使用的分区策略,在新版本中已经改为轮询了。

按消息键保序策略

Kafka 允许为每条消息定义消息键,简称为 Key。这个 Key 的作用非常大,它可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等;也可以用来表征消息元数据。特别是在 Kafka 不支持时间戳的年代,在一些场景中,工程师们都是直接将消息创建时间封装进 Key 里面的。一旦消息被定义了 Key,那么你就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的,故这个策略被称为按消息键保序策略

StickyAssignor

根据上面的分配策略,在出现rebalance之后,每次都要全量重新分配,对于已经分配的数据会有很大改动,耗费性能

Sticky是“粘性的”,可以理解为分配结果是带“粘性的”——每一次分配变更相对上一次分配做最少的变动(上一次的结果是有粘性的),其目标有两点:

  1. 分区的分配尽量的均衡
  2. 每一次重分配的结果尽量与上一次分配结果保持一致

当这两个目标发生冲突时,优先保证第一个目标。第一个目标是每个分配算法都尽量尝试去完成的,而第二个目标才真正体现出StickyAssignor特性的。根据图分析下,不同的分配策略在一个节点下线后,重新分配的结果,使用sticky策略,T1P0的数据的消费者分区没有修改,还是C1,但round方式会变成C2

重平衡触发与通知

重平衡过程是如何通知到其他消费者实例的?答案就是,靠消费者端的心跳线程(Heartbeat Thread)。

Kafka Java 消费者需要定期地发送心跳请求(Heartbeat Request)到 Broker 端的协调者,以表明它还存活着。在 Kafka 0.10.1.0 版本之前,发送心跳请求是在消费者主线程完成的,也就是你写代码调用 KafkaConsumer.poll 方法的那个线程。

这样做有诸多弊病,最大的问题在于,消息处理逻辑也是在这个线程中完成的。因此,一旦消息处理消耗了过长的时间,心跳请求将无法及时发到协调者那里,导致协调者“错误地”认为该消费者已“死”。自 0.10.1.0 版本开始,社区引入了一个单独的心跳线程来专门执行心跳请求发送,避免了这个问题。

重平衡的通知机制正是通过心跳线程来完成的。当协调者决定开启新一轮重平衡后,它会将“REBALANCE_IN_PROGRESS”封装进心跳请求的响应中,发还给消费者实例。当消费者实例发现心跳响应中包含了“REBALANCE_IN_PROGRESS”,就能立马知道重平衡又开始了,这就是重平衡的通知机制。

协调者组件(Coordinator)

Coordinator一般指的是运行在broker上的group Coordinator,用于管理Consumer Group中各个成员,每个KafkaServer都有一个GroupCoordinator实例,管理多个消费者组,主要用于offset位移管理和Consumer Rebalance。

consumer group如何确定自己的coordinator是谁呢? 简单来说分为两步:

  1. 确定consumer group位移信息写入__consumers_offsets这个topic的哪个分区。具体计算公式:
    __consumers_offsets partition# = Math.abs(groupId.hashCode() % groupMetadataTopicPartitionCount) 注意:groupMetadataTopicPartitionCount由offsets.topic.num.partitions指定,默认是50个分区。
  2. 该分区leader所在的broker就是被选定的coordinator

消费者组状态机

重平衡一旦开启,Broker 端的协调者组件就要开始忙了,主要涉及到控制消费者组的状态流转。当前,Kafka 设计了一套消费者组状态机(State Machine),来帮助协调者完成整个重平衡流程。严格来说,这套状态机属于非常底层的设计,Kafka 官网上压根就没有提到过,但你最好还是了解一下,因为它能够帮助你搞懂消费者组的设计原理,比如消费者组的过期位移(Expired Offsets)删除等

目前,Kafka 为消费者组定义了 5 种状态,它们分别是:Empty、Dead、PreparingRebalance、CompletingRebalance 和 Stable。那么,这 5 种状态的含义是什么呢?我们一起来看看下面这张表格

一个消费者组最开始是 Empty 状态,当重平衡过程开启后,它会被置于 PreparingRebalance 状态等待成员加入,之后变更到 CompletingRebalance 状态等待分配方案,最后流转到 Stable 状态完成重平衡

当有新成员加入或已有成员退出时,消费者组的状态从 Stable 直接跳到 PreparingRebalance 状态,此时,所有现存成员就必须重新申请加入组。当所有成员都退出组后,消费者组状态变更为 Empty。Kafka 定期自动删除过期位移的条件就是,组要处于 Empty 状态。因此,如果你的消费者组停掉了很长时间(超过 7 天),那么 Kafka 很可能就把该组的位移数据删除了

消费者端重平衡流程

在消费者端,重平衡分为两个步骤:分别是加入组和等待领导者消费者(Leader Consumer)分配方案。这两个步骤分别对应两类特定的请求:JoinGroup 请求和 SyncGroup 请求

当组内成员加入组时,它会向协调者发送 JoinGroup 请求。在该请求中,每个成员都要将自己订阅的主题上报,这样协调者就能收集到所有成员的订阅信息。一旦收集了全部成员的 JoinGroup 请求后,协调者会从这些成员中选择一个担任这个消费者组的领导者。

通常情况下,第一个发送 JoinGroup 请求的成员自动成为领导者。你一定要注意区分这里的领导者和之前我们介绍的领导者副本,它们不是一个概念。这里的领导者是具体的消费者实例,它既不是副本,也不是协调者。领导者消费者的任务是收集所有成员的订阅信息,然后根据这些信息,制定具体的分区消费分配方案

选出领导者之后,协调者会把消费者组订阅信息封装进 JoinGroup 请求的响应体中,然后发给领导者,由领导者统一做出分配方案后,进入到下一步:发送 SyncGroup 请求。

在这一步中,领导者向协调者发送 SyncGroup 请求,将刚刚做出的分配方案发给协调者。值得注意的是,其他成员也会向协调者发送 SyncGroup 请求,只不过请求体中并没有实际的内容。这一步的主要目的是让协调者接收分配方案,然后统一以 SyncGroup 响应的方式分发给所有成员,这样组内所有成员就都知道自己该消费哪些分区了。

JoinGroup

SyncGroup

SyncGroup 请求的主要目的,就是让协调者把领导者制定的分配方案下发给各个组内成员。当所有成员都成功接收到分配方案后,消费者组进入到 Stable 状态,即开始正常的消费工作

Broker 端重平衡场景剖析

要剖析协调者端处理重平衡的全流程,我们必须要分几个场景来讨论。这几个场景分别是新成员加入组、组成员主动离组、组成员崩溃离组、组成员提交位移。

场景一:新成员入组。

新成员入组是指组处于 Stable 状态后,有新成员加入。如果是全新启动一个消费者组,Kafka 是有一些自己的小优化的,流程上会有些许的不同。我们这里讨论的是,组稳定了之后有新成员加入的情形。

当协调者收到新的 JoinGroup 请求后,它会通过心跳请求响应的方式通知组内现有的所有成员,强制它们开启新一轮的重平衡。具体的过程和之前的客户端重平衡流程是一样的。现在,我用一张时序图来说明协调者一端是如何处理新成员入组的。

场景二:组成员主动离组

何谓主动离组?就是指消费者实例所在线程或进程调用 close() 方法主动通知协调者它要退出。这个场景就涉及到了第三类请求:LeaveGroup 请求。协调者收到 LeaveGroup 请求后,依然会以心跳响应的方式通知其他成员,因此我就不再赘述了,还是直接用一张图来说明

场景三:组成员崩溃离组。

崩溃离组是指消费者实例出现严重故障,突然宕机导致的离组。它和主动离组是有区别的,因为后者是主动发起的离组,协调者能马上感知并处理。但崩溃离组是被动的,协调者通常需要等待一段时间才能感知到,这段时间一般是由消费者端参数 session.timeout.ms 控制的。也就是说,Kafka 一般不会超过 session.timeout.ms 就能感知到这个崩溃。当然,后面处理崩溃离组的流程与之前是一样的,我们来看看下面这张图。

场景四:重平衡时协调者对组内成员提交位移的处理

正常情况下,每个组内成员都会定期汇报位移给协调者。当重平衡开启时,协调者会给予成员一段缓冲时间,要求每个成员必须在这段时间内快速地上报自己的位移信息,然后再开启正常的 JoinGroup/SyncGroup 请求发送。还是老办法,我们使用一张图来说明。

设计知识-副本机制

副本概念:

在kafka中,每个主题可以有多个分区,每个分区又可以有多个副本。这多个副本中,只有一个是leader,而其他的都是follower副本。仅有leader副本可以对外提供服务

多个follower副本通常存放在和leader副本不同的broker中。通过这样的机制实现了高可用,当某台机器挂掉后,其他follower副本也能迅速”转正“,开始对外提供服务。

所谓副本(Replica),本质就是一个只能追加写消息的提交日志。根据 Kafka 副本机制的定义,同一个分区下的所有副本保存有相同的消息序列,这些副本分散保存在不同的 Broker 上,从而能够对抗部分 Broker 宕机带来的数据不可用。

在实际生产环境中,每台 Broker 都可能保存有各个主题下不同分区的不同副本,因此,单个 Broker 上存有成百上千个副本的现象是非常正常的。

接下来我们来看一张图,它展示的是一个有 3 台 Broker 的 Kafka 集群上的副本分布情况。从这张图中,我们可以看到,主题 1 分区 0 的 3 个副本分散在 3 台 Broker 上,其他主题分区的副本也都散落在不同的 Broker 上,从而实现数据冗余。

kafka的副本作用

在kafka中,通常是指分布式系统在多台网络互联的机器上保存有相同的数据拷贝,实现副本的目的就是冗余备份,且仅仅是冗余备份,所有的读写请求都是由leader副本进行处理的。follower副本仅有一个功能,那就是从leader副本拉取消息,尽量让自己跟leader副本的内容一致,副本机制是 Kafka 确保系统高可用和消息高持久性的重要基石

下图是领导者副本提供服务的示意图

当leader挂了,依托与ZK进行察觉,经过选举,从多个follower中选举产生一个新的leader,在老的leader重启后,只能作为追随者加入集群中

ISR-追随者与leader数据同步机制

什么是ISR(In-sync Replicas

追随者副本不提供服务,只是定期地异步拉取领导者副本中的数据而已。既然是异步的,就存在着不可能与 Leader 实时同步的风险。

追随者副本到底在什么条件下才算与 Leader 同步。基于这个想法,Kafka 引入了 In-sync Replicas,也就是所谓的 ISR 副本集合。ISR 中的副本都是与 Leader 同步的副本,相反,不在 ISR 中的追随者副本就被认为是与 Leader 不同步的。那么,到底什么副本能够进入到 ISR 中呢

Leader 副本天然就在 ISR 中。也就是说,ISR 不只是追随者副本集合,它必然包括 Leader 副本。甚至在某些情况下,ISR 只有 Leader 这一个副本

进入ISR的条件

能够进入到 ISR 的追随者副本要满足一定的条件,图中有 3 个副本:1 个领导者副本和 2 个追随者副本。Leader 副本当前写入了 10 条消息,Follower1 副本同步了其中的 6 条消息,而 Follower2 副本只同步了其中的 3 条消息。

上图中follower1到底算不算与领导者数据同步,这个标准就是 Broker 端参数replica.lag.time.max.ms 参数值。这个参数的含义是 Follower 副本能够落后 Leader 副本的最长时间间隔,当前默认值是 10 秒。这就是说,只要一个 Follower 副本落后 Leader 副本的时间不连续超过 10 秒,那么 Kafka 就认为该 Follower 副本与 Leader 是同步的,即使此时 Follower 副本中保存的消息明显少于 Leader 副本中的消息

Follower 副本唯一的工作就是不断地从 Leader 副本拉取消息,然后写入到自己的提交日志中,如果这个同步过程的速度持续慢于 Leader 副本的消息写入速度,那么在 replica.lag.time.max.ms 时间后,此 Follower 副本就会被认为是与 Leader 副本不同步的,因此不能再放入 ISR 中。此时,Kafka 会自动收缩 ISR 集合,将该副本“踢出”ISR。值得注意的是,倘若该副本后面慢慢地追上了 Leader 的进度,那么它是能够重新被加回 ISR 的。这也表明,ISR 是一个动态调整的集合,而非静态不变的。

ISR作用

在leader挂了之后,从ISR列表中选举一个节点当leader,如果所有副本距离leader同步速度都很慢,会导致ISR中为空,Kafka 把所有不在 ISR 中的存活副本都称为非同步副本,如果选择这些非同步副本作为leader,会导致数据丢失,这种选举叫做:Unclean 领导者选举(Unclean Leader Election)。可以通过unclean.leader.election.enable参数控制

Unclean 领导者选举使得分区 Leader 副本一直存在,不至于停止对外提供服务,因此提升了高可用性。反之,禁止 Unclean 领导者选举的好处在于维护了数据的一致性,避免了消息丢失,但牺牲了高可用性

高水位

什么是高水位

水位一词多用于流式处理领域,比如,Spark Streaming 或 Flink 框架中都有水位的概念。教科书中关于水位的经典定义通常是这样的:

在时刻 T,任意创建时间(Event Time)为 T’,且 T’≤T 的所有事件都已经到达或被观测到,那么 T 就被定义为水位。

“Streaming System”一书则是这样表述水位的:水位是一个单调增加且表征最早未完成工作(oldest work not yet completed)的时间戳

为了帮助你更好地理解水位,我借助这本书里的一张图来说明一下。

图中标注“Completed”的蓝色部分代表已完成的工作,标注“In-Flight”的红色部分代表正在进行中的工作,两者的边界就是水位线

kafka中水位是跟位置信息绑定的。

高水位作用

  1. 定义消息可见性,即用来标识分区下的哪些消息是可以被消费者消费的。
  2. 帮助 Kafka 完成副本同步。

我们假设这是某个分区 Leader 副本的高水位图,请你注意图中的“已提交消息”和“未提交消息”。在分区高水位以下的消息被认为是已提交消息,反之就是未提交消息消费者只能消费已提交消息,即图中位移小于 8 的所有消息。

图中还有一个日志末端位移的概念,即 Log End Offset,简写是 LEO。它表示副本写入下一条消息的位移值。注意,数字 15 所在的方框是虚线,这就说明,这个副本当前只有 15 条消息,位移值是从 0 到 14,下一条新消息的位移是 15。显然,介于高水位和 LEO 之间的消息就属于未提交消息。这也从侧面告诉了我们一个重要的事实,那就是:同一个副本对象,其高水位值不会大于 LEO 值

Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本。只不过 Leader 副本比较特殊,Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位

消费者不能消费高水位之后的数据,如图,在高水位在4的时候,broker0接到了新的消息5,但数据还没有同步到另两个broker(未提交),水位线在4,消费者只能消费到水位线是4的数据

高水位更新机制

现在,我们知道了每个副本对象都保存了一组高水位值和 LEO 值,但实际上,在 Leader 副本所在的 Broker 上,还保存了其他 Follower 副本的 LEO 值。我们一起来看看下面这张图。

我们可以看到,Broker 0 上保存了某分区的 Leader 副本和所有 Follower 副本的 LEO 值,而 Broker 1 上仅仅保存了该分区的某个 Follower 副本。Kafka 把 Broker 0 上保存的这些 Follower 副本又称为远程副本(Remote Replica)。Kafka 副本机制在运行过程中,会更新 Broker 1 上 Follower 副本的高水位和 LEO 值,同时也会更新 Broker 0 上 Leader 副本的高水位和 LEO 以及所有远程副本的 LEO,但它不会更新远程副本的高水位值,也就是我在图中标记为灰色的部分。

为什么要在 Broker 0 上保存这些远程副本呢?其实,它们的主要作用是,帮助 Leader 副本确定其高水位,也就是分区高水位

从leader角度看高水位更新机制

Leader 副本处理生产者请求的逻辑如下:

  1. 写入消息到本地磁盘。
  2. 更新分区高水位值。
    1. i. 获取 Leader 副本所在 Broker 端保存的所有远程副本 LEO 值(LEO-1,LEO-2,……,LEO-n)。
    2. ii. 获取 Leader 副本高水位值:currentHW。
    3. iii. 更新 currentHW = max{currentHW, min(LEO-1, LEO-2, ……,LEO-n)}。

处理 Follower 副本拉取消息的逻辑如下:

  1. 读取磁盘(或页缓存)中的消息数据。
  2. 使用 Follower 副本发送请求中的位移值更新远程副本 LEO 值。
  3. 更新分区高水位值(具体步骤与处理生产者请求的步骤相同)。

从Follower 副本角度看高水位更新机制

 从 Leader 拉取消息的处理逻辑如下:

  1. 写入消息到本地磁盘。
  2. 更新 LEO 值。
  3. 更新高水位值。
    1. i. 获取 Leader 发送的高水位值:currentHW。
    2. ii. 获取步骤 2 中更新过的 LEO 值:currentLEO。
    3. iii. 更新高水位为 min(currentHW, currentLEO)。

副本同步机制解析

举一个实际的例子,说明一下 Kafka 副本同步的全流程。

该例子使用一个单分区且有两个副本的主题。当生产者发送一条消息时,看下Leader 和 Follower 副本对应的高水位更新流程。

首先是初始状态。下面这张图中的 remote LEO 就是刚才的远程副本的 LEO 值。在初始状态时,所有值都是 0。

当生产者给主题分区发送一条消息后,状态变更为:

此时,Leader 副本成功将消息写入了本地磁盘,故 LEO 值被更新为 1。Follower 再次尝试从 Leader 拉取消息。和之前不同的是,这次有消息可以拉取了,因此状态进一步变更为:

这时,Follower 副本也成功地更新 LEO 为 1。此时,Leader 和 Follower 副本的 LEO 都是 1,但各自的高水位依然是 0,还没有被更新。它们需要在下一轮的拉取中被更新,如下图所示:

在新一轮的拉取请求中,由于位移值是 0 的消息已经拉取成功,因此 Follower 副本这次请求拉取的是位移值 =1 的消息。Leader 副本接收到此请求后,更新远程副本 LEO 为 1,然后更新 Leader 高水位为 1。做完这些之后,它会将当前已更新过的高水位值 1 发送给 Follower 副本。Follower 副本接收到以后,也将自己的高水位值更新成 1。至此,一次完整的消息同步周期就结束了。事实上,Kafka 就是利用这样的机制,实现了 Leader 和 Follower 副本之间的同步。

Leader Epoch

定义

所谓 Leader Epoch,我们大致可以认为是 Leader 版本。它由两部分数据组成。

  • Epoch。一个单调增加的版本号。每当副本领导权发生变更时,都会增加该版本号。小版本号的 Leader 被认为是过期 Leader,不能再行使 Leader 权力。
  • 起始位移(Start Offset)。Leader 副本在该 Epoch 值上写入的首条消息的位移。

我举个例子来说明一下 Leader Epoch。假设现在有两个 Leader Epoch<0, 0> 和 <1, 120>,那么,第一个 Leader Epoch 表示版本号是 0,这个版本的 Leader 从位移 0 开始保存消息,一共保存了 120 条消息。之后,Leader 发生了变更,版本号增加到 1,新版本的起始位移是 120。

作用

Kafka Broker 会在内存中为每个分区都缓存 Leader Epoch 数据,同时它还会定期地将这些信息持久化到一个 checkpoint 文件中。当 Leader 副本写入消息到磁盘时,Broker 会尝试更新这部分缓存。如果该 Leader 是首次写入消息,那么 Broker 会向缓存中增加一个 Leader Epoch 条目,否则就不做更新。这样,每次有 Leader 变更时,新的 Leader 副本会查询这部分缓存,取出对应的 Leader Epoch 的起始位移,以避免数据丢失和不一致的情况。

单纯依赖高水位是怎么造成数据丢失的

开始时,副本 A 和副本 B 都处于正常状态,A 是 Leader 副本。某个使用了默认 acks 设置的生产者程序向 A 发送了两条消息,A 全部写入成功,此时 Kafka 会通知生产者说两条消息全部发送成功。

现在我们假设 Leader 和 Follower 都写入了这两条消息,而且 Leader 副本的高水位也已经更新了,但 Follower 副本高水位还未更新——这是可能出现的。Follower 端高水位的更新与 Leader 端有时间错配。倘若此时副本 B 所在的 Broker 宕机,当它重启回来后,副本 B 会执行日志截断操作,将 LEO 值调整为之前的高水位值,也就是 1。这就是说,位移值为 1 的那条消息被副本 B 从磁盘中删除,此时副本 B 的底层磁盘文件中只保存有 1 条消息,即位移值为 0 的那条消息。

当执行完截断操作后,副本 B 开始从 A 拉取消息,执行正常的消息同步。如果就在这个节骨眼上,副本 A 所在的 Broker 宕机了,那么 Kafka 就别无选择,只能让副本 B 成为新的 Leader,此时,当 A 回来后,需要执行相同的日志截断操作,即将高水位调整为与 B 相同的值,也就是 1。这样操作之后,位移值为 1 的那条消息就从这两个副本中被永远地抹掉了。这就是这张图要展示的数据丢失场景。

严格来说,这个场景发生的前提是 Broker 端参数 min.insync.replicas 设置为 1。此时一旦消息被写入到 Leader 副本的磁盘,就会被认为是“已提交状态”,但现有的时间错配问题导致 Follower 端的高水位更新是有滞后的。如果在这个短暂的滞后时间窗口内,接连发生 Broker 宕机,那么这类数据的丢失就是不可避免的。

通过Epoch避免数据丢失处理

Follower 副本 B 重启回来后,需要向 A 发送一个特殊的请求去获取 Leader 的 LEO 值。在这个例子中,该值为 2。当获知到 Leader LEO=2 后,B 发现该 LEO 值不比它自己的 LEO 值小,而且缓存中也没有保存任何起始位移值 > 2 的 Epoch 条目,因此 B 无需执行任何日志截断操作。这是对高水位机制的一个明显改进,即副本是否执行日志截断不再依赖于高水位进行判断。

现在,副本 A 宕机了,B 成为 Leader。同样地,当 A 重启回来后,执行与 B 相同的逻辑判断,发现也不用执行日志截断,至此位移值为 1 的那条消息在两个副本中均得到保留。后面当生产者程序向 B 写入新消息时,副本 B 所在的 Broker 缓存中,会生成新的 Leader Epoch 条目:[Epoch=1, Offset=2]。之后,副本 B 会使用这个条目帮助判断后续是否执行日志截断操作。这样,通过 Leader Epoch 机制,Kafka 完美地规避了这种数据丢失场景。

持久化

持久化原理

Kakfa 依赖文件系统来存储和缓存消息。对于硬盘的传统观念是硬盘总是很慢,基于文件系统的架构能否提供优异的性能?实际上硬盘的快慢完全取决于使用方式。

为了提高性能,现代操作系统往往使用内存作为磁盘的缓存,所有的磁盘读写操作都会经过这个缓存,所以如果程序在线程中缓存了一份数据,实际在操作系统的缓存中还有一份,这等于存了两份数据。

同时 Kafka 基于 JVM 内存有以下缺点:

  • 对象的内存开销非常高,通常是要存储的数据的两倍甚至更高
  • 随着堆内数据的增加,GC的速度越来越慢

实际上磁盘线性写入的性能远远大于任意位置写的性能,线性读写由操作系统进行了大量优化(read-ahead、write-behind 等技术),甚至比随机的内存读写更快。所以与常见的数据缓存在内存中然后刷到硬盘的设计不同,Kafka 直接将数据写到了文件系统的日志中:

  • 写操作:将数据顺序追加到文件中
  • 读操作:从文件中读取

这样实现的好处:

  • 读操作不会阻塞写操作和其他操作,数据大小不对性能产生影响
  • 硬盘空间相对于内存空间容量限制更小
  • 线性访问磁盘,速度快,可以保存更长的时间,更稳定

 消息的偏移量和顺序消费原理

  • 生产者将消息发送给broker,broker会将消息保存在本地的日志文件中

/usr/local/kafka/data/kafka-logs/主题-分区/00000000.log

  • 消息的保存是有序的,通过offset偏移量来描述消息的有序性
  • 消费者消费消息时也是通过offset来描述当前要消费的那条消息的位置
  • 如果一个生产者,两个消费者,后启动的消费者会一直收到消息,先启动的消费者不会收到消息,注意,不是轮训的接到消息,跟activeMQ的QUEUE不一样(这个是轮训的消费)

持久化文件

分区文件

Partition包含多个Segment,每个Segment对应一个文件,Segment可以手动指定大小,当Segment达到阈值时,将不再写数据,每个Segment都是大小相同的
Segment由多个不可变的记录组成,记录只会被append到Segment中,不会被单独删除或者修改,每个Segment中的Message数量不一定相等

其实每个分区下保存了很多文件,而概念上我们把他叫segment,即每个分区都是又多个segment构成的,其中index(索引文件),log(数据文件),time index(时间索引文件)统称为一个segment。

每个Partition只会在一个Broker上,物理上每个Partition对应的是一个文件夹

看Kafka-logs文件夹内的日志:

 有2个文件夹,代表刚刚建立的test1的topic的2个分区,打开test-0文件夹内:

索引文件

通过dump index我们发现索引文件中其实就保存了offset和position,分别是消息的offset也就是具体那一条消息,position表示具体消息存储在log中的物理地址,例如:

offset: 1049 position: 16205  
offset: 1065 position: 32410  
offset: 1081 position: 48615  
offset: 1097 position: 64820  
offset: 1113 position: 81025  
offset: 1129 position: 97230

通过上面数据可以看出,kafka并不是每个offset都保存了,每隔6个offset存储一条索引数据,为什么在index文件中这些offset编号不是连续的呢?

因为index文件中并没有为数据文件中的每条消息都建立索引,而是采用了稀疏存储的方式,每隔一定字节的数据建立一条索引。这样避免了索引文件占用过多的空间,从而可以将索引文件保留在内存中。但缺点是没有建立索引的Message也不能一次定位到其在数据文件的位置,从而需要做一次顺序扫描,但是这次顺序扫描的范围就很小了。

log数据文件

数据文件中并不是直接存储数据,而是通过许多的message组成,message包含了实际的消息数据

日志分析

F:\workspace\arch\KAFKA\kafka2.8.1\bin\windows>.\kafka-topics.bat --create --boo
tstrap-server localhost:9092 --replication-factor 1 --partitions 2 --topic test1

消费者如何通过offset查找message

假如我们想要读取offset=1066的message,需要通过下面2个步骤查找。

  1. 查找segment file 
    00000000000000000000.index表示最开始的文件,起始偏移量(offset)为0.第二个文件00000000000000001018.index的消息量起始偏移量为1019 = 1018 + 1.同样,第三个文件00000000000000002042.index的起始偏移量为2043=2042 + 1,其他后续文件依次类推,以起始偏移量命名并排序这些文件,只要根据offset 二分查找文件列表,就可以快速定位到具体文件。 当offset=1066时定位到00000000000000001018.index|log
  2. 通过segment file查找message
    通过第一步定位到segment file,当offset=1066时,依次定位到00000000000000001018.index的元数据物理位置和00000000000000001018.log的物理偏移地址,此时我们只能拿到1065的物理偏移地址,然后再通过00000000000000368769.log顺序查找直到offset=1066为止。每个message都有固定的格式很容易判断是否是下一条消息

 _consumer_offsets

每个消费者会定期将自己消费分区的offset提交给kafka内部topic:__consumer_offsets,提交过去的时候,key是consumerGroupId+topic+分区号,value就是当前offset的值,kafka会定期清理topic里的消息,最后就保留最新的那条数据因为__consumer_offsets可能会接收高并发的请求,kafka默认给其分配50个分区(可以通过offsets.topic.num.partitions设置),这样可以通过加机器的方式抗大并发。通过如下公式可以选出consumer消费的offset要提交到__consumer_offsets的哪个分区,公式:hash(consumerGroupId) % __consumer_offsets主题的分区数

设计知识-请求处理流程

无论是 Kafka 客户端还是 Broker 端,它们之间的交互都是通过“请求 / 响应”的方式完成的。比如,客户端会通过网络发送消息生产请求给 Broker,而 Broker 处理完成后,会发送对应的响应给到客户端。

Apache Kafka 自己定义了一组请求协议,用于实现各种各样的交互操作。比如常见的 PRODUCE 请求是用于生产消息的,FETCH 请求是用于消费消息的,METADATA 请求是用于请求 Kafka 集群元数据信息的。总之,Kafka 定义了很多类似的请求格式。所有的请求都是通过 TCP 网络以 Socket 的方式进行通讯的。

Kafka 用reactor接收请求

Kafka 使用的是 Reactor 模式。Reactor 模式是事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务器端发送请求的场景。

Reactor 模式的架构如下图所示:

从这张图中,我们可以发现,多个客户端会发送请求给到 Reactor。Reactor 有个请求分发线程 Dispatcher,也就是图中的 Acceptor,它会将不同的请求下发到多个工作线程中处理。在这个架构中,Acceptor 线程只是用于请求分发,不涉及具体的逻辑处理,非常得轻量级,因此有很高的吞吐量表现。而这些工作线程可以根据实际业务处理需要任意增减,从而动态调节系统负载能力。如果我们来为 Kafka 画一张类似的图的话,那它应该是这个样子的:

如果我们来为 Kafka 画一张类似的图的话,那它应该是这个样子的:

显然,这两张图长得差不多。Kafka 的 Broker 端有个 SocketServer 组件,类似于 Reactor 模式中的 Dispatcher,它也有对应的 Acceptor 线程和一个工作线程池,只不过在 Kafka 中,这个工作线程池有个专属的名字,叫网络线程池。Kafka 提供了 Broker 端参数 num.network.threads,用于调整该网络线程池的线程数。其默认值是 3,表示每台 Broker 启动时会创建 3 个网络线程,专门处理客户端发送的请求

Acceptor 线程采用轮询的方式将入站请求公平地发到所有网络线程中,因此,在实际使用过程中,这些线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单,同时也避免了请求处理的倾斜,有利于实现较为公平的请求处理调度。

逻辑处理的部分

客户端发来的请求会被 Broker 端的 Acceptor 线程分发到任意一个网络线程中,由它们来进行处理。那么,当网络线程接收到请求后,它是怎么处理的呢?

当网络线程拿到请求后,它不是自己处理,而是将请求放入到一个共享请求队列中。Broker 端还有个 IO 线程池,负责从该队列中取出请求,执行真正的处理。如果是 PRODUCE 生产请求,则将消息写入到底层的磁盘日志中;如果是 FETCH 请求,则从磁盘或页缓存中读取消息。

请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的。这么设计的原因就在于,Dispatcher 只是用于请求分发而不负责响应回传,因此只能让每个网络线程自己发送 Response 给客户端,所以这些 Response 也就没必要放在一个公共的地方

我们再来看看刚刚的那张图,图中有一个叫 Purgatory 的组件,这是 Kafka 中著名的“炼狱”组件。它是用来缓存延时请求(Delayed Request)的。所谓延时请求,就是那些一时未满足条件不能立刻处理的请求。比如设置了 acks=all 的 PRODUCE 请求,一旦设置了 acks=all,那么该请求就必须等待 ISR 中所有副本都接收了消息后才能返回,此时处理该请求的 IO 线程就必须等待其他 Broker 的写入结果。当请求不能立刻处理时,它就会暂存在 Purgatory 中。稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中

控制类请求和数据类请求分离

到目前为止,我提及的请求处理流程对于所有请求都是适用的,也就是说,Kafka Broker 对所有请求是一视同仁的。但是,在 Kafka 内部,除了客户端发送的 PRODUCE 请求和 FETCH 请求之外,还有很多执行其他操作的请求类型,比如负责更新 Leader 副本、Follower 副本以及 ISR 集合的 LeaderAndIsr 请求,负责勒令副本下线的 StopReplica 请求等。与 PRODUCE 和 FETCH 请求相比,这些请求有个明显的不同:它们不是数据类的请求,而是控制类的请求。也就是说,它们并不是操作消息数据的,而是用来执行特定的 Kafka 内部动作的。

Kafka 社区把 PRODUCE 和 FETCH 这类请求称为数据类请求,把 LeaderAndIsr、StopReplica 这类请求称为控制类请求。细究起来,当前这种一视同仁的处理方式对控制类请求是不合理的。为什么呢?因为控制类请求有这样一种能力:它可以直接令数据类请求失效

集群

搭建集群

搭建一主二从的集群,

复制2个server.properties配置文件,修改配置

 

 启动服务

kafka-server-start.bat ../../config/server.properties

kafka-server-start.bat ../../config/server-9093.properties

kafka-server-start.bat ../../config/server-9094.properties

在ZK中查看是否启动成功

创建多副本的TOPIC

创建3个副本的topic

.\kafka-topics.bat --create --bootstrap-server localhost:9092 --replication-factor 3 --partitions 2 --topic my-replication-test

 查看下topic详细情况

.\kafka-topics.bat --describe --topic my-replication-test --bootstrap-server localhost:9092

集群中多副本发送和消费消息

发送消息

.\kafka-console-producer.bat --broker-list localhost:9092,localhost:9093,localhost:9094 --topic my-replication-test

消费消息,建立2个消费组

.\kafka-console-consumer.bat --bootstrap-server localhost:9092,localhost:9093,localhost:9094 --topic my-replication-test --from-beginning  --consumer-property group.id=testgroup1

左上是生产者,下面2个是消费者,都是一个消费组,所以只有一个接到了消息

关于分区消费组消费者的流程

图中集群有两个Broker,每个broker有多个Partition,一个partition只能被一个消费组的某一个消费者消费,从而保证消费顺序。kafka只在partition的范围内保证消息消费的局部顺序性,不能在同一个topic中多个partition中保证总体的消费顺序性,一个消费者可以消费多个partition

消费组中的消费者数量不能比一个topic的partition数量多,否则多出来的那个消费者无法消费到消息,比如下图有4个partition,如果group b中再多出一个消费者consumer 7,这个消费者将无法消费到消息。但如果其中一个消费者挂了,会触发rebalance机制,会让其他的消费者消费该分区

控制器组件(Controller)

什么是Controller Broker

在分布式系统中,通常需要有一个协调者,该协调者会在分布式系统发生异常时发挥特殊的作用。在Kafka中该协调者称之为控制器(Controller),其实该控制器并没有什么特殊之处,它本身也是一个普通的Broker,只不过需要负责一些额外的工作(追踪集群中的其他Broker,并在合适的时候处理新加入的和失败的Broker节点、Rebalance分区、分配新的leader分区等)。值得注意的是:Kafka集群中始终只有一个Controller Broker。

Controller Broker是如何被选出来的

上一小节解释了什么是Controller Broker,并且每台 Broker 都有充当控制器的可能性。那么,控制器是如何被选出来的呢?当集群启动后,Kafka 怎么确认控制器位于哪台 Broker 呢?

实际上,Broker 在启动时,会尝试去 ZooKeeper 中创建 /controller 节点。Kafka 当前选举控制器的规则是:第一个成功创建 /controller 节点的 Broker 会被指定为控制器

Controller Broker的具体作用是什么

Controller Broker的主要职责有很多,主要是一些管理行为,主要包括以下几个方面:

  • 创建、删除主题,增加分区并分配leader分区
  • 集群Broker管理(新增 Broker、Broker 主动关闭、Broker 故障)
  • preferred leader选举,一个broker挂了,在ISR列表中中选举出leader
  • 分区重分配

选举

Controller选举机制

是使用zookeeper的选举机制来实现:
在zookeeper中的主节点下面创建一个 /controller 临时节点,zookeeper会保证有且仅有一个broker能创建成功,这个broker
就会成为集群的总控器controller,如下:

在这里插入图片描述

Controller里面记录了当前的Controller是哪个节,如下:

Controller里面记录了当前的Controller是哪个节

Controller会用zookeeper的watcher机制,监控brokers下面的所有的broker,一旦发现某个broker挂掉了,就会去找到该broker有多少partion是leader并发起对该partition的选举。


对partition的leader重新选举的机制

监听topic相关的变化。为Zookeeper中的/brokers/topics节点添加TopicChangeListener,用来处理topic增减的变化;
如下图, 对于每个Topic都有一个ISR列表,直接取ISR列表的第一个作为leader,如果当前挂的就是第一个,则选择后面一个作为leader。

在这里插入图片描述

kafka优化问题

如何防止消息丢失

 如何防止重复消费

 如何做到顺序消费

 这会牺牲性能的,一般别这么玩

解决消息积压问题

参考文章

kafka中文教程 - OrcHome

千锋教育最新kafka入门到精通教程|kafka快速入门,阿里P7架构师带你深度解析_哔哩哔哩_bilibili

Kafka学习笔记:Kafka的Topic、Partition和Message_lrxcmwy2的博客-CSDN博客_kafka topic

http://cache.baiducontent.com/c?m=WmFzEdlFCz5hlhWMftuWkVnO-f4qeLHAet0W0cFMgD8QlCxfvOukrfsTKGEd9O4rQHx88ouj9noTueld9QnlIFk1mHvz06_cVRu8VDHSGUNnhA5z_8myBPPwKzj-a_Vl9RnvFAOZg2N2wC14SgI2kE9RYX8i18DlJ_msEc6NBIYqEod0ldq7zN5C1YkbqRqxF_VyGT85tVk-jnvwHRVQHUbV3hdWEk7KM0D6WTfbB4buchfCmxon2AxyHzew3fyZIXt91meiQpDYxeWD5r9POJCi2GVOuyqeXB3hvbcLnNCfi64B9nj_TlnMw4a97H73MurLG1OgM9jysbs7fZqpjq&p=9c759a4788d917fc57efdb3a50&newp=8b2a971d86cc43bc13bbc6265353d8304a02c70e3dc3864e1290c408d23f061d4863e1bc2427140fd7c57c6401af4f58ebf0377323454df6cc8a871d81edca&s=1679091c5a880faf%20%20%20%20%20%20%20%20%20%20%20%20&user=baidu&fm=sc&query=%B6%A8%C6%DA%BD%AB%D7%D4%BC%BA%CF%FB%B7%D1%B7%D6%C7%F8%B5%C4offset%CC%E1%BD%BB%B8%F8+%5Fconsumer%5Foffset&qid=e762a26e00022d3e&p1=1

Kafka 消息持久化 - 简书

书 深入理解Kafka:核心设计与实践原理 

书 kafka权威指南

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值