最近失业在家闲了下来,自己一直以来对存储和数据库都比较感兴趣,在完成了MIT6.824,TinyKV和CMU15445之后,觉得不够尽兴,就想着自己实现一个存储引擎。经过几天的耕耘,终于结束,特写下此篇总结送给对存储引擎感兴趣或者希望尝试但又不知如何下手的小伙伴,希望对你有所帮助。
先送上我的实现的仓库地址:https://github.com/yanghao888/minidb
目录
论文参考
Minidb是参考bitcask论文的设计,有关bitcask的相关内容可以看一下这篇论文:https://riak.com/assets/bitcask-intro.pdf
它的设计思路也比较好理解,数据存储分为内存和磁盘两部分,磁盘上从第一个文件(没有就会新建一个)开始写,文件达到一定大小就会再新建一个新的文件,旧的这个文件就变成了只读,所以任意时刻都只有一个文件可以写入,这个文件称为active data file,那些只读的旧文件称为older data file,由于数据在新旧文件中都存在,所以active data file除了可写也是可读的。对于内存中存放的就是全量的key本身和value在文件的地址(文件ID和在文件中的offset),也可以存放一些其他内容作为优化,比如value的长度;但是由于内存中存放全量的key,那么这个存储引擎能最多存放多少数据就受制于内存的大小了,所以内存中数据字段的数量和存放方式需要做考虑。需要提一嘴的是,论文中虽然说是采用哈希表作为内存的数据存放方式,但其实可以自己根据需要选择合适的数据结构,比如跳表或红黑树之类的,这样可以支持key的范围查询。
基于以上设计,写流程采用类似LSM Tree的方式,将数据追加写在文件的末尾,这样可以利用磁盘的顺序IO(不明白的小伙伴可以了解一下尤其是机械硬盘的随机IO和顺序IO的性能差距),具体做法是先将key和value封装,其中除了KV本身,还会在Header包含一下元数据,比如key和value的长度、这一条数据的版本号(论文中tstamp),还有为了校验数据是否完整需要的hash值(比如论文中的crc)等等,可以根据需要添加别的字段。封装好的这么一条数据写入到active data file的末尾,写入文件成功后,需要在内存中更新key的索引数据,即value的最新文件地址和一些其他的你自己定义的字段,总之就是要保证每次写入成功后,内存中的是最新的索引。需要注意的是,除了key的Put操作,key的Delete操作也一样,并不会直接改之前写入的数据,而是跟Put一样封装成一条数据追加写入,只不过不会有Value值,会有个称为tombstone的标记表示这个key删除了,这个tombstone可以自己根据需要设计,有的是利用一个特殊的版本号,有的是利用value的那个位置,还有的单独弄个字段存等等,写入成功后在内存中删除key的索引数据就好了,至于删除的key怎么读取下面会讲到。那么上述整个写入流程就包括了一次文件追加写和一次内存索引更新。
读流程也比较简单,先根据key查询内存中的索引信息,查询不到说明key不存在或者已经被删除了,直接返回就结束了;如果查询到了就用value的文件地址去磁盘上读取,先根据文件ID找到对应文件,再根据offset定位到这条数据的开始,由于一般会把每条数据的header放在开头且header长度是固定的,我们就可以读取出header信息,进而得到key和value的长度,然后就可以读取出key和value本身了。当然也可以在写入的时候在内存索引信息中记录value的长度或者直接记录整条数据的长度,一次性读取出来。然而我觉得性能差异可能并不大,因为机械硬盘的瓶颈主要在于寻址速度,传输数据一般都比较快,不过考虑到在并发读的情况下,磁头可能会在读取完header之后未读取key和value之前,转去为其他线程服务了,这样一来就会增加延迟,由此看来一次性读取出来可以减少随机IO的次数,笔者实现的时候刚开始是一次性读取的,后面因为Merge流程,又统一采用了分开读取,原方法保留了下来。所以怎么做还是由你自己选择吧。以上所述读取流程需要一次内存读取,一次文件随机读取。
数据结构设计
以下所称数据文件、日志文件其实都是一个意思,表示数据在磁盘上的持久化。
type DB struct {
mu sync.RWMutex // 读写锁,用于keyDir的并发读写
dirLockGuard *directoryLockGuard // 目录锁,用于保证只有一个minidb进程操作数据文件目录
opt Options // 启动DB的配置参数
keyDir map[string]*logOffset // key的索引信息,这里为了简单直接采用map来实现
dbFile dbFile // 封装了对数据库文件的相关的操作
closed atomic.Bool // DB的关闭标志
gcLock sync.Mutex // 保证数据文件的Merge过程的互斥
}
type logOffset struct {
fid uint32 // 数据所在文件的ID
offset uint32 // 数据在文件中的偏移量
}
type dbFile struct {
dirPath string // 数据文件目录
files []*logFile // 数据文件的封装,封装了对文件相关的操作
maxPtr uint64 // 记录下一次写入的开始位置,即active data file里面写到哪个位置了
db *DB // 见上面
opt Options // 见上面
}
// logFile provides read and write for log entry.
type logFile struct {
fid uint32 // 当前日志文件的ID
size uint32 // 当前文件的大小,注意对于active data file来说maxPtr和size并不一定相同
path string // 当前日志文件的绝对路径,比如:/tmp/minidb/000000.log
fd *os.File // 当前日志文件的文件描述符,用于文件读写
db *DB // 见上面
}
// 封装要写入日志文件的数据,这里为了简单,删除了论文中的crc和tstamp
type Entry struct {
mark EntryMark // 区分是正常的还是删除的数据
kLen uint32 // key的长度
vLen uint32 // value的长度
key []byte // key本身
value []byte // value本身
}
// Index is used in hint file.(要写入hint file的索引数据,这里单独用了一个结构体,关于hint file下文会讲到)
type Index struct {
fid uint32 // 此索引所指向的数据的文件ID
offset uint32 // 此索引所指向的数据在数据文件中的位置
kLen uint32 // key的长度
key []byte // key本身
}
读写流程
Put操作:
// Put adds a key-value pair to the database.
func (db *DB) Put(key, val []byte) (err error) {
if db.isClosed() {
return ErrDatabaseClosed
}
if len(key) == 0 {
return ErrEmptyKey
}
db.mu.Lock()
defer db.mu.Unlock()
// Write to file
e := NewEntry(key, v