本篇是 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 的上下文中,预写日志仅用于记录事件并在启动时恢复内存中的状态。它不以任何其它方式进行读写操作。
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
中的所有记录,并按照如下顺序进行操作:
删除操作也可以是重写操作,从而从记录中删除不必要的项目(因为单个记录可以包含多个序列、样本或墓碑)。
这样,您就不会丢失仍在头块中的序列、样品和墓碑。该检查点的名称为 checkpoint.X
,其中 X
是在其上创建检查点的最后一个续写日志段的编号(此处为 00003
,您将在下一节中知道为什么要这样做)。
在预写日志被截断并且创建检查点之后,磁盘上的文件看起来像这样(检查点看起来又是另一个预写日志):
data
└── wal
├── checkpoint.000003
| ├── 000000
| └── 000001
├── 000004
└── 000005
如果有任何旧的检查点,将其全部删除。
我们首先从最后一个检查点开始按顺序遍历记录(与它关联的编号最大的为最后一个检查点)。 对于checkpoint.X
,X
告诉我们需要从哪个预写日志段开始继续重放,即 X + 1
。 因此,在上面的示例中,在重放 checkpoint.000003
之后,我们需要从000004
段开始。
您可能会想为什么要在删除预写日志段之前,需要在检查点中记录这个段的编号。关键原因在于,创建检查点和删除预写日志段不是原子的。在进行这两个操作时可能发生一些未知的情况,导致预写日志段未被真正删除。这样的话我们就不得不重放已被删除的另外 2/3 的预写日志段,从而导致重放速度变慢。
针对单个记录,它们将按照如下顺序进行操作:
Series
):在头块中创建对应的序列并包含这个引用(以便我们以后可以匹配样本)。Prometheus 可以通过引用映射来处理同一序列的多个序列记录。Simples
):将此记录中的样本添加到头块中。记录中的引用说明属于哪个序列。如果这里找不到对应的序列,则跳过该样本。Tombstones
):将这些墓碑存回头块中并使用引用标识所属的序列。当有大量写入请求到来时,要避免磁盘的随机写入,从而避免写放大。此外在读取记录时,您要确保记录没有损坏(损坏很容易发生在突然关机或磁盘故障时)。
Prometheus 实现了一个普通版的预写日志,其中一条记录只是一个字节切片,调用者必须负责对记录进行编码。为了解决以上两个问题,预写日志包会执行以下操作:
预写日志包负责无缝地拼接这些片段,并在遍历记录的时候检查记录的校验和以用于重放。
默认情况下,预写日志记录不进行很重的压缩(或完全不压缩)。所以预写日志包提供了Snappy(现在默认开启)的压缩选项。此信息存储在预写日志记录头中,所以压缩和未压缩的记录可以同时存在,以便您随时打开或关闭这个选项。
tsdb/wal/wal.go 中提供了预写日志的实现,该实现将记录转为字节切片以确保和磁盘进行低频率交互。该文件的实现包含了记录的写入和迭代(再次作为字节切片)。
tsdb/record/record.go 包含各种记录的编解码逻辑。
检查点相关逻辑放在 tsdb/wal/checkpoint.go。
tsdb/head.go 包含剩下的逻辑:
每当有新的博文完成时,我都会使用指向它的链接来更新此部分内容。如果我错过了此列表中的任何内容,请告诉我!
更多云原生可观性一手资料,请关注微信公众号 "cloudnativemetric" 或扫描二维码。