《Java 消息中间件·Kafka 内核与客户端》2.3 保留与清理

日志不会因为被读过就删除,它的生死由 cleanup.policy 决定。本节实测 delete 策略按时间整段回收,以及 compact 策略如何把同 key 的多次写入压成最后一条、墓碑如何先删除旧值、又为什么墓碑自己还要再等一轮清理,并给出两种策略的选择边界。

日志的生死不由消费者决定

1.1 节已经证明:消费不删数据。那数据什么时候消失?

答案在 topic 的 cleanup.policy 和一组保留参数里。Kafka 有两套清理语义:

策略删除单位保留什么典型用途
delete整个段保留期内的全部记录事件流、日志、待处理消息
compact重写段每个 key 的最新值配置、状态、用户画像、最后已知位置

两者可以单独用,也可以组合成 compact,delete:既按 key 压掉旧值,又按时间删掉过老的段。

这一节把两种策略都跑一遍。重点不是记住参数名,而是理解三件事:

  1. 删除和消费无关。消费者读到哪,不影响段什么时候被删。
  2. compact 不是「删除重复」,是「保留每个 key 的最新值」。没有 key 的记录在 compact 下行为不同。
  3. 墓碑(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 策略有两个容易踩的点:

  1. 活跃段不删。当前正在写的段即使超过 retention.ms,也不会被删除;它要等滚动之后才参与保留检查。所以「为什么 retention 到了磁盘没降」的第一反应应该是看活跃段。
  2. 按段的最大时间戳判断。一个段里只要还有一条记录在保留期内,整个段就不会被删。段越大,删除的粒度越粗,实际保留时间可能比 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

墓碑的清理分两步:

  1. 第一次 compact:墓碑和它之前的 k1 值一起被处理,旧值被丢掉,墓碑保留。这样还在从更早位置读的消费者能知道「这个 key 被删除了」,而不是「这个 key 从来没出现过」。
  2. 超过 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 的选择

你的数据选什么原因
每次发生都要保留的事件deletecompact 会丢掉中间事件
只需要最新值的状态compact旧值没有保留价值,省空间
状态有明确过期时间compact,delete既压旧值,又按时间回收
无 key 的记录deletecompact 无法归并无 key 记录
需要按 key 删除compact + 墓碑delete 只能整段删,不能按 key 删

选择之前先问一句:下游需要看到每一次变化,还是只需要知道现在是什么? 需要每一次变化,用 delete;只需要现在,用 compact。这个问题比「哪个更省空间」重要,因为选错了语义,省下来的空间会在业务层变成本更高的补救。

本节判据

回答五个问题:

  1. 消费者读完了所有消息,段会立刻删除吗?——不会。删除由 retention.ms / retention.bytes 和检查周期决定,与消费进度无关。
  2. compact topic 里同一个 key 写了三次,日志里一定只剩一条吗?——不一定。compact 是异步的,活跃段不参与,清理前可能看到多条;最终至少保留最后一条。
  3. 墓碑记录什么时候消失?——先删除旧值,自己保留;delete.retention.ms 到期后,所在段再参与清理时才被删除。
  4. 无 key 的记录在 compact topic 里会被去重吗?——不会。compact 以 key 为单位,无 key 记录无法归并。
  5. 为什么调小 retention.ms 后磁盘没有立刻下降?——保留检查默认 5 分钟一次,而且删除以段为单位,活跃段不删。

下一节离开存储,进入生产者:batch.size、linger.ms、compression.type 三个参数如何互相挤压,以及为什么压缩是批次级而不是消息级。

阅读导航:上一节 2.2 Topic 的物理结构 | 下一节 3.1 批次、linger 与压缩

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收