【思路】线上kafka重平衡问题排查

212 阅读5分钟

业务背景

公司上半年调度过来一批GPU卡服务于业务,业务侧需要利用这批GPU卡在夜间生成智能化数据 (白天这批GPU卡会服务于其他业务在晚上才会被调度回来)。

方案

方案消费队列 + 延迟消费

业务大致流程:在需要触发其生成的业务场景(B)的链路上埋点往kafka中写入一条消息,消费方在白天阻塞消息,在晚上11点后解除阻塞进行消费处理。(ps:这里暂不考虑消息量级和限流问题)。

延迟消费控制器代码片段

// DelayConsumerCtl 延迟消费控制器
type DelayConsumerCtl struct {
    State   int32    // 状态值: 0: 关闭(阻塞消费) 1: 打开 (不阻塞消费)
    C       chan int // 信号量
    TimeSep TimeSep  // 允许时间段
}

// NewDelayConsumerCtl 新建对象
func NewDelayConsumerCtl() *DelayConsumerCtl {
    return &DelayConsumerCtl{
       State: 0,
       C:     make(chan int),
    }
}

// Open 打开
func (c *DelayConsumerCtl) Open(ctx context.Context) {
    if c.inTimeSep(ctx) {
       if c.State == 0 {
          c.State = 1
          c.C <- 1 // 释放信号
       }
    } else {
       c.State = 0
    }
}

// Pass 延迟控制能否消费(会阻塞消费
func (c *DelayConsumerCtl) Pass(ctx context.Context) {
    log.InfoContext(ctx, "enter DelayConsumerCtl")
    for !c.pass(ctx) {
       log.InfoContextf(ctx, "pass DelayConsumerCtl")
       time.Sleep(5 * time.Second)
    }
}

// pass 延迟控制能否消费(会阻塞消费
func (c *DelayConsumerCtl) pass(ctx context.Context) bool {
    switch c.State {
    case 0: // 关闭
       select { // 阻塞
       case <-c.C: // 信号量
          if c.inTimeSep(ctx) {
             return true
          }
       }
    case 1:
       if c.inTimeSep(ctx) { // 时间段允许
          return true
       }
    default:

    }
    // 如果不能访问则需要将状态重置
    c.Reset()
    return false
}

流程

sequenceDiagram
定时任务->>服务: 晚上11点定时任务触发(广播)调用open方法解除阻塞
服务-->>定时任务: 

线上表现

项目上线后值守观察的现状,如下:

日期业务表现结果
第一天11后正常消费正常
第二天11后 重平衡,持续0.5小时,后续正常消费异常
第三天11后 重平衡,持续1小时,后续正常消费异常
第四天11后 重平衡,持续0.5小时,后续正常消费异常

.....

框架

sarama v1.29.1

github.com/IBM/sarama

其他因素

  • 第二天晚上该服务有其他需求发布

思考

为什么一到11点kafka会触发重平衡呢?

kafka重平衡触发条件

  1. 有新的消费者加入消费者组。

  2. 当运行的消费者停止运行,离开消费者组。常见的情况如消费者重启,消费者应用崩溃,消费者进程上报的心跳超时等

  3. 分区数变动的时候(增加或删除)。

问题溯源

从上面kafka触发重平衡条件看 1和3 可以排除,只有2有可能导致重平衡,考虑到是夜晚11点触发任务,消费者没有重启和崩溃,那只能是心跳上报的问题。但之前看过sarama源码它的心跳上报是单独一个协程来处理,阻塞消费应该不会影响心跳上报。(代码片段如下)。第二天联系kafka平台的同事帮忙捞一下日志,发现在11点那个时间段有大量的 类似下面代码段输入的日志

info(s"Member ${member.memberId} in group ${group.groupId} has failed, removing it from the group") 从服务端日志不难看出,还是有对应的机器心跳上报导致的超时。服务端日志和我的理解存在出入,导致我又扒了sarama的源码重新看了一遍,整体下来也没有发现导致重平衡的点。后面从业务逻辑开始分析,刚开始分析一遍没有任何问题,后面不经意的一瞥发现的症结所在,就是无缓冲的chan导致的心跳超时,具体怎么导致的这里流程很复杂。

sarama 心跳上报

newConsumerGroupSession -> sess.heartbeatLoop()

func newConsumerGroupSession(ctx context.Context, parent *consumerGroup, claims map[string][]int32, memberID string, generationID int32, handler ConsumerGroupHandler) (*consumerGroupSession, error) {
    // init offset manager
    offsets, err := newOffsetManagerFromClient(parent.groupID, memberID, generationID, parent.client)
    if err != nil {
       return nil, err
    }

    // init context
    ctx, cancel := context.WithCancel(ctx)

    // init session
    sess := &consumerGroupSession{
       parent:       parent,
       memberID:     memberID,
       generationID: generationID,
       handler:      handler,
       offsets:      offsets,
       claims:       claims,
       ctx:          ctx,
       cancel:       cancel,
       hbDying:      make(chan none),
       hbDead:       make(chan none),
    }

    // start heartbeat loop
    go sess.heartbeatLoop()

    // create a POM for each claim
    for topic, partitions := range claims {
       for _, partition := range partitions {
          pom, err := offsets.ManagePartition(topic, partition)
          if err != nil {
             _ = sess.release(false)
             return nil, err
          }

          // handle POM errors
          go func(topic string, partition int32) {
             for err := range pom.Errors() {
                sess.parent.handleError(err, topic, partition)
             }
          }(topic, partition)
       }
    }

    // perform setup
    if err := handler.Setup(sess); err != nil {
       _ = sess.release(true)
       return nil, err
    }

    // start consuming
    for topic, partitions := range claims {
       for _, partition := range partitions {
          sess.waitGroup.Add(1)

          go func(topic string, partition int32) {
             defer sess.waitGroup.Done()

             // cancel the as session as soon as the first
             // goroutine exits
             defer sess.cancel()

             // consume a single topic/partition, blocking
             sess.consume(topic, partition)
          }(topic, partition)
       }
    }
    return sess, nil
}

根因

业务上线时刻

由于业务上线延迟消费都是关闭(没有阻塞)所有服务滚动发布时kafka重平衡可以均衡,每台机器都能分配一个分区。

1day

晚上11点定时任务广播触发,定时任务逻辑会调用open方法,此时会往chan写数据并把状态设置,消费逻辑的阻塞会打开进行正常消息消费。

2day

在晚上11点之前,该服务的其他需求发布打破了kafka平衡状态触发重平衡 且 机器在滚动发布,这就会导致被阻塞的旧机器 在心跳上报的时候发现消费组在重平衡 从而导致心跳上报推出,新机器开始分配分区,可能1台机器分配多个分区。等所有的机器都发布完成的话,发现最终分区都会落在最新的机器上。等到晚上11点时,信号量发送,协程消费被唤醒,开始进行业务逻辑处理,处理完毕发现之前的订阅关系不存在 且 其他分区的消费还在阻塞中(无缓冲chan导致),导致该机器不能进行重平衡,有些机器之前分了一个分区,这时可以参与重平衡会发送joinGroup请求导致现有的消费组进行重平衡。

3day

由于机器都有之前订阅的协程阻塞,所以每次发送信号量就会唤醒之前的协程,如果正常全部唤醒就会重新出发点重平衡,周而复始,后续每天在11点都会出发重平衡。

总结

延迟控制器设计的缺陷,并发场景下设计的欠缺,这里如果改造的可以采用关闭chan的方法解除所有的阻塞。