消息队列-kafka

消息队列-kafka

摘要 :本文将会对kafka进行介绍,首先介绍消息队列的一些基础知识,然后是kafka的基本概念和底层原理,以及kafka如何保证消息可靠性、消息不丢失,如何解决消息重复以及消息积压等问题,并且分析kafka为什么具有高吞吐量、低延迟等优势。最后,以golang语言来进行kafka的实战。
关键词:消息队列,kafka,golang,面试

kafka常见面试题

  1. 消息队列是什么
    消息队列,顾名思义,就是一种保存消息的队列,只不过被抽离成了一个外部组件。一般来说,消息队列可以分为两种,一种是点对点通信,一条消息只能被一个消费者消费,另外一种是生产者往一个topic发送了一条消息,所有订阅了这个topic的消费者都可以消费这条消息,显然,发布订阅模式更加常用。从设计模式的角度去考虑,发布订阅模式不同于观察者模式,观察者模式下,观察者和主题彼此知道彼此的存在,且是同步的,即主题会自动调用观察者的方法,而在发布订阅模式下,生产者和消费者彼此不知道彼此的存在,且是异步的。
  2. 消息队列的应用场景
  • 异步,可以把一些操作异步化,比如在经典的注册发送邮箱中,发送邮箱这个操作可以异步进行,将发送邮箱这样的操作消息发给消息队列,接口直接返回。然后某个消费者消费到这一个消息,去进行邮件的发送。这样有利于减少接口的延时。
  • 削锋:在高并发系统中,可以用一个消息队列来暂存用户的请求,然后系统作为消费者取请求来处理,这样可以避免短时间内的高并发打垮整个系统
  • 解除耦合:对于两个模块之间的通信,如果在中间引入一个消息队列,可以解除这两个模块的耦合,这样系统的可扩展性也会更强。
  1. kafka是什么
    kafka官方的定义就是一个分布式的流式处理平台,可以用作消息队列,相比与其他消息队列组件如rabbitmq、rocketmq、activemq等,kafka的优势在于其高吞吐量、低延迟,且kafka持久化存储以及冗余存储使得其也可以作为一个可靠的存储系统,不过要将数据存储配置为永久存储。
  2. kafka的整体架构和各个组成部分

    kafka的整体架构如上图所示,下面依次介绍各个组成部分:
  • Producer:生存者,负责将消息发送到Broker上,可以自定义规则将消息发送到topic的某个分区上
  • Broker:服务器,可以理解为kafka集群中的一个服务器实例
  • consumer:消费者,从broker中拉取消息进行业务逻辑处理
  • consumer group:由一组消费者组成,一个消费者组内只能有一个消费者消费某个topic下的某个分区,消费者组的存在可以提高消费能力,避免消息积压
  • zookeeper:保存一些元数据,包括主从副本信息,新版本中可以不依赖于zookeeper来保存消息
  • Topic:话题,消息队列中的经典概念,可以理解为消息的类别,同类别的消息发送到同一个话题上
  • Partition:分区,一个话题上的消息可以有多个分区,保存到多个broker上,水平扩展,可以进行负载均衡,从而提高极高的吞吐量
  • Replica:副本,broker会将消息持久化到磁盘中,但是依然存在单点故障导致消息丢失的风险,因此进行冗余存储,将一个分区中的消息冗余存储到多台broker上
  • Leader:指副本集合中的主副本,只有leader负责和生存者消费者交互
  • Follower:副本集合中的从副本,负责从Leader中同步数据,如果leader挂了,会从follower中选出一个成为新的leader对外提供服务
  1. 生存者发送消息的模式

    生产者发送消息有三种模式,分别是发后即忘,也就是不关心消息发送的结果;同步发送,发送完之后根据返回的future对象等待消息发送成功;异步发送,在发送的时候注册一个回调函数,根据消息发送成功或者失败,进行相应的逻辑处理,这也是最常用的模式

  2. 分区策略

    生产者在发送消息到某一个话题中的过程中,面临选择分区的问题,分区策略大致有四种,如下:

  • 轮询,依次将消息发送到一个个分区,当消息的key为null的时候,默认采用这种分区策略
  • hash分区:对key进行hash后对分区数量取模,决定发送到某个分区,但是分区数量的变化,会破坏key与分区的映射关系
  • 自定义分区策略
  • 指定分区发送
  1. kafka读写分离问题

    读写分离一般是让主节点负责写,从节点负责读,适用于读多写少的场景,典型的案例有redis和mysql。但是kafka不支持读写分离,而是读写都由主副本负责,原因在于读写分离面对着复杂的数据一致性问题和较高的同步延时。但是kafka通过分区策略可以实现负载均衡,一个topic的数据被分散到了多个分区。

  2. kafka负载均衡存在的问题

    kafka通过分区策略实现了负载均衡,但是特殊情况下还是会出现负载不均衡的情况。比如部分broker上分区数较多,生产者偏向某个分区发送消息,过多的消费者消费一个broker上的分区,leader选举后导致大致主副本存在一个broker中。

    出现负载不均衡的情况,可以通过分区再分配来解决这个问题。比如在kafka集群中有节点下线或者上线,通过控制器对副本进行迁移完成分区再分配的过程。

  3. 如何确定kafka的分区数量

    首先,在一定条件下,分区数量越多,系统的吞吐量越高,但是过高的分区数也会带来很多问题,比如会增加生产者、消费者、broker的开销,生产者会为每一个分区准备一个缓冲,broker也会设置分区级别的缓冲,消费者根据分区的数量决定消息线程的数量;并且分区数过大,broker需要维护的文件描述符越多;分区数量多了,副本数量也会越多,同步的延迟和开销也会变高。一般来说,分区数量可以取broker的数量,或者系统的默认分区数量。

  4. kafka如何管理副本

    kafka某个话题的某个分区的副本集合叫做AR,AR又分为两部分,ISR和OSR,ISR包括副本中与主副本保持一定同步的副本集合,包括主副本,只有ISR中的副本才能被选举为主副本,OSR则是同步比较落后的副本集合。维护ISR和OSR的过程由主副本负责。其实这是一个通用的逻辑,在分布式系统中,从节点同步主节点的数据,同步延迟是无法避免的,如果主节点挂了,当然要选举数据尽可能新的从节点,从而减少数据丢失的程度。

  5. 如何从副本中选出主副本

    在kafka集群启动或者副本故障时,需要重新选择一个主副本。选举的过程就是选择ISR集合中,且在AR集合中排名最前面的。这个过程由控制器来协调完成。

  6. 消费者端的分区策略

    多个消费者消费同一个话题,每一个消费者需要确定一个分区去进行消费。分区策略大致有如下四种:

  • RangeAssignor分配策略:在一个消费者组内,所有消费组按照名称的字典序排序,根据分区的数量除以消费者数量,然后一段一段地按顺序分配给消费者,比如分区数量为5,两个消费者A和B,则A消费P0-P2,B消费P3-P4
  • RoundRobinAssignor分配策略:在全局所有的话题分区中,按照字典序排序后,轮询分配给所有消费者
  • StickyAssignor分配策略:可以理解为在RoundRobinAssignor分区策略基础上增加了粘性,即因为某个消费者下线后引起的分区重分配中,如果是RoundRobinAssigor,很多分区会进行重分配,而StickyAssgnor分区策略只会对下线消费者原先负责的分区进行RoundRobinAssgnor
  • 自定义分区分配策略
  1. kafka控制器
    在一个kafka集群中,有多个broker节点,但是只有一个broker节点会被选举为controller,其将负责分区主副本的选举、当分区ISR集合发生变化时由其通知broker以及分区的重新分配。
    控制器的选举依赖于zookeeper,类似于分布式锁,每一个broker会尝试在zookeeper中创建一个临时节点/controller,如果该节点的值不为-1,则说明已有节点当选为控制器,那么会将这个值保存起来,否则的话就创建这个临时节点,自身生成controller,并且在zookeeper中还有一个永久节点,用来保存控制器的纪元,每次控制器选举,这个值就会加一,broker通过比较消息中的纪元确定这条消息是否是由当前控制器节点发出的消息。
  2. kafka 高吞吐量、低延迟原理
  • 顺序写,kafka消息是保存到磁盘中的,对于磁盘的读写,顺序写远快于随机写,而kafka的消息是通过顺序写到日志文件中的,相当于是一种WAL(预写日志技术)
  • 零拷贝技术:传统的文件传输过程是首先将文件数据从磁盘拷贝到内核缓存区,然后再拷贝到用户缓冲区,然后再拷贝到socket缓存区,然后在拷贝到NIC缓冲区,会经历四次拷贝和四次用户态内核态的切换,通过零拷贝技术,由DMA直接将数据从磁盘拷贝到内核缓冲区,然后再拷贝到NIC缓存区,大大减少了拷贝次数
  • Page cache缓存:kafka直接使用了操作系统的Page Cache,而不是JVM的内存空间
  • 分区分段+索引:每一个话题的数据是分配到多个分区中的,每一个分区相当于是一个文件夹,数据又会分段保存到日志文件中,并且会为这些分段文件建立索引文件提高检索效率
  • 批量读写和批量压缩:无论是生产者发送消息还是生产者读取消息,都可以批量读写数据,并且可以对数据进行压缩
  1. kafka如何保证消息的可靠性
  • 生产者:设置参数acks,表示broker什么情况下才会对生产者返回成功,该值默认为1,表示只要主副本写入消息成功,就返回成功,如果设置为0,表示生产者发送完消息就返回成功了,如果设置为-1或者all,表示只有当这个分区的所有ISR副本都写入成功后,才会返回成功。为了增加消息的可靠性,可以将这个值设置为-1或者all,并且消费者发送消息采取异步模式,当消息发送不成功,进行失败重试。
  • broker:分区存在多个副本,冗余存储,避免单点故障
  • 消费者:关闭自动提交offset,改为手动提交,只有当消费者完成对于这条消息的业务逻辑处理后,才手动提交offset。
  1. kafka如何保证消息顺序消费
  • 由于kafka对于单分区内的消息是可以保证顺序性的,因此可以使用同一个分区键将所有消息发到一个分区,或者这个话题只设置一个分区,并且消费者对于一个分区只使用单个线程去处理
  • 但是单分区会影响kafka系统的吞吐量,因此如果要使用多分区,可以在应用层设计一个等待队列,比如是一个堆,每一个分区一个元素,某个分区的元素是最小的,就进行消费,然后再从这个分区中取出一个加入到堆中;或者使用kafka streams,处理流数据,其可以管理消息的顺序,在流处理应用中提供有序的消息序列
  1. kafka如何解决消息积压问题
  • 增强消息者的消费能力,比如增加更多的分区和消费者,然后每一个消费者可以用多线程的方式来提高处理消息的效率,或者优化消费者对于消息的处理逻辑,采用异步的方式来消费,并且批量拉取消息进行消费
  • 提升kafka集群的性能:增加更多的服务器数量,使用更高的cpu内存磁盘配置;适当减少分区副本的数量
  • kafka集群配置:kafka默认保存消息的时间是七日,可以适当增加这个参数
  • 生产者限流:在生产者的应用层进行限流,避免过快发送消息,同时增加生产者的linger参数,使得消息在生产者端停留更长的时间
  1. kafka如何解决消息重复问题
  • 生产者的幂等性:生产者端添加幂等配置,原理是生产者每次启动后都会向broker申请一个pid,然后对于每一个话题的每一个分区都有一个seq序号,broker根据消息的pid和序号判断生产者发送过来的消息是否已经收到过,存在一定局限性,比如生产者崩溃恢复后由于pid的不同还是会导致消息重复
  • 使用kafka事务
  • 消费者端的幂等性:可以为每一个消息设置一个全局唯一的id,消息者通过哈希表之类的机制保证不会处理同一条重复消息,或者消息的处理逻辑就是幂等的

kafka实践

我们这里以这样的一个场景来进行说明:有一个权限管理系统,其会记录用户访问过那些资源,这些日志会通过kafka发送到一个日志处理系统,可以是一个态势感知系统之类的,根据日志进行智能判断。

环境准备

实验平台:ubuntu20.04, 已安装docker和git

首先执行下面的命令,搭建zookeeper和kafka环境,通过docker容器来搭建相比更加方便。

powershell 复制代码
# 环境准备
sudo docker pull docker.unsee.tech/zookeeper
sudo docker tag docker.unsee.tech/zookeeper:latest zookeeper
sudo docker run -d --name zookeeper -p 2181:2181 zookeeper
sudo docker pull docker.unsee.tech/wurstmeister/kafka
sudo docker tag docker.unsee.tech/wurstmeister/kafka:latest kafka
sudo docker run -d --name kafka-broker -p 9092:9092 -e KAFKA_BROKER_ID=1 -e KAFKA_ZOOKEEPER_CONNECT=192.168.247.128:2181 -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://192.168.247.128:9092 -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 kafka  # 这里的192.168.247.128需要替换为zookeeper安装地址

# 代码拉取
https://gitee.com/tan-xiaoyong/kafka-go.git

可以通过sudo docker logs kafka_broker来查看kafka容器日志来确定是否安装成功。

创建话题为后面做准备sudo docker exec -it kafka-broker kafka-topics.sh --create --zookeeper 192.168.247.128:2181 --replication-factor 1 --partitions 1 --topic user_log

代码

生产者相关代码如下:

go 复制代码
package main

import (
	"fmt"
	"net/http"
	"sync"

	"github.com/bytedance/sonic"
	"github.com/gin-gonic/gin"
	kafka "github.com/segmentio/kafka-go"
)

// Record 表述一次用户访问资源的记录
type Record struct {
	UserID int
	UserName string
	URL    string
	IsOver bool   // 是否越权
}


var writer *kafka.Writer
var once sync.Once

func getKafkaWriter(kafkaURL, topic string) *kafka.Writer {
	if writer == nil {
		once.Do(func() {
			writer = &kafka.Writer{
				Addr:     kafka.TCP(kafkaURL),
				Topic:    topic,
				Balancer: &kafka.LeastBytes{},
				RequiredAcks: kafka.RequireAll,  // 分区ISR副本集合中所有副本写入消息时才返回成功
			}
		})
	}
	return writer
}


func userLogRecordMiddleWare(c *gin.Context) {
	fmt.Println("1")
	// 权限判断逻辑
	record := Record{
		UserID: 1001,
		UserName: "demo",
		URL: c.FullPath(),
		IsOver: true,
	}
	recordData, _ := sonic.Marshal(record)   // sonic更加高效
	msg := kafka.Message{   // kafka消息
		Key: []byte(fmt.Sprintf("%d-%s", record.UserID, record.URL)),
		Value: recordData,
	}
	sendMessage(c, []kafka.Message{msg})
	c.Next()
}


func sendMessage(ctx *gin.Context, messages []kafka.Message) {
	writer.WriteMessages(ctx, messages...)
}



func startProducer() {
	kafkaURL := "192.168.247.128:9092"
	topic := "user_log"

	kafkaWriter := getKafkaWriter(kafkaURL, topic)
	defer kafkaWriter.Close()

	// 创建一个不带任何中间件的gin路由器实例
    router := gin.Default()
	router.Use(userLogRecordMiddleWare)

    // 定义路由和处理函数
    router.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "hello world",
        })
    })
    router.GET("/login", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "welcome",
        })
    })

	

    router.Run(":8080")
}

消费者代码如下:

go 复制代码
package main

import (
	"context"
	"fmt"
	"log"
	"strings"

	"github.com/bytedance/sonic"
	kafka "github.com/segmentio/kafka-go"
)

func getKafkaReader(kafkaURL, topic, groupID string) *kafka.Reader {
	brokers := strings.Split(kafkaURL, ",")
	return kafka.NewReader(kafka.ReaderConfig{
		Brokers:  brokers,
		GroupID:  groupID,
		Topic:    topic,
		MinBytes: 10e3, // 10KB
		MaxBytes: 10e6, // 10MB
		CommitInterval: 0,   // 关闭手动提交offset
	})
}

func startConsumer() {
	kafkaURL := "192.168.247.128:9092"
	topic := "user_log"
	groupID := ""

	reader := getKafkaReader(kafkaURL, topic, groupID)

	defer reader.Close()

	fmt.Println("start consuming ... !!")
	for {
		m, err := reader.ReadMessage(context.Background())
		if err != nil {
			log.Fatalln(err)
		}
		var record Record
		err = sonic.Unmarshal([]byte(m.Value), &record)
		if err != nil {
			log.Fatalln(err)
		}
		fmt.Printf("message at topic:%v partition:%v offset:%v	%s = %s\n", m.Topic, m.Partition, m.Offset, string(m.Key), string(m.Value))
		if record.IsOver {
			fmt.Printf("user log: %+v\n", record)
		}
	}
}

主程序相关代码如下:

go 复制代码
package main



func main() {
	go startProducer()
	go startConsumer()

	select {   // 无限阻塞

	}
}

完整代码请参考gitee仓库。

结果截图

如上图中的红色框中,就是消费者消费到的用户访问日志。

参考

相关推荐
FoldWinCard10 小时前
D5 Linux 网络及端口命令
linux·运维·服务器
零涂毕业设计11 小时前
毕业设计模块开发-OLED显示屏(IIC协议0.96寸)STM32 ESP32 FPGA Linux驱动免费分享
linux·stm32·单片机·嵌入式·esp32·fpga·oled
tedcloud12311 小时前
OmniRoute怎么部署?开源AI模型路由平台Linux部署教程
linux·服务器·人工智能·开源·音视频
霸道流氓气质11 小时前
KMS 密钥管理服务(Key Management Service)原理与实践
linux·服务器·数据库
SakitamaX11 小时前
LVS(Linux Virtual Server)概念详解
linux·运维·lvs
小张同学a.12 小时前
Linux系统管理
linux·运维
阿龙的工作记录12 小时前
深入理解Linux设备驱动模型
linux·嵌入式硬件·工作记录
代码村新手12 小时前
Linux的基本指令
linux·运维·服务器
hkj880813 小时前
Ubuntu 切换java版本
java·linux·ubuntu
撩得Android一次心动14 小时前
Linux编程笔记3【个人用】
linux·笔记