内存时序数据库引擎storage
背景
近期研究内存统计stats
,并可以快速通过statistics
数学方法进行统计活动相关数据。 比如统计历史5min、15min、1h、1d
的平均值、最大值、最小值和方差。
需要的几个要求和特点:
- 按时间排序:按metrics产生时间 排序或者存储
- 高基数:本身就是内存监控数据,需要更高的基数
- 批量读:读出时,主要是通过指定时间段来获取多个具有连续时间戳的数据点。即range能力
- 仅追加写:数据按产生的时间进行追加写,不会对历史时间进行update
- 时间窗口:记录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
「如果这篇文章对你有用,请随意打赏」
如果这篇文章对你有用,请随意打赏
使用微信扫描二维码完成支付