背景
近期项目上遇到这么一个场景:
要搭建一个推送系统,但是接入方的消息有优先级的区分,在系统处理能力有限的情况下,希望优先推送优先级更高的消息(甚至可以饿死地优先级的消息)
项目里使用的消息中间是Kafka,并不支持优先级消费的逻辑,因为一些客观原因,也不打算换用其他消息中间件
因此,这个功能的大致需求就是
- 通过 kafka 实现
- 支持单一消费者订阅多个topic,并可以根据优先级进行消费,首先消费高优先级,然后再考虑其他队列
- 消息不重不丢,尽量保留kafka的可靠性
实现方案一
对于这个需求,其实很容易能够考虑使用优先队列来处理:
- 多个消费者消费不同优先级的topic
- 将消息统一存储到一个优先队列,进行排序消费,保证处理的优先级
大致逻辑如下图所示:
但这个方案有几个问题
- 优先队列是在内存/第三方实现的,重启或宕机存在丢失的风险,可靠性较低
- 需要考虑内存问题,优先队列如果是无界队列,容易导致OOM; 如果是有界队列,队伍满了塞不进来,容易丢消息/无法保证优先级
- 引入一些额外的中间件(如redis等)和复杂的业务逻辑,提高复杂度
因此这种方案被淘汰(实际感觉这种方式其实更改动更小更合理些,但不太能满足严格的优先级消费)
实现方案二
一个消费者订阅多个topic,按照优先级从高到低去拉取topic消息
- 高优先topic有消息就首先消费
- 没有就去拉取后面优先级topic的消息,如此循环反复
大致方案如下图:
这个方案可以利用上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相关信息
- 开启心跳协程和注册一些错误处理函数
- 开启携程,死循环拉取&处理消息
这里死循环执行的就是我们之间实现的handler定义的处理逻辑
而在这里,我们实际上是可以获取到我们本次订阅的所有topic的,消费的具体逻辑也是由我们定义handler决定的,所以我们可以改写 ConsumerGroupHandler 接口为
对于这个handler,我们需要多实现一个优先级消费的逻辑,判断是否进行优先消费的逻辑,以及优先级排序的逻辑
然后在原先sarama 的 注册消费逻辑 出,增加一个判断
- 如果我们注册的优先级消费handler,那么会走优先级消费逻辑这个消费逻辑由业务定义
这样,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爆了的坑
通过pprof调试,可以看到绝大部分的cpu时间在这个siftdown里了
所以最好就是不要频繁的创建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
}
}