【etcd】4.boltdb存储引擎(一):boltdb的核心思想

目录

1. boltdb 核心思想

2. 概念

3. 核心方法

3.1 boltDB 的初始化

3.2 mmap 磁盘内存映射

3.3 bucket的创建和查询

3.4 数据的CRUD

3.5 事务提交


1. boltdb 核心思想

boltdb 是 go 语言实现的一种单机 kv 数据磁盘存储系统:
  • 单机运行: 无需考虑分布式共识相关内容(简单)
  • 磁盘存储: kv 数据存储于磁盘(可靠)
  • 本地读写: 读写时直接与本地文件交互,没有客户端与服务端的通信环节(简单、粗暴、高效)
boltdb 在 etcd 整体架构中的层次定位:
核心思想:
  • 数据以一个文件的形式存在磁盘上,建立磁盘与内存的映射,在程序中直接操作内存
  • 磁盘与内存以页(page)为单位交换数据
  • 底层数据结构使用 B+ 树实现,bucket(表)和数据都使用 B+ 树维护
  • 基于 mmap 实现读
  • 基于 page buffer 实现写
  • 使用事务机制,读读并行,读写串行

2. 概念

(1) mmap

memory-mapping,是内核的一个概念,它隐藏了与磁盘交互的细节,使用方能够像访问内存中的字节数组一样读取磁盘文件中的内容。

(2) page

内存与磁盘间数据的交换以页 page 为单位,page 大小一般是操作系统的页大小,分为 4 类 page:

  • meta page:存储 boltdb 的元数据,如版本号、校验和、全局递增的事务 id 
  • freelist page:存储空闲 page 信息,哪些 page 空闲可用,哪些 page 将被事务释放(全局维度,相当于 go 语言中的 heap,以空间换时间,缓存并管理空闲 page 以供复用,减少与操作系统的交互频率)
  • branch element page:存储索引的节点, 对应为 b+ 树中的分支节点
  • leaf element page:存储数据的节点, 对应为 b+树中的叶子节点

(3) B+树

boltdb 的实现对 B+ 树进行了改造:

  • 引入游标工具: 底层叶子节点未通过链表串联,范围检索会借助一个压栈记录了移动路径的游标指针来完成
  • 降低调整频率: 为兼顾操作效率与b+树的平衡性,boltdb 仅在数据溢写落盘前,才一次性完成 b+树的平衡性调整

(4) bucket

boltdb 中的 bucket 即为数据库表,是嵌套式的拓扑关系,使用 B+ 树实现。每个 db 会有个默认的 root bucket,以此为起点可以衍生出一个 bucket B+ 树,每个 bucket 下的数据又是一颗 B+树。

(5) 只读事务和读写事务

boltdb 中的事务分为只读事务 read-only tx 和读写事务 read-write tx 两类:
  • 读写事务:同一时刻只能有一个读写事务执行,但可以和多个只读事务并行执行
  • 只读事务:多个只读事务可以并行执行

3. 核心方法

3.1 boltDB 的初始化

DB 数据结构定义为:

type DB struct {
    // ...
    // 数据库文件路径
    path     string
    // 打开文件方法
    openFile func(string, int, os.FileMode) (*os.File, error)
    // 数据库文件,所有数据存储于此
    file     *os.File
    // 基于 mmap 映射的数据库文件内容,maxMapSize默认256TB
    data     *[maxMapSize]byte
    // ...
    // 两个轮换使用的 meta page, 记录数据库元信息
    meta0    *meta
    meta1    *meta
    // 数据库单个 page 的大小,单位 byte, 默认是操作系统页面大小,一般是4KB
    pageSize int
    // 数据库是否已启动
    opened   bool
    // 全局唯一的读写事务
    rwtx     *Tx
    // 一系列可并行的只读事务
    txs      []*Tx
    // freelist,管理空闲的 page
    freelist     *freelist
    freelistLoad sync.Once
    // 对象池,复用 page 字节数组
    pagePool sync.Pool
    // ...
    
    // 互斥锁,保证读写事务全局唯一
    rwlock   sync.Mutex   
    // 保护 meta page 的互斥锁
    metalock sync.Mutex   
    // 保护 mmap 的读写锁
    mmaplock sync.RWMutex
    // 数据落盘持久化时使用的操作方法,对应为 pwrite 操作
    ops struct {
        writeAt func(b []byte, off int64) (n int, err error)
    }

    // 是否已只读模式启动数据库
    readOnly bool
}

DB 结构在 Open() 时被初始化,其主要流程为:

  • 构造 db 实例,并读取各项 option 完成配置
  • 通过传入的 path,打开对应的数据库文件(文件不存在则创建)
  • 倘若在创建新的数据库文件,会初始化 4 个 page:2 个 meta page、1 个 freelist page、1 个 leaf element page
  • 构造 pagePool 对象池,后续可复用 page 的字节数组
  • 执行 mmap 操作,完成数据库文件和内存空间的映射
  • 返回构造好的 db 实例

3.2 mmap 磁盘内存映射

mmap 创建了磁盘和内存映射, 方法:
func (db *DB) mmap(minsz int) (err error)

其流程为:

  • 加锁保证 mmap 操作并发安全
  • 设置合适的 mmap 空间大小
  • 若此前已经有读写事务在运行,此时因为要执行 mmap 操作,则需要对 bucket 内容进行重塑
  • 解除之前建立的 mmap 映射
  • 建立新的 mmap 映射

mmap 底层是通过调用系统内核实现的,不同的操作系统(windows、unix 等)会有不同的实现细节,unix的调用:

unix.Mmap(int(db.file.Fd()), 0, sz, syscall.PROT_READ, syscall.MAP_SHARED|db.MmapFlags)
映射后的内容放入了 DB 结构的 data 数组 中,后续的读操作就是直接读这个数组。

3.3 bucket的创建和查询

bucket 的创建方法:
func (b *Bucket) CreateBucket(key []byte) (*Bucket, error)

其步骤为:

  • 借助游标,找到 bucket key 所应当从属的父 bucket b+ 树的位置
  • 创建子 bucket实例,并取得序列化后的结果
  • 将 bucket 名称作为 key,bucket 序列化结果作为 value,以一组 kv 对的形式插入到父 bucket b+ 树中
bucket 的查询方法:
func (b *Bucket) Bucket(name []byte) *Bucket

其步骤为:

  • 查看父 bucket 的缓存 map,如果子 bucket 已反序列化过,则直接复用
  • 通过游标 cursor 检索父 bucket 的 b+ 树,找到对应子 bucket 的 kv 对数据
  • 根据 kv 数据反序列化生成子 bucket 实例
  • 将子 bucket 添加到父 bucket 的缓存 map 中
  • 返回检索得到的子 bucket

3.4 数据的CRUD

数据的增、改都是 Put 方法:
func (b *Bucket) Put(key []byte, value []byte) error
  • 借助游标检索到 k-v 对所在的位置
  • 在对应位置中插入 k-v 对内容
数据的删除方法:
func (b *Bucket) Delete(key []byte) error
  • 借助游标移动到 key 对应位置
  • 若 key 不存在,直接返回
  • 在 b+ 树节点中删除对应的 key
数据的查询方法:
func (b *Bucket) Get(key []byte) []byte
  • 借助游标检索到 kv 对所在位置
  • 若 key 不存在,直接返回
  • 返回对应的 value

3.5 事务提交

事务提交方法:
func (tx *Tx) Commit() error

在 boltdb 提交读写事务时,会一次性将更新的脏数据溢写落盘:

  • 通过 rebalance 和 spill 操作,保证 b+ 树的平衡性满足要求
  • 执行 pwrite+fdatasync 操作,完成脏数据的 page 的一些落盘
  • 通过 pagePool 回收用于指向这部分 page 对应的字节数组
  • 由于更新了事务进度,meta page 也需要溢写落盘
  • 关闭读写事务
贴上源码:
func (tx *Tx) Commit() error {
    // ...
    
    // 数据溢写磁盘前,需要调整一轮 b+ 树,保证其平衡性
    // rebalance 是为了避免因为 delete 操作,导致某些节点 kv 对数量太少,不满足 b+ 树平衡性要求
    tx.root.rebalance()
    // ...
    // spill 是为了避免因为 put 操作,导致某些节点 kv 对数量太多,不满足 b+ 树平衡性要求
    if err := tx.root.spill(); err != nil {
        tx.rollback()
        return err
    }
    
    // 事务更新到的脏数据溢写落盘
    if err := tx.write(); err != nil {
        tx.rollback()
        return err
    }
    // ...

    // meta page 溢写落盘
    if err := tx.writeMeta(); err != nil {
        tx.rollback()
        return err
    }
    // ...

    // 关闭事务
    tx.close()
    // ...
    return nil
}

// 事务脏页溢写落盘
func (tx *Tx) write() error {
    // 事务缓存的脏页
    pages := make(pages, 0, len(tx.pages))
    for _, p := range tx.pages {
        pages = append(pages, p)
    }
    // 清空缓存
    tx.pages = make(map[pgid]*page)
    // 对脏页进行排序
    sort.Sort(pages)


    // 按照顺序,将脏页溢写落盘
    for _, p := range pages {
        // page 总大小,包含 overflow 不分
        rem := (uint64(p.overflow) + 1) * uint64(tx.db.pageSize)
        // page 的 offset,可以根据 page id 推算得到
        offset := int64(p.id) * int64(tx.db.pageSize)
        var written uintptr

        // Write out page in "max allocation" sized chunks.
        for {
            sz := rem
            if sz > maxAllocSize-1 {
                sz = maxAllocSize - 1
            }
            buf := unsafeByteSlice(unsafe.Pointer(p), written, 0, int(sz))
            // 将 page 溢写到文件对应 offset 的位置
            if _, err := tx.db.ops.writeAt(buf, offset); err != nil {
                return err
            }

            rem -= sz
            // 一次性写完了
            if rem == 0 {
                break
            }

            // 如果没有一次性写完,下一轮接着写
            offset += int64(sz)
            written += uintptr(sz)
        }
    }

    // fdatasync 操作,确保数据溢写落盘完成
    if !tx.db.NoSync || IgnoreNoSync {
        if err := fdatasync(tx.db); err != nil {
            return err
        }
    }

    // 释放这部分已落盘 page,倘若其不存在 overflow,说明是标准规格的字节数组,则清空内容,然后添加到对象池中进行复用
    for _, p := range pages {
        // Ignore page sizes over 1 page.
        // These are allocated using make() instead of the page pool.
        if int(p.overflow) != 0 {
            continue
        }

        buf := unsafeByteSlice(unsafe.Pointer(p), 0, 0, tx.db.pageSize)

        // See https://go.googlesource.com/go/+/f03c9202c43e0abb130669852082117ca50aa9b1
        for i := range buf {
            buf[i] = 0
        }
        tx.db.pagePool.Put(buf) //nolint:staticcheck
    }
    return nil
}

实际使用时,通常会调用 Update 方法,启动隐式读写事务,方法结束时,boltdb 会处理事务的 commit 与 rollback,不需要手动调用了:

func (db *DB) Update(fn func(*Tx) error) error


  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值