动机 我一直在研究一些处理海量时间序列数据的工具。其中之一名为Ali(https://github.com/nakabonne/ali),是一个负载测试工具,可以在客户端实时绘制度量值。它请求执行特定的查询,并将每个查询的结果,如延迟或任何其它度量,进行无休止地写入。换句话说,这有点像在一台主机上创建一个带有简单查询功能的推送监视系统。
在以前的实现中,它只是将数据点追加到堆上的变长数组中。这种做法自然会导致一个问题,即随着时间的推移,内存使用量将不断增加:
由nakabonne/gosivy测量的Ali堆使用量
为了解决这个问题,我尝试开发了一个存储引擎,可以被用作Go程序库。
时间序列数据的特征
我们首先要了解时间序列数据,以便理清需要解决的问题。
时间序列数据是具有时间戳的多个值的集合。它一般被用于观察随时间变化的数据。每个时间序列数据都被称为数据点,通常表示为一个带有时间戳和值的元组。时间序列数据具有以下特征:
1.数据量巨大
由于时间序列数据的性质,单个数据点很少有意义,只有在收集了大量数据之后,它才会变得有效。在金融行业里,数据捕获要求超过1000000/s次的情况并不少见,因为数据通常是在短时间内写入的。
在Ali的用例中,用户指定的请求速率与写性能需求直接相关(尽管文件描述符数量的上限基本上是瓶颈)。
为了处理海量数据,我们需要集中精力尽量优化数据写入过程,还需要尽可能减少磁盘空间的消耗。
2.只追加
每个数据点都是不变的。而且,通常会对较老的数据执行批量删除操作,而不指定特定数据点。
3.按时间排序
由于数据按时间戳排序存储,因此可以将其视为已按时间索引。利用这一性质你可以在没有任何开销的情况下建立索引。
4.批量访问
在读出数据时,主要是通过指定一个时间段来检索具有连续时间戳的多个数据点。你可以利用此特性来提升数据读取时的局部性。
5.最近优先
在许多用例中,我们倾向于读取和使用最近的数据点。这可能会影响缓存算法的选择。
6.高基数
时间序列数据往往具有更高的基数。例如,在系统监控环境中尤其如此。云原生时代,我们得到了更多的机会来监控动态变化的主机和网络等环境。
从这个意义上说,几乎没有完全相同的度量标准;为每个度量创建一个文件将导致各种问题,例如inode限制。
现有解决方案
总体来说,我们发现对时间序列数据的操作是写密集型,并且很多情况下是在一个时间范围内顺序读/写数据。
Google的LevelDB是一个众所周知的键-值存储引擎,并已发布了一些Go语言的实现。LevelDB是基于LSM树实现的,它只对尾部进行顺序写入,适用于只追加的时间序列数据。按键排序也使它适合于基于时间戳的时间序列数据。事实上,早期的Prometheus和InfluxDB存储引擎也是基于LevelDB的。
然而有一些东西被浪费了。我们将在后面看到,时间序列数据是单向的,可以利用此特性将其编码成更小的尺寸。我们希望利用这一点,因为要处理的是海量的时间序列数据。
另外,由于所有数据点都是不可变的,无需更新过程,因此所有写操作都可以按顺序进行。尽管如此,我还是有点担心,在执行诸如合并SSTables等操作时会发生写放大。
tstorage
有鉴于此,我决定自己编写一个数据库引擎库,命名为tstorage。
它是一个使用本地磁盘的轻量级引擎,完全用纯Go语言编写。
本文介绍了我是如何实现tstorage的,并解释了创建它所需的知识。
什么是数据库引擎
典型的DBMS处理来自客户端的请求,并控制节点之间的通信以进行集群。它还解析查询,制定执行计划,并根据这些计划从磁盘读/写数据。数据库引擎是只执行最后读/写部分的组件,也是下图中存储引擎的一部分。
数据库内部结构,Alex Petrov,2019,第1章,图1-1。数据库管理系统架构
数据库引擎对磁盘/内存上的数据结构进行抽象,为图中所示的执行引擎提供API,并提供了事务和恢复功能。
数据模型
基于这些特性,tstorage采用了一种按时间划分数据点的线性数据模型结构。每个分区充当一个完全独立的数据库,包含其时间范围内的所有数据点。
│ │
Read Write
│ │
│ V
│ ┌───────────────────┐ max: 1600010800
├─────> Memory Partition
│ └───────────────────┘ min: 1600007201
│
│ ┌───────────────────┐ max: 1600007200
├─────> Memory Partition
│ └───────────────────┘ min: 1600003601
│
│ ┌───────────────────┐ max: 1600003600
└─────> Disk Partition
└───────────────────┘ min: 1600000000
受LSM树的轻微影响,Prometheus的V3存储也使用了一种非常近似的模型
只有头和下一个分区在堆上并且是可写的。这称为内存分区。在内存分区中,数据在写入之前会先被追加到预写日志的末尾,以防止数据丢失(如果不需要这样的持久性,可以将其关闭)。
旧分区会写入磁盘上的单个文件。这称为磁盘分区,是只读的。写入磁盘的文件通过mmap(2)内核透明地缓存,后面将解释这点。
你可以为分区设置一个时间段,在这段时间之后,新的内存分区将被自动添加到堆头,旧的内存分区将被刷新到磁盘。
这种模式有许多优点。首先,读取时可以完全忽略指定范围之外的分区。这种尽可能缩小搜索范围的想法,是受到了LevelDB中使用的布隆过滤器的启发。另外,因为最新的数据点缓存在堆中,所以大多数都能被快速读取。
除此之外,由于tstorage被设计成将一个分区写入一个文件:
所有的写操作都只能追加,而不会发生任何写放大,因为它只按顺序写入一个文件。
文件的数量不依赖于基数(度量类型的数量)。
如前所述,读取操作通常指定一个时间段并获取相邻的数据点,因此提升了局部性。
以下部分描述每种分区实现中的关键点。
内存分区
1.数据点列表
数据点列表被表示为堆上的一个数组(从技术上讲,它有点像Go语言中指向名为Slice的基数组的指针列表)。这是一个包含无限多个要写入的数据点的列表,因此乍一看,链表似乎是最佳选择,因为它能使用O(1)复杂度添加元素。然而,内存中元素相邻排列的数组将受益于高速缓存的空间局部性。此外,由于时间序列数据总是预先排序的,因此可以很容易地实现二分搜索等经典搜索算法。
2.无序数据点
在实际应用中,由于网络延迟或时钟同步等问题,数据点出现无序的情况并不少见。写入时应考虑到这一点,分区中的数据点应始终保持有序。
但是要注意,在将数据点作为数组进行管理时,每次写入都需要应用一个独占锁。我们必须考虑一种很酷的方法来接收它们,这样就不必延长锁定时间来容纳无序的数据点。
无序数据点可以分为两种情况:一是它们在要写入的分区范围内无序;第二种情况是数据点超出了首先尝试写入的分区的范围。
如果写入的数据点对应于第一种情况,我们将先在一个单独的数组中无序地缓存数据点。然后,在刷新到磁盘时,数据点与内存分区中的数据点合并并重新排序。
在数据模型部分,我提到堆上只有头部及其下一个分区,以适应第二种情况:在头部添加新分区之后,可能会立即写入跨分区的数据点。这是为了解决第二种情况。通过使最近的两个分区保持可写状态,我们可以避免这些数据点被丢弃。
3.预写日志(WAL)
内存分区使用易失性存储,因此有可能由于突然崩溃或掉电而导致数据丢失。为了应对这个问题,内存分区首先将操作日志写入预写日志(WAL)。在发生崩溃的情况下,你可以通过执行从日志开头到结尾写入的操作进行恢复。
对于支持磁盘上数据更新操作的数据库引擎,WAL必须执行非常低级别的操作:要完全恢复更新所做的处理,必须准确地存储哪个磁盘块中的哪个字节被更改,等等。
但是,时间序列数据是只追加的,tstorage会将所有磁盘分区都置为只读。它只需要附加有关哪些数据点已被写入内存分区的高级信息,这样就可以用简单且与磁盘无关的格式来恢复它们。
磁盘分区
理解磁盘分区的最快方法是查看文件系统上的宏布局。
如下所示,每个分区使用一个目录,其下面是元数据和实际的压缩数据。这是普罗米修斯布局的简化版本,所以你以前可能见过类似的结构。
$ tree ./data
./data
├── p-1600000001-1600003600
│ ├── data
│ └── meta.json
├── p-1600003601-1600007200
│ ├── data
│ └── meta.json
└── p-1600007201-1600010800
├── data
└── meta.json
我将按照数据->元数据的顺序来描述实现点。
1.内存映射数据
如上所述,分区中的所有数据点都被写入一个文件。tstorage采用以下格式。
┌────────────┐
│ 度量-1 │度量-2 │
│────────────│
│ 度量-3 │ │
│────┘ │
│ 度量-4 │
│────────────│
│ 度量-5 │ 度量-6 │
│────────────│
│ 度量-7 │ │
│──────────┘ │
│ 度量-8 │
│────────────│
│度量-9│ 度量-10 │
└────────────┘
文件格式
度量-1~度量-10分别表示度量的数据点列表
回忆一下时间序列数据的特征。数据点是不可变的,而且大多数情况下,度量是通过指定一个范围来批量获取的。因此,我们可以通过按度量对数据点进行分组来提高局部性。
这个文件通过mmap(2)中的内核透明地缓存。这使得我们可以缓存文件而不将其复制到用户空间。这个方法非常有效,因为我最初的动机就是解决Ali随着时间的推移吃光堆的问题。
内存映射文件可以被Go程序以[]bytes形式访问,就像堆上的一个字节字符串一样。
type diskPartition struct {
// file descriptor of data file
f *os.File
// memory-mapped file backed by f
mappedFile []byte
现在,我们如何搜索这个自包含的字节序列呢?将它复制到堆上,并在Go程序中对其解码为数据结构是很容易的,但这达不到内存映射的目标。反正,我们需要一个索引结构来有效地访问编码数据。接下来介绍的元数据用于此目的。
2.元数据
元数据的内容如下所示。一个分区只有一个元数据,这就是为什么采用JSON格式,虽然有点多余,但很容易通过编程进行处理。
$ cat ./data/p-1600000001-1600003600/meta.json
{
"minTimestamp": 1600000001,
"maxTimestamp": 1600003600,
"numDataPoints": 7200,
"metrics": {
"metric-1": {
"name": "metric-1",
"offset": 0,
"minTimestamp": 1600000001,
"maxTimestamp": 1600003600,
"numDataPoints": 3600
},
"metric-2": {
"name": "metric-2",
"offset": 36014,
"minTimestamp": 1600000001,
"maxTimestamp": 1600003600,
"numDataPoints": 3600
}
}
}
元数据用于在分区中构建索引。这是存储每个度量的文件内偏移量和字节大小的地方,只有这些元数据才能进入堆。通过这些信息,tstorage尝试随机访问映射到内核空间的数据。类似于:
{
"minTimestamp": 1600000001,
"maxTimestamp": 1600003600,
"numDataPoints": 7200,
"metrics": {
"metric-1": {
"name": "metric-1",
┌───── "offset": 0,
│ "minTimestamp": 1600000001,
│ "maxTimestamp": 1600003600,
│ "numDataPoints": 3600
│ },
│ "metric-2": {
│ "name": "metric-2",
│ "offset": 36014, ────────────┐
│ "minTimestamp": 1600000001, │
│ "maxTimestamp": 1600003600, │
│ "numDataPoints": 3600 │
│ } │
│ } │
│ } ┌───────────────────┘
│ │
V V
0 36014
┌───────────────────────────┐
│ Metric-1 │ Metric-2 │
│───────────────────────────│
│ Metric-3 │ │
│──────────┘ │
│ Metric-4 │
│───────────────────────────│
│ Metric-5 │ Metric-6 │
│───────────────────────────│
│ Metric-7 │ │
│───────────────────────┘ │
│ Metric-8 │
│───────────────────────────│
│Metric-9│ Metric-10 │
└───────────────────────────┘
为了存储每个度量的起始偏移量,我们需要在刷新到磁盘时将其持久化。即对每个度量的数据点列表进行编码,并在构建索引时将偏移量写入元数据文件。
编码
如前所述,时间序列数据通常由时间戳和值的元组表示,这就可以将其巧妙地编码为非常小的尺寸。
2015年,Facebook发表了一篇名为Gorilla:一个快速、可伸缩的内存时间序列数据库的论文,他们在其中引入了一种利用时间序列数据特征的编码方式。当前许多主流的时间序列数据库都基于这种编码方式,而tstorage也是如此。
时间戳和值使用不同的方法进行编码。首先,tstorage中的UNIX时间戳表示为一个无符号的64位整数。由于该时间戳趋向于单调递增,仅存储与先前值差异的编码方法是有效的。因此,使用Delta-of-delta编码。
此外,tstorage中的值表示为有符号的64位浮点类型。该值使用XOR编码,因为它倾向于保持接近这个值,尽管它不可能单调地增加或减少。
我将解释每种编码方式。
1.Delta编码
Delta-of-Delta编码是一种利用Delta编码的方法,因此我将先解释一下Delta编码。
Delta编码只写入上一个数字和当前数字之间的差异。
例如,假设第一个时间戳是160000000。如果后面写入1600000060->160000120->160000181,则增量分别为60、60、61。
时间戳 | 增量 |
---|---|
160000000 | - |
160000060 | 60 |
160000120 | 60 |
160000181 | 61 |
只将这些增量值按顺序写入文件。解码时,只需按顺序应用增量值,就可以恢复原始值。
2.Delta-of-delta编码
即使使用delta编码,一些可变长度的编码结果也可以足够小。然而,时间序列数据的时间戳通常具有固定的间隔,并且增量值本身很可能彼此接近。因此,利用这个增量值的增量值可以节省更多。这个增量值的增量值叫做delta-of-delta。
我们取上例时间戳的delta-of-delta。
时间戳 | 增量 | 增量的增量 |
---|---|---|
1600000000 | - | - |
1600000060 | 60 | - |
1600000120 | 60 | 0 |
1600000181 | 61 | 1 |
对于第一个时间戳,由于无法计算增量值,我们按原样写入160000000。根据论文,Gorilla使用固定长度编码来编码数据,而tstorage使用一种称为Variants的可变长度编码方法。
对于第二个时间戳,因为delta-of-delta还无法计算,所以我们按原样写增量值60。因为Gorilla假设每4小时(=16384秒)创建一个时间序列数据块,可以获取的最大位数是14位,所以它用14位的固定长度进行编码。然而,tstorage使用Variants进行可变长度编码(Prometheus也使用Variants编码前两个时间戳,我实在不知道为什么Gorilla使用固定长度)。
如果你有兴趣了解更多Variants的工作原理,请查看我的上一篇文章:https://nakabonne.dev/posts/binary-encoding-go/#how-varints-works
delta-of-delta使用可变长度编码进行编码,因此它的大小取决于要写入的数字的大小。如果delta-of-delta为0,则使用1位写入0。如果delta-of-delta在64~64范围内,则使用2位写入1和0,然后使用7位写入delta-of-delta。
所以每个时间戳的大小是:
时间戳 | 增量 | 增量的增量 | 长度 |
---|---|---|---|
1600000000 | - | - | 40位 |
1600000060 | 60 | - | 8位 |
160000120 | 60 | 0 | 1位 |
16000018 | 06 | 1 | 9位 |
总大小为40+8+1+9=58位。
如果我们对原始的四个时间戳进行定长编码,则得到64 x 4=256位;可以看到我们节省了很多尺寸。
如你所见,如果时间戳以固定的间隔排列,那么delta-of-delta将始终为零,这使得编码非常高效。如果想保持尽可能小的尺寸,你应该尝试以固定周期写入数据点。
如果想更详细地了解它,我建议你阅读论文的4.1.1压缩时间戳。
3.XOR编码
XOR编码是一种对两个浮点值进行异或运算,并将其代替实际值写入的方法。
如果取接近值的异或,前导零和尾部零往往更多,而我们希望可以忽略要写入的大小。例如,如果我们取2.0和3.0的异或:
2.0 ^ 3.0 = 0000000000001000000000000000000000000000000000000000000000000000
└leading 0s┘ └ trailing 0s
正如你所看到的,异或结果分为三个部分:
第二部分的1被称为有意义位,通过写入这个有意义位和前导零的个数,就能准确地表示这个数。我们将根据以下规则对数字进行编码。
写入0并退出
写入1
如果有意义位与前一个值的相同
写入0和有意义位并退出
写前导零的数目(5位)
写入有意义位的大小(6位)
写有意义位本身
如果你希望更详细地了解它,我建议阅读论文的4.1.2压缩值。
注意
一定要牢记这些编码方式取决于相邻值之间的关系,因此不能按原样随机访问它们。
九、Ali的性能提升
前:
后:
我们可以看到,时间序列存储层的增加解决了堆使用量随时间增长的问题。
tstorage的缺点
tstorage非常简单,因此仍有一些功能不够强大。
例如,如果一个磁盘分区有大量不同的度量,则存在堆变得不堪重负的问题。如前所述,数据本身映射到内核空间,因此对数据量增长是很好的。但是,索引的所有元数据都在堆上。因为每个度量都有元数据,所以每个分区的元数据数量会随着度量数量而增加,堆使用量将线性增长。
总结
我觉得时间序列数据对于第一次实现存储引擎来说是一个很好的课题,因为要处理的问题很简单,并且可以用直截了当的方式进行设计。
本文以一种抽象的方式介绍了实现要点,但如果你还感兴趣的话,我希望你能看看源代码(https://github.com/nakabonne/tstorage)。
如果你有任何问题或反馈,或发现了错误,可以用自己喜欢的任何方式联系我,那简直太好了。
参考:
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/YWPVqKgRPeShRCKjie90MQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。