从零实现一个时序数据库
<p><img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/a6d3d01665b925d5153ab0a7d4c5cf60.jpg" width="auto"></p>
<p>
时序数据库(TSDB: Time Series Database)大多数时候都是为了满足监控场景的需求,这里先介绍两个概念:</p>
<ul>
<li>
数据点(Point): 时序数据的数据点是一个包含 (Timestamp:int64, Value:float64) 的二元组。</li>
<li>
时间线(Series): 不同标签(Label)的组合称为不同的时间线,如</li>
</ul>
<ol class="dp-sql">
<li class="alt">
<span><span>series1: {</span><span class="string">"__name__"</span><span>: </span><span class="string">"netspeed"</span><span>, </span><span class="string">"host"</span><span>: </span><span class="string">"localhost"</span><span>, </span><span class="string">"iface"</span><span>: </span><span class="string">"eth0"</span><span>} </span></span>
</li>
<li>
<span>series2: {<span class="string">"__name__"</span><span>: </span><span class="string">"netspeed"</span><span>, </span><span class="string">"host"</span><span>: </span><span class="string">"localhost"</span><span>, </span><span class="string">"iface"</span><span>: </span><span class="string">"eth1"</span><span>} </span></span>
</li>
</ol>
<p>
Prometheus, InfluxDB, M3, TimescaleDB 都是时下流行的 TSDB。时序数据的压缩算法很大程度上决定了 TSDB 的性能,以上几个项目的实现都参考了 Fackbook 2015 年发表的论文《Gorilla: A fast, scalable, in-memory time series database》(http://www.vldb.org/pvldb/vol8/p1816-teller.pdf) 中提到的差值算法,该算法平均可以将 16 字节的数据点压缩成 1.37 字节。</p>
<p>
<strong>Who's mando?</strong></p>
<ul>
<li>
Din Djarin, also known as "the Mandalorian" or simply "Mando," was a human male Mandalorian who worked as a famous bounty hunter during the New Republic Era.</li>
</ul>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/5c7799a4125848f167e8ed441146d4f2.jpg" width="auto"></p>
<p>
<strong>What's mandodb?</strong></p>
<p>
mandodb(https://github.com/chenjiandongx/mandodb) 是我在学习过程中实现的一个最小化的 TSDB,从概念上来讲它还算不上是一个完整的 TSDB,因为它:</p>
<ul>
<li>
没有实现自己的查询引擎(实现难度大)</li>
<li>
缺少磁盘归档文件 Compact 操作(有空的话会实现)</li>
<li>
没有 WAL 作为灾备保证高可用(心情好的话会实现)</li>
</ul>
<p>
mandodb 主要受到了两个项目的启发。本项目仅限于学习用途,未经生产环境测试验证!</p>
<ul>
<li>
nakabonne/tstorage</li>
<li>
prometheus/prometheus</li>
</ul>
<p>
prometheus 的核心开发者 Fabian Reinartz 写了一篇文章 《Writing a Time Series Database from Scratch》(https://fabxc.org/tsdb/) 来介绍 prometheus TSDB 的演变过程,非常值得一读,强烈推荐。</p>
<h3>
数据模型 & API 文档</h3>
<p>
</p>
<p>
<strong>数据模型定义</strong></p>
<ol class="dp-sql">
<li class="alt">
<span><span>// Point 表示一个数据点 (ts, value) 二元组 </span></span>
</li>
<li>
<span>type Point struct { </span>
</li>
<li class="alt">
<span> Ts int64 // <span class="op">in</span><span> seconds </span></span>
</li>
<li>
<span> Value float64 </span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// Label 代表一个标签组合 </span>
</li>
<li>
<span>type Label struct { </span>
</li>
<li class="alt">
<span> <span class="keyword">Name</span><span> string </span></span>
</li>
<li>
<span> Value string </span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// Row 一行时序数据 包括数据点和标签组合 </span>
</li>
<li>
<span>type Row struct { </span>
</li>
<li class="alt">
<span> Metric string </span>
</li>
<li>
<span> Labels LabelSet </span>
</li>
<li class="alt">
<span> Point Point </span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// LabelSet 表示 Label 组合 </span>
</li>
<li class="alt">
<span>type LabelSet []Label </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// LabelMatcher Label 匹配器 支持正则 </span>
</li>
<li>
<span>type LabelMatcher struct { </span>
</li>
<li class="alt">
<span> <span class="keyword">Name</span><span> string </span></span>
</li>
<li>
<span> Value string </span>
</li>
<li class="alt">
<span> IsRegx bool </span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// LabelMatcherSet 表示 LabelMatcher 组合 </span>
</li>
<li class="alt">
<span>type LabelMatcherSet []LabelMatcher </span>
</li>
</ol>
<p>
<strong>API</strong></p>
<ol class="dp-sql">
<li class="alt">
<span><span>// InsertRows 写数据 </span></span>
</li>
<li>
<span>InsertRows(<span class="keyword">rows</span><span> []*Row) error </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// QueryRange 查询时序数据点 </span>
</li>
<li class="alt">
<span>QueryRange(metric string, lms LabelMatcherSet, start, <span class="keyword">end</span><span> int64) ([]MetricRet, error) </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// QuerySeries 查询时序序列组合 </span>
</li>
<li>
<span>QuerySeries(lms LabelMatcherSet, start, <span class="keyword">end</span><span> int64) ([]mapstring, error) </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// QueryLabelValues 查询标签值 </span>
</li>
<li class="alt">
<span>QueryLabelValues(label string, start, <span class="keyword">end</span><span> int64) []string </span></span>
</li>
</ol>
<h3>
配置选项</h3>
<p>
</p>
<p>
配置项在初始化 TSDB 的时候设置。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>// WithMetaSerializerType 设置 Metadata 数据的序列化类型 </span></span>
</li>
<li>
<span>// 目前只提供了 BinaryMetaSerializer </span>
</li>
<li class="alt">
<span>WithMetaSerializerType(t MetaSerializerType) <span class="keyword">Option</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// WithMetaBytesCompressorType 设置字节数据的压缩算法 </span>
</li>
<li>
<span>// 目前提供了 </span>
</li>
<li class="alt">
<span>// * 不压缩: NoopBytesCompressor(默认) </span>
</li>
<li>
<span>// * ZSTD: ZstdBytesCompressor </span>
</li>
<li class="alt">
<span>// * Snappy: SnappyBytesCompressor </span>
</li>
<li>
<span>WithMetaBytesCompressorType(t BytesCompressorType) <span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithOnlyMemoryMode 设置是否默认只存储在内存中 </span>
</li>
<li class="alt">
<span>// 默认为 <span class="keyword">false</span><span> </span></span>
</li>
<li>
<span>WithOnlyMemoryMode(memoryMode bool) <span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithEnabledOutdated 设置是否支持乱序写入 此特性会增加资源开销 但会提升数据完整性 </span>
</li>
<li class="alt">
<span>// 默认为 <span class="keyword">true</span><span> </span></span>
</li>
<li>
<span>WithEnabledOutdated(outdated bool) <span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithMaxRowsPerSegment 设置单 Segment 最大允许存储的点数 </span>
</li>
<li class="alt">
<span>// 默认为 19960412(夹杂私货 ) </span>
</li>
<li>
<span>WithMaxRowsPerSegment(n int64) <span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithDataPath 设置 Segment 持久化存储文件夹 </span>
</li>
<li class="alt">
<span>// 默认为 <span class="string">"."</span><span> </span></span>
</li>
<li>
<span>WithDataPath(d string) <span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithRetention 设置 Segment 持久化数据保存时长 </span>
</li>
<li class="alt">
<span>// 默认为 7d </span>
</li>
<li>
<span>WithRetention(t <span class="keyword">time</span><span>.Duration) </span><span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithWriteTimeout 设置写入超时阈值 </span>
</li>
<li class="alt">
<span>// 默认为 30s </span>
</li>
<li>
<span>WithWriteTimeout(t <span class="keyword">time</span><span>.Duration) </span><span class="keyword">Option</span><span> </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// WithLoggerConfig 设置日志配置项 </span>
</li>
<li class="alt">
<span>// logger: github.com/chenjiandongx/logger </span>
</li>
<li>
<span>WithLoggerConfig(opt *logger.Options) <span class="keyword">Option</span><span> </span></span>
</li>
</ol>
<h3>
用法示例</h3>
<p>
</p>
<ol class="dp-sql">
<li class="alt">
<span><span>package main </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>import ( </span>
</li>
<li>
<span> <span class="string">"fmt"</span><span> </span></span>
</li>
<li class="alt">
<span> <span class="string">"time"</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="string">"github.com/chenjiandongx/mandodb"</span><span> </span></span>
</li>
<li>
<span>) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>func main() { </span>
</li>
<li class="alt">
<span> store := mandodb.OpenTSDB( </span>
</li>
<li>
<span> mandodb.WithOnlyMemoryMode(<span class="keyword">true</span><span>), </span></span>
</li>
<li class="alt">
<span> mandodb.WithWriteTimeout(10*<span class="keyword">time</span><span>.</span><span class="keyword">Second</span><span>), </span></span>
</li>
<li>
<span> ) </span>
</li>
<li class="alt">
<span> defer store.<span class="keyword">Close</span><span>() </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 插入数据 </span>
</li>
<li>
<span> _ = store.InsertRows([]*mandodb.Row{ </span>
</li>
<li class="alt">
<span> { </span>
</li>
<li>
<span> Metric: <span class="string">"cpu.busy"</span><span>, </span></span>
</li>
<li class="alt">
<span> Labels: []mandodb.Label{ </span>
</li>
<li>
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"node"</span><span>, Value: </span><span class="string">"vm1"</span><span>}, </span></span>
</li>
<li class="alt">
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"dc"</span><span>, Value: </span><span class="string">"gz-idc"</span><span>}, </span></span>
</li>
<li>
<span> }, </span>
</li>
<li class="alt">
<span> Point: mandodb.Point{Ts: 1600000001, Value: 0.1}, </span>
</li>
<li>
<span> }, </span>
</li>
<li class="alt">
<span> { </span>
</li>
<li>
<span> Metric: <span class="string">"cpu.busy"</span><span>, </span></span>
</li>
<li class="alt">
<span> Labels: []mandodb.Label{ </span>
</li>
<li>
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"node"</span><span>, Value: </span><span class="string">"vm2"</span><span>}, </span></span>
</li>
<li class="alt">
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"dc"</span><span>, Value: </span><span class="string">"sz-idc"</span><span>}, </span></span>
</li>
<li>
<span> }, </span>
</li>
<li class="alt">
<span> Point: mandodb.Point{Ts: 1600000001, Value: 0.1}, </span>
</li>
<li>
<span> }, </span>
</li>
<li class="alt">
<span> }) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">time</span><span>.Sleep(</span><span class="keyword">time</span><span>.Millisecond) </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 时序数据查询 </span>
</li>
<li>
<span> data, _ := store.QueryRange(<span class="string">"cpu.busy"</span><span>, nil, 1600000000, 1600000002) </span></span>
</li>
<li class="alt">
<span> fmt.Printf(<span class="string">"data: %+v\n"</span><span>, data) </span></span>
</li>
<li>
<span> // <span class="keyword">output</span><span>: </span></span>
</li>
<li class="alt">
<span> // data: [{Labels:{__name__=<span class="string">"cpu.busy"</span><span>, dc=</span><span class="string">"gz-idc"</span><span>, node=</span><span class="string">"vm1"</span><span>} Points:[{Ts:1600000001 Value:0.1}]}] </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 查询 Series </span>
</li>
<li>
<span> // __name__ 是 metric 名称在 TSDB 中的 Label <span class="keyword">Key</span><span> </span></span>
</li>
<li class="alt">
<span> ser, _ := store.QuerySeries( </span>
</li>
<li>
<span> mandodb.LabelMatcherSet{{<span class="keyword">Name</span><span>: </span><span class="string">"__name__"</span><span>, Value: </span><span class="string">"cpu.busy"</span><span>}}, 1600000000, 1600000002) </span></span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> _, d := range ser { </span></span>
</li>
<li>
<span> fmt.Printf(<span class="string">"data: %+v\n"</span><span>, d) </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> // <span class="keyword">output</span><span>: </span></span>
</li>
<li class="alt">
<span> // data: map </span>
</li>
<li>
<span> // data: map </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // 查询标签值 </span>
</li>
<li class="alt">
<span> lvs := store.QueryLabelValues(<span class="string">"node"</span><span>, 1600000000, 1600000002) </span></span>
</li>
<li>
<span> fmt.Printf(<span class="string">"data: %+v\n"</span><span>, lvs) </span></span>
</li>
<li class="alt">
<span> // <span class="keyword">output</span><span>: </span></span>
</li>
<li>
<span> // data: </span>
</li>
<li class="alt">
<span>} </span>
</li>
</ol>
<p>
下面是我对这段时间学习内容的整理,尝试完整介绍如何从零开始实现一个小型的 TSDB。</p>
<p>
我本身并没有数据库开发的背景,某些描述可能并不那么准确,所以欢迎 实名 diss 指正。</p>
<h3>
Gorilla 差值算法</h3>
<p>
</p>
<p>
Gorilla 论文 4.1 小节介绍了压缩算法,先整体看一下压缩方案,T/V 是紧挨存储的,'0'/'10'/'11' 表示控制位。</p>
<p>
Figure: Gorilla 压缩算法</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/ba72a89af78b5923a8f0a75eb381812f.jpg" width="auto"></p>
<p>
<strong>Timestamp DOD 压缩:</strong></p>
<p>
在时序的场景中,每个时序点都有一个对应的 Timestamp,一条时序序列中相邻数据点的间隔是有规律可循的。一般来讲,监控数据的采集都是会以固定的时间间隔进行的,所以就可以用差值来记录时间间隔,更进一步,我们可以用差值的差值来记录以此来减少存储空间。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>t1: 1627401800; t2: 1627401810; t3: 1627401820; t4: 1627401830 </span></span>
</li>
<li>
<span><span class="comment">--------------------------------------------------------------</span><span> </span></span>
</li>
<li class="alt">
<span>// 差值:delta </span>
</li>
<li>
<span>t1: 1627401800; (t2-t1)d1: 10; (t3-t2)d2: 10; (t4-t3)d3: 10; </span>
</li>
<li class="alt">
<span><span class="comment">--------------------------------------------------------------</span><span> </span></span>
</li>
<li>
<span>// 差值的差值:delta <span class="keyword">of</span><span> delta </span></span>
</li>
<li class="alt">
<span>t1: 1627401800; dod1: 0; dod2: 0; dod3: 0; </span>
</li>
</ol>
<p>
实际环境中当然不可能每个间隔都这么均匀,由于网络延迟等其他原因,差值会有波动。</p>
<p>
Value XOR 压缩:</p>
<p>
Figure: IEEE 浮点数以及 XOR 计算结果</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/f76d7ba9580528a0af0a885b35518636.jpg" width="auto"></p>
<p>
当两个数据点数值值比较接近的话,通过异或操作计算出来的结果是比较相似的,利用这点就可以通过记录前置零和后置零个数以及数值部分来达到压缩空间的目的。</p>
<p>
下面通过算法实现来介绍,代码来自项目 dgryski/go-tsz。代码完全按照论文中给出的步骤来实现。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>// New 初始化 block 这里会将第一个原始时间戳写入到 block 中 </span></span>
</li>
<li>
<span>func New(t0 uint32) *Series { </span>
</li>
<li class="alt">
<span> s := Series{ </span>
</li>
<li>
<span> T0: t0, </span>
</li>
<li class="alt">
<span> leading: ^uint8(0), </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> s.bw.writeBits(uint64(t0), 32) </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> &s </span></span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// Push 负责写入时序数据 </span>
</li>
<li class="alt">
<span>func (s *Series) Push(t uint32, v float64) { </span>
</li>
<li>
<span> // .... </span>
</li>
<li class="alt">
<span> // 如果是第一个数据点的话写入原始数据后直接返回 </span>
</li>
<li>
<span> if s.t == 0 { </span>
</li>
<li class="alt">
<span> s.t = t </span>
</li>
<li>
<span> s.val = v </span>
</li>
<li class="alt">
<span> s.tDelta = t - s.T0 // 实际上这里为 0 </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // The block header stores the starting <span class="keyword">time</span><span> stamp, t-1(前一个时间戳), </span></span>
</li>
<li>
<span> // which <span class="keyword">is</span><span> aligned </span><span class="keyword">to</span><span> a two </span><span class="keyword">hour</span><span> window; the </span><span class="keyword">first</span><span> </span><span class="keyword">time</span><span> </span></span>
</li>
<li class="alt">
<span> // stamp, t0, <span class="op">in</span><span> the block </span><span class="keyword">is</span><span> stored </span><span class="keyword">as</span><span> a delta </span><span class="keyword">from</span><span> t−1 </span><span class="op">in</span><span> 14 bits. </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 用 14 个 <span class="keyword">bit</span><span> 写入时间戳差值 </span></span>
</li>
<li>
<span> s.bw.writeBits(uint64(s.tDelta), 14) </span>
</li>
<li class="alt">
<span> // 原始数据点完整写入 </span>
</li>
<li>
<span> s.bw.writeBits(math.Float64bits(v), 64) </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> tDelta := t - s.t </span>
</li>
<li class="alt">
<span> dod := int32(tDelta - s.tDelta) // 计算差值的差值 Detla <span class="keyword">of</span><span> Delta </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 下面开始就处理非第一个数据点的情况了 </span>
</li>
<li>
<span> switch { </span>
</li>
<li class="alt">
<span> // If D <span class="keyword">is</span><span> zero, </span><span class="keyword">then</span><span> store a single ‘0’ </span><span class="keyword">bit</span><span> </span></span>
</li>
<li>
<span> // 如果是零的话 那直接用 <span class="string">'0'</span><span> 一个字节就可以直接表示 </span></span>
</li>
<li class="alt">
<span> <span class="func">case</span><span> dod == 0: </span></span>
</li>
<li>
<span> s.bw.writeBit(zero) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // If D <span class="keyword">is</span><span> </span><span class="op">between</span><span> [-63, 64], store ‘10’ followed </span><span class="keyword">by</span><span> the value (7 bits) </span></span>
</li>
<li class="alt">
<span> <span class="func">case</span><span> -63 <= dod && dod <= 64: </span></span>
</li>
<li>
<span> s.bw.writeBits(0x02, 2) // 控制位 <span class="string">'10'</span><span> </span></span>
</li>
<li class="alt">
<span> s.bw.writeBits(uint64(dod), 7) // 7bits 可以表示 [-63, 64] 的范围 </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // If D <span class="keyword">is</span><span> </span><span class="op">between</span><span> [-255, 256], store ‘110’ followed </span><span class="keyword">by</span><span> the value (9 bits) </span></span>
</li>
<li>
<span> <span class="func">case</span><span> -255 <= dod && dod <= 256: </span></span>
</li>
<li class="alt">
<span> s.bw.writeBits(0x06, 3) // 控制位 <span class="string">'110'</span><span> </span></span>
</li>
<li>
<span> s.bw.writeBits(uint64(dod), 9) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // if D <span class="keyword">is</span><span> </span><span class="op">between</span><span> [-2047, 2048], store ‘1110’ followed </span><span class="keyword">by</span><span> the value (12 bits) </span></span>
</li>
<li class="alt">
<span> <span class="func">case</span><span> -2047 <= dod && dod <= 2048: </span></span>
</li>
<li>
<span> s.bw.writeBits(0x0e, 4) // 控制位 <span class="string">'1110'</span><span> </span></span>
</li>
<li class="alt">
<span> s.bw.writeBits(uint64(dod), 12) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // Otherwise store ‘1111’ followed <span class="keyword">by</span><span> D using 32 bits </span></span>
</li>
<li>
<span> <span class="keyword">default</span><span>: </span></span>
</li>
<li class="alt">
<span> s.bw.writeBits(0x0f, 4) // 其余情况控制位均用 <span class="string">'1111'</span><span> </span></span>
</li>
<li>
<span> s.bw.writeBits(uint64(dod), 32) </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 到这里 (T, V) 中的时间戳已经写入完毕了 接下来是写 V 部分 </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 先计算两个值的异或结果 </span>
</li>
<li>
<span> vDelta := math.Float64bits(v) ^ math.Float64bits(s.val) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // If XOR <span class="keyword">with</span><span> the previous </span><span class="keyword">is</span><span> zero (same value), store single ‘0’ </span><span class="keyword">bit</span><span> </span></span>
</li>
<li class="alt">
<span> // 如果前后两个值相等的话 直接用 <span class="string">'0'</span><span> 1 个 </span><span class="keyword">bit</span><span> 就可以表示 </span></span>
</li>
<li>
<span> // 所以如果上报的时序数据是 1 或者 0 这种的话 占用的内存会非常少 </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // zero = <span class="string">'0'</span><span>; one = </span><span class="string">'1'</span><span> </span></span>
</li>
<li class="alt">
<span> if vDelta == 0 { </span>
</li>
<li>
<span> s.bw.writeBit(zero) </span>
</li>
<li class="alt">
<span> } <span class="keyword">else</span><span> { // 非 0 情况那就要把控制位置为 1 </span></span>
</li>
<li>
<span> s.bw.writeBit(one) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // 计算前置 0 和后置 0 </span>
</li>
<li class="alt">
<span> leading := uint8(bits.LeadingZeros64(vDelta)) </span>
</li>
<li>
<span> trailing := uint8(bits.TrailingZeros64(vDelta)) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // clamp number <span class="keyword">of</span><span> leading zeros </span><span class="keyword">to</span><span> avoid overflow </span><span class="keyword">when</span><span> encoding </span></span>
</li>
<li class="alt">
<span> if leading >= 32 { </span>
</li>
<li>
<span> leading = 31 </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // (Control <span class="keyword">bit</span><span> ‘0’) If the block </span><span class="keyword">of</span><span> meaningful bits </span></span>
</li>
<li>
<span> // falls within the block <span class="keyword">of</span><span> previous meaningful bits, </span></span>
</li>
<li class="alt">
<span> // i.e., there are <span class="keyword">at</span><span> least </span><span class="keyword">as</span><span> many leading zeros </span><span class="op">and</span><span> </span></span>
</li>
<li>
<span> // <span class="keyword">as</span><span> many trailing zeros </span><span class="keyword">as</span><span> </span><span class="keyword">with</span><span> the previous value, </span></span>
</li>
<li class="alt">
<span> // use that information <span class="keyword">for</span><span> the block position </span><span class="op">and</span><span> </span></span>
</li>
<li>
<span> // just store the meaningful XORed value. </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // 如果前置 0 不小于上一个值计算的异或结果的前置 0 且后置 0 也不小于上一个值计算的异或结果的后置 0 </span>
</li>
<li class="alt">
<span> if s.leading != ^uint8(0) && leading >= s.leading && trailing >= s.trailing { // => 控制位 <span class="string">'10'</span><span> </span></span>
</li>
<li>
<span> s.bw.writeBit(zero) </span>
</li>
<li class="alt">
<span> // 记录异或值非零部分 </span>
</li>
<li>
<span> s.bw.writeBits(vDelta>>s.trailing, 64-<span class="keyword">int</span><span>(s.leading)-</span><span class="keyword">int</span><span>(s.trailing)) </span></span>
</li>
<li class="alt">
<span> } <span class="keyword">else</span><span> { // => 控制位 </span><span class="string">'11'</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // (Control <span class="keyword">bit</span><span> ‘1’) Store the length </span><span class="keyword">of</span><span> the number </span></span>
</li>
<li>
<span> // <span class="keyword">of</span><span> leading zeros </span><span class="op">in</span><span> the </span><span class="keyword">next</span><span> 5 bits, </span><span class="keyword">then</span><span> store the </span></span>
</li>
<li class="alt">
<span> // length <span class="keyword">of</span><span> the meaningful XORed value </span><span class="op">in</span><span> the </span><span class="keyword">next</span><span> </span></span>
</li>
<li>
<span> // 6 bits. Finally store the meaningful bits <span class="keyword">of</span><span> the XORed value. </span></span>
</li>
<li class="alt">
<span> s.leading, s.trailing = leading, trailing </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 其他情况控制位置为 1 并用接下来的 5bits 记录前置 0 个数 </span>
</li>
<li>
<span> s.bw.writeBit(one) </span>
</li>
<li class="alt">
<span> s.bw.writeBits(uint64(leading), 5) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 然后用接下来的 6bits 记录异或差值中的非零部分 </span>
</li>
<li>
<span> sigbits := 64 - leading - trailing </span>
</li>
<li class="alt">
<span> s.bw.writeBits(uint64(sigbits), 6) </span>
</li>
<li>
<span> s.bw.writeBits(vDelta>>trailing, <span class="keyword">int</span><span>(sigbits)) </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // 状态更新 至此(T, V)均已被压缩写入到内存中 </span>
</li>
<li class="alt">
<span> s.tDelta = tDelta </span>
</li>
<li>
<span> s.t = t </span>
</li>
<li class="alt">
<span> s.val = v </span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// 每个 block 的结尾会使用特殊标记用于标识 </span>
</li>
<li class="alt">
<span>func finish(w *bstream) { </span>
</li>
<li>
<span> // write an <span class="keyword">end</span><span>-</span><span class="keyword">of</span><span>-stream record </span></span>
</li>
<li class="alt">
<span> w.writeBits(0x0f, 4) </span>
</li>
<li>
<span> w.writeBits(0xffffffff, 32) </span>
</li>
<li class="alt">
<span> w.writeBit(zero) </span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
论文给出了不同 case 的 buckets 占比分布。</p>
<p>
Figure: Timestamp buckets distribution</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/f82da3e6e6a1affcef1e169329a0cad1.jpg" width="auto"></p>
<p>
Figure: Value buckets distribution</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/7f970776390775cc547c3ac2e5dca5f7.jpg" width="auto"></p>
<p>
Timestamp buckets 中,前后两个时间戳差值相同的比例高达 96.39%,而在 Value buckets 中只用一个控制位的占比也达到了 59.06%,可见其压缩比之高。</p>
<p>
论文还给出了一个重要结论,数据压缩比随着时间的增长而增长,并在 120 个点的时候开始收敛到一个最佳值。</p>
<p>
Figure: 压缩率曲线</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/a91ca3c8a33a1c55fafd09e4c9290378.jpg" width="auto"></p>
<p>
Gorilla 差值算法也应用于我的另外一个项目 chenjiandongx/tszlist,一种时序数据线程安全链表。</p>
<h3>
数据写入</h3>
<p>
</p>
<p>
时序数据具有「垂直写,水平查」的特性,即同一时刻有多条时间线的数据不断被追加。但查询的时候往往是查某条时间线持续一段时间内的数据点。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>series </span></span>
</li>
<li>
<span> ^ </span>
</li>
<li class="alt">
<span> │ . . . . . . . . . . . . . . . . . . . . . . {__name__=<span class="string">"request_total"</span><span>, method=</span><span class="string">"GET"</span><span>} </span></span>
</li>
<li>
<span> │ . . . . . . . . . . . . . . . . . . . . . . {__name__=<span class="string">"request_total"</span><span>, method=</span><span class="string">"POST"</span><span>} </span></span>
</li>
<li class="alt">
<span> │ . . . . . . . </span>
</li>
<li>
<span> │ . . . . . . . . . . . . . . . . . . . ... </span>
</li>
<li class="alt">
<span> │ . . . . . . . . . . . . . . . . . . . . . </span>
</li>
<li>
<span> │ . . . . . . . . . . . . . . . . . . . . . {__name__=<span class="string">"errors_total"</span><span>, method=</span><span class="string">"POST"</span><span>} </span></span>
</li>
<li class="alt">
<span> │ . . . . . . . . . . . . . . . . . {__name__=<span class="string">"errors_total"</span><span>, method=</span><span class="string">"GET"</span><span>} </span></span>
</li>
<li>
<span> │ . . . . . . . . . . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . . . . . . . . . . . . . . . ... </span>
</li>
<li>
<span> │ . . . . . . . . . . . . . . . . . . . . </span>
</li>
<li class="alt">
<span> v </span>
</li>
<li>
<span> <<span class="comment">-------------------- time ---------------------></span><span> </span></span>
</li>
</ol>
<p>
时序数据跟时间是强相关的(不然还叫时序数据?),即大多数查询其实只会查询最近时刻的数据,这里的「最近」是个相对概念。所以没必要维护一条时间线的完整生命周期,特别是在 Kubernetes 这种云原生场景,Pod 随时有可能会被扩缩容,也就意味着一条时间线的生命周期可能会很短。如果我们一直记录着所有的时间线的索引信息,那么随着时间的推移,数据库里的时间线的数量会呈现一个线性增长的趋势 ,会极大地影响查询效率。</p>
<p>
这里引入一个概念「序列分流」,这个概念描述的是一组时间序列变得不活跃,即不再接收数据点,取而代之的是有一组新的活跃的序列出现的场景。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>series </span></span>
</li>
<li>
<span> ^ </span>
</li>
<li class="alt">
<span> │ . . . . . . </span>
</li>
<li>
<span> │ . . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . . </span>
</li>
<li>
<span> │ . . . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . . . </span>
</li>
<li>
<span> │ . . . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . . </span>
</li>
<li>
<span> │ . . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . </span>
</li>
<li>
<span> │ . . . . . </span>
</li>
<li class="alt">
<span> │ . . . . . </span>
</li>
<li>
<span> v </span>
</li>
<li class="alt">
<span> <<span class="comment">-------------------- time ---------------------></span><span> </span></span>
</li>
</ol>
<p>
我们将多条时间线的数据按一定的时间跨度切割成多个小块,每个小块本质就是一个独立小型的数据库,这种做法另外一个优势是清除过期操作的时候非常方便,只要将整个块给删了就行 (梭哈是一种智慧)。内存中保留最近两个小时的热数据(Memory Segment),其余数据持久化到磁盘(Disk Segment)。</p>
<p>
Figure: 序列分块</p>
<center>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/b7211dd837f0a3e764d5eb70e055fbf6.jpg" width="auto">
</center>
<p>
DiskSegment 使用的是 AVL Tree 实现的列表,可在插入时排序。为什么不用更加高大上的红黑树?因为不好实现...</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/ad92e67492ffa327fb92580a27654fde.jpg" width="auto"></p>
<p>
当 Memory Segment 达到归档条件的时候,会创建一个新的内存块并异步将刚归档的块写入到磁盘,同时会使用 mmap 将磁盘文件句柄映射到内存中。代码实现如下。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>func (tsdb *TSDB) getHeadPartition() (Segment, error) { </span></span>
</li>
<li>
<span> tsdb.mut.Lock() </span>
</li>
<li class="alt">
<span> defer tsdb.mut.Unlock() </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> if tsdb.segs.head.Frozen() { </span>
</li>
<li>
<span> head := tsdb.segs.head </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> go func() { </span>
</li>
<li class="alt">
<span> tsdb.wg.<span class="keyword">Add</span><span>(1) </span></span>
</li>
<li>
<span> defer tsdb.wg.Done() </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> tsdb.segs.<span class="keyword">Add</span><span>(head) </span></span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> t0 := <span class="keyword">time</span><span>.Now() </span></span>
</li>
<li class="alt">
<span> dn := dirname(head.MinTs(), head.MaxTs()) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> if err := writeToDisk(head.(*memorySegment)); err != nil { </span>
</li>
<li>
<span> logger.Errorf(<span class="string">"failed to flush data to disk, %v"</span><span>, err) </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> fname := path.<span class="op">Join</span><span>(dn, </span><span class="string">"data"</span><span>) </span></span>
</li>
<li class="alt">
<span> mf, err := mmap.OpenMmapFile(fname) </span>
</li>
<li>
<span> if err != nil { </span>
</li>
<li class="alt">
<span> logger.Errorf(<span class="string">"failed to make a mmap file %s, %v"</span><span>, fname, err) </span></span>
</li>
<li>
<span> <span class="keyword">return</span><span> </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> tsdb.segs.Remove(head) </span>
</li>
<li>
<span> tsdb.segs.<span class="keyword">Add</span><span>(newDiskSegment(mf, dn, head.MinTs(), head.MaxTs())) </span></span>
</li>
<li class="alt">
<span> logger.Infof(<span class="string">"write file %s take: %v"</span><span>, fname, </span><span class="keyword">time</span><span>.Since(t0)) </span></span>
</li>
<li>
<span> }() </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> tsdb.segs.head = newMemorySegment() </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> tsdb.segs.head, nil </span></span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
Figure: Memory Segment 两部分数据</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/1f0cf45ad2606788cafa9e0ab9a0006b.jpg" width="auto"></p>
<p>
写入的时候支持数据时间回拨,也就是支持有限的乱序数据写入,实现方案是在内存中对还没归档的每条时间线维护一个链表(同样使用 AVL Tree 实现),当数据点的时间戳不是递增的时候存储到链表中,查询的时候会将两部分数据合并查询,持久化的时候也会将两者合并写入。</p>
<h3>
Mmap 内存映射</h3>
<p>
</p>
<p>
mmap 是一种将磁盘文件映射到进程的虚拟地址空间来实现对文件读取和修改操作的技术。</p>
<p>
从 Linux 角度来看,操作系统的内存空间被分为「内核空间」和「用户空间」两大部分,其中内核空间和用户空间的空间大小、操作权限以及核心功能都不相同。这里的内核空间是指操作系统本身使用的内存空间,而用户空间则是提供给各个进程使用的内存空间。由于用户进程不具有访问内核资源的权限,例如访问硬件资源,因此当一个用户进程需要使用内核资源的时候,就需要通过 系统调用 来完成。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/bd9f9b6d342adc4dafc7ec21791414d6.jpg" width="auto"></p>
<p>
虚拟内存细节可以阅读 《虚拟内存精粹》 这篇文章。</p>
<p>
Figure: 常规文件操作和 mmap 操作的区别</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/8872f8f35bceaec0bff9b021c66fec5a.jpg" width="auto"></p>
<p>
<strong>常规文件操作</strong></p>
<p>
读文件: 用户进程首先执行 read(2) 系统调用,会进行系统上下文环境切换,从用户态切换到内核态,之后由 DMA 将文件数据从磁盘读取到内核缓冲区,再将内核空间缓冲区的数据复制到用户空间的缓冲区中,最后 read(2) 系统调用返回,进程从内核态切换到用户态,整个过程结束。</p>
<p>
写文件: 用户进程发起 write(2) 系统调用,从用户态切换到内核态,将数据从用户空间缓冲区复制到内核空间缓冲区,接着 write(2) 系统调用返回,同时进程从内核态切换到用户态,数据从内核缓冲区写入到磁盘,整个过程结束。</p>
<p>
<strong>mmap 操作</strong></p>
<p>
mmap 内存映射的实现过程,总的来说可以分为三个阶段:</p>
<p>
进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域。</p>
<p>
执行内核空间的系统调用函数 mmap,建立文件物理地址和进程虚拟地址的一一映射关系。</p>
<p>
进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存的拷贝。</p>
<p>
<strong> 小结</strong></p>
<p>
常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的 buffer 在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。</p>
<p>
而使用 mmap 操作文件,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/d5144e211cb3b39fd8e1abedaccc273a.jpg" width="auto"></p>
<p>
总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而 mmap 操控文件只需要从磁盘到用户主存的一次数据拷贝过程。mmap 的关键点是实现了「用户空间」和「内核空间」的数据直接交互而省去了不同空间数据复制的开销。</p>
<h3>
索引设计</h3>
<p>
</p>
<p>
TSDB 的查询,是通过 Label 组合来锁定到具体的时间线进而确定分块偏移检索出数据。</p>
<p>
Sid(MetricHash/-/LabelHash) 是一个 Series 的唯一标识。</p>
<p>
Label(Name/-/Value) => vm="node1"; vm="node2"; iface="eth0"。</p>
<p>
在传统的关系型数据库,索引设计可能是这样的。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/b0b6725cf693d2ce3162d2317c9e29e3.jpg" width="auto"></p>
<p>
时序数据是 NoSchema 的,没办法提前建表和定义数据模型 ,因为我们要支持用户上报任意 Label 组合的数据,这样的话就没办法进行动态的扩展了。或许你会灵光一现 ✨,既然这样,那把 Labels 放一个字段拼接起来不就可以无限扩展啦,比如下面这个样子。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/bfffff33cead73a1c35a4571f87ea7ce.jpg" width="auto"></p>
<p>
哟嚯,乍一看没毛病,靓仔窃喜。</p>
<p>
不对,有问题,要定位到其中的某条时间线,那我是不是得全表扫描一趟。而且这种设计还有另外一个弊病,就是会导致内存激增,Label 的 Name 和 Value 都可能是特别长的字符串。</p>
<p>
那怎么办呢(靓仔沉默...),刹那间我的脑中闪过一个帅气的身影,没错,就是你,花泽类「只要倒立眼泪就不会流出来」。</p>
<p>
我悟了!要学会逆向思维,把 Label 当做主键,Sid 当做其字段不就好了。这其实有点类似于 ElasticSearch 中的倒排索引,主键为 Keyword,字段为 DocumentID。索引设计如下。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/2e051f33900f36cfe8908c4751b627a4.jpg" width="auto"></p>
<p>
Label 作为主键时会建立索引(Hashkey),查找的效率可视为 O(1),再根据锁定的 Label 来最终确定想要的 Sid。举个例子,我们想要查找 {vm="node1", iface="eth0"} 的时间线的话就可以快速定位到 Sids(忽略其他 ... sid)。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>sid1; sid2; sid3 </span></span>
</li>
<li>
<span>sid2; sid3; sid5 </span>
</li>
</ol>
<p>
两者求一个交集,就可以得到最终要查询的 Sid 为 sid2 和 sid3。Nice!</p>
<p>
假设我们的查询只支持相等匹配的话,格局明显就小了。查询条件是 {vm=~"node*", iface="eth0"} 肿么办?对 label1、label2、label3 和 label4 一起求一个并集吗?显然不是,因为这样算的话那结果就是 sid3。</p>
<p>
厘清关系就不难看出,只要对相同的 Label Name 做并集然后再对不同的 Label Name 求交集就可以了。这样算的正确结果就是 sid3 和 sid5。实现的时候用到了 Roaring Bitmap,一种优化的位图算法。</p>
<p>
<strong>Memory Segment 索引匹配</strong></p>
<ol class="dp-sql">
<li class="alt">
<span><span>func (mim *memoryIndexMap) MatchSids(lvs *labelValueSet, lms LabelMatcherSet) []string { </span></span>
</li>
<li>
<span> // ... </span>
</li>
<li class="alt">
<span> sids := newMemorySidSet() </span>
</li>
<li>
<span> var got bool </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := len(lms) - 1; i >= 0; i</span><span class="comment">-- {</span><span> </span></span>
</li>
<li>
<span> tmp := newMemorySidSet() </span>
</li>
<li class="alt">
<span> vs := lvs.Match(lms) </span>
</li>
<li>
<span> // 对相同的 Label <span class="keyword">Name</span><span> 求并集 </span></span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> _, v := range vs { </span></span>
</li>
<li>
<span> midx := mim.idx.<span class="keyword">Name</span><span>, v)] </span></span>
</li>
<li class="alt">
<span> if midx == nil || midx.<span class="keyword">Size</span><span>() <= 0 { </span></span>
</li>
<li>
<span> <span class="keyword">continue</span><span> </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> tmp.<span class="keyword">Union</span><span>(midx.Copy()) </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if tmp == nil || tmp.<span class="keyword">Size</span><span>() <= 0 { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> nil </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if !got { </span>
</li>
<li class="alt">
<span> sids = tmp </span>
</li>
<li>
<span> got = <span class="keyword">true</span><span> </span></span>
</li>
<li class="alt">
<span> <span class="keyword">continue</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // 对不同的 Label <span class="keyword">Name</span><span> 求交集 </span></span>
</li>
<li class="alt">
<span> sids.Intersection(tmp.Copy()) </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> <span class="keyword">return</span><span> sids.List() </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
</ol>
<p>
<strong>Disk Segment 索引匹配</strong></p>
<ol class="dp-sql">
<li class="alt">
<span><span>func (dim *diskIndexMap) MatchSids(lvs *labelValueSet, lms LabelMatcherSet) []uint32 { </span></span>
</li>
<li>
<span> // ... </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> lst := make([]*roaring.Bitmap, 0) </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := len(lms) - 1; i >= 0; i</span><span class="comment">-- {</span><span> </span></span>
</li>
<li>
<span> tmp := make([]*roaring.Bitmap, 0) </span>
</li>
<li class="alt">
<span> vs := lvs.Match(lms) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 对相同的 Label <span class="keyword">Name</span><span> 求并集 </span></span>
</li>
<li>
<span> <span class="keyword">for</span><span> _, v := range vs { </span></span>
</li>
<li class="alt">
<span> didx := dim.label2sids.<span class="keyword">Name</span><span>, v)] </span></span>
</li>
<li>
<span> if didx == nil || didx.<span class="keyword">set</span><span>.IsEmpty() { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">continue</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> tmp = append(tmp, didx.<span class="keyword">set</span><span>) </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">union</span><span> := roaring.ParOr(4, tmp...) </span></span>
</li>
<li>
<span> if <span class="keyword">union</span><span>.IsEmpty() { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> nil </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> lst = append(lst, <span class="keyword">union</span><span>) </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // 对不同的 Label <span class="keyword">Name</span><span> 求交集 </span></span>
</li>
<li>
<span> <span class="keyword">return</span><span> roaring.ParAnd(4, lst...).ToArray() </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
</ol>
<p>
然而,确定相同的 LabelName 也是一个问题,因为 Label 本身就代表着 Name:Value,难不成我还要遍历所有 label 才能确定嘛,这不就又成了全表扫描???</p>
<p>
没有什么问题是一个索引解决不了的,如果有,那就再增加一个索引。--- 鲁迅。</p>
<p>
只要我们保存 Label 的 Name 对应的 Value 列表的映射关系即可高效解决这个问题。</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/0815f08a5f7959c962995f6947156031.jpg" width="auto"></p>
<p>
还是上面的 {vm=~"node1|node2", iface="eth0"} 查询,第一步通过正则匹配确定匹配到 node1, node2,第二步匹配到 eth0,再将 LabelName 和 LabelValue 一拼装,Label 就出来了,完事!</p>
<p>
桥豆麻袋!还有一个精彩的正则匹配优化算法没介绍。</p>
<p>
fastRegexMatcher 是一种优化的正则匹配器,算法来自 Prometheus。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>// 思路就是尽量先执行前缀匹配和后缀匹配 能不用正则就不用正则 </span></span>
</li>
<li>
<span>// 如 label 表达式为 {vm=<span class="string">"node*"</span><span>} </span></span>
</li>
<li class="alt">
<span>// 而我们此时内存中有 vm=node1, vm=node2, vm=foo, vm=bar,那这个时候只需要前缀匹配就能直接把 vm=foo,vm=bar 给过滤了 </span>
</li>
<li>
<span>// 毕竟前缀匹配和后缀匹配的执行效率还是比正则高不少的 </span>
</li>
<li class="alt">
<span>type fastRegexMatcher struct { </span>
</li>
<li>
<span> re *regexp.Regexp </span>
</li>
<li class="alt">
<span> prefix string </span>
</li>
<li>
<span> suffix string </span>
</li>
<li class="alt">
<span> <span class="keyword">contains</span><span> string </span></span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>func newFastRegexMatcher(v string) (*fastRegexMatcher, error) { </span>
</li>
<li class="alt">
<span> re, err := regexp.Compile(<span class="string">"^(?:"</span><span> + v + </span><span class="string">")$"</span><span>) </span></span>
</li>
<li>
<span> if err != nil { </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> nil, err </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> parsed, err := syntax.Parse(v, syntax.Perl) </span>
</li>
<li class="alt">
<span> if err != nil { </span>
</li>
<li>
<span> <span class="keyword">return</span><span> nil, err </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> m := &fastRegexMatcher{ </span>
</li>
<li>
<span> re: re, </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> if parsed.Op == syntax.OpConcat { </span>
</li>
<li>
<span> m.prefix, m.suffix, m.<span class="keyword">contains</span><span> = optimizeConcatRegex(parsed) </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> m, nil </span></span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>// optimizeConcatRegex <span class="keyword">returns</span><span> literal prefix/suffix text that can be safely </span></span>
</li>
<li class="alt">
<span>// checked against the label value before running the regexp matcher. </span>
</li>
<li>
<span>func optimizeConcatRegex(r *syntax.Regexp) (prefix, suffix, <span class="keyword">contains</span><span> string) { </span></span>
</li>
<li class="alt">
<span> sub := r.Sub </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // We can safely remove <span class="keyword">begin</span><span> </span><span class="op">and</span><span> </span><span class="keyword">end</span><span> text matchers respectively </span></span>
</li>
<li>
<span> // <span class="keyword">at</span><span> the beginning </span><span class="op">and</span><span> </span><span class="keyword">end</span><span> </span><span class="keyword">of</span><span> the regexp. </span></span>
</li>
<li class="alt">
<span> if len(sub) > 0 && sub.Op == syntax.OpBeginText { </span>
</li>
<li>
<span> sub = sub </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> if len(sub) > 0 && sub.Op == syntax.OpEndText { </span>
</li>
<li class="alt">
<span> sub = sub[:len(sub)-1] </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if len(sub) == 0 { </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // Given Prometheus regex matchers are always anchored <span class="keyword">to</span><span> the </span><span class="keyword">begin</span><span>/</span><span class="keyword">end</span><span> </span></span>
</li>
<li class="alt">
<span> // <span class="keyword">of</span><span> the text, if the </span><span class="keyword">first</span><span>/</span><span class="keyword">last</span><span> operations are literals, we can safely </span></span>
</li>
<li>
<span> // treat them <span class="keyword">as</span><span> prefix/suffix. </span></span>
</li>
<li class="alt">
<span> if sub.Op == syntax.OpLiteral && (sub.Flags&syntax.FoldCase) == 0 { </span>
</li>
<li>
<span> prefix = string(sub.Rune) </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> if <span class="keyword">last</span><span> := len(sub) - 1; sub[</span><span class="keyword">last</span><span>].Op == syntax.OpLiteral && (sub[</span><span class="keyword">last</span><span>].Flags&syntax.FoldCase) == 0 { </span></span>
</li>
<li class="alt">
<span> suffix = string(sub[<span class="keyword">last</span><span>].Rune) </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // If <span class="keyword">contains</span><span> </span><span class="op">any</span><span> literal which </span><span class="keyword">is</span><span> </span><span class="op">not</span><span> a prefix/suffix, we keep the </span></span>
</li>
<li class="alt">
<span> // 1st one. We do <span class="op">not</span><span> keep the whole list </span><span class="keyword">of</span><span> literals </span><span class="keyword">to</span><span> simplify the </span></span>
</li>
<li>
<span> // fast path. </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := 1; i < len(sub)-1; i++ { </span></span>
</li>
<li>
<span> if sub.Op == syntax.OpLiteral && (sub.Flags&syntax.FoldCase) == 0 { </span>
</li>
<li class="alt">
<span> <span class="keyword">contains</span><span> = string(sub.Rune) </span></span>
</li>
<li>
<span> break </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> <span class="keyword">return</span><span> </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>func (m *fastRegexMatcher) MatchString(s string) bool { </span>
</li>
<li>
<span> if m.prefix != <span class="string">""</span><span> && !strings.HasPrefix(s, m.prefix) { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span><span class="keyword">false</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if m.suffix != <span class="string">""</span><span> && !strings.HasSuffix(s, m.suffix) { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span><span class="keyword">false</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if m.<span class="keyword">contains</span><span> != </span><span class="string">""</span><span> && !strings.</span><span class="keyword">Contains</span><span>(s, m.</span><span class="keyword">contains</span><span>) { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> </span><span class="keyword">false</span><span> </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> m.re.MatchString(s) </span></span>
</li>
<li>
<span>} </span>
</li>
</ol>
<h3>
存储布局</h3>
<p>
</p>
<p>
既然是数据库,那么自然少不了数据持久化的特性。了解完索引的设计,再看看落到磁盘的存储布局就很清晰了。先跑个示例程序写入一些数据热热身。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>package main </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>import ( </span>
</li>
<li>
<span> <span class="string">"fmt"</span><span> </span></span>
</li>
<li class="alt">
<span> <span class="string">"math/rand"</span><span> </span></span>
</li>
<li>
<span> <span class="string">"strconv"</span><span> </span></span>
</li>
<li class="alt">
<span> <span class="string">"time"</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="string">"github.com/chenjiandongx/mandodb"</span><span> </span></span>
</li>
<li>
<span> <span class="string">"github.com/satori/go.uuid"</span><span> </span></span>
</li>
<li class="alt">
<span>) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// 模拟一些监控指标 </span>
</li>
<li>
<span>var metrics = []string{ </span>
</li>
<li class="alt">
<span> <span class="string">"cpu.busy"</span><span>, </span><span class="string">"cpu.load1"</span><span>, </span><span class="string">"cpu.load5"</span><span>, </span><span class="string">"cpu.load15"</span><span>, </span><span class="string">"cpu.iowait"</span><span>, </span></span>
</li>
<li>
<span> <span class="string">"disk.write.ops"</span><span>, </span><span class="string">"disk.read.ops"</span><span>, </span><span class="string">"disk.used"</span><span>, </span></span>
</li>
<li class="alt">
<span> <span class="string">"net.in.bytes"</span><span>, </span><span class="string">"net.out.bytes"</span><span>, </span><span class="string">"net.in.packages"</span><span>, </span><span class="string">"net.out.packages"</span><span>, </span></span>
</li>
<li>
<span> <span class="string">"mem.used"</span><span>, </span><span class="string">"mem.idle"</span><span>, </span><span class="string">"mem.used.bytes"</span><span>, </span><span class="string">"mem.total.bytes"</span><span>, </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// 增加 Label 数量 </span>
</li>
<li>
<span>var uid1, uid2, uid3 []string </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>func init() { </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := 0; i < len(metrics); i++ { </span></span>
</li>
<li>
<span> uid1 = append(uid1, uuid.NewV4().String()) </span>
</li>
<li class="alt">
<span> uid2 = append(uid2, uuid.NewV4().String()) </span>
</li>
<li>
<span> uid3 = append(uid3, uuid.NewV4().String()) </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span>} </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>func genPoints(ts int64, node, dc <span class="keyword">int</span><span>) []*mandodb.Row { </span></span>
</li>
<li class="alt">
<span> points := make([]*mandodb.Row, 0) </span>
</li>
<li>
<span> <span class="keyword">for</span><span> idx, metric := range metrics { </span></span>
</li>
<li class="alt">
<span> points = append(points, &mandodb.Row{ </span>
</li>
<li>
<span> Metric: metric, </span>
</li>
<li class="alt">
<span> Labels: []mandodb.Label{ </span>
</li>
<li>
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"node"</span><span>, Value: </span><span class="string">"vm"</span><span> + strconv.Itoa(node)}, </span></span>
</li>
<li class="alt">
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"dc"</span><span>, Value: strconv.Itoa(dc)}, </span></span>
</li>
<li>
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"foo"</span><span>, Value: uid1}, </span></span>
</li>
<li class="alt">
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"bar"</span><span>, Value: uid2}, </span></span>
</li>
<li>
<span> {<span class="keyword">Name</span><span>: </span><span class="string">"zoo"</span><span>, Value: uid3}, </span></span>
</li>
<li class="alt">
<span> }, </span>
</li>
<li>
<span> Point: mandodb.Point{Ts: ts, Value: float64(rand.Int31n(60))}, </span>
</li>
<li class="alt">
<span> }) </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> <span class="keyword">return</span><span> points </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>func main() { </span>
</li>
<li>
<span> store := mandodb.OpenTSDB() </span>
</li>
<li class="alt">
<span> defer store.<span class="keyword">Close</span><span>() </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> now := <span class="keyword">time</span><span>.Now().Unix() - 36000 // 10h ago </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := 0; i < 720; i++ { </span></span>
</li>
<li>
<span> <span class="keyword">for</span><span> n := 0; n < 5; n++ { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> j := 0; j < 1024; j++ { </span></span>
</li>
<li>
<span> _ = store.InsertRows(genPoints(now, n, j)) </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> now += 60 //1min </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> fmt.Println(<span class="string">"finished"</span><span>) </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">select</span><span> {} </span></span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
每个分块保存在名字为 seg-${mints}-${maxts} 文件夹里,每个文件夹含有 data 和 meta.json 两个文件。</p>
<ul>
<li>
data: 存储了一个 Segment 的所有数据,包括数据点和索引信息。</li>
<li>
meta.json: 描述了分块的时间线数量,数据点数量以及该块的数据时间跨度。</li>
</ul>
<ol class="dp-sql">
<li class="alt">
<span><span>❯ tree -h seg-* </span></span>
</li>
<li>
<span>seg-1627709713-1627716973 </span>
</li>
<li class="alt">
<span>├── [ 28M] data </span>
</li>
<li>
<span>└── [ 110] meta.json </span>
</li>
<li class="alt">
<span>seg-1627716973-1627724233 </span>
</li>
<li>
<span>├── [ 28M] data </span>
</li>
<li class="alt">
<span>└── [ 110] meta.json </span>
</li>
<li>
<span>seg-1627724233-1627731493 </span>
</li>
<li class="alt">
<span>├── [ 28M] data </span>
</li>
<li>
<span>└── [ 110] meta.json </span>
</li>
<li class="alt">
<span>seg-1627731493-1627738753 </span>
</li>
<li>
<span>├── [ 28M] data </span>
</li>
<li class="alt">
<span>└── [ 110] meta.json </span>
</li>
<li>
<span>seg-1627738753-1627746013 </span>
</li>
<li class="alt">
<span>├── [ 28M] data </span>
</li>
<li>
<span>└── [ 110] meta.json </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>0 directories, 10 files </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>❯ cat seg-1627709713-1627716973/meta.json -p </span>
</li>
<li class="alt">
<span>{ </span>
</li>
<li>
<span> <span class="string">"seriesCount"</span><span>: 81920, </span></span>
</li>
<li class="alt">
<span> <span class="string">"dataPointsCount"</span><span>: 9912336, </span></span>
</li>
<li>
<span> <span class="string">"maxTs"</span><span>: 1627716973, </span></span>
</li>
<li class="alt">
<span> <span class="string">"minTs"</span><span>: 1627709713 </span></span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
存储 8 万条时间线共接近 1 千万的数据点的数据块占用磁盘 28M。实际上在写入的时候,一条数据是这个样子的。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>{__name__=</span><span class="string">"cpu.busy"</span><span>, node=</span><span class="string">"vm0"</span><span>, dc=</span><span class="string">"0"</span><span>, foo=</span><span class="string">"bdac463d-8805-4cbe-bc9a-9bf495f87bab"</span><span>, bar=</span><span class="string">"3689df1d-cbf3-4962-abea-6491861e62d2"</span><span>, zoo=</span><span class="string">"9551010d-9726-4b3b-baf3-77e50655b950"</span><span>} 1627710454 41 </span></span>
</li>
</ol>
<p>
这样一条数据按照 JSON 格式进行网络通信的话,大概是 200Byte,初略计算一下。</p>
<p>
200 * 9912336 = 1982467200Byte = 1890M</p>
<p>
可以选择 ZSTD 或者 Snappy 算法进行二次压缩(默认不开启)。还是上面的示例代码,不过在 TSDB 启动的时候指定了压缩算法。</p>
<p>
ZstdBytesCompressor</p>
<ol class="dp-sql">
<li class="alt">
<span><span>func main() { </span></span>
</li>
<li>
<span> store := mandodb.OpenTSDB(mandodb.WithMetaBytesCompressorType(mandodb.ZstdBytesCompressor)) </span>
</li>
<li class="alt">
<span> defer store.<span class="keyword">Close</span><span>() </span></span>
</li>
<li>
<span> // ... </span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// 压缩效果 28M -> 25M </span>
</li>
<li>
<span>❯ ll seg-1627711905-1627719165 </span>
</li>
<li class="alt">
<span>Permissions <span class="keyword">Size</span><span> </span><span class="func">User</span><span> </span><span class="keyword">Date</span><span> Modified </span><span class="keyword">Name</span><span> </span></span>
</li>
<li>
<span>.rwxr-xr-x 25M chenjiandongx 1 Aug 00:13 data </span>
</li>
<li class="alt">
<span>.rwxr-xr-x 110 chenjiandongx 1 Aug 00:13 meta.json </span>
</li>
</ol>
<p>
SnappyBytesCompressor</p>
<ol class="dp-sql">
<li class="alt">
<span><span>func main() { </span></span>
</li>
<li>
<span> store := mandodb.OpenTSDB(mandodb.WithMetaBytesCompressorType(mandodb.SnappyBytesCompressor)) </span>
</li>
<li class="alt">
<span> defer store.<span class="keyword">Close</span><span>() </span></span>
</li>
<li>
<span> // ... </span>
</li>
<li class="alt">
<span>} </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span>// 压缩效果 28M -> 26M </span>
</li>
<li>
<span>❯ ll seg-1627763918-1627771178 </span>
</li>
<li class="alt">
<span>Permissions <span class="keyword">Size</span><span> </span><span class="func">User</span><span> </span><span class="keyword">Date</span><span> Modified </span><span class="keyword">Name</span><span> </span></span>
</li>
<li>
<span>.rwxr-xr-x 26M chenjiandongx 1 Aug 14:39 data </span>
</li>
<li class="alt">
<span>.rwxr-xr-x 110 chenjiandongx 1 Aug 14:39 meta.json </span>
</li>
</ol>
<p>
多多少少还是有点效果的 ...</p>
<p>
压缩是有成本的,压缩体积的同时会增大 CPU 开销(mbp 可以煎鸡蛋了),减缓写入速率。</p>
<p>
敲黑板,接下来就要来好好讲讲 data 文件到底写了什么东西。 data 存储布局如下。</p>
<p>
Figure: Segment Stroage</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/2dec784d0153c7fa28ad7d4106b9ae55.jpg" width="auto"></p>
<p>
TOC 描述了 Data Block 和 Meta Block(Series Block + Labels Block)的体积,用于后面对 data 进行解析读取。Data Block 存储了每条时间线具体的数据点,时间线之间数据紧挨存储。DataContent 就是使用 Gorilla 差值算法压缩的 block。</p>
<p>
Figure: Data Block</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/0f2fbe6fb18b2a1c18edd254c2287630.jpg" width="auto"></p>
<p>
Labels Block 记录了具体的 Label 值以及对应 Label 与哪些 Series 相关联。</p>
<p>
Figure: Labels Block</p>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/3d3af9831dca80b8b61f551af6860cb1.jpg" width="auto"></p>
<p>
Series Block 记录了每条时间线的元数据,字段解释如下。</p>
<ul>
<li>
SidLength: Sid 的长度。</li>
<li>
Sid: 时间线的唯一标识。</li>
<li>
StartOffset: 时间线数据块在 Data Block 中的起始偏移。</li>
<li>
EndOffset: 时间线数据块在 Data Block 中的终止偏移。</li>
<li>
LabelCount: 时间线包含的 Label 数量。</li>
<li>
Labels: 标签在 Labels Block 中的序号(仅记录序号,不记录具体值)。</li>
<li>
Figure: Series Block</li>
</ul>
<p>
<img title="从零实现一个时序数据库" alt="从零实现一个时序数据库" border="0" height="auto" src="https://zhuji.jb51.net/uploads/img/202305/de19cc8a881061b943d8c4ca34f66ed1.jpg" width="auto"></p>
<p>
了解完设计,再看看 Meta Block 编码和解编码的代码实现,binaryMetaSerializer 实现了 MetaSerializer 接口。</p>
<ol class="dp-sql">
<li class="alt">
<span><span>type MetaSerializer interface { </span></span>
</li>
<li>
<span> Marshal(Metadata) ([]byte, error) </span>
</li>
<li class="alt">
<span> Unmarshal([]byte, *Metadata) error </span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
编码 Metadata</p>
<ol class="dp-sql">
<li class="alt">
<span><span>const ( </span></span>
</li>
<li>
<span> endOfBlock uint16 = 0xffff </span>
</li>
<li class="alt">
<span> uint16Size = 2 </span>
</li>
<li>
<span> uint32Size = 4 </span>
</li>
<li class="alt">
<span> uint64Size = 8 </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> magic = <span class="string">"https://github.com/chenjiandongx/mandodb"</span><span> </span></span>
</li>
<li>
<span>) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span>func (s *binaryMetaSerializer) Marshal(meta Metadata) ([]byte, error) { </span>
</li>
<li class="alt">
<span> encf := newEncbuf() </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // labels block </span>
</li>
<li>
<span> labelOrdered := make(map<span class="keyword">int</span><span>) </span></span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> idx, row := range meta.Labels { </span></span>
</li>
<li>
<span> labelOrdered = idx </span></span>
</li>
<li class="alt">
<span> encf.MarshalUint16(uint16(len(row.<span class="keyword">Name</span><span>))) </span></span>
</li>
<li>
<span> encf.MarshalString(row.<span class="keyword">Name</span><span>) </span></span>
</li>
<li class="alt">
<span> encf.MarshalUint32(uint32(len(row.Sids))) </span>
</li>
<li>
<span> encf.MarshalUint32(row.Sids...) </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> encf.MarshalUint16(endOfBlock) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> // series block </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> idx, series := range meta.Series { </span></span>
</li>
<li>
<span> encf.MarshalUint16(uint16(len(series.Sid))) </span>
</li>
<li class="alt">
<span> encf.MarshalString(series.Sid) </span>
</li>
<li>
<span> encf.MarshalUint64(series.StartOffset, series.EndOffset) </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> rl := meta.sidRelatedLabels </span>
</li>
<li class="alt">
<span> encf.MarshalUint32(uint32(rl.Len())) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> lids := make([]uint32, 0, rl.Len()) </span>
</li>
<li>
<span> <span class="keyword">for</span><span> _, lb := range rl { </span></span>
</li>
<li class="alt">
<span> lids = append(lids, uint32(labelOrdered)) </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> sort.Slice(lids, func(i, j <span class="keyword">int</span><span>) bool { </span></span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> lids < lids </span></span>
</li>
<li>
<span> }) </span>
</li>
<li class="alt">
<span> encf.MarshalUint32(lids...) </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> encf.MarshalUint16(endOfBlock) </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> encf.MarshalUint64(uint64(meta.MinTs)) </span>
</li>
<li>
<span> encf.MarshalUint64(uint64(meta.MaxTs)) </span>
</li>
<li class="alt">
<span> encf.MarshalString(magic) // <<span class="comment">-- magic here</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> <span class="keyword">return</span><span> ByteCompress(encf.Bytes()), nil </span></span>
</li>
<li>
<span>} </span>
</li>
</ol>
<p>
解码 Metadata</p>
<ol class="dp-sql">
<li class="alt">
<span><span>func (s *binaryMetaSerializer) Unmarshal(data []byte, meta *Metadata) error { </span></span>
</li>
<li>
<span> data, err := ByteDecompress(data) </span>
</li>
<li class="alt">
<span> if err != nil { </span>
</li>
<li>
<span> <span class="keyword">return</span><span> ErrInvalidSize </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> if len(data) < len(magic) { </span>
</li>
<li>
<span> <span class="keyword">return</span><span> ErrInvalidSize </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> decf := newDecbuf() </span>
</li>
<li>
<span> // 检验数据完整性 </span>
</li>
<li class="alt">
<span> if decf.UnmarshalString(data) != magic { </span>
</li>
<li>
<span> <span class="keyword">return</span><span> ErrInvalidSize </span></span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // labels block </span>
</li>
<li>
<span> offset := 0 </span>
</li>
<li class="alt">
<span> labels := make([]seriesWithLabel, 0) </span>
</li>
<li>
<span> <span class="keyword">for</span><span> { </span></span>
</li>
<li class="alt">
<span> var labelName string </span>
</li>
<li>
<span> labelLen := decf.UnmarshalUint16(data) </span>
</li>
<li class="alt">
<span> offset += uint16Size </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> if labelLen == endOfBlock { </span>
</li>
<li>
<span> break </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> labelName = decf.UnmarshalString(data) </span></span>
</li>
<li>
<span> offset += <span class="keyword">int</span><span>(labelLen) </span></span>
</li>
<li class="alt">
<span> sidCnt := decf.UnmarshalUint32(data) </span>
</li>
<li>
<span> offset += uint32Size </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> sidLst := make([]uint32, sidCnt) </span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> i := 0; i < </span><span class="keyword">int</span><span>(sidCnt); i++ { </span></span>
</li>
<li>
<span> sidLst = decf.UnmarshalUint32(data) </span>
</li>
<li class="alt">
<span> offset += uint32Size </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> labels = append(labels, seriesWithLabel{<span class="keyword">Name</span><span>: labelName, Sids: sidLst}) </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> meta.Labels = labels </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> // series block </span>
</li>
<li>
<span> <span class="keyword">rows</span><span> := make([]metaSeries, 0) </span></span>
</li>
<li class="alt">
<span> <span class="keyword">for</span><span> { </span></span>
</li>
<li>
<span> series := metaSeries{} </span>
</li>
<li class="alt">
<span> sidLen := decf.UnmarshalUint16(data) </span>
</li>
<li>
<span> offset += uint16Size </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> if sidLen == endOfBlock { </span>
</li>
<li class="alt">
<span> break </span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> series.Sid = decf.UnmarshalString(data) </span></span>
</li>
<li class="alt">
<span> offset += <span class="keyword">int</span><span>(sidLen) </span></span>
</li>
<li>
<span> series.StartOffset = decf.UnmarshalUint64(data) </span>
</li>
<li class="alt">
<span> offset += uint64Size </span>
</li>
<li>
<span> series.EndOffset = decf.UnmarshalUint64(data) </span>
</li>
<li class="alt">
<span> offset += uint64Size </span>
</li>
<li>
<span> labelCnt := decf.UnmarshalUint32(data) </span>
</li>
<li class="alt">
<span> offset += uint32Size </span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> labelLst := make([]uint32, labelCnt) </span>
</li>
<li>
<span> <span class="keyword">for</span><span> i := 0; i < </span><span class="keyword">int</span><span>(labelCnt); i++ { </span></span>
</li>
<li class="alt">
<span> labelLst = decf.UnmarshalUint32(data) </span>
</li>
<li>
<span> offset += uint32Size </span>
</li>
<li class="alt">
<span> } </span>
</li>
<li>
<span> series.Labels = labelLst </span>
</li>
<li class="alt">
<span> <span class="keyword">rows</span><span> = append(</span><span class="keyword">rows</span><span>, series) </span></span>
</li>
<li>
<span> } </span>
</li>
<li class="alt">
<span> meta.Series = <span class="keyword">rows</span><span> </span></span>
</li>
<li>
<span> </span>
</li>
<li class="alt">
<span> meta.MinTs = int64(decf.UnmarshalUint64(data)) </span>
</li>
<li>
<span> offset += uint64Size </span>
</li>
<li class="alt">
<span> meta.MaxTs = int64(decf.UnmarshalUint64(data)) </span>
</li>
<li>
<span> offset += uint64Size </span>
</li>
<li class="alt">
<span> </span>
</li>
<li>
<span> <span class="keyword">return</span><span> decf.Err() </span></span>
</li>
<li class="alt">
<span>} </span>
</li>
</ol>
<p>
至此,对 mandodb 的索引和存储整体设计是不是就了然于胸。</p>
<p>
【编辑推荐】https://mp.weixin.qq.com/s/PqVHjGLLu5dXxjHubPbXYA</p>
頁:
[1]