BlotDB 学习总结(二)

本文详细介绍了BlotDB的事务实现,包括事务开启、数据写入及提交过程,重点解析了B+树在事务中的应用和页分裂操作。同时,文章阐述了BlotDB如何通过MVCC实现并发读取和串行写入,确保数据一致性。通过对meta的双版本管理,保证了事务的原子性和回滚机制。

上一章学习 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 事务实现 - 云+社区 - 腾讯云

Boltdb源码分析(四)----bucket结构 - 云+社区 - 腾讯云

BoltDB 介绍与源代码分析(八):MVCC 多版本并发控制_fananchong2的博客-优快云博客_boltdb

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值