一个订单消息在日志里出现了两次,业务侧扣了两次库存。查了半天发现代码逻辑没错——重复来自 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,后者换工具。
自测
- 同一个消费者组里,消费者数超过分区数会怎样?
- 自动提交为什么无法保证"处理完才提交"?
- 再均衡后,新消费者凭什么决定从哪继续读?
- 只有一两个分区 lag 特别高,最可能是什么原因?
- 一批消息处理到一半崩溃,重启后会从哪里开始?这属于哪种语义?
- 去重表为什么必须和业务写入放在同一个事务里?