RocksDB PlainTable 学习

[TOC]

参考

RockDB 官方 wiki - PlainTable-Format
RockDB 官方 wiki 中文版- PlainTable-Format
看图了解RocksDB

1. 简介

PlainTable是RocksDB的一种SST文件格式,针对低延迟纯内存或者非常低延迟的媒介进行了优化。
优势:

  • 创建一个全内存索引,使用二分法+哈希的方法替代直接二分查找
  • 绕过 block cache,避免 block 的拷贝和维护 LRU cache 的开销
  • 使用 mmap,避免查询时的内存拷贝

限制:

  • 文件大小小于31位整型数
  • 不支持数据压缩
  • 不支持delta编码
  • 不支持Iterator.Prev()
  • 不支持无前缀的Seek()
  • 构建索引的表加载速度较慢
  • 只支持 mmap 模式

可以通过

options.table_factory.reset(NewPlainTableFactory());
options.prefix_extractor.reset(NewFixedPrefixTransform(8));

或者

options.table_factory.reset(NewTotalOrderPlainTableFactory());

两种方式使用。NewPlainTableFactory()创建的基于前缀进行哈希索引的plain table,这也是 plain table 主要优化的对象。NewTotalOrderPlainTableFactory()不需要指定前缀提取器,创建的是纯二分查找的 plain table,主要是为了保全功能,对性能没怎么优化。

整体结构如下:


整体结构图

它的索引比较特殊,由hash结构和二进制查找缓存两部分组成。依然按照key的前缀做hash,如果桶对应的K-V记录很少,则直接指向第一个key(有多个key属于该桶)的记录位置。如果属于桶的K-V记录多于16条,或者包含多于一个前缀的记录,则先指向二进制查找缓存(先二分查找),而后指向第一个key的记录位置。

2. 文件格式

2.1. 总览

<beginning_of_file>
      [data row1]
      [data row1]
      [data row1]
      ...
      [data rowN]
      [Property Block]
      [Footer]                               (fixed size; starts at file_size - sizeof(Footer))
<end_of_file>

PlainTable的文件格式分为三部分

  • Data Row - 一个 kv 数据对,格式见后文分析
  • Property Block - 存放了两个信息:1. data_size,文件中数据部分的结束位置;2. fixed_key_len:如果所有的 key 都是相同长度,代表 key 的长度,否则设为0
  • Footer - 和BlockBasedTable format 一样

2.2. Data Row

每个 data row 如下编码:

   <beginning of a row>
      encoded key
      length of value: varint32
      value bytes
   <end of a row>

2.2.1. key 的编码

key 有两种编码方式:kPlain 和 kPrefix,在创建 plain table 工厂类的时候可以指定。

2.2.1.1. Plain 编码

如果 key 指定为定长,则按照 plain internal key 编码。
如果指定 key 没有指定为定长,则 key 是变长的,如下编码:

[length of key: varint32] + user key + internal bytes

internal bytes 编码见后文

2.2.1.2. Prefix 编码

Prefix 编码是一种特殊的delta编码。如果上一个 key 和当前 key 的前缀相同(前缀根据用户提供的前缀提取器决定),我们可以避免重复 key的前缀部分。
前缀相同的场景下,有三种情况对 key 进行编码(记住所有的 key 都排好序了,所以共享相同前缀的 key 是挨在一起的):

  • 相同前缀的第一个 key,完整的 key 需要写入
  • 相同前缀的第二个 key,需要记录前缀长度和前缀以外(简记为后缀)的字节数
  • 相同前缀的第三个及之后的 key,只写入后缀部分
    针对以上三种情况,定义了三个 flag 来表示完整 key、前缀、后缀。三种情况都需要记下 size。第一个字节编码如下:
+----+----+----+----+----+----+----+----+
|  Type   |            Size             |
+----+----+----+----+----+----+----+----+

前2个 bit 位是Type,00表示完整 key,01表示前缀,02表示后缀。后6个 bit 位用于存放 key 的 size:如果6个 bit不是全1,则表示 key 的大小;否则这个字节之后,会写入一个varint32。varint32加上0x3F就是 key 的size。这个方法在 key 比较短的时候可以省空间。
三种情况的举例说明如下:

  • 完整 key
    +----------------------+---------------+----------------+
    | Full Key Flag + Size | Full User Key | Internal Bytes |
    +----------------------+---------------+----------------+
    
  • 第二个 key
    +--------------------+--------------------+------------+----------------+
    | Prefix Flag + Size | Suffix Flag + Size | Key Suffix | Internal Bytes |
    +--------------------+--------------------+------------+----------------+
    
  • 第三个及之后的 key
    +--------------------+------------+----------------+
    | Suffix Flag + Size | Key Suffix | Internal Bytes |
    +--------------------+------------+----------------+
    

Internal Bytes 见后文。
有如下的 key,前后缀通过空格分开:

AAAA AAAB
AAAA AAABA
AAAA AAAC
AAAB BAA # wiki 上是AAABB AA,应该是错的,这里我更正了
AAAC AAAB

编码结果如下:

FK 8 AAAAAAAB
PF 4 SF 5 AAABA
SF 4 AAAC
FK 7 AAABBAA
FK 8 AAACAAAB

其中,FK 是完整 key的 flag,SF 是后缀flag,PF 是前缀flag。

当一个前缀下的 key 过多的时候,为防止 seek 性能受影响,将会重写完整 key。

2.2.1.3. internal bytes 编码

不管是Plain还是前缀编码类型,内部key的内部字节码都以同一种方式编码。在 RocksDB 中,key 的 internal bytes(固定为64位) 包含 type (value,delete,merge等,低8位)和 sequence ID(高56位)。通常格式如下:

+----------- ...... -------------+---+---+---+---+---+---+---+----+
|       user key                 |      sequence ID          |type|
+----------- ..... --------------+---+---+---+---+---+---+---+----+

\color{red}{注:这里 sequence ID 和 type 的顺序和官方 wiki 相反,wiki 中应该是以小端法来阐述的,此处改为大端法,方便和源码对应理解}

其中,type 占用1个字节,sequence ID 占用7个字节。对于sequence ID为0的value 类型,还可以进一步优化空间占用。当确认没有 key 没有更早的值(或者说,level风格压缩的最后一层的第一个key,或者universal风格的最后一个文件),sequence ID 就填为0,这样可以更好的压缩和编码。比如说,在 PlainTable中,我们使用1个字节"0xFF"而不是8个字节存储 internal bytes:

 +----------- ...... -------------+----+
 |       user key                 |0xFF|
 +----------- ..... --------------+----+

\color{red}{注:wiki 上写的是“0x80”,但是代码中是“char(0取反)”,应该是“0xFF”,此处以代码为准}

\color{red}{注:这里结合BlockBasedTable的 key 格式一起看,加深理解}

key 的结构

2.2.2. 内存索引格式

2.2.2.1. 基本思想

内存索引构建的时候,是尽可能的紧凑的。在顶层,索引是一个哈希表,每一个桶要么是文件里的偏移,要么是一个二分查找索引。二分查找在两种情况下需要:

  • 哈希冲突:两个以上的前缀哈希到了一个桶
  • 一个前缀下的 key 太多:需要加速同一个前缀里的查找

2.2.2.2. 格式

索引由两部分内存构成:

  • 哈希桶数组
  • 一个包含索引的缓冲区,索引支持二分查找(下面称之为“二分查找缓冲区”,用“文件”特指SST文件)

key 通过前缀(通过Options.prefix_extractor提取)的哈希值哈希到桶上

+--------------+------------------------------------------------------+
| Flag (1 bit) | Offset to binary search buffer or file (31 bits)     +
+--------------+------------------------------------------------------+

如果 flag = 0 并且 offset 等于文件数据末尾的offset,那说明这个桶里没有数据;如果 offset 更小,说明这个桶里从offset 开始只有一个前缀。如果 flag = 1,offset 存放的是一个二分查找缓冲区。用一个 bit 位记录 flag,31位记录 offset 的编码方式是为了数据更加紧凑。

二分查找缓冲区的偏移开始,二分查找索引如下编码:

<begin>
  number_of_records:  varint32
  record 1 file offset:  fixedint32
  record 2 file offset:  fixedint32
  ....
  record N file offset:  fixedint32
<end>
# N表示 record 的数量,offset 是升序

2.2.2.3. 举例

假设文件内容如下:

+----------------------------+ <== offset_0003_0000 = 0
| row (key: "0003 0000")     |
+----------------------------+ <== offset_0005_0000
| row (key: "0005 0000")     |
+----------------------------+
| row (key: "0005 0001")     |
+----------------------------+
| row (key: "0005 0002")     |
+----------------------------+
|                            |
|    ....                    |
|                            |
+----------------------------+
| row (key: "0005 000F")     |
+----------------------------+ <== offset_0005_0010
| row (key: "0005 0010")     |
+----------------------------+
|                            |
|    ....                    |
|                            |
+----------------------------+
| row (key: "0005 001F")     |
+----------------------------+ <== offset_0005_0020
| row (key: "0005 0020")     |
+----------------------------+
| row (key: "0005 0021")     |
+----------------------------+
| row (key: "0005 0022")     |
+----------------------------+ <== offset_0007_0000
| row (key: "0007 0000")     |
+----------------------------+
| row (key: "0007 0001")     |
+----------------------------+ <== offset_0008_0000
| row (key: "0008 0000")     |
+----------------------------+
| row (key: "0008 0001")     |
+----------------------------+
| row (key: "0008 0002")     |
+----------------------------+
|                            |
|    ....                    |
|                            |
+----------------------------+
| row (key: "0008 000F")     |
+----------------------------+ <== offset_0008_0010
| row (key: "0008 0010")     |
+----------------------------+ <== offset_end_data
|                            |
| property block and footer  |
|                            |
+----------------------------+

假设这个例子中,我们用2个字节的定长前缀,每一个前缀行总是加1。
现在构建索引。通过扫描文件,知道有4个不同的前缀("0003", "0005", "0007" 和 "0008")。假设我们用了5个哈希桶,前缀通过哈希函数哈希如下:

bucket 0: 0005
bucket 1: empty
bucket 2: 0007
bucket 3: 0003 0008
bucket 4: empty

桶2因为只有一个前缀("0007"),且只有2行数据(<16),因此不需要二分查找索引。
桶0需要二分查找索引,因为前缀0005有超过16行数据。
桶3需要二分查找,因为有不止一个前缀。
我们需要为桶0、3分配二分查找索引,结果如下:

+---------------------+ <== bs_offset_bucket_0
+  2 (in varint32)    |
+---------------------+----------------+
+  offset_0005_0000 (in fixedint32)    |
+--------------------------------------+
+  offset_0005_0010 (in fixedint32)    |
+---------------------+----------------+ <== bs_offset_bucket_3
+  3 (in varint32)    |
+---------------------+----------------+
+  offset_0003_0000 (in fixedint32)    |
+--------------------------------------+
+  offset_0008_0000 (in fixedint32)    |
+--------------------------------------+
+  offset_0008_0010 (in fixedint32)    |
+--------------------------------------+

哈希桶里的数据如下:

+---+---------------------------------------+
| 1 |    bs_offset_bucket_0 (31 bits)       |  <=== bucket 0
+---+---------------------------------------+
| 0 |    offset_end_data    (31 bits)       |  <=== bucket 1
+---+---------------------------------------+
| 0 |    offset_0007_0000   (31 bits)       |  <=== bucket 2
+---+---------------------------------------+
| 1 |    bs_offset_bucket_3 (31 bits)       |  <=== bucket 3
+---+---------------------------------------+
| 0 |    offset_end_data    (31 bits)       |  <=== bucket 4
+---+---------------------------------------+

2.2.2.4. 索引查找

要查询一个 key,首先使用Options.prefix_extractor计算 key 的前缀,然后找到前缀对应的桶。如果桶里没有记录(Flag 为0并且 offset 是文件数据结尾),则 key 不存在;否则:

  • 如果 Flag 为0,意味着桶里只有一个前缀,且这个前缀下没有很多 key,offset 字段只想的就是前缀在文件中的偏移。直接线性查找即可。
  • 如果 Flag 为1,则这个桶需要进行二分查找。根据offset 字段取出二分查找索引,从二分查找索引找到所属的区域,然后在区域中进行线性查找。

2.2.2.5. 构建索引

创建索引的时候,扫面文件。对每一个 key 计算前缀,从第一个前缀开始,记录每个前缀的第(16n+1, n=0,1,2...)行的信息(包括前缀哈希值,偏移)。16是二分查找后进行线性查找的最大行数。增加这个数字,可以减少索引的内存消耗,但是会增加线性查找带来的开销。根据前缀的数量,确定最优的桶大小。分配所需的精确桶和二分查找缓冲区,然后根据桶大小填充索引。

2.2.2.6. 布隆过滤器

可以为查找请求配置前缀的布隆过滤器。用户可以给每个前缀配置所需字节数。当调用Get()或者Seek()的时候,先检查布隆过滤器,过滤掉不存在的前缀,再进行索引查找。

3. 未来计划

  • 把索引变成现成 sst 文件的一部分
  • 增加可选项根据内存消耗的权衡,去除文件大小限制
  • 构建额外更稀疏的索引,支持更通用的 iterator seek
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,776评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,527评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,361评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,430评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,511评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,544评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,561评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,315评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,763评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,070评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,235评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,911评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,554评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,173评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,424评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,106评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,103评论 2 352