rollback transaction什么意思_keyvalue数据库bbolt深入分析:事务 (Transaction)

本文深入剖析bbolt数据库如何实现事务的ACID属性,包括只读和读写事务的隔离,通过文件锁和DB读写锁确保并发控制,利用Copy-on-write保证原子性,并详细解释了事务提交和回滚的机制,阐述了事务原子性的三个关键点。
摘要由CSDN通过智能技术生成

前几篇文章主要介绍了bbolt数据文件的存储及索引格式。本文主要分析bbolt是如何实现事务的。

由于事务具有ACID属性,所以一个事务作为一个整体,要么全部成功,要么全部失败回滚,永远不存在部分成功。关于事务的概念,网上有很多资料,就不啰嗦了。零君以前也写过一个系列的相关文章:

分布式数据系统(系列一):基本概念

由于事务的存在,极大的简化了上层应用开发的复杂度,因为复杂的逻辑都被数据库处理了。事务绝对是二十世纪数据库领域最伟大的发明之一。

但是事务并不是一个天然就存在的东西,归根结底,还是需要底层数据库去实现支持事务这个概念,否则这个概念只是空中楼阁。本文就是要深入探讨bbolt是如何实现事务这个概念的,其实主要是分析bbolt是如何实现事务之间的隔离性,并保证事务的原子性。本文是这个系列的第四篇文章。

文件锁和DB读写锁

bbolt的事务分为只读(read-only)事务和读写(read-write)事务。顾名思义,只读事务只能查询,不能修改数据;而读写事务则既可以查询,也可以修改数据。

使用DB.View函数可以启动一个只读事务:

err := db.View(func(tx *bolt.Tx) error {  ...  return nil})

使用DB.Update则可以启动一个读写事务:

err := db.Update(func(tx *bolt.Tx) error {  ...  return nil})


多个只读事务可以同时并行执行,甚至不同进程的只读事务也可以并行执行。但任何时刻只允许一个读写事务执行,不管是一个进程内,还是跨进程,都不允许多个读写事务针对同一个bbolt数据库同时执行。

bbolt是如何保证在跨进程的场景下只允许一个读写事务呢?换句话说,如果一个进程正在执行一个读写事务,bbolt是如何阻止另一个进程针对同一个bbolt文件执行读写事务?答案其实很简单,就是文件排他锁(exclusive lock)

如果需要更改bbolt中的数据,那么在打开数据文件时,需要获取文件的排他锁。排他锁需要借助于内核对象,所以只能使用系统调用来完成

err := syscall.Flock(int(fd), syscall.LOCK_NB | syscall.LOCK_EX)

当另一个进程打开同一个数据文件后,再次调用syscall.Flock尝试获取该文件的排他锁时,则会被阻塞,一直到其它进程释放占有的排他锁。

注:通过flock系统调用锁只是建议锁(advisory lock),不具有强制性。另一个进程可以直接修改正在被锁的文件中的数据。

如果只是查询数据库中的数据,则应该获取数据文件的共享锁:

err := syscall.Flock(int(fd), syscall.LOCK_NB | syscall.LOCK_SH)

另外,为了防止同一个进程内的多个goroutine(协程)修改同一个DB实例中的数据,bbolt则是通过DB读写锁来控制的。当用户程序通过bbolt.Open(...)打开数据文件时,会返回一个DB实例,其中就包含一个DB读写锁:

type DB struct {  ......  rwlock   sync.Mutex   // Allows only one writer at a time.   ......} 

在使用DB.Update启动一个读写事务的过程中,会获取该锁:

// Obtain writer lock. This is released by the transaction when it closes.// This enforces only one writer transaction at a time.db.rwlock.Lock()

在读写事务结束时,会释放该锁:

tx.db.rwlock.Unlock()

这里小结一下,bbolt通过两个锁(文件排他锁以及DB读写锁)实现了读写事务之间的隔离,保证了每一个事务在其执行期间总能看到一致的数据视图

Copy-on-write
在事务的执行期间,不管对数据做过什么修改,最后提交事务(commit)时,修改的数据才会持久到存储介质。这就意味着,在提交这个时间点之前的所有操作,都是在内存中进行,对持久存储介质上的数据没有任何影响。

这里用到的关键技术就是Copy-on-write。当在读取数据的时候,开始其实还是直接从硬盘上读取。但是一旦要修改数据时,则会将数据所在的页面全部加载到内存中,随后的操作都是在内存中的node上进行。

在bbolt中,page和node是两个紧密联系的概念。page是指数据文件中的一个页面,而node是page在内存中的代表,并且是反序列化后的页面。关于这一点,在后续的文章还会提到。

因为在提交(commit)之前所做的任何修改,都只存在于内存中,所以提交之前如果发生任何错误,只需要reset内存,即可轻松rollback事务

显然Copy-on-write对于保证事务的原子性起到了很重要的作用。但是别高兴得太早,仅仅实现了Copy-on-write还不能完全保证原子性,因为事务提交(commit)时如果发生错误处理不当,同样会破坏事务的原子性。

接下来的一节就是讲述bbolt如何巧妙地保证了事务提交(commit)时,遇到任何情况都不会破坏原子性。

事务提交(Commit)

本节将揭开神秘的commit的面纱,看看它到底是如何保证了事务的原子性(atomicity)。首先来看看Commit到底做了哪些事情:

c1ff20bd78832e603b64b95a06e99959.png

第一步就是rebalance。当对node中的数据做了大量删除之后,那么就会尝试合并几个node为一个node。这里一个基本判断标准就是:当一个node中的数据还不足一个页面的1/4,就会尝试与别的node合并。

第二步是spill。这一步主要是将大node分裂成一个page能容纳的小node,并将每个node序列化成最终的page格式,为后续的同步数据到文件做准备。因为bbolt采用Copy-on-write的方式,每一个内存中的node必然对应一个文件中的page。因为每一个node最终会保存到文件中的一个新的位置(page),所以会释放node之前所在的page ID,并申请一个新的page ID。这么做是非常巧妙的一个设计,是保证事务原子性的第一个关键点。具体原因后文会讲到。

第三步是commitFreelist。其实第三步的处理逻辑与第二步完全一样,只是处理的对象不同。第二步处理的是key-value数据,而这一步处理的是freelist。

关于freelist的具体定义以及作用,请参考本系列的前一篇文章。

到目前为止,前面所有的步骤操作的都是内存中的数据,对数据库文件中的数据没有任何影响。

第四步是write。这一步会依次同步内存中所有的页面到文件中。这一步会实际修改数据库文件中的数据。让我们来看一个示意性的例子。假设数据库文件中B+树的结构如下图所示。现在leaf page 8中有数据被修改了,那么从root page到当前leaf page这一条路径上的page都会被加载到内存中的node。

f723a0462066fbe79a419e637989bba4.png

由于每一个node会分配新的page ID,老的page ID会被释放,所以最后文件中B+树的结构会变成下面这样。背景为灰色的page被释放了,而背景为橙色的page是新分配的。

9bdf28db89cc1e22a69cb7f37d6bef83.png

第五步是writeMeta。就是同步meta数据到文件中。metadata中的root pageID会指向新的root page ID, freelist也会指向新的freelist pageID。

最后一步就是关闭事务,释放DB读写锁,reset内存,从而完成了整个commit过程。

原子性

不难看出,commit整个过程的前三步都是操作内存中的数据,是安全的,从第四步开始会修改数据文件的内容。

看到这里,估计很多人开始产生疑问了,在第四步中,如果由于某种意外的原因(比如断电)导致同步失败怎么办?或者一些页面同步成功,而另一些页面同步失败,又怎么办?其实前面讲到的第一个关键点保证了在第四步发生任何意外,都不会破坏硬盘文件中的数据。因为修改的页面都会保存到新的位置,也就意味着老的页面没有被覆盖,而且metadata中的root pageID和freelist pageID还是保存的老的值

由于所有的数据会保存到新的位置,所以老的page ID都会被释放。但是,所有释放的page ID不会直接加入freelist,而只会被加入到pending列表。这就意味着,这些释放的page ID在当前transaction中还不能被复用,只有到了下一个transaction才能被重新分配使用。这就有效保证了老数据在当前transaction中不会被覆盖。这就是保证事务原子性的第二个关键点

你可能会追问,就算第四步是安全的,那第五步呢?如果在同步metadata的时候发生任何意外怎么办?在本系列的第一篇文章中就讲过,bbolt的文件header中有两个metadata page,也就是说page 0和page 1都是metadata page。metadata page中包含txid,也就是transaction ID,这个值是单调递增的。开始的时候,两个page中的txid的值分别是0和1。

每当创建一个读写事务时,会加载其中一个metadata page到内存中。具体加载两个中的哪个呢?很简单,选择txid较大的哪一个。但是如果这个metadata page的checksum错误,就意味着这个页面被破坏了,这时会选择另一个metadata page。但是最后同步内存中的metadata数据到文件时,又会同步到txid较小的那个metadata page。 所以会交替保存metadata数据到两个page。大致示意如下:

In the beginning, page 0 txid: 0, page 1 txid: 1txid 3: load from page 1, save to page 0; Result txid: 2, 1txid 4: load from page 0, save to page 1; Result txit: 2, 3txid 5: load from page 1, save to page 0; Result txit: 4, 3...

看到这里,是不是恍然大悟?因为会交替保存metadata数据到两个metadata page。所以如果同步metadata数据过程中发生任何意外,那么当前metadata的checksum肯定错误。那么后续起作用的是另外一个checksum正确的metadata page。这就是保证事务原子性的第三个关键点

Rollback(回滚)

不管哪一步发生错误,只需要重新加载freelist即可。之前释放的page都会被撤销。因为老数据没有被覆盖,metadata page又是双保险,所以保证了事务的原子性。唯一的副作用就是数据文件的大小可能会变大。变大的空间在后续的transaction中可以被复用,所以不是什么大问题

小结

以上红色标注的三个关键点巧妙地保证了事务的原子性。在同步数据到文件的时候,会分配新的页面,而老的页面虽然被释放了,但只有到下一个transaction才能被复用,所以这就保证了在当前transaction中,老的页面永远不会被覆盖。

而metadata又有两个page,交替使用,如果最后同步metadata失败,下一次使用另一个checksum正确的page即可。

--END--

相关文章

key-value数据库bbolt深入分析:简介、文件头格式

key-value数据库bbolt深入分析:B+树

key-value数据库bbolt深入分析:Bucket & Freelist

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值