基于内存的时序数据库-Gorilla的设计和实现
Gorrilla
是Facebook在2015年在VLDB发表的论文Gorilla: A Fast, Scalable, In-Memory Time Series Database
中介绍的内存型时序数据库,并在github公布了其开源实现Beringei。但前最流行的时序数据库 InfluxDB
的数据压缩算法,也参考了 Gorrila
的相关实现。
本文将以论文为主线,结合具体的开源代码实现浅析Gorrilla
的设计。原论文阐述具体实现的章节包括Time series compression
、In-memory data structures
、On disk structures
和Handling failures
共4个小节,本文也将按照这个顺序进行展开。
时序数据压缩
数据格式Gorrilla
存储的数据是一个三元组:字符串类型的key,64bit的整形时间戳,双精度的浮点数数值。每个时序数据点(又称time series
)由时间戳和当时对应的数据点值(在Gorrilla
中称为measurement
)组成。利用时序数据点在时间轴上具备的较强相关性,可以实现较好的数据压缩效果。针对整数类型时间戳和浮点数类型的值,Gorrilla
采用了不同的压缩算法。压缩后的数据(包括时间戳和数据值)以block
的形式组织,block
包含的数据段按照时间范围进行划分。
时间戳压缩
-
block
的header
和第一个时序数据点
每个block
的header
存储block
完整的起始时间戳t-1。每个block
的对应的时间窗口是2小时,其起始时间按照两小时对齐;紧跟header
之后的是第一个时序数据点,第一个时序数据点的时间信息用实际的戳t0与t-1的差异(delta)来表示。对应到图中则是
t-1=March 24,2015 02:00:00
t0=March 24,2015 02:00:00
delta=62
-
block
的第一个时序数据点之后的数据点
(a) 计算时间戳的delta的delta
D = (tn - tn-1) - (tn-1 - tn-2)
(b) 如果 D = 0,则该数据点的时间戳用1个比特位0
表示;
(c) 如果 D 属于[-63,64]区间,则该数据点的时间戳用2个比特位10
和7个比特位的 D 表示;
(d) 如果 D 属于[-255,256]区间,则该数据点的时间戳用3个比特位110
和9个比特位的 D 表示;
(e) 如果 D 属于[-2047,2048]区间,则该数据点的时间戳用4个比特位1110
和12个比特位的 D 表示;
(e) 其他,则该数据点的时间戳用4个比特位1111
和32个比特位的 D 表示;
对生产环境的时序数据进行采样后,选择了一组比较合适的各个时间范围区间的划分。对于固定时间间隔的时间点,只需要1个比特位即可表示。用Facebook的一段生产数据集进行了验证,该压缩方法可以取得较好的压缩效果。
从Beringei
中摘取了一段经过简化,保留了主要逻辑的代码如下:
struct {
int64_t bitsForValue;
uint32_t controlValue;
uint32_t controlValueBitLength;
} static const timestampEncodings[4] = {{7, 2, 2}, //如果 D 属于[-63,64]区间,则该数据点的时间戳用2个比特位10和7个比特位的 D 表示
{9, 6, 3}, //如果 D 属于[-255,256]区间,则该数据点的时间戳用3个比特位110和9个比特位的 D 表示
{12, 14, 4}, //如果 D 属于[-2047,2048]区间,则该数据点的时间戳用4个比特位1110和12个比特位的 D 表示
{32, 15, 4}}; //其他,则该数据点的时间戳用4个比特位1111和32个比特位的 D 表示
bool TimeSeriesStream::appendTimestamp(
int64_t timestamp,
int64_t minTimestampDelta) {
if (data_.empty()) {
// Store the first value as is
// 首个时间点存储时间园值
BitUtil::addValueToBitString(
timestamp, kBitsForFirstTimestamp, data_, numBits_);
prevTimestamp_ = timestamp;
prevTimestampDelta_ = kDefaultDelta;
return true;
}
if (deltaOfDelta == 0) {
// 如果 D = 0,则该数据点的时间戳用1个比特位0表示
prevTimestamp_ = timestamp;
BitUtil::addValueToBitString(0, 1, data_, numBits_);
return true;
}
// 循环用于判断值属于哪个区间
for (int i = 0; i < 4; i++) {
if (absValue < ((int64_t)1 << (timestampEncodings[i].bitsForValue - 1))) {
// 控制位
BitUtil::addValueToBitString(
timestampEncodings[i].controlValue,
timestampEncodings[i].controlValueBitLength,
data_,
numBits_);
// Make this value between [0, 2^timestampEncodings[i].bitsForValue - 1]
// 数据位
int64_t encodedValue = deltaOfDelta +
((int64_t)1 << (timestampEncodings[i].bitsForValue - 1));
BitUtil::addValueToBitString(
encodedValue, timestampEncodings[i].bitsForValue, data_, numBits_);
break;
}
}
// 保存当前值为下一次调用时的前值
prevTimestamp_ = timestamp;
prevTimestampDelta_ = delta;
return true;
}
值压缩
Gorilla
限制值的类型为双精度浮点数。根据Facebook已有的数据分析,大部分的时序数据点相对于其邻近点并不会有显著变化,这一特点使得可以实现高效的数据压缩。相近的浮点数的符号、指数和尾数的开头几位都会是相同的。为了利用这一特性,将当前值与前值进行异或计算得到中间值,并对中间值按照如下方式进行编码:
- 第一个时序数据点的值存储原值;
- 若中间值为0,则存储1个比特位
0
; - 若中间值不为0,则存储1个比特位
1
,计算中间值首尾的0的数量;
3.1 若首0和尾0的数量均不小于前值,则存储1个比特位0
(表示需要使用前值的有效位数信息)。将中间值按照前值的有效位进行截取,先去掉前值的尾0数量个尾0,再去掉前值首0数量个首0,然后存储(因此恢复时需要知道前值的首尾0个数);
3.2 否则,存储1个比特位1
(表示不需要使用前值的有效位数信息)。并用5个bit位存储首0个数,随后用6个bit存储有效位的长度,最后存储有效位;
采用这样的压缩方法,可以有效压缩Facebook的生产数据样本。但需要注意的是,当查询的时间范围较小时,由于值的解压缩可能依赖前值,通常需要读取更多的数据以得到期望的时间段的时序数据。
从Beringei
中摘取了一段经过简化,保留了主要逻辑的代码如下:
void TimeSeriesStream::appendValue(double value) {
// 若中间值为0,则存储1个比特位0
if (xorWithPrevius == 0) {
BitUtil::addValueToBitString(0, 1, data_, numBits_);
return;
}
// 若中间值不为0,则存储1个比特位`1`
BitUtil::addValueToBitString(1, 1, data_, numBits_);
if (leadingZeros >= previousValueLeadingZeros_ &&
trailingZeros >= previousValueTrailingZeros_ &&
previousBlockInformationSize < expectedSize) {
// Control bit for using previous block information.
// 否则,存储1个比特位`1`(表示不需要使用前值的有效位数信息)
BitUtil::addValueToBitString(1, 1, data_, numBits_);
// 否则,存储1个比特位`1`(表示不需要使用前值的有效位数信息)。并用5个bit位存储首0个数,随后用6个bit存储有效位的长度,最后存储有效位
uint64_t blockValue = xorWithPrevius >> previousValueTrailingZeros_;
BitUtil::addValueToBitString(
blockValue, previousBlockInformationSize, data_, numBits_);
} else {
// Control bit for not using previous block information.
// 若首0和尾0的数量均不小于前值,则存储1个比特位`0`(表示需要使用前值的有效位数信息)
BitUtil::addValueToBitString(0, 1, data_, numBits_);
// 将中间值按照前值的有效位进行截取,先去掉前值的尾0数量个尾0,再去掉前值首0数量个首0,然后存储(因此恢复时需要知道前值的首尾0个数)
// 存储前值的首0个数
BitUtil::addValueToBitString(
leadingZeros, kLeadingZerosLengthBits, data_, numBits_);
// 存储有效位
BitUtil::addValueToBitString(
// To fit in 6 bits. There will never be a zero size block
blockSize - kBlockSizeAdjustment,
kBlockSizeLengthBits,
data_,
numBits_);
uint64_t blockValue = xorWithPrevius >> trailingZeros;
BitUtil::addValueToBitString(blockValue, blockSize, data_, numBits_);
// 存储当前值,下一次调用时作为前值
previousValueTrailingZeros_ = trailingZeros;
previousValueLeadingZeros_ = leadingZeros;
}
previousValue_ = *p;
}
内存数据结构
Gorrila内存数据结构TSmap
TSmap
即Timeseries Map
,由一个vector和map组成,均作为时序数据的索引使用。vector元素位指向时间序列的智能指针(shared_ptr);map以时间序列的名称作为key(大小写敏感但是保留大小写),指向时间序列的智能指针作为value。vector可以提供较好的范围查询能力,map提供较好的点查询能力。
使用智能指针避免了范围查询时的大量数据拷贝。在删除时间序列数据的索引vector时,只需要将这片连续内存标记为dead,并将其归还给内存池,以等待下一次内存分配重用。
为了实现并发访问,每个TSmap
用一个读写锁保护访问索引vector和map,同时每个时间序列由一个自旋锁保护其访问。Facebook
认为每个时间序列的写入吞吐量不会太高,因此自旋锁并不会因为读写产生太强的冲突。
Beringei
中,TSmap
对应的实现叫做BucketMap
,摘取了一段实现范围扫描和特定key查询的代码如下:
// Get all the TimeSeries.
void BucketMap::getEverything(std::vector<Item>& out) {
out.reserve(tableSize_);
folly::RWSpinLock::ReadHolder guard(lock_);
out.insert(out.end(), rows_.begin(), rows_.end());
}
BucketMap::Item
BucketMap::getInternal(const std::string& key, State& state, uint32_t& id) {
folly::RWSpinLock::ReadHolder guard(lock_);
state = state_;
if (state_ >= UNOWNED && state_ <= READING_KEYS) {
// Either the state is UNOWNED or keys are being read. In both
// cases do not try to find the key.
return nullptr;
}
const auto& it = map_.find(key.c_str());
if (it != map_.end()) {
id = it->second;
return rows_[id];
}
return nullptr;
}
ShardMap
ShardMap
使用vector存储TSmap
的指针(unique_ptr)。
std::vector<std::unique_ptr<BucketMap>> data_;
Timeseries
timeseries
是直接存储时序数据点的内存数据结构,包括保存最近一个时间窗口数据的热数据块(open block)以及保存更早时间窗口数据的冷数据块(open block)。热数据块是一个 append-only 的字符串结构。每个数据块的时间窗口都是2个小时,热数据块的时间窗口过期后,会开启一个新的热数据块,并将过期的热数据块设置为冷数据块。热数据块转变为冷数据块过程中,所有数据会进行一次拷贝以减小内存碎片。因为热数据块会因为容量的变化经常进行内存再分配,通过数据拷贝可以在总体上减少内存碎片。
与常规数据库有所不同的是,数据的读取是直接拷贝数据块到RPC调用的数据结构中,数据的解压是由客户端完成的。
磁盘数据结构
Goriila
的底层数据存储是POSIX兼容的的分布式文件系统GlusterFS,包括3个数据副本。一个Gorrila
节点可能包括多个shard,每个shard只包括一个数据目录,每个数据目录包括4种类型的文件:key list, append-only log, complete block file, checkpoint file。
key list
key list是维护时序数据key到id的映射,这个id值是内部数据数据的下标。
append-only log file
每个shard包括一个append-only log file,存储使用本文前文中描述的压缩算法压缩后的数据。Gorrila
不提供ACID保证,此日志与传统的write-ahead-log
不同,不能完全保证数据的安全性。当数据在缓存中超过64kB,就会触发一次从数据从内存flush到磁盘。在数据持久化到磁盘前,内存中的数据可能因为故障崩溃后丢失。相对于传统的write-ahead-log
,用一定的数据丢失的风险,换来了更高的数据写入性能。
complete block file
每过两个小时,Gorrila
会复制压缩block数据到磁盘生成complete block file。文件内容包括两个section:若干64kB连续的数据块,这些数据块的格式与内存中的保持一致;保存了<时序key id, 数据block的指针>的数据对的列表。
checkpoint file
checkpoint file是用来标记complete block内存数据是否flush到磁盘的文件。当一个complete block持久化到磁盘生成相应文件,Gorrila
会删除相应的日志文件。
故障处理
Gorrila
的故障处理优先考虑单节点故障、不可观测的临时故障以及地域性的故障。对于其他故障,优先考虑最近数据的可用性,而通过HBase等外部系统来处理历史数据不可用时的历史数据查询。
Gorrila
在不同的数据中心维护两个完全一样但不考虑一致性的实例级别的数据副本,以实现数据中心故障或分区的情况下的高可用。发往故障region副本的请求会透明地重定向到另一个可用的region副本。当副本故障持续超过一分钟,副本数据将会被删除。在这期间,所有的请求都不会再发往该副本,直到副本恢复并存储有超过26小时时间的数据。
在region内部,有一个基于Paxos复制的管控系统叫ShardManager。当一个节点故障时,ShardManager会将故障节点上的shard重新分配到集群内的其他节点上。在shard移动过程中,客户端会暂存写入的数据。数据暂存的空间大小支持容纳1分钟的数据,1分钟前的数据将会被丢弃以存储新的数据。通常情况下,数据的移动会在30秒内完成。如果超过1分钟,那么可以从另一个region副本上读取完整数据。
当一个shard被分配到一台机器节点上后,shard会从GlusterFS上读取所有数据,通常需要几分钟时间。在读文件的同时,也会同时接收新的写入数据将其放入队列中暂存起来以待后续处理。在版本升级等可控的重启过程中,所有的数据都会被flush持久化到磁盘。当然,在append-only log file未将全部数据flush到磁盘的场景中,部分数据会丢失。Gorrila
选择这样的权衡,以提高系统写入吞吐量。
在节点故障恢复过程中,shard可能只包括部分数据,所有的请求都会返回一个partial标记。当客户端从一个副本处得到了一个partial的结果后,可以选择从另一个副本再次查询。
当然,最终Facebook也采用了HBase作为兜底方案。在所有内存数据副本失效后,可以通过HBase进行查询。