中间节点页(branchPage)
中间节点页和叶子节点页的结构大体相同。不同之处在于,页中保存的数据的 value 是 page id,即该中间节点在哪些 key 上的分支分别指向的 page 。
branchPageElement
中的 key 存的是其指向的页中的起始 key。
转换流程
boltdb 使用 mmap 将 db 文件映射到内存空间。在构建树并且访问过程中,按需将对应的页加载到内存里,并且利用操作系统的页缓存策略进行替换。
文件增长
当我们打开一个 db 时,如果发现该 db 文件为空,会在内存中初始化四个页(4*4k=16K),分别是两个元信息页、一个空的空闲列表页和一个空的叶子节点页,然后将其写入 db 文件,然后走正常打开流程。
func (db *DB) init() error { // 设置页大小与操作系统一致 db.pageSize = os.Getpagesize() buf := make([]byte, db.pageSize*4) // 在 buffer 中创建两个元信息页. for i := 0; i < 2; i++ { p := db.pageInBuffer(buf[:], pgid(i)) p.id = pgid(i) p.flags = metaPageFlag // 初始化元信息页. m := p.meta() m.magic = magic m.version = version m.pageSize = uint32(db.pageSize) m.freelist = 2 m.root = bucket{root: 3} m.pgid = 4 m.txid = txid(i) m.checksum = m.sum64() } // 在 pgid=2 的页写入一个空的空闲列表. p := db.pageInBuffer(buf[:], pgid(2)) p.id = pgid(2) p.flags = freelistPageFlag p.count = 0 // 在 pgid=3 的页写入一个空的叶子元素. p = db.pageInBuffer(buf[:], pgid(3)) p.id = pgid(3) p.flags = leafPageFlag p.count = 0 // 将 buffer 中的这四个页写入数据文件并刷盘 if _, err := db.ops.writeAt(buf, 0); err != nil { return err } if err := fdatasync(db); err != nil { return err } return nil }
随着数据的不断写入,需要申请新的页。boltdb 首先会去 freelist 中找有无可重复利用的页,如果没有,就只能进行 re-mmap(先 mumap 在 mmap),扩大 db 文件。每次扩大会进行倍增(因此从 16K * 2 = 32K 开始),到达 1G 后,再次扩大会每次新增 1G。
func (db *DB) mmapSize(size int) (int, error) { // 从 32KB 开始,直到 1GB. for i := uint(15); i <= 30; i++ { if size <= 1<<i { return 1 << i, nil } } // Verify the requested size is not above the maximum allowed. if size > maxMapSize { return 0, fmt.Errorf("mmap too large") } // 对齐到 maxMmapStep = 1G sz := int64(size) if remainder := sz % int64(maxMmapStep); remainder > 0 { sz += int64(maxMmapStep) - remainder } // 对齐到 db.pageSize pageSize := int64(db.pageSize) if (sz % pageSize) != 0 { sz = ((sz / pageSize) + 1) * pageSize } // 不能超过 maxMapSize if sz > maxMapSize { sz = maxMapSize } return int(sz), nil }
在 32 位 机器上文件最大不能超过 maxMapSize
= 2G;在 64 位机器上,文件上限为 256T。
读写流程
在打开一个已经存在的 db 时,会首先将 db 文件映射到内存空间,然后解析元信息页,最后加载空闲列表。
在 db 进行读取时,会按需将访问路径上的 page 加载到内存,并转换为 node,进行缓存。
在 db 进行修改时,使用 COW 原则,所有修改不在原地,而是在改动前先复制一份。如果叶子节点 node 需要修改,则 root bucket 到该 node 路径上所涉及的所有节点都需要修改。这些节点都需要新申请空间,然后持久化,这些和事务的实现息息相关,之后会在本系列事务文章中做详细说明。
小结
boltdb 在数据组织方面只使用了两个概念:页(page) 和节点 (node)。每个数据库对应一个文件,每个文件中包含一系列线性组织的页。页的大小固定,依其性质不同,分为四种类型:元信息页、空闲列表页、叶子节点页、中间节点页。打开数据库时,会渐次进行以下操作:
- 利用 mmap 将数据库文件映射到内存空间。
- 解析元信息页,获取空闲列表页 id 和 bucket 树页 id。
- 依据空闲列表页 id ,将所有空闲页列表载入内存。
- 依据 bucket 树起始地址,解析 bucket 树根节点。
- 根据读写需求,从树根开始遍历,按需将访问路径上的数据页(中间节点页和叶子节点页)载入内存成为节点(node)。
可以看出,节点分两种类型:中间节点(branch node)和叶子节点(leaf node)。
另外需要注意的是,多个子 Bucket 树和 Bucket 对应的 B+ 树复用了 bucket 这个数据结构,导致这一块稍微有点不好理解。在下一篇 boltdb 的索引设计中,将详细剖析 boltdb 是如何组织多个 bucket 以及单个 bucket 内的 B+ 树索引的。
参考
- github,boltdb repo
- 我叫尤加利,boltdb 源码分析
不妨一读