基于内存的时序数据库Gorilla的设计和实现-论文阅读笔记

Gorrilla是Facebook在2015年在VLDB发表的论文Gorilla: A Fast, Scalable, In-Memory Time Series Database
中介绍的内存型时序数据库,并在github公布了其开源实现Beringei。当前最流行的时序数据库 InfluxDB的数据压缩算法,也参考了 Gorrila的相关实现。

本文将以论文为主线,结合具体的开源代码实现浅析Gorrilla的设计。原论文阐述具体实现的章节包括Time series compressionIn-memory data structuresOn disk structuresHandling failures共4个小节,本文也将按照这个顺序进行展开。

时序数据压缩

数据格式

Gorrilla存储的数据是一个三元组:字符串类型的key,64bit的整形时间戳,双精度的浮点数数值。每个时序数据点(又称time series)由时间戳和当时对应的数据点值(在Gorrilla中称为measurement)组成。利用时序数据点在时间轴上具备的较强相关性,可以实现较好的数据压缩效果。针对整数类型时间戳和浮点数类型的值,Gorrilla采用了不同的压缩算法。压缩后的数据(包括时间戳和数据值)以block的形式组织,block包含的数据段按照时间范围进行划分。

时间戳压缩

  1. blockheader和第一个时序数据点
    每个blockheader存储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

  1. 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已有的数据分析,大部分的时序数据点相对于其邻近点并不会有显著变化,这一特点使得可以实现高效的数据压缩。相近的浮点数的符号、指数和尾数的开头几位都会是相同的。为了利用这一特性,将当前值与前值进行异或计算得到中间值,并对中间值按照如下方式进行编码:

  1. 第一个时序数据点的值存储原值;
  2. 若中间值为0,则存储1个比特位0
  3. 若中间值不为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

TSmapTimeseries 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进行查询。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270