Golang之 golang common storage 内存时序数据库引擎

Golang 通用基础库: storage时序数据库引擎

Posted by 董江 on Monday, May 22, 2023

内存时序数据库引擎storage

背景

近期研究内存统计stats,并可以快速通过statistics数学方法进行统计活动相关数据。 比如统计历史5min、15min、1h、1d的平均值、最大值、最小值和方差。

需要的几个要求和特点:

  1. 按时间排序:按metrics产生时间 排序或者存储
  2. 高基数:本身就是内存监控数据,需要更高的基数
  3. 批量读:读出时,主要是通过指定时间段来获取多个具有连续时间戳的数据点。即range能力
  4. 仅追加写:数据按产生的时间进行追加写,不会对历史时间进行update
  5. 时间窗口:记录latest的时间窗口数据,对于过旧数据,进行淘汰。

Google 的LevelDB是众所周知的键值存储引擎,并且已经发布了一些 Go 实现。这个LevelDB是基于LSM树实现的,只顺序写入尾部;它适用于仅追加写时间序列数据。 它按键排序的内容也使其适用于基于时间戳的时间序列数据。

然而, 时间序列数据是单调递增的, 并且在内存数据中,LevelDB会放大数据占用大小。

因此考虑实现基于内存时序数据库引擎storage

存储数据模型设计

基于这些特点,storage采用线性数据模型结构,按时间对数据点进行分区。每个分区充当一个完全独立的数据库,包含其时间范围内的所有数据点。

memory data tree. just for data code list
  │                 │
Read              Write
  │                 │
  │                 V
  │      ┌───────────────────┐ max: 1615010800
  ├─────>   Memory Partition
  │      └───────────────────┘ min: 1615007201
  │      ┌───────────────────┐ max: 1615007200
  ├─────>   Memory Partition
  │      └───────────────────┘ min: 1615003601
  │      ┌───────────────────┐ max: 1615003600
  └─────>   Memory Partition
         └───────────────────┘ min: 1615000000

受 LSM 树的轻微影响,Prometheus 的 V3 存储也使用了一个非常接近的模型。

只有 Head 和下一个分区在堆上并且是可写的。这称为内存分区。在内存分区中,数据在写入前追加到Write Ahead Log的末尾以防止数据丢失(如果不需要这种持久性可以关闭)。

较旧的分区被写入磁盘上的单个文件。这称为磁盘分区并且是只读的。写入磁盘的文件由mmap(2)通过内核透明地缓存,这将在后面解释。

可以为分区设置一个时间段 window,之后新的内存分区会自动添加到Head,旧的会刷到磁盘。

内存分区

数据点列表

数据点列表表示为堆上的数组(从技术上讲,它有点像指向基本数组的指针列表,在 Go 中称为Slice)。这是一个要写入的数据点数量不受限制的列表,因此乍一看,Linked-list 似乎是最好的选择,因为它允许以 O(1) 的时间添加元素。然而,其元素在 RAM 中彼此相邻排列的数组受益于高速缓存的空间局部性。此外,由于时间序列数据总是预先排序的,因此可以轻松实现二进制搜索等经典搜索算法。

乱序数据点

由于网络延迟时钟同步问题,在实际应用程序中数据点出现乱序的情况并不少见。写入时应考虑到这一点,分区中的数据点应始终保持排序。

但是,请注意,由于我们将数据点作为数组进行管理,因此每次写入它们时都需要应用排它锁。我们必须想出一种很酷的方法来摄取它们,这样我们就不必延长锁定时间来容纳无序数据点。

无序数据点可以分为两种情况:

  • 1.它们在您要写入的分区范围内是无序的。
  • 2.数据点超出了您首先尝试写入的分区范围。

如果写入的数据点对应第一种情况,我们先将数据点乱序缓存在一个单独的数组中。然后,在刷新到磁盘时,数据点与内存分区中的数据点合并并重新排序。

在数据模型部分,会堆上只有Head和它的下一个分区,这是为了适应第二种情况: 在Head中添加新分区后,可能会写入跨分区的数据点。这是为了解决第二种情况。通过保持最近的两个分区可写,避免了它们被丢弃。

存储用法上

示例说明了如何将一行插入内存并立即选择。

package main

import (
	"fmt"

	"github.com/kubeservice-stack/common/pkg/storage"
)

func main() {
	stg, _ := storage.NewStorage(
		storage.WithTimestampPrecision(storage.Seconds),
	)
	defer stg.Close()

	_ = stg.InsertRows([]storage.Row{
		{
			Metric: "metric1",
			DataPoint: storage.DataPoint{Timestamp: 1659000000, Value: 0.1},
		},
	})
	points, _ := stg.Select("metric1", nil, 1659000000, 1659000001)
	for _, p := range points {
		fmt.Printf("timestamp: %v, value: %v\n", p.Timestamp, p.Value)
		// => timestamp: 1659000000, value: 0.1
	}
}

标记指标

storage 中,您可以使用指标名称和可选标签的组合来标识指标。

metric := "mem_alloc_bytes"
labels := []tstorage.Label{
	{Name: "host", Value: "host-1"},
}

_ = stg.InsertRows([]storage.Row{
	{
		Metric:    metric,
		Labels:    labels,
		DataPoint: storage.DataPoint{Timestamp: 1659000000, Value: 0.1},
	},
})
points, _ := storage.Select(metric, labels, 1659000000, 1659000001)

benchmark

在 macOS Ventura 13.3.1 上使用 VirtualApple M2 CPU @ 2.50GHz 和 8GB RAM 进行的

goos: darwin
goarch: amd64
pkg: github.com/kubeservice-stack/common/pkg/storage
cpu: VirtualApple @ 2.50GHz
BenchmarkStorage_InsertRows-8                  	 4942462	        241.4 ns/op
BenchmarkStorage_SelectAmongThousandPoints-8   	14780418	        81.13 ns/op
BenchmarkStorage_SelectAmongMillionPoints-8    	14889984	        83.52 ns/op
PASS
ok  	github.com/kubeservice-stack/common/pkg/storage	8.492s

其他

storage ,目前还是非常简单。 例如,数据本身是映射到内核空间的,所以增加数据量是可以的。但是,索引的所有元数据都在堆上。由于每个指标都存在元数据,因此每个分区的元数据数量会随着指标数量的增加而增加,并且堆使用量的增加呈线性上升

欢迎试用: https://github.com/kubeservice-stack/common/blob/main/pkg/storage

「如果这篇文章对你有用,请随意打赏」

Kubeservice博客

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付