Kafka 重复消费:自动提交位点与手动提交的 3 个错位

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

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

文章目录

  • [Kafka 重复消费:自动提交位点与手动提交的 3 个错位](#Kafka 重复消费:自动提交位点与手动提交的 3 个错位)
    • 一、先把根因说死
    • [二、错位 1:自动提交的时间窗口](#二、错位 1:自动提交的时间窗口)
    • [三、错位 2:先提交后处理(或反过来)](#三、错位 2:先提交后处理(或反过来))
    • [四、错位 3:退出时序错了](#四、错位 3:退出时序错了)
    • [五、rebalance 时的重复消费](#五、rebalance 时的重复消费)
    • 六、带走物

Kafka 重复消费:自动提交位点与手动提交的 3 个错位

一、先把根因说死

Kafka 里"重复消费"只有一个本质原因:消息已经被处理了,但 offset 没有(或者没能)提交。

Broker 侧什么都不知道,它只认 offset。只要 offset 还停在上一个位置,下一次消费者启动或者分区被重新分配时,那段消息就一定会被再读一遍。

所以排查重复消费,不是去查 Kafka,而是去查**"处理完成"和"提交 offset"这两个动作之间,到底哪里断了**。

二、错位 1:自动提交的时间窗口

自动提交是默认配置(enable.auto.commit=true),客户端每隔 auto.commit.interval.ms(默认 5 秒)把"上一次 poll 回来的最大 offset"提交掉。

问题在于:提交是定时发生的,和你的业务处理进度无关。

时间线大概是这样:

text 复制代码
t=0.0s  poll 回来 offset 100~200(100 条)
t=4.9s  业务才处理到第 60 条
t=5.0s  自动提交触发 → 提交的却是 offset 200(poll 回来的最大值)
t=5.1s  进程崩溃

重启之后从 offset 200 继续,第 61~200 条消息永久丢失 ------这是自动提交更可怕的一面:它不仅会重复消费,还会丢消息。

反过来,如果提交发生在处理之前、崩溃发生在提交之后但处理之前,就会出现重复消费。

提交时机 vs 处理进度 结果
提交 > 实际处理进度,然后崩了 丢消息
提交 < 实际处理进度,然后崩了 重复消费
两者一致 正常(at least once)

结论:只要你在意不丢消息,就关掉自动提交。 这不是风格问题,是正确性问题。

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

三、错位 2:先提交后处理(或反过来)

关掉自动提交之后,第二个坑就来了------手动提交的位置写错了。

常见的错误写法长这样(伪代码):

go 复制代码
for msg := range claim.Messages() {
    go func(m *sarama.ConsumerMessage) {
        handle(m)                              // 异步处理
    }(msg)
    session.MarkMessage(msg, "")               // 立刻标记:还没处理完就标记了
}

MarkMessage 只是标记,真正提交要等 Commit()。但这里的根本问题是:标记发生在处理完成之前。异步任务失败了,offset 也已经提交了,消息就丢了。

正确的顺序必须保证"处理成功"是"提交"的前置条件:

go 复制代码
for msg := range claim.Messages() {
    if err := handle(msg); err != nil {
        // 处理失败:不要标记,让这一批重来
        log.Printf("handle failed, offset=%d: %v", msg.Offset, err)
        continue
    }
    session.MarkMessage(msg, "")   // 只有成功了才标记
}

还有一个更隐蔽的变体:批量处理时提交了整个批次的最后一个 offset,但批次中间有失败的。

go 复制代码
msgs := batch()                    // 一次拉 100 条
for _, m := range msgs {
    handle(m)                      // 第 37 条失败,但没中断
}
session.MarkMessage(msgs[len(msgs)-1], "")   // 提交了第 100 条的 offset

第 37 条失败了,offset 却提交到了 100。这是最典型的"看起来跑通了、实际在静默丢消息"。

正确做法:记录批次内失败的最小 offset,只提交到那个位置之前。

四、错位 3:退出时序错了

第三个错位发生在关闭流程里。看这段真实存在过的代码:

go 复制代码
// 错误示例
consumer.Unsubscribe()   // 先退订
consumer.Close()         // 再关闭

Unsubscribe() 之后,还在缓冲里、还没来得及提交的 offset 就没有机会提交了------因为客户端已经不再参与这个组,coordinator 也不再接受它的提交。

正确顺序是先提交、再关闭:

go 复制代码
// 正确:先确保 offset 落盘,再断开
handler.Flush()          // 或者手动 Commit 一次
if err := consumer.Close(); err != nil {
    log.Printf("close consumer: %v", err)
}

在 K8s 环境里,这个坑还会被放大:Pod 收到 SIGTERM 后只有 terminationGracePeriodSeconds(默认 30 秒)的时间。如果你的关闭流程里没有把"提交 offset"放在最前面,那么每一次发布都会制造一批重复消费。

一个能用的优雅退出骨架:

go 复制代码
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    wg := sync.WaitGroup{}

    wg.Add(1)
    go func() {
        defer wg.Done()
        consumeLoop(ctx)          // 消费主循环
    }()

    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
    <-sig

    cancel()                      // 1. 先停消费循环
    wg.Wait()                     // 2. 等在手的消息处理完
    commitPendingOffsets()        // 3. 再提交 offset
    consumer.Close()              // 4. 最后关闭
}

顺序不能反。 先 Close() 再提交,提交会失败;先 Unsubscribe() 再提交,提交会被拒。

五、rebalance 时的重复消费

前面三类都是"提交时机"的问题,还有一类是分区易主带来的。

消费者 A 处理完 offset 500,还没提交,就被判定超时踢出了组。分区重新分配给消费者 B,B 从上一次已提交的 offset 450 开始读------450 到 500 这 50 条被消费了两次。

这一类完全无法靠"提交时机"避免,因为崩溃/超时是外部事件。它只能靠消费端幂等兜底:

go 复制代码
// 用业务主键做幂等,而不是靠 Kafka 的 offset
func handle(m *sarama.ConsumerMessage) error {
    var evt Event
    json.Unmarshal(m.Value, &evt)

    // 唯一键去重:处理过了就跳过
    ok, err := dedup.TryMark(ctx, evt.BizID)   // 比如 Redis SETNX / 唯一索引
    if err != nil {
        return err
    }
    if !ok {
        return nil    // 已处理过,直接返回成功
    }
    return doBusiness(evt)
}

记住一条:Kafka 的 offset 机制保证的是"至少一次",不是"恰好一次"。要"恰好一次"的效果,必须在业务侧做幂等,或者用 Kafka 事务 + 外部存储的原子提交(成本高,多数业务不值得)。

六、带走物

排查重复消费的按顺序三问:

  1. 是不是自动提交? 是 → 关掉,改手动。enable.auto.commit=false。
  2. 提交点在处理之后吗? 异步/批量的场景尤其要检查"是否提交了失败消息的 offset"。
  3. 退出时序对吗? 取消消费 → 等处理完 → 提交 offset → Close(),四步顺序不能乱。

最后一句结论可以直接写进设计文档:Kafka 只提供 at-least-once,重复消费是常态而不是异常;幂等是消费端的必选项,不是可选项。

相关推荐
qq_452396233 天前
第二篇:《CommitLog + ConsumeQueue:一条日志解决 Kafka 多分区随机写的难题》
分布式·kafka
我不会起名字3225 天前
消费回调固定 return nil:错误传播与 Kafka 位点提交的区别
后端·mongodb·kafka·go·im·故障排查
qq_452396235 天前
第十篇:《Kafka 安全加固:SASL、SSL、ACL 与多租户隔离》
安全·kafka·ssl
用户3610588626126 天前
KafkaProducer 元数据管理:send() 卡住时,它在等什么
kafka
空奈qwq6 天前
【Kafka 从入门到实战】从手写一个队列到生产级消息中间件:架构、分区、消费组、零拷贝全解析
分布式·架构·kafka
我不会起名字3227 天前
Redis 从 0 到 1(三十三):机器断电 Redis 起不来——AOF/RDB 损坏的 4 步抢救流程
redis·数据恢复·aof·rdb·排查
用户3610588626128 天前
深度拆解 Kafka 源码:Producer 构造器装配了哪些核心组件
kafka
我不会起名字3228 天前
Redis 从 0 到 1(二十九):CPU 突然 100%——commandstats 定位法与 6 步排查链路
redis·cpu·慢查询·排查·commandstats