前言
最近在看数据密集型应用系统设计这本神书,里面提到了非常多的数据结构,SSTable是其中的一个,看了B站一个大佬的视频后,发现整个组织的过程与之前看过的MySQL底层原理极为相似,所以在此记录一下
如何构建SSTable
磁盘:数据的荒漠
当数据只有数据本身
我们在内存中组织好了KV键值对并持久化到磁盘中,想要再次读取出来时,就遇到一个问题,哪些数据是key,哪些数据是value
磁盘只是一个存储数据的工具,是不会帮我们标记这些的
我们可以在KV键值对前各自使用1Byte保存KV键值的长度,读取的时候,先读取出长度,后根据长度区分key、value。这么简单的方案肯定是有问题的,1Byte最多表示2的8次方-1,如果key或者value的长度超过这个值,要怎么处理呢?顺着这个方案延伸,我们可以使用2Byte甚至4Byte保存key、value的长度,比较简单暴力,却也容易造成浪费,总不可能使用4Byte存储1吧
分配固定的空间,无论大小总会有问题,要么浪费,要么不足,所以最好的方案就是按需分配,需要多少分配多少,总不会有问题吧!这个时候就需要另外一个数据结构-----链表,每个节点1Byte,根据数据大小自由伸缩
相信大家已经想到了链表存在的问题,自由伸缩的同时需要存储下一个节点的地址,而这个地址有可能比内容还大,这样就很不值了,所以大佬们就把链表稍微改造了一下,以数组的形式组织,只需要一比特存储指针数据,这个指针数组代表下一个节点还有没有数据
从左到右,每次读取1Byte
如果第一个比特是1,则继续读
如果第一个比特是0,则停止
如何在磁盘中组织Entry
我们已经知道了一条Entry是长什么样子(当然,远远没有这么简单),多条Entry要怎么组装呢?
已知SSTable输入的key是有序的、没有插入操作、希望有较高的查询性能
在已知的条件下,我们比较一下链表和数组
链表
- 查询性能差
- 容易产生大量随机读
数组
- 有序,二分查找,查询性能高
- 插入,移动大量元素(不需要插入)
- Entry紧密排列,可以一次性读取
比较后,我们发现,SSTable没有插入操作,完美躲避掉了数组的缺点,也就是,我们可以如下图组装Entry
多条Entry组成的结构我们称为Block
Block的优化--二分查找
SSTable是有序的,如果我们想要查找其中的某个Entry,二分查找是比较合适的方式,但是每个Entry的长度是不同的,为了能够进行二分查找,我们加上了辅助字段entry offset去索引Entry
Block的优化--前缀压缩
由于SSTable是有序的,相邻的key大部分有相似的地方,所以我们可以通过前缀压缩的方式减少空间放大
于是我们需要改变一下之前的设计,将key的长度改为shared(共享key长度),non_shared(非共享key长度),unshared_key(非共享key的详情),这样设计也很好理解,共享key的详情可以从上一个Entry读到,不需要记录,非共享key是独享的,自然需要存储了
那我们怎么找到这个共享key呢?
- 从左到右遍历字符
- 相同位置的字符相同则继续
- 不相同就停止
读取的时候会稍微麻烦一点
- 读取第一个Entry的完整的Key
- 读取第二个Entry的共享长度,根据它从上一个Key读取到共享的key
- 读取第二个Entry的非共享长度,根据它读取到非共享Key
- 拼接两个字符串组成完整的Key
看到这个读取过程,相信大家已经想到前缀压缩的缺点了,只能顺序遍历,解析了上一个Entry才能解析下一个Entry,假如我们要找的Entry在最后一位,也得从第一位开始找,更可怕的是,没有办法进行二分查找,除了第一个key,其它key单独拎出来,都是不完整的,为了优化存储,做这么大的牺牲,显然是不能接受的
于是大佬们又想到了一个很好的方法,分组内前缀压缩,之前是一个Block,现在将这个Block拆分成多个Group,这样即使是顺序遍历也只是遍历单个Group的长度
如上图,拆分成Group之后的结构
- Restart Point:Group第一个Entry的地址
- 读取Restart Point可读取到Entry的Key
- 在Restart Point上进行二分查找
如何定位Group
优化之路是不可能一帆风顺的,在没有进行前缀压缩时,我们可以通过entry offset索引Entry,进行前缀压缩并且拆分Group后,我们要怎么知道Entry在哪个Group呢?
只能抓住已知条件,SSTable是有序的,并且每个Group的首个Entry没有进行前缀压缩
如上图,通过二分查找Restart Point(Restart Point可以索引到Group),找到最后一个key < target的Gruop,之所以要找最后一个key < target的Gruop,是因为我们要锁定Entry所在的区间,找到最后一个key < target的Gruop,我们最多需要遍历一个Group加一个Entry就可以找到对应的Entry(有可能这个Entry等于下一个Group的首个key,所以是一个Group加一个Entry)
组内顺序遍历
读取当前的Entry的value
下一个Entry的开头:value_offset + value_size
如何组织Block
首先看单个Block,Block只是在内存中的东西,放到磁盘中还要进行压缩,并且做CRC校验,持久化到磁盘后,我们可以得到一个BlockHandle,这个BlockHandle包含了两个信息,起始地址与Block大小
如果有多个Block的话,我们要怎么组织呢?我们要如何知道Entry在哪一个Block呢?
其实还是老方法--索引,上面我们已经得到了BlockHandle(通过BlockHandle可以在磁盘中扫描到Block),但是我们怎么知道Entry位于哪个Block呢?这个时候我们要构建另外一个索引--IndexBlock,用它找到Entry位于哪个Block,结构如上
IndexBlock也是由一个个Entry组成的,每个Entry的value是BlockHandle,我们要达到的效果是输入任意一个key,都能返回key所在BlockHandle,那这个key要怎么选取呢?
key 是大于等于当前 DataBlock 中最大的 key 且小于下一个DataBlock中最小的 key的值,搜索IndexBlock的条件是找到第一个key >= target的Entry,这样子就能锁定key所在DataBlock
蛮巧妙的
Index Block持久化到磁盘的过程,需要加一些填充位
我们再来回顾一下整个过程
- 存储key/value的Block:DataBlock
- 存储DataBlock索引信息的Block:Index Block
- 每持久化好一个DataBlock,就向Index Block写入一对<key, BlockHandle>
- DataBlock都持久化完成后,Index Block构建完成,持久化Index Block获得Index BlockHandle,将Index BlockHandle写入固定大小的Footer,持久化Footer
整体读取流程
- 从磁盘读取固定大小的Footer
- 根据Index BlockHandle读取Index Block
- 根据Index Block搜索Value,得到BlockHandle
- 根据BlockHandle读取DataBlock
- 搜索DataBlock获得key对应的Value
布隆过滤器
经过上面的介绍,大致可以知道,找到一个key不容易,既要遍历Group,又要加载磁盘数据,一次两次不算什么,如果查找的次数比较多,性能的损耗也是蛮严重的,所以我们最好做到每一次查询都是有效的,拦截大部分无效的查询
有一个工具有这样的特性--布隆过滤器
结构如上
每个DataBlock生成一个filter data,每隔2K生成一个filter_offset,同一个DataBlock的filter_offset指向同一个filter data
filter data中存储一个DataBlock的filter的bitmap
filter_offset指向一个filter data,指向同一个filter data的filter offset中,只有第一个可以被读取,其它都是填充
当有多个DataBlock时,filter data与filter_offset的结构如上
我们要寻找filter data时,使用DataBlock offset(每个DataBlock 4k) 除以 2K,就可以得到filter_offset的下标,根据下标找到filter data的地址
整个filter block的持久化过程如上
我们再来回顾下
- 每次向DataBlock写入key/value,向filter buffer写入key
- 一个DataBlock持久化完成,将filter buffer数据写入到filter block
- 所有DataBlock持久化完成,filter block构建完成,持久化filter block获得BlockHandle
- 持久化Meta Index Block,得到Meta BlockHandle,写入Meta BlockHandle到Footer,持久化Footer
合体
将filter block和DataBlock整合起来,就变成下面这个样子了
学习资料
\