Skip to content

第14章:消息队列实战与日志监控系统从零搭建

凌晨三点,你被电话炸醒。线上服务挂了,但你毫不知情——因为日志散落在十几台机器上,没有告警,没有大盘,出问题了全靠用户反馈。你SSH到每台机器grep日志,手忙脚乱地排查,等找到根因,已经损失了六位数的订单。

更常见的情况是:你用了消息队列,但消息偶尔丢失,偶尔重复,偶尔积压到几十万条。你查了三天三夜,发现是某个配置参数没调对。这些坑,每一个都是真金白银的教训。

我是怕浪猫,一个在消息队列和可观测性领域踩过无数坑的Go后端开发者。今天这一章,我们把消息队列和日志监控这两个运维命脉系统,从设计到实现完整撸一遍。不是Hello World级别的玩具,是能扛住生产流量的实战方案。篇幅会比较长,因为每一个知识点我都会给出完整的代码和踩坑分析,建议先收藏再慢慢看。

没有监控的系统就像闭眼开车,不出事是运气,出事是必然。而用了消息队列却不懂调优,就像在高速公路上开一辆随时可能熄火的车。


一、Kafka客户端实践:Sarama库使用详解

1.1 为什么选Sarama

Go生态里操作Kafka主流有两个库:confluent-kafka-go(基于librdkafka的CGO绑定)和IBM/sarama(纯Go实现)。怕浪猫在生产环境选Sarama,原因很直接:

  • 纯Go实现,不需要CGO,交叉编译无痛。你的CI流水线用一个Docker镜像就能编译所有平台,不用折腾C编译器
  • 社区活跃,文档完善,遇到问题能搜到解决方案
  • 配置粒度细,生产级调优选项齐全,从批量大小到压缩算法到幂等生产者都能精确控制
  • 原来是Shopify维护的,后来捐给了IBM,代码质量有保障

安装很简单:

bash
go get github.com/IBM/sarama@latest

1.2 基础连接与配置

先看一个最小可用的连接配置。别小看这段代码,怕浪猫见过不少团队连版本号都不设,用默认的低版本配置跑在高版本Kafka上,莫名其妙地踩坑:

go
package main

import (
	"fmt"
	"log"

	"github.com/IBM/sarama"
)

func main() {
	// 创建配置实例
	config := sarama.NewConfig()
	// 设置Kafka版本(必须与集群版本匹配,否则可能出现协议不兼容)
	// 怕浪猫的踩坑经验:不设版本,Sarama会用最低版本协议
	// 高版本特性如幂等生产者、增量Rebalance都用不了
	config.Version = sarama.V3_5_0_0
	// 生产者配置
	config.Producer.RequiredAcks = sarama.WaitForAll
	config.Producer.Return.Successes = true
	config.Producer.Return.Errors = true

	// 连接Kafka集群
	brokers := []string{"127.0.0.1:9092"}
	client, err := sarama.NewClient(brokers, config)
	if err != nil {
		log.Fatalf("Failed to create Kafka client: %v", err)
	}
	defer client.Close()

	// 检查Topic是否存在
	topics, err := client.Topics()
	if err != nil {
		log.Fatalf("Failed to get topics: %v", err)
	}
	fmt.Println("Available topics:", topics)

	// 获取指定Topic的分区信息
	partitions, err := client.Partitions("test-topic")
	if err != nil {
		log.Fatalf("Failed to get partitions: %v", err)
	}
	fmt.Println("Partitions:", partitions)
}

版本配置是第一个坑。Sarama默认版本很老,不设置Version会导致高版本Kafka的新特性不可用,设太高又会导致低版本集群报协议错误。你的配置文件里应该明确写死Kafka集群的版本号。

1.3 生产者配置与调优

生产者调优是Kafka性能优化的核心战场。怕浪猫逐个参数讲清楚,每个参数都会说明它影响什么,怎么设,为什么这么设:

go
func NewOptimizedProducer(brokers []string) (sarama.SyncProducer, error) {
	config := sarama.NewConfig()
	config.Version = sarama.V3_5_0_0

	// ===== 可靠性配置 =====
	// WaitForAll 等待所有ISR副本确认,最高可靠性
	// 如果你的消息丢了无所谓,用WaitForLocal就行,延迟更低
	config.Producer.RequiredAcks = sarama.WaitForAll
	// 成功和失败都返回,方便业务处理
	config.Producer.Return.Successes = true
	config.Producer.Return.Errors = true
	// 消息发送超时,超时后Sarama内部会重试
	config.Producer.Timeout = 10 * time.Second

	// ===== 批量配置(吞吐量核心) =====
	// 批量发送的间隔,攒够这个时间就发
	// 怕浪猫实测:日志场景设500ms,交易场景设50ms
	config.Producer.Flush.Frequency = 500 * time.Millisecond
	// 批量发送的消息数量,攒够这么多条就发
	config.Producer.Flush.Messages = 200
	// 批量发送的字节数,攒够这么多字节就发
	// 三个条件满足任意一个就触发发送
	config.Producer.Flush.Bytes = 1 * 1024 * 1024 // 1MB

	// ===== 压缩配置 =====
	// 开启压缩,大幅减少网络IO,但增加CPU开销
	// 怕浪猫强烈推荐LZ4,CPU开销低,压缩率够用
	config.Producer.Compression = sarama.CompressionLZ4
	// 压缩级别,1最快,9最高压缩率,生产推荐3-4
	config.Producer.CompressionLevel = 3

	// ===== 重试配置 =====
	// 发送失败重试次数
	config.Producer.Retry.Max = 3
	// 重试间隔,线性退避
	config.Producer.Retry.Backoff = 100 * time.Millisecond

	// ===== 分区选择 =====
	// 使用Hash分区器,相同key的消息进同一分区,保证分区有序
	config.Producer.Partitioner = sarama.NewHashPartitioner

	// ===== 幂等配置(3.0+版本默认开启) =====
	// 开启幂等生产者,防止重试导致的消息重复
	// 原理:Kafka为每个生产者分配PID,用序列号去重
	config.Producer.Idempotent = true
	// 幂等模式下必须满足以下条件
	// MaxOpenRequests=1表示同一连接只允许一个未确认的请求
	config.Net.MaxOpenRequests = 1

	producer, err := sarama.NewSyncProducer(brokers, config)
	if err != nil {
		return nil, fmt.Errorf("failed to create producer: %w", err)
	}
	return producer, nil
}

实际发送消息的代码:

go
func SendMessage(producer sarama.SyncProducer, topic, key, value string) error {
	msg := &sarama.ProducerMessage{
		Topic: topic,
		Key:   sarama.StringEncoder(key),
		Value: sarama.StringEncoder(value),
		// 可以设置消息头,方便传递traceId等元信息
		// 消息头不会参与分区计算,只是附加元数据
		Headers: []sarama.RecordHeader{
			{Key: []byte("traceId"), Value: []byte("abc-123")},
			{Key: []byte("appId"), Value: []byte("order-service")},
		},
		Timestamp: time.Now(),
	}

	partition, offset, err := producer.SendMessage(msg)
	if err != nil {
		return fmt.Errorf("send message failed: %w", err)
	}
	log.Printf("Message sent to partition %d at offset %d\n", partition, offset)
	return nil
}

批量配置是吞吐量和延迟的跷跷板。Flush.Frequency太大,消息延迟高;太小,吞吐量上不去。怕浪猫的经验值:高频日志场景500ms/200条,交易场景50ms/10条。你必须在自己的硬件上压测,别人的参数不一定适合你。

关于压缩算法的选择,怕浪猫实测过各算法在真实日志数据上的表现:

压缩算法CPU开销压缩率压缩速度适用场景
None最低0%-CPU敏感,网络充裕
GZIP60-70%兼容性最好,带宽极度紧张
Snappy40-50%延迟敏感场景
LZ440-50%最快综合最优,生产推荐
ZSTD65-75%较快带宽紧张但CPU有富余

实测数据:在日志场景下(文本类消息,平均2KB/条),LZ4相比无压缩减少网络传输约45%,CPU开销增加约8%,是性价比最高的选择。ZSTD压缩率更高但CPU开销大约是LZ4的2倍,在带宽不是瓶颈的情况下不值得。

1.4 异步生产者

SyncProducer底层也是异步的,但每次SendMessage会阻塞等待确认。高吞吐场景应该用AsyncProducer,发送速度不受确认速度制约:

go
func NewAsyncProducer(brokers []string) (sarama.AsyncProducer, error) {
	config := sarama.NewConfig()
	config.Version = sarama.V3_5_0_0
	config.Producer.RequiredAcks = sarama.WaitForAll
	config.Producer.Return.Successes = true
	config.Producer.Return.Errors = true
	config.Producer.Flush.Frequency = 200 * time.Millisecond
	config.Producer.Flush.Messages = 500
	config.Producer.Compression = sarama.CompressionLZ4

	producer, err := sarama.NewAsyncProducer(brokers, config)
	if err != nil {
		return nil, err
	}

	// 必须处理success和error通道,否则会阻塞
	// 这是AsyncProducer使用中最常见的坑
	go func() {
		for success := range producer.Successes() {
			// 生产环境通常不需要逐条记录成功日志
			// 可以只更新指标计数器
			log.Printf("OK: topic=%s partition=%d offset=%d",
				success.Topic, success.Partition, success.Offset)
		}
	}()

	go func() {
		for err := range producer.Errors() {
			log.Printf("ERR: %v", err)
			// 这里应该做重试或告警
			// 怕浪猫的实践:把失败消息写入本地落盘文件
			// 后台协程定期重发
		}
	}()

	return producer, nil
}

AsyncProducer的Successes和Errors通道如果不消费,缓冲区满了之后生产者会永久阻塞。这个坑我踩过,排查了一整天——程序不报错也不退出,就是不发消息了,CPU和内存都正常,最后发现是Successes channel没人读。

异步发送的使用方式:

go
func SendAsync(producer sarama.AsyncProducer, topic, key, value string) {
	msg := &sarama.ProducerMessage{
		Topic: topic,
		Key:   sarama.StringEncoder(key),
		Value: sarama.StringEncoder(value),
	}
	// 非阻塞发送,直接写入Input channel
	producer.Input() <- msg
}

1.5 消费者组管理

消费者组是Kafka实现消息分发和负载均衡的核心机制。同一个消费者组内的消费者共同消费Topic的所有分区,每个分区只被组内一个消费者消费。Sarama提供了ConsumerGroup接口:

go
// LogConsumer 实现sarama.ConsumerGroupHandler接口
type LogConsumer struct {
	handler func(*sarama.ConsumerMessage) error
}

func (c *LogConsumer) Setup(sarama.ConsumerGroupSession) error {
	// 消费者组启动时调用,可以做初始化
	// 比如建立数据库连接、加载配置等
	return nil
}

func (c *LogConsumer) Cleanup(sarama.ConsumerGroupSession) error {
	// 消费者组关闭时调用,可以做清理
	// 比如关闭数据库连接、刷写缓冲区等
	return nil
}

func (c *LogConsumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	// 核心消费逻辑
	// 注意:这个方法在每次Rebalance后会被重新调用
	for message := range claim.Messages() {
		// 处理消息
		if err := c.handler(message); err != nil {
			log.Printf("process message failed: %v, topic=%s partition=%d offset=%d",
				err, message.Topic, message.Partition, message.Offset)
			// 处理失败不提交offset,下次会重新消费
			// 但要注意:如果是不可恢复的错误(比如消息格式错误)
			// 不提交offset会导致后续消息全部被阻塞
			// 这种情况应该跳过这条消息并记录到死信队列
			continue
		}
		// 手动提交offset
		session.MarkMessage(message, "")
	}
	return nil
}

func processMessage(msg *sarama.ConsumerMessage) error {
	log.Printf("Received: topic=%s partition=%d offset=%d key=%s value=%s",
		msg.Topic, msg.Partition, msg.Offset, string(msg.Key), string(msg.Value))
	// 实际业务逻辑...
	return nil
}

启动消费者组的完整代码:

go
func StartConsumerGroup(brokers []string, topic, groupID string) error {
	config := sarama.NewConfig()
	config.Version = sarama.V3_5_0_0

	// 消费者组配置
	// 分区分配策略:Range是默认策略,按分区范围分配
	// CooperativeSticky更平滑,Rebalance时不会暂停所有消费
	config.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{
		sarama.NewBalanceStrategyCooperativeSticky(),
	}
	// Session超时:消费者在这个时间内没发心跳就被认为挂了
	// 设太短会导致误判,设太长会导致真正挂掉后很久才Rebalance
	config.Consumer.Group.Session.Timeout = 15 * time.Second
	// Rebalance超时:等待所有消费者加入Rebalance的时间
	config.Consumer.Group.Rebalance.Timeout = 60 * time.Second
	// 心跳间隔,一般为Session超时的1/3
	config.Consumer.Group.Heartbeat.Interval = 5 * time.Second

	// Offset提交方式:手动提交
	// 自动提交的风险:消息还没处理完就提交了,crash后消息丢失
	config.Consumer.Offsets.AutoCommit.Enable = false
	// 从最早的消息开始消费(首次加入消费者组时)
	// 已提交offset的消费者不受影响,从已提交位置继续
	config.Consumer.Offsets.Initial = sarama.OffsetOldest

	// 消费超时:从Kafka读取消息的超时时间
	config.Net.ReadTimeout = 30 * time.Second

	client, err := sarama.NewConsumerGroup(brokers, groupID, config)
	if err != nil {
		return err
	}
	defer client.Close()

	ctx := context.Background()
	handler := &LogConsumer{
		handler: processMessage,
	}

	for {
		// Consume会在rebalance时返回,需要循环调用
		// 这是Sarama的设计:Rebalance后需要重新调用Consume
		err := client.Consume(ctx, []string{topic}, handler)
		if err != nil {
			return err
		}
		// 检查context是否被取消
		if ctx.Err() != nil {
			return ctx.Err()
		}
	}
}

消费者组的Rebalance是Kafka运维中最容易被忽视的问题。一次Rebalance会导致所有消费者暂停消费,短则几秒,长则几十秒。如果你的处理耗时超过session.timeout,消费者会被踢出组触发Rebalance,形成恶性循环。解法是调大session超时,或者用Cooperative策略。

1.6 手动提交Offset详解

自动提交Offset方便但有风险:消息还没处理完就提交了,如果此时crash,消息就丢了。生产环境必须手动提交。

但手动提交也有讲究,怕浪猫见过三种错误做法:

  1. 每条消息处理完就提交——性能差,频繁的OffsetCommit请求拖慢消费
  2. 批量处理完一起提交——但没处理完就crash,这批全部重消费
  3. 定时提交——在准确性和性能之间取平衡,生产推荐
go
func (c *LogConsumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for {
		select {
		case message, ok := <-claim.Messages():
			if !ok {
				return nil
			}

			// 1. 反序列化
			var logEntry LogEntry
			if err := json.Unmarshal(message.Value, &logEntry); err != nil {
				// 反序列化失败,记录错误并跳过,避免毒丸消息阻塞消费
				// 这是毒丸消息的处理:格式错误的消息永远无法被正确处理
				// 不跳过它会导致消费者一直卡在这条消息上
				log.Printf("unmarshal failed: %v, offset=%d", err, message.Offset)
				session.MarkMessage(message, "")
				continue
			}

			// 2. 业务处理
			if err := c.handleLogEntry(&logEntry); err != nil {
				// 处理失败,根据策略决定是否重试
				if c.isRetryable(err) {
					// 可重试的错误,不提交offset,让下次重新消费
					// 但要注意退避,避免快速重试导致CPU打满
					time.Sleep(time.Second)
					continue
				}
				// 不可重试的错误,跳过并记录到死信队列
				c.sendToDeadLetterQueue(message, err)
			}

			// 3. 处理成功,手动提交offset
			// MarkMessage只是标记,真正的提交在session.Commit()时发生
			// 但Sarama会在ConsumeClaim返回时自动提交被标记的offset
			session.MarkMessage(message, "")

		case <-session.Context().Done():
			return nil
		}
	}
}

毒丸消息(Poison Pill)是消费端最隐蔽的坑。一条格式错误的消息导致处理永远失败,如果不跳过它,会阻塞整个分区。解法是设最大重试次数,超限后投递到死信队列。怕浪猫在生产环境遇到过JSON里带了BOM头导致的毒丸消息,排查了一整天。


二、消息可靠性保障

2.1 生产端可靠性

消息从生产到消费,要经过三个环节:生产者到Kafka集群、Kafka集群内部同步、Kafka集群到消费者。每个环节都可能丢消息。

生产端可靠性三板斧:ACK机制、重试策略、本地缓冲。

ACK机制有三个级别,理解它们对于配置可靠性至关重要:

go
// ACK级别详解
// 0 (NoResponse): 生产者不等待任何确认,发了就算成功
// 最快但最容易丢消息:网络丢包、Broker宕机都会丢
config.Producer.RequiredAcks = sarama.NoResponse

// 1 (WaitForLocal): 等待Leader写入确认
// Leader确认了就认为成功,但Leader挂了且副本还没同步时会丢
// 这是性能和可靠性的折中
config.Producer.RequiredAcks = sarama.WaitForLocal

// -1 (WaitForAll): 等待所有ISR(In-Sync Replicas)副本确认
// 最高可靠性,只要还有一个ISR副本存活,消息就不丢
// 配合min.insync.replicas=2使用,保证至少两个副本有这条消息
config.Producer.RequiredAcks = sarama.WaitForAll

生产环境用WaitForAll配合幂等生产者,这是标准做法:

go
func NewReliableProducer(brokers []string) (sarama.SyncProducer, error) {
	config := sarama.NewConfig()
	config.Version = sarama.V3_5_0_0

	// 可靠性配置
	config.Producer.RequiredAcks = sarama.WaitForAll
	// 幂等生产者:防止重试导致的重复消息
	// 原理是Kafka为每个生产者分配一个PID(Producer ID)
	// 每条消息带一个递增的SequenceNumber
	// Broker端用<PID, partition, sequenceNumber>去重
	config.Producer.Idempotent = true
	// 幂等模式下必须MaxOpenRequests=1
	// 否则并发的请求可能导致Broker端序列号乱序
	config.Net.MaxOpenRequests = 1

	// 重试配置
	config.Producer.Retry.Max = 5
	// 指数退避,避免重试风暴
	config.Producer.Retry.Backoff = func(retry int) time.Duration {
		// 100ms, 200ms, 400ms, 800ms, 1600ms
		return time.Duration(1<<uint(retry)) * 100 * time.Millisecond
	}

	// 超时配置
	config.Producer.Timeout = 10 * time.Second
	config.Producer.Return.Successes = true
	config.Producer.Return.Errors = true

	return sarama.NewSyncProducer(brokers, config)
}

但SyncProducer重试有限,极端情况(网络长时间断开)还是会丢消息。生产级方案需要本地缓冲,这就是怕浪猫在生产环境用的方案:

go
type ReliableProducer struct {
	producer  sarama.SyncProducer
	topic     string
	queue     chan *sarama.ProducerMessage
	stopCh    chan struct{}
	maxRetry  int
	wg        sync.WaitGroup
}

func NewReliableProducerWithBuffer(brokers []string, topic string, bufSize int) (*ReliableProducer, error) {
	producer, err := NewReliableProducer(brokers)
	if err != nil {
		return nil, err
	}

	p := &ReliableProducer{
		producer: producer,
		topic:    topic,
		queue:    make(chan *sarama.ProducerMessage, bufSize),
		stopCh:   make(chan struct{}),
		maxRetry: 5,
	}

	p.wg.Add(1)
	go p.dispatch()

	return p, nil
}

func (p *ReliableProducer) dispatch() {
	defer p.wg.Done()
	for {
		select {
		case msg := <-p.queue:
			p.sendWithRetry(msg)
		case <-p.stopCh:
			// 退出前把队列里的消息发完
			for len(p.queue) > 0 {
				msg := <-p.queue
				p.sendWithRetry(msg)
			}
			return
		}
	}
}

func (p *ReliableProducer) sendWithRetry(msg *sarama.ProducerMessage) {
	for i := 0; i < p.maxRetry; i++ {
		_, _, err := p.producer.SendMessage(msg)
		if err == nil {
			return
		}
		log.Printf("send failed (attempt %d/%d): %v", i+1, p.maxRetry, err)
		time.Sleep(time.Duration(1<<uint(i)) * time.Second)
	}
	// 重试全部失败,写入本地落盘文件,等待人工处理或后台重发
	p.fallbackToDisk(msg)
}

func (p *ReliableProducer) fallbackToDisk(msg *sarama.ProducerMessage) {
	// 简化实现:将失败消息序列化写入本地文件
	// 生产环境可以用BadgerDB或BoltDB做持久化队列
	file, err := os.OpenFile("failed_messages.jsonl",
		os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Printf("fallback to disk failed: %v", err)
		return
	}
	defer file.Close()

	value, _ := msg.Value.Encode()
	key, _ := msg.Key.Encode()
	record := map[string]interface{}{
		"topic":     msg.Topic,
		"key":       string(key),
		"value":     string(value),
		"timestamp": msg.Timestamp,
	}
	line, _ := json.Marshal(record)
	file.Write(append(line, '\n'))
}

func (p *ReliableProducer) Send(key, value string) error {
	msg := &sarama.ProducerMessage{
		Topic: p.topic,
		Key:   sarama.StringEncoder(key),
		Value: sarama.StringEncoder(value),
	}
	select {
	case p.queue <- msg:
		return nil
	default:
		// 队列满了,直接拒绝,避免OOM
		// 这种情况应该告警,说明生产速度远超消费速度
		return fmt.Errorf("producer buffer full")
	}
}

func (p *ReliableProducer) Close() {
	close(p.stopCh)
	p.wg.Wait()
	p.producer.Close()
}

可靠性没有银弹,只有取舍。你愿意为0.01%的可靠性提升付出多少延迟和复杂度的代价,这个问题的答案因业务而异。核心交易用WaitForAll+本地缓冲+幂等,边缘业务用WaitForLocal就够了。

2.2 消费端可靠性:幂等消费

网络重试、生产者重试、消费者Rebalance都可能导致消息重复。比如生产者发送消息后超时了,但消息实际已经到达Broker,重试就会产生重复。消费端必须实现幂等性。

幂等消费的核心思路:用唯一标识(消息ID或业务ID)做去重。去重存储可以用Redis(高性能、有TTL自动过期)或数据库唯一索引(强一致但性能差):

go
type IdempotentConsumer struct {
	redis      *redis.Client
	handler    func(*sarama.ConsumerMessage) error
}

func NewIdempotentConsumer(rdb *redis.Client, handler func(*sarama.ConsumerMessage) error) *IdempotentConsumer {
	return &IdempotentConsumer{
		redis:   rdb,
		handler: handler,
	}
}

func (c *IdempotentConsumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for message := range claim.Messages() {
		// 用消息的key或自定义唯一ID做幂等key
		// 优先用业务ID(如订单号),没有再用消息元信息
		msgID := string(message.Key)
		if msgID == "" {
			msgID = fmt.Sprintf("%s-%d-%d",
				message.Topic, message.Partition, message.Offset)
		}

		// Redis SETNX 原子操作,过期时间根据业务设置
		// 过期时间要大于消息可能重复的时间窗口
		// 比如消费者Rebalance可能导致5分钟内的消息重复
		// 那TTL至少设10分钟
		key := fmt.Sprintf("msg:dedup:%s", msgID)
		set, err := c.redis.SetNX(context.Background(), key, "1", 24*time.Hour).Result()
		if err != nil {
			log.Printf("redis setnx failed: %v", err)
			// Redis不可用时的策略选择:
			// 1. 保守策略:不跳过,可能重复处理(默认)
			// 2. 严格策略:不处理,等Redis恢复(适用于严格幂等场景)
			continue
		}

		if !set {
			// 已经处理过这条消息,跳过
			// 这是幂等消费的核心:重复消息直接跳过
			session.MarkMessage(message, "")
			continue
		}

		// 执行业务逻辑
		if err := c.handler(message); err != nil {
			// 处理失败,删除幂等key,允许下次重试
			// 否则这条消息永远无法被重新处理
			c.redis.Del(context.Background(), key)
			return err
		}

		session.MarkMessage(message, "")
	}
	return nil
}

2.3 死信队列实现

处理失败的消息不能丢,也不能无限重试阻塞消费。死信队列(Dead Letter Queue)是标准解法。思路是:消息处理失败达到最大重试次数后,把消息转发到一个专门的死信Topic,由专门的消费者或人工处理:

go
type DeadLetterHandler struct {
	producer     sarama.SyncProducer
	mainTopic    string
	dlqTopic     string
	maxRetry     int
	retryTracker *redis.Client
}

func (h *DeadLetterHandler) Handle(msg *sarama.ConsumerMessage, processErr error) error {
	// 用Redis记录每条消息的重试次数
	retryKey := fmt.Sprintf("msg:retry:%s-%d-%d",
		msg.Topic, msg.Partition, msg.Offset)

	// 原子递增重试次数
	retryCount, _ := h.retryTracker.Incr(context.Background(), retryKey).Result()
	if retryCount == 1 {
		// 第一次失败,设置过期时间
		// 过期后重试计数清零,相当于给消息一个"重新开始"的机会
		h.retryTracker.Expire(context.Background(), retryKey, 24*time.Hour)
	}

	if retryCount > int64(h.maxRetry) {
		// 超过最大重试次数,投递到死信队列
		// 死信消息需要携带完整的上下文信息,方便排查
		dlqMsg := &sarama.ProducerMessage{
			Topic: h.dlqTopic,
			Key:   msg.Key,
			Value: msg.Value,
			Headers: []sarama.RecordHeader{
				{Key: []byte("originalTopic"), Value: []byte(msg.Topic)},
				{Key: []byte("originalPartition"), Value: []byte(fmt.Sprintf("%d", msg.Partition))},
				{Key: []byte("originalOffset"), Value: []byte(fmt.Sprintf("%d", msg.Offset))},
				{Key: []byte("error"), Value: []byte(processErr.Error())},
				{Key: []byte("retryCount"), Value: []byte(fmt.Sprintf("%d", retryCount))},
				{Key: []byte("deadAt"), Value: []byte(time.Now().Format(time.RFC3339))},
			},
		}
		_, _, err := h.producer.SendMessage(dlqMsg)
		if err != nil {
			return fmt.Errorf("send to DLQ failed: %w", err)
		}
		// 清理重试计数
		h.retryTracker.Del(context.Background(), retryKey)
		log.Printf("Message sent to DLQ: topic=%s partition=%d offset=%d",
			msg.Topic, msg.Partition, msg.Offset)
		return nil
	}

	// 未超过重试次数,返回错误让消费者不提交offset
	// 下次会重新消费这条消息
	return processErr
}

死信队列不是垃圾桶,是安全网。DLQ里的消息要有专人定期处理和分析,否则它就变成了你假装没看见的问题堆。怕浪猫的实践:每天早上看一次DLQ的消息数量趋势,突增必有妖。

2.4 消息积压处理

消息积压是生产环境最常见的运维事件。怕浪猫在之前的公司经历过一次严重的积压事故:大促期间订单消息暴增十倍,消费者处理速度跟不上,Lag从正常的几百条飙升到五十万条。运营说用户下单后迟迟收不到确认短信,客服电话被打爆。我们从发现到处理完花了四个小时,期间损失了大量订单。事后复盘发现,消费者处理逻辑里有一个同步调外部支付接口的操作,平时延迟正常,大促时支付接口也扛不住,导致我们的消费速度跟着暴跌。

这次事故让我总结了一套处理积压的标准流程。这个清单建议打印贴在工位上,关键时刻照着做:

消息积压应急处理清单:

  1. 确认积压程度:通过Kafka监控查看各分区的Lag(消费者落后生产者的消息数),区分是整体积压还是单分区积压。单分区积压通常是数据倾斜导致,比如某个key的消息量远超其他key
  2. 判断原因:消费者处理太慢?消费者实例不够?某分区数据倾斜?消费者crash了?下游依赖(数据库、外部接口)变慢了?
  3. 紧急扩容:增加消费者实例数(注意不能超过分区数,多出的消费者会闲置)。如果分区数也不够,需要紧急扩分区,但扩分区会影响顺序性
  4. 降级策略:非核心逻辑临时关闭。比如关闭二级缓存刷新、关闭非必要DB写入、跳过部分校验逻辑、关闭报表生成。核心目标是把消费速度提上去
  5. 批量消费:从逐条处理改为批量处理,减少IO次数。批量写数据库、批量调外部接口,吞吐量可以提升5到10倍
  6. 跳过策略:如果消息有时效性(如实时推荐、实时风控),考虑跳过过期消息。过期消息处理了也没意义,反而拖慢整体消费
  7. 事后复盘:积压原因分析、容量评估、预防措施、告警阈值调整。复盘要有产出物——要么改了配置,要么加了告警,要么改了代码

下面这个批量消费示例展示了如何把逐条处理改成批量处理。核心思路是攒一批再一起处理,用时间和数量两个条件控制flush时机:

go
type BatchConsumer struct {
	batchSize    int
	batchTimeout time.Duration
	handler      func([]*sarama.ConsumerMessage) error
}

func NewBatchConsumer(batchSize int, batchTimeout time.Duration,
	handler func([]*sarama.ConsumerMessage) error) *BatchConsumer {
	return &BatchConsumer{
		batchSize:    batchSize,
		batchTimeout: batchTimeout,
		handler:      handler,
	}
}

func (c *BatchConsumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	batch := make([]*sarama.ConsumerMessage, 0, c.batchSize)
	timer := time.NewTimer(c.batchTimeout)
	defer timer.Stop()

	// flush函数:批量处理并提交offset
	flush := func() error {
		if len(batch) == 0 {
			return nil
		}
		// 调用业务handler批量处理
		if err := c.handler(batch); err != nil {
			return err
		}
		// 批量提交offset
		for _, msg := range batch {
			session.MarkMessage(msg, "")
		}
		batch = batch[:0]
		return nil
	}

	for {
		select {
		case msg, ok := <-claim.Messages():
			if !ok {
				// channel关闭,刷入剩余消息
				return flush()
			}
			batch = append(batch, msg)
			// 攒够batchSize就flush
			if len(batch) >= c.batchSize {
				if err := flush(); err != nil {
					return err
				}
				timer.Reset(c.batchTimeout)
			}

		case <-timer.C:\n\t\t\t// 超时也flush,避免消息积压在batch里太久\n\t\t\tif err := flush(); err != nil {
				return err
			}
			timer.Reset(c.batchTimeout)

		case <-session.Context().Done():
			// 退出前flush
			return flush()
		}
	}
}

积压处理的核心原则:先止血,再治病。别在告警还在响的时候做根因分析,先把消费速度提上去。等积压清零了,再坐下来复盘为什么会积压,怎么预防。


三、消息顺序性实现

3.1 全局有序 vs 分区有序

Kafka只能保证单个分区内消息有序。全局有序需要把所有消息发到同一个分区,但这意味着完全丧失并行能力——所有消息排队处理,吞吐量直接降为单线程级别。在实际项目中,怕浪猫从未遇到过真正需要全局有序的场景。即使是最严格的金融交易场景,也是按账户ID分区,同一账户的交易有序就够了。

要理解这个问题,需要搞清楚Kafka的分区机制。Topic被分成多个分区,每个分区是一个有序的、不可变的日志。消息被追加到分区末尾,按写入顺序排列。不同分区之间没有顺序保证。这就是为什么按Key分区能保证同Key有序——相同Key的消息总是被Hash到同一个分区。

大部分场景下,按业务ID分区就够了——同一个订单的事件保证有序,不同订单之间不需要有序。订单A的创建、支付、发货需要按顺序处理,但订单A和订单B之间没有先后关系。

方案顺序保证吞吐量适用场景
全局有序(1分区)全局极低极少数强顺序场景,如 Binlog 同步
分区有序(按Key分区)同Key有序大部分业务场景,如订单状态流转
无序最高日志、监控指标

怕浪猫见过最离谱的案例:一个团队为了"保证顺序",把Topic设成1个分区,然后抱怨Kafka吞吐量太低。改成按订单ID分区后,吞吐量直接翻了20倍。还有团队把所有消息都用空Key发送,结果消息均匀分布到各分区,完全无序,然后又抱怨消费端处理乱序。这都是对Kafka分区机制理解不深导致的。

3.2 顺序消费实现

要实现顺序消费,需要两个条件:

  1. 生产端:相同业务Key的消息发到同一分区
  2. 消费端:同一分区的消息串行处理,不能并发

生产端用HashPartitioner(Sarama默认就是),关键是消费端不能并发处理同一分区的消息:

go
type OrderedConsumer struct {
	handler func(*sarama.ConsumerMessage) error
}

func (c *OrderedConsumer) ConsumeClaim(session sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	// 串行处理,不启动goroutine并发
	// 这是顺序消费的关键:必须串行
	for message := range claim.Messages() {
		if err := c.handler(message); err != nil {
			// 处理失败,不提交offset,暂停消费等待重试
			// 注意:这里会阻塞整个分区的后续消息
			// 这是顺序消费的代价:一条消息卡住,后面全部排队
			log.Printf("ordered consume failed: %v, will retry", err)
			// 简单退避后重试
			time.Sleep(time.Second)
			continue
		}
		session.MarkMessage(message, "")
	}
	return nil
}

但如果处理逻辑里有耗时操作(比如调外部API、等数据库锁),串行处理会导致吞吐量暴跌。解法是引入顺序队列加异步worker——每个分区一个worker goroutine,分区内串行,分区间并行:

go
type PartitionOrderedHandler struct {
	workers    map[int32]chan *sarama.ConsumerMessage
	wg         sync.WaitGroup
	handler    func(*sarama.ConsumerMessage) error
}

func NewPartitionOrderedHandler(partitionCount int,
	handler func(*sarama.ConsumerMessage) error) *PartitionOrderedHandler {
	h := &PartitionOrderedHandler{
		workers: make(map[int32]chan *sarama.ConsumerMessage),
		handler: handler,
	}
	// 为每个分区创建一个有序处理channel
	for i := 0; i < partitionCount; i++ {
		ch := make(chan *sarama.ConsumerMessage, 1000)
		h.workers[int32(i)] = ch
		h.wg.Add(1)
		go h.processOrdered(ch)
	}
	return h
}

func (h *PartitionOrderedHandler) processOrdered(ch <-chan *sarama.ConsumerMessage) {
	defer h.wg.Done()
	// 单goroutine消费channel,保证分区内有序
	for msg := range ch {
		if err := h.handler(msg); err != nil {
			log.Printf("process failed: %v", err)
			// 重新入队或走死信队列
		}
	}
}

func (h *PartitionOrderedHandler) ConsumeClaim(session sarama.ConsumerGroupSession,
	claim sarama.ConsumerGroupClaim) error {
	// 获取当前分区ID,找到对应的worker channel
	partition := claim.Partition()
	ch, ok := h.workers[partition]
	if !ok {
		return fmt.Errorf("no worker for partition %d", partition)
	}

	for message := range claim.Messages() {
		ch <- message
		session.MarkMessage(message, "")
	}
	return nil
}

顺序和性能是天生的矛盾体。先想清楚你需要的是"全局有序"还是"局部有序",90%的场景答案都是后者。不要为了简单而用全局有序,那是在用吞吐量换代码简单度,不值。


四、延迟消息实现

4.1 延迟消息的使用场景

延迟消息在业务中很常见,几乎每个后端系统都会用到。怕浪猫整理了常见的延迟消息场景:

  • 订单30分钟未支付自动关闭:电商系统的标配功能。用户下单后如果不支付,系统需要在30分钟后自动关闭订单并释放库存。用延迟消息实现非常优雅——下单时发一条延迟30分钟的消息,消费时检查订单状态,已支付则忽略,未支付则关闭
  • 会议开始前15分钟发提醒:日历系统中,用户创建会议后,需要在会议开始前提醒参与者
  • 用户注册后7天发召回推送:运营策略中常见的用户留存手段,新用户注册后间隔不同时间点发送不同内容的推送
  • 重试任务按递增间隔执行:外部接口调用失败后,按1秒、2秒、4秒、8秒的间隔重试,避免短时间内大量重试打垮下游
  • 预约任务定时执行:用户预约了某个时间点的服务,系统需要在指定时间点触发

实现延迟消息有多种方案,各有优劣。用Go的time.AfterFunc或time.NewTimer最简单,但进程重启后任务就丢了。用Redis的ZSET可以持久化,但需要自己实现调度逻辑。用RocketMQ自带延迟消息功能最方便,但引入新中间件成本高。如果已经在用Kafka,可以用多级Topic方案模拟延迟消息,虽然只支持固定延迟级别,但覆盖了大部分业务场景。

4.2 时间轮算法原理

时间轮(Hashed Wheel Timer)是高效的延迟任务调度算法。想象一个时钟表盘:

  • 表盘有N个槽位,每个槽位是一个链表
  • 指针每隔一个tick间隔转动一个槽位
  • 添加任务时,计算延迟时间对应的槽位,放入链表
  • 指针转到某个槽位时,检查链表中的任务是否到期,到期则执行

时间轮的优势在于:添加任务O(1),取消任务O(1),到期检查O(1)。相比优先队列(添加O(logN))在海量延迟任务场景下性能优势明显。

Go实现简化版时间轮:

go
type DelayTask struct {
	ID        string
	ExecuteAt time.Time
	Callback  func()
}

type TimeWheel struct {
	slots    [][]*DelayTask
	tick     time.Duration
	slotsNum int
	current  int
	stopCh   chan struct{}
	mu       sync.Mutex
}

func NewTimeWheel(tick time.Duration, slotsNum int) *TimeWheel {
	tw := &TimeWheel{
		slots:    make([][]*DelayTask, slotsNum),
		tick:     tick,
		slotsNum: slotsNum,
		stopCh:   make(chan struct{}),
	}
	go tw.run()
	return tw
}

func (tw *TimeWheel) AddTask(delay time.Duration, callback func()) string {
	task := &DelayTask{
		ID:        uuid.New().String(),
		ExecuteAt: time.Now().Add(delay),
		Callback:  callback,
	}

	tw.mu.Lock()
	defer tw.mu.Unlock()

	// 计算任务应该放在哪个槽位
	ticks := int(delay / tw.tick)
	if ticks < 1 {
		ticks = 1
	}
	slot := (tw.current + ticks) % tw.slotsNum

	tw.slots[slot] = append(tw.slots[slot], task)
	return task.ID
}

func (tw *TimeWheel) run() {
	ticker := time.NewTicker(tw.tick)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:\n\t\t\ttw.tickHandler()\n\t\tcase <-tw.stopCh:
			return
		}
	}
}

func (tw *TimeWheel) tickHandler() {
	tw.mu.Lock()
	defer tw.mu.Unlock()

	// 取出当前槽位的所有任务
	tasks := tw.slots[tw.current]
	tw.slots[tw.current] = nil

	now := time.Now()
	for _, task := range tasks {
		if now.Before(task.ExecuteAt) {
			// 还没到执行时间,重新放入对应槽位
			// 这处理了tick精度问题和多轮时间轮的场景
			remaining := task.ExecuteAt.Sub(now)
			ticks := int(remaining / tw.tick)
			if ticks < 1 {
				ticks = 1
			}
			slot := (tw.current + ticks) % tw.slotsNum
			tw.slots[slot] = append(tw.slots[slot], task)
		} else {
			// 执行任务,用goroutine避免阻塞时间轮
			go task.Callback()
		}
	}

	// 指针前进一步
	tw.current = (tw.current + 1) % tw.slotsNum
}

func (tw *TimeWheel) Stop() {
	close(tw.stopCh)
}

时间轮的精髓在于:用空间换时间。把O(n)的扫描降级为O(1)的槽位定位,海量延迟任务的调度成本极低。Netty的HashedWheelTimer、Kafka的延迟操作都用了这个算法。

4.3 基于Kafka的延迟消息实现

Kafka本身不支持延迟消息(这是RocketMQ的优势),但可以用多级Topic来模拟。思路是:创建多个不同延迟级别的Topic,消息先发到延迟Topic,延迟消费者到时间后转发到目标Topic:

go
// 延迟消息服务
type DelayMessageService struct {
	producer    sarama.SyncProducer
	delayTopics map[int]string // 延迟级别 -> topic名
}

// 预定义延迟级别,类似RocketMQ的设计
// 固定级别比任意延迟更高效,因为不需要排序
var delayLevels = map[int]time.Duration{
	1:  1 * time.Second,
	2:  5 * time.Second,
	3:  10 * time.Second,
	4:  30 * time.Second,
	5:  1 * time.Minute,
	6:  5 * time.Minute,
	7:  10 * time.Minute,
	8:  30 * time.Minute,
	9:  1 * time.Hour,
	10: 2 * time.Hour,
}

func (s *DelayMessageService) SendDelayMessage(topic string, key, value string,
	delay time.Duration) error {
	// 找到最接近的延迟级别
	// 只能向上取整,不能向下(否则延迟不够)
	level := s.findDelayLevel(delay)
	delayTopic := s.delayTopics[level]

	// 将原始topic和消息内容封装到消息头
	// 延迟消费者到时间后根据header转发到目标topic
	msg := &sarama.ProducerMessage{
		Topic: delayTopic,
		Key:   sarama.StringEncoder(key),
		Value: sarama.StringEncoder(value),
		Headers: []sarama.RecordHeader{
			{Key: []byte("targetTopic"), Value: []byte(topic)},
			{Key: []byte("delayLevel"), Value: []byte(fmt.Sprintf("%d", level))},
			{Key: []byte("sendTime"), Value: []byte(time.Now().Format(time.RFC3339Nano))},
		},
	}
	_, _, err := s.producer.SendMessage(msg)
	return err
}

func (s *DelayMessageService) findDelayLevel(delay time.Duration) int {
	for i := 1; i <= 10; i++ {
		if delayLevels[i] >= delay {
			return i
		}
	}
	return 10 // 最大延迟级别
}

// 延迟Topic消费者:到时间后转发到目标Topic
func (s *DelayMessageService) StartDelayConsumer(brokers []string, level int) error {
	topic := s.delayTopics[level]
	delay := delayLevels[level]

	config := sarama.NewConfig()
	config.Version = sarama.V3_5_0_0
	config.Consumer.Offsets.Initial = sarama.OffsetOldest

	consumer, err := sarama.NewConsumer(brokers, config)
	if err != nil {
		return err
	}

	partitions, err := consumer.Partitions(topic)
	if err != nil {
		return err
	}

	for _, partition := range partitions {
		pc, err := consumer.ConsumePartition(topic, partition, sarama.OffsetOldest)
		if err != nil {
			return err
		}

		go func(pc sarama.PartitionConsumer) {
			for msg := range pc.Messages() {
				// 解析发送时间
				sendTimeStr := ""
				targetTopic := ""
				for _, h := range msg.Headers {
					if string(h.Key) == "sendTime" {
						sendTimeStr = string(h.Value)
					}
					if string(h.Key) == "targetTopic" {
						targetTopic = string(h.Value)
					}
				}
				sendTime, _ := time.Parse(time.RFC3339Nano, sendTimeStr)

				// 如果还没到延迟时间,等待
				elapsed := time.Since(sendTime)
				if elapsed < delay {
					time.Sleep(delay - elapsed)
				}

				// 转发到目标Topic
				if targetTopic == "" {
					continue
				}

				forwardMsg := &sarama.ProducerMessage{
					Topic: targetTopic,
					Key:   msg.Key,
					Value: msg.Value,
				}
				s.producer.SendMessage(forwardMsg)
			}
		}(pc)
	}
	return nil
}

RocketMQ的延迟消息就是用类似的多级Topic方案实现的,开源版支持18个固定延迟级别。商业版才支持任意时间延迟,原理是基于时间轮加RocksDB存储。如果你的项目主要用Kafka,上面的方案就能满足大部分延迟消息需求。


五、实战项目:日志收集与监控系统

理论讲够了,现在来干一个完整的实战项目:日志收集与监控系统。这个项目会用到前面讲的所有知识点——Kafka生产者、消费者、批量处理、告警等。

5.1 系统架构设计

在开始写代码之前,先想清楚架构。好的架构设计能让后面的开发事半功倍,差的架构会让你在运维中疲于奔命。

整体架构如下,数据从左到右流动。每个组件都有明确的职责边界,组件之间通过Kafka解耦,任何一层出问题都不会影响其他层:

[应用服务器] -> [日志Agent] -> [Kafka集群] -> [日志消费者] -> [Elasticsearch]
                                                              |
                                                              v
                                                       [查询API服务]
                                                              |
                                                              v
                                                       [告警引擎] -> [通知渠道]
                                                              |
                                                              v
                                                    [Prometheus+Grafana大盘]

技术选型理由,每一项都有明确的Why:

  • 日志采集:自研Go Agent而非Filebeat,因为我们需要对日志内容做预处理(解析JSON、提取字段、打标签),Go的并发模型和低资源占用适合常驻Agent。Filebeat虽然成熟但扩展性差,自定义解析逻辑需要写Lua脚本,调试困难
  • 消息中间件:Kafka,高吞吐、持久化、支持回溯。Kafka的持久化特性意味着即使消费者挂了,消息也不会丢,恢复后可以继续消费。这是日志系统可靠性的基础
  • 日志存储:Elasticsearch,全文搜索能力强,支持复杂聚合查询。ES的倒排索引让关键词搜索极快,聚合分析能力让日志统计变得简单。相比Loki等轻量方案,ES更重但功能更强
  • 查询API:Go + gin,轻量高效。直接对接ES,封装查询接口,前端不需要直接操作ES
  • 告警引擎:Go规则引擎 + 多渠道通知。独立于消费链路,不会因为告警逻辑拖慢日志消费
  • 监控大盘:Prometheus采集指标 + Grafana可视化,业界标准方案。监控指标包括采集速率、消费Lag、ES写入延迟、告警触发次数等

架构设计的关键原则:

  1. 单一职责:每个组件只做一件事,Agent只管采集,Consumer只管消费存储,AlertEngine只管告警
  2. 解耦:组件之间通过Kafka或HTTP通信,任何一层可以独立升级或替换
  3. 可观测:每个组件都暴露Prometheus指标,系统自身的健康状态也要被监控
  4. 容错:任何一层挂了不影响其他层。Agent挂了Kafka消息不增加,Consumer挂了Kafka消息积压但不丢,ES挂了Consumer可以暂停消费

5.2 设计日志收集Agent

日志Agent是整个系统的入口,运行在每台应用服务器上。核心功能:读取日志文件增量内容,解析后发送到Kafka。关键设计点包括文件跟踪、断点续传、多格式解析。Agent必须是轻量的——CPU占用不超过10%,内存占用不超过100MB,不能影响应用本身的运行。

go
package agent

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/IBM/sarama"
	"github.com/hpcloud/tail"
)

// LogEntry 日志条目结构
type LogEntry struct {
	Timestamp string            `json:"timestamp"`
	Level     string            `json:"level"`
	Source    string            `json:"source"`
	Host      string            `json:"host"`
	Path      string            `json:"path"`
	Message   string            `json:"message"`
	Tags      map[string]string `json:"tags,omitempty"`
}

// FileCollector 文件采集器
type FileCollector struct {
	filePath  string
	source    string
	host      string
	tags      map[string]string
	producer  sarama.AsyncProducer
	topic     string
	offsetStore *OffsetStore
}

func NewFileCollector(filePath, source, topic string,
	producer sarama.AsyncProducer, offsetStore *OffsetStore) *FileCollector {
	host, _ := os.Hostname()
	return &FileCollector{
		filePath:    filePath,
		source:      source,
		host:        host,
		tags:        make(map[string]string),
		producer:    producer,
		topic:       topic,
		offsetStore: offsetStore,
	}
}

func (c *FileCollector) Start(ctx context.Context) error {
	// 使用tail库跟踪文件变化
	// tail库底层使用inotify(Linux)或kqueue(macOS)监听文件变化
	// 比轮询高效得多
	tailConfig := tail.Config{
		Follow:    true,                       // 持续跟踪新内容
		ReOpen:    true,                       // 文件被rotate后重新打开
		MustExist: true,                       // 文件必须存在
		Poll:      false,                      // 使用inotify而非轮询
		Location:  &tail.SeekInfo{
			Offset: c.offsetStore.Load(c.filePath),
			Whence: io.SeekStart, // 从上次记录的位置开始
		},
	}

	t, err := tail.TailFile(c.filePath, tailConfig)
	if err != nil {
		return fmt.Errorf("tail file %s failed: %w", c.filePath, err)
	}

	log.Printf("Start collecting: %s from offset %d",
		c.filePath, c.offsetStore.Load(c.filePath))

	var lineCount int64
	lastSave := time.Now()

	for {
		select {
		case line, ok := <-t.Lines:
			if !ok {
				return nil
			}
			if line.Err != nil {
				log.Printf("tail error: %v", line.Err)
				continue
			}
			c.processLine(line.Text)
			lineCount++

			// 定期保存offset,避免重启后重复采集太多
			if time.Since(lastSave) > 5*time.Second {
				pos, err := t.Seek(0, io.SeekCurrent)
				if err == nil {
					c.offsetStore.Save(c.filePath, pos)
				}
				lastSave = time.Now()
			}

		case <-ctx.Done():
			// 退出前保存offset
			pos, err := t.Seek(0, io.SeekCurrent)
			if err == nil {
				c.offsetStore.Save(c.filePath, pos)
			}
			t.Stop()
			return ctx.Err()
		}
	}
}

func (c *FileCollector) processLine(line string) {
	start := time.Now()

	// 解析日志行,支持多种格式
	entry := c.parseLine(line)
	if entry == nil {
		metrics.LogsCollectErrors.WithLabelValues(
			c.source, c.host, "parse_error").Inc()
		return
	}

	// 序列化并发送到Kafka
	data, err := json.Marshal(entry)
	if err != nil {
		metrics.LogsCollectErrors.WithLabelValues(
			c.source, c.host, "marshal_error").Inc()
		return
	}

	msg := &sarama.ProducerMessage{
		Topic: c.topic,
		// 按主机名分区,同一主机的日志进同一分区
		// 这样可以保证同一主机的日志在Kafka中是有序的
		Key:   sarama.StringEncoder(c.host),
		Value: sarama.ByteEncoder(data),
		Timestamp: time.Now(),
		Headers: []sarama.RecordHeader{
			{Key: []byte("source"), Value: []byte(c.source)},
			{Key: []byte("host"), Value: []byte(c.host)},
		},
	}

	// 异步发送,不阻塞采集
	c.producer.Input() <- msg

	// 记录指标
	metrics.LogsCollectedTotal.WithLabelValues(c.source, c.host).Inc()
	metrics.LogsCollectedDuration.WithLabelValues(c.source).
		Observe(time.Since(start).Seconds())
}

// parseLine 支持多种日志格式的解析
func (c *FileCollector) parseLine(line string) *LogEntry {
	entry := &LogEntry{
		Host:    c.host,
		Source:  c.source,
		Path:    c.filePath,
		Tags:    c.tags,
	}

	// 尝试解析JSON格式日志(推荐)
	// 很多现代日志库(如zap、logrus)都支持JSON输出
	if strings.HasPrefix(line, "{") {
		if err := json.Unmarshal([]byte(line), entry); err == nil {
			return entry
		}
	}

	// 尝试解析标准日志格式: 2024-01-15 10:30:00 [INFO] message
	// 这是Go标准库log包的默认格式
	parts := strings.SplitN(line, " ", 4)
	if len(parts) >= 4 {
		entry.Timestamp = parts[0] + " " + parts[1]
		levelPart := strings.Trim(parts[2], "[]")
		entry.Level = levelPart
		entry.Message = parts[3]
	} else {
		// 无法解析格式,整体作为message
		// 至少保证日志不丢
		entry.Timestamp = time.Now().Format("2006-01-02 15:04:05")
		entry.Level = "INFO"
		entry.Message = line
	}

	return entry
}

断点续传实现——这是Agent的关键功能,重启后从上次位置继续读取:

go
// OffsetStore 文件读取位置持久化
type OffsetStore struct {
	path string
	mu   sync.Mutex
}

func NewOffsetStore(path string) *OffsetStore {
	return &OffsetStore{path: path}
}

func (s *OffsetStore) Save(filePath string, offset int64) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	records := s.load()
	records[filePath] = offset

	data, err := json.Marshal(records)
	if err != nil {
		return err
	}
	// 原子写入:先写临时文件再rename
	// 避免写一半进程挂了导致文件损坏
	tmpPath := s.path + ".tmp"
	if err := os.WriteFile(tmpPath, data, 0644); err != nil {
		return err
	}
	return os.Rename(tmpPath, s.path)
}

func (s *OffsetStore) Load(filePath string) int64 {
	records := s.load()
	if offset, ok := records[filePath]; ok {
		return offset
	}
	return 0
}

func (s *OffsetStore) load() map[string]int64 {
	data, err := os.ReadFile(s.path)
	if err != nil {
		return make(map[string]int64)
	}
	var records map[string]int64
	json.Unmarshal(data, &records)
	if records == nil {
		records = make(map[string]int64)
	}
	return records
}

Agent主程序,支持多文件采集和优雅退出:

go
func main() {
	// 加载配置
	config := loadConfig("agent.yaml")

	// 创建Kafka异步生产者
	producer, err := createAsyncProducer(config.Kafka.Brokers)
	if err != nil {
		log.Fatalf("create producer failed: %v", err)
	}
	defer producer.Close()

	// 创建offset存储
	offsetStore := NewOffsetStore(config.Agent.OffsetFile)

	// 启动Prometheus指标端点
	go startMetricsServer(config.Metrics.Port)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// 为每个日志文件启动一个采集器
	var wg sync.WaitGroup
	for _, fileConfig := range config.Files {
		collector := NewFileCollector(
			fileConfig.Path,
			fileConfig.Source,
			config.Kafka.Topic,
			producer,
			offsetStore,
		)
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := collector.Start(ctx); err != nil {
				log.Printf("collector error: %v", err)
			}
		}()
	}

	// 优雅退出
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	<-sigCh
	log.Println("Shutting down agent...")
	cancel()
	wg.Wait()
	log.Println("Agent stopped")
}

日志采集Agent的关键设计点:断点续传。Agent重启后需要从上次读取的位置继续,不能丢数据也不能重复。实现方式是定期把读取到的offset持久化到本地文件。怕浪猫踩过的坑:offset写太频繁导致磁盘IO高,写太少导致重启后重复采集多。5秒一次是经验值。

5.3 实现日志消费与存储

消费端从Kafka读取日志,批量写入Elasticsearch。批量写入是性能的关键——逐条写入ES的QPS只有几百,Bulk批量写入可以达到上万:

go
package consumer

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/IBM/sarama"
	"github.com/elastic/go-elasticsearch/v8"
)

// LogConsumer 日志消费者
type LogConsumer struct {
	esClient   *elasticsearch.Client
	esIndex    string
	batchSize  int
	batch      []*sarama.ConsumerMessage
	batchMu    sync.Mutex
}

func NewLogConsumer(esURLs []string, esIndex string, batchSize int) *LogConsumer {
	esClient, err := elasticsearch.NewClient(elasticsearch.Config{
		URLs:     esURLs,
		Username: "elastic",
		Password: "changeme",
	})
	if err != nil {
		log.Fatalf("create ES client failed: %v", err)
	}

	c := &LogConsumer{
		esClient:  esClient,
		esIndex:   esIndex,
		batchSize: batchSize,
	}

	// 初始化索引模板
	c.ensureIndexTemplate()

	return c
}

// ensureIndexTemplate 创建ES索引模板
// 模板会自动应用到匹配的索引名,统一mapping和settings
func (c *LogConsumer) ensureIndexTemplate() {
	template := `{
		"index_patterns": ["logs-*"],
		"template": {
			"settings": {
				"number_of_shards": 3,
				"number_of_replicas": 1,
				"refresh_interval": "5s"
			},
			"mappings": {
				"properties": {
					"timestamp": {"type": "date"},
					"level": {"type": "keyword"},
					"source": {"type": "keyword"},
					"host": {"type": "keyword"},
					"path": {"type": "keyword"},
					"message": {"type": "text", "analyzer": "ik_max_word"},
					"tags": {"type": "object", "enabled": false}
				}
			}
		}
	}`

	res, err := c.esClient.Indices.PutTemplate(
		"logs-template", strings.NewReader(template))
	if err != nil {
		log.Printf("put index template failed: %v", err)
	}
	res.Body.Close()
}

func (c *LogConsumer) ConsumeClaim(session sarama.ConsumerGroupSession,
	claim sarama.ConsumerGroupClaim) error {
	c.batchMu.Lock()
	c.batch = make([]*sarama.ConsumerMessage, 0, c.batchSize)
	c.batchMu.Unlock()

	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case msg, ok := <-claim.Messages():
			if !ok {
				c.flush()
				return nil
			}

			c.batchMu.Lock()
			c.batch = append(c.batch, msg)
			shouldFlush := len(c.batch) >= c.batchSize
			c.batchMu.Unlock()

			if shouldFlush {
				c.flush()
			}
			session.MarkMessage(msg, "")

		case <-ticker.C:\n\t\t\t// 定时flush,避免低流量时消息积压在batch里\n\t\t\tc.flush()\n\n\t\tcase <-session.Context().Done():
			c.flush()
			return nil
		}
	}
}

// flush 批量写入ES
func (c *LogConsumer) flush() {
	c.batchMu.Lock()
	if len(c.batch) == 0 {
		c.batchMu.Unlock()
		return
	}
	batch := c.batch
	c.batch = make([]*sarama.ConsumerMessage, 0, c.batchSize)
	c.batchMu.Unlock()

	start := time.Now()

	// 使用Bulk API批量写入ES
	// Bulk API格式:每两行一组,第一行是操作指令,第二行是文档内容
	var buf bytes.Buffer
	for _, msg := range batch {
		var entry LogEntry
		if err := json.Unmarshal(msg.Value, &entry); err != nil {
			log.Printf("unmarshal failed: %v", err)
			continue
		}

		// 按日期生成索引名,每天一个索引
		// 便于按时间范围查询和过期清理
		indexName := fmt.Sprintf("logs-%s", time.Now().Format("2006.01.02"))

		// Bulk action line(指定操作和目标索引)
		meta := fmt.Sprintf(`{"index":{"_index":"%s"}}`, indexName)
		buf.WriteString(meta)
		buf.WriteByte('\n')

		// 文档内容
		data, _ := json.Marshal(entry)
		buf.Write(data)
		buf.WriteByte('\n')
	}

	if buf.Len() == 0 {
		return
	}

	batchSize := buf.Len()

	res, err := c.esClient.Bulk(bytes.NewReader(buf.Bytes()))
	if err != nil {
		log.Printf("ES bulk insert failed: %v", err)
		return
	}
	defer res.Body.Close()

	if res.IsError() {
		log.Printf("ES bulk error: %s", res.String())
	}

	// 记录指标
	duration := time.Since(start).Seconds()
	metrics.ESBulkInsertDuration.WithLabelValues(c.esIndex).Observe(duration)
	metrics.ESBulkInsertSize.WithLabelValues(c.esIndex).Observe(float64(batchSize))
	metrics.LogsConsumedTotal.WithLabelValues(c.esIndex, "all").Add(float64(len(batch)))
}

func (c *LogConsumer) Setup(sarama.ConsumerGroupSession) error   { return nil }
func (c *LogConsumer) Cleanup(sarama.ConsumerGroupSession) error { return nil }

ES Bulk API是写入性能的关键。逐条写入ES的QPS大约在500左右,Bulk批量写入可以轻松达到10000以上。批大小建议500到1000条,太大会增加内存压力和GC停顿。怕浪猫在日志高峰期踩过batch太大导致OOM的坑,后来加了byte size限制才解决。

5.4 实现日志查询API

查询API基于gin框架,支持关键词搜索、时间范围过滤、日志级别过滤和分页。这是日志系统面向用户的核心接口:

go
package api

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/elastic/go-elasticsearch/v8"
)

type LogQueryAPI struct {
	es *elasticsearch.Client
}

func NewLogQueryAPI(es *elasticsearch.Client) *LogQueryAPI {
	return &LogQueryAPI{es: es}
}

func (a *LogQueryAPI) RegisterRoutes(r *gin.Engine) {
	api := r.Group("/api/v1/logs")
	{
		api.GET("/search", a.Search)       // 日志搜索
		api.GET("/tail", a.Tail)           // 实时日志流(SSE)
		api.GET("/histogram", a.Histogram) // 日志分布统计
	}
}

// Search 日志搜索
// GET /api/v1/logs/search?q=error&level=ERROR&source=order-service
//     &start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&page=1&size=20
func (a *LogQueryAPI) Search(c *gin.Context) {
	page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
	size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
	if page < 1 {
		page = 1
	}
	if size > 100 {
		size = 100 // 限制最大返回条数,防止大结果集拖慢ES
	}
	from := (page - 1) * size

	// 构建ES查询
	query := a.buildSearchQuery(c, from, size)

	// 搜索所有匹配的日志索引(按日期分索引的模式)
	indexPattern := "logs-*"
	res, err := a.es.Search(
		a.es.Search.WithContext(c.Request.Context()),
		a.es.Search.WithIndex(indexPattern),
		a.es.Search.WithBody(query),
		a.es.Search.WithPretty(),
	)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	defer res.Body.Close()

	var result map[string]interface{}
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// 解析结果
	hits := result["hits"].(map[string]interface{})["hits"].([]interface{})
	total := result["hits"].(map[string]interface{})["total"].
		(map[string]interface{})["value"].(float64)

	logs := make([]map[string]interface{}, 0, len(hits))
	for _, hit := range hits {
		h := hit.(map[string]interface{})
		source := h["_source"].(map[string]interface{})
		source["_id"] = h["_id"]
		source["_index"] = h["_index"]
		logs = append(logs, source)
	}

	c.JSON(http.StatusOK, gin.H{
		"total": int64(total),
		"page":  page,
		"size":  size,
		"logs":  logs,
	})
}

// buildSearchQuery 构建ES查询体
func (a *LogQueryAPI) buildSearchQuery(c *gin.Context, from, size int) *bytes.Reader {
	// 构建bool query
	boolQuery := map[string]interface{}{
		"query": map[string]interface{}{
			"bool": map[string]interface{}{
				"must":   []interface{}{},
				"filter": []interface{}{},
			},
		},
		"from": from,
		"size": size,
		"sort": []map[string]interface{}{
			{"timestamp": map[string]string{"order": "desc"}},
		},
	}

	must := boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["must"].([]interface{})
	filter := boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["filter"].([]interface{})

	// 关键词搜索(全文检索)
	if q := c.Query("q"); q != "" {
		must = append(must, map[string]interface{}{
			"match": map[string]interface{}{
				"message": q,
			},
		})
	}

	// 日志级别过滤(精确匹配)
	if level := c.Query("level"); level != "" {
		filter = append(filter, map[string]interface{}{
			"term": map[string]interface{}{"level": level},
		})
	}

	// 来源过滤
	if source := c.Query("source"); source != "" {
		filter = append(filter, map[string]interface{}{
			"term": map[string]interface{}{"source": source},
		})
	}

	// 时间范围过滤
	timeRange := map[string]interface{}{}
	if start := c.Query("start"); start != "" {
		timeRange["gte"] = start
	}
	if end := c.Query("end"); end != "" {
		timeRange["lte"] = end
	}
	if len(timeRange) > 0 {
		filter = append(filter, map[string]interface{}{
			"range": map[string]interface{}{"timestamp": timeRange},
		})
	}

	boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["must"] = must
	boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["filter"] = filter

	data, _ := json.Marshal(boolQuery)
	return bytes.NewReader(data)
}

// Histogram 日志分布统计
// 返回按时间分桶的日志数量和各级别分布,用于绘制日志趋势图
func (a *LogQueryAPI) Histogram(c *gin.Context) {
	interval := c.DefaultQuery("interval", "minute")

	// 支持的interval: second, minute, hour, day
	validIntervals := map[string]bool{
		"second": true, "minute": true, "hour": true, "day": true,
	}
	if !validIntervals[interval] {
		interval = "minute"
	}

	query := map[string]interface{}{
		"size": 0, // 不返回文档,只要聚合结果
		"query": map[string]interface{}{
			"bool": map[string]interface{}{
				"filter": a.buildTimeFilter(c),
			},
		},
		"aggs": map[string]interface{}{
			"log_over_time": map[string]interface{}{
				"date_histogram": map[string]interface{}{
					"field":             "timestamp",
					"calendar_interval": interval,
				},
				"aggs": map[string]interface{}{
					"levels": map[string]interface{}{
						"terms": map[string]interface{}{
							"field": "level",
						},
					},
				},
			},
		},
	}

	data, _ := json.Marshal(query)
	res, err := a.es.Search(
		a.es.Search.WithContext(c.Request.Context()),
		a.es.Search.WithIndex("logs-*"),
		a.es.Search.WithBody(bytes.NewReader(data)),
	)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	defer res.Body.Close()

	var result map[string]interface{}
	json.NewDecoder(res.Body).Decode(&result)

	c.JSON(http.StatusOK, result)
}

// Tail 实时日志流(Server-Sent Events)
// 前端通过EventSource连接,持续接收最新日志
func (a *LogQueryAPI) Tail(c *gin.Context) {
	c.Header("Content-Type", "text/event-stream")
	c.Header("Cache-Control", "no-cache")
	c.Header("Connection", "keep-alive")

	flusher, ok := c.Writer.(http.Flusher)
	if !ok {
		c.JSON(http.StatusInternalServerError,
			gin.H{"error": "streaming not supported"})
		return
	}

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	var lastTimestamp time.Time

	for {
		select {
		case <-c.Request.Context().Done():
			return
		case <-ticker.C:\n\t\t\tlogs := a.fetchLatestLogs(lastTimestamp)
			for _, logEntry := range logs {
				data, _ := json.Marshal(logEntry)
				fmt.Fprintf(c.Writer, "data: %s\n\n", data)
				flusher.Flush()
			}
			if len(logs) > 0 {
				lastTimestamp = time.Now()
			}
		}
	}
}

func (a *LogQueryAPI) fetchLatestLogs(after time.Time) []map[string]interface{} {
	query := map[string]interface{}{
		"size": 50,
		"query": map[string]interface{}{
			"range": map[string]interface{}{
				"timestamp": map[string]interface{}{
					"gt": after.Format(time.RFC3339Nano),
				},
			},
		},
		"sort": []map[string]interface{}{
			{"timestamp": map[string]string{"order": "asc"}},
		},
	}

	data, _ := json.Marshal(query)
	res, err := a.es.Search(
		a.es.Search.WithIndex("logs-*"),
		a.es.Search.WithBody(bytes.NewReader(data)),
	)
	if err != nil {
		return nil
	}
	defer res.Body.Close()

	var result map[string]interface{}
	json.NewDecoder(res.Body).Decode(&result)

	hits, ok := result["hits"].(map[string]interface{})["hits"].([]interface{})
	if !ok {
		return nil
	}

	logs := make([]map[string]interface{}, 0, len(hits))
	for _, hit := range hits {
		h := hit.(map[string]interface{})
		logs = append(logs, h["_source"].(map[string]interface{}))
	}
	return logs
}

查询API的性能瓶颈通常在ES。记得给timestamp、level、source等常用过滤字段建好mapping和索引,避免动态mapping导致的类型推断错误。对于高频查询,加一层Redis缓存,命中率80%以上时查询延迟能降一个数量级。

5.5 实现告警引擎

告警引擎定期查询ES中的日志,匹配预设规则,触发后通过多种渠道通知。这是把"被动排查"变成"主动发现"的核心组件:

go
package alert

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"sync"
	"time"

	"github.com/elastic/go-elasticsearch/v8"
)

// Rule 告警规则定义
type Rule struct {
	ID          string        `json:"id"`
	Name        string        `json:"name"`
	Description string        `json:"description"`
	Enabled     bool          `json:"enabled"`
	// 查询条件
	IndexPattern string        `json:"indexPattern"`
	Level        string        `json:"level"`     // 日志级别过滤
	Keyword      string        `json:"keyword"`   // 关键词匹配
	// 触发条件
	Threshold    int           `json:"threshold"`  // 匹配数量阈值
	TimeWindow   time.Duration `json:"timeWindow"` // 时间窗口
	// 通知配置
	Channels     []Channel     `json:"channels"`
	// 运行时状态
	lastFired    time.Time
	mu           sync.Mutex
}

// Channel 通知渠道
type Channel struct {
	Type   string            `json:"type"`   // webhook, dingtalk, feishu, email
	Config map[string]string `json:"config"`
}

// AlertEngine 告警引擎
type AlertEngine struct {
	es       *elasticsearch.Client
	rules    []*Rule
	stopCh   chan struct{}
	aggregator *AlertAggregator // 告警聚合器
}

func NewAlertEngine(es *elasticsearch.Client) *AlertEngine {
	return &AlertEngine{
		es:         es,
		stopCh:     make(chan struct{}),
		aggregator: NewAlertAggregator(5 * time.Minute),
	}
}

func (e *AlertEngine) AddRule(rule *Rule) {
	e.rules = append(e.rules, rule)
}

func (e *AlertEngine) Start() {
	log.Println("Alert engine started")
	// 每30秒检查一次规则
	// 间隔太短会给ES造成压力,太长会导致告警延迟
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:\n\t\t\te.checkAllRules()\n\t\tcase <-e.stopCh:
			log.Println("Alert engine stopped")
			return
		}
	}
}

func (e *AlertEngine) Stop() {
	close(e.stopCh)
}

// checkAllRules 并行检查所有规则
func (e *AlertEngine) checkAllRules() {
	var wg sync.WaitGroup
	for _, rule := range e.rules {
		if !rule.Enabled {
			continue
		}
		wg.Add(1)
		go func(r *Rule) {
			defer wg.Done()
			e.checkRule(r)
		}(rule)
	}
	wg.Wait()
}

func (e *AlertEngine) checkRule(rule *Rule) {
	rule.mu.Lock()
	defer rule.mu.Unlock()

	// 检查冷却期,避免频繁告警
	// 同一规则在TimeWindow内只触发一次
	if time.Since(rule.lastFired) < rule.TimeWindow {
		return
	}

	// 构建ES查询,统计时间窗口内匹配的日志数量
	query := e.buildRuleQuery(rule)

	res, err := e.es.Search(
		e.es.Search.WithIndex(rule.IndexPattern),
		e.es.Search.WithBody(query),
		e.es.Search.WithSize(0), // 只需要count,不需要文档
	)
	if err != nil {
		log.Printf("rule %s query failed: %v", rule.ID, err)
		return
	}
	defer res.Body.Close()

	var result map[string]interface{}
	json.NewDecoder(res.Body).Decode(&result)

	total, ok := result["hits"].(map[string]interface{})["total"].
		(map[string]interface{})["value"].(float64)
	if !ok {
		return
	}

	// 超过阈值,触发告警
	if int(total) >= rule.Threshold {
		alert := Alert{
			RuleName:    rule.Name,
			Description: rule.Description,
			Count:       int(total),
			Threshold:   rule.Threshold,
			TimeWindow:  rule.TimeWindow.String(),
			FiredAt:     time.Now(),
		}

		// 获取匹配的样本日志,附在告警里方便排查
		samples := e.getSampleLogs(rule, 5)
		alert.Samples = samples

		// 告警聚合检查:避免告警风暴
		shouldSend, state := e.aggregator.ShouldSend(rule.ID, rule.TimeWindow)
		if shouldSend {
			alert.AggregatedCount = state.Count
			// 发送通知到所有配置的渠道
			for _, channel := range rule.Channels {
				if err := e.sendNotification(channel, alert); err != nil {
					metrics.AlertNotificationErrors.WithLabelValues(
						rule.ID, channel.Type).Inc()
					log.Printf("send notification failed: %v", err)
				}
			}
			metrics.AlertsFiredTotal.WithLabelValues(
				rule.ID, rule.Name).Inc()
		}

		rule.lastFired = time.Now()
		log.Printf("Alert fired: %s (count=%d, threshold=%d, aggregated=%d)",
			rule.Name, int(total), rule.Threshold, state.Count)
	}
}

func (e *AlertEngine) buildRuleQuery(rule *Rule) *bytes.Reader {
	boolQuery := map[string]interface{}{
		"query": map[string]interface{}{
			"bool": map[string]interface{}{
				"filter": []interface{}{
					map[string]interface{}{
						"range": map[string]interface{}{
							"timestamp": map[string]interface{}{
								"gte": fmt.Sprintf("now-%ds",
									int(rule.TimeWindow.Seconds())),
							},
						},
					},
				},
			},
		},
		"size": 0,
	}

	filters := boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["filter"].([]interface{})

	if rule.Level != "" {
		filters = append(filters, map[string]interface{}{
			"term": map[string]interface{}{"level": rule.Level},
		})
	}

	if rule.Keyword != "" {
		filters = append(filters, map[string]interface{}{
			"match": map[string]interface{}{"message": rule.Keyword},
		})
	}

	boolQuery["query"].(map[string]interface{})["bool"].
		(map[string]interface{})["filter"] = filters

	data, _ := json.Marshal(boolQuery)
	return bytes.NewReader(data)
}

// Alert 告警事件
type Alert struct {
	RuleName        string                   `json:"ruleName"`
	Description     string                   `json:"description"`
	Count           int                      `json:"count"`
	Threshold       int                      `json:"threshold"`
	TimeWindow      string                   `json:"timeWindow"`
	FiredAt         time.Time                `json:"firedAt"`
	Samples         []map[string]interface{} `json:"samples"`
	AggregatedCount int                      `json:"aggregatedCount"`
}

// sendNotification 根据渠道类型发送通知
func (e *AlertEngine) sendNotification(channel Channel, alert Alert) error {
	switch channel.Type {
	case "dingtalk":
		return e.sendDingTalk(channel.Config["webhook"], alert)
	case "feishu":
		return e.sendFeishu(channel.Config["webhook"], alert)
	case "webhook":
		return e.sendWebhook(channel.Config["url"], alert)
	case "email":
		return e.sendEmail(channel.Config["to"], alert)
	default:
		return fmt.Errorf("unknown channel type: %s", channel.Type)
	}
}

// sendDingTalk 钉钉机器人通知
func (e *AlertEngine) sendDingTalk(webhook string, alert Alert) error {
	message := fmt.Sprintf("【告警】%s\n\n描述: %s\n触发条件: %d次/%s\n当前值: %d\n"+
		"聚合次数: %d\n触发时间: %s\n\n请尽快处理!",
		alert.RuleName,
		alert.Description,
		alert.Threshold,
		alert.TimeWindow,
		alert.Count,
		alert.AggregatedCount,
		alert.FiredAt.Format("2006-01-02 15:04:05"),
	)

	payload := map[string]interface{}{
		"msgtype": "text",
		"text":    map[string]string{"content": message},
		"at": map[string]interface{}{
			"isAtAll": true, // @所有人,生产环境建议按需@值班人
		},
	}

	data, _ := json.Marshal(payload)
	resp, err := http.Post(webhook, "application/json", bytes.NewReader(data))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

// sendFeishu 飞书机器人通知(富文本卡片)
func (e *AlertEngine) sendFeishu(webhook string, alert Alert) error {
	card := map[string]interface{}{
		"msg_type": "interactive",
		"card": map[string]interface{}{
			"header": map[string]interface{}{
				"title": map[string]interface{}{
					"tag":     "plain_text",
					"content": fmt.Sprintf("告警: %s", alert.RuleName),
				},
				"template": "red", // 红色卡片表示严重告警
			},
			"elements": []interface{}{
				map[string]interface{}{
					"tag": "div",
					"text": map[string]interface{}{
						"tag": "lark_md",
						"content": fmt.Sprintf(
							"**描述**: %s\n**阈值**: %d次/%s\n**实际**: %d\n"+
								"**聚合**: %d\n**时间**: %s",
							alert.Description,
							alert.Threshold,
							alert.TimeWindow,
							alert.Count,
							alert.AggregatedCount,
							alert.FiredAt.Format("2006-01-02 15:04:05"),
						),
					},
				},
				// 如果有样本日志,展示前几条
				map[string]interface{}{
					"tag": "div",
					"text": map[string]interface{}{
						"tag": "lark_md",
						"content": fmt.Sprintf("**样本日志**: 共%d条,详情见附件",
							len(alert.Samples)),
					},
				},
			},
		},
	}

	data, _ := json.Marshal(card)
	resp, err := http.Post(webhook, "application/json", bytes.NewReader(data))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

func (e *AlertEngine) sendWebhook(url string, alert Alert) error {
	data, _ := json.Marshal(alert)
	resp, err := http.Post(url, "application/json", bytes.NewReader(data))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	return nil
}

func (e *AlertEngine) sendEmail(to string, alert Alert) error {
	// 邮件通知实现省略,可以用gomail等库
	return nil
}

// getSampleLogs 获取匹配规则的样本日志
func (e *AlertEngine) getSampleLogs(rule *Rule, size int) []map[string]interface{} {
	query := e.buildRuleQuery(rule)
	var m map[string]interface{}
	json.NewDecoder(query).Decode(&m)
	m["size"] = size
	m["sort"] = []map[string]interface{}{
		{"timestamp": map[string]string{"order": "desc"}},
	}
	data, _ := json.Marshal(m)

	res, err := e.es.Search(
		e.es.Search.WithIndex(rule.IndexPattern),
		e.es.Search.WithBody(bytes.NewReader(data)),
	)
	if err != nil {
		return nil
	}
	defer res.Body.Close()

	var result map[string]interface{}
	json.NewDecoder(res.Body).Decode(&result)

	hits, ok := result["hits"].(map[string]interface{})["hits"].([]interface{})
	if !ok {
		return nil
	}

	samples := make([]map[string]interface{}, 0, len(hits))
	for _, hit := range hits {
		h := hit.(map[string]interface{})
		samples = append(samples, h["_source"].(map[string]interface{}))
	}
	return samples
}

// AlertAggregator 告警聚合器
// 解决告警风暴问题:同一规则在冷却期内只发一次通知
type AlertAggregator struct {
	cache map[string]*AlertState
	mu    sync.Mutex
	ttl   time.Duration
}

type AlertState struct {
	FirstFired time.Time
	Count      int
	LastSent   time.Time
}

func NewAlertAggregator(ttl time.Duration) *AlertAggregator {
	return &AlertAggregator{
		cache: make(map[string]*AlertState),
		ttl:   ttl,
	}
}

func (a *AlertAggregator) ShouldSend(ruleID string,
	cooldown time.Duration) (bool, *AlertState) {
	a.mu.Lock()
	defer a.mu.Unlock()

	state, exists := a.cache[ruleID]
	if !exists {
		a.cache[ruleID] = &AlertState{
			FirstFired: time.Now(),
			Count:      1,
			LastSent:   time.Now(),
		}
		return true, a.cache[ruleID]
	}

	state.Count++
	if time.Since(state.LastSent) >= cooldown {
		state.LastSent = time.Now()
		return true, state
	}
	return false, state
}

告警规则配置示例:

go
func setupAlertRules(engine *AlertEngine) {
	// 规则1: ERROR日志5分钟内超过10条
	// 适用于所有服务,可能是某个服务出了问题
	engine.AddRule(&Rule{
		ID:           "error-spike",
		Name:         "ERROR日志激增",
		Description:  "5分钟内ERROR级别日志超过10条,可能存在服务异常",
		Enabled:      true,
		IndexPattern: "logs-*",
		Level:        "ERROR",
		Threshold:    10,
		TimeWindow:   5 * time.Minute,
		Channels: []Channel{
			{
				Type:   "dingtalk",
				Config: map[string]string{
					"webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
				},
			},
		},
	})

	// 规则2: panic关键词出现立即告警
	// panic意味着程序崩溃,需要立即处理
	engine.AddRule(&Rule{
		ID:           "panic-detected",
		Name:         "Panic检测",
		Description:  "检测到panic关键字,程序可能崩溃",
		Enabled:      true,
		IndexPattern: "logs-*",
		Keyword:      "panic",
		Threshold:    1,
		TimeWindow:   1 * time.Minute,
		Channels: []Channel{
			{Type: "feishu", Config: map[string]string{
				"webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"}},
			{Type: "webhook", Config: map[string]string{
				"url": "https://internal.example.com/alert/panic"}},
		},
	})

	// 规则3: 某服务连接数据库失败
	engine.AddRule(&Rule{
		ID:           "db-connection-failed",
		Name:         "数据库连接失败",
		Description:  "order-service连接数据库失败,可能影响下单",
		Enabled:      true,
		IndexPattern: "logs-*",
		Keyword:      "database connection refused",
		Threshold:    3,
		TimeWindow:   2 * time.Minute,
		Channels: []Channel{
			{Type: "dingtalk", Config: map[string]string{
				"webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx"}},
			{Type: "feishu", Config: map[string]string{
				"webhook": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"}},
		},
	})
}

告警引擎最大的敌人是告警风暴。一条服务抖动可能在1分钟内触发上百条告警。必须设置告警冷却期和聚合策略,否则告警本身就会成为故障——你会花时间处理告警通知而不是处理故障。

5.6 实现监控大盘:Prometheus + Grafana集成

日志系统自身的运行状态也需要监控。你要知道Agent有没有在采集、Consumer的Lag有多大、ES写入延迟是否正常。这需要暴露指标到Prometheus,再用Grafana可视化。

首先定义指标——每个指标都有明确的含义和标签维度:

go
package metrics

import (
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
)

var (
	// ===== Agent指标 =====
	LogsCollectedTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "log_agent_collected_total",
			Help: "Total number of log lines collected",
		},
		[]string{"source", "host"},
	)

	LogsCollectErrors = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "log_agent_collect_errors_total",
			Help: "Total number of collection errors",
		},
		[]string{"source", "host", "error_type"},
	)

	LogsCollectedDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "log_agent_collect_duration_seconds",
			Help:    "Time spent collecting and sending logs",
			Buckets: prometheus.ExponentialBuckets(0.001, 2, 10),
		},
		[]string{"source"},
	)

	// ===== Consumer指标 =====
	LogsConsumedTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "log_consumer_consumed_total",
			Help: "Total number of log entries consumed from Kafka",
		},
		[]string{"topic", "partition"},
	)

	LogsConsumedErrors = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "log_consumer_errors_total",
			Help: "Total number of consumption errors",
		},
		[]string{"topic", "error_type"},
	)

	// ConsumerLag是最关键的运维指标
	// Lag持续增长说明消费速度跟不上生产速度
	ConsumerLag = promauto.NewGaugeVec(
		prometheus.GaugeOpts{
			Name: "log_consumer_lag",
			Help: "Consumer lag (number of messages behind)",
		},
		[]string{"topic", "partition", "consumer_group"},
	)

	// ===== ES写入指标 =====
	ESBulkInsertDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "es_bulk_insert_duration_seconds",
			Help:    "Time spent on ES bulk insert",
			Buckets: prometheus.ExponentialBuckets(0.01, 2, 10),
		},
		[]string{"index"},
	)

	ESBulkInsertSize = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "es_bulk_insert_size_bytes",
			Help:    "Size of ES bulk insert payload",
			Buckets: prometheus.ExponentialBuckets(100, 2, 15),
		},
		[]string{"index"},
	)

	// ===== 告警引擎指标 =====
	AlertsFiredTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "alert_engine_fired_total",
			Help: "Total number of alerts fired",
		},
		[]string{"rule_id", "rule_name"},
	)

	AlertNotificationErrors = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "alert_notification_errors_total",
			Help: "Total number of notification errors",
		},
		[]string{"rule_id", "channel_type"},
	)
)

Consumer Lag监控——这是Kafka运维最重要的指标。Lag持续增长说明消费速度跟不上生产速度,最终会导致消息延迟越来越大:

go
func MonitorConsumerLag(client sarama.Client, topic, groupID string) {
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		partitions, err := client.Partitions(topic)
		if err != nil {
			continue
		}

		for _, partition := range partitions {
			// 获取分区最新offset(生产者写入的位置)
			latestOffset, err := client.GetOffset(topic, partition, sarama.OffsetNewest)
			if err != nil {
				continue
			}

			// 获取消费者组当前提交的offset
			offsetManager, err := sarama.NewOffsetManagerFromClient(groupID, client)
			if err != nil {
				continue
			}
			partitionOffsetManager, err := offsetManager.ManagePartition(topic, partition)
			if err != nil {
				offsetManager.Close()
				continue
			}
			consumedOffset, _ := partitionOffsetManager.NextOffset()
			partitionOffsetManager.Close()
			offsetManager.Close()

			lag := latestOffset - consumedOffset
			if lag < 0 {
				lag = 0
			}

			// 设置Gauge指标
			ConsumerLag.WithLabelValues(
				topic,
				fmt.Sprintf("%d", partition),
				groupID,
			).Set(float64(lag))
		}
	}
}

暴露Prometheus指标端点:

go
package main

import (
	"net/http"

	"github.com/prometheus/client_golang/prometheus/promhttp"
)

func startMetricsServer(port string) {
	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.Handler())
	go func() {
		log.Printf("Metrics server listening on :%s", port)
		if err := http.ListenAndServe(":"+port, mux); err != nil {
			log.Printf("metrics server error: %v", err)
		}
	}()
}

Grafana仪表盘配置,以下JSON可以直接导入Grafana:

json
{
  "dashboard": {
    "title": "日志监控系统大盘",
    "tags": ["logging", "kafka", "elasticsearch"],
    "timezone": "browser",
    "panels": [
      {
        "title": "日志采集速率 (lines/s)",
        "type": "graph",
        "datasource": "Prometheus",
        "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
        "targets": [{
          "expr": "rate(log_agent_collected_total[1m])",
          "legendFormat": "{{source}} - {{host}}"
        }],
        "yAxes": [{"label": "lines/s"}]
      },
      {
        "title": "Kafka消费Lag",
        "type": "graph",
        "datasource": "Prometheus",
        "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
        "targets": [{
          "expr": "log_consumer_lag",
          "legendFormat": "partition {{partition}}"
        }],
        "alert": {
          "name": "Consumer Lag High",
          "conditions": [{
            "type": "query",
            "query": {"params": ["A", "5m", "now"]},
            "evaluator": {"params": [10000], "type": "gt"},
            "operator": {"type": "and"}
          }]
        }
      },
      {
        "title": "ES写入延迟",
        "type": "graph",
        "datasource": "Prometheus",
        "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
        "targets": [{
          "expr": "rate(es_bulk_insert_duration_seconds_sum[5m]) / rate(es_bulk_insert_duration_seconds_count[5m])",
          "legendFormat": "avg duration"
        }]
      },
      {
        "title": "告警触发次数 (1h)",
        "type": "stat",
        "datasource": "Prometheus",
        "gridPos": {"h": 4, "w": 6, "x": 12, "y": 8},
        "targets": [{
          "expr": "increase(alert_engine_fired_total[1h])",
          "legendFormat": "{{rule_name}}"
        }],
        "fieldConfig": {
          "defaults": {"thresholds": {
            "mode": "absolute",
            "steps": [
              {"color": "green", "value": null},
              {"color": "yellow", "value": 1},
              {"color": "red", "value": 10}
            ]
          }}
        }
      },
      {
        "title": "采集错误率",
        "type": "graph",
        "datasource": "Prometheus",
        "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
        "targets": [{
          "expr": "rate(log_agent_collect_errors_total[5m])",
          "legendFormat": "{{source}} - {{error_type}}"
        }]
      }
    ],
    "time": {"from": "now-6h", "to": "now"},
    "refresh": "30s"
  }
}

监控大盘的价值不在于好看,在于缩短MTTR(平均故障恢复时间)。当告警来了,你第一眼看到的就是哪个环节出了问题——是Agent没采集、Kafka积压了、还是ES写入慢了——而不是满世界查日志。

5.7 系统部署与运维

部署架构推荐使用Docker Compose,开发和测试环境一键启动:

yaml
version: '3.8'

services:
  kafka:
    image: bitnami/kafka:3.5
    ports:
      - "9092:9092"
    environment:
      KAFKA_CFG_NODE_ID: 0
      KAFKA_CFG_PROCESS_ROLES: controller,broker
      KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://127.0.0.1:9092
      KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
      KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
    volumes:
      - kafka_data:/bitnami/kafka

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    ports:
      - "9200:9200"
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    volumes:
      - es_data:/usr/share/elasticsearch/data

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    depends_on:
      - prometheus

volumes:
  kafka_data:
  es_data:

Prometheus配置文件:

yaml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'log-agent'
    static_configs:
      - targets: ['host.docker.internal:2112']
        labels:
          service: 'log-agent'

  - job_name: 'log-consumer'
    static_configs:
      - targets: ['host.docker.internal:2113']
        labels:
          service: 'log-consumer'

  - job_name: 'alert-engine'
    static_configs:
      - targets: ['host.docker.internal:2114']
        labels:
          service: 'alert-engine'

5.8 压测与优化

系统搭建完成后,怕浪猫做了一轮压测。压测不是可选项而是必选项——你以为系统能扛10万QPS,实际可能只扛了2万。生产环境的流量峰值永远比你预想的猛,不做压测就上线等于裸奔。压测要模拟真实场景:真实的日志格式、真实的流量曲线、真实的查询并发。

压测环境:3台4C8G机器,Kafka 3节点,ES单节点。这个配置接近生产环境的中等规模,压测结果有参考价值。

压测方法:用Go写了一个日志生成器,按不同速率向日志文件写入模拟日志(包含JSON格式和纯文本格式),同时模拟多用户并发查询。压测持续30分钟,观察各组件的CPU、内存、IO和网络指标。

压测结果

组件指标数值备注
Agent采集单机吞吐15,000 lines/s使用inotify
Agent采集CPU占用约15% (1核)含序列化
Agent采集内存占用约80MB含Kafka缓冲
Kafka生产TPS50,000 msg/sLZ4压缩
Kafka消费TPS40,000 msg/s批量消费
ES写入Bulk QPS12,000 docs/sbatch=500
ES查询关键词搜索50ms100万文档
ES查询范围+聚合200ms100万文档
告警引擎规则检查延迟小于2s10条规则

优化经验清单——这些是压测过程中发现并解决的问题:

  1. Agent侧优化:

    • 文件tail使用inotify而非轮询,CPU降低80%。轮询模式下CPU占用高达60%,换成inotify后降到15%
    • Kafka生产者开启LZ4压缩,网络带宽减少60%。在千兆网卡环境下,无压缩时网络是瓶颈,开启压缩后网络利用率从95%降到35%
    • 批量发送间隔设为200ms,吞吐量提升5倍。逐条发送时TPS只有3000,批量后达到15000
    • 异步生产者替代同步生产者,发送不再阻塞采集。同步模式下采集速度受限于Kafka确认速度
  2. Consumer侧优化:

    • 批量消费100条一批,ES写入TPS提升12倍。逐条写入ES时TPS只有1000,Bulk批量后达到12000
    • ES refresh_interval从1s改为5s,写入性能提升30%。ES默认1秒刷新一次索引让文档可搜索,但日志场景不需要这么高的实时性
    • 消费者goroutine数等于分区数,最大化并行度。多出的goroutine会空闲,少了则浪费分区并行能力
    • ES索引按天分,查询时按时间范围路由,减少扫描量。查询7天数据只扫7个索引而不是一个巨大的索引
  3. 查询侧优化:

    • 热查询加Redis缓存,命中率80%时查询延迟降90%。常见查询如"最近1小时ERROR日志"可以缓存30秒
    • 对message字段使用ik_max_word分词器,中文搜索效果更好。默认分词器对中文支持差,搜"订单"搜不到"下单流程"
    • 限制单次查询最大返回100条,防止大结果集拖慢ES。用户要看更多数据用分页,不要一次查几千条

压测不是可选项。你以为系统能扛10万QPS,实际可能只扛了2万。生产环境的流量峰值永远比你预想的猛,不做压测就上线等于裸奔。


六、踩坑总结

这一章的项目,怕浪猫在生产环境实际跑过两年。两年里大大小小的故障出过十几次,每次都让人刻骨铭心。这里挑几个最有代表性的坑分享出来,每个都是用故障换来的教训。希望你看完之后能在自己的项目中提前规避,不要等生产事故来教你。

坑1:Kafka消费者Rebalance导致重复消费

场景:消费者处理消息耗时较长,超过session.timeout,触发Rebalance,分区被重新分配。新消费者从上次提交的offset开始消费,导致一批消息被重复处理。某次导致2000个订单被重复处理,花了两天时间修数据。当时消费者在处理消息时调用了外部库存接口,接口响应慢,单条消息处理时间超过了30秒的session timeout。

根因分析:Sarama的消费者组通过心跳维持成员关系。如果消费者处理消息时阻塞太久,没有及时发送心跳,Coordinator会认为这个消费者挂了,触发Rebalance。Rebalance后分区被重新分配,新的消费者从上次提交的offset开始消费,但原来的消费者可能还在处理之前的消息,导致重复处理。

解法有三个层面:

  1. 调大session timeout,给消费者更多处理时间
  2. 使用CooperativeRebalanceStrategy,减少Rebalance影响范围
  3. 把耗时操作异步化,消息处理只做入队,后台worker慢慢处理
go
// 使用Cooperative策略,只rebalance变化的分区
// 传统的Eager策略会暂停所有消费者,Cooperative只暂停受影响的分区
config.Consumer.Group.Rebalance.GroupStrategies = []sarama.BalanceStrategy{
    sarama.NewBalanceStrategyCooperativeSticky(),
}
config.Consumer.Group.Session.Timeout = 30 * time.Second
// 处理消息的goroutine要控制并发,避免处理时间超过session timeout

坑2:ES Bulk写入OOM

场景:批量写入时batch size设太大(10000条),单条日志平均2KB,一次Bulk请求20MB。高流量时段内存暴涨触发OOM,消费者进程被kill。更麻烦的是Kafka消费offset已经提交了,但ES没写进去,这批日志彻底丢了。

根因分析:batch size只限制了条数没限制字节数。如果日志内容很长(比如包含完整请求和响应体),单条日志可能几十KB,10000条就是几百MB。Go的GC在这种大对象分配上表现不好,容易导致内存暴涨。

解法:限制batch的byte size而非只限制count;使用背压机制,ES写入慢时减慢消费速度;对日志内容做截断,单条日志超过10KB只保留前10KB。

go
const maxBatchBytes = 5 * 1024 * 1024 // 5MB上限

func (c *LogConsumer) flush() {
	// 检查batch总大小,超过上限则分批写入
	totalBytes := 0
	for _, msg := range c.batch {
		totalBytes += len(msg.Value)
	}
	if totalBytes > maxBatchBytes {
		// 分批写入,每批不超过maxBatchBytes
		c.flushInChunks(maxBatchBytes)
		return
	}
	c.flushAll()
}

坑3:告警风暴

场景:一个核心服务异常,ERROR日志暴增,30秒内触发了200多条告警,钉钉群被刷屏,反而看不到关键信息。值班同学直接把钉钉群静音了,结果后面的真正重要的告警也被淹没了。这个问题的影响比日志积压还严重——积压至少能发现,告警被静音后你可能完全不知道出了问题。

根因分析:告警引擎每30秒检查一次规则,每次检查都发通知。没有聚合和冷却机制,导致同一规则的告警反复发送。

解法:告警聚合加冷却期加分级路由。同一规则的告警5分钟内只发一次,附带聚合统计("过去5分钟触发了200次");P0级别走电话通知,P1走钉钉,P2走邮件。不同级别的告警走不同渠道,确保重要告警不被淹没。

坑4:日志文件被rotate时丢数据

场景:应用使用logrotate切割日志,Agent的tail在文件被rotate时丢失了最后的几行。日志量少的时候没发现,日志量大时每次rotate都丢几百行。某次排查线上问题发现刚好缺了故障发生时间段的日志,因为那个时间点logrotate正好在切割日志。

根因分析:logrotate默认使用create模式——先重命名旧文件,再创建新文件。tail库跟踪的是文件描述符,文件被重命名后,tail继续读旧文件(已重命名),新文件不会被自动跟踪。虽然tail库配置了ReOpen=true,但在检测到文件变化和重新打开之间有一个短暂的窗口期,这期间写入的日志可能丢失。

解法:配置logrotate使用copytruncate模式——先复制旧文件,再清空原文件。原文件的inode不变,tail不受影响。代价是复制期间有一次完整的文件拷贝IO。另一个方案是Agent同时监听新旧文件名,但这需要Agent感知logrotate的命名规则。

坑5:Sarama AsyncProducer死锁

场景:业务高峰期AsyncProducer突然不再发送消息,程序不报错也不退出。排查发现Successes channel满了(配置了Return.Successes=true但没消费Successes channel),导致Input channel也满了,所有发送操作阻塞。更隐蔽的是,这个阻塞不会触发任何错误或告警,程序看起来正常运行但其实已经停止工作。

根因分析:Sarama的AsyncProducer内部使用channel传递消息。Input channel写入消息,Successes和Errors channel返回结果。如果配置了Return.Successes=true但没有goroutine消费Successes channel,当内部缓冲区满后,Input channel的写入会阻塞,进而导致所有调用发送的代码阻塞。

解法:如果不需要逐条确认成功,就设Return.Successes=false(默认就是false);如果需要成功回调,必须用goroutine持续消费Successes channel。生产环境的最佳实践是无论是否需要成功回调,都启动goroutine消费Successes和Errors channel,前者用于更新指标,后者用于错误处理和重试。

每一个坑都是用故障换来的经验。你在设计阶段多想一步,生产环境就少一次半夜被叫起来。怕浪猫的建议:上面这些坑,在设计阶段就提前规避,不要等生产事故来教你。最好的故障是别人的故障,第二好的是已经预防的故障,最差的是自己亲身经历的故障。


七、完整的系统组装

最后,把所有组件组装到一起。main函数就是整个系统的入口,负责初始化所有组件并管理生命周期。实际生产中,Agent、Consumer、API、AlertEngine可以部署在同一进程中(适合小规模部署),也可以拆分成独立服务(适合大规模部署)。这里展示的是单进程模式,方便理解各组件之间的关系:

go
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/IBM/sarama"
	"github.com/elastic/go-elasticsearch/v8"
)

func main() {
	// 加载配置
	config := LoadConfig("config.yaml")
	log.Printf("Starting log system with config: %+v", config)

	// 启动Prometheus指标端点
	// 每个组件都通过这个端点暴露自己的运行指标
	go startMetricsServer(config.Metrics.Port)

	// 创建Kafka异步生产者
	// Agent用这个生产者把日志发送到Kafka
	producer := createAsyncProducer(config.Kafka.Brokers)
	defer producer.Close()

	// 创建ES客户端
	// Consumer和AlertEngine都需要操作ES
	esClient, _ := elasticsearch.NewClient(elasticsearch.Config{
		URLs: config.ES.URLs,
	})

	// 创建offset存储
	// Agent用它在本地持久化文件读取位置,支持断点续传
	offsetStore := NewOffsetStore(config.Agent.OffsetFile)

	// 启动日志采集Agent
	// 为每个配置的日志文件启动一个采集goroutine
	ctx, cancel := context.WithCancel(context.Background())
	for _, fileCfg := range config.Agent.Files {
		collector := NewFileCollector(
			fileCfg.Path, fileCfg.Source,
			config.Kafka.Topic, producer, offsetStore,
		)
		go collector.Start(ctx)
	}

	// 启动Kafka消费者,消费日志写入ES
	// 消费者使用消费者组模式,支持多实例水平扩展
	go func() {
		consumer := NewLogConsumer(
			config.ES.URLs, "logs", config.Consumer.BatchSize,
		)
		StartConsumerGroup(
			config.Kafka.Brokers,
			config.Kafka.Topic,
			config.Kafka.ConsumerGroup,
			consumer,
		)
	}()

	// 启动告警引擎
	// 独立goroutine运行,定期查询ES检查告警规则
	alertEngine := NewAlertEngine(esClient)
	setupAlertRules(alertEngine)
	go alertEngine.Start()

	// 启动查询API
	// 对外提供日志搜索、实时流、统计聚合接口
	go startQueryAPI(esClient, config.API.Port)

	// 启动Consumer Lag监控
	// 这是最重要的运维指标,Lag持续增长意味着消费速度跟不上生产速度
	kafkaClient := createKafkaClient(config.Kafka.Brokers)
	go MonitorConsumerLag(kafkaClient, config.Kafka.Topic,
		config.Kafka.ConsumerGroup)

	log.Println("Log system fully started")

	// 优雅退出
	// 收到SIGINT或SIGTERM后,先停止采集,再等消费者处理完积压消息
	// 最后关闭生产者和ES连接
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	<-sigCh

	log.Println("Shutting down...")
	cancel()           // 停止所有采集器
	alertEngine.Stop() // 停止告警引擎
	producer.Close()   // 关闭Kafka生产者
	log.Println("Log system stopped")
}

部署这个系统时,建议把配置文件外置,支持不同环境使用不同配置。配置文件应该包含Kafka地址、ES地址、监听的日志文件路径、告警规则等。生产环境建议用配置中心(如Nacos、Apollo)管理配置,避免改配置就要重新部署。


写在最后

这一章我们从Kafka客户端使用讲到消息可靠性保障,从延迟消息讲到日志监控系统完整实现。内容量很大,代码很多,但每一行都是生产验证过的。

怕浪猫想说,消息队列和可观测性不是两个独立的东西。当你把日志采集、Kafka传输、ES存储、告警引擎这条链路打通后,你的系统就有了"自我感知"能力。出问题之前告警先到,出问题之后三分钟定位根因——这就是可观测性的价值,也是这一章整个项目的意义。

如果你正在搭建类似系统,建议按这个顺序来:先把Kafka跑通,再把Agent和Consumer跑通,最后接ES和告警。每一步都压测确认性能达标再进入下一步。不要一口气全搭起来再调试,那样出问题都不知道是哪个环节的。

下一章我们进入工程化与CI/CD,聊聊怎么把代码从开发环境安全可靠地送到生产环境。包括多环境配置管理、Docker化、CI流水线、CD策略、灰度发布等内容。


如果这篇文章帮到了你,点个收藏,以后排查问题能快速翻出来。代码量这么大,总有用得上的时候。

你在消息队列和日志监控方面踩过什么坑?评论区聊聊,怕浪猫在线答疑。

这是Go实战手册第14章,系列进度 14/16。关注我,下一章「工程化与CI/CD」更精彩。


怕浪猫说:消息队列是系统的血管,日志监控是系统的神经。血管堵了系统会停,神经断了你会变成瞎子——出了事还在傻笑。把这两套基础设施建好,你的系统才真正具备生产级生存能力。

热爱生活,喜好美食,追求未来!