为了写出优秀软件,你必须同时具备两种互相冲突的信念。一方面,你要像初生牛犊一样,对自己的能力信心万丈;另一方面,你又要像历经沧桑的老人一样,对自己的能力抱着怀疑态度。在你的大脑中,有一个声音说“千难万险只等闲”,还有一个声音却说“早岁那知世事艰”。 --《黑客与画家》
背景
年初的时候nutsdb作者徐佳军大佬邀请我(因为之前看过goleveldb的源码和LSM相关的一些论文,对存储引擎有一些基本的了解)一起加入到nutsdb的开发与维护工作之中。对于我来说这是一个难得的学习与实践的机会,所以就欣然同意了。
这个优化的背景是,roseduan大佬(rosedb作者)针对go现有的开源kv存储引擎做了一个benchmark测试,测试的对象有goleveldb,boltdb,nutsdb,rosedb。各个存储引擎性能结果如下:
顾名思义,GetValue就是读取,PutValue就是写入。从上图我们可以看到,nutsdb写入性能是最高的,但是读取性能确实最低的。这个读取性能比起其他的DB低的还蛮多的。这个时候我们必须找到问题所在,然后优化一下。
![image-20220531233151710](https://i-blog.csdnimg.cn/blog_migrate/0d96eaef7085b3a4261b0c34a71a9ff3.png)
过了一会,佳军已经找到了问题所在,在开发者群里发言找人解决了。此时的我在加入这个组织之后就补了一些单测和开发了一些http的api,对nutsdb整体还不是很熟悉。但是机会摆在面前,我先虚晃一枪,问问这个东西紧不紧急,如果不紧急的话我就冲一下。毕竟这是一个比较好的锻炼机会。果然得到的答复是不紧急,所以我就冲上去了,先把这个东西领取了。下面就开始我们的性能优化之旅。
问题探索之旅
接到任务的时候我对nutsdb的整体架构还不是很熟悉,那么我最开始处理这个问题的做法就是去充分的了解nutsdb的实现原理而不是上来就想着怎么写代码。我去翻看了nutsdb的官方文档和佳军之前在go夜读活动分享的nutsdb的视频。得知nutsdb是基于bitcask模型实现的,然后又去看了bitcask的论文(说句题外话,这个论文可有意思了,看到最后他说,很显然我们并不打算把所有的细节都在在这里讲清楚,看到这里的时候我真的是忍不住笑出了声。)收回话题,我们来讲讲bitcask存储模型的存储原理。第一个星期就做了这些事情,先了解nutsdb的基本实现原理。
bitcask架构就像是一个巨大的hashmap,在内存中建立一个hash索引,key是对应数据的key,value对应的是这个数据具体存在那个文件的哪个位置,这样就可以直接通过一次读取拿到数据了。这样的索引方式好处就是查询单条数据会很快,缺点就是范围查询的时候会很慢,因为hash算法会打乱原来key的顺序,使用范围查询的时候,查询多少数据就会发起多少次IO读取。
下面这张图代表bitcask的内存kv数据结构,value是这个key对应数据存储的文件和在这个文件中的位置。
下面这张图代表的是获取数据的整体流程。在内存中拿到这个key数据存储文件和具体的位置直接发起一次IO调用获取这个数据。
如下图所示,bitcask有两个概念,active data file和older data file,active data file在系统中只会存在一个,最新写入和更新的数据都会通过append的形式追加到这个文件中,等待这个文件的大小到达阈值的时候会创建一个新的active data file,原来的active data file就会变成older data file,older data file只能读取不能写入。这样子的操作能够保证数据的写入对磁盘的操作都是顺序写入从而避免了随机写入性能不佳的问题(这里的性能不佳指的是普通的机械硬盘随机读写的效率比顺序读写要低很多,对于固态硬盘来说随机写和顺序写性能实际上是差不多的。)
下面这张图是数据的存储格式,实际上数据的持久化存储方式都大差不差,按照我个人的理解就是,一个数据在内存中可以表现出不同的数据结构,他可以是链表,数组,跳表,哈希表,但是要把他存到磁盘里的时候,我们要设计他的磁盘存储物理结构的时候,要考虑两点,一是怎么用比较少的磁盘空间去表达更多的数据,二是怎么保证存进去和拿出来东西是一样的,也就是一个数据校验的机制。对于磁盘来说,他就像一个保险箱,我不管你放进来的是什么,这个是你的自由,你拿出去的时候自己知道他是什么和怎么使用就好。所以一般在存储引擎这一侧会定义数据在内存中的逻辑结构和在磁盘中的物理结构之间相互转化的算法。
我看过一些存储引擎的持久化数据结构,比如innodb的行格式,golevedldb的kv存储结构和sst,redis rdb数据格式,逻辑结构和物理结构之间的转化算法。按照我的理解,总结起来就是一句话,用确定性去表达不确定性。什么意思呢?比如一个KV的存储引擎,key的长度(一般是指这个key的byte数组的长度)的表示无非就是一个int,int64(key能够这么长也蛮厉害的),所以key的长度要存在磁盘里使用的空间是能够固定的,同理,value也是的,当我们知道key和value在磁盘中占用的空间,自然也可以通过长度去精准的找到key和value,就像上图所示。如果他有多种的数据结构,加一个字段type,表示他是什么数据就可以了,可以参考redis持久化的rdb数据存储格式。至于数据的校验,因为磁盘偶尔会发生一些奇奇怪怪的事情,比如一个位置明明存的是正电荷,突然就变成负电荷了,或者说磁头扫描磁盘的时候出错了,也是有可能的,所以要对整个数据进行一次校验来确保他的安全。存进去之前把校验值存进去,拿出来的时候计算一次校验值看看匹不匹配。这种做法还是很常见的,TCP报文也有类似的操作。
现在我们知道了每一次数据的更改操作,也就是插入,删除,更新,都是以一条新的数据的形式append到数据库中。但是每次读取实际上读取的是最新版本的数据,以前的数据就用不到了,这里会存在一个写放大的问题,所以需要做一次合并操作去把以前的数据给他清除掉,以优化存储空间。下图就是合并的过程,把所有的older数据文件都合并起来,并且在合并后的文件旁边还生成一个提示文件记录key存储在哪个文件的哪个位置。
好了,上面讲完了bitcask存储模型的原理,实际上nutsdb是按照这个模型实现的,比较大的差别就是,实现了三个读写模式,当数据量小的时候,内存层面由B+Tree实现索引,B+Tree是分层的结构,虽然不像HashMap这样可以一次查找就找到对应的数据,但是他可以支持范围查询,相对来说是一个比较优秀的设计,但是数据量大的时候在内存之中就是稀疏索引了,因为内存是有限的,当数据量大时候在用B+Tree这种密集对应的结构去存储内存就不够用了。其次nutsdb支持bucket的概念,类似于数据库的表,用户可以把一类数据放在一个bucket里面,对业务比较友好,业务逻辑之间的数据可以通过bucket做业务隔离。
怎么解决
上面讲完nutsdb的存储原理之后,我们在回过头来看看这个问题,这个问题的本质是每次读取的时候打开一个文件,读完我们就把fd关闭了,通过上面我们的了解,读取的文件主要是active data file和older data file,如果每次读取都重新执行一个系统调用去获取fd,性能开销是很大的。可以比较容易的得到一个比较简单的结论,在数据量一定的情况下,一个文件存储的数据越多,他被频繁访问的概率越大。在这种情况下,对于一个文件重复去获取他的fd的概率也就越高。所以如果把fd缓存起来,不再去重复获取,文件越大,性能的提高也就越大。那么问题来了,为什么可以缓存fd,文件越大缓存fd的性能提升越大这个结论是怎么来的?
我们来简单分析一下,在Linux的虚拟文件系统下(这里我只讨论Linux系统下的,因为对于别的系统我不太清楚hhh),文件的存储如下图所示。
主要对象的作用:
- superblock:存储文件系统基本的元数据。如文件系统类型、大小、状态,以及其他元数据相关的信息(元元数据)。
- index node(inode):保存一个文件相关的元数据。包括文件的所有者(用户、组)、访问时间、文件类型等,但不包括这个文件的名称。文件和目录均有具体的inode对应。
- directory entry(dentry):保存了文件(目录)名称和具体的inode的对应关系,用来粘合二者,同时可以实现目录与其包含的文件之间的映射关系。另外也作为缓存的对象,缓存最近最常访问的文件或目录,提示系统性能。
- file:一组逻辑上相关联的数据,被一个进程打开并关联使用。
磁盘是以扇区为单位的,一个扇区一般是512B,一般我们在系统中会设置block,一般大小是4KB(这个是可以设置的,太大太小都不好,还是得考虑具体的业务场景,这里不展开赘述了),一次读取是按照block读取的。文件读取的时候,进程先打开找到dentry找到inode,然后构建file Object让进程持有,进程可以通过这个file Object去读取文件,怎么读呢,inode会记录这个文件的有哪些block组成,这些block分别在哪里,读取的时候按照block读取出来就好。
所以根据这个逻辑来判断,实际上文件的大小对块的读取性能的影响是比较小的,找到这个块磁盘会摆臂把数据读出来,不管文件大还是小,磁盘摆臂时间的数学期望都是差不多的。
我们拿到fd用完后关闭,实际上就是创建了一个file object,并且宣布文件对应的dentry对象在缓存(dentry缓存是lru缓存)中没啥用了。我们把fd缓存起来,也就是不会瘦file object和dentry object而已,对系统整体影响并不大。
但是这里要注意一个问题,一个进程能打开的fd数量是有限的,这个可以通过nlimit -u命令查看。这里我们就需要考虑这个问题怎么解决了,因为nutsdb是一个嵌入型的数据库,我们不知道用户在使用的时候他自己的业务有没有打开别的文件,所以我们要把nutsdb能打开多少文件的权利交给用户去决定,并且当我们打开文件的时候报错显示打开文件过多的时候要尝试去清楚缓存内现在没有被使用的fd,然后再重新尝试看看能不能打开文件。
我这里的解决方法是构建了一个lru的fd缓存,考虑是可以回收最近不常被使用的fd,感觉实现的比较简单,就是普普通通的hashmap+双向链表的操作罢了,为了防止并发导致的双向链表进行指针操作时出现错误,我加了一把锁。实际上要是在此基础之上优化的话,可以给每个链表上的节点设置一个阶梯式的阈值,达到阈值了才把这个节点往前移,这样可以较少并发资源的争抢,不过可能这个优化不会带来多少的性能提升,先简单实现了,需要优化的时候再优化也不迟。
code展示
文章篇幅有限,这里将展示主要逻辑的实现与一些思考,具体详细的代码可以看github源码。
// fdManager hold a fd cache in memory, it is lru based cache.
type fdManager struct {
// 为了防止并发操作链表和其他数据导致异常,加一把锁
sync.Mutex
// cache缓存fd信息
cache map[string]*FdInfo
// 双向链表
fdList *doubleLinkedList
// 缓存数据量
size int
// 清除缓存阈值
cleanThresholdNums int
// 缓存fd数量上限
maxFdNums int
}
// 双向链表,一个比较方便的写法就是头节点和尾节点都初始化为空节点,
// 这样链表的操作的始终是中间节点,可以省下很多边界判断逻辑
type doubleLinkedList struct {
head *FdInfo
tail *FdInfo
size int
}
// FdInfo holds base fd info
type FdInfo struct {
fd *os.File
path string
// using代表正在被多少外部逻辑拿出去使用了,这个是比较关键的字段
// 设想这样的问题,如果这个fd被拿出去了,然后还没有进行数据的读取,就被这边回收close掉了,那么它进行读取逻辑的时候是会报错的
// 所以这个字段的作用就是,当这个fd被外部get到的时候+1,用完释放的时候-1,
// 当他是0的时候代表这个fd的缓存是可以被清除的。
using uint
next *FdInfo
prev *FdInfo
}
// getFd go through this method to get fd.
// 获取fd的逻辑。(感觉我的注释写的蛮清楚了这里就不翻译了hhh)
func (fdm *fdManager) getFd(path string) (fd *os.File, err error) {
fdm.Lock()
defer fdm.Unlock()
cleanPath := filepath.Clean(path)
if fdInfo := fdm.cache[cleanPath]; fdInfo == nil {
fd, err = os.OpenFile(cleanPath, os.O_CREATE|os.O_RDWR, 0o644)
if err == nil {
// if the numbers of fd in cache larger than the cleanThreshold in config, we will clean useless fd in cache
if fdm.size >= fdm.cleanThresholdNums {
err = fdm.cleanUselessFd()
}
// if the numbers of fd in cache larger than the max numbers of fd in config, we will not add this fd to cache
if fdm.size >= fdm.maxFdNums {
return fd, nil
}
// add this fd to cache
fdm.addToCache(fd, cleanPath)
return fd, nil
} else {
// determine if there are too many open files, we will first clean useless fd in cache and try open this file again
if strings.HasSuffix(err.Error(), TooManyFileOpenErrSuffix) {
cleanErr := fdm.cleanUselessFd()
// if something wrong in cleanUselessFd, we will return "open too many files" err, because we want user not the main err is that
if cleanErr != nil {
return nil, err
}
// try open this file again,if it still returns err, we will show this error to user
fd, err = os.OpenFile(cleanPath, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
// add to cache if open this file successfully
fdm.addToCache(fd, cleanPath)
}
return fd, err
}
} else {
fdInfo.using++
fdm.fdList.moveNodeToFront(fdInfo)
return fdInfo.fd, nil
}
}
再次测试性能
写完代码之后在本地再次性能测试,和master对比得到性能如下:
Master:
BenchmarkPutValue_NutsDB-10 94042 10999 ns/op 3490 B/op 22 allocs/op
BenchmarkGetValue_NutsDB-10 98317 11650 ns/op 1419 B/op 19 allocs/op
Optimize_HintKeyAndRAMIdxMode:(我的分支)
BenchmarkPutValue_NutsDB-10 111870 10808 ns/op 3547 B/op 22 allocs/op
BenchmarkGetValue_NutsDB-10 1640752 682.1 ns/op 331 B/op 6 allocs/op
可以看到写入性能提升20%左右,读取性能提升就比较夸张了,提升了接近20倍。本次的修改已经合并进主干,并随着0.9.0版本发布。
总结
这个事情说起来其实也不算难,不就加个缓存嘛,但是这一路走来的探索经历还是值得回味的。也忙碌了三个半星期才把代码merge进去的。第一版代码写完之后因为原来的代码架构问题,塞入一个fd的cache和原来的代码看起来比较奇怪,然后又进行了一次小范围的重构。当代码合并进去的瞬间,我感觉到前所未有的快乐。现在可以稍微总结一下在这里我的一些想法:
- 面对机会要把握住,迎难而上。
- 面对不熟悉的东西,先花时间去了解他,这样你能对这个事情有个大致的了解,从而定位到问题和确认解决问题要改动的地方。
- 多问为什么,为什么这样做能带来性能提升,背后的原理是什么?以前看过一本书,《苏菲的世界》,里面讲到:“没有什么事是理所应当的”。可以多去思考一些事情背后的真相,这样收获会更多。
广告时间
nutsdb目前是有用户在生产环境中使用的,可以看issue:https://github.com/nutsdb/nutsdb/issues/27,得到的反响也不错。github地址为:https://github.com/nutsdb/nutsdb,如果想了解bitcask kv存储引擎的实现,我相信nutsdb会是一个好的选择,如有兴趣了解或者参与开发,可以留言联系我,目前有学习交流群和开发群,欢迎一起交流和探讨。
参考资料
- bitcask论文地址:https://riak.com/assets/bitcask-intro.pdf
- nutsdb go 夜读分享视频:https://www.bilibili.com/video/BV1T34y1x7AS?spm_id_from=333.337.search-card.all.click
- 《深入理解Linux内核》第十二章,虚拟文件系统。
个人推广
笔者公众号与博客同名“陪计算机走过漫长岁月”,对笔者的分享感觉还可以的话可以多多关注,感谢您的支持啦~