一、 去除各种封装,简单来看
1. 相关核心知识点
1. 在消息队列中,“topic” 和 “groupId” 是两个关键概念:
- Topic(主题):Topic 是消息队列中的一个逻辑概念,用于将消息进行分类和组织。它可以看作是一个消息的容器或者分类目录。当消息生产者发送消息时,需要指定消息所属的主题。消费者可以订阅一个或多个主题,以接收和处理其中的消息。主题可以根据业务需求进行划分,每个主题下可以有多个消息分区,用于实现并行处理和负载均衡。可以将Topic看作是数据库中的表,但不是完全结构化的,是半结构化的,也就是说可以存储多种数据
- GroupID(消费者组ID):GroupID 是一组消费者的标识符,属于消费者端的概念。当多个消费者属于同一个 GroupID 时,它们被认为是一个消费者组。具有相同 GroupID 的消费者组之间会进行消息的协调工作,以实现负载均衡和消息的分发。消费者组中的每个消费者都会从一个或多个主题的分区中消费消息。消息队列会确保每个分区中的消息只被同一消费者组中的一个消费者消费,以实现消费者之间的协调和避免重复消费。
Topic 和 GroupID 的作用如下:
- Topic 标识了消息的类型或分类,使得生产者可以将消息发送到指定的主题,而消费者可以订阅感兴趣的主题,从中接收相关消息。
- GroupID 允许多个消费者结合成一个消费者组,协同地从同一个或多个主题的分区中消费消息。通过负载均衡和分区分配算法,消费者组中的每个消费者只处理其中一部分消息,从而实现高吞吐量和并行消费的能力。
总结起来,通过 Topic 将消息进行分类和组织,而通过 GroupID 实现消费者之间的协调和负载均衡,使得消息队列系统能够支持高可靠性、高吞吐量和水平扩展等特性。
举例说明:
假设我们有以下两个主题(Topic):
- order_topic:用于处理订单相关的消息。
- inventory_topic:用于处理库存相关的消息。
现在有两个消费者组(GroupID): - order_consumer_group:负责处理订单的消费者组。
- inventory_consumer_group:负责处理库存的消费者组。
生产者将以下两个消息发送到消息队列:
- 将一个新订单的消息发送到 order_topic 主题。
- 将一个商品库存更新的消息发送到 inventory_topic 主题。
消费者组的消费者分别订阅相应的主题:
- order_consumer_group 订阅 order_topic 主题,以便处理新订单消息并执行相关操作。
- inventory_consumer_group 订阅 inventory_topic 主题,以便实时更新商品库存信息。
通过这样的设置,我们可以实现以下效果: - order_consumer_group 中的消费者会从 order_topic 主题中接收到新订单的消息,并执行订单处理逻辑,例如生成发货单、发送邮件通知等。
- inventory_consumer_group 中的消费者会从 inventory_topic 主题中接收到商品库存更新的消息,并即时更新本地的库存信息。
这样的设计可以提高系统的可伸缩性和可靠性。多个消费者可以并行地处理来自不同主题的消息,而消费者组内部的负载均衡机制可以确保每个消费者只处理分配给它的一部分消息,以提高整体的吞吐量和效率。
2. kafka的client
在使用 Kafka 时,构建 Kafka 的客户端(Client)是必要的。Kafka 客户端是与 Kafka 集群进行通信并执行各种操作的组件。它提供了与 Kafka 服务器进行连接、订阅主题、发送和接收消息等功能。
构建 Kafka 客户端的主要作用包括:
连接管理:Kafka 客户端负责与 Kafka 集群建立连接,并保持与集群的稳定连接。它处理连接的建立、断开、重连等细节,以确保客户端能够与集群进行可靠的通信。
消息的生产者和消费者:Kafka 客户端可以扮演消息的生产者和消费者角色。作为生产者,它能够将消息发送到指定的主题;作为消费者,它能够订阅一个或多个主题,并从中接收消息。
分区分配:Kafka 客户端负责分区的管理和分配。对于消费者来说,它会根据消费者组的机制,将分区均匀地分配给消费者,实现负载均衡。对于生产者来说,它会选择适当的分区将消息发送到。
错误处理和重试:Kafka 客户端能够处理出现的错误情况,并进行相应的错误处理和重试机制。它可以处理网络故障、分区变更、消息发送失败等情况,提高整个系统的可靠性。
相比之下,如果不构建 Kafka 客户端直接连接,我们将无法使用 Kafka 提供的丰富功能和操作。Kafka 客户端的构建与 Kafka 集群之间建立了一个稳定、可靠的通信通道,通过它我们可以发送和接收消息,管理分区和消费者组等。没有客户端,我们将无法有效地与 Kafka 进行交互,从而无法进行生产和消费消息的操作。因此,构建 Kafka 客户端是使用 Kafka 的必要步骤。
3. kafka的Partition(分区)和 Offset(偏移量)
- 具体在生产者和消费者中使用partition与offset:
在Kafka中,每个主题(topic)被分为一个或多个分区(partition),每个分区都有一个唯一标识符,称为分区号。分区的作用是将消息在主题中进行水平划分和并行处理。
Partition(分区)和 Offset(偏移量)是Kafka中消息的重要概念:
Partition:分区是数据在Kafka集群内部的物理存储单元,可以看作是Kafka中的一个顺序日志文件。
Offset:Offset代表消息在分区中的位置,也可以理解为消息的唯一标识符。
对于生产者(Producer)来说:
在发送消息时,可以选择指定分区号,也可以让Kafka根据一定的分区策略自动选择分区。如果未指定分区号,Kafka会根据消息的Key进行分区策略的计算,或者使用轮询的方式依次选择一个分区。通过指定不同的分区号,可以实现消息的有序生产。
// 发送消息到指定分区
message := &sarama.ProducerMessage{
Topic: topic,
Partition: 0, // 指定分区号
Value: sarama.StringEncoder("Hello, Kafka!"),
}
对于消费者(Consumer)来说:
在消费消息时,需要指定要消费的分区和起始的Offset。消费者从指定的分区号和Offset开始消费消息,并可以按照一定的方式逐条递增Offset来消费后续的消息。注意,消费者在消费过程中需要维护自己当前消费到的Offset位置。
Sarama库提供了方便的API来让消费者指定分区和Offset:
// 创建分区消费者,从指定分区号和Offset开始消费消息
partitionConsumer, err := consumer.ConsumePartition(topic, 0, sarama.OffsetNewest)
Kafka的默认行为是自动选择分区(如果未指定)和使用最新的Offset进行消费。这意味着,如果生产者没有指定分区号,消息将根据分区策略自动分配到某个分区;如果消费者没有指定起始Offset,将从最新的消息开始消费。
需要注意的是,在某些场景下,为了实现精确控制和保证消息的顺序性,可能需要显式地指定分区号和Offset。
2. 使用kafka核心步骤总结
- 必不可少的组件:Producer、Consumer、Topic、GroupId
- go get github.com/segmentio/kafka-go 使用的是这个库
2.1 创建一个生产者
肯定需要和kafka服务器连接,也就是需要broker的ip+port。由于是生产者,需要指定自己将来生产的Topic,以便消费者绑定消费。注意返回值类型是*kafka.Writer,这个Writer 有一个WriteMessages的方法,就可以将消息写入kafka消息队列了。
func (m *Manager) NewProducer(topic string) *kafka.Writer {
// TODO writer 优雅关闭
return &kafka.Writer{
Addr: kafka.TCP(m.Brokers...),
Topic: topic,
Balancer: &kafka.Hash{}, // 使用Hash算法按照key将消息均匀分布到不同的partition上
WriteTimeout: 1 * time.Second,
RequiredAcks: kafka.RequireAll, // 需要确保Leader和所有Follower都写入成功才可以发送下一条消息, 确保消息成功写入, 不丢失
AllowAutoTopicCreation: true, // Topic不存在时自动创建。生产环境中一般设为false,由运维管理员创建Topic并配置partition数目
}
}
2.2 由生产者发送一条消息
调用上面的方法,生成一个Producer后,调用producer的WriteMessage方法,将需要发送的消息序列化后即可发送
// ProduceMessage 向 Kafka 写入消息的公共函数, 由于不同业务的消息格式不同, 所以使用 interface{} 代替
func (m *Manager) ProduceMessage(producer *kafka.Writer, message interface{}) error {
messageBytes, err := json.Marshal(message)
if err != nil {
return err
}
return producer.WriteMessages(context.Background(), kafka.Message{
Value: messageBytes,
})
}
2.3 创建一个消费者
创建消费者的核心,首先肯定还是需要绑定broker服务器的地址,之后绑定要消费的Topic,指定消费的GroupId都是不可少的。注意返回值 是Kafaka.Reader,实际上这个Reader就是consumer,它有ReadMessage方法,能直接读取到消息msg,之后根据自己的需求反序列化和使用这个msg即可。
func (m *Manager) NewConsumer(topic, groupId string) *kafka.Reader {
// TODO reader 优雅关闭
return kafka.NewReader(kafka.ReaderConfig{
Brokers: m.Brokers,
Topic: topic,
GroupID: groupId,
// CommitInterval: 1 * time.Second, // 不配置此项, 默认每次读取都会自动提交offset
StartOffset: kafka.FirstOffset, //当一个特定的partition没有commited offset时(比如第一次读一个partition,之前没有commit过),通过StartOffset指定从第一个还是最后一个位置开始消费。StartOffset的取值要么是FirstOffset要么是LastOffset,LastOffset表示Consumer启动之前生成的老数据不管了。仅当指定了GroupID时,StartOffset才生效
})
}
2.4 消费这条消息
Consumer := kafkaManager.NewConsumer(VideoMQInstance.Topic, VideoMQInstance.GroupId)
msg, err := Consumer.ReadMessage(context.Background())
二、 kafka系统的完整生成示例,自顶向下
1. kafaka发送消息
- 1.1 是最初始外部调用kafaka的地方
- 1.6 是最初调用kafaka的函数。中间是对kafaka的构建
1.1 向Kafka发送一条发布视频的message
- 在videoHandler的发布视频逻辑中,向Kafka发送一条发布视频的mq,之后就解耦,先返回状态告知发布成功,不再等待具体执行
// 通过MQ异步处理视频的上传操作, 包括上传到OSS,截帧, 保存到MySQL, 更新redis
zap.L().Info("上传视频发送到消息队列", zap.String("videoPath", videoPath))
kafka.VideoMQInstance.Produce(&kafka.VideoMessage{
VideoPath: videoPath,
VideoFileName: videoFileName,
UserID: uint(request.GetUserId()),
Title: request.GetTitle(),
})
return &video.PublishVideoResponse{
StatusCode: common.CodeSuccess,
StatusMsg: common.MapErrMsg(common.CodeSuccess),
}, nil
1.2. 构造MQ结构体,核心包括Topic,GroupId,Producer,Consumer
- 上面的VideoMQInstance 是*VideoMQ的类型,实际上就是包括Topic,GroupId,Producer,Consumer这几个成员的结构体
1.3 对MQ结构体进行初始化
- 对上面这个结构体VideoMQInstance中的几个成员进行初始化
func InitVideoKafka() {
VideoMQInstance = &VideoMQ{
MQ{
Topic: "videos",
GroupId: "video_group",
},
}
// 创建 Video 业务的生产者和消费者实例
VideoMQInstance.Producer = kafkaManager.NewProducer(VideoMQInstance.Topic)
VideoMQInstance.Consumer = kafkaManager.NewConsumer(VideoMQInstance.Topic, VideoMQInstance.GroupId)
go VideoMQInstance.Consume()
}
Topic、GroupId都很简单,赋一个string的字符串就好了,关键在Producer和Consumer需要一步步创建
1.4 Producer和Consumer的创建流程
先看代码:
type Manager struct {
Brokers []string
}
var kafkaManager *Manager
func (m *Manager) NewProducer(topic string) *kafka.Writer {
return &kafka.Writer{
Addr: kafka.TCP(m.Brokers...),
Topic: topic,
Balancer: &kafka.Hash{}, // 使用Hash算法按照key将消息均匀分布到不同的partition上
WriteTimeout: 1 * time.Second,
RequiredAcks: kafka.RequireAll, // 需要确保Leader和所有Follower都写入成功才可以发送下一条消息, 确保消息成功写入, 不丢失
AllowAutoTopicCreation: true, // Topic不存在时自动创建。生产环境中一般设为false,由运维管理员创建Topic并配置partition数目
}
}
func (m *Manager) NewConsumer(topic, groupId string) *kafka.Reader {
// TODO reader 优雅关闭
return kafka.NewReader(kafka.ReaderConfig{
Brokers: m.Brokers,
Topic: topic,
GroupID: groupId,
// CommitInterval: 1 * time.Second, // 不配置此项, 默认每次读取都会自动提交offset
StartOffset: kafka.FirstOffset, //当一个特定的partition没有commited offset时(比如第一次读一个partition,之前没有commit过),通过StartOffset指定从第一个还是最后一个位置开始消费。StartOffset的取值要么是FirstOffset要么是LastOffset,LastOffset表示Consumer启动之前生成的老数据不管了。仅当指定了GroupID时,StartOffset才生效
})
}
可以看到,Producer实际上就是kafka.Writer,consumer实际上就是kafka.Reader,其中writer肯定需要绑定Topic,而reader肯定需要Topic和GroupId,去消费这些消息。
1.5 创建Kafaka的manager
- 发现上述创建Producer和Consumer的代码都是Manager的成员方法,Manager是什么呢?
- NewProducer()、NewConsumer()是Manager的成员方法。说明肯定是使用Manager这个结构体去创建Producer和Consumer,而Manager核心包含的就是Brokers(存的是broker的url地址,即为kafka的服务器地址),broker实际看作就是kafka服务器,比如:0.0.0.0:9092 这个url表示当前电脑下9092端口的kafka服务器
type Manager struct {
Brokers []string
}
var kafkaManager *Manager
type MQ struct {
Topic string
GroupId string
Producer *kafka.Writer
Consumer *kafka.Reader
}
func Init(appConfig *config.AppConfig) (err error) {
var conf *config.KafkaConfig
if appConfig.Mode == config.LocalMode {
conf = appConfig.Local.KafkaConfig
} else {
conf = appConfig.Remote.KafkaConfig
}
brokerUrl := conf.Address + ":" + strconv.Itoa(conf.Port)
// 初始化 Kafka Manager
brokers := []string{brokerUrl}
kafkaManager = NewKafkaManager(brokers)
//InitMessageKafka()
//InitCommentKafka()
//InitVideoKafka()
return nil
}
func NewKafkaManager(brokers []string) *Manager {
return &Manager{
Brokers: brokers,
}
}
1.6 VideoMQ 它有个成员方法是Produce(和最早的1.1调用对应)
// Produce 发布将本地视频上传到OSS的消息
func (m *VideoMQ) Produce(message *VideoMessage) {
err := kafkaManager.ProduceMessage(m.Producer, message)
if err != nil {
log.Println("kafka发送添加视频的消息失败:", err)
return
}
}
Produce其中又调用了ProduceMessage方法,方法具体内容如下,就是将通过producer将要发送的消息序列化后发送出去
// ProduceMessage 向 Kafka 写入消息的公共函数, 由于不同业务的消息格式不同, 所以使用 interface{} 代替
func (m *Manager) ProduceMessage(producer *kafka.Writer, message interface{}) error {
messageBytes, err := json.Marshal(message)
if err != nil {
return err
}
return producer.WriteMessages(context.Background(), kafka.Message{
Value: messageBytes,
})
}
2. kafka消费消息
2.1 开启消费goroutine
kafka消费消息的代码之前在initMQ的时候就已经开启一个goroutine开始消费,只要有消息对应上topic就可以消费
func InitVideoKafka() {
VideoMQInstance = &VideoMQ{
MQ{
Topic: "videos",
GroupId: "video_group",
},
}
// 创建 Video 业务的生产者和消费者实例
VideoMQInstance.Producer = kafkaManager.NewProducer(VideoMQInstance.Topic)
VideoMQInstance.Consumer = kafkaManager.NewConsumer(VideoMQInstance.Topic, VideoMQInstance.GroupId)
go VideoMQInstance.Consume()
}
2.2 消费的具体逻辑举例:执行一个上传视频到oss的函数
步骤:
- Consumer.ReadMessage 先拿到序列化的消息msg,并反序列化为最初的结构体
- 现在拿到了msg,利用里面的内容,开启goroutine执行相关函数
- 开启一个goroutine:比如拿到msg中: video的url,和name。那现在就可以调用oss的函数,将指定url地址中name为name的视频上传到oss。上传完成之后,还可以将最开始传来的msg(包含video的消息)的内容上传到mysql
- 再开启一个goroutine:将视频上传到redis
- 再开启一个goroutine:删除用户哈希字段
- 再开启一个goroutine:将视频id加入到布隆过滤器中
上面开的那么多goroutine都是互相不影响的,没有先后执行的需要,因此可以分别开启
// Consume 消费将本地视频上传到OSS的消息
func (m *VideoMQ) Consume() {
for {
msg, err := m.Consumer.ReadMessage(context.Background())
if err != nil {
log.Fatal("[VideoMQ]从消息队列中读取消息失败:", err)
}
videoMsg := new(VideoMessage)
err = json.Unmarshal(msg.Value, videoMsg)
if err != nil {
log.Println("[VideoMQ]解析消息失败:", err)
return
}
go func() {
defer func() {
os.Remove(videoMsg.VideoPath)
}()
zap.L().Info("开始处理视频消息", zap.Any("videoMsg", videoMsg))
// 视频存储到oss
if err = common.UploadToOSS(videoMsg.VideoPath, videoMsg.VideoFileName); err != nil {
zap.L().Error("上传视频到OSS失败", zap.Error(err))
return
}
// 利用oss功能获取封面图
imgName, err := common.GetVideoCover(videoMsg.VideoFileName)
if err != nil {
zap.L().Error("图片截帧失败", zap.Error(err))
return
}
// 视频信息存储到MySQL
video := model.Video{
AuthorId: videoMsg.UserID,
VideoUrl: videoMsg.VideoFileName,
CoverUrl: imgName,
Title: videoMsg.Title,
CreatedAt: time.Now().Unix(),
}
mysql.InsertVideo(&video)
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
redis.AddVideo(&video)
}()
go func() {
defer wg.Done()
// cache aside
redis.DelUserHashField(videoMsg.UserID, redis.WorkCountField)
}()
go func() {
defer wg.Done()
// 添加到布隆过滤器
common.AddToWorkCountBloom(fmt.Sprintf("%d", videoMsg.UserID))
}()
wg.Wait()
zap.L().Info("视频消息处理成功", zap.Any("videoMsg", videoMsg))
}()
}
}