写这篇文章的目的,是为了帮助更多的人理解 rosedb,我会从零开始实现一个简单的包含 PUT、GET、DELETE 操做的 k-v 存储引擎,你能够将其看作是一个简易版本的 rosedb,就叫它 minidb 吧(mini 版本的 rosedb)。git
不管你是 Go 语言初学者,仍是想进阶 Go 语言,或者是对 k-v 存储感兴趣,均可以尝试本身动手实现一下,我相信必定会对你帮助很大的。github
说到存储,其实解决的一个核心问题就是,怎么存放数据,怎么取出数据。在计算机的世界里,这个问题会更加的多样化。数据库
计算机当中有内存和磁盘,内存是易失性的,掉电以后存储的数据所有丢失,因此,若是想要系统崩溃再重启以后依然正常使用,就不得不将数据存储在非易失性介质当中,最多见的即是磁盘。数据结构
因此,针对一个单机版的 k-v,咱们须要设计数据在内存中应该怎么存放,在磁盘中应该怎么存放。app
固然,已经有不少优秀的前辈们去探究过了,而且已经有了经典的总结,主要将数据存储的模型分为了两类:B+ 树和 LSM 树。分布式
本文的重点不是讲这两种模型,因此只作简单介绍。性能
B+ 树测试
B+ 树由二叉查找树演化而来,经过增长每层节点的数量,来下降树的高度,适配磁盘的页,尽可能减小磁盘 IO 操做。优化
B+ 树查询性能比较稳定,在写入或更新时,会查找并定位到磁盘中的位置并进行原地操做,注意这里是随机 IO,而且大量的插入或删除还有可能触发页分裂和合并,写入性能通常,所以 B+ 树适合读多写少的场景。spa
LSM 树
LSM Tree(Log Structured Merge Tree,日志结构合并树)其实并非一种具体的树类型的数据结构,而只是一种数据存储的模型,它的核心思想基于一个事实:顺序 IO 远快于随机 IO。
和 B+ 树不一样,在 LSM 中,数据的插入、更新、删除都会被记录成一条日志,而后追加写入到磁盘文件当中,这样全部的操做都是顺序 IO。
LSM 比较适用于写多读少的场景。
看了前面的两种基础存储模型,相信你已经对如何存取数据有了基本的了解,而 minidb 基于一种更加简单的存储结构,整体上它和 LSM 比较相似。
我先不直接干巴巴的讲这个模型的概念,而是经过一个简单的例子来看一下 minidb 当中数据 PUT、GET、DELETE 的流程,借此让你理解这个简单的存储模型。
PUT
咱们须要存储一条数据,分别是 key 和 value,首先,为预防数据丢失,咱们会将这个 key 和 value 封装成一条记录(这里把这条记录叫作 Entry),追加到磁盘文件当中。Entry 的里面的内容,大体是 key、value、key 的大小、value 的大小、写入的时间。
因此磁盘文件的结构很是简单,就是多个 Entry 的集合。
磁盘更新完了,再更新内存,内存当中能够选择一个简单的数据结构,好比哈希表。哈希表的 key 对应存放的是 Entry 在磁盘中的位置,便于查找时进行获取。
这样,在 minidb 当中,一次数据存储的流程就完了,只有两个步骤:一次磁盘记录的追加,一次内存当中的索引更新。
GET
再来看 GET 获取数据,首先在内存当中的哈希表查找到 key 对应的索引信息,这其中包含了 value 存储在磁盘文件当中的位置,而后直接根据这个位置,到磁盘当中去取出 value 就能够了。
DEL
而后是删除操做,这里并不会定位到原记录进行删除,而仍是将删除的操做封装成 Entry,追加到磁盘文件当中,只是这里须要标识一下 Entry 的类型是删除。
而后在内存当中的哈希表删除对应的 key 的索引信息,这样删除操做便完成了。
能够看到,不论是插入、查询、删除,都只有两个步骤:一次内存中的索引更新,一次磁盘文件的记录追加。因此不管数据规模如何, minidb 的写入性能十分稳定。
Merge
最后再来看一个比较重要的操做,前面说到,磁盘文件的记录是一直在追加写入的,这样会致使文件容量也一直在增长。而且对于同一个 key,可能会在文件中存在多条 Entry(回想一下,更新或删除 key 内容也会追加记录),那么在数据文件当中,其实存在冗余的 Entry 数据。
举一个简单的例子,好比针对 key A, 前后设置其 value 为 十、20、30,那么磁盘文件中就有三条记录:
此时 A 的最新值是 30,那么其实前两条记录已是无效的了。
针对这种状况,咱们须要按期合并数据文件,清理无效的 Entry 数据,这个过程通常叫作 merge。
merge 的思路也很简单,须要取出原数据文件的全部 Entry,将有效的 Entry 从新写入到一个新建的临时文件中,最后将原数据文件删除,临时文件就是新的数据文件了。
这就是 minidb 底层的数据存储模型,它的名字叫作 bitcask,固然 rosedb 采用的也是这种模型。它本质上属于类 LSM 的模型,核心思想是利用顺序 IO 来提高写性能,只不过在实现上,比 LSM 简单多了。
介绍完了底层的存储模型,就能够开始代码实现了,我将完整的代码实现放到了个人 Github 上面,地址:
https://github.com/roseduan/minidb,
文章当中就截取部分关键的代码。
首先是打开数据库,须要先加载数据文件,而后取出文件中的 Entry 数据,还原索引状态,关键部分代码以下:
func Open(dirPath string) (*MiniDB, error) { // 若是数据库目录不存在,则新建一个 if _, err := os.Stat(dirPath); os.IsNotExist(err) { if err := os.MkdirAll(dirPath, os.ModePerm); err != nil { return nil, err } } // 加载数据文件 dbFile, err := NewDBFile(dirPath) if err != nil { return nil, err } db := &MiniDB{ dbFile: dbFile, indexes: make(map[string]int64), dirPath: dirPath, } // 加载索引 db.loadIndexesFromFile(dbFile) return db, nil }
再来看看 PUT 方法,流程和上面的描述同样,先更新磁盘,写入一条记录,再更新内存:
func (db *MiniDB) Put(key []byte, value []byte) (err error) { offset := db.dbFile.Offset // 封装成 Entry entry := NewEntry(key, value, PUT) // 追加到数据文件当中 err = db.dbFile.Write(entry) // 写到内存 db.indexes[string(key)] = offset return }
GET 方法须要先从内存中取出索引信息,判断是否存在,不存在直接返回,存在的话从磁盘当中取出数据。
func (db *MiniDB) Get(key []byte) (val []byte, err error) { // 从内存当中取出索引信息 offset, ok := db.indexes[string(key)] // key 不存在 if !ok { return } // 从磁盘中读取数据 var e *Entry e, err = db.dbFile.Read(offset) if err != nil && err != io.EOF { return } if e != nil { val = e.Value } return }
DEL 方法和 PUT 方法相似,只是 Entry 被标识为了 DEL
,而后封装成 Entry 写到文件当中:
func (db *MiniDB) Del(key []byte) (err error) { // 从内存当中取出索引信息 _, ok := db.indexes[string(key)] // key 不存在,忽略 if !ok { return } // 封装成 Entry 并写入 e := NewEntry(key, nil, DEL) err = db.dbFile.Write(e) if err != nil { return } // 删除内存中的 key delete(db.indexes, string(key)) return }
最后是重要的合并数据文件操做,流程和上面的描述同样,关键代码以下:
func (db *MiniDB) Merge() error { // 读取原数据文件中的 Entry for { e, err := db.dbFile.Read(offset) if err != nil { if err == io.EOF { break } return err } // 内存中的索引状态是最新的,直接对比过滤出有效的 Entry if off, ok := db.indexes[string(e.Key)]; ok && off == offset { validEntries = append(validEntries, e) } offset += e.GetSize() } if len(validEntries) > 0 { // 新建临时文件 mergeDBFile, err := NewMergeDBFile(db.dirPath) if err != nil { return err } defer os.Remove(mergeDBFile.File.Name()) // 从新写入有效的 entry for _, entry := range validEntries { writeOff := mergeDBFile.Offset err := mergeDBFile.Write(entry) if err != nil { return err } // 更新索引 db.indexes[string(entry.Key)] = writeOff } // 删除旧的数据文件 os.Remove(db.dbFile.File.Name()) // 临时文件变动为新的数据文件 os.Rename(mergeDBFile.File.Name(), db.dirPath+string(os.PathSeparator)+FileName) db.dbFile = mergeDBFile } return nil }
除去测试文件,minidb 的核心代码只有 300 行,麻雀虽小,五脏俱全,它已经包含了 bitcask 这个存储模型的主要思想,而且也是 rosedb 的底层基础。
理解了 minidb 以后,基本上就可以彻底掌握 bitcask 这种存储模型,多花点时间,相信对 rosedb 也可以游刃有余了。
进一步,若是你对 k-v 存储这方面感兴趣,能够更加深刻的去研究更多相关的知识,bitcask 虽然简洁易懂,可是问题也很多,rosedb 在实践的过程中,对其进行了一些优化,但目前仍是有很多的问题存在。
有的人可能比较疑惑,bitcask 这种模型简单,是否只是一个玩具,在实际的生产环境中有应用吗?答案是确定的。
bitcask 最初源于 Riak 这个项目的底层存储模型,而 Riak 是一个分布式 k-v 存储,在 NoSQL 的排名中也名列前茅:
豆瓣所使用的的分布式 k-v 存储,其实也是基于 bitcask 模型,并对其进行了不少优化。目前纯粹基于 bitcask 模型的 k-v 并非不少,因此你能够多去看看 rosedb 的代码,能够提出本身的意见建议,一块儿完善这个项目。
最后,附上相关项目地址:
minidb:https://github.com/roseduan/minidb
rosedb:https://github.com/roseduan/rosedb
参考资料: