背景
众所周知,Nutsdb提供了Bucket这个概念,相当于数据库中数据表的概念,方便用户在数据层面做到对数据的业务切分和管理。但是对于这个概念,现有的设计与实现并不是很完美, 对于一些场景的拓展性还是不够的。是怎么一回事呢? 且让我们来捋一捋现在Bucket机制的设计与实现,讲述清楚问题所在,方便读者了解本次我做了什么事情吧~
现状
这时候不得不回到下面这张老图了,相信看过我之前写的文章的小伙伴对这张图肯定不陌生。当下Nutsdb对于数据的存储格式就是这样设计的,header存储这条数据的元信息,bucket,key,value存储的就是这条数据实际的 信息。现在无论是对于数据的增加删除修改操作,还是对与bucket的新增和删除操作,都是通过添加这样一条数据写入到磁盘中,来实现对数据的持久化机制的。也就是说,bucket的值(比如你有个的bucket叫aaa)实际上是和数据(比如你要存储的kv数据是key_1, value_1)存储在一起的。另外对数据的增加删除和修改,以及对bucket的增加删除和修改,都是通过写入一条以下格式的数据来实现的。
这么做比较简单直白,浅显易懂,同时也会带来一些问题。什么问题呢?且看下面为你娓娓道来。
问题是什么?
1. 重复存储bucket的值
从上面entry数据的存储协议设计来看,我们可以明确的一点是,每存储一条数据,会连带着bucket也存储一次。比如这个bucket的名字叫做bucket_1, 另外他有100万条数据,这时候其实bucket_1也存储了100万次。这样会造成存储空间上的浪费。
2. 读取重复的bucket值
另外对于Bitcask存储模型来说,重启的时候需要读取所有的数据来重新构建索引以恢复DB原有的运行状态。再拿上面的例子来分析的话,就是这一百万条同一个bucket下的数据,其中的bucket的值需要读取一百万次。这里也是不少的性能损耗。一条两条数据可能没什么。如果几万,几十万,几百万数据,累积起来就是不小的损失了。
另外一个关于重启的性能损失,假设现在有这样的一个场景。
- 创建bucket, bucket的名字是bucket_1
- 往这个bucket中写入100万条数据
- 删除这个bucket
在这个场景下,重启的时候,读取每一条数据的时候,是不会管你这条数据对应的bucket的状态是怎么样的,因为确实也管不了。当前的实现上,读取到一条数据的时候是不会知道这条数据是否被删除的。直到读取到对应删除bucket的那条数据之后,才会知道这个bucket被删除了。这样造成的结果是,我们在读取到删除bucket的时候之前,会把之前的一百万条数据都放进内存中构建好索引,然后在读取删除bucket数据之后会删除这部分内存索引,然后这部分的内存空间在下一次GC的时候才会被回收掉。
3. bucket级别的更新变得难以支持
几个月前在我们的交流群中一个用户提到了这个点,现在bucket只支持新增和删除操作,用户建议新增一个更新操作,我觉得用户的这个诉求也是合理的,只是对于现在这个实现方案来说,很难在这个基础上实现这个feature。因为在某一个时刻更新了一个一个bucket的名字,从bucket_1改成bucket_2, 那么在这之前写入的,和bucket_1关联的数据该怎么办呢?总不能重新再写一次这些数据吧?
How,如何改善现状?
好了,以上就是我能想到的现有的实现方式带来的问题,那么我们要怎么优化这个问题呢?给这个问题带来一个有效的解决方案呢?实际上这个解决方法我去年就已经想到了,当时建了个issue,然后由于种种原因一直没有坐上去。还是太懒了呀。。
那么具体的方案是什么呢?既然这些问题都是bucket信息和具体的数据存在一起导致的,那么把bucket单独拿出来另外存在一个地方,这不就可以解决了吗?
嗯?怎么解决啊?各位看官且看我下面娓娓道来。
技术方案
首先我们讲讲分离bucket存储为什么可以解决上述的这些问题。
首先对于第一个问题,如果不存bucket的值了,那么就需要存储一个与之相关联的值,因为我们需要知道这条数据对应的bucket是什么。这个值可以叫做bucket_id, 是给一个bucket的一个身份标识。那么理性分析一下,如果bucket_id的值是uint32类型,这时候占4个byte的存储空间,只要bucket的名字长度大于4个byte,这里就会起到省存储空间的作用。
另外,对于第二个问题,如果我把所有的bucket信息存储到一个单独的文件中,比如bucket.meta. 在启动的时候我可以先读取这个文件,恢复在db关闭的时候,这个db的bucket现场。这时候我就知道什么bucket是确实存在的,什么bucket已经被删除了(内存中会有一个模块管理当前db中所有的bucket信息,在这个模块中找不到这个bucket了,就算是已经被删除了) 然后我们再读取具体存储数据的文件。在构建索引之前先做一次判断,发现数据中的bucket_id在bucket管理模块中已经不存在了,就不构建这个bucket相关的索引了,因为现在构建了,后面也是需要删掉的。这时候第二个问题也被解决了。
关于第三个问题,就更好解决了,我们给每一个bucket都分配了一个独一无二的id,这时候更新bucket的话可以记录在bucket的管理文件bucket_meta中, id还是不变的,这样做简单高效,无需做太多的操作就解决了这个问题。
好,我们在上面已经讲完了解决问题的大概思路,下面我们来稍微展开一下是如何实现的。
实现
上面讲的是比较笼统的解决方法和思路,我这里会做一个详细的展开,以求读者可以更清晰的理解这个feature都做了什么改动。
整体架构调整
首先是整体架构的调整。在原来的版本中整体架构是这样子的。在内存中会有一个内存索引模块,为磁盘中的每一条数据提供索引信息。
现在的整体架构应该变成这个样子。首先我们可以看到,内存中不仅仅有索引模块了,还有bucket的管理模块,这一块的作用是存放现在db中最新的bucket状态,也就是db系统运行到当前这个时刻,所有bucket的状态,当然被删除的bucket是不会出现在这里的。
其次磁盘中也不仅仅有数据模块了,同时存在的是存储bucket信息的bucket.meta文件,这个文件实现用户对bucket操作的持久性的。也就是说用户对bucket的所有操作都会存储在这个文件之中,db恢复的时候会读取这个文件恢复bucket现场。在我的设计里,这个文件也是append only的,也就是说所有对bucket的新增,删除,修改,都会以一条数据的形式写入到这个文件中。
bucket存储格式
在上述的bucket.meta文件中,每一条数据存储格式都是上图这个样子的。让我们来解释一下每个字段的意思吧~
-
Meta: 元信息,是整条数据的描述信息。
- CRC 数据校验值。磁盘内部如果发生01互换,可以通过读取这条数据的时候计算比对这个值来判断数据是否异常。
- Op代表当下这条数据是用户具体什么操作带来的结果,如更新,删除,新增。
- Size,表示数据有效部分的长度。就是后面id,ds,name的总长度。
-
id,是每条数据的唯一标识。
-
ds,代表bucket的数据结构是什么,当前我们支持多个数据结构,每个数据结构可以有名字相同的bucket,以这个字段作为区分。
-
name是原来存储在数据中bucket的值。
如何运作解决问题?
我们想象在db运行过程中产生了下面这样的bucket操作
- add bucket 1
- add bucket 2
- delete bucket 1
在重启db的时候读取bucket.meta文件是从前往后读,因为bucket.meta文件写入顺序就是从前往后写,所以越往后的操作就是越新的,bucket的最终状态就是在最后读到这个bucket信息的状态。所以我们可以想象,重启恢复的时候,我们要做的操作就是模拟用户的所有操作,从而恢复了bucket的现场。至于更新bucket操作也是一样的,因为bucket id是不变的,取最后更新的值就好了,完美解决所有问题。