Kafka原理详细介绍

1 Kafka

1.1 定义

Kafka 是一个分布式流媒体平台,kafka官网:http://kafka.apache.org/

Kafka 是一种高吞吐量、分布式、基于发布/订阅的消息系统,最初由 LinkedIn 公司开发,使用Scala 语言编写,目前是Apache 的开源项目。

流媒体平台有三个关键功能:

  • 发布和订阅记录流,类似于消息队列或企业消息传递系统。
  • 以容错的持久方式存储记录流。
  • 记录发生时处理流。

Kafka通常用于两大类应用:

  • 构建可在系统或应用程序之间可靠获取数据的实时流数据管道
  • 构建转换或响应数据流的实时流应用程序

1.1.1 Kafka名词

下面是Kafka中涉及到的相关概念:

  1. brokerKafka 服务器,负责消息存储和转发
  2. topic:消息类别,Kafka 按照topic 来分类消息(即使如此,kafka仍然有点对点和广播发布类型)
  3. partitiontopic 的分区,一个 topic 可以包含多个 partitiontopic 消息保存在各个partition
  4. offset:消息在日志中的位置,可以理解是消息在 partition 上的偏移量,也是代表该消息的唯一序号
  5. Producer:消息生产者
  6. Consumer:消息消费者
  7. Consumer Group:消费者分组,每个Consumer 必须属于一个 group
  8. Zookeeper:保存着集群 broker、topic、partitionmeta 数据;另外,还负责 broker 故障发现,partition leader 选举,负载均衡等功能

1.1.2 Kafka核心API

Kafka有四个核心API:

  • Producer API(生产者API)允许应用程序发布记录流至一个或多个kafkatopics(主题)

  • Consumer API(消费者API)允许应用程序订阅一个或多个topics(主题),并处理所产生的对他们记录的数据流。

  • Streams API(流API)允许应用程序充当流处理器,从一个或多个topics(主题)消耗的输入流,并产生一个输出流至一个或多个输出的topics(主题),有效地变换所述输入流,以输出流。

  • Connector API(连接器API)允许构建和运行kafka topics(主题)连接到现有的应用程序或数据系统中重用生产者或消费者。例如,关系数据库的连接器可能捕获对表的每个更改。

Kafka中,客户端和服务器之间的通信是通过简单,高性能,语言无关的TCP协议完成的。此协议已版本化并保持与旧版本的向后兼容性。Kafka提供Java客户端,但客户端有多种语言版本。

1.2 相关组件介绍

1.2.1 Topic

Topic 是生产者发送消息的目标地址,是消费者的监听目标
在这里插入图片描述
一个服务可以监听、发送多个 Topics
在这里插入图片描述
Kafka 中有一个consumer-group(消费者组)的概念。
这是一组服务,扮演一个消费者
在这里插入图片描述
如果是消费者组接收消息,Kafka 会把一条消息路由到组中的某一个服务
在这里插入图片描述
这样有助于消息的负载均衡,也方便扩展消费者。
Topic 扮演一个消息的队列。
首先,一条消息发送了
在这里插入图片描述
然后,这条消息被记录和存储在这个队列中,不允许被修改
在这里插入图片描述
接下来,消息会被发送给此 Topic 的消费者。
但是,这条消息并不会被删除,会继续保留在队列中
在这里插入图片描述
像之前一样,这条消息会发送给消费者、不允许被改动、一直呆在队列中。
(消息在队列中能呆多久,可以修改 Kafka 的配置)
在这里插入图片描述
在这里插入图片描述

1.2.2 Partitions分区

上面 Topic 的描述中,把 Topic 看做了一个队列,实际上,一个 Topic 是由多个队列组成的,被称为Partition(分区)
这样可以便于 Topic 的扩展
在这里插入图片描述
生产者发送消息的时候,这条消息会被路由到此 Topic 中的某一个 Partition
在这里插入图片描述
消费者监听的是所有分区
在这里插入图片描述
生产者发送消息时,默认是面向 Topic 的,由 Topic 决定放在哪个 Partition,默认使用轮询策略
在这里插入图片描述
也可以配置 Topic,让同类型的消息都在同一个 Partition
例如,处理用户消息,可以让某一个用户所有消息都在一个 Partition
例如,用户1发送了3条消息:A、B、C,默认情况下,这3条消息是在不同的 Partition 中(如 P1、P2、P3)。
在配置之后,可以确保用户1的所有消息都发到同一个分区中(如 P1)
在这里插入图片描述
这个功能有什么用呢?
这是为了提供消息的【有序性】。
消息在不同的 Partition 是不能保证有序的,只有一个 Partition 内的消息是有序的
在这里插入图片描述
在这里插入图片描述

1.2.3 Topics主题 和 partitions分区

一个Topic可以认为是一类消息,每个topic将被分成多个partition(区),每个partition在存储层面是append log文件
主题是发布记录的类别或订阅源名称。Kafka的主题总是多用户; 也就是说,一个主题可以有零个,一个或多个消费者订阅写入它的数据。
对于每个主题,Kafka集群都维护一个如下所示的分区日志:
在这里插入图片描述

每个分区都是一个有序的,不可变的记录序列,不断附加到结构化的提交日志中。分区中的记录每个都分配了一个称为偏移的顺序ID号,它唯一地标识分区中的每个记录。

Kafka集群持久保存所有已发布的记录 - 无论是否已使用 - 使用可配置的保留期。例如,如果保留策略设置为两天,则在发布记录后的两天内,它可供使用,之后将被丢弃以释放空间。Kafka的性能在数据大小方面实际上是恒定的,因此长时间存储数据不是问题。
在这里插入图片描述

实际上,基于每个消费者保留的唯一元数据是该消费者在日志中的偏移或位置。这种偏移由消费者控制:通常消费者在读取记录时会线性地提高其偏移量,但事实上,由于该位置由消费者控制,因此它可以按照自己喜欢的任何顺序消费记录。例如,消费者可以重置为较旧的偏移量来重新处理过去的数据,或者跳到最近的记录并从“现在”开始消费。

这些功能组合意味着Kafka 消费者consumers 非常cheap - 他们可以来来往往对集群或其他消费者没有太大影响。例如,可以使用我们的命令行工具“tail”任何主题的内容,而无需更改任何现有使用者所消耗的内容。

日志中的分区有多种用途。首先,它们允许日志扩展到超出适合单个服务器的大小。每个单独的分区必须适合托管它的服务器,但主题可能有许多分区,因此它可以处理任意数量的数据。其次,它们充当了并行性的单位 - 更多的是它

1.2.4 Distribution分配

一个Topic的多个partitions,被分布在kafka集群中的多个server上;每个server(kafka实例)负责partitions中消息的读写操作;此外kafka还可以配置partitions需要备份的个数(replicas),每个partition将会被备份到多台机器上,以提高可用性.

基于replicated方案,那么就意味着需要对多个备份进行调度;每个partition都有一个serverleader;leader负责所有的读写操作,如果leader失效,那么将会有其他follower来接管(成为新的leader);follower只是单调的和leader跟进,同步消息即可..由此可见作为leaderserver承载了全部的请求压力,因此从集群的整体考虑,有多少个partitions就意味着有多少个"leader",kafka会将"leader"均衡的分散在每个实例上,来确保整体的性能稳定

1.2.5 Producers生产者 和 Consumers消费者

1.2.5.1 Producers生产者

Producers将数据发布到指定的topics 主题。同时Producer 也能决定将此消息归属于哪个partition;比如基于round-robin方式或者通过其他的一些算法等。

1.2.5.2 Consumers

本质上kafka只支持Topic.每个consumer属于一个consumer group;反过来说,每个group中可以有多个consumer.发送到Topic的消息,只会被订阅此Topic的每个group中的一个consumer消费。

如果所有使用者实例具有相同的使用者组,则记录将有效地在使用者实例上进行负载平衡。

如果所有消费者实例具有不同的消费者组,则每个记录将广播到所有消费者进程。

图片

分析:两个服务器Kafka群集,托管四个分区(P0-P3),包含两个使用者组。消费者组A有两个消费者实例,B组有四个消费者实例。

Kafka中实现消费consumption 的方式是通过在消费者实例上划分日志中的分区,以便每个实例在任何时间点都是分配的“公平份额”的独占消费者。维护组中成员资格的过程由Kafka协议动态处理。如果新实例加入该组,他们将从该组的其他成员接管一些分区; 如果实例死亡,其分区将分发给其余实例。

Kafka仅提供分区内记录的总订单,而不是主题中不同分区之间的记录。对于大多数应用程序而言,按分区排序与按键分区数据的能力相结合就足够了。但是,如果您需要对记录进行总订单,则可以使用仅包含一个分区的主题来实现,但这将意味着每个使用者组只有一个使用者进程。

1.2.5.3 Consumers kafka确保

发送到partitions中的消息将会按照它接收的顺序追加到日志中。也就是说,如果记录M1由与记录M2相同的生成者发送,并且首先发送M1,则M1将具有比M2更低的偏移并且在日志中更早出现。

消费者实例按照它们存储在日志中的顺序查看记录。对于消费者而言,它们消费消息的顺序和日志中消息顺序一致。

如果Topicreplicationfactor为N,那么允许N-1个kafka实例失效,我们将容忍最多N-1个服务器故障,而不会丢失任何提交到日志的记录。

1.2.6 架构和zookeeper关系

Kafka 是集群架构的,ZooKeeper是重要组件。
在这里插入图片描述
ZooKeeper 管理者所有的 TopicPartition
TopicPartition 存储在 Node 物理节点中,ZooKeeper负责维护这些 Node
在这里插入图片描述
有2个 Topic,各自有2个 Partition
在这里插入图片描述
这是逻辑上的形式,但在 Kafka 集群中的实际存储可能是这样的
在这里插入图片描述
Topic APartition #1 有3份,分布在各个 Node 上。
这样可以增加 Kafka 的可靠性和系统弹性。
3个 Partition #1 中,ZooKeeper 会指定一个 Leader,负责接收生产者发来的消息
在这里插入图片描述
其他2个 Partition #1 会作为 Follower,Leader 接收到的消息会复制给 Follower
在这里插入图片描述
这样,每个 Partition 都含有了全量消息数据。
在这里插入图片描述
即使某个 Node 节点出现了故障,也不用担心消息的损坏。
Topic A 和 Topic B 的所有 Partition 分布可能就是这样的
在这里插入图片描述
转载于:https://mp.weixin.qq.com/s/k7DJJGmImcpnaSy9AhAmmQ

1.3 kafka是如何保证消息的有序性

kafka这样保证消息有序性的:
一个 topic,一个 partition,一个 consumer,内部单线程消费,单线程吞吐量太低,一般不会用这个。(全局有序性)
N 个内存 queue,具有相同 key 的数据都到同一个内存 queue;然后对于 N 个线程,每个线程分别消费一个内存 queue 即可,这样就能保证顺序性。
大家可以看下消息队列的有序性是怎么推导的:

消息的有序性,就是指可以按照消息的发送顺序来消费。有些业务对消息的顺序是有要求的,比如先下单再付款,最后再完成订单,这样等。假设生产者先后产生了两条消息,分别是下单消息(M1),付款消息(M2),M1比M2先产生,如何保证M1比M2先被消费呢。

在这里插入图片描述

为了保证消息的顺序性,可以将将M1、M2发送到同一个Server上,当M1发送完收到ack后,M2再发送。如图:
在这里插入图片描述

这样还是可能会有问题,因为从MQ服务器到服务端,可能存在网络延迟,虽然M1先发送,但是它比M2晚到。
在这里插入图片描述

那还能怎么办才能保证消息的顺序性呢?将M1和M2发往同一个消费者,且发送M1后,等到消费端ACK成功后,才发送M2就得了。
在这里插入图片描述

消息队列保证顺序性整体思路就是这样啦。比如Kafka的全局有序消息,就是这种思想的体现: 就是生产者发消息时,1个Topic只能对应1个Partition,一个 Consumer,内部单线程消费。
但是这样吞吐量太低,一般保证消息局部有序即可。在发消息的时候指定Partition KeyKafka对其进行Hash计算,根据计算结果决定放入哪个Partition。这样Partition Key相同的消息会放在同一个Partition。然后多消费者单线程消费指定的Partition
参考链接:https://mp.weixin.qq.com/s/gHjuYH6R6Fgfn3WZ8W79Zg

1.4 消息重复

1.4.1 引言

数据重复这个问题其实也是挺正常,全链路都有可能会导致数据重复
在这里插入图片描述

通常,消息消费时候都会设置一定重试次数来避免网络波动造成的影响,同时带来副作用是可能出现消息重复。

整理下消息重复的几个场景:

  • 生产端: 遇到异常,基本解决措施都是重试
    • 场景一:leader分区不可用了,抛 LeaderNotAvailableException 异常,等待选出新 leader 分区。
    • 场景二:Controller 所在 Broker 挂了,抛 NotControllerException 异常,等待 Controller 重新选举。
    • 场景三:网络异常、断网、网络分区、丢包等,抛 NetworkException 异常,等待网络恢复。
  • 消费端poll 一批数据,处理完毕还没提交 offset ,机子宕机重启了,又会 poll 上批数据,再度消费就造成了消息重复。

1.4.2 解决重复消息

了解下消息的三种投递语义:

  • 最多一次(at most once): 消息只发一次,消息可能会丢失,但绝不会被重复发送。例如:mqtt 中 QoS = 0。
  • 至少一次(at least once): 消息至少发一次,消息不会丢失,但有可能被重复发送。例如:mqtt 中 QoS = 1
  • 精确一次(exactly once): 消息精确发一次,消息不会丢失,也不会被重复发送。例如:mqtt 中 QoS = 2。

了解了这三种语义,再来看如何解决消息重复,即如何实现精准一次,可分为三种方法:

  • Kafka 幂等性 Producer: 保证生产端发送消息幂等。局限性,是只能保证单分区且单会话(重启后就算新会话)
  • Kafka 事务: 保证生产端发送消息幂等。解决幂等 Producer 的局限性。
  • 消费端幂等: 保证消费端接收消息幂等

1.4.2.1 Kafka 幂等性 Producer

幂等性指:无论执行多少次同样的运算,结果都是相同的。即一条命令,任意多次执行所产生的影响均与一次执行的影响相同

幂等性使用示例:在生产端添加对应配置即可

  1. 设置幂等,启动幂等。
  2. 配置 acks注意:一定要设置 acks=all,否则会抛异常。
  3. 配置 max.in.flight.requests.per.connection 需要 <= 5,否则会抛异常 OutOfOrderSequenceException
    • 0.11 >= Kafka < 1.1, max.in.flight.request.per.connection = 1
    • Kafka >= 1.1, max.in.flight.request.per.connection <= 5
Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 注意

为了更好理解,需要了解下 Kafka 幂等机制:
在这里插入图片描述

  1. Producer 每次启动后,会向 Broker 申请一个全局唯一的 pid。(重启后 pid 会变化,这也是弊端之一)
  2. Sequence Numbe:针对每个 <Topic, Partition> 都对应一个从0开始单调递增的 Sequence,同时 Broker端会缓存这个 seq num
  3. 判断是否重复: 拿 <pid, seq num>Broker 里对应的队列 ProducerStateEntry.Queue(默认队列长度为 5)查询是否存在
    如果 nextSeq == lastSeq + 1,即 服务端seq + 1 == 生产传入seq,则接收。
    如果 nextSeq == 0 && lastSeq == Int.MaxValue,即刚初始化,也接收。
    反之,要么重复,要么丢消息,均拒绝。
    在这里插入图片描述

这种设计针对解决了两个问题:

  • 消息重复: 场景 Broker 保存消息后还没发送 ack 就宕机了,这时候 Producer 就会重试,这就造成消息重复。
  • 消息乱序: 避免场景,前一条消息发送失败而其后一条发送成功,前一条消息重试后成功,造成的消息乱序。

那什么时候该使用幂等:

  • 如果已经使用 acks=all,使用幂等也可以。
  • 如果已经使用 acks=0 或者 acks=1,说明系统追求高性能,对数据一致性要求不高。不要使用幂等

使用例子
启动消息者:可以用 Kafka 提供的脚本

# 举个栗子:topic 需要自己去修改
$ cd ./kafka-2.7.1-src/bin
$ ./kafka-console-producer.sh --broker-list localhost:9092 --topic test_topic

创建 topic : 1副本,2 分区

$ ./kafka-topics.sh --bootstrap-server localhost:9092 --topic myTopic --create --replication-factor 1 --partitions 2

# 查看
$ ./kafka-topics.sh --bootstrap-server broker:9092 --topic myTopic --describe

生产者代码

public class KafkaProducerApplication {

    private final Producer<String, String> producer;
    final String outTopic;

    public KafkaProducerApplication(final Producer<String, String> producer,
                                    final String topic) {
        this.producer = producer;
        outTopic = topic;
    }

    public void produce(final String message) {
        final String[] parts = message.split("-");
        final String key, value;
        if (parts.length > 1) {
            key = parts[0];
            value = parts[1];
        } else {
            key = null;
            value = parts[0];
        }
        final ProducerRecord<String, String> producerRecord
            = new ProducerRecord<>(outTopic, key, value);
        producer.send(producerRecord,
                (recordMetadata, e) -> {
                    if(e != null) {
                        e.printStackTrace();
                    } else {
                        System.out.println("key/value " + key + "/" + value + "\twritten to topic[partition] " + recordMetadata.topic() + "[" + recordMetadata.partition() + "] at offset " + recordMetadata.offset());
                    }
                }
        );
    }

    public void shutdown() {
        producer.close();
    }

    public static void main(String[] args) {

        final Properties props = new Properties();

        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
        props.put(ProducerConfig.ACKS_CONFIG, "all");

        props.put(ProducerConfig.CLIENT_ID_CONFIG, "myApp");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);

        final String topic = "myTopic";
        final Producer<String, String> producer = new KafkaProducer<>(props);
        final KafkaProducerApplication producerApp = new KafkaProducerApplication(producer, topic);

        String filePath = "/home/donald/Documents/Code/Source/kafka-2.7.1-src/examples/src/main/java/kafka/examples/input.txt";
        try {
            List<String> linesToProduce = Files.readAllLines(Paths.get(filePath));
            linesToProduce.stream().filter(l -> !l.trim().isEmpty())
                    .forEach(producerApp::produce);
            System.out.println("Offsets and timestamps committed in batch from " + filePath);
        } catch (IOException e) {
            System.err.printf("Error reading file %s due to %s %n", filePath, e);
        } finally {
            producerApp.shutdown();
        }
    }
}

启动消费者:

$ ./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic myTopic

1.4.2.2 Kafka 事务

使用 Kafka 事务解决幂等的弊端:单会话且单分区幂等。

事务使用示例:分为生产端 和 消费端

Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 最大等待数
props.put("transactional.id", "my-transactional-id"); // 4. 设定事务 id

Producer<String, String> producer = new KafkaProducer<String, String>(props);

// 初始化事务
producer.initTransactions();

try{
    // 开始事务
    producer.beginTransaction();

    // 发送数据
    producer.send(new ProducerRecord<String, String>("Topic", "Key", "Value"));
 
    // 数据发送及 Offset 发送均成功的情况下,提交事务
    producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
    // 数据发送或者 Offset 发送出现异常时,终止事务
    producer.abortTransaction();
} finally {
    // 关闭 Producer 和 Consumer
    producer.close();
    consumer.close();
}

这里消费端 Consumer 需要设置下配置:isolation.level 参数

  • read_uncommitted: 这是默认值,表明 Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。如果用了事务型 Producer,那么对应的 Consumer 就不要使用这个值。
  • read_committed: 表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。当然了,它也能看到非事务型 Producer 写入的所有消息。

1.4.2.3 消费端幂等

只要消费端具备了幂等性,那么重复消费消息的问题也就解决了。
典型的方案是使用:消息表,来去重:
在这里插入图片描述
上述demo中,消费端拉取到一条消息后,开启事务,将消息Id 新增到本地消息表中,同时更新订单信息。
如果消息重复,则新增操作 insert 会异常,同时触发事务回滚。

参考链接:https://juejin.cn/post/7172897190627508237

posted @ 2022-03-30 09:52  上善若泪  阅读(849)  评论(0编辑  收藏  举报