文章详情

短信预约信息系统项目管理师 报名、考试、查分时间动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Boltdb学习笔记之三--事务与并发控制

2021-06-02 11:39

关注

Boltdb学习笔记之三--事务与并发控制

如果说数据库是软件工程领域的皇冠,而事务与并发控制可称之为皇冠上的钻石。本节将详细分析boltdb中如何实现事务与并发控制

事务

事务定义

boltdb中使用Tx表示事务, 定义如下:

// Tx represents a read-only or read/write transaction on the database.
// Read-only transactions can be used for retrieving values for keys and creating cursors.
// Read/write transactions can create and remove buckets and create and remove keys.
//
// IMPORTANT: You must commit or rollback transactions when you are done with
// them. Pages can not be reclaimed by the writer until no more transactions
// are using them. A long running read transaction can cause the database to
// quickly grow.
type Tx struct {
	writable       bool
	managed        bool
	db             *DB
	meta           *meta
	root           Bucket
	pages          map[pgid]*page
	stats          TxStats
	commitHandlers []func()

	// WriteFlag specifies the flag for write-related methods like WriteTo().
	// Tx opens the database file with the specified flag to copy the data.
	//
	// By default, the flag is unset, which works well for mostly in-memory
	// workloads. For databases that are much larger than available RAM,
	// set the flag to syscall.O_DIRECT to avoid trashing the page cache.
	WriteFlag int
}

其中的成员:

事务初始化

在Boltdb学习笔记之〇--概述中我们提到,用户可通过DB.Update新建一个读写事务,通过DB.View新建一个只读事务。二者都调用了DB.Begin

func (db *DB) Begin(writable bool) (*Tx, error) {
	if writable {
		return db.beginRWTx()
	}
	return db.beginTx()
}

接下来我们分别分析读写事务和只读事务的初始化过程

读写事务的初始化

因此创建读写事务的调用链:

DB.Update
	-> DB.Begin
		-> DB.beginRWTx

`DB.beginRWTx`返回一个读写事务:
- 首先创建一个`wriable`为`true`的`Tx`对象`tx`
- 然后对其进行初始化:拷贝当前db中的meta和root Bucket。并创建`pages`用于存储执行读写事务过程中产生的脏页, 并自增本地meta副本中的txid
- 释放过期事务所占据的pending pages
- 返回读写事务

func (db *DB) beginRWTx() (*Tx, error) {
	...

	// Create a transaction associated with the database.
	t := &Tx{writable: true}
	t.init(db)
	db.rwtx = t

	// Free any pages associated with closed read-only transactions.
	var minid txid = 0xFFFFFFFFFFFFFFFF
	for _, t := range db.txs {
		if t.meta.txid < minid {
			minid = t.meta.txid
		}
	}
	if minid > 0 {
		db.freelist.release(minid - 1)
	}
	return t, nil
}

只读事务的初始化

而创建只读事务的调用链:

DB.View
	-> DB.Begin
		-> DB.beginTx

DB.beginTx返回一个只读事务,

func (db *DB) beginTx() (*Tx, error) {
	...

	// Create a transaction associated with the database.
	t := &Tx{}
	t.init(db)

	// Keep track of transaction until it closes.
	db.txs = append(db.txs, t)
	n := len(db.txs)

	// Unlock the meta pages.
	db.metalock.Unlock()

	...

	return t, nil
}

事务执行

事务初始化之后,便开始执行了。在读写事务中,首先执行用户注册的回调函数,如果回调函数没有返回错误,则提交事务,否则回滚。有的读者会问了,如果回调函数执行过程中发生panic该如何处理呢?这里用了defer来捕捉异常。

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

	// Make sure the transaction rolls back in the event of a panic.
	defer func() {
		if t.db != nil {
			t.rollback()
		}
	}()

	// Mark as a managed tx so that the inner function cannot manually commit.
	t.managed = true

	// If an error is returned from the function then rollback and return error.
	err = fn(t)
	t.managed = false
	if err != nil {
		_ = t.Rollback()
		return err
	}

	return t.Commit()
}

在只读事务中,首先执行用户注册的回调函数。之后的处理与读写事务不同:这里不管回调函数是否返回错误,都会执行回滚。其实没毛病,对于只读事务来说,其执行不改变db中任何数据,因此没什么好回滚的,Tx.Rollback只是释放只读事务的资源并将其从当前db中抹去。

func (db *DB) View(fn func(*Tx) error) error {
	...
	if err != nil {
		_ = t.Rollback()
		return err
	}

	if err := t.Rollback(); err != nil {
		return err
	}

	return nil
}

事务提交

从上一节中我们看到,只有读写事务才会有提交。步骤如下:

上述步骤中关于B+树节点合并与分裂的具体执行过程见Boltdb学习笔记之二--数据结构

事务回滚

当事务执行过程中返回错误或panic, 或事务提交失败(比如磁盘I/O失败)时,即对当前事务执行回滚。

对于读写事务来说,回滚分为三步

func (tx *Tx) rollback() {
	if tx.db == nil {
		return
	}
	if tx.writable {
		tx.db.freelist.rollback(tx.meta.txid)
		tx.db.freelist.reload(tx.db.page(tx.db.meta().freelist))
	}
	tx.close()
}

对于只读事务,其回滚只有上述步骤的最后一步

现在我们分析下Tx.managed如何保证用户注册的回调函数中不会调用CommitRollback

我们看到,不管是读写事务还是只读事务,在进入回调函数中之后,managed必为true,直到程序跳出回调函数。假设用户此时在其回调函数中手动调用CommitRollback, 则必然会panic,因为CommitRollback中会断言managedfalse

func (tx *Tx) Commit() error {
	_assert(!tx.managed, "managed tx commit not allowed")
	...
}
func (tx *Tx) Rollback() error {
	_assert(!tx.managed, "managed tx rollback not allowed")
	...
}

至于为什么不让用户在回调函数中调用CommitRollback, 个人理解应该是处于简化设计的目的, 让用户和boltdb的职责划分更明确:用户的职责是写好回调函数,在各种异常场景下返回错误;而boltdb的职责是根据回调函数是否返回错误决定Commit或是Rollback

如何保证ACID

前面我们花了很大篇幅讲了boltdb中事务的实现细节。接下来我们分析boltdb中的事务如何满足ACID四个属性的:

原子性

对于只读事务,不修改任何数据,在查询过程中产生的缓存随着事务结束也会被释放掉,因此它是符合原子性的

对于读写事务来说,提交之前所有操作都在内存中,事务提交时,按照freelist、B+树数据和meta的顺序先后持久化到磁盘。只有meta成功持久化到磁盘之后,读写事务的操作才可见,换言之在此之前读写事务的操作都不可见。综上,boltdb中不管只读事务或读写事务都满足原子性

隔离性

boltdb中允许一个读写事务和多个只读事务执行。读写事务提交时只会分配新的page,直到在该事务之前的所有只读事务都完成才彻底释放旧page;而只读事务执行过程中不会释放和分配任何page。那么boltdb中如何保证读写事务和只读事务之间互不干扰的呢?以下我们分情况讨论

  1. 读写事务 + 读写事务 boltdb中通过互斥锁DB.rwlock保证了任何时刻最多只有一个读写事务在运行。因此两个读写事务并存的情况不存在
func (db *DB) beginRWTx() (*Tx, error) {
	// If the database was opened with Options.ReadOnly, return an error.
	if db.readOnly {
		return nil, ErrDatabaseReadOnly
	}

	// Obtain writer lock. This is released by the transaction when it closes.
	// This enforces only one writer transaction at a time.
	db.rwlock.Lock()
	...
}
  1. 只读事务 + 只读事务 只读事务不会新增/修改/删除任何page, 因此它们之间是互不影响的

  2. 读写事务 + 只读事务 boltdb中在创建一个新的读写事务时,首先会从只读事务中获取最小txid, 并彻底释放最小txid之前的已提交的读写事务的待释放page,即这些page可用于再次分配

	// Free any pages associated with closed read-only transactions.
	var minid txid = 0xFFFFFFFFFFFFFFFF
	for _, t := range db.txs {
		if t.meta.txid < minid {
			minid = t.meta.txid
		}
	}
	if minid > 0 {
		db.freelist.release(minid - 1)
	}

为什么要这样做呢?考虑这样一种时序,

初始状态:db meta txid为0

RW-1 begin -> RO-0 begin -> RW-1 commit -> RO-0 finish -> RW-2 begin

RW-n表示读写事务,其中n表示该事务id, 为创建该事务时,当前db meta page中txid+1 RO-n表示只读事务,其中n表示该事务id, 为创建该事务时,当前db meta page中的txid

当执行RW-1 commit时,会创建新page, 用于存储已更新的数据,同时更新db meta txid为1。注意此时对应的老的pending page还不能释放,因为事务RO-O可能还在引用。只有当RO-0完成后,此时再也没有只读事务引用txid=0版本的db。因此在RW-2初始化时,即可彻底释放掉RW-1中产生的老的pending page。

所以boltdb中保留了多种版本(用txid标识)的page, 当版本过期时便彻底释放掉对应的page用于再次分配,以此来保证读写事务和只读事务的隔离性

临界资源
  1. 读写事务 boltdb中最多只能同时运行一个读写事务,使用互斥锁db.rwlock保护

  2. 元数据 不管是创建、提交、回滚事务,都涉及boltdb中元数据的读写,因此使用互斥锁DB.metalock保护之

  3. mmap缓冲区 只读事务会读取mmap缓冲区,但是读写事务有可能触发remmap,如果不对mmap缓冲区加以保护,将会导致只读事务读取到的mmap缓冲区过时。因此实现上,整个只读事务执行过程中都对mmap缓冲区加读锁,而对DB.mmap函数加写锁,保证数据一致性。

持久性

只读事务因为不修改任何数据,因此无所谓持久性。

读写事务中提交时,不管是freelist(调用freelist.write)、B+树数据(调用Tx.write)还是meta数据(调用Tx.writeMeta),都会被持久化到磁盘。

Tx.writeMeta为例, 首先分配pageSize大小的缓冲区,并将meta序列化到该缓冲区内,再将缓冲区中的数据写入到磁盘上的meta page中,最后调用fdatasync将内核缓冲区中的数据全部flush到磁盘

// writeMeta writes the meta to the disk.
func (tx *Tx) writeMeta() error {
	// Create a temporary buffer for the meta page.
	buf := make([]byte, tx.db.pageSize)
	p := tx.db.pageInBuffer(buf, 0)
	tx.meta.write(p)

	// Write the meta page to file.
	if _, err := tx.db.ops.writeAt(buf, int64(p.id)*int64(tx.db.pageSize)); err != nil {
		return err
	}
	if !tx.db.NoSync || IgnoreNoSync {
		if err := fdatasync(tx.db); err != nil {
			return err
		}
	}

	// Update statistics.
	tx.stats.Write++

	return nil
}

推荐阅读

更多精彩内容,请扫码关注微信公众号:后端技术小屋。如果觉得文章对你有帮助的话,请多多分享、转发、在看。
二维码

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     807人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     351人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     314人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     433人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     221人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-数据库
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯