前言
在KV存储引擎的世界中,Bitcask与B+树、LSM树等概念相似,都是用于高效管理键值对的数据结构。Bitcask通过在内存中维护一个索引表,记录每个键对应的值在磁盘文件中的位置,实现了快速的数据检索。
相比于另外两种模型,Bitcask是一个更加简单,易于理解的模型。它是 LSM树的一种变形,通过顺序写日志,合并清理冗余日志提升写性能。
架构设计 & 简单代码实现
Bitcask
一个 Bitcask 数据库对应一个目录,包含一个活动文件(active file
)和一个或多个旧文件(old files
)。每个文件由多个条目(entry
)组成,每个条目存储一个键值对。
entry
// Entry 磁盘文件是多个 Entry的集合 type Entry struct { Key []byte Value []byte KeySize uint32 //单位是字节 ValueSize uint32 Mark uint16 // 标记是删除/新增操作 }
file
type DBFile struct { // 实际的文件 File *os.File // 偏移量 即文件现在写到那里 Offset int64 // 对应正在操作的 Entry HeaderBufPool *sync.Pool }
Bitcask
type MiniBitcask struct { indexes map[string]int64 // 内存中的索引信息 dbFile *DBFile // 数据文件 dirPath string // 数据目录 mu sync.RWMutex // 锁 }
内存哈希表(indexes)
bitcask的特点在于 在内存中维护了一个 哈希表,这个哈希表存储了 每个key 在哪个文件,位置及最近一次更新得到大小。对应 上述 Bitcask 的 indexes
图片来源:数据库存储系列(3)解析 Bitcask 存储引擎原理高性能的 Key Value 存储在今天的互联网技术圈里几乎是一个 - 掘金 (juejin.cn)
读请求(Read)
当接收到读请求时,首先在内存中的 indexes
中查找键的偏移量,如果找到,则读取磁盘文件中的数据。
// Get 取出数据 func (db *MiniBitcask) Get(key []byte) (val []byte, err error) { if len(key) == 0 { return } db.mu.RLock() defer db.mu.RUnlock() offset, err := db.exist(key) if errors.Is(ErrKeyNotFound, err) { return } e, err := db.dbFile.Read(offset) if err != nil && err != io.EOF { return } if e != nil { val = e.Value } return }
写请求(Put/Delete)
写操作仅能写入到活动文件内,写入后更新内存中的索引。
// Put 写入数据 func (db *MiniBitcask) Put(key []byte, value []byte) (err error) { if len(key) == 0 { return } //加锁 db.mu.Lock() defer db.mu.Unlock() offset := db.dbFile.Offset // 封装成 Entry entry := NewEntry(key, value, PUT) // 追加到数据文件当中 err = db.dbFile.Write(entry) //写到内存 db.indexes[string(key)] = offset return }
删除操作也写入到活动文件内,Mark
字段标记操作类型。
// Del 删除数据 func (db *MiniBitcask) Del(key []byte) (err error) { if len(key) == 0 { return } db.mu.Lock() defer db.mu.Unlock() _, err = db.exist(key) if err == ErrKeyNotFound { err = nil return } // 封装成 Entry 并写入 e := NewEntry(key, nil, DEL) err = db.dbFile.Write(e) if err != nil { return } // 删除内存中的 key delete(db.indexes, string(key)) return }
清理旧数据(Merge)
合并所有的旧文件,压缩空间。
从文件开头开始读 entry,如果该entry的key在内存中,说明这个entry需要保留。如果不在,说明已经被删了
func (db *MiniBitcask) Merge() error { if db.dbFile.Offset == 0 { return nil } var ( validEntries []*Entry offset int64 ) // 读取数据文件的Entry for { e, err := db.dbFile.Read(offset) if err != nil { if err == io.EOF { break } return err } //内存的索引状态是最新的,直接对比过滤出有效的 Entry off, ok := db.indexes[string(e.Key)] if ok && off == offset { validEntries = append(validEntries, e) } offset += e.GetSize() } mergeFile, err := newMergeFile(db.dirPath) if err != nil { return err } // 执行完删除合并文件 defer func() { _ = os.Remove(mergeFile.File.Name()) }() db.mu.Lock() defer db.mu.Unlock() //写入有效的Entry for _, entry := range validEntries { off := mergeFile.Offset // offset在Write里被更新 err = mergeFile.Write(entry) if err != nil { return err } //更新索引 db.indexes[string(entry.Key)] = off } dbFileName := db.dbFile.File.Name() // 关闭文件 _ = db.dbFile.File.Close() _ = os.Remove(dbFileName) _ = mergeFile.File.Close() mergeFileName := mergeFile.File.Name() // 临时文件更名为数据文件 _ = os.Rename(mergeFileName, filepath.Join(db.dirPath, FileName)) dbFile, err := newDBFile(db.dirPath) if err != nil { return err } db.dbFile = dbFile return nil }
启动
// 从文件当中加载索引 func (db *MiniBitcask) loadIndexesFromFile() { if db.dbFile == nil { return } var offset int64 for { e, err := db.dbFile.Read(offset) if err != nil { // 读取完毕 if err == io.EOF { break } return } // 设置索引状态 db.indexes[string(e.Key)] = offset if e.Mark == DEL { //删除内容中的key delete(db.indexes, string(e.Key)) } offset += e.GetSize() } }
总结
Bitcask 是一种简单而高效的键值存储机制,主要通过顺序写入和合并操作来优化性能。虽然 Bitcask 易于理解并实现,但在实际应用中仍需解决以下问题:
-
存储索引:如何高效存储索引信息,减少内存和磁盘空间的使用。
-
高效构建内存哈希表:如何利用提示文件(hint file)等方式加速内存哈希表的构建。
存在的问题
-
无法支持range scan
-
merge操作和写入同时进行的时候,会变成随机io,性能下降