栈:Kafka 3.5.1(KRaft,3 broker,8C32G,NVMe SSD)、Spring Kafka 3.1.5、Java 17 场景:内部路由表服务
route-table(compacted topic,8 分区,RF=3)→ 消费组 4 实例 → 本地 Caffeine + 全量重放兜底 关键参数:cleanup.policy=compact、retention.ms=3600000(1h)、min.retention.bytes未设(默认 -1)、segment.ms=604800000、segment.bytes=1073741824
一、现象:路由表「偶发回退」,监控一片绿
上线第 3 周,下游服务开始报「新路由不生效」:一个 key 在 route-table 里明明刚被更新为 v42,下游消费组全量重放后拿到的还是 v38(一周前的值)。抓 kafka-log-dirs 一看,每个分区的 segment 只剩 1~2 个,.log 文件里能 kafka-console-consumer --from-beginning 读到的「全量快照」根本覆盖不到所有 key——长尾 key(更新频率 <1 次/小时的 80% 长尾)从日志里彻底消失。
规模:route-table 日均写入约 12 万条 key 更新,其中 80% 集中在 3 个热点 key(路由策略批量下发),剩下 2 万个长尾 key 更新频率 <1 次/小时。流量本身不大,QPS 峰值约 180,broker 侧零 ERROR,consumer lag 长期为 0。
关键反直觉点:我们设了 cleanup.policy=compact,直觉认为「compact 就是 KV 语义,retention.ms 应该不生效」。事实是——它仍然生效,且没有兜底。
二、根因:retention.ms 对 compact topic 依然生效,min.retention.bytes 才是兜底
Kafka 的日志清理策略由两个开关组合决定:
cleanup.policy:决定「用什么方式清理」。compact是「按 key 保留最新版本(含 tombstone)」;delete是「按时间/大小删老 segment」。retention.ms/retention.bytes:决定「删到哪一条」。这两个参数对 compact topic 同样生效——只要某个 segment 里所有 key 的最新版本都已经迁移到更新 segment,且该 segment 的最老记录时间戳超过retention.ms,broker 就会把它整体清掉。
这里有一个非常隐蔽的组合陷阱:
min.retention.bytes默认 -1(不启用);retention.ms默认 168h,但我们为了控制磁盘,手动改成了 1h;segment.ms仍是默认 7 天,导致一个 segment 可能很久才翻转;- 长尾 key 更新频率 <1 次/小时 → 它所在的老 segment 一旦时间戳跨过 1h 阈值,整个 segment(包括这条 key 的唯一一次写入)被清掉。
后果具体化:
- 热点 key(高频更新)→ 最新值在最新 segment 里,安全;
- 长尾 key(低频更新)→ 最新值落在 30 分钟前写入的老 segment,该 segment 时间戳一过 1h 就被清,KV 语义直接退化成「最近 1 小时的日志」;
- 下游全量重放(
offset=0)拿到的「快照」缺了 80% 的长尾 key,等价于路由表回退到 1 小时前的版本。
这跟江清和在 #454 里讲的「compaction 不是实时、segment 没 swap 前读旧值」是同一族问题——compact topic 的可见性受后台调度影响;而这里多了一层:retention 时间阈值会主动把老 segment 整段干掉,让「该 key 的唯一版本」物理消失。1749 里「compacted topic 被同名重建后部分 key 最新值直接消失」是同类破坏,只是触发路径不同(人为重建 vs. 时间阈值自动清理)。
三、具体做法:三条参数一起改,缺一不可
- 禁用时间清理:
retention.ms=-1(保留 168h 也行,但语义不清晰,建议显式 -1 表意「不按时间删」)。 - 启用空间兜底:
min.retention.bytes=104857600(100MB)。只要分区内「尚未被 compaction 覆盖」的日志超过 100MB,即使时间戳老也不允许被 retention 清掉。 - 收紧 segment 翻转:
segment.ms=86400000(1 天)+segment.bytes=134217728(128MB),避免一个 segment 攒 7 天才翻转、期间老 key 被 retention 提前清掉的窗口。
变更命令(KRaft 下用 kafka-configs):
kafka-configs.sh --bootstrap-server prod-kafka:9092 \
--alter --entity-type topics --entity-name route-table \
--add-config retention.ms=-1,min.retention.bytes=104857600,segment.ms=86400000,segment.bytes=134217728
验证:
# 1. 看当前配置是否生效
kafka-configs.sh --bootstrap-server prod-kafka:9092 \
--describe --entity-type topics --entity-name route-table | grep -E 'retention|min.retention'
# 2. 看 segment 数量与时间戳分布
kafka-log-dirs.sh --bootstrap-server prod-kafka:9092 --describe --topic route-table
# 3. 全量重放核对 key 覆盖率(脚本侧)
kafka-console-consumer.sh --bootstrap-server prod-kafka:9092 \
--topic route-table --from-beginning --property print.key=true \
| awk -F'\t' '{print $1}' | sort -u | wc -l # 应 ≥ 20000(长尾 key 全集)
重建丢失的长尾 key:从上游配置中心(ZK)做一次全量 dump,按 key=原 key, value=原值 写回 route-table,等 compaction 跑完(log-cleaner 周期默认 10min,可用 kafka-log-dirs 看 .log → .swap 切换确认),再让下游全量重放一次。
四、踩过的坑 / 反直觉
cleanup.policy=compact不是「retention 参数全部失效」的豁免。retention.ms 对 compact topic 仍然生效,除非 min.retention.bytes 兜底。官方文档里这句话藏得很深,很多人按直觉以为 compact = 只按 key 删,结果踩坑。min.retention.bytes=-1是默认值,且语义是「不启用」,不是「无限保留」。看到 -1 不要脑补成「负数=无上限」,它是开关位。segment.ms默认 7 天,在低频写入的 compact topic 上意味着「一个 segment 可能 7 天才翻转一次」。期间如果 retention.ms 短(比如 1h),老 segment 里的 key 在 segment 还没翻转时就被时间阈值清掉,这是最容易被忽略的窗口。- 下游不能假设「compact topic 全量重放 = 最新 KV 快照」。只要上面三条参数没对齐,这个假设就是错的。兜底方案:下游本地持久化一份快照(SQLite / 文件),全量重放时与本地快照做 merge,而不是完全信任 Kafka 侧。
kafka-console-consumer --from-beginning看到的不是「所有 key 的最新值」,而是「所有 key 在日志里的所有版本」。compact 后理论上只剩最新,但前提是 compaction 已经跑完且 retention 没清掉老 segment。验证 KV 语义要用sort -u后的 key 全集去对,而不是看「最后一条」。
五、结论与可复用清单
- compacted topic 的「KV 语义」=
cleanup.policy=compact+retention.ms=-1+min.retention.bytes>0,三者缺一不可。 min.retention.bytes是 compact topic 的安全网,建议至少 100MB(按分区算),避免「低频 key 被 retention 误清」。segment.ms在低频写入场景要收紧(1 天级别),避免「老 segment 长时间不翻转、期间被 retention 清掉」的窗口。- 下游全量重放要带本地快照兜底,不要 100% 信任 Kafka 侧的 compact 完整性。
- 验证 KV 语义的三件套:
kafka-log-dirs看 segment 数量 / 时间戳 +kafka-console-consumer --from-beginning看 key 全集 + 业务侧对账(key 覆盖率 ≥ 上游权威源)。 - 变更 retention 参数属于「数据面」操作,建议走变更评审 + 灰度 + 全量重放验证三步,不要「顺手改」。
一句话总结:compact topic 的「KV 语义」是三条参数共同承诺的,少一条就是「最近 1 小时的日志」。