Go 写 Kafka 消费者:sarama 的 3 个坑与优雅退出写法

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Go 写 Kafka 消费者:sarama 的 3 个坑与优雅退出写法](#Go 写 Kafka 消费者:sarama 的 3 个坑与优雅退出写法)
    • [一、先说结论:用 ConsumerGroup,不要用 Consumer](#一、先说结论:用 ConsumerGroup,不要用 Consumer)
    • [二、坑 1:没处理错误通道](#二、坑 1:没处理错误通道)
    • [三、坑 2:在 `ConsumeClaim` 里开 goroutine](#三、坑 2:在 ConsumeClaim 里开 goroutine)
    • [四、坑 3:offset 提交时机](#四、坑 3:offset 提交时机)
    • 五、优雅退出:完整写法
    • 六、还有三个细节
      • [1. 必须指定 Kafka 版本](#1. 必须指定 Kafka 版本)
      • [2. `Offsets.Initial` 的选择](#2. Offsets.Initial 的选择)
      • [3. 设置合理的超时](#3. 设置合理的超时)
    • 七、带走物

Go 写 Kafka 消费者:sarama 的 3 个坑与优雅退出写法

一、先说结论:用 ConsumerGroup,不要用 Consumer

sarama 有两套消费者 API:

API 语义 适用
Consumer 手动指定 topic/partition/offset 特殊场景(要精确控制位点)
ConsumerGroup 自动加入组、自动分配分区、自动 rebalance 99% 的场景

用 Consumer 手写"我要消费分区 0、1、2"是最常见的错误------它不支持 rebalance,消费者挂了分区就没人消费,扩容也不会自动接管。

go 复制代码
// ✅ 正确:用 ConsumerGroup
config := sarama.NewConfig()
config.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRoundRobin
config.Consumer.Offsets.Initial = sarama.OffsetNewest

client, err := sarama.NewConsumerGroup(brokers, "order-consumer-group", config)
if err != nil {
    return err
}

handler := &consumerHandler{}
// 这个 Consume 是阻塞的,要在循环里跑(rebalance 后会返回)
for {
    if err := client.Consume(ctx, []string{"order-topic"}, handler); err != nil {
        log.Printf("consume error: %v", err)
    }
    if ctx.Err() != nil {
        return
    }
}

注意 Consume 的循环调用 :它不是"调用一次就一直消费",而是每次 rebalance 结束就会返回 ,所以必须放在 for 循环里重新调用。忘了这层循环,rebalance 之后消费者就"消失"了。

二、坑 1:没处理错误通道

sarama 的异步生产者默认关闭返回通道,必须显式打开,否则失败全静默:

go 复制代码
config := sarama.NewConfig()
config.Producer.Return.Successes = true
config.Producer.Return.Errors = true      // 不开这个,发送失败永远不知道

producer, _ := sarama.NewAsyncProducer(brokers, config)

// 必须有 goroutine 消费这两个 channel,否则会阻塞
go func() {
    for err := range producer.Errors() {
        log.Printf("发送失败: %v", err)
        // 这里要决定:重试 / 落库补偿 / 告警
    }
}()

消费者这边也有一个容易漏的 :ConsumerGroup 的 Errors():

go 复制代码
go func() {
    for err := range client.Errors() {
        log.Printf("consumer group error: %v", err)
    }
}()

三、坑 2:在 ConsumeClaim 里开 goroutine

ConsumeClaim 的语义是:每个分区一个 goroutine。所以默认写法天然就是"分区内串行 + 分区间并行"。

在里面再开 goroutine 会破坏顺序性,而且会让 offset 提交变得不可控:

go 复制代码
// ❌ 错误:破坏分区内顺序,且 MarkMessage 的语义被破坏
func (h *handler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
    for msg := range claim.Messages() {
        go func(m *sarama.ConsumerMessage) {
            handle(m)
            sess.MarkMessage(m, "")     // 并发调用,顺序乱
        }(msg)
    }
    return nil
}

// ✅ 正确:分区内串行
func (h *handler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
    for msg := range claim.Messages() {
        if err := handle(ctx, msg); err != nil {
            log.Printf("处理失败 offset=%d: %v", msg.Offset, err)
            continue        // 或者按策略决定要不要中断
        }
        sess.MarkMessage(msg, "")    // 只有成功才标记
    }
    return nil
}

如果单分区真的处理不过来 ,正确做法是加分区数,而不是在分区内并发。

四、坑 3:offset 提交时机

sarama 的 ConsumerGroup 默认自动提交 (Consumer.Offsets.AutoCommit.Enable 默认 true,间隔 1 秒)。

这个默认行为的风险在重复消费那篇讲过:提交的是"已处理的最大 offset",但业务可能只处理了一半。进程崩溃时,既可能重复消费也可能丢消息。

改成手动:

go 复制代码
config := sarama.NewConfig()
config.Consumer.Offsets.AutoCommit.Enable = false    // 关掉自动提交

// 在处理循环里自己控制提交时机
func (h *handler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
    for msg := range claim.Messages() {
        if err := handle(ctx, msg); err != nil {
            return err      // 返回错误会触发重新消费
        }
        sess.MarkMessage(msg, "")
    }
    return nil
}

关掉自动提交后,MarkMessage 只是标记,真正提交需要 sess.Commit()。 不过 sarama 在 ConsumeClaim 返回前会自动提交一次标记过的 offset。

如果要精确控制(比如每 100 条提交一次):

go 复制代码
count := 0
for msg := range claim.Messages() {
    if err := handle(ctx, msg); err != nil {
        return err
    }
    sess.MarkMessage(msg, "")
    if count++; count%100 == 0 {
        sess.Commit()
    }
}
sess.Commit()   // 退出前再提交一次

五、优雅退出:完整写法

这是最值得抄的一段。要点是顺序不能反:

go 复制代码
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    config := sarama.NewConfig()
    config.Version = sarama.V2_8_0_0                    // 必须指定版本
    config.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRoundRobin
    config.Consumer.Offsets.Initial = sarama.OffsetNewest
    config.Consumer.Offsets.AutoCommit.Enable = false
    config.Consumer.Return.Errors = true

    client, err := sarama.NewConsumerGroup(brokers, groupID, config)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()        // 最后关

    go func() {
        for err := range client.Errors() {
            log.Printf("group error: %v", err)
        }
    }()

    handler := &consumerHandler{ready: make(chan bool)}
    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            // rebalance 后 Consume 会返回,所以要循环调用
            if err := client.Consume(ctx, topics, handler); err != nil {
                log.Printf("consume: %v", err)
            }
            if ctx.Err() != nil {
                return
            }
        }
    }()

    <-handler.ready
    log.Println("消费者已就绪")

    // 等退出信号
    sigterm := make(chan os.Signal, 1)
    signal.Notify(sigterm, syscall.SIGINT, syscall.SIGTERM)
    <-sigterm

    log.Println("收到退出信号,开始优雅退出")
    cancel()          // 1. 取消 ctx,Consume 循环退出
    wg.Wait()         // 2. 等在手的消息处理完(ConsumeClaim 里的循环会自然结束)
    log.Println("退出完成")
}

Handler 里也要配合 ctx:

go 复制代码
type consumerHandler struct {
    ready chan bool
}

func (h *consumerHandler) Setup(sarama.ConsumerGroupSession) error {
    close(h.ready)
    return nil
}

func (h *consumerHandler) Cleanup(sarama.ConsumerGroupSession) error {
    log.Println("分区被收回,Cleanup")
    return nil
}

func (h *consumerHandler) ConsumeClaim(sess sarama.ConsumerGroupSession,
    claim sarama.ConsumerGroupClaim) error {

    // 关键:监听 claim.Messages() 的关闭,以及 session 的 ctx
    for {
        select {
        case msg, ok := <-claim.Messages():
            if !ok {
                return nil      // 通道关闭,正常退出
            }
            if err := handle(sess.Context(), msg); err != nil {
                log.Printf("handle offset=%d: %v", msg.Offset, err)
                continue
            }
            sess.MarkMessage(msg, "")

        case <-sess.Context().Done():
            // session 被取消(比如 rebalance 或者主流程 cancel)
            return nil
        }
    }
}

select 里一定要加 sess.Context().Done() :否则 cancel() 之后消费者不会退出,wg.Wait() 会永远卡住,最后被 SIGKILL------那和没有优雅退出一样。

六、还有三个细节

1. 必须指定 Kafka 版本

go 复制代码
config.Version = sarama.V2_8_0_0

不指定的话 sarama 会用一个很老的默认值,某些新特性(比如静态成员、增量 rebalance)不生效,而且可能报奇怪的协议错误。

2. Offsets.Initial 的选择

go 复制代码
config.Consumer.Offsets.Initial = sarama.OffsetNewest   // 从最新开始(默认)
config.Consumer.Offsets.Initial = sarama.OffsetOldest   // 从最早开始

注意这个只在"没有已提交 offset"时生效(第一次启动或者 offset 过期)。一旦有提交记录,就从提交的位置继续。

  • 新业务用 OffsetNewest(避免启动时灌进一堆历史消息);
  • 要补数据的场景用 OffsetOldest。

3. 设置合理的超时

go 复制代码
config.Consumer.Group.Session.Timeout = 10 * time.Second
config.Consumer.Group.Heartbeat.Interval = 3 * time.Second
config.Consumer.MaxProcessingTime = 5 * time.Minute   // 对应 max.poll.interval.ms

MaxProcessingTime 就是 max.poll.interval.ms 在 sarama 里的对应项,默认 5 分钟。处理慢的话要么减小批量,要么调大它(详见 rebalance 那篇)。

七、带走物

一份可直接用的配置骨架:

go 复制代码
config := sarama.NewConfig()
config.Version = sarama.V2_8_0_0                              // 必须指定版本
config.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRoundRobin
config.Consumer.Offsets.Initial = sarama.OffsetNewest
config.Consumer.Offsets.AutoCommit.Enable = false              // 手动提交
config.Consumer.Return.Errors = true                           // 打开错误通道
config.Consumer.Group.Session.Timeout = 10 * time.Second
config.Consumer.Group.Heartbeat.Interval = 3 * time.Second
config.Consumer.MaxProcessingTime = 5 * time.Minute

三个坑的速查:

坑 症状 修法
没开错误通道 发送/消费失败静默 Return.Errors = true + goroutine 消费它
Consume 没循环调用 rebalance 后消费者消失 放在 for 循环里
分区内开 goroutine 顺序乱、offset 不可控 ConsumeClaim 里串行处理

优雅退出顺序:

text 复制代码
1. cancel()                     停止 Consume 循环
2. wg.Wait()                    等在手消息处理完
3. ConsumeClaim 里监听 sess.Context().Done(),及时返回
4. defer client.Close()         最后关闭客户端

一句话结论:sarama 的坑大多集中在"异步通道没消费""Consume 没循环调用""优雅退出没等到位"这三处;把这三处写对,剩下的就是 offset 提交策略的选择问题。

相关推荐
我不会起名字3222 小时前
Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码
redis·缓存·go·布隆过滤器·雪崩
bug菌16 小时前
一个周末就想把短链服务推到 10 万 QPS,真能靠加 Redis 做到吗?
java·redis·go
tachibana217 小时前
Go 语言里的 Context 是什么?
开发语言·后端·golang·go·语言
Linux实战笔记17 小时前
sync.Mutex还是Channel?Go并发场景选型对比与goroutine泄漏实战
go·channel·sync.mutex·goroutine泄漏·race detector
wflynn1 天前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
git·开源·go·github·agent·coding-agents
Thinker QAQ1 天前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
java·python·go·并发编程·channel·blockingqueue·message passing
茉莉玫瑰花茶2 天前
GO [ 标准库 · unsafe ]
后端·go
RobinXYuan2 天前
【Kafka 源码手记】02 Kafka 的核心哲学:Everything Is A Log
kafka·源码阅读
人人都是软件工程师2 天前
17-反射:在运行时观察和操作类型
go