前言
为什么看 boltdb ?起因是在学习 etcd 的时候,底层的存储以及 MVCC 事务控制就是交给 boltdb。之前也学过 Mysql,但是由于其底层源码复杂度,同时看到 boltdb 是纯 Go 编写,可以把 b+ tree 以及 MVCC 的实现复习一下。
下面就开始我们的探索!
import (
"log"
"time"
bolt "go.etcd.io/bbolt"
)
func main() {
db, err := bolt.Open("my.db", 0600, &bolt.Options{Timeout: 10 * time.Second})
if err != nil {
log.Fatal(err)
}
defer db.Close()
}
复制代码
以上是 etcd 中的 boltdb 最简单的入门代码。
在获取 go get github.com/boltdb/bolt 时候,一直down不下来。
所以改为 etcd 维护的 boltdb 。不过基本 API 功能是不变的。
另外:etcd get 完之后,会同时下载一个 bbolt 的 command line,便于分析 boltdb 的底层结构。
存储结构
在执行完上述代码,会在同级目录下生成一个 my.db 文件。这就是一个 boltdb 的数据库实例:包含了当前这个数据库的所有信息:metadata, index, data
先来看看 etcd 提供的 bbolt 的命令有什么提示:
❯ bbolt -h
Bolt is a tool for inspecting bolt databases.
Usage:
bolt command [arguments]
The commands are:
bench run synthetic benchmark against bolt
buckets print a list of buckets
check verifies integrity of bolt database
compact copies a bolt database, compacting it in the process
dump print a hexadecimal dump of a single page
get print the value of a key in a bucket
info print basic info
keys print a list of keys in a bucket
help print this screen
page print one or more pages in human readable format
pages print list of pages with their types
page-item print the key and value of a page item.
stats iterate over all pages and generate usage stats
Use "bolt [command] -h" for more information about a command.
复制代码
看到其中的 bblot pages ... :
❯ bbolt pages -h
usage: bolt pages PATH
Pages prints a table of pages with their type (meta, leaf, branch, freelist).
Leaf and branch pages will show a key count in the "items" column while the
freelist will show the number of free pages in the "items" column.
The "overflow" column shows the number of blocks that the page spills over
into. Normally there is no overflow but large keys and values can cause
a single page to take up multiple blocks.
复制代码
首先一个数据库实例在存储的时候是以 page 为单位组织的。每个 page 大小由当前文件系统的 PAGE_SIZE 决定,这是数据在磁盘和内存中活动的最小单位:
❯ getconf PAGE_SIZE
4096
#4096 = 4k
复制代码
Page
数据库实例初始化的页面分布:
❯ bbolt pages my.db
ID TYPE ITEMS OVRFLW
======== ========== ====== ======
0 meta 0
1 meta 0
2 freelist 0
3 leaf 0
复制代码
至于上述的 page 是什么?下面会解释每一种 page 的作业和分布。
Page Structure
不管是哪种 page,结构上都是 header + data body 构成的。
type pgid uint64
// page header {id, flags, count, overflow}
type page struct {
id pgid
flags uint16
count uint16
overflow uint32
ptr uintptr
}
复制代码
id:page id
flags:区分不同类型的页面。也就是上述的一些什么 meta/freelist page
count:该页面存储的数据数量。只有当页面为 branch/leaf page 才会存储数据
overflow:单个页面不够存储数据,该值存放溢出页数量
ptr:指向页表头数据的结尾,也就是页面数据起始的位置【页表头不包括 ptr】
其实在 bbolt pages -h 命令下的对于 overflow 的解释很清楚。我这里翻译一下:
overflow 列显示页面溢出的块的数量。通常不会发生溢出,但是大的键和值可能会导致溢出一个页面要占用多个块。
Meta Page
meta page 存储数据库实例元数据。
type meta struct {
magic uint32
version uint32
pageSize uint32
flags uint32
root bucket
freelist pgid
pgid pgid
txid txid
checksum uint64
}
复制代码
magic:魔数,用来识别 boltdb db file
version:boltdb 版本
pageSize:db page 大小,os.Getpagesize() 可以获取
flags:不知道是干啥的
root:boltdb 中数据组织是以 b+ tree 形式,而整个树的 root node 就保存在这
freelist:数据删除时,这些 page 会被保存 freelist 中的 ids 中
pgid:下一个将要分配的 pgid【可以理解为当前 max page num,即目前 page 数量 】
txid:下一个将要分配 transaction id,和事务实现有关
checksum:校验码,用于校验 metapage 是否写完整
// meta returns a pointer to the metadata section of the page.
func (p *page) meta() *meta {
return (*meta)(unsafe.Pointer(&p.ptr))
}
复制代码
此处可以证明:ptr 是指向 page data ,同时这里基本上都涉及 unsafe 的指针操作
freelist page
空闲链表页是 db file 中一组连续的页(1个甚至多个),保存 db 使用过程中的由于修改操作而释放的页的 id 列表。
// freelist represents a list of all pages that are available for allocation.
// It also tracks pages that have been freed but are still in use by open transactions.
type freelist struct {
ids []pgid // all free and available free page ids.
pending map[txid][]pgid // mapping of soon-to-be free page ids by tx.
cache map[pgid]bool // fast lookup of all free and pending page ids.
}
复制代码
ids :保存当前空闲页面的 id array
pending:保存事务操作对应的 page id,key: trxid; value: []pgid,在事务操作完成之后会被释放。即是事务操作中的中间产物
cache:标记一个 pgid 可用,存放着 key: pgid; value: 标记当前都是闲置可分配使用
boltdb 通过 freelist page 来管理页面的分配。
开始说到 freelist 记录了哪些 page 是空闲的,在 删除 和 新数据分配 时都会涉及到:
删除大量数据,对应的 page 会释放 -> pgid 存储到 freelist 的 ids 中;
写入数据时,直接从空闲页 ids 中申请即可。
上述例图中,ids 是事先排序好的。这个设计也是方便 freelist 分配 new page 。
branch page
前面提到 meta page 中的 root 字段,它是整个数据库实例数据组织 b+ tree 的 root node。
那其他的 node 呢?
index node -> branch page
leaf node -> leaf page
// branchPageElement retrieves the branch node by index
func (p *page) branchPageElement(index uint16) *branchPageElement {
return &((*[0x7FFFFFF]branchPageElement)(unsafe.Pointer(&p.ptr)))[index]
}
// branchPageElements retrieves a list of branch nodes.
func (p *page) branchPageElements() []branchPageElement {
if p.count == 0 {
return nil
}
return ((*[0x7FFFFFF]branchPageElement)(unsafe.Pointer(&p.ptr)))[:]
}
// branchPageElement represents a node on a branch page.
type branchPageElement struct {
pos uint32
ksize uint32
pgid pgid
}
// key returns a byte slice of the node key.
func (n *branchPageElement) key() []byte {
buf := (*[maxAllocSize]byte)(unsafe.Pointer(n))
return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos]))[:n.ksize]
}
复制代码
摘取了部分有关 branchPage 的代码,从上面可以看出以下几个特征:
branch page 的数据部分:存储由此分支开始的每一个 index node 元数据信息
index data 采取空间换时间,分为:
branchPageElement:存储分支下的每个 branch node 元数据
key:真正的数据【也只是可以找下一个 node 的 key】
branchPageElement 字段含义:
pos:page data 部分的偏移量,可以找到相应的 key
ksize:key 的大小
pgid:下一个 branch page 的 page id
从 key() 函数看出:
是从 element 的 pos 位置开始,读取 e.kszie 偏移量。
可以注意到:先获取当前 ele 的 Pointer address ,因为整个数据段都是连续的数组,所以可以通过 ele.pos ,加上这个偏移量,就直接找到对应的 key
branchPageElement() 中也是从 page 的 []branchPageElement 读取对应index。从而说明 branch page 存储的是一个连续的 branchPageElement。
但是 etcd 版中,key() 被改写成:reflect.SliceHeader
// key returns a byte slice of the node key.
func (n *branchPageElement) key() []byte {
return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
Data: uintptr(unsafe.Pointer(n)) + uintptr(n.pos),
Len: int(n.ksize),
Cap: int(n.ksize),
}))
}
复制代码
这个就是转换为 []byte 的 zero-copy 做法:
先拿到结构体指针指向的地址,+ 对应偏移量 => 得到 data
key slice len和cap都是其 ele 的 ksize
而上述代码也是在 page 到 node ,也就是把数据从磁盘读到内存中。当然其中重要的就是 read():
// read initializes the node from a page.
func (n *node) read(p *page) {
n.pgid = p.id
n.isLeaf = ((p.flags & leafPageFlag) != 0)
n.inodes = make(inodes, int(p.count))
for i := 0; i < int(p.count); i++ {
inode := &n.inodes[i]
if n.isLeaf {
elem := p.leafPageElement(uint16(i))
inode.flags = elem.flags
inode.key = elem.key()
inode.value = elem.value()
} else {
elem := p.branchPageElement(uint16(i))
inode.pgid = elem.pgid
inode.key = elem.key()
}
_assert(len(inode.key) > 0, "read: zero-length inode key")
}
...
}
复制代码
read() 就可以很明显看到 branch page 的页面布局,再来整体梳理一下:
从 page 中获取 pgid,标明当前 node 来源哪个 page
[]inodes 长度为 page.count ,也就是当前node有多少个 子node
isLeaf 由 page.flags 决定【也决定后面的 if 分支的赋值】
由 p.count 的数量,循环每一个 element,同时对应 n.inodes 的每一个赋值
这样就把磁盘的 b+ tree 映射到内存中。此部分内容还会在后面继续深入的。
leaf page
根据上面的 read() 也可以看到针对 leaf node 字段的赋值:
type leafPageElement struct {
flags uint32
pos uint32
ksize uint32
vsize uint32
}
复制代码
flags:区分 subbucket和 普通 leaf node【关于 bucket 后续再讲】
pos:与 branch page 的一致,距离对应 leafPageElement 的偏移量
ksize/vsize:key/value 存储大小
其中 value 的定位在 read() 中体现了:
// read initializes the node from a page.
func (n *node) read(p *page) {
...
for i := 0; i < int(p.count); i++ {
inode := &n.inodes[i]
if n.isLeaf {
elem := p.leafPageElement(uint16(i))
inode.flags = elem.flags
inode.key = elem.key()
inode.value = elem.value() // 正是这行。。。
} else {
...
}
_assert(len(inode.key) > 0, "read: zero-length inode key")
}
...
}
// value returns a byte slice of the node value.
func (n *leafPageElement) value() []byte {
buf := (*[maxAllocSize]byte)(unsafe.Pointer(n))
return (*[maxAllocSize]byte)(unsafe.Pointer(&buf[n.pos+n.ksize]))[:n.vsize:n.vsize]
}
复制代码
key:&element + pos == &key
value:&leafPageElement + pos + ksize == &val
总结
本文从整体的页面布局上了解了 boltdb 在磁盘中的结构。下一篇来看看 boltdb 在内存中的操作和设计思路。