前言
与生产者对应的是消费者,应用程序可以通过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主题有两种主要方式:消费者和消费者组
- 使用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:
// 继续检查其它分区
}
}
}
}
}
- 使用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
}