bigywater.tech
Kafka 内部原理 · 17

Log Compaction:同一个 Key 为什么只保留最新值

cleanup.policy=compact 的 topic 不按时间或大小删数据,而是保证"每个 Key 至少保留最新的一条"。这次给 10 个 Key 各写 5 轮更新,中途给其中一个 Key 写一条墓碑(value 为 null),跨三个 Segment,然后读出清理前后的磁盘文件和消息内容,看 broker 到底保留了什么、删掉了什么。

验证:每个 Key 只剩最新值,被保留的消息保持原来的 offset
52 条消息清理后只剩 11 条:k0、k1、k2、k4 到 k9 各保留最新的 v5,k3 的位置被墓碑 null 占
验证:压缩是把旧 Segment 合并重写,最新的活跃 Segment 不参与
前两个 Segment 一共 3670 字节,被合并重写成一个 720 字节的 00000000000000000000

验证:每个 Key 只剩最新值,被保留的消息保持原来的 offset

命令 · shell $ kafka-topics.sh --create --topic compact-demo --partitions 1 --replication-factor 1 \ --config cleanup.policy=compact --config min.cleanable.dirty.ratio=0.01 \ --config segment.ms=5000 --config delete.retention.ms=1000 # Java Producer:k0~k9 各写 v1~v5 共 5 轮(其中 v1~v3 一批,7 秒后 v4~v5 一批), # 再给 k3 写一条 value=null 的墓碑,7 秒后再写一条 roll=tail 触发 Segment 滚动 $ kafka-console-consumer.sh --topic compact-demo --partition 0 --offset 0 \ --property print.key=true --property print.offset=true
真实实测 · 本机执行输出
写入完成后 log end offset = 52,磁盘上 3 个 Segment:
2160  00000000000000000000.log
1510  00000000000000000030.log
  76  00000000000000000051.log

清理之后消费到的全部消息:
Offset:40 k0 v5    Offset:41 k1 v5    Offset:42 k2 v5
Offset:44 k4 v5    Offset:45 k5 v5    Offset:46 k6 v5
Offset:47 k7 v5    Offset:48 k8 v5    Offset:49 k9 v5
Offset:50 k3 null
Offset:51 roll tail

52 条消息清理后只剩 11 条:k0、k1、k2、k4 到 k9 各保留最新的 v5,k3 的位置被墓碑 null 占着,加上最后的 roll。最值得注意的是 offset:保留下来的消息还是原来的 offset(40、41、42、44……),中间空出来的 43 是 k3 v5,它被后面的墓碑取代了。这说明压缩不会重新编号,offset 序列里会出现"洞",所以按 offset + 1 一条条读的逻辑在压缩 topic 上不成立,消费者拿到的下一个 offset 可能是跳着的。

验证:压缩是把旧 Segment 合并重写,最新的活跃 Segment 不参与

真实实测 · 本机执行输出
清理前:2160 + 1510 + 76 字节,三个文件
清理后:
 720  00000000000000000000.log        <- 前两个 Segment(offset 0~50)合并重写成一个文件
  76  00000000000000000051.log        <- 活跃 Segment,未被改动
旧文件:00000000000000000000.log.deleted (2160)、00000000000000000030.log.deleted (1510)
t+54s:两个 .deleted 文件被物理删除

前两个 Segment 一共 3670 字节,被合并重写成一个 720 字节的 00000000000000000000.log,旧文件同样先改名成 .deleted,约 54 秒后才被删除(我开始轮询时清理已经完成,所以这个时间点只能说明延迟删除的现象和上一篇 Retention 一致,不能精确对应到 60 秒)。最后那个只有 76 字节的 00000000000000000051.log 没有变化。这是压缩的关键约束:只有已经滚动的 Segment 才会被清理,活跃 Segment 里的重复 Key 不会被清理,所以我在实验里特意用 segment.ms=5000 加上一条尾部消息把前面的 Segment 滚出来。如果不滚动会怎样,这次没有单独实测。

验证:墓碑不会永远保留,后续清理会把它连同 Key 一起删掉

命令 · shell # 之后再写 roll2=x,等 7 秒,再写 roll3=y,等 20 秒,重新读取全部
真实实测 · 本机执行输出
Offset:40 k0 v5 ... Offset:49 k9 v5
Offset:51 roll  tail
Offset:52 roll2 x
Offset:53 roll3 y

之前保留的 Offset:50 k3 null 这次消失了,k3 在整个 topic 里再也没有任何记录——不是"值为空",而是这个 Key 不存在了。这个 topic 的 delete.retention.ms 我设成了 1000(默认 24 小时):墓碑保留期过了以后,下一次清理才会把它一起删除。默认值是 86400000 毫秒(查配置得到),也就是一天,设计意图应该是给消费者留出时间看到这条删除记录,但这一点本文没有实测。这里的实验没有验证"如果消费者在墓碑被清理后才开始读"会怎样,但从结果可以看出:此时读到的就是一份完全没有 k3 的数据。

结论

压缩的效果是:已经滚动的 Segment 里,每个 Key 只留最后一条;保留的消息 offset 不变、序列有洞;活跃 Segment 不清理,所以最新的几条更新总是可能留有重复 Key;删除一个 Key 要靠墓碑,墓碑在 delete.retention.ms 之后被清理。它保证的是"每个 Key 的最新值一定在",不保证"历史值一定没有"。

分步演示:Log Compaction 是怎么把重复key压掉的

每个key保留的最终状态和offset,都是本文上面几节消费compact-demo这个topic得到的原始结果。

52条消息,压缩到只剩11条,offset序列出现了洞
这一步新验证的key

    参考与说明

    • 数据来自本机 3 节点 Kafka 4.3.1 集群,min.cleanable.dirty.ratio=0.01、segment.ms=5000、delete.retention.ms=1000 都是为了让清理在几十秒内发生而调整的,默认值下(min.cleanable.dirty.ratio=0.5、segment.ms 7 天)清理会慢很多,观察到的时间不能直接套到生产配置。
    • 和《Retention:消息什么时候真正被删除》一起看:同样是"改名 .deleted 再延迟删除",但 Retention 删的是整个过期的 Segment,压缩是重写 Segment 内容。
    • 没有验证:压缩过程中读到重复 Key 的时间窗口;min.compaction.lag.ms 的效果;多个分区、多个 cleaner 线程下的行为;压缩 topic 上带 retention.ms 的 cleanup.policy=compact,delete 组合。
    京ICP备2026067502号-1京公网安备11010502063487号