多版本并发控制(Multi-Version Concurrency Control, MVCC)指的是一种提高并发的技术。MVCC的实现方式有多种, 典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制;
多版本并发控制是InnoDB存储引擎实现隔离级别的一种具体方式,用于实现 已读提交和可重复读 两种隔离级别。读未提交隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
MVCC 可以保证一致性非锁定读。但是,MVCC 并没有对实现细节做约束,为此不同的数据库的语义有所不同,比如:
- Postgres 对写操作也是乐观并发控制。在表中保存同一行数据记录的多个不同版本,每次写操作,都是创建,而回避更新。在事务提交时,按版本号检查当前事务提交的数据是否存在写冲突,冲突则抛异常告知用户,回滚事务;
- InnoDB 则对读无锁,写操作仍是上锁的悲观并发控制,这也意味着,InnoDB 中只能见到因死锁和不变性约束而回滚,而见不到因为写冲突而回滚。在 InnoDB 内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。不像 Postgres 那样对数据修改在表中创建新纪录,而是每行数据只在表中保留一份,在更新数据时上行锁,同时将旧版数据写入 undo log,未提交事务会直接更新数据页上数据,回滚时使用 undo log 中的数据覆盖数据页上的数据;表和 undo log 中行数据都记录着事务ID,在检索时,只读取当前已提交的事务的行数据(读已提交和可重复读隔离级别);可见 MVCC 中的写操作仍可以按悲观并发控制实现,而 CAS 的写操作只能是乐观并发控制。还有一个不同在于,MVCC 在语境中倾向于 “对多行数据打快照造平行宇宙”,然而 CAS 一般只是保护单行数据而已。比如 Mongodb 有 CAS 的支持,但不能说这是 MVCC。
事务版本号
每次事务开启前都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。
- 系统版本号: 是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
- 事务版本号: 事务开始时的系统版本号。
在InnoDB中,每一行都有2个隐藏列DATA_TRX_ID
和DATA_ROLL_PTR
,整个MVCC的关键就是通过DATA_TRX_ID
和DATA_ROLL_PTR
这两个隐藏列来实现:
- DATA_TRX_ID: 表示最近修改该行数据的事务ID,包括创建事务版本号和删除事务版本号
- DATA_ROLL_PTR: 该行数据所有旧的版本,在 undo 中都通过链表的形式组织,而该值正是指向 undo 中该行的历史记录链表
行数据版本号变更
- SELECT:在可重复读隔离级别下,InnoDB会根据以下条件检查每一行记录,只有符合下面两个条件的才会被查询出来:
- 只查找版本早于(不一定是小于)当前事务版本的数据行,这样可以确保事务读取的行要么是在开始事务之前已经存在要么是事务自身插入或者修改过的,在事务开始之后才插入或修改的数据行,事务不会看到。
- 行的删除版本号要么未定义,要么大于当前事务版本号,这样可以确保事务读取到的行在事务开始之前未被删除,在事务开始之前就已经过期的数据行,该事务也不会看到。
-
INSERT:将当前系统版本号作为数据行快照的创建版本号。
-
DELETE:将当前系统版本号作为数据行快照的删除版本号。
- UPDATE:将当前系统版本号作为待更新数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。保存这两个版本号,使大多数操作都不用加锁。使数据操作简单,性能很好,并且能保证只会读取到符合要求的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作和一些额外的维护工作。
事务链表
MySQL 中的事务在开始到提交这段过程中,都会被保存到一个叫 trx_sys 的事务链表中,这是一个基本的链表结构:
事务链表中保存的都是还未提交的事务,事务一旦被提交,则会被从事务链表中摘除。
ReadView
ReadView 是一个数据结构,包含了 3 个主要的成员:ReadView{low_trx_id, up_trx_id, trx_ids}
:
- low_trx_id:当前系统中创建最早但还未提交的事务,即当前事务链表中最小的事务 id 编号
- up_trx_id:当前系统中创建最晚但还未提交的事务,即当前事务链表中最大的事务 id 编号
- trx_ids:当前系统活跃(未提交)事务版本号集合,即所有事务链表中事务的 id 集合
这个快照是基于整库的。
读已提交
RC(Read Commit) 级别下同一个事务里每一次查询都会获得一个新的 ReadView。这样就可能造成同一个事务里前后读取数据可能不一致(不可重复读)。
- 如果数据行上
DATA_TRX_ID
小于 low_trx_id,说明修改该行的事务在当前查询之前已经提交完成,所以对当前查询来说,是可见的。 - 如果数据行上
DATA_TRX_ID
大于 up_trx_id,说明修改该行的事务在当前查询之后,所以对当前查询来说是不可见的。 - 如果数据行上
DATA_TRX_ID
存在于 trx_ids 集合中,而且又不等于自身事务 id, 那就说明 ReadView 产生的时候数据还没有提交,又不是自己生成的,所以这种情况下此数据不能显示。
可重复读
RR(重复读)级别下的一个事务里只会获取一次 ReadView(第一次查询时),从而保证每次查询的数据都是一样的。
- 如果数据行上 DATA_TRX_ID 小于 low_trx_id,说明修改该行的事务在当前事务开启之前已经提交完成,所以对当前事务来说,都是可见的。
- 如果数据行上 DATA_TRX_ID 大于 up_trx_id,说明修改该行记录的事务在当前事务之后,所以对于当前事务来说是不可见的。
- 如果数据行上 DATA_TRX_ID 存在于 trx_ids 集合中,而且又不等于自身事务 id, 那就说明 ReadView 产生的时候数据还没有提交,又不是自己生成的,所以这种情况下此数据不能显示。
读未提交
READ_UNCOMMITTED 级别的事务不会获取 ReadView 副本。
相关概念
快照读
在 InnoDB 可重复度隔离级别下,当执行 select 操作时会执行一致性非锁定读。快照在第一次执行快照读时候生成(ReadView生成时)。也就是说假设当 A 开启了事务,然后没有执行任何操作,这时候B insert 了一条数据然后 commit, 这时候 A 执行 select,那么返回的数据中就会有 B 添加的那条数据。之后无论再有其他事务 commit 都没有关系,因为快照已经生成了,后面的 select 都是根据快照来的。使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。
当前读
对数据修改的操作(update、insert、delete)都是采用当前读的模式,即加锁读取最新的记录,防止并发修改造成冲突。以下第一个语句需要加 S 锁,其它都需要加 X 锁。
1
2
3
4
5
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update;
delete;
更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。除此之外,select语句如果加锁(包括读锁和写锁),也是当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待
一致性非锁定读
InnoDB存储引擎通过 “行多版本控制” 的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行 delete 或 update 操作,这时读取操作不会因此去等待行上锁的释放。相反的,InnoDB 会去读取行的一个快照数据。这是默认的读取方式,即读取不会占用或等待表上的锁。
一致性非锁定读 是指读时不需要等待访问的行上 X 锁的释放。
在事务隔离级别 Read Commited 和 Repeatable Read 下,InnoDB 存储引擎使用非锁定的一致性读。
- Read Committed: 读取行的最新版本,如果行被锁定了,读取被锁定行的最新一份快照数据。其违反了 ACID 中的 I (隔离性)。
- Repeatable Read: 读取事务开始时的行版本数据。
快照数据是指该行的之前版本的数据,一个行记录可能有不止一个快照数据。该实现是通过 undo段 来实现,而 undo 用来在事务中回滚数据。读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
一致性锁定读
显示地对数据库读取操作进行加锁以保证数据逻辑的一致性:
- select … for update: 对读取的行记录加 X 锁
- select … lock in share mode: 对读取的行记录加一个 S 锁
解决幻读问题
- 快照读:通过 MVCC 来进行控制的,不用加锁。按照 MVCC 中规定的“语法”进行增删改查等操作,以避免幻读。
- 当前读:通过 next-key 锁(行锁+gap锁)来解决问题的。
参考文献
参考 乐观锁和 MVCC 的区别? - fleuria的回答 - 知乎 数据库基础(四)Innodb MVCC实现原理 全网最全一篇数据库MVCC详解,不全你打我