菜单
开源

预写日志

作者:Owen Diehl - owen-d ( Grafana Labs)

日期:2020/09/30

动因

Loki 已采取许多步骤来确保日志数据的持久性,最显著的是在 ingester 中使用了可配置的复制因子(冗余)。然而,在持久性保证方面仍有许多不足之处,特别是对于单二进制部署。本提案概述了一种预写日志(WAL),通过允许在 ingester 组件的本地磁盘上存储/重放传入写入,来补充现有措施。

策略

我们建议采用两阶段 WAL 实现,包括对已接受写入(段)的初始记录以及后续的检查点(checkpoints),检查点将第一阶段的数据合并为更高效的表示形式,以加快重放速度。

段是第一阶段、最基本的 WAL。它们存储已接受传入写入的单个记录,可用于在没有任何外部输入的情况下重建 ingester 的内存状态。每个段是 32kB 的倍数,填满一个段后,就会创建一个新段。Loki 最初会尝试 256KB 的段大小,并根据需要进行调整。它们在磁盘上按顺序命名,达到目标大小时会自动创建,如下所示

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

截断

为了防止无限制增长并将已刷新到存储的操作从 WAL 中移除,会定期截断 WAL,并以可配置的时间间隔 (ingester.checkpoint-duration) 删除除最后一个活动段之外的所有段。这就是检查点发挥作用的地方。

检查点

在截断 WAL 之前,我们将 WAL 段前进一位,以确保不会删除当前正在写入的段。目录结构将如下所示

data
└── wal
   ├── 000000
   ├── 000001
   ├── 000002 <- likely not full, no matter
   └── 000003 <- newly written, empty

每个内存中的流会按照一个间隔(由 checkpoint_duration / in_memory_streams 计算得出)进行迭代,并写入检查点。检查点完成后,会从其临时目录移动到 ingester.wal-dir,并以开始之前最后一个段的名称(checkpoint.000002)命名,然后删除所有适用的段(00000、00001、00002)以及任何先前的检查点。

之后,它将如下所示

data
└── wal
   ├── checkpoint.000002 <- completed checkpoint
   └── 000003 <- currently active wal segment

检查点操作排队

可能一个检查点操作正在运行时,另一个检查点操作同时开始。在这种情况下,现有检查点操作应忽略其内部计时器,并尽快刷新其 series。之后,下一个检查点操作即可开始。这可能会在后续检查点操作的分摊效应生效之前产生局部的 IOPS 峰值,这也是将 WAL 运行在独立磁盘上以缓解“吵闹的邻居”问题的重要原因。写入/移动当前检查点后,我们将清除旧的检查点。

WAL 记录类型

当 ingester 接收到尚不存在于内存中的 series 的推送时,会写入一个 Stream 记录类型。从高层面看,它将包含

golang
type SeriesRecord struct {
	UserID  string
	Labels labels.Labels
	Fingerprint uint64 // label fingerprint
}

日志

当 ingester 接收到推送时,会写入 Logs 记录类型,其中包含其引用的 series 的指纹以及 (timestamp, log_line) 元组列表,如果适用,此操作会在写入 Stream 记录类型之后进行。

golang
type LogsRecord struct {
	UserID  string
	Fingperprint uint64 // label fingerprint for the series these logs refer to
	Entries []logproto.Entry
}

恢复

重放 WAL 的方法是将任何可用检查点加载到内存中,然后重放后续命名段中的任何操作(checkpoint.000003 -> 000004 -> 000005 等)。这些操作中的一些可能会失败,因为它们已包含在检查点中(由于我们分摊中引入的延迟),但这没问题——我们不会丢失任何数据,只会尝试写入一些数据两次,这将被忽略。

部署

引入 WAL 要求 ingester 具有持久化磁盘,并在重启后重新连接(这非常适合 Kubernetes 中的 StatefulSets)。此外,建议 WAL 使用独立的磁盘,使其免受“吵闹的邻居”问题的影响或引发该问题,特别是在任何 IOPS 峰值期间。

实现目标

  • 尽可能使用底层的 prometheus wal 包以保持一致性并减轻无差别繁重工作。接口处理页面对齐并使用 []byte。
    • 确保此包能够处理任意长度的记录(在 Loki 的情况下是日志行)。
  • 确保我们的内存表示可以高效地与 []byte 互相转换,以便生成用于从检查点快速/高效加载的转换。
  • 确保已刷新到存储的 chunk 在 WAL 重放后仍保留 ingester.retain-period 设定的时间。

备选方案

使用 Cortex WAL

由于我们不是从 WAL 记录进行检查点操作,而是进行内存转储,因此瓶颈不在于吞吐量,而在于内存大小。所以我们可以从基于时长的检查点开始,而不是同时考虑吞吐量。这使得提议的解决方案与 Cortex WAL 方法几乎相同。需要注意的是,在检查点操作之间,WAL 段会累积,并可能构成大量数据(日志吞吐量变化很大)。如果基于时长的检查点被证明不足,我们最终可能会考虑其他方法来处理这个问题。

不从内存构建检查点,而是写入新的 WAL 元素

与从内存构建检查点不同,这种方法将相同的效率构建到两种不同的 WAL 记录类型中:Blocks 和 FlushedChunks。前者是一种记录类型,它将在切割后包含整个压缩块,而后者将在刷新时包含整个 chunk + 其持有的块序列。这可以对写入提供足够好的分摊效果,因为块切割被认为是均匀分布的,而 chunk 刷新具有相同的属性并使用抖动进行同步。

这可用于丢弃已超过 ingester.retain-period 的 WAL 记录,从而实现更快的 WAL 重放和更高效的加载。

golang
type FlushRecord struct {
  Fingerprint uint64 // labels
  FlushedAt uint64 // timestamp when it was flushed, can be used with `ingester.retain-period` to either keep or discard records on replay
  LastEntry logproto.Entry // last entry included in the flushed chunk
}

它还允许在不依赖 ingester 内部状态的情况下构建检查点,但这可能需要多个 WAL,按记录类型分区,以便能够迭代所有 FlushedChunks -> Blocks -> Series -> Samples,从而我们可以对被前者类型取代的后续(较低优先级)类型执行无操作。考虑到更简单的建议备选方案以及当 ingester 内部更改时需要添加新记录类型的可扩展性成本,这里的收益似乎不值得付出代价。