如何看待 Todis 的数据存储编码格式?

简单的来说,对于原版 Pika , Blackwidow 的储存编码方案已经很优秀了;但可惜的是,对于 Todis 来说,原版储存编码方案存在一个致命的缺陷。我们不得不重新设计了现在的编码方式。

Todis 的储存编码方案

Todis 基本沿用了原版 Blackwidow 的编码方案:对于除 string 之外的数据类型,均拆解为元数据 (meta_key , meta_value) 和普通数据 (data_key , data_value) 。

区别在于:对于储存数据的 data_key ,原版编码方式采用了 KeySize + KeyData 来描述原始 key ,而 Todis 改为了双写原 key 的 0x00 ,并以 0x00 0x01 表示终止(以下称 "编码 Key" )。除了 data_key 里描述原始 key 的方式之外,其余部分与原版 Blackwidow 方案完全相同。

这样做的目的是什么呢?

  1. Bytewise Comparable, 只有这样,才能使用 Topling 的 MemTab 和 SST
  2. 支持分布式 Compact,Todis 结点和 Compact 结点是互相独立的机器,这样的编码方案可以最小化数据传输(后面详细说明)

设计细节

0. 编码 key

举一个简单栗子: 假设我们现在要存的 key 是 "0102507" 。(注意这里是字节意义上的,即每个 byte 的值)

在原版 Blackwidow 中,这个 key 会被编码为 "00070102507" , 前 4 字节 "0007" 是 KeySize , 后面的 7 个字节 "0102507" 是原始的 key 。(这里忽略了实际编码中后续的 version / index / member 等要素)

而在 Todis 中,我们会首先双写原 key 的每一个 0 , 变为 "0010025007" ;再在末尾加上终止符 "01" , 最终的编码为 "001002500701" 。

容易看出,将排序后的原始 Key 均改写为编码 Key 后,所有的 Key 仍然是有序的。

另一个额外的好处是更节省空间,因为 0 字节在 Redis 的 Key 中是极少出现的,因为绝大部分场景下,其 Key 都是 C 语言 string 格式(以 0 字节结尾,但不包括结尾的 0)。

1. String 结构的存储

String 本身就是一个 Key ,无需额外的 meta_data ,所有记录以普通数据的形式储存。

  • String 结构 data_key 和 data_value 的落盘方式

data_value 包含原始 Value 和 4 Bytes 的有效时间戳 timestamp 。timestamp 主要用于支持 Redis 的 expire 功能,若未设置超时时间,则 timestamp 的值为 0 。

大部分情况下, timestamp 储存与对应结构的元数据中, String 比较简单因而记录在普通数据中。

  • String 结构的查询方式

在查询时,会首先解析末尾的有效时间戳 timestamp 。若获取的 timestamp 已经过期,则不会返回数据。过期数据会在 compact 时被一并清理。

2. Hash 结构的存储

哈希表的元数据储存了哈希表 Filed - Value 对的数量,版本号(创建时间戳)以及过期时间,普通数据则是该哈希表的每一对 Filed 和 Value 。

  • Hash 结构 meta_key 和 meta_value 的落盘方式

meta_key 就是 Hash 结构的 Key 本身,meta_value则包含 3 个部分: 4 Bytes 的 Hash Size (当前 Hash 表的元素数量)、 4 Bytes 的版本号 Version (创建时间戳,用于秒删)、 4 Bytes 的有效时间戳 timestamp 。

  • Hash 结构 data_key 和 data_value 的落盘方式

data_key 由三个部分组成:编码 Key 、 4 Bytes 的版本号 Version 和该条记录中 Field 的内容。 data_value 就是该 Field 对应的 Value 。

  • Hash 结构的查询方式

查找过程中,首先会获取到该 Key 的 meta_value ,解析出有效时间戳 timestamp 和版本号 version 。当数据没有过期时,将对应的编码 key 、 version 和 field 拼成 data_key ,再进行一次查询。

timestamp 超时或是 version 无效的数据(data_key 中的 version 与 meta_key 的 version 不一致)会在 compact 的过程中被清理。

3. List 结构的存储

Blackwidow 底层以数组的形式储存 List 的每一个节点。除了 Value 之外,还为每一个元素创建了索引。Blackwidow 保证同一列表的所有节点的索引是连续的。

列表的元数据储存了该列表的大小、版本号、过期时间以及索引范围,普通数据则储存该列表的每一个元素和它的索引。

  • List 结构 meta_key 和 meta_value 的落盘方式

meta_key 就是 List 结构的 Key 本身,meta_value则包含 5 个部分: 8 Bytes 的 List Size (当前列表的元素数量)、 4 Bytes 的版本号 Version 、 4 Bytes 的有效时间戳 timestamp 、 8 Bytes 的列表数组左边界 Left Index 和 8 Bytes 的列表数组右边界 Right Index 。

  • List 结构 data_key 和 data_value 的落盘方式

data_key 由三个部分组成:编码 Key 、 4 Bytes 的版本号 Version 和 8 Bytes 的该节点的底层索引。 data_value 就是该列表元素对应的 Value 。

4. Set 结构的存储

Set 结构的元数据中储存了集合的大小、版本号和过期时间,每个集合的 member 储存为普通数据。

  • Set 结构 meta_key 和 meta_value 的落盘方式

meta_key 就是 Set 结构的 Key 本身,meta_value则包含 3 个部分: 4 Bytes 的 Set Size (当前集合的 member 数量)、 4 Bytes 的版本号 Version 、 4 Bytes 的有效时间戳 timestamp 。

  • Set 结构 data_key 和 data_value 的落盘方式

data_key 由三个部分组成:编码 Key 、 4 Bytes 的版本号 Version 和该条记录中 Member 的内容。集合只需要记录 member ,data_value 目前为空串。

5. ZSet 结构的存储

Zset 结构比较特殊,既需要支持按照 Score 排序,也需要支持按照 Member 排序,故 Zset 中的每一个数据都会在普通数据中被写两遍,我们分别将其称为 ZsetScoreKey 和 ZsetMemberKey 。这两种 Key 都储存了某一条 Score - Member 的全部信息,但 ZsetScoreKey 落盘后按 Score 排序,而 ZsetMemberKey 按 Member 排序。

Zset 的元数据中储存 Zset 的大小、版本号和过期时间。

  • Zset 结构 meta_key 和 meta_value 的落盘方式

meta_key 就是 Zset 结构的 Key 本身,meta_value则包含 3 个部分: 4 Bytes 的 Zset Size (当前有序集合的元素数量)、 4 Bytes 的版本号 Version 、 4 Bytes 的有效时间戳 timestamp 。

  • Zset 结构中描述 ZsetMemberKey 的 data_key 和 data_value 的落盘方式

data_key 由三个部分组成:编码 Key 、 4 Bytes 的版本号 Version 和该条记录中 Member 的内容。 与Set 不同, 可以在 data_value 中储存该 Member 对应的 Value 。

由于 RocksDB 默认按照字节序排序,因此这样排布后,同一 Key 下的所有记录的开头部分均相同,必定是按照 Member 排序的。

  • Zset 结构中描述 ZsetScoreKey 的 data_key 和 data_value 的落盘方式

data_key 由四个部分组成:编码 Key 、 4 Bytes 的版本号 Version 、该条记录的 Score 以及 Member 。由于 data_key 中储存了所有的信息,因此这里的 data_value 依然为空。

针对 ZsetScoreKey , Blackwidow 实现了它对应的 comparator ,同一个 Key 的 ZsetScoreKey 会先按照 Score 的大小排序,再按照 Member 排序。

为什么采取新的编码方案

  • Todis 编码方案的优点

Todis 编码方案的主要目的是使同一结构的 data_key 对应的 meta_key 也有序。

RocksDB 默认按照字节序排序。在原版的 Blackwidow 编码方案中, meta_key 就是原始 key ,而 data_key 总是以 key_size + key 的方式开头,因此 meta_key 是按 key 排序的, data_key 是以 key_size 为第一关键字, key 为第二关键字排序的。 data_key 和 meta_key 的对应关系是混乱的:

采用了新的编码方案后,由于编码 Key 和原始 Key 有一致的字节序,因此 data_key 对应的 meta_key 是有序的。

  • 原版编码方案对于 Todis 的缺陷

在编码方案介绍中,我们提到过,过期的数据( timestamp 非 0 且小于当前时间)和无效的数据( data_key 中的 version 低于 meta_key 中的 version )是在 compact 的过程中被清理的。这可以通过自定义的 rocksdb::CompactionFilter 接口来实现。而在原版的 Pika 中,相关的代码逻辑如下。

// 仅保留了主要逻辑, BaseData 可以用于构建 Hash 、 Set 、 ZsetMemberKey 的 data_key
BaseDataFilter::Filter(data_key, data_value) {
    [meta_key, data_version] = parse_data_key(data_key);
    [meta_version, meta_timestamp] = parse_meta_value(get_from_meta_cf(meta_key));
    if (meta_timestamp && meta_timestamp < curr_time)
      return true;
    if (meta_version > data_version)
      return true;
    return false;
}

可以看到,作为筛选条件的 meta_timestamp 和 meta_version 都储存在元数据中,必须先在 RocksDB 中查询 meta_value (get_from_meta_cf) 。对于原版 Pika 而言,data_key 对应的 meta_key 无序并不是什么大问题,最多也就是慢一点,但是对于 Todis就无法接受了:

Todis 的设计目标之一是存储计算分离储存数据执行分布式 compact 的不是同一个节点, 没法直接拿到 meta_value !

直接走远程调用?每次 get_from_meta_cf 走一个网络来回,得慢到什么程度?费力不讨好......

把所有的 meta_data 直接发给分布式 compact 节点?理论上可以,但实际数据量大到无法负担......

把 CompactionJob 中需要用到的 meta_data 提前找出来,一起发给 compact 节点?不是不可以,但有这个时间,在本地跑 compact 都结束了......

以上因素促使我们设计了新的编码方式。在 meta_key 和 data_key 拥有一致的有序性后,对于每一个 CompactionJob ,我们可以快速找到所有需要用到的 meta_data ,发送给执行 compact 的远端节点。(具体可以看 compaction_job.cc 中的 RunRemote )与此同时,由于 compact 扫描的 key 与所要用到 meta_key 均有序,执行 compact 的节点不需要在发送的 meta_data 中进行二次搜索,直接进行两路归并即可。我们为它实现了对应的 WorkerBaseDataFilter 接口。

@你说的都队 (通过评论回答,结果评论被知乎站务删掉了,这个知乎有待改进)

comact dataCF 时,输入数据的 key 范围对应的 metaCF 中的数据,要提前捞出来放到共享存储上给 compact 结点使用,这样就要求我们只能从 metaCF 中拿有用的数据,不碰无用的数据,这种编码方式,就可以做到这一点。pika/blackwidow 自身的编码方式,做不到这一点。我们的架构跟你的这个猜测不同,我们是这样的:

@你说的都队

你对data_key采用的新编码。这个会和meta_key顺序保持一致,依据是什么?

依据是,顺序保持一致只需要满足谓词逻辑:对任意的 k1,k2,如果 k1 < k2,则 encode(k1) < encode(k2)。(其中 < 的含义是 BytewiseCompare)

这很容推导出来,因为 encode 仅对 0 进行了转移(变成 00),如果 0 之前的部分不同,就提前得出比较结果了,0 本身,两者都一样,那就继续比较后续部分……

把原先的key中的0变成两个,是为了识别到最后一个0处于奇数为并且相邻的为1,即认为是data_key结束吗?

是的,我们没有保存 key_size,所以就必须有某种方式来判断 key 结束,因为 0 变成了 00,所以 对 encode(key) 内容,如果我们看到当前字节是 0,下一个字节也必然是 0,如果不是 0,就意味着结束。我们用 01 做结束标记,其实也可以用 02,03 ……

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值