先抛一个我想听大家拍砖的问题:你们有没有核过,__consumer_offsets 里存的"消费进度",其实是一个带 TTL 的缓存,而不是权威源? 我们去年就栽在这儿——一个只在白天跑的对账结算消费者,停机 8 天后重启,Kafka 不报任何业务错误,直接从 latest 续读,8 天里 465 万条结算任务被静默跳过,T+1 对账才发现。这篇想把"offset 保留期"和"重置策略"这两件被当成默认正确、其实默认危险的事讲清楚。
一、现象与背景
去年 Q4,我们的结算域:Kafka 3.5.1(KRaft,3 broker,各 8C32G,NVMe SSD),topic settle-task 24 分区、RF=3、retention 30 天,日均约 520 万条。消费组 ledger-settle-consumer 只有 1 个实例,按调度每天 02:00 起来跑一批,跑完就退(典型的 batch consumer,非常驻)。
一次叠加事故:国庆长假(3 天)+ 一次发布冻结(2 天)+ 下游账务服务一个连接池 bug 把消费者卡死又没被拉起(3 天),合计停机约 8 天 12 小时。消费者配置里 enable.auto.commit=true、consumer.offsets.commit.interval.ms=5000、auto.offset.reset=latest(默认值)。
8 天后消费者被拉起,监控上 consumer lag 从巨大的数字瞬间掉到 0,一切"看起来正常"。直到 T+1 对账报警:漏结算 4,654,259 笔,回补用了 36 小时。整条链路里,Kafka 侧零异常日志——它只是把 offset 重置到了 latest,然后继续读。
二、原理与根因:offset 是"带 TTL 的缓存",不是"权威游标"
关键在于两个默认值的组合:
offsets.retention.minutes(默认 10080 = 7 天)
- 消费组的提交 offset 存在
__consumer_offsets(一个压缩 + 保留期 topic,分区数由num.consumer.offsets.partitions控制,默认 50)。 - Kafka 对不活跃的组做 offset 保留:一个组超过
offsets.retention.minutes没有再提交,它的 offset 记录就会被清除。 - 我们那个 batch 消费者每次只提交一次就退出,两次运行间隔 > 7 天时,上次的 offset 记录已经被清了。
auto.offset.reset(默认 latest)
- 消费者重启后向 group coordinator 要 offset,coordinator 里查不到(已被清)→ 返回"无 offset" → 消费者退回
auto.offset.reset策略。 latest= 直接跳到当前 log end → 中间那 8 天的消息全被跳过,且没有任何错误抛给业务。
所以根因不是"Kafka 丢了消息"(topic retention 30 天,消息其实还在 log 里),而是 "消费进度"这个状态在 __consumer_offsets 里过期被清,而默认重置策略 latest 把"查不到进度"悄悄翻译成了"从头都不用补,从最新读"。这是一个静默的业务数据丢失,不是 Kafka 的 bug,是默认配置与"批处理消费者"这种使用方式的错配。
三个重置策略在同一事故下的表现:
| auto.offset.reset | 重启后行为 | 业务后果 |
|---|---|---|
| latest(默认) | 跳到 log end | 静默跳过 8 天 = 漏 465 万笔(最危险,因为无声) |
| earliest | 跳到 log start | 静默重放 8 天 = 重复结算(不幂等则双花) |
| none | 抛 NoOffsetForPartitionException(code 144) | 大声失败,最安全,但要求业务有恢复/降级预案 |
三、具体做法(可复现)
1. 把 offset 保留期抬到 ≥ 你的最大预期停机
# broker.properties
# 默认 10080(7 天),按最大停机窗口抬到 30 天
offsets.retention.minutes=43200
注意代价:__consumer_offsets 体积会变大,broker 内存与磁盘要评估,别无脑拉满。
2. 对"不能丢"的组,把重置策略改成 none(fail loud)
# consumer.properties
auto.offset.reset=none
enable.auto.commit=false # 别用自动提交,手动提交 + 落业务游标
# 处理完一批、业务落库成功后再 commitSync
none 会在 offset 缺失时抛 NoOffsetForPartitionException(code 144),把"我不知道从哪续"这件事显式暴露给业务,而不是替它猜一个方向。
3. 真正的权威游标放在业务侧,别只信 __consumer_offsets
-- 业务进度表:以 (group, partition) 为键存"已确认处理到的进度"
CREATE TABLE consumer_cursor (
group_id VARCHAR(128),
partition_id INT,
last_offset BIGINT,
last_watermark_ts BIGINT, -- 已处理到的最大业务时间戳
updated_at TIMESTAMP,
PRIMARY KEY (group_id, partition_id)
);
重启时:先读业务游标 → 与 Kafka 当前 offset 对比 → 有 gap 就走对账/回补,而不是直接续读。
4. 监控要盯"距上次提交的时间",别只盯 lag
# 距上次 offset 提交的小时数逼近保留期阈值(7d=168h)→ 一级告警
( time() - kafka_consumergroup_max_timestamp ) / 3600 > 160
lag 掉到 0 恰恰是"最像正常"的时刻——这个事故里 lag 归零是假象,真问题是 offset 被重置了。
四、踩过的坑 / 反直觉
- lag 归零 ≠ 一切正常:重启后 lag 瞬间从巨大变 0、监控"全绿",但恰恰是漏数据的时刻。lag 只反映"当前消费位置 vs log end",不反映"你是不是从对的地方续的"。
__consumer_offsets是缓存不是权威源:它有自己的保留期,和 topic 的 retention 是两回事。topic 30 天还留着消息,不代表你的进度也留着。- latest 比 earliest 更危险:直觉上"重放(earliest)"比"跳过(latest)"可怕,但在不幂等的结算场景里,latest 的静默漏单比 earliest 的静默重放更难发现——重放对账会显示"多",漏单显示的是"少",而"少"往往要靠 T+1 对账才暴露。
- batch 消费者 + auto-commit + 默认 retention 是三重错配:常驻消费者每 5s 提交一次,7 天保留期绰绰有余;但按调度跑的 batch 消费者两次运行间隔很容易 > 7 天,默认值对它根本不适用。
五、结论与可复用清单
- [ ] 上线前核对:
offsets.retention.minutes≥ 该消费组最大预期停机时长。 - [ ] 不能丢的组:
auto.offset.reset=none+ 手动提交 + 业务侧权威游标。 - [ ] 别用
enable.auto.commit承载"业务进度",进度要落到业务库并带水位。 - [ ] 监控:除 lag 外,加"距上次 offset 提交时间"告警,阈值贴近保留期。
- [ ] 重启恢复逻辑:先读业务游标 → 对账 gap → 再决定是否续读/回补。
- [ ] batch / 低频消费者单独评审默认配置,别套用常驻消费者的参数。
留个开放问题:当业务进度既不能丢也不能重时,你们把"消费进度"的权威源放在哪? 是继续信 __consumer_offsets(配大保留期 + none),还是彻底改到业务库游标 + 水位对账?想听听大家在"至少一次 vs 恰好一次"之外,对"进度本身"是怎么保证的。