玩转Prometheus(3)–数据存储
版权声明 本站原创文章 由 萌叔 发表
转载请注明 萌叔 | https://vearne.cc
1.引言
在监控系统中,海量的监控数据如何存储,一直是设计人员所必须关心的问题。OpenTSDB选择了Hbase;Open-Falcon选择了RRD(round-robin database)。Prometheus另辟蹊径,借鉴了facebook的paper(参考资料2),设计了自己的TSDB(time series database)。本文试图简单介绍TSDB中使用的2个压缩算法
2. 简单的聊聊TSDB的文件结构
值得一提的是在prometheus中
1)数据是没有做预聚合的,所有的聚合操作都是在查询阶段进行。
据笔者观察查询的时间跨度如果超过7d,速度就会变得比较慢(3 ~ 5秒)
2)Prometheus数据都是单机存储的,数据存在丢失的可能,最近产生的数据存储在内存中,历史数据落在硬盘上,默认数据存储15天。
可以使用storage.tsdb.retention.time来修改数据存储的跨度
--storage.tsdb.retention.time=7d
3) 文件结构
├── 01D5X2A81S8FMS16S5Q1GWNQDE
│ ├── chunks // chunk数据
│ │ └── 000001
│ ├── index // 索引文件
│ ├── meta.json // 人类可读的文件
│ └── tombstones
├── 01D5X2A83TVJD7FFGKPHED5VA1
│ ├── chunks
│ │ └── 000001
│ ├── index
│ ├── meta.json
│ └── tombstones
└── wal // wal下的全是预写日志,类似于MySQL中的binlog
├── 00000010
├── 00000011
├── 00000012
├── 00000013
└── checkpoint.000009
└── 00000000
如果读者仔细观察会发现,文件夹的modify时间是递增。
没错,监控数据首先按照时间维度,划分在不同的文件夹中, 然后通过索引文件index
去定位不同的series
, 实际的数据在chunks
文件夹中 。(补充,WAL
文件夹存有最近的数据。可简单的把WAL
理解为最近的临时数据,chunks
中的为归档数据。)
drwxr-xr-x 3 prometheus prometheus 4096 Mar 7 17:00 01D5BP4CVKS560HC8VR1TQSEAX
drwxr-xr-x 3 prometheus prometheus 4096 Mar 7 23:00 01D5CAQJK2Q0MJ04W170RZX866
drwxr-xr-x 3 prometheus prometheus 4096 Mar 8 05:00 01D5CZARBF5F2CSPCB517T2MQS
drwxr-xr-x 3 prometheus prometheus 4096 Mar 8 11:00 01D5DKXY4C9KS9NY22P56791WZ
drwxr-xr-x 3 prometheus prometheus 4096 Mar 8 17:00 01D5E8H3W57R95FABE85FWGZVM
drwxr-xr-x 3 prometheus prometheus 4096 Mar 8 23:00 01D5EX49K598YTQWWB2NH9Y2FA
drwxr-xr-x 3 prometheus prometheus 4096 Mar 9 05:00 01D5FHQFCPHB0SES21N9JN9MG6
meta.json
{
"ulid": "01D5BP4CVKS560HC8VR1TQSEAX",
"minTime": 1551916800000, // 时间跨度的开始时间
"maxTime": 1551938400000, // 时间跨度的结束时间
"stats": {
"numSamples": 191160, // 采样点的数量
"numSeries": 531, // `Series`的数量
"numChunks": 1593 // `Chunk`的数量
},
...
}
3. 压缩算法
时序数据库的最大特点是,每个series
都是由若干二元组组成的
(T1, V1) (T2, V2) (T3, V4) ... (Tn, Vn)
T表示时间戳(int64),V表示数值(float64),各占8个字节
每个sample
点(二元组)为16个字节。由于Prometheus采用了delta-of-delta
和XOR
压缩算法,据笔者测试,平均保存sample
只需要2个字节,压缩率为12.5%,还是非常惊人的。
注意 delta-of-delta
和XOR
压缩算法利用监控数据的特定特点所实施的,在其它场合未必有效。
3.1 delta-of-delta 压缩算法
采用delta-of-delta
压缩算法针对时间戳
进行压缩
Delta1 Delta2 Delta3
T1 --------> T2 --------> T3 --------> T4
由于在Prometheus中,检查间隔往往是固定的(通常是1min),因此
Delta2 - Delta1 或者 Delta3 - Delta2 将会是非常小的值,然后根据差值的范围,采用不等长的编码就可以达到压缩的目的
代码如下:
xor.go
func (a *xorAppender) Append(t int64, v float64) {
var tDelta uint64
num := binary.BigEndian.Uint16(a.b.bytes())
if num == 0 {
buf := make([]byte, binary.MaxVarintLen64)
for _, b := range buf[:binary.PutVarint(buf, t)] {
a.b.writeByte(b)
}
a.b.writeBits(math.Float64bits(v), 64)
} else if num == 1 {
tDelta = uint64(t - a.t)
buf := make([]byte, binary.MaxVarintLen64)
for _, b := range buf[:binary.PutUvarint(buf, tDelta)] {
a.b.writeByte(b)
}
a.writeVDelta(v)
} else {
tDelta = uint64(t - a.t)
dod := int64(tDelta - a.tDelta)
// Gorilla has a max resolution of seconds, Prometheus milliseconds.
// Thus we use higher value range steps with larger bit size.
switch {
case dod == 0:
a.b.writeBit(zero)
case bitRange(dod, 14):
a.b.writeBits(0x02, 2) // '10'
a.b.writeBits(uint64(dod), 14)
case bitRange(dod, 17):
a.b.writeBits(0x06, 3) // '110'
a.b.writeBits(uint64(dod), 17)
case bitRange(dod, 20):
a.b.writeBits(0x0e, 4) // '1110'
a.b.writeBits(uint64(dod), 20)
default:
a.b.writeBits(0x0f, 4) // '1111'
a.b.writeBits(uint64(dod), 64)
}
a.writeVDelta(v)
}
a.t = t
a.v = v
binary.BigEndian.PutUint16(a.b.bytes(), num+1)
a.tDelta = tDelta
}
3.2 XOR压缩算法
采用XOR
压缩算法针对值
进行压缩
由于相邻的sample
点的数据值差异很小,我随便挑了2个数,117.0和118.0来看看异或后的结果
package main
import (
"fmt"
"math"
)
func main(){
v1 := math.Float64bits(117)
v2 := math.Float64bits(118)
fmt.Printf("%064b\n", v1)
fmt.Printf("%064b\n", v2)
fmt.Println("-----------")
fmt.Printf("%064b\n", v1 ^ v2)
}
结果输出
0100000001011101010000000000000000000000000000000000000000000000
0100000001011101100000000000000000000000000000000000000000000000
-----------
0000000000000000110000000000000000000000000000000000000000000000
异或后,不同的bits只有2位,头部(leading
)和尾部(trailing
)都有大量连续的位是'0', 根据这种情况,对leading
和trailing
的'0'进行压缩存储就能节约大量空间。
事实上由于IEEE 754规范所约定的Float64的内部结构,如果2个数的值接近,异或的值在头部(leading
)和尾部(trailing
)一定会有大量连续的位是'0', 有兴趣的朋友可以想想为什么。
代码如下
xor.go
func (a *xorAppender) writeVDelta(v float64) {
vDelta := math.Float64bits(v) ^ math.Float64bits(a.v)
if vDelta == 0 {
a.b.writeBit(zero)
return
}
a.b.writeBit(one)
leading := uint8(bits.LeadingZeros64(vDelta))
trailing := uint8(bits.TrailingZeros64(vDelta))
// Clamp number of leading zeros to avoid overflow when encoding.
if leading >= 32 {
leading = 31
}
if a.leading != 0xff && leading >= a.leading && trailing >= a.trailing {
a.b.writeBit(zero)
a.b.writeBits(vDelta>>a.trailing, 64-int(a.leading)-int(a.trailing))
} else {
a.leading, a.trailing = leading, trailing
a.b.writeBit(one)
a.b.writeBits(uint64(leading), 5)
// Note that if leading == trailing == 0, then sigbits == 64. But that value doesn't actually fit into the 6 bits we have.
// Luckily, we never need to encode 0 significant bits, since that would put us in the other case (vdelta == 0).
// So instead we write out a 0 and adjust it back to 64 on unpacking.
sigbits := 64 - leading - trailing
a.b.writeBits(uint64(sigbits), 6)
a.b.writeBits(vDelta>>trailing, int(sigbits))
}
}
3.3 总结
由于delta-of-delta 和 XOR都是不等长的压缩算法,也带来了2个缺点。
1)无法直接定位到特定时间区间的sample
2)解压必须从chunk
的首部开始计算,依次解出每个sample
4. 参考资料
- Writing a Time Series Database from Scratch
- Gorilla: A Fast, Scalable, In-Memory Time Series Database
- prometheus-storage
- TSDB format
- IEEE 754
感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/no320w 欢迎点赞支持!
使用开发者头条 App 搜索 334598 即可订阅《萌叔》
您好,您第三部分压缩算法下面的第一行有误。T,V代表的是samples,而不是series。
Every time series is uniquely identified by its metric name and optional key-value pairs called labels.
Samples form the actual time series data. Each sample consists of:a float64 value,a millisecond-precision timestamp。
链接:https://prometheus.io/docs/concepts/data_model/
另外,“没错,监控数据首先按照时间维度,划分在不同的文件夹中, 然后通过索引文件index去定位不同的series, 实际的数据在chunks文件夹中”这句也感觉有点不严谨,WAL也存有数据。
不对之处欢迎指正,谢谢!
确实不够严谨,已修改