走进blotdb的事务实现

245 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

blotdb【etcd底层的kv存储】,本文改编自作者:青藤木鸟 www.qtmuniao.com/2020/11/29/…, 转载请注明出处

事务实现

事务定义:执行:计算层面;逻辑单位:不可分割;操作序列有限:粒度不大;

boltdb 只支持一写多读的事务,即同时至多有一个读写事务,而可以有多个只读事务,算是一种弱化的事务模型,好处在于容易实现,坏处在于牺牲了写并发的性能。也因此,boltdb 适合读多写少的应用。

boltdb 事务实现的主要代码在 tx.go 中,但这个源文件大抵算一个事务实现入口,事务提交时的一些行为,主要在数据库索引逻辑中实现。

1. 持久性

  1. 改动数据刷盘
  • 在一个读写事务中,所有用户的直接改动(增加、删除、改动)都发生在叶子节点,但为了维持 B+ 树的性质,会在 Commit前进行调整,会引起中间节点的级联变动。所有这些节点(Node)在 spill阶段通过 node.write(p)转化为页(Page),所有变动的页(包括复用 freelist 中的和新申请的)称为脏页(dirty pages)。在 spill为 page 后,boltdb 会通过 func (tx *Tx) write() error将这些脏页进行刷盘,大体逻辑为:

    1. 将脏页按 page id 排序后逐个遍历
    2. 将 page id 转化为 offset
    3. 通过 db.ops.writeAt 将脏页在 offset 处刷盘
    4. 通过 page pool 复用 page size = 1 的脏页,以备 allocate 时复用
  1. 元信息刷盘
  • 元信息包括freelist表和整个db的元信息页刷盘

2.一致性

表示写入的数据必须完全符合所有的预设约束触发器级联回滚[3]。举个例子来说,A 给 B 转账,转账前后,A 和 B 的账户总额应该保持不变。

该性质描述侧重于应用层面,而非数据库本身。boltdb 是一个简单的 KV 引擎,不支持用户自定义约束。

3.原子性

在事务中,原子性其实更侧重于出现问题时的可回滚性rollback),或者说可丢弃性abortability),即事务中的操作不能部分执行,要么都成功执行,要么都未执行。

  • 主动

    • 主动调用tx.Rollback进行回滚,其主要逻辑包括回滚使用的 freelist,释放一些资源(如锁和节点内存引用)。只读事务结束时必须要调用回滚函数,以关闭事务,防止对读写事务的阻塞,之前文章分析过原因(主要是争抢 remap 时候的锁)。
  • 被动

    • 在读写事务进行到一半时,如果 boltdb 实例意外挂掉重启后,boltdb 如何保证事务的原子性?

      1. 读写事务执行过程中,所有的改动都是增量改动,不影响其他只读事务
      2. 最后提交时,元信息页落盘成功,才会使得所有增量改动对用户可见
    • 也就是说,使用元信息页作为“全局指针”,以该指针的写入原子性来保证事务的原子性。如果宕机时,元信息页没有写入完成,所有改动便不会生效,达到了自动回滚的效果。

4. 隔离性

4个隔离级别:

  • 未提交读:某个事务读到了另一个未提交事务修改过的记录

    • 脏读:读到了一个不存在的数据,一般发生在回滚时
  • 已提交读:一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值

  • 可重复读:一个事务只能读到另一个已经提交的事务修改过的数据,但是第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,该事务之后再读该条记录时,读到的仍是第一次读到的值,而不是每次都读到不同的数据。

  • 串行化:3种隔离级别都允许对同一条记录进行读-读读-写写-读的并发操作,如果我们不允许读-写写-读的并发操作,可以使用SERIALIZABLE隔离级别

    boltdb 实现隔离性的方法是:

    1. 增量写内存。
    2. 穿透读磁盘。

    读写事务的变动都在内存中,而只读事务通过 mmap 直接读取的磁盘上的内容,因此读写事务的改动不会为只读事务所见。多个读写事务是串行的,也不会互相影响。而每个只读事务期间所看到的状态,就是该只读事务开始执行时的状态。

参考文献

  1. boltdb repo:github.com/boltdb/bolt