上一章学习 DB 文件是如何通过字节组织成具体的文件、如何进行读写,本章将继续学习
1.BoltDB 如何实现事务?
2.BlotDB 如何实现 MVCC?
首先 BoltDB 是通过 B+ 树来组织 page 的。其中 db 文件,pageid 为 0,1 页固定用于存储 meta 数据。而 BlotDB 也是通过 meta 来实现事务,在 BlotDB 中,只有 meta 数据写入成功数据才能被其他新事务可见(具体代码阅读 tx.go 和 db.go)。
事务
先来看看一个事务写事务的开启:
func (db *DB) Begin(writable bool) (*Tx, error) {
if writable {
return db.beginRWTx()
}
return db.beginTx()
}
func (db *DB) beginRWTx() (*Tx, error) {
...
t := &Tx{writable: true} //初始化一个写写事务
t.init(db)
db.rwtx = t
db.freePages() //释放之前写事务产生是 pending 中的 ids,这样新事务可以直接利用这些空闲页
return t, nil
}
func (tx *Tx) init(db *DB) {
tx.db = db
tx.pages = nil
//拷贝元数据
tx.meta = &meta{}
db.meta().copy(tx.meta)
tx.root = newBucket(tx) //新的bucket(一个 bucket 管理一个事务缓存的 page 数据,事务之间数据隔离)
tx.root.bucket = &bucket{}
*tx.root.bucket = tx.meta.root //这个包含两个值一个是 (root pgid) 查找数据时从哪个page开始查找,(sequence uint64) 事务ID,事务 ID 是递增的,每次开启写事务+1
if tx.writable {
tx.pages = make(map[pgid]*page) //这个保存事务过程涉及脏页(数据被修改过的),后续将这些数据刷入磁盘。
tx.meta.txid += txid(1) //事务 ID+1
}
}
上面代码是一个事务初始化的过程。其主要三点,一是释放空闲页,二是拷贝meta,三是初始化一个新的 Bucket,用于管理整个事务过程涉及的数据。
BlotDB 一个简单写数据的例子:
err = db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("test"))
if b != nil {
err := b.Put([]byte("hello"), []byte("world"))
if err != nil {
}
}
return nil
})
db.Update 函数用于初始化事务(上述的流程),执行 func、提交或者滚回事务。func 中,Put 流程大概可以总结为:
1.根据指定 key,从 meta.root 记录的根页开始,生成B+ 快照,查找(B+ 树节点元素是已经根据key 进行来排序,所以可以通过二分法查找以提高查询效率)指定 key 不大于 B+ 树节点 key 的位置。查看过程具体可以阅读 cursor.go 中的 seek。比如已有节点是下图

查找方式:指定key >节点key,继续前进。否则停止。
那么如果是 key=3, 在节点4停留,判断指定 key != 节点key,则在节点4前插入一个节点3,在节点3写入数据。

如果 key=4, 指定 key =节点 key,直接修改节点4的数据。
已经找到指定 key 的节点的位置,然后通过 pageId,加载到 node 中(内存),并在 inodes 中找到适合的位置写入数据。至此,数据已经更改到了内存中,下一步就是 Commit,我们先看看Commit 的主要流程(具体代码在 tx.go 文件中)。
func (tx *Tx) Commit() error {
tx.root.rebalance() //小页合并
if err := tx.root.spill(); err != nil { //大页分裂(不管是否分裂,都会为每个 node 分配一个pageId,这个pageId从空闲页中获取或申请一个新页,这样就不会影响的旧页的数据)
tx.rollback()
return err
}
if tx.meta.freelist != pgidNoFreelist { //将老的f reelist 也放到 appending (待释放的空闲页)
tx.db.freelist.free(tx.meta.txid, tx.db.page(tx.meta.freelist))
}
if !tx.db.NoFreelistSync {
err := tx.commitFreelist() //创建一个新的也,来记录 freeList,并更新 db.freelist 的pageId
if err != nil {
return err
}
} else {
tx.meta.freelist = pgidNoFreelist
}
if err := tx.write(); err != nil { //将事务涉及的 page,全部写入磁盘(都是有)
tx.rollback()
return err
}
if err := tx.writeMeta(); err != nil { //将 meta 数据写入。 根据 txid 决定写在那个meta
tx.rollback()
return err
}
}
这里需要关注一下 tx.root.spill(), spill 主要目的是分裂大页,让所有的页保持在 pageSize 范围内,同时为了保证不影响旧数据(其他事务还在用不能修改)。会为 node 数据重新申请一个新的 page (空闲页或者重新申请),将数据写入新的 page 中(具体代码再 bucket.go 和 node.go)。node 分裂步骤是:
func (b *Bucket) spill() error {
//从子 bucket 开始分裂
// Spill all child buckets first.
for name, child := range b.buckets {
...
}
...
//分裂 node
if err := b.rootNode.spill(); err != nil {
return err
}
b.rootNode = b.rootNode.root()
b.root = b.rootNode.pgid //更新根节点 pageId
return nil
}
func (n *node) spill() error {
var tx = n.bucket.tx
if n.spilled {
return nil
}
//从子节点开始分裂
sort.Sort(n.children)
for i := 0; i < len(n.children); i++ {
if err := n.children[i].spill(); err != nil {
return err
}
}
....
//将 node 按 pageSize,分裂出多个节点。
var nodes = n.split(tx.db.pageSize)
for _, node := range nodes {
// Add node's page to the freelist if it's not new.
if node.pgid > 0 {
tx.db.freelist.free(tx.meta.txid, tx.page(node.pgid)) //将老ID放到 appending 中(待释放页),
node.pgid = 0
}
....
//申请新的 page,将数据写入新的页。
p, err := tx.allocate((node.size() + tx.db.pageSize - 1) / tx.db.pageSize)
//将数据写新page
node.pgid = p.id
node.write(p)
node.spilled = true
//如果存在 parenet, 将 node 第一个元素(已经排序),向 parent 添加。
if node.parent != nil {
...
node.parent.put(key, node.inodes[0].key, nil, node.pgid, 0)
node.key = node.inodes[0].key
}
}
if n.parent != nil && n.parent.pgid == 0 { //分裂 parent
n.children = nil
return n.parent.spill()
}
return nil
}
分裂的顺序是自底向上。主要步骤是:
1.将 node 按 pageSize 分成多个新节点(nodes)。
2.为每个新节点重新分配 page,并将 node 数据写入 page。
3.如果新节点存在 parent,将node起始元素追加到parent这个分支页中。
4.分裂 parent 节点。上述过程不断递归,直到分裂完根节点。
5.分裂完成后,将新的根节点p ageId,更新到 bucket.root 中。
至此,所有涉及到修改的页都写入了新的 page
分裂演示
下面将演示一下分裂过程,为了方便演示,假设每页最大存储两个元素,除叶子节点外,其他都是分支节点。branch(分支节点)内部pgid指向下一个要读取的pgid,直到读取到left(叶子节点)。初始状态根节点root=1。

向 db 插入元素 key=i, value=i1,从根节点 pgid=1 开始搜索,找到元素的位置。需要插入到 key=h 之后。如下图该事务会涉及到 pageid(1,2,4)。

查找过程,将遍历的 page 转换为 node 结构(读取到内存中),并插入 key=i、value=i1。freelist 表示空闲列表。

Commit 过程中需要经历分裂过程,按每页最大两个元素分裂,从低向上开始。由于 pageid=8 的node 元素超过2个,需要分裂成 2个 node,并为这2个 node 重新分配 pgid, 同时向父节点 pgid=4 插入 key=i,pageid=10 的元素信息并更新父节点 inodes 中对应的 pgid。最后将原 pgid=8,添加到待释放的空闲列表。

然后尝试去分裂 pgid=7,由于元素没用超过两个,只需要重新申请 pgid,并更新父节点 inodes 中对应的 pgid。最后将原 pgid=7,添加到待释放的空闲列表。

分裂 pgid=4, 节点元素超过2个,需要分裂成2个 node,并为这2个 node 重新分配 pgid,同时向父节点 pgid=2 插入 key=i、pageid=12 的元素信息并更新父节点 inodes 中对应的 pgid。最后将原pgid=4,添加到待释放的空闲列表。

pageid=2 流程相似,一直分裂,直到根节点。并更新 root=18。并将脏数据和元数据写入文件中。

最终实现效果

从上述过程可以知,数据的写都是在内存中进行的,不会涉及的原数据的修改,同时会为每个node 重新申请 pgid,当事务 rollback 时只需要将之前申请的pagid放入空闲列表,同时将待释放的空闲列表移除,commit 时,将这些page、freelist 和 meta 全部写入磁盘即可。
在 BlotDB中,会存储两份 meta 数据。每个事务读取到的 meta 公式为 txId (事务 ID)%2。
如上述流程meta如下:

root:根节点ID
seq:事务序列号。
读事务:txId = meta.seq
写事务:txId = meta.seq+1
在事务提交之前,读取到都是 meta 0, 事务将 meta 写入之后,后面事务读取到的是 meta1 的数据。以此来实现原子性。即只有 meta 的写入成功,才算事务成功,其他事务才能读取到最新的数据。
而MVCC实现:
1.BlotDB 实现并发读,串行写。BlotDB 保留两个版本的meta。读只需将对应的meta拷贝到事务中,然后从 root 开始构建 B+ 树快照,之后的读写都以这个B+ 树为主。
2.读取数据时,都会将涉及的 page 读取到 node 中(写时复制)。之后写操作都发送在node中,不会对原数据产生影响。
3.每个写事务,最终提交数据时,涉及到的page,都会生成新pageid。不影响旧pageid的读。
总结
读写事务执行过程中,所有的改动都是增量改动,不影响其他只读事务,最后提交时,meta 页落盘成功才会使得所有增量改动对用户可见。
也就是说,使用 meta 作为“全局指针”,以该指针的写入原子性来保证事务的原子性。如果宕机时,元信息页没有写入完成,所有改动便不会生效,达到了自动回滚的效果。
参考
Boltdb 源码导读(一):Boltdb 数据组织 - 云+社区 - 腾讯云
boltdb 源码导读(二):boltdb 索引设计 - 云+社区 - 腾讯云
boltdb 源码导读(三):boltdb 事务实现 - 云+社区 - 腾讯云
本文详细介绍了BlotDB的事务实现,包括事务开启、数据写入及提交过程,重点解析了B+树在事务中的应用和页分裂操作。同时,文章阐述了BlotDB如何通过MVCC实现并发读取和串行写入,确保数据一致性。通过对meta的双版本管理,保证了事务的原子性和回滚机制。
1190

被折叠的 条评论
为什么被折叠?



