JAVA八股文面试必会-分布式框架-4.6 Kafka原理解析

一. Kafka快速入门

自媒体用户发布文章成功之后需要进行文章的审核 , 审核通过之后才会发布到APP端供用户查看 ,  审核功能因为耗时较久 , 长时间阻塞会影响用户体验 , 而且长时间阻塞会严重影响系统的吞吐量

所以为了实现功能之间的解耦 , 提升用户体验 , 我们可以抽取一个独立的审核服务  , 文章发布成功之后自媒体服务通过MQ通知审核服务进行文章审核 , 如下图所示 :

为什么要选择使用Kafka作为消息中间件

  • 因为我们后期会使用MQ进行行为数据采集  , 对于消息的吞吐量要求更高
  • 因为后期会进行文章的实时推荐 , 会使用到一些实时流计算技术 , Kafka提供这么一个技术 Kafka Stream , 开发成本和运维成本会更低一些

1.1 kafka概述和安装

1.1.1 kafka概述

消息中间件对比

特性

ActiveMQ

RabbitMQ

RocketMQ

Kafka

开发语言

java

erlang

java

scala(JVM)

单机吞吐量

万级

万级

10万级

100万级

时效性

ms

us

ms

ms级以内

可用性

高(主从)

高(主从)

非常高(分布式)

非常高(分布式)

功能特性

成熟的产品、较全的文档、各种协议支持好

并发能力强、性能好、延迟低

MQ功能比较完善,扩展性佳

只支持主要的MQ功能,主要应用于大数据领域

消息中间件对比-选择建议

消息中间件

建议

Kafka

追求高吞吐量,适合产生大量数据的互联网服务的数据收集业务

RocketMQ

可靠性要求很高的金融互联网领域,稳定性高,经历了多次阿里双11考验

RabbitMQ

性能较好,社区活跃度高,数据量没有那么大,优先选择功能比较完备的RabbitMQ

kafka介绍

Kafka 是一个分布式流媒体平台,类似于消息队列或企业消息传递系统。

kafka官网:Apache Kafka

Kafka中文文档 : 【布客】kafka 中文翻译

kafka介绍-名词解释

  • producer:发布消息的对象称之为主题生产者(Kafka topic producer)
  • topic:Kafka将消息分门别类,每一类的消息称之为一个主题(Topic)
  • consumer:订阅消息并处理发布的消息的对象称之为主题消费者(consumers)
  • broker:已发布的消息保存在一组服务器中,称之为Kafka集群。集群中的每一个服务器都是一个代理(Broker)。 消费者可以订阅一个或多个主题(topic),并从Broker拉数据,从而消费这些已发布的消息。

1.1.2  kafka安装配置

Kafka对于zookeeper是强依赖,保存kafka相关的节点数据,所以安装Kafka之前必须先安装zookeeper

参考课前资料Kafka部分运行

1.2 kafka快速入门

  • 生产者发送消息,多个消费者只能有一个消费者接收到消息
  • 生产者发送消息,多个消费者都可以接收到消息

1.2.1 创建项目

1.2.2 导入依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
</dependencies>

1.2.3 发送消息

启动引导类

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

在kafka-producer项目中编写生产者代码发送消息 , 创建application.yml配置文件, 配置Kafka连接信息

spring:
  application:
    name: kafka-producer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer

配置消息主题

@Configuration
public class KafkaConfig {

    @Bean
    public NewTopic newTopic(){
        return TopicBuilder.name("topic.my-topic1").build();
    }
}

发送消息到Kafka

package com.heima.kafka;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.util.concurrent.FailureCallback;
import org.springframework.util.concurrent.ListenableFuture;
import org.springframework.util.concurrent.SuccessCallback;

import java.util.concurrent.ExecutionException;

@SpringBootTest
public class KafkaProducerTest {

    @Autowired
    private KafkaTemplate kafkaTemplate;

    @Test
    public void testSend() throws ExecutionException, InterruptedException {
        kafkaTemplate.send("kafka.topic.my-topic1", "kafka", "hello kafka !");
    }
}

1.2.4 接收消息

启动引导类

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

在kafka-consumer项目中编写消费者代码接收消息 , 创建application.yml配置文件, 配置Kafka连接信息

spring:
  application:
    name: kafka-consumer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer

创建监听器类, 监听kafka消息

@Component
public class KafkaConsumerListener {

    @KafkaListener(topics = "kafka.topic.my-topic1", groupId = "group1")
    public void listenTopic1group1(ConsumerRecord<String, String> record) {
        String key = record.key();
        String value = record.value();
        System.out.println("group1中的消费者接收到消息:" + key + " : " + value+"));
    }
}

二. Kafka原理和设计思想

2.1 kafka高性能设计

跟RabbitMQ不同的是, Kafka 是基于磁盘进行存储的,但 Kafka 官方又称其具有高性能、高吞吐、低延时的特点,其吞吐量动辄几十上百万。那么 Kafka 又是怎么做到其吞吐量动辄几十上百万的呢?

Kafka 高性能,是多方面协同的结果,包括宏观架构、分布式 partition 存储、ISR 数据同步、以及“无所不用其极”的高效利用磁盘、操作系统特性等。总结一下其实就是六个要点 :

  • 磁盘顺序读写
  • 消息分区
  • 页缓存
  • 零拷贝
  • 消息压缩
  • 分批发送

2.1.1 顺序读写

磁盘读写有两种方式:顺序读写或者随机读写。

随机读写顾名思义读写具有随机性,不遵循文件的先后顺序进行数据的读取和写入,可任意跳到某个文件节点处进行读写操作。

当一个硬盘被使用了一段时间后,之前不断写入数据和删除数据,时间长了,自然会在硬盘的闪存颗粒里产生很多零零散散的存储空间或数据存放地址不集中在某个连续空间,此时读写数据的方式就是随机读写了,随机读写的特点是读写数据小而分散,随机性强,读写时间较长

顺序读写比较好理解,顾名思义就是将要处理的数据集中起来排好队,按照最优化的速度进行连续读写,通常在读写大型文件时可以获得比较理想的顺序读写速度。

当我们用硬盘coty资料的时候 , copy一个完整的文件的速度要比copy零散文件的速度快很多

为了提高读写硬盘的速度,Kafka就是使用顺序读写。规避了磁盘寻址 , 因此效率非常高。

磁盘的顺序读写效率, 基本上可以和内存的随机读写效率持平

2.1.2 消息分区

Kafka 对于数据的读写是以分区为粒度的,分区可以分布在多个主机(Broker)中,这样每个节点能够实现独立的数据写入和读取,并且能够通过增加新的节点来增加 Kafka 集群的吞吐量,通过分区部署在多个 Broker 来实现负载均衡的效果

配置Kafka分区以及分区数量

@Configuration
public class KafkaConfig {

    @Bean
    public NewTopic topic1() {
        return TopicBuilder.name("topic.my-topic2").partitions(3).build();
    }
}

Kafka 的分区策略指的就是将生产者发送到哪个分区的算法。Kafka 为我们提供了默认的分区策略,同时它也支持你自定义分区策略

  1. 手动指定分区 : 发送消息的时候, 可以手动指定分区编号 , 消息会发送到对应的分区
  2. 按键分发 : 发送消息的时候, 传递了消息key值, 默认会根据key进行hash计算, 用hash值%分区数量, 得到的结果就是分区编号
  3. 轮询分发 : 发送消息的时候, 没有指定消息key值, 采用轮询分区策略 , 按顺序轮流将每条数据分配到每个分区中

手动指定分区

for (int i = 0; i < 10; i++) {
    kafkaTemplate.send("test.topic2", i%2, null, "kafka" + i);
    Thread.sleep(1000);
}

按键路由

kafkaTemplate.send("test.topic2", "hello spring", "kafka");

轮询分发

for (int i = 0; i < 10; i++) {
    kafkaTemplate.send("test.topic2", "kafka"+i);
    Thread.sleep(1000);
}

2.1.3 页缓存

我们知道文件一般存放在硬盘(机械硬盘或固态硬盘)中,CPU 并不能直接访问硬盘中的数据,而是需要先将硬盘中的数据读入到内存中,然后才能被 CPU 访问

由于读写硬盘的速度比读写内存要慢很多(DDR4 内存读写速度是机械硬盘500倍,是固态硬盘的200倍),所以为了避免每次读写文件时,都需要对硬盘进行读写操作,Linux 内核使用 页缓存(Page Cache) 机制来对文件中的数据进行缓存

索引 内存页(16K) : 页分裂&页合并

什么是页缓存 ?

为了提升对文件的读写效率,Linux 内核会以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为 页缓存)与文件中的数据块进行绑定

  • 当从文件中读取数据时,如果要读取的数据所在的页缓存已经存在,那么就直接把页缓存的数据拷贝给用户即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把页缓存的数据拷贝给用户。
  • 当向文件中写入数据时,如果要写入的数据所在的页缓存已经存在,那么直接把新数据写入到页缓存即可。否则,内核首先会申请一个空闲的内存页(页缓存),然后从文件中读取数据到页缓存,并且把新数据写入到页缓存中。对于被修改的页缓存,内核会定时把这些页缓存刷新到文件中

Kafka 中消息先被写入页缓存,由操作系统负责刷盘任务 , 这是 Kafka 实现高吞吐的重要因素之一

2.1.4 零拷贝

零拷贝(Zero-Copy)技术是指电脑执行操作时,CPU不需要参与数据的搬运复制。这种技术通常用于网络传输文件时,节省CPU周期和内存带宽

传统读取数据流程:

  1. 用户进程请求读取数据
  2. CPU发出指令给磁盘控制器,然后返回;
  3. 磁盘控制器收到指令后,将数据复制到磁盘的内部缓冲区,随后对CPU发起IO中断信号
  4. CPU收到中断信号后,将缓冲区的读到寄存器中,再将寄存器中的数据写入的内存,写入到内存期间CPU是无法执行其他任务

整个数据搬运到内存的过程中都需要CPU参与计算。如果用到千兆网卡或者磁盘传输大量数据的时候,CPU一直处于搬运复制数据的过程中,将会对系统的负载和吞吐量产生比较大的影响。

于是发明了DMA(Direct Memory Acess)技术,也就是直接内存访问。简单理解就是,在磁盘和内存进行数据搬运时,这些工作会由DMA控制器进行,而不是CPU,这样可以减轻CPU的负载

可以看到,整个数据从磁盘到内存传输的过程中,CPU不再参与搬运,全都是DMA控制器完成。早期DMA只存在于主板上,如今基本上每个I/0设备都有自己的DMA控制器

如果服务端需要有文件传输的功能,简单的方式是:调用系统read()函数 将磁盘文件读入内存,然后通过调用系统write()函数将内存数据写给网络协议栈发送给客户端。如下图:

首先可以看到,读磁盘文件写入到网卡,一共经历了4次的用户态和内核态的切换。原因是:用户线程调用了系统函数一次read()和一次write(),每次系统调用都需要先从用户态切换到内核态,等内核态完成任务后,再从内核态切换回用户态。

其次,发送了4次数据拷贝,两次拷贝是由DMA完成的,两次拷贝是CPU完成的。

由此我们可以分析出,搬运一份数据存在冗余的用户态和内核态的切换以及多余的拷贝。所以想要提高文件传输性能,需要减少用户态和内核态的切换和拷贝次数

可以通过调用sendfile()函数替代前面的read()和write()系统调用,这样可以减少一次系统调用,也就减少了两次次用户态和内核态之间的切换开销。其次,该函数可以直接把内核缓冲区里面的数据拷贝的socket缓冲区,这样就只有2次上下文切换,和3次数据拷贝。如下图所示:

但是这个还不是真正的零拷贝技术,从内核2.4版本开始,如果网卡支持SG-DMA(The Scatter-Gather Direct Memory Access),可以进一步减少CPU把内核缓冲区里面的数据拷贝到socket缓冲区的过程。于是,从内核2.4版本开始,对于网卡支持SG-DMA技术的情况下,sendfile() 系统调用过程可以实现CPU的零拷贝,整个过程如下图所示 :

这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过cpu来搬运数据,所有的数据都是由DMA来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了两次用户态和内核态的切换和数据拷贝次数。所以总体上看,零拷贝技术可以把文件的传输性能提高至少一倍以上

Kafka中存在大量的网络数据持久化到磁盘(Producer到Broker)和磁盘文件通过网络发送(Broker到Consumer)的过程。这一过程的性能直接影响到Kafka的整体性能。Kafka采用零拷贝这一通用技术解决该问题

传统的网络IO模型下面, 我们需要进行数据传输需要进行4次的上下文切换和4次的数据搬运, 首先就是从磁盘上读取数据到操作系统内核缓存 , 在由CPU把数据复制到应用进程缓存 , 因为涉及到网络传输还需要把数据复制到Socket缓冲区 , 最后再把数据复制给网卡, 由网卡把数据发送给用户 , 这样才能完成整体的数据传输

但是在使用了零拷贝的情况下就只需要进行2次的上下文切换和2次的数据复制 , 首先DMA控制器将数据复制到操作系统内核缓存, 再有SG-DMA控制器将数据直接复制给网卡进行数据传输 ,, 这样数据传输的效率至少提高了一倍以上 , 这就是零拷贝

2.1.5 消息压缩

消息压缩顾名思义就是将消息内容压缩之后传输 , 可以有效节省带宽, 提高消息发送的效率

默认情况下, 消息发送时不会被压缩。我们可以在发送消息的时候配置压缩算法

代码中配置方式:

spring:
  application:
    name: kafka-producer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      compression-type: gzip  # 消息压缩算法

100M 80M 20%

100M 50M 50%

压缩算法

说明

snappy

占用较少的  CPU,  却能提供较好的性能和相当可观的压缩比, 如果看重性能和网络带宽,建议采用

lz4

占用较少的 CPU, 压缩和解压缩速度较快,压缩比也很客观

gzip

占用较多的  CPU,但会提供更高的压缩比,网络带宽有限,可以使用这种算法

使用压缩可以降低网络传输开销和存储开销,从而提高Kafka数据传输的效率

2.1.6 分批发送

生产者发送多个消息到同一个分区的时候,为了减少网络带来的性能开销,kafka会对消息进行批量发送

batch.size : 通过这个参数来设置批量提交的数据大小,默认是16k,当积压的消息达到这个值的时候就会统一发送(发往同一分区的消息)

spring:
  application:
    name: kafka-producer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      retries: 10  # 重试次数
      compression-type: gzip  # 消息压缩算法
      batch-size: 16KB  #批量提交的数据大小

发送时间间隔 : 20ms

每一个批次的大小 : 16KB

满足其中任何一个, 消息都会被发送

2.2 kafka高可用设计

2.2.1 集群架构

Kafka 的服务器端由被称为 Broker 的服务进程构成,即一个 Kafka 集群由多个 Broker 组成

这样如果集群中某一台机器宕机,其他机器上的 Broker 也依然能够对外提供服务。这其实就是 Kafka 提供高可用的基础

2.2.2 备份机制(Replication)

Kafka 中消息的备份又叫做 副本(Replica)

Kafka 定义了两类副本:

  • 领导者副本(Leader Replica): 负责数据读写
  • 追随者副本(Follower Replica): 只负责数据备份
  • 当领导者副本所在节点宕机之后, 会从追随者副本中选举一个节点, 升级为领导者副本 , 对外提供数据读写服务, 保证数据安全

2.2.3 消费者组和再均衡

2.2.3.1 消费者组

消费者组(Consumer Group)是由一个或多个消费者实例(Consumer Instance)组成的群组,具有可扩展性和可容错性的一种机制。消费者组内的消费者共享一个消费者组ID,这个ID 也叫做 Group ID,组内的消费者共同对一个主题进行订阅和消费,同一个组中只能够由一个消费者去消费某一个分区的数据,多余的消费者会闲置,派不上用场。

同一个分区只能被一个消费者组中的一个消费者消费 , 一个消费者组中的某一个消费者, 可以消费多个分区

一个生产者发送一条消息只能被一个消费者消费 : 让消费者处于同一个组中即可

一个生产者发送一条消息需要被多个消费者消费 : 让消费者处于不同的组中

@Component
public class KafkaConsumerListener {

    @KafkaListener(topics = "kafka.topic.my-topic1",groupId = "group1")
    public void listenTopic1group1(ConsumerRecord<String, String> record) {
        String key = record.key();
        String value = record.value();
        System.out.println("group1中的消费者接收到消息:"+key + " : " + value);
    }

    @KafkaListener(topics = "kafka.topic.my-topic1",groupId = "group2")
    public void listenTopic1group2(ConsumerRecord<String, String> record) {
        String key = record.key();
        String value = record.value();
        System.out.println("group2中的消费者接收到消息:"+key + " : " + value);
    }
}
2.2.3.2 再均衡(重平衡)

再均衡就是指 当消费者组中的消费者发生变更的时候(新增消费者, 消费者宕机) , 重新为消费者分配消费分区的过程

当消费者组中重新加入消费者 , 或者消费者组中有消费者宕机 , 这个时候Kafka会为消费者组中的消费者从新分配消费分区的过程就是再均衡

重平衡(再均衡)非常重要,它为消费者群组带来了高可用性伸缩性,我们可以放心的添加消费者或移除消费者,不过在正常情况下我们并不希望发生这样的行为。在重平衡期间,消费者无法读取消息,造成整个消费者组在重平衡的期间都不可用 , 并且在发生再均衡的时候有可能导致消息的丢失和重复消费

2.3 kafka生产者详解

2.3.1 发送类型

同步发送 : 使用send()方法发送,它会返回一个Future对象,调用get()方法进行等待,就可以知道消息是否发送成功

@Test
public void testSend() throws ExecutionException, InterruptedException {
    同步发送
    SendResult result = (SendResult) kafkaTemplate.send("kafka.topic.my-topic1", "kafka", "hello kafka").get();
    System.out.println(result.getRecordMetadata().offset());
}

异步发送 : 调用send()方法,并指定一个回调函数,服务器在返回响应时调用函数

@Test
public void testSend() throws ExecutionException, InterruptedException {
    //异步发送
    ListenableFuture future = kafkaTemplate.send("kafka.topic.my-topic1", "kafka", "hello kafka");
    future.addCallback(result -> {
        //消息发送成功执行
        SendResult sendResult = (SendResult) result;
        System.out.println(sendResult.getRecordMetadata().offset());
    }, throwable -> {
        //消息发送失败执行
        System.out.println("发送消息出现异常:" + throwable);
    });

    Thread.sleep(1000);
}

2.3.2 参数详解

签收机制 : acks

代码的配置方式:

spring:
  application:
    name: kafka-producer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      retries: 10  # 重试次数
      compression-type: gzip  # 消息压缩算法
      batch-size: 16KB  #批量提交的数据大小
      acks: all  # 消息确认机制  0: 不签收 , 1 : leader签收 , all : leader和follower都签收

参数的选择说明

确认机制

说明

acks=0

生产者在成功写入消息之前不会等待任何来自服务器的响应,消息有丢失的风险,但是速度最快

acks=1(默认值)

只要集群首领节点收到消息,生产者就会收到一个来自服务器的成功响应

acks=all

只有当所有参与赋值的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应

追求极致的吞吐量和性能使用 acks=0

追求是数据安全, 消息发送不丢失 , acks=all

既要吞吐量也要可靠性 : acks=1 (折中方案)

重试机制 : retries

生产者从服务器收到的错误有可能是临时性错误,在这种情况下,retries参数的值决定了生产者可以重发消息的次数,如果达到这个次数,生产者会放弃重试返回错误,默认情况下,生产者会在每次重试之间等待100ms

代码中配置方式:

spring:
  application:
    name: kafka-producer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      retries: 10  # 重试次数

为了提高消息投递的成功率, 可以将重试次数设为一个很大的值 , 例如 : 999999999999999

2.4 kafka消费者详解

2.4.1 消息有序性

所谓消息有序性就是保证Kafka消息消费的顺序和发送的顺序保持一致 , 应用场景很多 :

比如客户开车出事故了需要保险公司来处理,至少要有以下几个步骤: 报案、查勘定损、立案、收单理算支付、结案等环节,这些环节是严格有序的。保险公司每完成一个环节,需要给中保信(监管保险公司的)推送数据,如果推送顺序有问题,会返回错误,比如上一个环节还没有完成。同样电商行业也是如此,下单、支付、发货都是有序的。

2.4.2 Kafka消息有序性

我们知道Kafka中的每个分区中的数据是有序的,但有序性仅限于当前的分区中。比如我们现在往一个topic中发送消息 , 这个topic有两个分区 , 默认采用轮询策略, 那么这个topic分区0中插入数据 1,3,5,然后在分区1中插入数据2,4,6 , 这时如果消费者想要读取这个topic的数据,他就可能随机从分区0和分区1中读取数据,比如读出结果为1,3,2,5,4,6。这时可以看到读到的数据顺序已经不是插入的顺序了。

方法一 : 一个 Topic 只对应一个 Partion

消息在被追加到 Partition(分区)的时候都会分配一个特定的偏移量(offset)。Kafka 通过偏移量(offset)来保证消息在分区内的顺序性。每次添加消息到 Partition(分区) 的时候都会采用尾加法,如下图所示。Kafka 只能为我们保证 Partition(分区) 中的消息有序,而不能保证 Topic(主题) 中的 Partition(分区) 的有序。

所以,我们就有一种很简单的保证消息消费顺序的方法:一个 Topic 只对应一个 Partion , 这种方式影响Kafka效率

方法二 : 按键路由

Kafka 中发送 1 条消息的时候,可以指定 topic, partition, key,data(数据) 4 个参数。如果你发送消息的时候指定了 partion 的话,所有消息都会被发送到指定的 partion。并且,同一个 key 的消息可以保证只发送到同一个 partition ! 这样我们就可以为需要保证顺序的消息设置同一个Key , 这样就能保证这组消息都发送到同一个分区中 , 从而保证消息顺序性

@Test
public void sendTopic3() {
    kafkaTemplate.send("test.topic03", "order_1001", "kafka!");
}

2.4.3 提交和偏移量

Kafka会记录每条消息的offset(偏移量) , 消费者可以使用offset来追踪消息在分区的位置 , 所以在Kafka中消息消费采用的是pull模型, 由消费者主动去Kafka Brocker中拉取消息

之前说过Kafka的消费者再均衡机制 : 如果消费者发生崩溃或有新的消费者加入群组,就会触发再均衡 , 例如:

如果消费者2挂掉以后,会发生再均衡,消费者2负责的分区会被其他消费者进行消费

再均衡后不可避免会出现一些问题(消息丢失&消息重复消费)

问题一:消息重复消费

如果提交偏移量小于客户端处理的最后一个消息的偏移量,那么处于两个偏移量之间的消息就会被重复处理。

问题二:消息丢失

如果提交的偏移量大于客户端的最后一个消息的偏移量,那么处于两个偏移量之间的消息将会丢失。

如果想要解决这些问题,还要知道目前kafka提交偏移量的方式 , 提交偏移量的方式有两种,分别是自动提交偏移量和手动提交

  • 自动提交偏移量 : enable.auto.commit被设置为true,提交方式就是让消费者自动提交偏移量,每隔5秒消费者会自动把从poll()方法接收的最大偏移量提交上去
  • 手动提交偏移量 : enable.auto.commit被设置为false , 需要程序员手动提交偏移量

手动提交偏移量 : 同步提交

enable.auto.commit设置为false,让应用程序决定何时提交偏移量。使用commitSync()提交偏移量,commitSync()将会提交poll返回的最新的偏移量,所以在处理完所有记录后要确保调用了commitSync()方法。否则还是会有消息丢失的风险。

只要没有发生不可恢复的错误,commitSync()方法会一直尝试直至提交成功,如果提交失败也可以记录到错误日志里。

@KafkaListener(topics = "kafka.topic.my-topic1", groupId = "group1")
public void listenTopic1group1(KafkaConsumer consumer, ConsumerRecord<String, String> record) {
    String key = record.key();
    String value = record.value();
    System.out.println("group1中的消费者接收到消息:" + key + " : " + value+",偏移量:"+record.offset());

    //同步提交偏移量
    consumer.commitSync();  
}

手动提交偏移量 : 异步提交

手动提交有一个缺点,那就是当发起提交调用时应用会阻塞。当然我们可以减少手动提交的频率,但这个会增加消息重复的概率(和自动提交一样)。另外一个解决办法是,使用异步提交的API。

@KafkaListener(topics = "kafka.topic.my-topic1", groupId = "group1")
public void listenTopic1group1(KafkaConsumer consumer, ConsumerRecord<String, String> record) {
    String key = record.key();
    String value = record.value();
    System.out.println("group1中的消费者接收到消息:" + key + " : " + value+",偏移量:"+record.offset());

    //异步提交偏移量
    consumer.commitAsync();
}

手动提交偏移量 : 同步和异步组合提交

异步提交也有个缺点,那就是如果服务器返回提交失败,异步提交不会进行重试。相比较起来,同步提交会进行重试直到成功或者最后抛出异常给应用。异步提交没有实现重试是因为,如果同时存在多个异步提交,进行重试可能会导致位移覆盖。

@KafkaListener(topics = "kafka.topic.my-topic1", groupId = "group1")
public void listenTopic1group1(KafkaConsumer consumer, ConsumerRecord<String, String> record) {
    String key = record.key();
    String value = record.value();
    System.out.println("group1中的消费者接收到消息:" + key + " : " + value+",偏移量:"+record.offset());

    //同步异步, 结合提交
    try {
        consumer.commitAsync();
    } catch (Exception e) {
        e.printStackTrace();
        consumer.commitSync();
    }
}

三. Kafka集群选举

3.1 搭建Kafka集群

Kafka集群需要使用Zookeeper实现分布式管理,我们先拉去Zookeeper镜像,再实现安装:

docker pull zookeeper:3.7.0

创建脚本文件kafka.yml

version: '3.8'
services:
  zookeeper:
    image: zookeeper:3.7.0
    restart: always
    hostname: 192.168.200.130
    container_name: zookeeper
    privileged: true
    ports:
      - 2181:2181
    volumes:
      - /usr/local/server/zookeeper/data/:/data
    build:
      context: .
      network: host

  kafka1:
    container_name: kafka1
    restart: always
    image: wurstmeister/kafka:2.12-2.5.0
    privileged: true
    ports:
      - 9092:9092
      - 19092:19092
    environment:
      KAFKA_BROKER_ID: 1
      HOST_IP: 192.168.200.130
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.200.130:9092    ## 宿主机IP
      KAFKA_ZOOKEEPER_CONNECT: 192.168.200.130:2181
      #docker部署必须设置外部可访问ip和端口,否则注册进zk的地址将不可达造成外部无法连接
      KAFKA_ADVERTISED_HOST_NAME: 192.168.200.130
      KAFKA_ADVERTISED_PORT: 9092
      KAFKA_PORT: 9092
      KAFKA_delete_topic_enable: 'true'
      KAFKA_JMX_OPTS: "-Dcom.sun.management.jmxremote=true -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=192.168.200.130 -Dcom.sun.management.jmxremote.rmi.port=19092"
      JMX_PORT: 19092
      volumes:
        /etc/localtime:/etc/localtime
      depends_on:
        zookeeper
  kafka2:
    container_name: kafka2
    restart: always
    image: wurstmeister/kafka:2.12-2.5.0
    privileged: true
    ports:
      - 9093:9093
      - 19093:19093
    environment:
      KAFKA_BROKER_ID: 2
      HOST_IP: 192.168.200.130
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.200.130:9093    ## 宿主机IP
      KAFKA_ZOOKEEPER_CONNECT: 192.168.200.130:2181
      #docker部署必须设置外部可访问ip和端口,否则注册进zk的地址将不可达造成外部无法连接
      KAFKA_ADVERTISED_HOST_NAME: 192.168.200.130
      KAFKA_ADVERTISED_PORT: 9093
      KAFKA_PORT: 9093
      KAFKA_delete_topic_enable: 'true'
      KAFKA_JMX_OPTS: "-Dcom.sun.management.jmxremote=true -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=192.168.200.130 -Dcom.sun.management.jmxremote.rmi.port=19093"
      JMX_PORT: 19093
      volumes:
        /etc/localtime:/etc/localtime
      depends_on:
        zookeeper
  kafka3:
    container_name: kafka3
    restart: always
    image: wurstmeister/kafka:2.12-2.5.0
    privileged: true
    ports:
      - 9094:9094
      - 19094:19094
    environment:
      KAFKA_BROKER_ID: 3
      HOST_IP: 192.168.200.130
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9094
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.200.130:9094    ## 宿主机IP
      KAFKA_ZOOKEEPER_CONNECT: 192.168.200.130:2181
      #docker部署必须设置外部可访问ip和端口,否则注册进zk的地址将不可达造成外部无法连接
      KAFKA_ADVERTISED_HOST_NAME: 192.168.200.130
      KAFKA_ADVERTISED_PORT: 9094
      KAFKA_PORT: 9094
      KAFKA_delete_topic_enable: 'true'
      KAFKA_JMX_OPTS: "-Dcom.sun.management.jmxremote=true -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=192.168.200.130 -Dcom.sun.management.jmxremote.rmi.port=19094"
      JMX_PORT: 19094
      volumes:
        /etc/localtime:/etc/localtime
      depends_on:
        zookeeper

  eagle:
    image: gui66497/kafka_eagle
    container_name: eagle_monitor
    restart: always
    depends_on:
      - kafka1
      - kafka2
      - kafka3
    ports:
      - "8048:8048"
    environment:
      ZKSERVER: "192.168.200.130:2181"

执行安装;

#执行命令
docker-compose -f kafka.yml up -d

#效果如下
[root@localhost kafka]# docker-compose -f kafka.yml up -d
[+] Running 5/5
 ⠿ Container kafka2         Started    1.1s
 ⠿ Container kafka3         Started    1.2s
 ⠿ Container zookeeper      Started    1.0s
 ⠿ Container kafka1         Started    1.1s
 ⠿ Container eagle_monitor  Started    3.0s
[root@localhost kafka]#

访问控制台http://192.168.200.130:8048/ke/,输入账号密码admin / 123456登录,效果如下:

3.2 Kafka集群选举

3.2.1 ISR与OSR

Kafka为了对消息进行分类,引入了Topic(主题)的概念。生产者在发送消息的时候,需要指定发送到某个Topic,然后消息者订阅这个Topic并进行消费消息。

Kafka为了提升性能,又在Topic的基础上,引入了Partition(分区)的概念。Topic是逻辑概念,而Partition是物理分组。一个Topic可以包含多个Partition,生产者在发送消息的时候,需要指定发送到某个Topic的某个Partition,然后消息者订阅这个Topic并消费这个Partition中的消息。

Kafka为了提高系统的吞吐量和可扩展性,把一个Topic的不同Partition放到多个Broker节点上,充分利用机器资源,也便于扩展Partition。

Kafka为了保证数据的安全性和服务的高可用,又在Partition的基础上,引入Replica(副本)的概念。一个Partition包含多个Replica,Replica之间是一主多从的关系,有两种类型Leader Replica(领导者副本)Follower Replica(跟随者副本),Replica分布在不同的Broker节点上。

Leader Replica负责读写请求,Follower Replica只负责同步Leader Replica数据,不对外提供服务。当Leader Replica发生故障,就从Follower Replica选举出一个新的Leader Replica继续对外提供服务,实现了故障自动转移。

Kafka为了提升Replica的同步效率和数据写入效率,又对Replica进行分类。针对一个Partition的所有Replica集合统称为AR(Assigned Replicas,已分配的副本),包含Leader Replica和Follower Replica。与Leader Replica保持同步的Replica集合称为ISR(In-Sync Replicas,同步副本),与Leader Replica保持失去同步的Replica集合称为OSR(Out-of-Sync Replicas,失去同步的副本)AR = ISR + OSR

Leader Replica将消息写入磁盘前,需要等ISR中的所有副本同步完成。如果ISR中某个Follower Replica同步数据落后Leader Replica过多,会被转移到OSR中。如果OSR中的某个Follower Replica同步数据追上了Leader Replica,会被转移到ISR中。当Leader Replica发生故障的时候,只会从ISR中选举出新的Leader Replica

3.2.2 LEO和HW

Kafka为了记录副本的同步状态,以及控制消费者消费消息的范围,于是引入了LEO(Log End Offset,日志结束偏移量)HW(High Watermark,高水位)。

LEO表示分区中的下一个被写入消息的偏移量,也是分区中的最大偏移量。LEO用于记录Leader Replica和Follower Replica之间的数据同步进度,每个副本中各有一份。

Leader : LEO 1000 HW : 950

Follower1 : LEO 980

Follower2 : LEO 1000

Follower3 : LEO 950

HW表示所有副本(Leader和Follower)都已成功复制的最小偏移量,是所有副本共享的数据值。换句话说,HW之前的消息都被视为已提交,消费者可以消费这些消息。用于确保消息的一致性和只读一次。

LEO和HW的更新流程:

  1. 初始状态,三个副本中各有0和1两条消息,LEO都是2,位置2是空的,表示是即将被写入消息的位置。HW也都是2,表示Leader Replica中的所有消息已经全部同步到Follower Replica中,消费者可以消费0和1两条消息。

  1. 生产者往Leader Replica中发送两条消息,此时Leader Replica的LEO的值增加2,变成4。由于还没有开始往Follower Replica同步消息,所以HW值和Follower Replica中LEO值都没有变。由于消费者只能消费HW之前的消息,也就是0和1两条消息

  1. Leader Replica开始向Follower Replica同步消息,同步速率不同,Follower1的两条消息2和3已经同步完成,而Follower2只同步了一条消息2。此时,Leader和Follower1的LEO都是4,而Follower2的LEO是3,HW表示已成功同步的最小偏移量,值是3,表示此时消费者只能读到0、1、2,三条消息

  1. 所有消息都同步完成,三个副本的LEO都是4,HW也是4,消费者可以读到0、1、2、3,四条消息

3.2.3 Kafka分区Leader选举

常见的有以下几种情况会触发Partition的Leader Replica选举:

  1. Leader Replica 失效:当 Leader Replica 出现故障或者失去连接时,Kafka 会触发 Leader Replica 选举。
  2. Broker 宕机:当 Leader Replica 所在的 Broker 节点发生故障或者宕机时,Kafka 也会触发 Leader Replica 选举。
  3. 新增 Broker:当集群中新增 Broker 节点时,Kafka 还会触发 Leader Replica 选举,以重新分配 Partition 的 Leader。
  4. 新建分区:当一个新的分区被创建时,需要选举一个 Leader Replica。
  5. ISR 列表数量减少:当 Partition 的 ISR 列表数量减少时,可能会触发 Leader Replica 选举。当 ISR 列表中副本数量小于 Replication Factor(副本因子)时,为了保证数据的安全性,就会触发 Leader Replica 选举。
  6. 手动触发:通过 Kafka 管理工具(kafka-preferred-replica-election.sh),可以手动触发选举,以平衡负载或实现集群维护
3.2.3.1 Leader Replica选举策略

在 Kafka 集群中,常见的 Leader Replica 选举策略有以下三种:

  1. ISR 选举策略:默认情况下,Kafka 只会从 ISR 集合的副本中选举出新的 Leader Replica,OSR 集合中的副本不具备参选资格。
  2. 不干净副本选举策略(Unclean Leader Election):在某些情况下,ISR 选举策略可能会失败,例如当所有 ISR 副本都不可用时。在这种情况下,可以使用 Unclean Leader 选举策略。Unclean Leader 选举策略会从所有副本中(包含OSR集合)选择一个副本作为新的 Leader 副本,即使这个副本与当前 Leader 副本不同步。这种选举策略可能会导致数据丢失,默认关闭
  3. 首选副本选举策略(Preferred Replica Election):首选副本选举策略也是 Kafka 默认的选举策略。在这种策略下,每个分区都有一个首选副本(Preferred Replica),通常是副本集合中的第一个副本。当触发选举时,控制器会优先选择该首选副本作为新的 Leader Replica,只有在首选副本不可用的情况下,才会考虑其他副本。
    当然,可以使用命令手动指定每个分区的首选副本:

bin/kafka-topics.sh --zookeeper localhost:2181 --topic my-topic-name --replica-assignment 0:1,1:2,2:0 --partitions 3
意思是:my-topic-name有3个partition,partition0的首选副本是Broker1,partition1首选副本是Broker2,partition2的首选副本是Broker0

3.2.3.2 Leader Replica选举过程

谁来主持选举?

kafka先在brokers里面选一个broker作为Controller主持选举。Controller是使用zookeeper选举出来的,每个broker都往zk里面写一个/controller节点,谁先写成功,谁就成为Controller。如果controller失去连接,zk上的临时节点就会消失。其它的broker通过watcher监听到Controller下线的消息后,开始选举新的Controller。

一个Broker节点相当于一台机器,多个Broker节点组成一个Kafka集群。Controller节点也叫控制器节点 , 他负责直接与zookeeper进行通信,并负责管理整个集群的状态和元数据信息

Controller的责任

  • 监听Broker的变化。
  • 监听Topic变化
  • 监听Partition变化
  • 获取和管理Broker、Topic、Partition的信息
  • 管理Partition的主从信息

当Leader Replica宕机或失效时,就会触发 Leader Replica 选举,分为两个阶段,第一个阶段是候选人的提名和投票阶段,第二个阶段是Leader的确认阶段。具体过程如下:

lag(滞后)是kafka消费队列性能监控的重要指标,lag的值越大,表示kafka的消息堆积越严重

  1. 候选人提名和投票阶段
    在Leader Replica失效时,ISR集合中所有Follower Replica都可以成为新的Leader Replica候选人。每个Follower Replica会在选举开始时向其他Follower Replica发送成为候选人的请求,并附带自己的元数据信息,包括自己的当前状态和Lag值。而Preferred replica优先成为候选人。
    其他Follower Replica在收到候选人请求后,会根据请求中的元数据信息,计算每个候选人的Lag值,并将自己的选票投给Lag最小的候选人。如果多个候选人的Lag值相同,则随机选择一个候选人。
  2. Leader确认阶段
    在第一阶段结束后,所有的Follower Replica会重新计算每位候选人的Lag值,并投票给Lag值最小的候选人。此时,选举的结果并不一定出现对候选人的全局共识。为了避免出现这种情况,Kafka中使用了ZooKeeper来实现分布式锁,确保只有一个候选人能够成为新的Leader Replica。
    当ZooKeeper确认有一个候选人已经获得了分布式锁时,该候选人就成为了新的Leader Replica,并向所有的Follower Replica发送一个LeaderAndIsrRequest请求,更新Partition的元数据信息。其他Follower Replica接收到请求后,会更新自己的Partition元数据信息,将新的Leader Replica的ID添加到ISR列表中

四. Kafka Stream

4.1 Kafka Stream 概述

Kafka Stream是Apache Kafka从0.10版本引入的一个新Feature。它是提供了对存储于Kafka内的数据进行流式处理和分析的功能。

Kafka Stream的特点如下:

  • Kafka Stream提供了一个非常简单而轻量的Library,它可以非常方便地嵌入任意Java应用中,也可以任意方式打包和部署
  • 除了Kafka外,无任何外部依赖
  • 充分利用Kafka分区机制实现水平扩展和顺序性保证
  • 通过可容错的state store实现高效的状态操作(如windowed join和aggregation)
  • 支持正好一次处理语义
  • 提供记录级的处理能力,从而实现毫秒级的低延迟
  • 支持基于事件时间的窗口操作,并且可处理晚到的数据(late arrival of records)
  • 同时提供底层的处理原语Processor(类似于Storm的spout和bolt),以及高层抽象的DSL(类似于Spark的map/group/reduce)

4.2 Kafka Stream 概念

  • 源处理器(Source Processor):源处理器是一个没有任何上游处理器的特殊类型的流处理器。它从一个或多个kafka主题生成输入流。通过消费这些主题的消息并将它们转发到下游处理器。
  • 处理拓扑 : 数据的处理流程 , 每一步处理流程就是一个处理拓扑
  • Sink处理器:sink处理器是一个没有下游流处理器的特殊类型的流处理器。它接收上游流处理器的消息发送到一个指定的Kafka主题

消息生产者 ----> Kafka Topic(原始数据)  ------> Source Processor ------> 处理拓扑(很多步处理)   ------> Sink Processor  -----> Kafka Topic (运算结果) -----> 消费者(接收运行结果)

4.3 Kafka Stream 数据结构

Kafka数据结构类似于map,如下图,key-value键值对

KStream

KStream数据流,即是一段顺序的,可以无限长,不断更新的数据集。KStream数据流中的每一条数据相当于一次插入

商品的行为分值运算(排行) :

{"type":"like","count":1}

{"type":"like","count":-1}

{"type":"like","count":1}

对上面的行为数据进行运算得到运算结果 :

{"type":"like","count":2}

KTable数据流 , 即是一段顺序的,可以无限长,不断更新的数据集。KTable数据流中的每一条数据相当于一次更新

公交车的运行数据

{"No":"518","location":"武湖新天地"}

{"No":"518","location":"潘森产业园"}

{"No":"518","location":"传智教育产业园"}

对上面的行为数据进行运算得到运算结果 :

{"No":"518","location":"传智教育产业园"}

4.4 入门案例一

4.4.1 需求描述与分析

计算每个单词出现的次数

@Test
void testSend5() {
    List<String> strs = new ArrayList<String>();
    strs.add("hello word");
    strs.add("hello kafka");
    strs.add("hello spring kafka");
    strs.add("kafka stream");
    strs.add("spring kafka");

    strs.stream().forEach(s -> {
        kafkaTemplate.send("kafka.stream.topic1", "10001", s);
    });
}

4.4.2 配置KafkaStream

添加依赖

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-streams</artifactId>
</dependency>

开启KafkaStream功能

配置Kafka Stream

spring:
  application:
    name: kafka-consumer
  kafka:
    bootstrap-servers: 118.25.197.221:9092
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      group-id: ${spring.application.name}
      enable-auto-commit: false  # 关闭自动提交, 使用手动提交偏移量
    streams:
      application-id: ${spring.application.name}-application-id
      client-id: ${spring.application.name}-client-id
      properties:
        default:
          key:
            serde: org.apache.kafka.common.serialization.Serdes$StringSerde
          value:
            serde: org.apache.kafka.common.serialization.Serdes$StringSerde

4.4.3 定义处理流程

package com.heima.kafka.stream;

import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.kstream.Grouped;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.Materialized;
import org.apache.kafka.streams.kstream.TimeWindows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

/**
 * @Author Administrator
 * @Date 2023/6/30
 **/
@Configuration
public class KafkaStreamConfig {

    /**
     * 原始数据 ------
     * 10001  hello word
     * 10001  hello kafka
     * 10001  hello spring kafka
     * 10001  kafka stream
     * 10001  spring kafka
     *
     * 对原始数据中的value字符串进行切割
     * 10001  [hello,word]
     * 10001  [hello,kafka]
     * 10001  [hello,spring,kafka]
     * 10001  [kafka,stream]
     * 10001  [spring,kafka]
     *
     * 对value数组进行扁平化处理(将多维数组转化为一维数组)
     * 10001  hello
     * 10001  word
     * 10001  hello
     * 10001  kafka
     * 10001  hello
     * 10001  spring
     * 10001  kafka
     * 10001  stream
     * 10001  spring
     * 10001  kafka
     *
     * 对数据格式进行转化, 使用value作为key
     * hello  hello
     * word   word
     * hello  hello
     * kafka  kafka
     * hello  hello
     * spring spring
     * kafka  kafka
     * kafka  kafka
     * stream stream
     * spring spring
     * kafka  kafka
     *
     * 对key进行分组 
     *  hello  hello
     *  hello  hello
     *  hello  hello
     *
     *  word   word
     *
     *  kafka  kafka
     *  kafka  kafka
     *  kafka  kafka
     *  kafka  kafka
     *
     *  spring  spring
     *  spring  spring
     *
     *  stream  stream
     *	
     *计算组内单词数量 , 得到运算结果 -----
     * hello 3
     * word 1
     * kafka 4
     * spring 2
     * stream 1
     *
     * @param builder
     * @return
     */
    @Bean
    public KStream<String, String> kStream(StreamsBuilder builder) {
        //1. 定义数据来源
        KStream<String, String> kStream = builder.<String, String>stream("kafka.stream.topic1");
        //2. 定义数据处理流程
        kStream
                //2.1 对原始数据中的value字符串进行切割   mapValues : 对流中数据的value进行处理转化
                .mapValues(value -> value.split(" "))
                //2.2 对value数组进行扁平化处理(将多维数组转化为一维数组)   flatMapValues : 对流中数据的数组格式的value进行处理转化(多维转一维)
                .flatMapValues(value -> Arrays.asList(value))
                //2.3 对数据格式进行转化, 使用value作为key   map : 对流中数据的key和value进行处理转化
                .map(((key, value) -> new KeyValue<>(value,value)))
                //2.4 对key进行分组  groupByKey : 根据key进行分组
                .groupByKey(Grouped.with(Serdes.String(),Serdes.String()))
                //设置聚合时间窗口, 在指定时间窗口范围之内的数据会进行一次运算, 输出运算结果
                .windowedBy(TimeWindows.of(Duration.ofSeconds(10)))
                //2.5 求每一个组中的单词数量   count : 组内计算元素数量
                .count(Materialized.with(Serdes.String(),Serdes.Long()))
                //2.6 将运算结果发送到另一个topic中   toStream : 将其他类型的流转化为 kStream
                .toStream()
                .map((key, value) -> new KeyValue<>(key.key(),value.toString()))
                //将运算结果发送到一个topic, 供消费者接收
                .to("kafka.stream.topic2");

        //3. 返回KStream对象
        return kStream;
    }
}

4.4.4 声明Topic

KafkaStream不会自动帮助我们创建Topic ,所以我们需要自己声明消息来源的topic和消息发送的topic

@Bean
public NewTopic streamTopic1() {
    return TopicBuilder.name("kafka.stream.topic1").build();
}

@Bean
public NewTopic streamTopic2() {
    return TopicBuilder.name("kafka.stream.topic2").build();
}

4.4.5 接收处理结果

定义一个消费者 , 从to("kafka.stream.topic2")中接收计算完毕的消息

@Component
@Slf4j
public class KafkaStreamConsumerListener {

    @KafkaListener(topics = "kafka.stream.topic2", groupId = "steam")
    public void listenTopic1(ConsumerRecord<String, String> record) {
        String key = record.key();
        String value = record.value();
        log.info("单词:{} , 出现{}次", key, value);
    }
}

4.4.5 发送消息测试

@SpringBootTest
@Slf4j
public class KafkaStreamProducerTest {

    @Resource
    private KafkaTemplate kafkaTemplate;

    @Test
    void testSend5() {
        List<String> strs = new ArrayList<String>();
        strs.add("hello word");
        strs.add("hello kafka");
        strs.add("hello spring kafka");
        strs.add("kafka stream");
        strs.add("spring kafka");

        strs.stream().forEach(s -> {
            kafkaTemplate.send("kafka.stream.topic1", "10001", s);
        });
    }
}

4.5 入门案例二

4.5.1 需求描述与分析

现在有一组文章行为数据 , 使用ArticleMessage对象封装

package com.heima.kafka.pojos;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @author Administrator
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ArticleMessage {

    /**
     * 文章ID
     */
    private Long articleId;

    /**
     * 修改文章的字段类型
     */
    private UpdateArticleType type;

    /**
     * 修改数据的增量,可为正负
     */
    private Integer add;


    public enum UpdateArticleType {
        COLLECTION, COMMENT, LIKES, VIEWS;
    }
}

模拟数据如下 :

@Test
void testSend6() {
    List<ArticleMessage> strs = new ArrayList<ArticleMessage>();
    ArticleMessage message1 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.LIKES, 1);
    ArticleMessage message4 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.LIKES, 1);
    ArticleMessage message7 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.LIKES, 1);
    ArticleMessage message3 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.LIKES, -1);
    ArticleMessage message2 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.VIEWS, 1);
    
    ArticleMessage message6 = new ArticleMessage(1498973263815045122l, ArticleMessage.UpdateArticleType.COLLECTION, 1);
    ArticleMessage message5 = new ArticleMessage(1498973263815045122l, ArticleMessage.UpdateArticleType.COLLECTION, 1);
    ArticleMessage message8 = new ArticleMessage(1498973263815045122l, ArticleMessage.UpdateArticleType.COLLECTION, 1);
    ArticleMessage message9 = new ArticleMessage(1498972384605040641l, ArticleMessage.UpdateArticleType.COLLECTION, 1);

    strs.add(message1);
    strs.add(message2);
    strs.add(message3);
    strs.add(message4);
    strs.add(message5);
    strs.add(message6);
    strs.add(message7);
    strs.add(message8);
    strs.add(message9);

    strs.stream().forEach(s -> {
        kafkaTemplate.send("hot.article.score.topic" , JSON.toJSONString(s));
    });
}

需求如下 : 请计算出每个文章每种行为的次数 , 输出 :  文章ID : COLLECTION:10,COMMENT:20,LIKES:5,VIEWS:30

4.5.2 定义处理流程

/**
 * @param builder
 * @return
 */
@Bean
public KStream<String, String> kStream(StreamsBuilder builder) {
    //获取KStream流对象
    KStream<String, String> kStream = builder.stream("hot.article.score.topic");
    //定义流处理拓扑
    kStream
            //JSON转化为Java对象
            .mapValues(value -> JSON.parseObject(value, ArticleMessage.class))
            //key和值处理  key: 文章ID  , value : 行为类型:数量
            .map((key, value) -> new KeyValue<>(value.getArticleId(), value.getType().name() + ":" + value.getAdd()))
            //根据key进行分组
            .groupByKey(Grouped.with(Serdes.Long(), Serdes.String()))
            //设置时间窗口
            .windowedBy(TimeWindows.of(Duration.ofMillis(10000)))
            //数据聚合
            .aggregate(() -> "COLLECTION:0,COMMENT:0,LIKES:0,VIEWS:0", (key, value, aggValue) -> {
                if (StringUtils.isBlank(value)) {
                    return aggValue;
                }
                String[] aggValues = aggValue.split(",");

                Map<String, Integer> map = new HashMap<>();
                for (String agg : aggValues) {
                    String[] strs = agg.split(":");
                    map.put(strs[0], Integer.valueOf(strs[1]));
                }

                String[] values = value.split(":");
                map.put(values[0], map.get(values[0]) + Integer.valueOf(values[1]));

                String format = String.format("COLLECTION:%s,COMMENT:%s,LIKES:%s,VIEWS:%s", map.get("COLLECTION"), map.get("COMMENT"), map.get("LIKES"), map.get("VIEWS"));

                return format;
            }, Materialized.with(Serdes.Long(), Serdes.String()))
            //重新转化为kStream
            .toStream()
            //数据格式转换
            .map((key, value) -> new KeyValue<>(key.key().toString(), value.toString()))
            .to("hot.article.incr.handle.topic");

    return kStream;
}

4.5.3 接收处理结果

@KafkaListener(topics = "hot.article.incr.handle.topic", groupId = "group3")
public void consumer8(ConsumerRecord<String, String> record) {
    String key = record.key();
    String value = record.value();
    System.out.println("consumer8接收到消息:" + key + ":" + value);
}

4.4.4 声明Topic

@Bean
public NewTopic topic7() {
    return TopicBuilder.name("kafka.topic7").build();
}

@Bean
public NewTopic article() {
    return TopicBuilder.name("hot.article.score.topic").build();
}

2.5.5 发送消息测试

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

吉迪恩

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

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

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

打赏作者

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

抵扣说明:

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

余额充值