CMU 15-445 18-Multi-Version Concurrency Control

Multi-version concurrency control

多版本并发控制协议(常常和2PL或TOO一起实现并发控制)

对于DBMS中的每一个数据,都会去记录数据的所有版本(包括历史版本和当前版本)

DBMS会维护当前所有数据对象的,所有历史版本和当前版本:

  • 当事务写入数据的时候,DBMS会创建该数据对象的一个新的版本
  • 当事务读入数据的时候,DBMS会读取当前事务启动时,该数据存在的最新版本


为什么会有MVCC的思路?

从写数据的角度考虑:

  • 在2PL中,写数据会加上写锁,这会导致其他的事务无法读取该数据
  • 但,有可能其他的事务只是需要读取一个历史版本的数据,那么只要给该事务提供一个原数据的副本,就可以使得两个事务并发执行
  • 从中发现,如果我们可以保留历史版本的数据,那么就可以提高并发度(因为不会阻塞读操作)

从读数据的角度考虑:

  • 在2PL中,读取数据会上读锁,这会导致其他的事务无法写入该数据
  • 但,其他的事务可能只是给原数据添加了一个新的版本,并没有覆盖原来的数据
  • 这样的话,读取数据的一方,可以继续读取原有版本的数据;写入数据的一方,可以继续写入新的版本的数据


总结:

Writers do not block readers(留下当前数据的历史版本,从而事务的写操作就不会阻塞其他事务的读操作)

Readers do not block writers(读取数据的历史版本,写入数据变成为数据添加新的版本,从而读取数据不阻塞写入数据)

Read-only txns can read a consistent snapshot without acquiring locks(只读的事务,相当于无锁的读取一个一致性快照

  • 可以理解为,当前事务对数据的读取,并不受数据库动态的影响
  • 读到的都是事务开始时的那个版本

使用时间戳来记录当前数据的版本号

同时方便DBMS的回滚(time-travel

  • 比如说想要回滚到DBMS三分钟之前的时候的数据,直接指定版本号读取即可



Example

版本链表中的begin表示该版本数据开始的时间,end表示该版本数据截止的时间(如果没有写的话,就代表是至今)

同时,全局会维护一个txn table,用于互相查明事务的状态



Example1

T1读数据会检查当前的版本,发现此时版本为T0,因此就直接读取

T2写数据,发现当前版本小于自身,因此在table中添加一条新的记录(该记录的begin为当前记录的版本号)



此后,T1又要读数据A,就得找到当前事务对应的版本(即历史版本)读取数据



Example2

T1对数据A的读写、T2对数据A的写入,参考Example1(读历史版本,写入新版本)


T2对数据A的读取:

此时T1的数据还没有提交,T2不能读取T1还未提交的数据(避免脏读)

因此T2读取的是A0时刻的数据

PS:可以看出来单单MVCC是无法实现可串行化的(因为可串行化中,T2读取到的应该是T1时刻修改后的数据,但MVCC读到的却是更早的数据)


T2对数据A的写入:

此时T2要修改数据,发现T1有一个未提交的事务数据(即发现了一个未提交的新版本),所以会wait到T1结束,才能继续修改

T1发生在T2之前,那么T2对数据写的版本必然是最终版本

如果T2不阻塞,直接写了新版本的数据

后续T1又重新写了一遍,逻辑上就会出现错误了

所以T2必须要等到T1commit,才能给数据添加上新的版本


T1对数据A的读取:读取当前事务中上一次添加的版本A1时的数据


当T1事务commit以后,T2事务才可以继续往下写数据



Oracle的最高隔离级别快照隔离(snapshot隔离),依靠的就是MVCC

只有MVCC的话,是无法实现可串行化的,所以一般都是结合其他方法实现并发的,比如说TO,OCC,2PL

MVCC不仅仅是并发控制的手段,更是DBMS管理事务的手段

几乎所有的DBMS都实现了MVCC




Concurrenct control protocol

MVCC常常和其他的并发控制协议结合在一起




Version storage

新老版本(不同版本)的数据是如何存储的

DBMS会使用一个指针,接着建立一个链表记录每个版本的版本号



Append only storage

简单追加

每次向DBMS中给数据追加新的版本,都相当于给数据表中追加了一条新的记录,通过指针将不同的版本记录链接起来,组成版本链

所有数据的所有版本,都存放在同一个表(Main Table)里面


追加一个新的数据记录:

首先要插入最新的数据记录

接着找到此时除去当前记录外最新的版本,将该版本的指针指向这个新的数据记录



Main table中链表的两种实现思路


第一种是将新的数据追加到链表后面(Oldest-to-Newest

  • 生成新版本时,将老版本的指针指向新版本
  • 所以访问最新记录时,需要遍历老记录(造成读放大)

第二种是将新的数据追加到链表前面(Newest-to-Oldest

  • 生成新版本时,将新版本的指针指向老版本
  • 所以访问某个版本的记录时,需要从新记录开始遍历到指定版本


Time-travel storage

main table存储的是当前最新的数据,time-travel table存储的是历史的数据

  • main table中的value是最新版本的数据,pointer则指向time-travel table上的历史数据,version是版本号

数据的写入:将老版本的数据复制到time-travel table中,将新版本数据写入main table中,同时调整指针指向(新版本的指针指向老版本)

数据的回滚:通过main table中最新版本的指针,找到对应的历史版本


比如说,总共有十行记录

那么main table就只有十行记录的最新版本,而time-travle table中就可能会存放很多的历史版本(这些历史版本都用链表连在一起)


time-travel也是append-only的存储方式,只是新老版本在不同的表空间中。这种方式有利于回收老版本记录,但同时产生了写放大




Delta storage

如果每次只修改了一点点数据,就要把整份数据都放到time-travel table中,那么无疑是一种浪费

因此一种改进方案就是只存储增量,即存储数据的变化


main table存储原数据,delta storage segment存储每次修改的增量(具体修改了哪些部分)



如果想读取历史版本,就要调用delta storage segment中的记录逐条回滚

优点:可以节约历史版本大小

缺点:查看历史版本(或回滚)复杂度高,即恢复(回滚)的时候需要逐步读delat storage segment(可以理解为用时间换取空间)


PS:MySQL使用的就是这种方法,不过MySQL存储的是undo段




Garbage collection

背景:DBMS不可能无限存储历史的版本,所以要删除掉一些历史版本


垃圾回收的宗旨:

  • 如果任何活跃的事务都看不到这个版本(或是说不需要这个版本),那么这个版本就直接删掉了

    • 例如:有个事务的版本是1,但当前活跃事务的版本都是10以上的,那么事务1的版本就可以删除了

    • 或者说,在快照隔离中,如果所有的事务都不需要它了,就要把它删掉

  • 如果一个事务发生了abort,就需要删除该版本


需要考量的问题:

  • 如何发现这些版本

  • 什么时候删掉历史版本



Tuple level

以元组(行记录)为粒度(范围),清理过期记录

遍历行记录,寻找哪些没有用的行记录


background vacuuming

开启一个后台线程对当前发生的事务和历史版本进行扫描对比,如果有数据的版本是小于当前所有的活跃事务的版本,就要清理掉


优化技巧:

  • 后台线程会对所有的数据进行扫描,浪费时间
  • 而如果当前的数据页被更新过了就标记一下,后续就扫描那些被标记过的数据页即可,而不是全表扫描


cooperative cleaning

合作清理

执行事务的语句在检索版本的时候

同时查看一下有哪些版本是没有用的

发现没有用的版本,就删除



Transaction-level

以事务为单位进行回收,清理旧事务的数据版本


每次修改数据时,同时记录修改数据之前的旧版本

过一段时间后,DBMS决定多少版本号之前的事务都可以干掉了,那么就可以遍历事务,然后把数据的老版本给干掉




Index management

研究在MVCC(多版本)下,如何对索引进行管理

主键索引指向的是版本链表的第一个,并且一般是物理地址(比如说某个数据具体在哪一个page的哪一个slot)

如果一个事务修改了主键的值,就要把数据先删除,后插入


比较麻烦的是辅助索引的处理,下图是辅助索引管理的两大流派:



Logical pointers

键(key)存储的是被索引的那一列的数据

值(value)记录的是逻辑地址(比如说记录的是主键的值,或者行id的值),即逻辑索引

索引指向一个”中间指针”,即逻辑指针,这个中间指针再指向主表存储的元组的位置(某个页面的某个位置)


优点:

  • 如果数据发生了更新,那么只需要修改主键索引的地址(因为辅助索引指向的是主键索引的位置,不用批量修改辅助索引)

  • 对于数据的写入比较友好:如果更新某条记录,则这条记录相关的所有索引都不需要更新,只需要更新”中间指针”指向新的元组的位置即可


缺点:

  • 存在读放大问题,所有索引访问数据都需要先访问”中间指针”,再跳转到实际数据存储位置,即存在回表的过程

PS:MySQL InnoDB就是使用逻辑指针的方式,所有索引都指向主键,通过主键再去访问真实的数据



Physical pointers

键(key)存储的是被索引的那一列的数据

值(value)记录的是物理索引(比如说某个数据具体在哪一个page的哪一个slot)


优点:

  • 对于读比较友好,索引指向元组的实际位置,直接就可以访问到元组,无需通过中间指针进行跳转(不需要回表)

缺点:

  • 如果辅助索引记录的是数据记录的物理地址,那么当有新的版本数据到来的时候,所有的辅助索引上数据记录都要修改(特别是在辅助索引数量很多的情况下,复杂度upup
  • 因此不利于写,如果更新了某条记录的位置,则相关的索引都需要更新,造成写放大

PS:PostgreSQL使用这种方式,所以更新记录时成本较高




MVCC index

DBMS的索引一般是不会保存数据的版本号的

但MVCC有快照,因此索引需要保存冗余的数据(即冗余的键)

即一个键,可能会指向多个值(多个版本)


为什么要存储冗余的数据?

T1第一次读数据A

T2修改了数据A后又删除了数据A

T3在原来的位置插入了新的数据A

问题是:T3插入数据的时候,形成了新的版本

而因为T3认为此时没有数据,此时产生的新版本号是A1

导致版本号出现了重复的情况,那么此时T1重复读取数据的时候,就会出现问题

所以就需要存储冗余的键值,以此实现隔离级别(比如此时的数据A,需要维护T1指向的A1的版本和T3指向A1的版本)


因此每个索引上需要维护多个版本的数据,更需要额外的逻辑去维护唯一约束等问题




MVCC deletes

DBMS一般不会在物理上将数据从数据库中删除

只有当该数据的所有版本在逻辑上被删除(即逻辑上该数据的所有版本都没有用的时候),才会将数据真正删除

因此,一般数据的删除都是指逻辑上的删除,而不是物理内存上的删除(需要保存历史版本,进行一些处理)

所以,需要一些方法来查看数据是否在逻辑上被删除



Deleted flag

在行记录上加上一个列,用于判断数据是否被删除



Tombstone tuple

给这个行记录添加一个新的版本(是没有数据的),相当于是一个墓碑

代表这个版本之前所有的版本,都是被删掉的

而在这个墓碑之后新的版本,是正常添加的




MVCC implementations

比较core的部分:

protocol(MVCC和什么手段结合)

version storage(版本管理,版本存储的方案)

garbage collection(垃圾回收)

辅助索引(是logical还是physical的)




Conclusion

几乎所有的关系型数据库都实现了MVCC,但一般是搭配着其他的并发控制协议使用(比如2PL,OCC等)

当然,也有部分NoSQL也在使用,比如RocksDB




Thinking

关于读写冲突(写读冲突,同理)

  • 普通2PL,因为有事务A先拿了读锁,以至于另一个事务B拿不了写锁,另一个事务B必须等到事务A放锁了以后,才能继续往下走拿到写锁
  • 而MVCC,事务A读不加锁,直接读,然后事务B写一个新的版本放入storage,接着如果事务A继续读的话,读历史版本即可
  • 由此可见2PL和MVCC都可以解决读写冲突,但MVCC没有锁,没有阻塞,提高了效率和并发度

而因为是用了类似TO的时间戳,MVCC也解决了写写冲突的问题



现有的2PL、严格2PL + 间隙锁就可以实现很多的隔离级别,为什么还需要MVCC?

  • 我觉得使用MVCC可以提高并发度,一定程度的不阻塞读(依靠的是历史版本数据)
  • 2PL其实是可以实现的,但是会减损一些并发度,所以要使用MVCC(使得原本的范围变得宽松而合理一些)
  • 从另一个角度说,MVCC需要2PL,因为单靠MVCC无法实现可串行化(参考Example2)

 上一篇
CMU 15-445 19-Database Logging CMU 15-445 19-Database Logging
Motivation从事务的角度来说,事务commit之后,使用者就会认为处理好的数据就被放到硬盘上了(并不管底层是如何实现的) 但实际上,出于对性能的考量(硬盘和磁盘的访问速度有明显差距) 实时修改的数据页是不会立刻写入到磁盘上的 而是优
2022-12-12
下一篇 
CMU 15-445 17-Timestamp Ordering Concurrency Control CMU 15-445 17-Timestamp Ordering Concurrency Control
Background并发控制的两个流派:悲观的2PL,乐观的OCC 上一章讲到的2PL其实是一种悲观的并发控制协议 它假设未来所有的事务都会发生竞争,所以在操作每一条SQL的时候都会提前加上锁 即在问题发生之前解决问题,阻止问题的发生
2022-11-28
  目录