2833 字
约 9 分钟
18
S17-B06 · Kafka 消费入门:分区、位移与重复消费从哪来

一个订单消息在日志里出现了两次,业务侧扣了两次库存。查了半天发现代码逻辑没错——重复来自 Kafka 的消费语义,不是 bug。"至少一次"意味着重复必然可能发生,问题不在"为什么重复",而在"靠什么挡住"。

这篇按一条主线走:消息怎么落到分区 → 位移怎么被记住 → 什么情况下会重放 → 怎么防重。


1. 三个概念的关系

概念 是什么 一句话作用
主题(topic) 逻辑上的消息类别 生产者写、消费者读的入口
分区(partition) 主题的物理切分,每个分区是一条有序追加日志 决定并行度与顺序范围
消费者组(group) 一组消费者共享一个组名 组内分摊分区,互不重复
topic: order-events(3 分区)
  partition-0  [0][1][2][3][4]...   ← 组内只能有一个消费者读它
  partition-1  [0][1][2]...

group: order-consumer
  consumer-A → partition-0;consumer-B → partition-1;consumer-C → 空闲

三条硬规则:同一分区同一时刻只能被同组一个消费者读;消费者数超过分区数时多出来的闲置;顺序只在分区内成立。


2. 位移提交的两种方式

位移(offset)记录"这个组在这个分区读到哪了"。它存在内部主题 __consumer_offsets 里,不在业务主题里。

方式 配置 提交时机 风险
自动提交 enable.auto.commit=true(默认) 每 auto.commit.interval.ms(默认 5000)一次 提交发生在处理完成之前或之后不确定,重复或丢失都可能
手动提交 enable.auto.commit=false + commitSync/commitAsync 代码决定 忘记提交 → 重启后大量重复

重复消费的来源就是这里:位移是"批量的、延迟的",而处理是"逐条的"。两者不可能完全对齐,所以只要处理完还没提交就崩了,重启后必然重放。

顺序 语义 后果
先处理,后提交 至少一次(at-least-once) 崩溃 → 重复
先提交,后处理 至多一次(at-most-once) 崩溃 → 丢失
处理与提交在同一事务 精确一次(需事务支持) 复杂度高,性能下降

绝大多数业务选至少一次 + 消费端幂等,而不是硬上精确一次。


3. 再均衡:分区为什么会换人

再均衡(rebalance)是组内重新分配分区。它会暂停消费,并且让"已提交位移"成为唯一可靠的进度。

触发条件 说明
消费者加入或离开 扩容、缩容、进程重启
心跳超时 session.timeout.ms 内没有心跳(默认 45 秒量级,按版本核对)
处理超时 两次 poll 间隔超过 max.poll.interval.ms(默认 5 分钟)
订阅主题分区数变化 主题扩分区会触发

__consumer_offsets 的作用就在这里:再均衡后分区换了消费者,新消费者只能从已提交的位移继续,内存里的进度全部作废。所以"提交得晚"等于"重复得多"。

增量式再均衡(CooperativeStickyAssignor)能避免全量暂停,但语义仍是至少一次。


4. 积压从哪来

lag = 分区最新位移 − 组已提交位移。积压只有三个来源:

来源 特征 处置
生产速率 > 消费速率 所有分区 lag 同步上涨 提并行度、优化处理逻辑、扩容消费者
单分区热点 只有一两个分区 lag 高 换分区键打散,或给热点键单独建主题
消费线程阻塞 lag 平稳上涨,但消费者 CPU 不高 查下游依赖(数据库、HTTP)与 max.poll.records

第三种最容易被误判成"消费者不够"。实际是每条消息处理变慢,加消费者也救不了单个分区。


5. 分区数与并行度

消费者数 分区数 结果
2 6 每个消费者 3 个分区,正常
6 6 一人一个分区,刚好
8 6 2 个消费者完全空闲
1 6 一个消费者读 6 个分区,串行处理

分区数是并行度的上限,而且只能增不能减。评估时按"目标吞吐 ÷ 单分区消费能力"估,再留一点余量;盲目开很多分区会带来更长的再均衡、更多的文件句柄与更慢的故障恢复。


6. 拉取参数与"被踢出组"

参数 默认量级 作用 调错的症状
max.poll.records 500 单次 poll 最多返多少条 太大 → 一批处理不完,超 max.poll.interval.ms 被踢
max.poll.interval.ms 5 分钟 两次 poll 的最大间隔 太小 → 处理慢就被踢,反复再均衡
fetch.min.bytes 1 攒够多少字节才返回 太大 → 低流量时空等,延迟上升
session.timeout.ms 45 秒 心跳超时判定 与心跳间隔不匹配会误判死亡

"消费超时被踢出组"的典型链路:max.poll.records=500,每条处理 50ms → 一批 25 秒;若下游抖动到 100ms/条,一批就是 50 秒,再叠加多次重试,就会越过 max.poll.interval.ms。此时消费者被判定死亡,分区被重新分配,另一边又在重复消费。

处理办法:减小批大小、把重活移出 poll 线程、或显式调大 interval,而不是直接调大 interval 掩盖问题。


7. 幂等消费的最小做法

做法 成本 适用
业务唯一键 + 去重表(唯一索引) 一次写库 有稳定业务主键,要求强一致
唯一键直接落到业务表 零额外表 业务表本身能唯一约束
Redis SET NX + 过期 一次 Redis 调用 允许极小概率漏判、追求吞吐
状态机校验(只允许 A→B) 一次查询 有明确状态流转
-- 去重表:唯一键就是业务幂等键
CREATE TABLE consume_dedup (
  biz_key    VARCHAR(64) NOT NULL,
  topic      VARCHAR(64) NOT NULL,
  created_at DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (biz_key, topic)
) ENGINE=InnoDB;

-- 处理与去重放在同一本地事务里
INSERT INTO consume_dedup (biz_key, topic) VALUES (?, ?);
-- 若抛唯一键冲突 → 已处理过,直接提交位移并跳过

要点:去重记录与业务写入必须在同一个事务,否则去重表写了、业务没写,消息就永久丢了。


8. 死信与重试放在哪

位置 优点 缺点
消费端本地重试(有限次) 简单,能覆盖瞬时故障 阻塞分区,放大 lag
重试主题(topic-retry-5s/1m/10m) 不阻塞主流程,可分级延迟 需要额外主题与转发逻辑
死信主题(DLQ) 保留原始消息,便于人工处理 需要监控与补偿流程

原则:重试不阻塞分区超过一次 poll 间隔;不可恢复的错误直接进死信,不要无限重试。死信里要保留原始 topic、分区、位移与异常信息,否则事后无法定位。


9. 监控三个指标

指标 含义 异常判断
lag 分区积压条数 持续上涨或长期不回落
消费速率 每秒处理条数 与生产速率长期背离
再均衡次数 单位时间内组内重分配次数 频繁发生 → 处理超时或成员不稳定
# 看组内 lag 与分区分配
kafka-consumer-groups.sh --bootstrap-server BROKER:9092 \
  --describe --group order-consumer

# 看成员与分配(诊断频繁再均衡)
kafka-consumer-groups.sh --bootstrap-server BROKER:9092 \
  --describe --group order-consumer --members --verbose


10. 最小示例:配置 → 拉取 → 处理 → 手动提交

bootstrap.servers=BROKER:9092
group.id=order-consumer
enable.auto.commit=false            # 关键:自己提交
auto.offset.reset=earliest          # 无位移时从最早开始(新组)
max.poll.records=100                # 减小批,避免超时被踢
max.poll.interval.ms=300000
session.timeout.ms=45000
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
    consumer.subscribe(List.of("order-events"));
    while (running) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
        for (ConsumerRecord<String, String> r : records) {
            handle(r);                       // 幂等处理,失败按策略重试或转死信
        }
        consumer.commitSync();               // 整批处理完再提交
    }
}                                    // close 时也会触发一次提交

先处理再提交 → 崩溃时会重放最后一批,这是预期行为,靠第 7 节的去重兜住。


11. 排错:六个常见坑

现象 原因 处理
反复再均衡,消费几乎不推进 处理逻辑太重,超过 max.poll.interval.ms 减小 max.poll.records,把重活移出 poll 线程
重启后大量重复 忘记 commitSync,或自动提交窗口跨过了崩溃点 关闭自动提交,处理完立即提交
有分区完全不动 多线程共享同一个 KafkaConsumer(非线程安全) 一个线程一个消费者,或手动分配分区
莫名多出新主题 auto.create.topics.enable=true,拼错也会被创建 关闭自动创建,统一走申请流程
lag 一直在但 CPU 很低 卡在下游调用或锁上 看线程栈与下游耗时,不看消费者数量
顺序错乱 跨分区无序,或处理时丢进线程池并行 同一业务键用同一分区键,且分区内单线程处理

12. 边界:什么时候不该用 Kafka 消费模型

场景 更合适的选择 理由
任务队列(每条只给一个 worker,需精确派发) RabbitMQ / SQS 类 Kafka 的位移模型按分区推进,不适合"单条确认"
低频定时任务 调度框架 用消息中间件是过度设计
需要按消息优先级处理 带优先级的队列 Kafka 分区内严格有序,无法插队
超低延迟的 RPC 直接调用 消息中间件天然引入批量与延迟

判断标准:你要的是"日志式的流"还是"任务式的队列"。前者用 Kafka,后者换工具。


自测

  1. 同一个消费者组里,消费者数超过分区数会怎样?
  2. 自动提交为什么无法保证"处理完才提交"?
  3. 再均衡后,新消费者凭什么决定从哪继续读?
  4. 只有一两个分区 lag 特别高,最可能是什么原因?
  5. 一批消息处理到一半崩溃,重启后会从哪里开始?这属于哪种语义?
  6. 去重表为什么必须和业务写入放在同一个事务里?
S17-B06 · Kafka 消费入门:分区、位移与重复消费从哪来
https://www.clxhxhhr.top/posts/6062/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。