Skip to content
0

prometheus tsdb - wal and checkpoint

引言

在 TSDB 博客系列的第一部分中,我提到过我们会先把接收到的样本写入预写日志(WAL, Write-Ahead Log)以保证持久性,并且当这个 WAL 被截断时,会创建一个检查点(checkpoint)。在这篇博客中,我们将简要讨论 WAL 的基本原理,然后深入介绍 Prometheus TSDB 中 WAL 和 checkpoint 是如何设计的。 由于这是我正在撰写的 Prometheus TSDB 博客系列 的一部分,建议你先阅读第一部分,以了解 WAL 在 TSDB 中所处的位置。

WAL 基础

WAL 是数据库中发生事件的顺序日志。在对数据库中的数据进行写入、修改或删除之前,事件会先被记录(追加)到 WAL 中,然后才会在数据库中执行相应操作。 如果因为某种原因机器或程序崩溃了,你仍然可以依靠 WAL 中记录的事件,按照相同的顺序回放这些事件,从而恢复数据。对于内存型数据库来说,这一点尤为重要,因为如果数据库崩溃,没有 WAL 的话,内存中的数据将会全部丢失。 WAL 在关系型数据库中被广泛使用,用来保证数据库的持久性(即 ACID 原则中的 D:Durability)。类似地,Prometheus 也使用 WAL 为其 Head 块提供持久性支持。同时,Prometheus 还利用 WAL 来实现优雅重启(graceful restart),以便在启动时恢复内存状态。 在 Prometheus 的语境下,WAL 仅用于记录事件,并在启动时恢复内存状态。除此之外,它不会参与其他读写操作。

在 Prometheus TSDB 中写入 WAL

记录的类型

在 TSDB 中,写入请求由时间序列的标签值(label values)以及对应的样本(samples)组成。因此有两种类型的记录:

  1. Series 记录:包含写入请求中所有时间序列的标签值。创建一个新的序列会生成一个唯一的引用(reference),用于查找该序列。 → 因此 Samples 记录 就包含了对应序列的引用,以及属于该序列的样本。
  2. Samples 记录:保存具体的样本数据及其所属的序列引用。
  3. Tombstones 记录:用于删除请求,包含被删除的序列引用以及对应的时间范围。 (这些记录的具体格式可以在官方文档中找到,这里不展开讨论。)

写入方式

  • Samples 记录:对于所有包含样本的写入请求,都会写入 Samples 记录。
  • Series 记录:只在首次出现某个新序列时写入(即在 Head 中“创建”它)。 ⚠️ 注意:如果写入请求包含新的序列,Series 记录必须总是在 Samples 记录之前写入,否则在回放时,Samples 记录中的序列引用就找不到对应的序列。
  • Series 记录:在 Head 中创建序列后写入,以便在记录中保存该引用。
  • Samples 记录:在向 Head 添加样本之前写入。 另外,每个写入请求只会写入 一个 Series 记录和一个 Samples 记录,通过把所有不同时间序列(以及它们的样本)组合到同一个记录里完成。 如果请求中的所有样本都对应于 Head 中已有的序列,则只会将 Samples 记录 写入 WAL。
  • 删除请求:当我们接收到删除请求时,并不会立即从内存中删除。相反,我们会存储一个称为 Tombstones 的标记,表示被删除的序列以及删除的时间范围。在处理删除请求之前,我们会先把 Tombstones 记录写入 WAL。

wal在磁盘上如何展示

WAL(预写日志,Write-Ahead Log)默认以一系列编号文件的形式存储,每个文件大小为 128MiB。 这里的每个 WAL 文件称为一个 “段”(segment)。

data
└── wal
    ├── 000000
    ├── 000001
    └── 000002

文件的大小是固定的,这样做是为了让旧文件的垃圾回收(删除)更简单。 正如你所猜的那样,文件的序列号会 不断递增。

WAL 截断(Truncation)与检查点(Checkpointing)

我们需要定期删除旧的 WAL 段文件,否则磁盘最终会被占满,并且 TSDB 启动时会非常耗时——因为它需要重放整个 WAL 中的所有事件(其中大部分其实是过期的、会被丢弃的数据)。 总体来说,任何不再需要的数据,都应该被清理掉。

WAL 截断(WAL Truncation)

WAL 的截断操作会在 Head 块截断之后进行(参见第 1 部分中关于 Head 截断的说明)。 这些文件不能随意删除,删除时必须从开头连续删除前 N 个文件,不能在序列号中留下空缺。 由于写入请求可能是随机的,要准确确定某个 WAL 段中样本的时间范围并不容易,也不高效——除非遍历其中的所有记录。 因此,系统通常会删除前 2/3 的 WAL 段文件。

data
└── wal
    ├── 000000
    ├── 000001
    ├── 000002
    ├── 000003
    ├── 000004
    └── 000005

在上面的例子中,文件 000000、000001、000002、000003 会被删除。 不过,这里有一个关键问题:序列记录(series records)只会被写入一次。 如果直接删除这些 WAL 段,你会丢失这些序列信息,导致系统在启动时无法恢复这些时间序列。 此外,前 2/3 的 WAL 段中可能仍然包含一些尚未从 Head 截断的样本数据,这样你也会丢失部分样本。 这时,检查点(checkpoint) 机制就派上用场了。

检查点(Checkpointing)

在截断(truncate)WAL 之前,我们会从即将被删除的 WAL 段中创建一个 “检查点(checkpoint)”。你可以把检查点理解为一个 经过过滤的 WAL。假设 Head 的截断操作是针对 时间早于 T 的数据,结合上面的 WAL 布局示例,checkpoint 创建过程会按顺序遍历 000000、000001、000002、000003 中的所有记录,并执行以下操作:

  • 删除所有已经不再存在于 Head 中的时间序列(series)记录
  • 删除所有时间早于 T 的样本(sample)
  • 删除所有时间范围早于 T 的墓碑(tombstone)记录
  • 保留其余仍然存在的 series、sample 和 tombstone 记录,

保留方式与它们在 WAL 中的存储方式一致,并且 保持它们在 WAL 中出现的原始顺序。在丢弃不需要的数据时,可能会伴随 重写(rewrite)操作,因为 一个 WAL 记录中可能包含多个 series、sample 或 tombstone,需要在移除无关内容后重新写入。

通过这种方式,可以确保 仍然存在于 Head 中的 series、sample 和 tombstone 不会丢失。生成的检查点文件命名为 checkpoint.X,其中 X 表示创建该检查点时所覆盖的最后一个 WAL 段号(这里是 000003;为什么这样命名会在下一节解释)。

在完成 WAL 截断和 checkpoint 创建之后,磁盘上的文件结构大致如下(checkpoint 看起来就像另一个 WAL):

data
└── wal
    ├── checkpoint.000003
    |   ├── 000000
    |   └── 000001
    ├── 000004
    └── 000005

如果此时存在更早的检查点文件,它们会被删除。

WAL 回放(Replaying the WAL)

在回放 WAL 时,我们会 先从最新的 checkpoint 开始,也就是 编号最大的 checkpoint。 对于 checkpoint.X 来说,X 表示从哪个 WAL 段号之后继续进行回放,也就是 从 X + 1 开始。因此,在上面的例子中,在回放完 checkpoint.000003 之后,会继续从 WAL 段 000004 开始回放。 你可能会想,既然 checkpoint 创建完成后会删除它之前的 WAL 段,为什么还需要在 checkpoint 中记录段号?原因在于:checkpoint 的创建和 WAL 段的删除并不是原子操作。在这两步之间,可能会发生任何事情,从而导致 WAL 段没有被成功删除。因此,在这种情况下,我们就需要 额外回放本应被删除的 2~3 个 WAL 段,这会让回放过程变慢一些,但可以保证数据不丢失。 针对不同类型的 WAL 记录,回放时会执行以下操作:

  • Series(时间序列): 使用记录中给出的 reference,在 Head 中创建对应的时间序列(以便后续样本能够正确匹配)。同一个时间序列可能会有多条 series 记录,Prometheus 会通过 reference 映射来正确处理这种情况。
  • Samples(样本): 将该记录中的样本加入到 Head 中。记录里的 reference 用于指示要把样本加入到哪个时间序列。如果找不到对应 reference 的时间序列,则跳过该样本。
  • Tombstones(墓碑): 通过 reference 来识别对应的时间序列,并将这些 tombstone 重新存储回 Head 中。

WAL 读写的底层细节(Low level details of writing to and reading from WAL)

当写入请求量很大时,需要避免对磁盘进行随机写入,以减少写放大。此外,在读取记录时,还需要确保数据没有被破坏(例如在异常关机或磁盘故障的情况下,这种问题很容易发生)。 Prometheus 提供了一套通用的 WAL 实现,其中 一条记录本质上只是一个字节切片(slice of bytes),而具体的记录编码由调用方负责。为了解决上述两个问题,WAL 包做了以下事情:

  • 按页写入磁盘: 数据以页(page)为单位写入磁盘,每个 page 的大小是 32KiB。如果一条记录大于 32KiB,就会被拆分成多个较小的片段,每个片段都会带有一个 WAL 记录头(record header),用于标识该片段是记录的开始、中间部分还是结束部分(即使记录本身能够完全放入一个 page,也同样会有 WAL 记录头)。
  • 校验和(checksum): 在每条记录的末尾都会附加一个校验和,用于在读取时检测数据是否发生损坏。
  • 自动拼接与校验: WAL 包在回放记录时,会自动将被拆分的记录片段无缝拼接成完整记录,并在遍历记录进行回放时校验其校验和。 默认情况下,WAL 记录并不会进行强压缩(甚至可能完全不压缩)。因此,WAL 包提供了使用 Snappy 对记录进行压缩的选项(现在默认已启用)。是否压缩的信息会存储在 WAL 记录头 中,所以即使在开启或关闭压缩的过程中,压缩和未压缩的记录也可以同时存在于同一个 WAL 中。

参考文献:

https://ganeshvernekar.com/blog/prometheus-tsdb-wal-and-checkpoint/

最近更新