Bootstrap

kafka消费堆积问题探索

背景

我们的商城项目用PHP写的,原本写日志方案用的是PHP的方案,但是,这个方案导致资源消耗一直降不下来,使用了20个CPU。后面考虑使用通过kafka的方案写日志,商城中把产生的日志丢到kafka中,在以go写的项目中消费kafka中的日志,并打印到控制台,最后,统一使用阿里sls抓取日志。我们kafka的分区有12个,go程序部署在k8s集群中,开启了弹性扩缩容,最多开启了8个pod进行消费,每秒产生的日志数量高峰在1500条左右,在这种情况下,依然产生了消息的堆积。

消费中执行的逻辑只有对象的映射和日志写控制台,所以,这种情况下产生了消息堆积,令我倍感困惑。

探索之路

第一步,确认一下每一步的执行时间。

func (s KafkaLogService) ReaderCreateLog(ctx context.Context, msg *customerkafka.CustomKafkaMsg) error {
	now := time.Now().UnixNano()
	var logEntry LogEntry
	data, ok := msg.Data.(string)

	if !ok {
		global.GIN_LOG.Error(ctx, "消息数据类型错误", "data", msg.Data)
		return fmt.Errorf("消息数据类型错误: %v", msg.Data)
	}

	// 解析 JSON 数据
	if err := json.Unmarshal([]byte(data), &logEntry); err != nil {
		global.GIN_LOG.Error(ctx, "解析 JSON 数据失败:", "error", err, "message", data)
		return fmt.Errorf("解析消息失败: %w", err)
	}
	now2 := time.Now().UnixNano()
	fmt.Printf("*******************分隔符*******************Unmarshal logEntry 耗时:%d 纳秒\n", now2-now)
	var logMessage LogMessage
	if err := json.Unmarshal([]byte(logEntry.Message), &logMessage); err != nil {
		global.GIN_LOG.Error(ctx, "解析 JSON 数据失败:", "error", err, "message", logEntry.Message)
		return fmt.Errorf("解析消息失败: %w", err)
	}
	now3 := time.Now().UnixNano()
	fmt.Printf("*******************分隔符*******************Unmarshal LogMessage 耗时:%d 纳秒\n", now3-now2)
	// 日志等级判断
	switch logEntry.Level {
	case "ERROR":
		global.GIN_LOG.Error(ctx, "", zap.Any("data", logMessage), zap.String("project", logMessage.Project))
	case "WARN":
		global.GIN_LOG.Warn(ctx, "", zap.Any("data", logMessage), zap.String("project", logMessage.Project))
	case "INFO":
		global.GIN_LOG.Info(ctx, "", zap.Any("data", logMessage), zap.String("project", logMessage.Project))
	default:
		global.GIN_LOG.Warn(ctx, "", zap.Any("data", logMessage), zap.String("project", logMessage.Project))
	}
	now4 := time.Now().UnixNano()
	fmt.Printf("*******************分隔符*******************log 耗时:%d 纳秒\n", now4-now3)
	return nil
}

 

 json的Unmarshal的耗时,倒是符合我的认知,在预期之中。

但是,打印日志尽然需要耗时1.5毫秒,这个有点超出我的意料之外。这个时间似乎有点夸张啊。

但是,即便如此,一个消费者每秒也可以消费670条左右的消息,在起了8个实例的情况下,也不应该造成kafka消息的阻塞。

继续我们的探索之路

下面这一段是我对于kakfa消费者的封装。大概的逻辑就是每一个ActionType起一个协程进行消费。在这篇《基于kafka-go写的生产者和消费者》文章中写过这个封装背后的设计逻辑,有兴趣的可以移步过去一探究竟。

// Start 方法启动消费者并开始读取消息,根据actionType调用不同的处理函数
func (c *ConsumerClient) Start(ctx context.Context, handlers map[string]ActionHandler) error {
	for {
		select {
		case <-ctx.Done():
			return nil // 上下文取消,直接返回
		default:
			msg, err := c.reader.ReadMessage(ctx)
			if err != nil {
				c.logger.Error(ctx, "Failed to read message from Kafka", "error", err)
				continue
			}
			c.logger.Info(ctx, fmt.Sprintf("Message on topic: %s value: %s partion:%d offset:%d", msg.Topic, string(msg.Value), msg.Partition, msg.Offset))
			var kafkaMsg CustomKafkaMsg
			if err := json.Unmarshal(msg.Value, &kafkaMsg); err != nil {
				c.logger.Error(ctx, "Failed to unmarshal Kafka message", "error", err)
				continue
			}
			channel := make(chan *CustomKafkaMsg)
			// 使用 sync.Map 来管理 worker
			worker, loaded := c.workerMap.LoadOrStore(kafkaMsg.ActionType, channel)
			if !loaded {
				c.wg.Add(1) // 增加 WaitGroup 计数
				if ch, ok := worker.(chan *CustomKafkaMsg); ok {
					go c.startWorker(ctx, kafkaMsg.ActionType, handlers, ch)
				}
			}
			// 发送消息到对应的通道,避免阻塞其他消息消费
			// 只有在 handlers 中存在对应的 actionType 时才发送消息到对应的通道
			if _, ok := handlers[kafkaMsg.ActionType]; ok {
				if ch, ok := worker.(chan *CustomKafkaMsg); ok {
					ch <- &kafkaMsg
				}
			}
		}
	}
}

 由于我的这个写法,让我产生了一点担忧,虽然,我想的是每个ActionType只起一个协程进行消费,难道,实际情况并不是如我预期一样运行,而是,一条kafka消息就起了一个协程进行消费,如果是这种情况的话,那么,会导致大量的垃圾回收,程序的性能就会下降,那么,消息阻塞的问题也就可以解释了。

为了,验证我的这一想法,基于pprof工具看一下实际情况。

实际验证,排除我的担忧,符合我的预期,不是有一条kafka消息就开一个协程进行消费,而是,一个ActionType就只有一个协程进行消费。 

模拟生产环境测试

上述的探索,依然不能够完美解释文章开头提到的现象,起了8个消费者,依然导致消息堆积的现象。为了进一步探究其背后的原因,我模拟生产环境的状态,每秒钟往kafka中丢了1000条消息,再观察,我发现,在这种情况下,有时json.Unmarshal也有比较长的耗时,会出现1.5毫秒的耗时,另外,而写日志需要5毫秒左右,如此,每秒只能消费140条消息,消息堆积的现象也就能够解释了。

结论 

消息堆积的主要原因是日志打印操作耗时较长,最差时每秒只能消费140条消息。此外,有时JSON解析的时间也较长,这也是一个需要关注的问题。

接下来的目标是找出JSON解析耗时较长和日志打印慢的具体原因,并进行优化。通过解决这些问题,我们有望提高日志处理的效率,从而解决消息堆积的问题。

;