1 个不稳定版本

0.1.0 2023 年 4 月 18 日

#1800数据库接口

MIT/Apache

84KB
2K SLoC

tstorage-rs

Rust 的嵌入式时间序列数据库。

关于

本项目参考了 nakabonne/tstorage。大部分实现想法都来自该项目,以及他们写的优秀的博客文章 从头开始写时间序列数据库

我建立这个项目是因为我想更深入地了解数据库实现,并提高我的 Rust 知识。本项目尚未在生产环境中进行测试。

使用方法

以下是如何向数据库插入和选择数据的示例

fn main() {
    let mut storage = Storage::new(Config {
        partition: PartitionConfig {
            duration: 100,
            hot_partitions: 2,
            max_partitions: 2,
        },
        ..Default::default()
    }).unwrap();

    storage.insert(&Row {
        metric: "metric1",
        data_point: DataPoint {
            timestamp: 1600000000,
            value: 0.1,
        },
    }).unwrap();

    let points = storage.select("metric1", 1600000000, 1600000001).unwrap();
    for p in points {
        println!("timestamp: {}, value: {}", p.timestamp, p.value);
        // => timestamp: 1600000000, value: 0.1
    }

    storage.close().unwrap();
}

有关使用 Storage 的更多示例,请参阅 examples/ 目录。

架构

数据库在 "分区" 中存储点,分区是基于时间的块,负责特定时间范围内所有数据点。分区可以是 "热"(内存中,可写)或 "冷"(磁盘上,只读)。当分区被刷新到磁盘时,该分区中的所有点都存储在同一个文件中。

数据库维护一个分区列表,这些分区按时间范围升序存储。当尝试向数据库中插入数据点时,找到负责的分区并将数据点插入。如果数据点相对于所有分区都是将来的,则会创建一个新分区(或多个新分区)。例如,假设我们有一个允许 2 个 "热" 分区的存储实例,如下所示

┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ │ │ │ │ │ │ │ │ │ 磁盘分区 │ │ 磁盘分区 │ │ 磁盘分区 │ │ 内存分区 │ │ 内存分区 │ │ t=0 to 100 ├──►│ t=100 to 200 ├──►│ t=200 to 300 ├──►│ t=300 to 400 ├──►│ t=400 to 500 │ │ │ │ │ │ │ │ │ │ │ └────────────────┘ └────────────────┘ └────────────────┘ └────────────────┘ └────────────────┘ ▲ ▲ │ │ └─────────────────────┤ │ 可写

时间从 t=300 到 500 的数据点将被插入到两个内存分区中的一个。可以设置插入窗口以进一步限制对数据点的容错性。如果插入了一个时间戳超过 t=500 的数据点,则会创建一个新的分区来支持该数据点,而负责 t=300 到 400 的分区的可写性将不再,并将很快被刷新(在下一次 sweep_interval 中)。

如果插入一个新的分区,例如时间戳 t=750,则将创建三个新的分区:一个从 t=500 到 t=600 的空分区,另一个从 t=600 到 t=700 的空分区,然后是 t=700 到 t=800 的分区,即插入点所在的位置。如果数据点插入到未来很远的地方,这可能会导致问题,因此数据库支持设置一个限制,以限制数据点可以插入到未来的时间。

用于刷新分区的异步循环也处理分区过期,以避免内存或磁盘空间不足。最初,在创建新分区时,分区刷新和过期是同步进行的,但最终导致插入速度变慢,因为插入不会在创建新分区、刷新分区和删除最旧分区之前完成。刷新分区涉及将分区中的所有数据点写入磁盘上的文件,这可能需要大量 CPU 资源。

并发性

使用 RwLock 而不是 Mutex 来最小化对分区列表的竞争。例如,在刷新分区时,刷新过程会保持对分区列表的读锁并创建所有磁盘分区等效物。在此期间,选择操作仍然可以正常工作,因为它们只需要读锁,插入操作仍然可以正常工作,因为系统将插入窗口限制为“热”分区,即未刷新的分区。一旦生成磁盘分区,就短暂持有写锁以交换内存分区和刚刚刷新的磁盘分区之间的引用。这会阻止对分区列表的所有其他操作,但这是一个低成本的运算,因此不应影响系统。

配置

可以在数据库中配置的主要设置包括分区大小、最大分区数和要保留在热存储中的分区数。当热分区数等于最大分区数时,数据库将完全在内存中运行。否则,分区将刷新到给定的数据路径上的磁盘,并按指定的编码策略进行内存映射。

编码

当前支持的编码策略是 CSV 格式(便于调试)和 Gorilla 格式(双 delta 压缩)。已刷新的分区位于目录 p-{start_timestamp}-{end_timestamp} 中,该目录包含一个元文件和一个数据文件。数据文件存储编码数据点,而元文件包含有关分区中的指标的信息,例如指标名称、数据点数量,以及数据文件中每个指标数据点的寻址偏移量。在 nakabonne 的文章 中的 Disk Partition 部分更详细地描述了此模式。

examples/simple.rs 中的示例运行中,每个分区(100 个点)在 CSV 格式中压缩到 1500 字节,在 Gorilla 格式中压缩到 49 字节。

TODO

  • 基本接口
  • 在内存中存储有序数据点
  • 支持基本查询
  • 支持内存分区
  • 支持无序数据点
  • 支持基准测试
  • 支持磁盘分区
  • 支持磁盘分区中的 Gorilla 压缩
  • 支持 WAL
  • 支持指标标签
  • 支持时间戳精度

实现说明

设计决策

无序写入

由于网络延迟、应用延迟或其他因素,数据点顺序错误是一种非常现实的情况,因此期望每个数据点严格有序是不合理的。在支持顺序错误写入时,一种选择是允许通过将它们按顺序插入数据点列表来插入(并立即可查询)。这可能会降低插入性能,因为顺序错误的插入是线性的,但它允许顺序错误的数据点立即可查询。此外,可以通过插入窗口(下面详细介绍)来限制性能下降。另一种选择是将顺序错误的数据点存储在单独的列表中,以保持恒定的插入性能,但在刷新到磁盘分区之前进行排序和合并。

第二种选择性能更高,然而时间序列数据通常需要的是最近、接近实时的值,而不是历史数据。等待数据点刷新到磁盘才能查询可能意味着它们一个小时或更长时间内不可用。因此,为了使数据点立即可查询而牺牲性能似乎是最合适的选择。

全局插入窗口与每个度量插入窗口

插入窗口的作用是允许数据点在一定时间内写入顺序错误,限制限制以保护插入性能。例如,在大内存分区开始插入可能会是线性性能,可能会降低系统性能。插入窗口对此窗口进行限制,以便延迟的数据点仍然可以写入和立即查询。

主要问题是是否应该全局应用插入窗口,还是按度量应用。例如,假设我有一个内存分区,其中有两个度量,以下数据点的时间戳

metricA | 2 | 3 | 4 | 5
metricB | 2

我可能不想允许时间戳为1的数据点插入到metricA,然而对于metricB来说,插入它可能不会那么大的性能问题。按度量支持插入窗口可以允许插入更多顺序错误的数据点。然而,这可能意味着具有相同时间戳的两个数据点可以发送到数据库,但只有其中一个会被写入。这使得插入更难以理解。此外,我们不得不移除分区边界优化,并查看每个度量条目以确定数据点是否可以插入。全局限制似乎是前进的最简单路径,尽管它更严格。

缺点

远在未来

如果rstorage接收到一个远在未来的异常数据点,它会为它创建一个新的分区,并取决于可写入分区的数量,使旧分区不可写入。这意味着一个远在未来的错误数据点可能会使系统过期所有现有数据点,并拒绝在其他合理的时间戳拒绝其他数据点。例如,给定以下时间戳的数据点流

0 1 2 99 4 5 6 7

和保留10,一旦接收到t=99的数据点,那么4 5 6 7将被拒绝。

为了防止这种情况,可以设置对数据点未来插入的限制。然而,这个问题是,如果系统一段时间没有接收到数据点(有合法原因),然后又开始接收数据点,系统可能会认为所有有效数据点都太远了。例如,假设系统接收到以下数据点

0 1 2 <no metrics for a while> 20 21 22 23 ...

对未来的插入设置限制,可能会导致未来的每个时间戳都被拒绝,完全锁定系统。

依赖关系

~10-22MB
~295K SLoC