深入剖析Kafka(二)

前言

与生产者对应的是消费者,应用程序可以通过KafkaConsumer来订阅主题,并从订阅的主题中拉取消息。不过在使用KafkaConsumer之前需要先了解消费者和消费组的概念,否则无法理解如何使用KafkaConsumer。

消费者与消费组

消费者负责订阅Kafka中的主题,并且从订阅的主题上拉取消息。与其它一些消息中间件不同的是:Kafka的消费理念中还有一层消费组的概念,每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者

例子:某个主题中共有4个分区:P0,P1,P2,P3。有两个消费组A和B都订阅了这个主题,消费组A中有4个消费者(C0,C1,C2,C3),消费组B中有两个消费者(C4,C5)。按照Kafka默认的规则,最后的分配结果是消费组A中的每一个消费者分配到一个分区,消费组B中的每一个消费者分配到两个分区,两个消费组之间互不影响。每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费组中的一个消费者所消费。

再看一下消费组内的消费者个数变化所对应的分区分配的演变。假设目前某消费组内只有一个消费者C0,订阅了一个主题,这个主题包含7个分区P0,P1,P2,P3,P4,P5,P6。那么C0所分配到的分区是P0,P1,P2,P3,P4,P5,P6。此时消费组内又加入了一个新的消费者C1,按照既定的逻辑,需要将原来消费者C0的部分分区分配给消费者C1消费,比如此时C0订阅P0,P1,P2,P3,C1订阅P4,P5,P6。紧接着消费组内又加入了一个新的消费者C2,那么可能最后的分配就是C0订阅P0,P1,P2,C1订阅P3,P4,C2订阅P5,P6。

消费者和消费组的这种模型可以让整体的消费能力具备横向伸缩性,我们可以增加(或减少)消费者的个数来提高(或降低)整体的消费能力。对于分区数固定的情况,一味的增加消费者的数量并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的情况,就会有消费者分配不到任何分区。加入一共有8个消费者,7个分区,那么最后的消费者P7就会由于分配不到任何分区而无法消费任何消息。

以上分配逻辑都是基于默认的分区分配策略进行分析的,可以通过消费者客户端参数partition.assignment.strategy来指定分区分配策略。

对于消息中间件而言,一般有两种消息投递模式:点对点(P2P)模式和发布订阅(Pub/Sub)模式。点对点模式是基于队列的,消息生产者发送消息到队列,消息消费者从队列中接收消息。发布订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点称之为主题,主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者从主题中订阅消息。主题使得消息的订阅者和发布者互相保持独立,不需要进行接触即可保证消息的传递,发布/订阅模式在消息的一对多广播时采用。Kafka同时支持两种消息投递模式,而这正是得益于消费者与消费组的契合:

  • 如果所有的消费者都隶属于同一个消费组,那么所有的消息都会被均衡的投递给每一个消费者,即每一条消息只会被一个消费者处理,这就是所谓的点对点模式的应用
  • 如果所有的消费者都隶属于不同的消费组,那么所有的消息都会被广播给所有的消费者,即每条消息都会被所有的消费者处理,这就相当于发布/订阅模式的应用

消费组是一个逻辑上的概念,它将旗下的消费者归为一类,每一个消费者只隶属于一个消费组。每一个消费组都会有一个固定的名称,消费者在进行消费前需要指定其所属消费组的名称,这个可以通过消费者客户端group.id来配置,默认值为空字符串。

消费者并非逻辑上的概念,它是实际的应用实例,它可以是一个线程,也可以是一个进程。同一个消费组的消费者既可以部署在同一台机器上,也可以部署在不同的机器上。

go使用Consumer

go 复制代码
package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"

	"github.com/IBM/sarama"
)

func main() {
	// 配置消费者
	config := sarama.NewConfig()
	config.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{
		sarama.NewBalanceStrategyRange(), // 范围分配策略
	}
	config.Consumer.Offsets.Initial = sarama.OffsetOldest // 从最早开始

	// 创建消费者组
	consumerGroup, err := sarama.NewConsumerGroup(
		[]string{"localhost:9092"},
		"my-consumer-group",
		config,
	)
	if err != nil {
		log.Fatal("Failed to create consumer group:", err)
	}
	defer consumerGroup.Close()

	// 实现 ConsumerGroupHandler
	handler := &ConsumerHandler{}

	// 设置信号处理
	sigchan := make(chan os.Signal, 1)
	signal.Notify(sigchan, os.Interrupt)

	// 消费消息
	go func() {
		for {
			err := consumerGroup.Consume(ctx, []string{"my-topic"}, handler)
			if err != nil {
				log.Printf("Error from consumer: %v", err)
			}
			if ctx.Err() != nil {
				return
			}
		}
	}()

	<-sigchan
	fmt.Println("Interrupt received, shutting down...")
}

// ConsumerHandler 实现 sarama.ConsumerGroupHandler 接口
type ConsumerHandler struct{}

func (h *ConsumerHandler) Setup(sarama.ConsumerGroupSession) error {
	fmt.Println("Consumer group setup")
	return nil
}

func (h *ConsumerHandler) Cleanup(sarama.ConsumerGroupSession) error {
	fmt.Println("Consumer group cleanup")
	return nil
}

func (h *ConsumerHandler) ConsumeClaim(
	session sarama.ConsumerGroupSession,
	claim sarama.ConsumerGroupClaim,
) error {
	for msg := range claim.Messages() {
		fmt.Printf("Received message: Topic=%s, Partition=%d, Offset=%d, Key=%s, Value=%s\n",
			msg.Topic, msg.Partition, msg.Offset, string(msg.Key), string(msg.Value))
		
		// 处理业务逻辑
		processMessage(msg)
		
		// 标记消息已处理,提交偏移量
		session.MarkMessage(msg, "")
	}
	return nil
}

func processMessage(msg *sarama.ConsumerMessage) {
	// 业务处理逻辑
}

必要的参数配置

在创建真正的消费者实例之前需要做相应的参数配置,在Kafka消费者客户端KafkaConsumer中有4个参数是必填的

  • bootstrap.servers:该参数的释义和生产者客户端KafkaProducer中的相同,用来指定连接Kafka集群所需的broker地址清单,具体内容形式为host1:port1,host2:port2,host3:port3,可以设置一个或多个地址,中间用逗号隔开,此参数的默认值为""。注意这里并非需要设置集群中全部的broker地址,消费者会从现有的配置中查找到全部的Kafka集群成员。这里设置两个以上的broker地址信息,当其中任意一个宕机时,消费者仍然可以连接到Kafka集群中。
  • group.id:消费者隶属的消费组名称,默认值为""。如果设置为空,则会报出异常。一般而言,这个参数需要设置成具有一定的业务意义的名称
  • key.deserializer和value.deserializer:与生产者客户端中的key.serializer和value.serializer参数对应。消费者从broker端获取的消息格式都是字节数组类型,所以需要执行相应的反序列化操作才能还原成原有的对象格式。这两个参数分别用来指定消息中key和value所需的反序列化器。这两个参数无默认值。注意这里必须填写反序列化器类的全限定名,比如org.apache.kafka.common.serialization.StringDeserializer,单单指定StringDeserializer是不行的。

订阅主题与分区

在创建好消费者之后,我们就需要为该消费者订阅相关的主题了。一个消费者可以订阅一个或多个主题。

基于sarama库订阅Kafka主题有两种主要方式:消费者和消费者组

  1. 使用Consumer(较简单,适合简单场景)
go 复制代码
func main() {
    //1. 创建消费者配置
    config:=sarama.NewConfig()
    config.Consumer.Return.Errors = true // 启用错误通道
    // 2. 创建消费者
    consumer,err:=sarama.NewConsumer(
        []string{"192.168.1.100:9092"},
        config,
    )
    defer consumer.Close()

    // 3.订阅主题
    topic:="my-topic"

    // 获取主题的所有分区
    partitions,err:=consumer.Partitions(topic)

    // 4. 为每个分区创建分区消费者
    var partitionConsumer []sarama.PartitionConsumer

     for _, partition := range partitions {
        pc,err:=consumer.ConsumePartition(
            topic,
            partition,
            sarama.OffsetNewest, // 从最早开始消费
        )
        partitionConsumer = append(partitionConsumer, pc)
     }
     defer func() {
        for _, pc := range partitionConsumer {
            pc.Close()
        }
     }
    // 5.处理信号,优雅关闭
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt)

     // 6. 消费消息
     consumed:=0
     ConsumerLoop:
     for {
        select{
            case <-sig:
            break ConsumerLoop
            default:
            // 从所有分区消费者读取消息
            for _,pc:=range partitionConsumers{
                select {
                    case msg:==<-pc.Messages():
                    fmt.Printf("Partition: %d, Offset: %d, Key: %s, Value: %s\n",
						msg.Partition, msg.Offset, string(msg.Key), string(msg.Value))
					consumed++
                    case err:=<-pc.Errors():
                    fmt.Println("Error: ", err.Error())
                    default:
                    // 继续检查其它分区
                }
            }
        }
     }
}
  1. 使用consumerGroup(支持自动负载均衡)
go 复制代码
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"

	"github.com/IBM/sarama"
)

func main() {
	// 1. 创建消费者组配置
	config := sarama.NewConfig()
	
	// 版本配置(重要!需要与Kafka版本匹配)
	config.Version = sarama.V2_8_0_0 // 根据你的Kafka版本设置
	
	// 消费组配置
	config.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{
		sarama.NewBalanceStrategyRange(), // 范围分配策略
		// sarama.NewBalanceStrategyRoundRobin(), // 轮询分配策略
		// sarama.NewBalanceStrategySticky(),     // 粘性分配策略
	}
	
	// 偏移量配置
	config.Consumer.Offsets.Initial = sarama.OffsetOldest
	config.Consumer.Offsets.AutoCommit.Enable = true       // 启用自动提交
	config.Consumer.Offsets.AutoCommit.Interval = 1 * time.Second // 提交间隔
	
	// 会话和心跳
	config.Consumer.Group.Session.Timeout = 10 * time.Second
	config.Consumer.Group.Heartbeat.Interval = 3 * time.Second
	
	// 返回错误
	config.Consumer.Return.Errors = true

	// 2. 创建消费者组
	brokers := []string{"localhost:9092", "localhost:9093"}
	groupID := "my-consumer-group"
	topics := []string{"my-topic", "another-topic"} // 可以订阅多个主题
	
	consumerGroup, err := sarama.NewConsumerGroup(brokers, groupID, config)
	if err != nil {
		log.Fatalf("Failed to create consumer group: %v", err)
	}
	defer consumerGroup.Close()

	// 3. 创建上下文,支持取消
	ctx, cancel := context.WithCancel(context.Background())
	
	// 4. 处理系统信号
	signals := make(chan os.Signal, 1)
	signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
	
	// 5. 等待组,用于消费者协程
	var wg sync.WaitGroup
	wg.Add(1)
	
	// 6. 启动消费者协程
	go func() {
		defer wg.Done()
		handler := &ConsumerGroupHandler{}
		
		for {
			// 开始消费(这是一个阻塞调用)
			err := consumerGroup.Consume(ctx, topics, handler)
			if err != nil {
				if err == sarama.ErrClosedConsumerGroup {
					fmt.Println("Consumer group closed")
					return
				}
				log.Printf("Error from consumer: %v", err)
			}
			
			// 检查上下文是否已取消
			if ctx.Err() != nil {
				fmt.Println("Context cancelled, stopping consumer")
				return
			}
			
			// 重置处理器状态(如果需要)
			handler.ready = make(chan bool)
		}
	}()

	// 7. 等待信号,优雅关闭
	<-signals
	fmt.Println("\nReceived interrupt signal, shutting down...")
	cancel() // 取消上下文
	
	// 等待消费者协程结束
	wg.Wait()
	fmt.Println("Consumer stopped gracefully")
}

// ConsumerGroupHandler 实现 sarama.ConsumerGroupHandler 接口
type ConsumerGroupHandler struct {
	ready chan bool
}

// Setup 在消费者组分配分区前调用
func (h *ConsumerGroupHandler) Setup(session sarama.ConsumerGroupSession) error {
	fmt.Printf("Consumer group setup: MemberID=%s, GenerationID=%d\n",
		session.MemberID(), session.GenerationID())
	
	// 获取分配到的分区
	claims := session.Claims()
	for topic, partitions := range claims {
		fmt.Printf("Assigned - Topic: %s, Partitions: %v\n", topic, partitions)
	}
	
	close(h.ready)
	return nil
}

// Cleanup 在消费者组释放分区后调用
func (h *ConsumerGroupHandler) Cleanup(session sarama.ConsumerGroupSession) error {
	fmt.Println("Consumer group cleanup")
	return nil
}

// ConsumeClaim 处理分配给消费者的分区消息
func (h *ConsumerGroupHandler) ConsumeClaim(
	session sarama.ConsumerGroupSession,
	claim sarama.ConsumerGroupClaim,
) error {
	fmt.Printf("Starting to consume partition: Topic=%s, Partition=%d, InitialOffset=%d\n",
		claim.Topic(), claim.Partition(), claim.InitialOffset())
	
	// 计数器
	messageCount := 0
	
	// 消费消息
	for {
		select {
		case message, ok := <-claim.Messages():
			if !ok {
				fmt.Printf("Message channel closed for partition %d\n", claim.Partition())
				return nil
			}
			
			// 处理消息
			fmt.Printf("Received message: Topic=%s, Partition=%d, Offset=%d, Key=%s, Value=%s\n",
				message.Topic, message.Partition, message.Offset,
				string(message.Key), string(message.Value))
			
			// 模拟业务处理
			err := processMessage(message)
			if err != nil {
				log.Printf("Failed to process message: %v", err)
				// 可以根据错误类型决定是否继续
			}
			
			// 标记消息已处理(异步提交偏移量)
			session.MarkMessage(message, "")
			
			messageCount++
			
			// 手动提交偏移量(如果需要更精确的控制)
			// if messageCount % 100 == 0 {
			//     session.Commit()
			// }
			
		case <-session.Context().Done():
			fmt.Printf("Session done for partition %d, processed %d messages\n",
				claim.Partition(), messageCount)
			return nil
		}
	}
}

func processMessage(message *sarama.ConsumerMessage) error {
	// 这里添加你的业务逻辑
	// 例如:解析JSON、写入数据库、调用API等
	
	// 模拟处理时间
	// time.Sleep(10 * time.Millisecond)
	
	return nil
}
相关推荐
YIN_尹2 小时前
【MySQL】增删查改的艺术——数据库CRUD完全指南(上)
数据库·mysql
yumgpkpm2 小时前
Cloudera CDP/CDH/Hadoop 信创大模型AI时代何去何从?
人工智能·hive·hadoop·elasticsearch·zookeeper·kafka·cloudera
zhengfei6112 小时前
sqligo - 轻松检测和利用 SQL 注入漏洞
数据库·sql
没有bug.的程序员2 小时前
Spring Cloud Gateway:API网关限流与熔断实战
java·开发语言·数据库·spring boot·gateway·api·springcloud
OnYoung2 小时前
用Python实现自动化的Web测试(Selenium)
jvm·数据库·python
Go高并发架构_王工2 小时前
Kafka监控体系构建:指标收集与可视化方案
分布式·kafka·linq
_F_y2 小时前
MySQL表的内连和外连
android·数据库·mysql
a努力。10 小时前
国家电网Java面试被问:混沌工程在分布式系统中的应用
java·开发语言·数据库·git·mysql·面试·职场和发展
li_wen0110 小时前
文件系统(八):Linux JFFS2文件系统工作原理、优势与局限
大数据·linux·数据库·文件系统·jffs2