日志的生死不由消费者决定
1.1 节已经证明:消费不删数据。那数据什么时候消失?
答案在 topic 的 cleanup.policy 和一组保留参数里。Kafka 有两套清理语义:
| 策略 | 删除单位 | 保留什么 | 典型用途 |
|---|---|---|---|
delete | 整个段 | 保留期内的全部记录 | 事件流、日志、待处理消息 |
compact | 重写段 | 每个 key 的最新值 | 配置、状态、用户画像、最后已知位置 |
两者可以单独用,也可以组合成 compact,delete:既按 key 压掉旧值,又按时间删掉过老的段。
这一节把两种策略都跑一遍。重点不是记住参数名,而是理解三件事:
- 删除和消费无关。消费者读到哪,不影响段什么时候被删。
- compact 不是「删除重复」,是「保留每个 key 的最新值」。没有 key 的记录在 compact 下行为不同。
- 墓碑(tombstone)是一条 value 为 null 的记录,它先删除旧值,自己在
delete.retention.ms之后才有资格被下一轮清理带走。
delete 策略:按时间或大小整段回收
先看默认策略。log.cleanup.policy 的 broker 默认值是 delete,log.retention.hours=168,也就是 7 天。topic 级可以用 retention.ms 覆盖。
本机做了一个可观测的实验:建一个 retention.ms=5000、segment.ms=2000 的 topic,写 5 条,等 8 秒让段滚动,再写 2 条:
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic a1.retention --partitions 1 --replication-factor 1 \
--config retention.ms=5000 --config segment.ms=2000
Created topic a1.retention.
写入与滚动:
sent key=r4 p=0 off=4 ts=1793247915929
sent key=r6 p=0 off=6 ts=1793247924617
第一次写入后等 8 秒,segment.ms=2000 已经把老段封口;第二次写入落在新段。滚动之后先看到两组段文件:
-rw-r--r-- 1 appuser appuser 360 Oct 29 04:25 /tmp/kafka-logs/a1.retention-0/00000000000000000000.log
-rw-r--r-- 1 appuser appuser 144 Oct 29 04:25 /tmp/kafka-logs/a1.retention-0/00000000000000000005.log
保留检查(log.retention.check.interval.ms=5000)跑过之后,offset=0 的段先被删除,目录里出现 .deleted 后缀:
-rw-r--r-- 1 appuser appuser 360 Oct 29 04:25 /tmp/kafka-logs/a1.retention-0/00000000000000000000.log.deleted
-rw-r--r-- 1 appuser appuser 144 Oct 29 04:25 /tmp/kafka-logs/a1.retention-0/00000000000000000005.log
再过一轮检查,offset=5 的段也被删了,只剩一个空的活跃段(base offset=7)。这个过程在本机大约花了十几秒,取决于检查周期落在哪个时刻:
-rw-r--r-- 1 appuser appuser 0 Oct 29 04:25 /tmp/kafka-logs/a1.retention-0/00000000000000000007.log
查当前最早的可用位移:
docker exec mq-a1 /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server 127.0.0.1:9092 --topic a1.retention --time -2
a1.retention:0:7
-2 表示「最早位移」,返回 7。offset=0 到 6 已经不在日志里了。这就是 delete 策略的行为:按段删除,和消费者读到哪无关。本节涉及的等待时间与检查周期都是单次运行,随调度波动,不作基准。
这个实验有一个前提要交代:本机把 broker 的 log.retention.check.interval.ms 覆盖成了 5000 毫秒,才能在几十秒内看到删除。默认值是 300000 毫秒(5 分钟),读者用默认配置时,即使 retention.ms=5000,也要等下一次检查才会真正删除。
这个参数不能动态改。本机试过:
docker exec mq-a1 /opt/kafka/bin/kafka-configs.sh \
--bootstrap-server 127.0.0.1:9092 \
--alter --entity-type brokers --entity-name 1 \
--add-config log.retention.check.interval.ms=5000
InvalidRequestException: Cannot update these configs dynamically: [log.retention.check.interval.ms]
要改只能在启动配置里写,然后重启 broker。这也是为什么生产环境调小保留期之后,磁盘不会立刻下降:删除是周期性的,而且以段为单位。
delete 策略有两个容易踩的点:
- 活跃段不删。当前正在写的段即使超过
retention.ms,也不会被删除;它要等滚动之后才参与保留检查。所以「为什么 retention 到了磁盘没降」的第一反应应该是看活跃段。 - 按段的最大时间戳判断。一个段里只要还有一条记录在保留期内,整个段就不会被删。段越大,删除的粒度越粗,实际保留时间可能比
retention.ms长。
compact 策略:每个 key 只留最后一条
compact 的语义完全不同。它不看时间,而是看 key:同一个 key 的多次写入,只保留最后一条;更早的值在段被重写时丢掉。
这对「状态类」数据非常合适。比如图书馆系统里每本书的当前库存:
key=book-1001 value=库存 3
key=book-1001 value=库存 2
key=book-1001 value=库存 5
消费方只关心「现在是多少」,不关心中间过程。compact 之后,日志里只留 库存 5 这一条,前面两条被清理。
本机建一个 compact topic,为了在短时间内看到效果,把段大小设为 1 MB、segment.ms=2000,并把 min.cleanable.dirty.ratio 设为 0.01:
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic a1.compact --partitions 1 --replication-factor 1 \
--config cleanup.policy=compact \
--config segment.bytes=1048576 \
--config segment.ms=2000 \
--config min.cleanable.dirty.ratio=0.01 \
--config min.compaction.lag.ms=0 \
--config delete.retention.ms=2000 \
--config max.compaction.lag.ms=10000
Created topic a1.compact.
--describe 确认配置:
Configs: min.insync.replicas=1,cleanup.policy=compact,segment.bytes=1048576,max.compaction.lag.ms=10000,min.compaction.lag.ms=0,min.cleanable.dirty.ratio=0.01,delete.retention.ms=2000,segment.ms=2000
写入 5 条,key 只有 k1 和 k2,其中 k1 写了三次:
sent key=k1 p=0 off=0 ts=1793247977899
sent key=k1 p=0 off=1 ts=1793247977933
sent key=k2 p=0 off=2 ts=1793247977945
sent key=k2 p=0 off=3 ts=1793247977955
sent key=k1 p=0 off=4 ts=1793247977966
再写一条 filler=pad,把活跃段滚出去(活跃段不参与 compact):
sent key=filler p=0 off=5 ts=1793247982444
滚动后的目录:
00000000000000000000.log 360 字节,offset 0-4
00000000000000000005.log 77 字节,offset 5
等大约 15 秒(单次运行,随调度波动,不作基准),cleaner 处理完封口的段。再看老段:
-rw-r--r-- 1 appuser appuser 144 Oct 29 04:26 /tmp/kafka-logs/a1.compact-0/00000000000000000000.log
从 360 字节缩到 144 字节。用 kafka-dump-log.sh 读内容:
| offset: 3 CreateTime: 1793247977955 keySize: 2 valueSize: 2 sequence: 3 headerKeys: [] key: k2 payload: v4
| offset: 4 CreateTime: 1793247977966 keySize: 2 valueSize: 2 sequence: 4 headerKeys: [] key: k1 payload: v5
原来 5 条,现在只剩 2 条:
k1的三次写入只剩 offset=4 的v5;k2的两次写入只剩 offset=3 的v4;- offset=0、1、2 被清掉。
注意 offset 没有重排。保留下来的记录仍然是 offset=3 和 offset=4,前面的 0、1、2 变成了空洞。消费者按 offset 读时,会跳过这些不存在的 offset,不会因为「offset 不连续」报错。这是 compact 和「重建日志」的重要区别:它保留原始位移,只删除记录。
墓碑:删除一个 key
compact 只能保留最新值,不能直接「删除一个 key」。要删除,得写一条 value 为 null 的记录,这叫墓碑(tombstone)。
本机在 offset=6 写一条 k1=null:
sent key=k1 p=0 off=6 ts=1793248010593
段文件里能看到 valueSize: -1,表示 value 为 null:
| offset: 6 CreateTime: 1793248010593 keySize: 2 valueSize: -1 sequence: 0 headerKeys: [] key: k1
墓碑的清理分两步:
- 第一次 compact:墓碑和它之前的
k1值一起被处理,旧值被丢掉,墓碑保留。这样还在从更早位置读的消费者能知道「这个 key 被删除了」,而不是「这个 key 从来没出现过」。 - 超过
delete.retention.ms之后的下一次 compact:墓碑本身也被丢掉。此时这个 key 在日志里彻底不存在。
本机把 delete.retention.ms 设为 2000 毫秒,再写几条记录、把活跃段滚出去,等 cleaner 跑完第一轮。参与本轮清理的段,前后对比如下:
清理前(offset: key/value):
3: k2=v4
4: k1=v5
5: filler=pad
6: k1=<null>
7: filler2=pad2
8: k2=v5
9: k2=v6
10: filler3=pad3
11: filler4=pad4
第一次 compact 后:
5: filler=pad
6: k1=<null>
7: filler2=pad2
9: k2=v6
10: filler3=pad3
11: filler4=pad4
k1 的旧值(offset=4)被清掉了,但墓碑 offset=6 还在;k2 的旧值 offset=3、8 被清掉,只留最后一条 v6;没有 key 的 filler 记录全部保留。
墓碑本身没有在这一轮消失。本机把等待时间拉长到 12 秒后重试,仍然只观察到第一阶段。原因在清理的分工:第一次 compact 的职责是「用墓碑作边界,清掉它之前的值」,墓碑要先留下来,让还在从更早位置读的消费者知道「这个 key 被删除了」,而不是「这个 key 从来没出现过」。墓碑自身只有在 delete.retention.ms 到期、并且所在段再次参与清理时才会被丢掉。这个第二阶段依赖 cleaner 的轮询和段选择,本机没有稳定复现出对应的文件状态,所以不把第二阶段的文件大小当成实测数据。
这个结果说明三件事:
- 墓碑删除的是「这个 key 的全部历史值」,不只是最近一条。
- 墓碑自己有资格消失的时间至少是
delete.retention.ms,但「有资格」不等于「立刻消失」,还要等下一轮清理;给慢消费者留出看到删除的机会。 - 没有 key 的记录不会被 compact 掉。compact 的键是 key,key 为 null 的记录无法归并,只能等 delete 策略或手工处理。
最后一点在建模时很重要:如果使用 compact topic,所有需要保留/删除语义的记录都必须有 key。无 key 的记录在 compact 下既不会被去重,也不会被墓碑删除。
compact 的几个参数
| 参数 | 作用 | 默认值 | 调它的后果 |
|---|---|---|---|
min.cleanable.dirty.ratio | 脏数据比例达到多少才清理 | 0.5 | 调小更积极,I/O 更多 |
min.compaction.lag.ms | 记录至少保留多久才可被压缩 | 0 | 调大给慢消费者更多时间 |
max.compaction.lag.ms | 记录最迟多久必须被压缩 | 很大 | 调小保证清理时效,I/O 更频繁 |
delete.retention.ms | 墓碑至少保留多久 | 86400000(1 天) | 调小墓碑更快消失,慢消费者可能看不到删除 |
segment.ms / segment.bytes | 活跃段多久滚出去 | 7 天 / 1 GB | 太大导致 compact 迟迟不发生 |
本机的实验把 min.cleanable.dirty.ratio 调到 0.01、delete.retention.ms 调到 2000,目的是在几十秒内看到现象。生产环境不要照抄这些值:它们会让 cleaner 频繁重写段,增加磁盘 I/O。
还有两个全局参数会影响 compact 的速度:
log.cleaner.threads=1
log.cleaner.backoff.ms=15000
log.cleaner.io.max.bytes.per.second=1.7976931348623157E308
默认只有 1 个 cleaner 线程,空闲 15 秒才检查一轮。这就是为什么「写完重复 key 之后立刻 dump,发现还没清理」是正常的:compact 是后台异步的,不是写入路径的一部分。要观察它,得等 cleaner 轮询到。
log.cleaner.io.max.bytes.per.second 默认是 Double.MAX_VALUE,也就是不限速。生产环境如果发现 compact 抢占正常读写 I/O,可以给它设一个上限。
compact 的语义边界
compact 经常被误解成「去重」或「只保留最新状态」,这两句话都不够准确。准确的说法是:
compact 保证:对于每个 key,日志中至少保留最后一条记录;更早的记录可能被删除,也可能暂时还在。
「可能」这两个字很重要。compact 是异步的、按段重写的,任何时刻你都可能读到同一个 key 的多条记录,尤其是在 compact 还没跑、或者消费者读的是活跃段的时候。所以消费 compact topic 的正确姿势是「按 key 覆盖」,而不是「假设每个 key 只出现一次」。
这也意味着 compact topic 不适合当事件日志用。事件日志需要「每一次发生都保留」,compact 会丢掉中间状态。反过来,事件日志用 delete 策略,状态类数据用 compact,这是最常见的分工。
一个折中是 cleanup.policy=compact,delete:既保留每个 key 的最新值,又把超过 retention.ms 的段整体删掉。它适合「状态有生命周期」的场景,比如用户会话、临时配置。
delete 与 compact 的选择
| 你的数据 | 选什么 | 原因 |
|---|---|---|
| 每次发生都要保留的事件 | delete | compact 会丢掉中间事件 |
| 只需要最新值的状态 | compact | 旧值没有保留价值,省空间 |
| 状态有明确过期时间 | compact,delete | 既压旧值,又按时间回收 |
| 无 key 的记录 | delete | compact 无法归并无 key 记录 |
| 需要按 key 删除 | compact + 墓碑 | delete 只能整段删,不能按 key 删 |
选择之前先问一句:下游需要看到每一次变化,还是只需要知道现在是什么? 需要每一次变化,用 delete;只需要现在,用 compact。这个问题比「哪个更省空间」重要,因为选错了语义,省下来的空间会在业务层变成本更高的补救。
本节判据
回答五个问题:
- 消费者读完了所有消息,段会立刻删除吗?——不会。删除由
retention.ms/retention.bytes和检查周期决定,与消费进度无关。 - compact topic 里同一个 key 写了三次,日志里一定只剩一条吗?——不一定。compact 是异步的,活跃段不参与,清理前可能看到多条;最终至少保留最后一条。
- 墓碑记录什么时候消失?——先删除旧值,自己保留;
delete.retention.ms到期后,所在段再参与清理时才被删除。 - 无 key 的记录在 compact topic 里会被去重吗?——不会。compact 以 key 为单位,无 key 记录无法归并。
- 为什么调小
retention.ms后磁盘没有立刻下降?——保留检查默认 5 分钟一次,而且删除以段为单位,活跃段不删。
下一节离开存储,进入生产者:batch.size、linger.ms、compression.type 三个参数如何互相挤压,以及为什么压缩是批次级而不是消息级。
阅读导航:上一节 2.2 Topic 的物理结构 | 下一节 3.1 批次、linger 与压缩
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。