本篇是 TSDB 系列翻译文章的第2篇,主要介绍 TSDB 中预写日志的基础知识、存储记录的类型、如何和头块内容映射以及如何做重放等内容。
原文链接: https://ganeshvernekar.com/blog/prometheus-tsdb-wal-and-checkpoint
前言
在 TSDB 博客系列的第1部分中我已经提到,为了持久性我们将传入的样本先写到预写日志(WAL)中,当预写日志被截断时,会创建一个检查点。在这篇博客中,我们将先简要讨论预写日志的基础知识,然后再深入探讨预写日志和检查点在 Prometheus 的 TSDB 中是如何设计的。
由于这是我编写的 Prometheus TSDB 博客系列其中一篇,所以建议您先阅读系列的第1部分,以便了解预写日志在 TSDB 中的位置。
预写日志基础知识
预写日志是数据库中顺序记录发生事件的日志。在写入/修改/删除数据库中的数据之前,先将事件记录(追加)到预写日志中,然后再在数据库中执行相应的操作。
不管什么原因,无论是机器还是程序崩溃,只要您在预写日志中记录了事件,就可以按照相同的顺序重放这些事件来恢复数据。这对于内存数据库尤为有用,因为在内存数据库中,如果不使用预写日志,一旦数据库崩溃,内存中的数据将全部丢失。
这在关系数据库中被广泛使用,为数据库提供了持久性(ACID 中的 D)。相似的,Prometheus 也用预写日志为头块(Head block)提供持久性。Prometheus 还使用预写日志进行优雅重启后内存状态的恢复。
在 Prometheus 的上下文中,预写日志仅用于记录事件并在启动时恢复内存中的状态。它不以任何其它方式进行读写操作。
在 Prometheus TSDB 预写日志中写入
记录的类型
TSDB 中的写请求由序列的标签值和对应的样本组成。这给我们提供了两种记录,序列(Series)和样本(Samples)。
序列(Series)记录由写请求中序列的所有标签值组成。序列的创建会产生一个唯一的引用,通过这个引用可以查找到该序列。因此样本(Samples)记录包含了序列的引用和写请求中属于该序列的样本列表。
最后一种记录类型是用于删除请求的墓碑(Tombstones)。它包含已删除序列的引用和要删除的时间范围。
这些记录的格式可以在这里找到,在这篇博客中我们不会讨论它们。
写入记录
对于包含样本的所有写请求,都会写到样本(Samples)记录中。序列(Series)记录只会写入一次,即我们第一次看到它的时候(因此它只在头块中”创建”)。
如果一个写请求包含新的序列,序列(Series)记录通常会比样本(Samples)记录先写到预写日志,不然的话如果样本(Samples)记录先写,那么在重放时这些样本(Samples)记录的序列引用将找不到对应的序列(Series)记录。
序列(Series)记录是在头块中创建后再写到预写日志中的,并且记录包含了它的引用,而样本(Samples)记录是先写到预写日志后再添加到头块中的。
通过将同一记录中的所有不同时间序列(以及不同时间序列的样本)进行分组,每个写入请求仅写入一个序列或样本记录。如果请求中所有样本的序列已存在头块中,则仅将样本记录写入预写日志。
当我们接收到一个删除请求后,我们不会立即将其从内存中删除。我们先存储一个叫做“墓碑”(tombstones)的东西,它表示已删除的序列和删除的时间范围。在处理删除请求之前,我们将写入一个墓碑(Tombstones)记录到预写日志。
磁盘上的样子
默认情况下,预写日志存储为大小为 128MiB 的一系列连续编号文件。一个预写文件在这里称为一个“段”(segment)。
data
└── wal
├── 000000
├── 000001
└── 000002
有边界的文件大小会使回收过时的文件变得简单。您可以猜到,它们的序列号总是递增的。
预写日志截断和检查点
我们需要定期删除预写日志中旧的段,否则,磁盘最终会被填满,TSDB 启动会花费大量的时间,因为它必须重放此预写日志中的所有事件(其中大部分会因过时而被丢弃)。通常,您需要忽略那些不再需要的数据。
预写日志截断
预写日志截断发生在头块截断完成之后(有关头块截断的信息,请参见第1部分)。文件不能随意的删除,而且删除操作只会针对前 N 个文件,这样不会造成序列的间隔。
由于写请求可以是随机的,因此在不遍历所有记录要确定预写日志段中样本的时间范围既不容易又不高效。所以我们只删除了前 2/3 的段。
data
└── wal
├── 000000
├── 000001
├── 000002
├── 000003
├── 000004
└── 000005
以上示例,只删除 000000、000001、000002、000003 这几个文件。
这里有一个陷阱:因为序列记录仅写入一次,所以如果盲目删除预写日志段,则会丢失这些记录,因此在启动时无法恢复这些序列。另外,在前 2/3 的段中可能还有一些样本没有从头块中截断,因此可能也会丢失它们。这是检查点存在的原因。
检查点
在预写日志被截断之前,我们从那些要被删除的预写日志段中创建一个“检查点”。您可以将检查点视为一个经过过滤的预写日志。考虑这种情况,还是以上面的预写日志为例,假如头块中的数据在时间T处发生截断,检查点将依次遍历 000000 、000001 000002 000003 中的所有记录,并按照如下顺序进行操作:
- 删除头块中不再使用的序列记录。
- 删除时间T之前的所有样本。
- 删除时间T之前的范围内的所有墓碑记录。
- 将剩余的序列、样本和墓碑记录以预写日志查找到的相同形式保留(而且顺序与预写日志中出现的相同)。
- 删除操作也可以是重写操作,从而从记录中删除不必要的项目(因为单个记录可以包含多个序列、样本或墓碑)。
这样,您就不会丢失仍在头块中的序列、样品和墓碑。该检查点的名称为 checkpoint.X,其中 X 是在其上创建检查点的最后一个续写日志段的编号(此处为 00003,您将在下一节中知道为什么要这样做)。
在预写日志被截断并且创建检查点之后,磁盘上的文件看起来像这样(检查点看起来又是另一个预写日志):
data
└── wal
├── checkpoint.000003
| ├── 000000
| └── 000001
├── 000004
└── 000005
如果有任何旧的检查点,将其全部删除。
重放预写日志
我们首先从最后一个检查点开始按顺序遍历记录(与它关联的编号最大的为最后一个检查点)。对于checkpoint.X,X 告诉我们需要从哪个预写日志段开始继续重放,即 X + 1。因此,在上面的示例中,在重放 checkpoint.000003 之后,我们需要从000004 段开始。
您可能会想为什么要在删除预写日志段之前,需要在检查点中记录这个段的编号。关键原因在于,创建检查点和删除预写日志段不是原子的。在进行这两个操作时可能发生一些未知的情况,导致预写日志段未被真正删除。这样的话我们就不得不重放已被删除的另外 2/3 的预写日志段,从而导致重放速度变慢。
针对单个记录,它们将按照如下顺序进行操作:
- 序列(Series):在头块中创建对应的序列并包含这个引用(以便我们以后可以匹配样本)。Prometheus 可以通过引用映射来处理同一序列的多个序列记录。2
- 样本 (Simples):将此记录中的样本添加到头块中。记录中的引用说明属于哪个序列。如果这里找不到对应的序列,则跳过该样本。
- 墓碑 (Tombstones):将这些墓碑存回头块中并使用引用标识所属的序列。
关于预写日志读写的底层细节
当有大量写入请求到来时,要避免磁盘的随机写入,从而避免写放大。此外在读取记录时,您要确保记录没有损坏(损坏很容易发生在突然关机或磁盘故障时)。
Prometheus 实现了一个普通版的预写日志,其中一条记录只是一个字节切片,调用者必须负责对记录进行编码。为了解决以上两个问题,预写日志包会执行以下操作:
- 数据一次写入一页磁盘。一页的长度为 32KiB。如果记录大于 32KiB,则将其拆解为多个较小的块,每块包含一个预写日志记录头,以便进行标识,用于区分这个块属于记录的结尾,起始还是中间部分(一个记录会有一个预写日志记录头即使它刚好一页)。
- 记录的校验和会附加在末尾,在读取时用于检测是否损坏。
预写日志包负责无缝地拼接这些片段,并在遍历记录的时候检查记录的校验和以用于重放。
默认情况下,预写日志记录不进行很重的压缩(或完全不压缩)。所以预写日志包提供了Snappy(现在默认开启)的压缩选项。此信息存储在预写日志记录头中,所以压缩和未压缩的记录可以同时存在,以便您随时打开或关闭这个选项。
代码参考
tsdb/wal/wal.go 中提供了预写日志的实现,该实现将记录转为字节切片以确保和磁盘进行低频率交互。该文件的实现包含了记录的写入和迭代(再次作为字节切片)。
tsdb/record/record.go 包含各种记录的编解码逻辑。
检查点相关逻辑放在 tsdb/wal/checkpoint.go。
tsdb/head.go 包含剩下的逻辑:
- 创建和编码记录并调用预写日志的写。
- 调用检查点创建和预写日志截断。
重放预习日志记录,对其进行解码并恢复内存中的状态。
关于 TSDB 预期将来会有更多的文章
- 从磁盘映射到内存 chunk 的内存映射。Prometheus TSDB (Part 3): 从磁盘映射到内存 chunk 的内存映射
- 持久块及其索引。Prometheus TSDB (Part 4): 持久块及其索引
- TSDB 的查询。
- 压实。
- 对内存中的 chunk 进行快照,以便在插入时更快地重启。