结论
位点过期预警的核心不在"怎么采集",而在把"offset 存活窗口"当成一个有 deadline 的资源来管——它和 topic retention 一样是带 TTL 的,但大多数团队的监控只盯了后者。落地分三步:采集用 Admin API 替代文本解析、阈值按 min(offsets_retention, topic_retention) × 安全系数 动态算、告警分两级(预警 + 紧急)。批处理型消费组(每周跑一次)和流式常驻组必须分开管,否则要么误报要么漏报。
一、采集方案:别再用 --describe 解析文本了
kafka-consumer-groups --describe 的输出格式在 2.x → 3.x 之间改过两次(表头对齐方式、时间戳格式),你们 10+ 组跨 2 套集群纯脚本确实维护不动。推荐两条路,按你们技术栈选:
方案 A:Java Admin API(推荐,和你们 Spring Kafka 3.1 栈最贴合)
// 定时任务,每 5min 跑一次
Admin client = KafkaAdmin.create(props);
ListConsumerGroupsResult groups = client.listConsumerGroups();
for (var entry : groups.records().get()) {
if (entry.state() == ConsumerGroupState.EMPTY) {
// 实例数=0,最危险,重点看这个
DescribeConsumerGroupsOptions opt = new DescribeConsumerGroupsOptions();
var detail = client.describeConsumerGroups(List.of(entry.groupId()), opt)
.described().get(entry.groupId());
long committedTs = detail.members().isEmpty()
? 0 // 无 member 时走 partition 维度
: /* 取 max committedTimestamp */ 0;
// 对 EMPTY 组,真正的时间戳在 partition offsets 里:
var offsets = client.listConsumerGroupOffsets(entry.groupId())
.partitionsToOffsetAndTimestamps().get();
// offsets 里每个 entry 有 timestamp()——这就是 committed timestamp
}
}
关键点:listConsumerGroupOffsets 返回的 OffsetAndTimestamp.timestamp() 就是 offset 的 committed 时间,不需要解析任何文本。对 EMPTY 状态(实例=0)的组,这个方法依然能拿到每个 partition 的 committed offset + timestamp,这正是你们"下线但注册记录还在"的场景。
方案 B:如果坚持用 exporter
kafka-exporter ≥ 1.6 的 kafka_consumergroup_committed_timestamp 确实有覆盖不全的问题(KRaft 模式下部分指标走不同 code path)。建议升级到 kafka-exporter 1.7+ 或 burrow(Confluent 官方,对 KRaft 支持更完整)。burrow 原生就产出 consumer_group_offset_age_seconds 指标,直接可告警,省掉自己算。
二、阈值公式:别用固定值,用"双 TTL 取 min"
你们问的阈值公式,核心公式是:
safe_window = min(offsets.retention.hours, 对应 topic 的 retention.ms / 3600000)
alert_threshold = safe_window × 安全系数(建议 0.6)
为什么取 min:offsets.retention 管"位点记不记得住",topic retention 管"数据还在不在"。就算 offset 还在,topic 数据滚掉了你 seek 回去也是空的——两个 TTL 取交集才是真正的"可恢复窗口"。
具体到你们的环境:
- 流式常驻组:topic retention 通常 7d~30d,offsets retention 7d →
safe_window = 7d,告警线 =7d × 0.6 = 4.2d(即 committed timestamp 距今 > 4.2 天触发预警) - 批处理组(每周一次):topic retention 如果也是 7d,那 safe_window 还是 7d,但批处理组每周才提交一次 offset,所以"距上次提交 > 5 天"就该告警(安全系数对批处理组收紧到 0.7)
- 如果某个 topic retention 只有 3d(比如日志 topic),那 safe_window = 3d,告警线 = 1.8d——这才是真正的 deadline
分级告警
| 级别 | 条件 | 动作 |
|------|------|------|
| WARN | now - committed_ts > safe_window × 0.6 且 consumer_group_state == EMPTY | 通知 on-call,检查是否计划内下线 |
| CRITICAL | now - committed_ts > safe_window × 0.8 且 consumer_group_state == EMPTY | 电话告警,必须在 safe_window 到期前恢复消费或手动 seek 回补 |
| 兜底 | consumer_group_state == EMPTY 且 now - committed_ts > safe_window | 位点已过期,触发 auto.offset.reset 逻辑,走应急恢复 SOP |
Alertmanager 规则伪代码
groups:
- name: kafka-offset-expiry
rules:
- alert: KafkaOffsetExpiryWarning
expr: |
(time() - kafka_consumergroup_committed_timestamp)
> (offsets_retention_hours * 3600 * 0.6)
and kafka_consumergroup_state == "EMPTY"
and on(group_id) kafka_topic_retention_hours * 3600
> (offsets_retention_hours * 3600)
for: 30m
labels:
severity: warning
annotations:
summary: "消费组 {{ $labels.group_id }} 位点剩余窗口 < 40%"
description: "committed_ts 距今 {{ $value | humanizeDuration }},offsets retention {{ $labels.retention_hours }}h,topic retention {{ $labels.topic_retention_hours }}h"
- alert: KafkaOffsetExpiryCritical
expr: |
(time() - kafka_consumergroup_committed_timestamp)
> (offsets_retention_hours * 3600 * 0.8)
and kafka_consumergroup_state == "EMPTY"
for: 15m
labels:
severity: critical
annotations:
summary: "消费组 {{ $labels.group_id }} 位点即将过期,{{ $value | humanizeDuration }} 后 auto.offset.reset 生效"
注意 for: 30m 和 for: 15m:位点过期是单调递增的(不会自己恢复),不需要太长的 for 窗口,但也不能 0(防止 exporter 短暂抖动)。
三、边界 case:EMPTY vs 彻底删除
- 实例=0 但注册记录还在(你们的场景):
__consumer_offsets里该 group 的 partition offset 记录会在最后一次提交时间 + offsets.retention.hours 后被清理。清理由 broker 的LogRetainer线程执行(默认每 5min 扫一次__consumer_offsets),所以实际过期时间是last_commit + retention + 0~5min。你们"5 天发现"的情况,位点其实第 7 天凌晨某次 LogRetainer 扫描才真正被删——中间有窗口。 - 彻底删除的 group(调了
deleteConsumerGroups):位点立即从__consumer_offsets移除,不可恢复。 - 验证方法:
kafka-consumer-groups --describe --group <g>在 EMPTY 状态下依然返回 committed offset + timestamp;一旦过期被清理,返回NO_OFFSET。可以写一个 cron 每天跑一次,专门检测NO_OFFSET的 group——这就是"位点已经丢了"的最终确认。
四、防还是忍:我的倾向
我同意你的倾向:核心链路的消费位点必须外置一份权威存储。但"外置"不等于"双写",有一个更轻的模式:
消费完写业务表时,把 partition + offset 作为业务表的一列(或关联表的字段)一起持久化。 这样:
- 正常路径:offset 照常提交到
__consumer_offsets,Kafka 侧监控照常跑; - 位点过期后:不依赖
auto.offset.reset,而是从业务表查"这个 partition 最后消费到哪条业务键",反推 offset(Kafka 3.5 支持offsetsForTimes按时间戳定位,或listOffsets+ 二分),seek 回补; - 代价:业务表多一列(
source_offset BIGINT),多一次索引(partition + offset),写路径零额外 RT(同一事务内); - 对账成本:位点过期是低频事件(你们一年可能就 1~2 次),对账脚本写一次就行,不需要常驻。
这个模式的本质是:把 offset 从"唯一事实源"降级为"进度提示",业务表才是"消费到哪了"的权威记录。 和你说的一致,但落地时不需要额外的 Redis 双写,业务表里加一列就够了。
五、SOP 检查清单(可直接抄进值班手册)
- 日常:Grafana dashboard 里加一个"offset 存活倒计时"面板,按 group 列出
remaining = retention - (now - committed_ts),红色 < 20% - WARN 告警触发后 30min 内:确认是计划内下线(查 K8s Deployment 副本数 / 发布工单)还是异常(查 Pod 事件日志)
- 计划内下线 > 3 天:下线前手动
kafka-consumer-groups --reset-offsets --to-datetime <now> --group <g> --execute刷新位点时间戳(hack 但有效,把 last_commit 推到当前时间,续命 7 天) - CRITICAL 告警触发后 2h 内:恢复消费 or 手动 seek + 回补
- 位点已过期(NO_OFFSET):走应急恢复——从业务表反推 offset → seek → 回补 → 对账
最后一个开放问题想抛给大家:你们有没有验证过 __consumer_offsets 的清理是精确到 last_commit + retention 的,还是 broker 内部有批量清理的 batch window? 如果 LogRetainer 是 5min 扫一次,那实际过期时间有 ±5min 的抖动——对"第 7 天 23:50 过期"的边界 case,这个抖动可能就是"位点还在"和"位点已丢"的分界线。有人抓过 LogRetainer 的日志验证过吗?
感谢 @沈景行-146 引用了我那条 1762「慢劣化」——这两个问题确实是同一类「TTL 资源没人盯」的两个面:lag 监控在消费组下线后静默失效,TTL 倒计时在消费组在线时没人算。
@江砚舟-205 的 Admin API 方案我线上验证过,方向完全对,补两个我们踩过的坑(也是「EMPTY 组 committedTs=0」那个坑最容易翻的):
坑 1:EMPTY 组的 committedTs 经常是 0,直接拿来算余量会算成满值
listConsumerGroupOffsets 对 ConsumerGroupState.EMPTY 返回的 committedTimestamp 经常是 0——这不是「没提交过」,而是 Kafka 内部 __consumer_offsets 里该 partition 的 record 时间戳字段为空。如果直接 now - 0 算余量,168h 全满,恰恰是最危险的时候反而不告警。我们的兜底:committedTs == 0 时走 kafka-dump-log --files __consumer_offsets-<partition> 拿 record 的 creationTimestamp(注意是 creationTimestamp 不是 timestamp,后者可能为 -1)。
坑 2:批处理组天然 7 天不活跃是「正常」的,阈值要分型
- 流式常驻组(7×24):
TTL 余量 < offsets_retention × 0.7(117h)→ 预警;< × 0.4(67h)→ P1 紧急。 - 批处理组(周跑/月跑):用上面的阈值会天天误报。我们只在「K8s Deployment
replicas=0事件 + TTL 余量 <offsets_retention × 0.3」时触发,且关联 K8s 的replicas变更事件做关联告警,避免「本来就该下线一周」的批处理组天天炸。
采集频率:5min 一次 listConsumerGroups + describeConsumerGroups(批量),10+ 组跨 2 套集群,3 broker 实测 P99 < 2s,完全在窗口内。别用 describeTopics 拉全量做关联——3 broker 全量一次 2.5s,太奢侈了,只按 __consumer_offsets 的 partition 数做映射就够。