Kafka -- 支持优先级消费(Golang 版)

753 阅读7分钟

背景

近期项目上遇到这么一个场景:

要搭建一个推送系统,但是接入方的消息有优先级的区分,在系统处理能力有限的情况下,希望优先推送优先级更高的消息(甚至可以饿死地优先级的消息)

项目里使用的消息中间是Kafka,并不支持优先级消费的逻辑,因为一些客观原因,也不打算换用其他消息中间件

因此,这个功能的大致需求就是

  • 通过 kafka 实现
  • 支持单一消费者订阅多个topic,并可以根据优先级进行消费,首先消费高优先级,然后再考虑其他队列
  • 消息不重不丢,尽量保留kafka的可靠性

实现方案一

对于这个需求,其实很容易能够考虑使用优先队列来处理:

  • 多个消费者消费不同优先级的topic
  • 将消息统一存储到一个优先队列,进行排序消费,保证处理的优先级

大致逻辑如下图所示:

image.png

但这个方案有几个问题

  • 优先队列是在内存/第三方实现的,重启或宕机存在丢失的风险,可靠性较低
  • 需要考虑内存问题,优先队列如果是无界队列,容易导致OOM; 如果是有界队列,队伍满了塞不进来,容易丢消息/无法保证优先级
  • 引入一些额外的中间件(如redis等)和复杂的业务逻辑,提高复杂度

因此这种方案被淘汰(实际感觉这种方式其实更改动更小更合理些,但不太能满足严格的优先级消费)

实现方案二

一个消费者订阅多个topic,按照优先级从高到低去拉取topic消息

  • 高优先topic有消息就首先消费
  • 没有就去拉取后面优先级topic的消息,如此循环反复

大致方案如下图:

image.png

这个方案可以利用上kafka 可靠的特性,但是需要通过修改他的开源实现来支持我们的优先级

本项目基于golang-sarama(github.com/IBM/sarama) 的kafka客户端进行改造

实现的优先级消费kafka客户端在 github.com/EricOo0/pri…

通过阅读源码,可以看到业务在注册消费者的时候,实际上需要实现一个 ConsumerGroupHandler

正常使用sarama 注册消费消费者组进行消费的逻辑如下

type ConsumerGrp struct {
}

func (c *ConsumerGrp) Setup(session sarama.ConsumerGroupSession) error {
	fmt.Println("消费者启动")
	return nil
}

func (c *ConsumerGrp) Cleanup(session sarama.ConsumerGroupSession) error {
	fmt.Println("消费者关闭")
	return nil
}

func (c *ConsumerGrp) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for msg := range claim.Messages() {
		fmt.Printf("接收topic=%s, partition=%d, offset=%d, value=%s\n", msg.Topic, msg.Partition, msg.Offset, msg.Value)
	}
	return nil
}

func TestConsumerGrp(t *testing.T) {
	client := newClient()
	ctx := context.Background()
	consumerGroup, err := sarama.NewConsumerGroupFromClient(group, client)
	if err != nil {
		return
	}
	defer consumerGroup.Close()

	consumer := &ConsumerGrp{}
	go func() {
		for {
			err := consumerGroup.Consume(ctx, []string{topic}, consumer)
			if err != nil {
				log.Fatal(err)
			}
		}
	}()

	for {

	}
}

Step in consumerGroup.Consume 继续看他的实现

可以看到消费者注册和消费的大致顺序如下

  • 初始化连接
  • 和broker 通信并获取相关信息
  • 获取topic,partion,offset相关信息
  • 开启心跳协程和注册一些错误处理函数
  • 开启携程,死循环拉取&处理消息

image.png image.png

这里死循环执行的就是我们之间实现的handler定义的处理逻辑

而在这里,我们实际上是可以获取到我们本次订阅的所有topic的,消费的具体逻辑也是由我们定义handler决定的,所以我们可以改写 ConsumerGroupHandler 接口为

image.png

对于这个handler,我们需要多实现一个优先级消费的逻辑,判断是否进行优先消费的逻辑,以及优先级排序的逻辑

然后在原先sarama 的 注册消费逻辑 出,增加一个判断

  • 如果我们注册的优先级消费handler,那么会走优先级消费逻辑这个消费逻辑由业务定义 image.png

image.png

这样,kafka 客户端就可以支持我们的优先消费了

使用方式也和原有方式相差不大

type ConsumerGrp struct {
}

func (c *ConsumerGrp) Setup(session sarama.ConsumerGroupSession) error {
	fmt.Println("消费者启动")
	return nil
}

func (c *ConsumerGrp) Cleanup(session sarama.ConsumerGroupSession) error {
	fmt.Println("消费者关闭")
	return nil
}

func (c *ConsumerGrp) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for msg := range claim.Messages() {
		fmt.Printf("接收topic=%s, partition=%d, offset=%d, value=%s\n", msg.Topic, msg.Partition, msg.Offset, msg.Value)
	}
	return nil
}
func (c *ConsumerGrp) ConsumeClaimsWithPriority(sess ConsumerGroupSession,claims []ConsumerGroupClaim) error {
   for {
      // 按优先级处理消息
      needbreak := false
      for _, cons := range claims {
         select {
         case msg, ok := <-cons.Messages():
            if !ok {
               fmt.Println("consumer closed", cons)
               return errors.New("consumer closed")
               //continue
            }
            sess.MarkMessage(msg, "")
            // dosomethion
            fmt.Println(cons.Topic(), msg)
            //sess.Commit()
            needbreak = true
         case <-time.NewTicker(10 * time.Millisecond).C:
            // nomsg ,next
         }

         if needbreak {
            break
         }

      }
   }
   return nil
}

func (c *ConsumerGrp)IsPriorityConsumer() bool {
   return true
}
func (c *ConsumerGrp) SortClaimsWithPriority(claims[]ConsumerGroupClaim) []ConsumerGroupClaim { return claims }

func TestConsumerGrp(t *testing.T) {
    cfg := sarama.NewConfig()
    ctx := context.Background()
    client, err := sarama.NewClient([]string{"localhost:9091"}, cfg)
    if err != nil {
       log.Println(err)
       return
    }
    defer client.Close()
    consumerGroup, err := sarama.NewConsumerGroupFromClient("group", client)
    if err != nil {
       return
    }
    defer consumerGroup.Close()

    consumer := &ConsumerGrp{}
    go func() {
       for {
          err := consumerGroup.Consume(ctx, []string{"test-topic"}, consumer)
          if err != nil {
             log.Fatal(err)
          }
       }
    }()

    for {

    }
}

在此基础上,还可以增加一些根据配额拉取的逻辑,这些都可以自定义的 ConsumeClaimsWithPriority中实现

PS. 这里提供一份docker compose 文件,可以本地起个容器跑kafka测试一下

  • 192.168.3.49 改成自己本地ip
  • 进一个kafka容器创建topic: kafka-topics.sh --create --zookeeper 192.168.3.49:2181/kafka --replication-factor 1 --partitions 2 --topic test-topic
  • 模拟发送消息(也可以用程序发):kafka-console-producer.sh --topic=test-topic --broker-list kafka1:9091,kafka2:9092,kafka3:9093
version: '3.8'
services:
  zookeeper:
    image: wurstmeister/zookeeper
    container_name: zookeeper
    ports:
      - "2181:2181"
    restart: always
  kafka1:
    image: wurstmeister/kafka
    depends_on: [ zookeeper ]
    container_name: kafka1
    ports:
      - "9091:9091"
    environment:
      HOSTNAME: kafka1
      KAFKA_BROKER_ID: 0
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.3.49:9091
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9091
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181/kafka
    extra_hosts:
      kafka1: 192.168.3.49
  kafka2:
    image: wurstmeister/kafka
    depends_on: [ zookeeper ]
    container_name: kafka2
    ports:
      - "9092:9092"
    environment:
      HOSTNAME: kafka2
      KAFKA_BROKER_ID: 1
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.3.49:9092
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181/kafka
    extra_hosts:
      kafka2: 192.168.3.49
  kafka3:
    image: wurstmeister/kafka
    depends_on: [ zookeeper ]
    container_name: kafka3
    ports:
      - "9093:9093"
    environment:
      HOSTNAME: kafka3
      KAFKA_BROKER_ID: 2
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.3.49:9093
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9093
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181/kafka
    extra_hosts:
      kafka3: 192.168.3.49

踩到的坑

在实际实现的过程中,也遇到了几个坑,在这里帮大家避避雷

1.注意channel的关闭

kafka 的消费者是会定时和服务端进行心跳握手的,以便于server及时将挂掉的消费者踢出并进行rebalance;

sarama拉取到的消息是通过channel传过来的,在debug或者一些其他情况阻塞了程序,会自动踢出消费者,并关闭channel,这个时候如果不感知channel 的关闭,就会导致消费者无法正常退出和重新注册

  • 错误方式 case:<-cons.Messages()
  • 合理方式 case:msg, ok := <-cons.Messages(),然后判断ok是否为true

2. 合理使用timer

网上经常容易看到的for-select 写法

select { 
    case msg, ok := <-cons.Messages(): 
       // dosomethion fmt.Println(cons.Topic(), msg)
    case <-time.NewTicker(10 * time.Millisecond).C: 
        // nomsg ,next 
}

这种写法呢,每次都会新建一个ticker,并且这个ticker在到期之前不会被释放,无法被gc,所以 频率很高的情况下呢,容易cpu爆了(timer 底层是gmp 模型中p管理的一个四叉树,会频繁进行下推和删除等操作),频率低的时候呢,又会内存泄漏; 听说go 1.23解决了这个问题。

这次就是踩到了cpu爆了的坑

image.png

通过pprof调试,可以看到绝大部分的cpu时间在这个siftdown里了

image.png

所以最好就是不要频繁的创建ticker,开局建一个,然后及时stop和reset

tm := time.NewTicker(10 * time.Millisecond)
defer tm.Stop()
for {
    tm.Stop()
    tm.Reset(10 * time.Millisecond)
    select { 
        case msg, ok := <-cons.Messages(): 
           // dosomethion fmt.Println(cons.Topic(), msg)
        case <-tm.C: 
            // nomsg ,next 
    }
}

3.stop 不会关闭channel

上面示例代码中,如果你的消息处理处理逻辑很长,超过的ticker的时间,那么其实在你处理的时候呢,ticker已经触发了,会往tm.C中投递一条消息

这个时候你虽然stop了这个tm,但是他不会被关闭和情况,你再reset 这个定时器,进入select 后,会直接触发 tm.C的这个信号

因此,可以reset前先清一下channel

tm := time.NewTicker(10 * time.Millisecond)
defer tm.Stop()
for {
    tm.Stop()
    if len(tm.C) >0{
        <-tm.C
    }
    tm.Reset(10 * time.Millisecond)
    select { 
        case msg, ok := <-cons.Messages(): 
           // dosomethion fmt.Println(cons.Topic(), msg)
        case <-tm.C: 
            // nomsg ,next 
    }
}