kafka:清理消息

121 阅读9分钟

本章内容概览:

  • Kafka 中消息清理的机制
  • 管理消息保留的可选策略
  • Kafka 如何处理过期数据的清理

在 Kafka 中,管理消息的生命周期对于维持系统性能和保证数据完整性至关重要。本章介绍两种关键方法:日志保留(log retention)和日志压缩(log compaction)。日志保留侧重于根据时间或大小删除消息,实现简单,适用于合规性或数据管理等多种场景;而日志压缩则基于消息的键选择性删除过时数据,确保每个键只保留最新的一条消息。通过理解日志保留与日志压缩的原理和配置,Kafka 用户可以为各自的需求制定合适的消息保留策略,优化存储使用并保证数据准确性。

10.1 为什么要清理消息?

在深入了解 Kafka 如何清理消息之前,先简要考虑为什么要在 Kafka 中清理消息,以及如果从不清理会产生什么后果。其一是存储容量的问题。理论上我们可以永久保存所有消息,但这会导致日志无限增长,并很快达到可用存储的上限。其二是性能问题:日志越大,处理所有消息所需的时间越长,而且很多场景里我们会处理大量无关的历史消息。最重要的原因可能是合规或法规要求:我们可能不再需要某些数据,或不能再继续保留这些数据。但同时我们必须确保不会错误地删除仍然需要的数据。

10.2 Kafka 的清理方法

为此,Kafka 采取了两种不同的方法:日志保留和日志压缩。使用日志保留时,凡达到一定年龄的消息(即在某一时间点之前产生的消息)会被简单地删除。另一方面,日志压缩会删除过时的数据,如图 10.1 所示。要判断某条消息是否过时,Kafka 依据消息的键(key)。在日志压缩过程中,每个键只保留最新的一条消息,因此只有在为消息指定了键的情况下才可以使用日志压缩。

image.png

每种清理方法各有优缺点,也可以结合使用。日志保留(log retention)的优点是实现相对简单,对 broker 的开销小——我们只需检查消息的“年龄”然后删除它们。同时,日志保留能以非常直接的方式满足一些重要用例。例如,我们可以确保出于数据保护原因需要在某个时间点后删除的消息被自动移除。

日志保留的另一个重要用例类别是传感器数据:传感器数据在一定时间后可能变得无关紧要,可以删除;或者是程序的日志,这类日志不需要永久保存。若 Kafka 仅被用作消息系统,我们也可以在很短的时间后安全地删除消息。然而,使用日志保留时我们无法按需选择性删除数据,因此必须小心不要仅因为数据“过旧”就误删仍然重要的数据。

这就是日志压缩(log compaction)发挥作用的地方。举例来说,假设我们有一个主题用于存储客户数据,例如客户地址。每当地址变更时,都会产生一条以该客户为键的新消息。旧地址随后通常就不再重要,但我们又不能因为消息达到某个年龄就把当前地址删掉。日志压缩对这种用例非常合适:它能确保对每个键始终保留最新的消息,自动删除过时信息。与日志保留相比,日志压缩的缺点是开销较大——为了判断哪些消息可以删除,压缩需要遍历(或扫描)整个日志。

无论选择日志保留、日志压缩,还是两者同时使用,都可以在每个主题级别通过 cleanup.policy 设置,或者为所有主题设置默认值 log.cleanup.policy。默认情况下,Kafka 启用了日志保留(log.cleanup.policy=delete)。下面两个小节我们将更详尽地介绍 Kafka 中日志保留和日志压缩的具体工作机制。

10.3 日志保留

在本节中,我们通过示例来研究 Kafka 在何时具体删除数据,以及如何根据需求配置日志保留。先创建一个名为 products.prices-retention 的主题:

$ kafka-topics.sh \
    --create \
    --topic products.prices-retention \
    --partitions 3 \
    --replication-factor 3 \
    --bootstrap-server localhost:9092
Created topic products.prices-retention.

随后,在新创建的主题中写入一些消息,以便后面有东西可以被删除:

$ kafka-console-producer.sh \
    --topic products.prices-retention \
    --bootstrap-server localhost:9092
> cola 2
> coffee pads 8
[…]
> energy drink 5

如果查看某个分区,会看到我们在第 8 章熟悉的那些文件:

$ ls -1 ~/kafka/data/kafka1/products.prices-retention-0
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
leader-epoch-checkpoint
partition.metadata

10.3.1 通过保留策略何时清理日志?

Kafka 提供两种方式来配置日志保留。第一种是按分区大小触发保留:当分区大小超过某个阈值时,最旧的 segment 会被删除。用于配置该阈值的参数是 retention.bytes,单位为字节。默认值为 -1,表示基于分区大小的保留被禁用(这种方式只在少数场景下有用)。

第二种方式是基于时间的保留——也就是在配置的保留期(retention period)之后删除消息或段(segment)。对应的参数是 retention.ms,以毫秒为单位定义时间段。默认情况下,Kafka 会在 segment 中最新消息的时间超过 7 天时删除消息或 segment,如图 10.2 所示。

image.png

通过 log.retention.byteslog.retention.ms,我们可以调整集群的默认保留值。这两个选项也可以结合使用。这样,我们既能删除过期的消息,又能防止主题或分区无限增长。

现在让我们通过调整 products.prices-retention 主题的配置来试验日志保留,使用 kafka-configs.shretention.ms 设置为 60 秒:

$ kafka-configs.sh --alter \
    --topic products.prices-retention \
    --add-config retention.ms=60000 \
    --bootstrap-server localhost:9092
Completed updating config for topic products.prices-retention.

如果随后查看某个分区,会注意到一些变化:

$ ls -1 ~/kafka/data/kafka1/products.prices-retention-0
00000000000000000004.index
00000000000000000004.log
00000000000000000004.snapshot
00000000000000000004.timeindex
leader-epoch-checkpoint
partition.metadata

注意:如果看不到新文件,可能是你查看得太快,或者应该检查另一个分区。另外,你也可能看到旧文件带有 .deleted 后缀。

如你所见,我们最初包含已写入消息的文件已经消失了。这是因为我们最后一条消息写入距今已超过 60 秒,因此该 segment 中最新消息的时间早于我们设定的保留期 60 秒,整个 segment 因而被删除。由于每个分区至少包含一个 segment,Kafka 会为该分区创建一个以当前 offset 命名的新 segment。

此外,leader epoch 和 recovery checkpoint 会被相应地调整到当前 offset。之后我们再生产新消息时,这些消息会落在新创建的 segment 中;60 秒后,该 segment 再被删除并创建新的 segment。

日志保留依赖于我们多频繁创建新的 segment。用一个例子来说明更清楚:假设出于合规性原因,我们被允许最多保留消息七天。看上去只把 retention.ms 设为七天似乎足够,但事实并非如此,因为保留期是以 segment 中最新消息的时间为准。只要我们持续往同一个 segment 写消息,该 segment 就无法被删除,因此其中的所有消息都会一直保留。

因此,首先需要保证我们会定期滚动(roll)出新的 segment——正如第 8 章所述,可通过 segment.ms 来控制。第二,要确保 retention.ms 的设置使得最旧的消息在规定的最大保留期限内能被删除。粗略地说,segment.msretention.ms 的和不应大于七天,但这仍不是 100% 精确的计算,因为 Kafka 默认每 5 分钟才检查一次是否可以删除 segment(可通过 log.retention.check.interval.ms 调整),所以还要再减去那 5 分钟左右的时间。

如何在这七天内分配 segment.msretention.ms 会影响消息实际被保留的时长。例如,如果我们每 6 天才滚动一次新的 segment,那么 retention.ms 最多只能设置为 23 小时 55 分钟,这样在删除时该 segment 中的消息可能介于 1 到 7 天之间。相反,如果我们每天滚动一次 segment,并把 retention.ms 设为 5 天 23 小时 55 分钟,那么删除时该 segment 中的消息会介于 6 到 7 天之间。也就是说,越频繁地滚动 segment,越能精细地控制删除行为,同时也可以把 retention.ms 设得更长以保留更多消息。

提示:在旧版 Kafka 中,删除主题所有消息的常用方法是把保留期设为 0,等待删除完成,然后再把保留期重置回原值。虽然这种方法可行,但我们建议改用 Kafka Admin API 来删除主题中的消息。

10.3.2 偏移量(offset)保留

由于消费者组的偏移量也存储在 Kafka 主题中,因此对长期不活跃的消费者组的偏移量进行清理也是有意义的。这个清理由日志清理器完成,并通过 broker 配置 offsets.retention.minutes 来控制,用于设定在多长时间后删除不活跃消费者组的偏移量。默认情况下,不活跃消费者组的偏移量会在 7 天后被删除。

我们不建议将该时间改得太短,因为当遇到 bug 需要停机修复时,可能需要超过一周的时间来恢复消费者组状态。另一方面,偏移量本身几乎不占用存储空间,有时甚至可以考虑把该值设得更长。

10.4 日志压缩(Log compaction)

日志压缩允许我们基于消息的键来识别并删除过时的数据,如图 10.3 所示。与日志保留不同,日志压缩保证对每个键至少保留最新的一条记录。

image.png

日志压缩最好通过一个实战示例来说明。我们先创建一个名为 products.prices-compaction 的新主题:

$ kafka-topics.sh \
    --create \
    --topic products.prices-compaction \
    --partitions 3 \
    --replication-factor 3 \
    --config cleanup.policy=compact \
    --bootstrap-server localhost:9092
Created topic products.prices-compaction.

这次我们通过 --config cleanup.policy=compact 显式设置了压缩策略,因为主题默认通常是以日志保留(delete)作为清理策略。像往常一样尝试发送一条消息:

$ kafka-console-producer.sh \
    --topic products.prices-compaction \
    --bootstrap-server localhost:9092
> cola 2
> ERROR when sending message to topic products.prices-compaction with 
key: null, value: 6 bytes with error: (…) 
org.apache.kafka.common.InvalidRecordException: Compacted topic cannot
accept message without key in topic partition products.prices-compaction-1

这次 Kafka 不允许我们发送没有 key 的消息。因为我们为主题选择了日志压缩作为清理策略,而压缩本身依赖于消息 key 来判定“是否为过时消息”,所以所有没有 key 的消息会被 broker 拒绝。我们改用带 key 的生产命令再试一次(使用 parse.keykey.separator):

$ kafka-console-producer.sh \
    --topic products.prices-compaction  \
    --property parse.key=true \
    --property key.separator=: \
    --bootstrap-server localhost:9092
>cola:2
>coffee pads:10
[…]
>energy drink:5

然后用 kafka-dump-log.sh 检查分区 0 的日志。带上 --print-data-log 参数可以把批次里的数据内容也打印出来,从而看到各条消息的 key 与 value:

$ kafka-dump-log.sh \
    --print-data-log \
    --files ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Dumping ~/kafka/data/kafka1/products.prices-compaction -0/0[……]000.log
Log starting offset: 0
baseOffset: 0 lastOffset: 0 count: 1 [] position: 0
CreateTime: 1738488072332 size: 81 [] crc: 906930835
| offset: 0 CreateTime: 1738488072332 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 5

在我们的示例中,只有 key 为 energy drink 的消息落在了 Partition 0。现在再发送一条相同 key(energy drink)的新消息:

$ kafka-console-producer.sh \
    --topic products.prices-compaction \
    --property parse.key=true \
    --property key.separator=: \
    --bootstrap-server localhost:9092
>energy drink:3

再次查看日志,正如预期,新消息(相同 key)也落在了 Partition 0:

$ kafka-dump-log.sh \
    --print-data-log \
    --files ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Dumping ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Log starting offset: 0
[]
baseOffset: 3 lastOffset: 3 count: 1 [] position: 243
CreateTime: 1738488316569 size: 81 [] crc: 3247897454
| offset: 0 CreateTime: 1738488316569 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 3

10.4.1 何时会触发压缩?

尽管开启了日志压缩,旧的那条消息仍然留在日志中 —— 难道旧消息不应该被删除吗?

注意:在这里我们也可以用 kafka-console-consumer.sh 来验证哪些消息仍可以消费、哪些已被压缩删除。

旧消息仍在的原因是:日志压缩(与日志保留类似)并不是连续不断发生的。首先,默认情况下日志清理器每隔 15 秒检查一次(log.cleaner.backoff.ms)是否有可以删除的消息;其次,每 15 秒扫描整个日志寻找可压缩的旧消息既低效也不现实。因此,日志清理器还依据其它参数来决定是否对某个分区进行压缩。

  • min.cleanable.dirty.ratio:我们可以通过它指定“脏日志”与整个日志的最小比率,只有当脏日志比例达到该阈值时,日志清理器才会考虑压缩该分区。所谓“脏段”(dirty segment)指的是从未被压缩过的段。默认值是 0.5:意思是当未压缩的段占整个分区日志的比例 ≥ 50% 时,才触发压缩。把这个值调低会让压缩更频繁,从而减少因未压缩消息产生的最大存储开销(按示例,0.5 情况下可能会使用大约双倍于必要的空间;若设为 0.2,开销最多约 25%)。

    那为什么不把这个值设为 0 或接近 0 呢?因为日志压缩非常消耗资源:每次压缩都需要读取整个分区日志,如果频繁触发,broker 会被压缩工作“吃掉”几乎所有资源,影响正常生产/消费。

  • min.compaction.lag.msmax.compaction.lag.ms:通过这两个参数可以设置消息在多长时间后可以被压缩(最小滞后)以及最长多长时间必须被压缩(最大滞后)。前者确保消息至少保留一段时间(便于消费者有机会读取到),后者用于对低吞吐的主题进行定期压缩。默认情况下,log.cleaner.min.compaction.lag.ms = 0(即时可压缩),而 log.cleaner.max.compaction.lag.ms 默认接近“无穷大”(约 9223372036854775807),即实际上没有硬性上限。

如图 10.4 所示:当存在未压缩消息超过最大压缩滞后,或(脏比率超过阈值且存在超过最小压缩滞后的未压缩消息)时,该分区会被认为适合压缩。脏日志比率也被用于当多个分区同时满足压缩条件时,确定优先级(优先压缩脏比率更高的分区)。

image.png

现在,我们回到最初的问题:为什么在前面的示例中该分区尚未被压缩?按我们刚才学到的规则,这个分区看起来应该已经被压缩了,但日志清理器(Kafka 中的日志压缩)总是会忽略当前正在写入的段。原因很简单:日志压缩会修改段或重建分区的结构。对当前段(我们正在把新消息写入的段)进行压缩,很容易导致不一致性。

因此,我们必须确保产生一个新的段。可以用熟悉的 segment.ms 参数强制切分段,但也可以通过 max.compaction.lag.ms 达到同样效果。该参数表示:如果段中最旧的消息比这个最大滞后时间还要旧,就会自动创建一个新段。我们把 products.prices-compaction 主题的配置调整为 max.compaction.lag.ms=60000

$ kafka-configs.sh \
    --alter \
    --topic products.prices-compaction \
    --add-config max.compaction.lag.ms=60000 \
    --bootstrap-server localhost:9092

不过,仅此还不足以立刻创建新段。与 segment.ms 类似,只有在产生新消息时才会检查并可能创建新段。因此我们需要再次用 kafka-console-producer.sh 发送一条消息到该分区:

$ kafka-console-producer.sh \
    --topic products.prices-compaction \
    --property parse.key=true \
    --property key.separator=: \
    --bootstrap-server localhost:9092
>energy drink:6

再查看该分区,不出意外会发现新段被创建了:

$ ls -1 ~/kafka/data/kafka1/products.prices-compaction-0
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
00000000000000000004.index
00000000000000000004.log
00000000000000000004.snapshot
00000000000000000004.timeindex
leader-epoch-checkpoint
partition.metadata

注意:如果我们查看得够快,可能还会看到带有 .deleted 扩展名的附加文件。

10.4.2 日志清理器的工作原理

动作快的同学可能已经实时看到日志清理器的工作:原始以 0 为起始偏移的段先被标记为删除(目录名或文件名带上 .delete 扩展),随后被删除。这就是日志清理器的工作流程。它会先扫描未压缩的段并为每个 key 记录最新的 offset,然后从最旧消息开始读取日志,忽略那些被后续条目覆盖(即过时)的 key 或消息,把需要保留的最新消息写入新的段。当这个新段写满后,旧段被替换为新段。由此可见,日志清理器最多只需要额外一段存储空间来完成压缩工作。

现在查看日志内容:

$ kafka-dump-log.sh \
    --print-data-log \
    --files ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Dumping ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Log starting offset: 0
[…]
| offset: 3 CreateTime: 1738488316569 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 3

旧的那些消息确实被删除了,但消息 3 仍然存在——这是因为最新的段不会参与压缩,因而被排除在外。我们再快速发送两条消息然后稍等日志清理器运行:

$ kafka-console-producer.sh \
    --topic products.prices-compaction \
    --property parse.key=true \
    --property key.separator=: \
    --bootstrap-server localhost:9092
>energy drink:5
>energy drink:6

再看分区,会发现日志已再次轮转:

$ ls -1 ~/kafka/data/kafka1/products.prices-compaction-0
00000000000000000000.index
00000000000000000000.log
00000000000000000000.timeindex
00000000000000000005.index
00000000000000000005.log
00000000000000000005.snapshot
00000000000000000005.timeindex
leader-epoch-checkpoint
partition.metadata

kafka-dump-log.sh 检查两个段的内容:

$ kafka-dump-log.sh \
    --print-data-log \
    --files ~/kafka/data/kafka1/products.prices-compaction-0/0[……]005.log
Dumping ~/kafka/data/kafka1/products.prices-compaction-0/0[……]005.log
Log starting offset: 5
[]
| offset: 5 CreateTime: 1738489003023 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 5
[]
| offset: 6 CreateTime: 1738489005780 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 6

当前段包含我们刚刚产生的两条消息,且说明它尚未被压缩;如果已被压缩,当前段中对该 key 只会保留最新一条消息。因此,最新段可以包含任意数量针对同一 key 的消息:

$ kafka-dump-log.sh \
    --print-data-log \
    --files ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Dumping ~/kafka/data/kafka1/products.prices-compaction-0/0[……]000.log
Log starting offset: 0
[…]
| offset: 4 CreateTime: 1738488639215 keysize: 12 valuesize: 1 sequence: 0
headerKeys: [] key: energy drink payload: 6

我们现在看到:被压缩的段只包含最后留下的 Message 6,对于该 key 在已压缩的段中最多只会有一条记录。即使有更多段存在,所有已压缩的段中也只会为每个 key 保留最多一条消息。

另一个重要点是:在日志压缩过程中会创建新段,但消息的 offsets 与消息顺序不变——这很自然,因为压缩仅删除过时条目并合并段,保持 offsets 不变对一致性至关重要

那么,如果消费者尝试读取一个不存在的 offset,会发生什么?在我们的示例中就会出现这种情况(因为被删除的记录导致日志中有“空洞”)。Kafka 处理方式很简单:如果某个 offset 不存在,就跳到下一个可用消息。因此消费者请求 offset 0 的效果与请求 offset 1 相同(会返回下一条存在的消息)。

10.4.3 墓碑记录(Tombstones)

日志压缩还有一个功能:不仅能覆盖旧数据,还可以选择性删除数据。为此只需发送一条 payload 为 null 的消息(所谓 tombstone 或“墓碑消息”) 。日志压缩会把与该 key 相关的旧消息删除。与普通压缩不同的是,tombstone 本身在经过一段时间后也会被删除,最终该 key 在日志中将完全不存在。

默认情况下,tombstone 会在 1 天后被 Kafka 或日志清理器删除,这个时间可以通过每个主题的 delete.retention.ms 或集群级别的 log.cleaner.delete.retention.ms 调整。之所以不立刻删除 tombstone,是为了给消费者留出时间去处理删除操作;如果立即删除,可能会导致一些消费者错过删除事件。另外,删除 tombstone 也能防止集群中出现大量只包含 tombstone 的段,从而避免日志膨胀。

小结

  • Kafka 有两种主要的消息清理方式:日志保留(基于时间或大小删除整段)和日志压缩(按 key 保留最新消息)。
  • 日志压缩不会影响最新段(当前写入段),因此需要段切分才能使旧段变为可压缩对象。
  • 可以用 segment.msmax.compaction.lag.ms 触发新段的创建;后者会在段中最旧消息超过设定滞后时促使切分。
  • 日志清理器会读取未压缩段并将每个 key 的最新消息写入新段,旧段被替换,从而最多只额外占用一个段的存储空间。
  • 压缩过程中保持消息 offset 与顺序不变,这对一致性非常重要;若客户端请求已被删除的 offset,则跳到下一个可用消息。
  • Tombstone(墓碑消息)用于显式删除某个 key 的数据;tombstone 在一段时间后也会被清理,默认保留 1 天,可配置。