rosedb03--实现

165 阅读6分钟

原文链接:风清默的博客 (fengqingmo.top)

目录结构

1 data目录:存储所有的数据文件和提示文件**(hint file 加速构建索引)**

​        其中 数据文件 由 WAL 管理

  1. merge目录: 存储merge后产生的文件,只会有一个文件

内存索引结构-基数树

roseDB在内存中保存索引的结构是基数树**(RadixTree)**

基数树是 **Trie(字典树)**的压缩版本,一条边可以存储多个字符。

因为索引都是用字符串表示,字符串有很多相同前缀,用基数树保存性能高

基数树的特点包括:

  1. 高效查询:对于给定的查询字符串,基数树能够在O(m)时间内确定该字符串是否存在于树中,或者是否存在以该字符串为前缀的节点。
  2. 空间效率:基数树可以有效利用空间,因为只有当路径上的字符存在时,才会创建对应的节点。
  3. 前缀搜索:基数树支持高效的前缀搜索,可以快速找到所有以特定前缀开头的字符串。

数据编码格式

rosedb中的每次put操作都视为一次批处理操作,每个chunk(LogRecord)有以下字段

+-------------+-------------+-------------+--------------+-------------+--------------+  
|    type     |  batch id   |   key size  |   value size |      key    |      value   |  
+-------------+-------------+-------------+--------------+-------------+--------------+

    1 byte          varint(max 10) varint(max 5)  varint(max 5)     varint        varint  
      
  • type: 分为
  1. LogRecordNormal: 这种类型的chunk包含实际数据
  2. LogRecordDelete: 这种类型的chunk 表示删除
  3. LogRecordDelete:这种类型的chunk 表示相应的batch id对应的批处理事务的完成,可以在批处理过程中出现错误时回滚
  • batch id 对应一次批处理

Varint类型

 varint 是用来压缩整数编码的,小的数字用 1字节,大的数字用5字节,64位整型数据占用 10字节,实际场景中小整数使用率更多,所以可以起到很好的压缩效果

详解varint编码原理-CSDN博客

编码方法

func encodeLogRecord(logRecord *LogRecord) []byte {  
    header := make([]byte, maxLogRecordHeaderSize)

    header[0] = logRecord.Type  
    var index = 1

    // batch id  
    index += binary.PutUvarint(header[index:], logRecord.BatchId)  
    // key size  
    index += binary.PutVarint(header[index:], int64(len(logRecord.Key)))  
    // value size  
    index += binary.PutVarint(header[index:], int64(len(logRecord.Value)))

    var size = index + len(logRecord.Key) + len(logRecord.Value)  
    encBytes := make([]byte, size)

    // copy header  
    copy(encBytes[:index], header[:index])  
    // copy key  
    copy(encBytes[index:], logRecord.Key)  
    // copy value  
    copy(encBytes[index+len(logRecord.Key):], logRecord.Value)

    return encBytes  
}  

关键方法

Merge操作实现

    // 遍历所有数据文件,并将有效数据写入新的数据文件。  
    reader := db.dataFiles.NewReaderWithMax(prevActiveSegId)  
    for {  
        chunk, position, err := reader.Next()  
        if err != nil {  
            if err == io.EOF {  
                break  
            }  
            return err  
        }  
        record := decodeLogRecord(chunk)  
        // 只处理 normal 类型数据  
        //LogRecordDeleted and LogRecordBatchFinished will be ignored, because they are not valid data.  
        if record.Type == LogRecordNormal {  
            indexPos := db.index.Get(record.Key)  
            if indexPos != nil && positionEquals(indexPos, position) {  
                // clear the batch id of the record,  
                // all data after merge will be valid data, so the batch id should be 0.  
                record.BatchId = mergeFinishedBatchID  
                // Since the mergeDB will never be used for any read or write operations,  
                // it is not necessary to update the index.  
                newPosition, err := mergeDB.dataFiles.Write(encodeLogRecord(record))  
                if err != nil {  
                    return err  
                }  
                // And now we should write the new posistion to the write-ahead log,  
                // which is so-called HINT FILE in bitcask paper.  
                // The HINT FILE will be used to rebuild the index fastly when the database is restarted.  
                _, err = mergeDB.hintFile.Write(encodeHintRecord(record.Key, newPosition))  
                if err != nil {  
                    return err  
                }  
            }  
        }  
    }  

初始化数据库,加载索引

  1. 加载 merge文件,将merge后的文件转存到data目录下,并将merge过的旧文件删除
  2. 从 hint 文件加载索引
  3. 从所有的数据文件加载索引
func Open(options Options) (*DB, error) {  
    // check options  
    if err := checkOptions(options); err != nil {  
        return nil, err  
    }

    // create data directory if not exist  
    if _, err := os.Stat(options.DirPath); err != nil {  
        if err := os.MkdirAll(options.DirPath, os.ModePerm); err != nil {  
            return nil, err  
        }  
    }

    // create file lock, prevent multiple processes from using the same database directory  
    fileLock := flock.New(filepath.Join(options.DirPath, fileLockName))  
    hold, err := fileLock.TryLock()  
    if err != nil {  
        return nil, err  
    }  
    if !hold {  
        return nil, ErrDatabaseIsUsing  
    }

    // load merge files if exists  
    if err = loadMergeFiles(options.DirPath); err != nil {  
        return nil, err  
    }

    // open data files from WAL  
    walFiles, err := wal.Open(wal.Options{  
        DirPath:       options.DirPath,  
        SegmentSize:   options.SegmentSize,  
        SementFileExt: dataFileNameSuffix,  
        BlockCache:    options.BlockCache,  
        Sync:          options.Sync,  
        BytesPerSync:  options.BytesPerSync,  
    })  
    if err != nil {  
        return nil, err  
    }

    // init DB instance  
    db := &DB{  
        dataFiles: walFiles,  
        index:     index.NewIndexer(),  
        options:   options,  
        fileLock:  fileLock,  
    }

    // load index frm hint file  
    if err = db.loadIndexFromHintFile(); err != nil {  
        return nil, err  
    }

    // load index from data files  
    if err = db.loadIndexFromWAL(); err != nil {  
        return nil, err  
    }

    return db, nil  
}  

Put/delete操作

每个写请求都被视为一次批处理, 先写入缓冲池,再提交写入到文件

// Put 将一个键值对写入数据库。  
// 实际上,它将打开一个新的批处理并提交它。  
// 你可以将批处理视为只包含一个Put操作。  
func (db *DB) Put(key []byte, value []byte) error {  
    // 创建一个批处理选项,默认为DefaultBatchOptions  
    options := DefaultBatchOptions  
    // 由于这是一个单一的写操作,我们可以将Sync设置为false  
    // 因为数据将被写入WAL(Write-Ahead Logging),  
    // WAL文件将根据数据库选项同步到磁盘。  
    options.Sync = false  
    // 创建一个新的批处理实例  
    batch := db.NewBatch(options)  
    // 在批处理中执行Put操作,将键值对写入batch缓冲池  
    if err := batch.Put(key, value); err != nil {  
        // 如果Put操作失败,返回错误  
        return err  
    }  
    // 提交批处理,将写入的数据永久保存到数据库  
    return batch.Commit()  
}

DefaultBatchOptions

var DefaultBatchOptions = BatchOptions{  
    //表示批处理操作是否需要同步到磁盘  
    Sync:     true,  
    // 表示批处理是否只用于读取操作  
    ReadOnly: false,  
}  

Get操作

先从待写入的batch缓冲池读,没读到再从内存索引拿到 position,交给 WAL

func (b *Batch) Get(key []byte) ([]byte, error) {  
    if len(key) == 0 {  
        return nil, ErrKeyIsEmpty  
    }  
    if b.db.closed {  
        return nil, ErrDBClosed  
    }

    // get from pendingWrites  
    if b.pendingWrites != nil {  
        b.mu.RLock()  
        if record := b.pendingWrites[string(key)]; record != nil {  
            if record.Type == LogRecordDeleted {  
                b.mu.RUnlock()  
                return nil, ErrKeyNotFound  
            }  
            b.mu.RUnlock()  
            return record.Value, nil  
        }  
    }

    // get from data file  
    chunkPosition := b.db.index.Get(key)  
    if chunkPosition == nil {  
        return nil, ErrKeyNotFound  
    }  
    chunk, err := b.db.dataFiles.Read(chunkPosition)  
    if err != nil {  
        return nil, err  
    }

    record := decodeLogRecord(chunk)  
    if record.Type == LogRecordDeleted {  
        return nil, ErrKeyNotFound  
    }  
    return record.Value, nil  
}  

理清架构 

主要分成 内存区的设计 和 磁盘文件区的设计

rosedb架构.drawio.png

完结

感觉设计一个 简单的数据库的 重难点在于以下几方面

  1. 设计数据在文件内的编码方式
    - 数据结构设计:确定每个记录所需包含的字段,并设计它们的数据类型和大小。
    - 数据完整性:实现校验和或 checksum 来确保数据的完整性。
    - 错误处理:设计错误检测和恢复机制保证事务的原子性
  2. 索引设计
    - 索引结构:选择合适的索引结构,如哈希表、B树、跳表、基数树等,以提高查询效率。
    - 索引维护:设计索引的维护机制,如在插入、删除和更新操作时如何更新索引。
  3. 临界情况处理
    - 数据分布:当数据过大无法连续存储时,设计数据分布策略,如分片或分段。
  4. 锁机制设计
    - 并发控制:设计合理的锁机制,如乐观锁、悲观锁、读写锁等,以避免数据冲突。
    - 锁优化:优化锁的获取和释放,减少锁冲突和锁等待

其他:

编码能力也很重要,对于之前是写java简单业务的来说,这些相对底层的代码无疑是有难度的。

最重要的还是心态,从刚开始clone下这个项目打开时看代码一脸懵,到现在理清脉络,中间一度看不下去。坚持啃下去才能有收获!!