MVCC原理

112 阅读7分钟

ACID 简介

  • A (Atomicity) 原子性:一个事务是一个不可分割的最小单位。只有事务中所有的数据库操作都执行成功,整个事务才算成功。事务中任何一个 SQL 语句执行失败,已经执行成功的 SQL 语句也必须撤销,数据库状态应该回退到执行事务前的状态。
  • C (Consistency) 一致性: 事务将数据库从一种正确状态转变为另一种正确的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
  • I (Isolation) 隔离性: 一个事务所做的修改在最终提交之前,对其它事务是不可见的。这个和数据库的隔离级别有关。
  • D (Durability) 持久性: 事务一旦提交,其所做的修改会被永久保存到数据库中。

ACID 里的 AID 都是数据库的特征,也就是依赖数据库的具体实现。而唯独这个 C,实际上它依赖于应用层,也就是依赖于开发者。这里的一致性是指系统从一个正确的状态, 迁移到另一个正确的状态。什么叫正确的状态呢?就是当前的状态满足预定的约束就叫做正确的状态。而事务具备 ACID 里 C 的特性是说通过事务 AID 来保证我们的一致性。

关于如何理解数据库事务中的一致性概念的讨论看这里

多个事务并发执行遇到的问题

关于不可重复读和幻读的区别网上相关的讨论太多了,这里以维基百科为准。

更新丢失 (Lost Update): 最后提交的事务的更新覆盖了其它事务之前的更新。

脏读 (Dirty Read): 一个事务读到了另一个未提交事务修改过的数据。

不可重复读 (Non-Repeatable Read): 在一次事务中,两次读取同一行数据,得到了不同的结果。

幻读 (Phantom Read): 在一次事务中,两次按照相同的条件查询,返回不同的结果集。

事务的隔离级别

数据库事务的隔离级别有 4 中,由低到高分别为:

读未提交(READ-UNCOMMITED): 一个事务还未提交,它做的变更就能被别的事务看到。可能发生脏读、不可重复读和幻读问题。

读已提交(READ-COMMITTED): 一个事务提交以后,它做的变更才能被别的事务看到。可能发生不可重复性读和幻读问题,不会发生脏读问题。

可重复读(REPEATABLE-READ): 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据时一致的。可能发生幻读问题,不会发生脏读和不可重复读问题。

串行化(SERIALIZABLE): 对于同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。各种问题都不会发生。

MVCC 是什么

Multi-Version Concurrency Control,多版本并发控制,一般在数据库管理系统中,解决数据库的多事务并发访问问题。

数据库通常使用锁来实现隔离性,最原生的锁,锁住一个资源后会禁止其它任何线程访问同一个资源。但是很多应用都是读多写少,很多数据的读取次数远大于修改的次数,而读取数据间互相排斥显得不是很必要。所以就使用了一种读写锁的方法,读锁和读锁之间不互斥,读锁和写锁、写锁和写锁之间都互斥,这样很大提升了系统的并发能力。

后来人们发现并发度还是不够,又提出了让读写之间也不冲突的方法,读取数据时通过一种类似快照的方式将数据保存下来,这样读锁和写锁之间就不冲突了,不同的事务会看到自己特定版本的数据,这就是 MVCC。但是 MVCC 并没有一个统一的实现标准,所以不同的数据库,不同的存储引擎的实现都不相同。

InnoDB 中 MVCC 的实现原理

InnoDB 实现 MVCC 是通过在每行记录后面保存两个隐藏的列来实现的。一个保存了行的事务 ID(DB_TRX_ID),一个保存了行的回滚指针(DB_ROLL_PTR)。

  • 每开始一个新的事务,都会自动递增产生一个新的事务id,事务开始时会把 事务id 放到受到当前事务影响的行记录的 事务id 中。
  • 每次对行记录进行改动,都会记录一条 undo 日志,每个 undo 日志也都有一个 roll_pointer,可以将这些 undo 日志都连起来,形成一个链表,称为 版本链。版本链的头结点就是当前记录的最新值,另外,每个版本中还包含生成该版本时对应的 事务id

ReadView

对于使用 READ COMMITTED 隔离级别的事务来说,所有事务直接读取数据库的最新值即可。

对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 规定使用加锁的方式来访问记录。

对于 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,必须保证读到的是已经提交了的事务修改过的记录,也就说假如另一个事务已经修改了记录但未提交,是不能直接读取最新版本的记录的。核心问题就是:需要判断一下版本链中的哪个版本是对当前事务可见的。为此,InnoDB 提交了 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:

  • m_ids:表示生成 ReadView 时当前系统中活跃的读写事务的 事务id 列表。

  • min_trx_id:表示生成 ReadView 时当前系统中活跃的读写事务中最小的 事务id,也就是 m_ids 中的最小值。

  • max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。

    注意 max_trx_id 并不是 m_ids 中的最大值,事务id 是递增分配的。比如说现在有 id 为 1, 2, 3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 ReadView 时,m_ids 就包括了 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。

  • creator_trx_id:表示生成该 ReadView 的事务的 事务id

    只要对表中的记录做改动时(执行 insertdeleteupdate )才会为事务分配 事务id,否则在一个只读事务中的 事务id 都默认为 0。

有了这个 ReadView,在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  • 如果被访问行的 trx_idReadView 中的 creator_trx_id 相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问行的 trx_id 小于 ReadView 中的 min_trx_id,意味着生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问行的 trx_id 大于等于 ReadView 中的 max_trx_id,意味着生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问行的 trx_idReadView 中的 min_trx_idmax_trx_id 之间,那需要先判断一下 trx_id 是不是在 m_ids 列表中。如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性。依次类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意外着该条记录对该事务完全不可见,查询结果就不包含该记录。

MySQL 中,READ COMMITTEDREPEATABLE READ 隔离级别最大的区别就是它们生成 ReadView 的时机不同:

READ COMMITTED:每次读取数据前都生成一个 ReadView

REPEATABLE READ:在第一个读取数据时生成一个 ReadView