MySQL、InnoDB与MVCC

多版本并发控制

多版本控制: 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

MySQL的大多数事务性存储引擎实现的都不是简单地行级锁,基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)。这不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。

InnoDB数据行结构

在 InnoDB 中,每一行都有3个隐藏字段

  • 6字节的事务ID(DB_TRX_ID)字段: 用来标识最近一次对本行记录做修改(insert|update)的事务的标识符, 即最后一次修改(insert|update)本行记录的事务id。
    至于delete操作,在innodb看来也不过是一次update操作,更新行中的一个特殊位将行表示为deleted, 并非真正删除。

  • 7字节的回滚指针(DB_ROLL_PTR)字段: 指写入回滚段(rollback segment)的 undo log record (撤销日志记录记录)。如果一行记录被更新, 则 undo log record 包含 ‘重建该行记录被更新之前内容’ 所必须的信息。

  • 6字节的DB_ROW_ID字段: 包含一个随着新行插入而单调递增的行ID, 当由innodb自动产生聚簇索引时,聚簇索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。

事务链表

MySQL中的事务在开始到提交的生命周期中,都会保存到一个叫trx_sys的事务链表中

trx_sys.png

需要注意的是,trx_sys链表保存的是尚未提交的事务,事务一旦提交,则会从trx_sys中移除。

ReadView

ReadView是MySQL用来做可见性判断的数据结构,用来表示“本事务不可见的当前其他活跃事务”。是实现MVCC的重要数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
dulint    low_limit_id;    /* 事务号 >= low_limit_id的记录,对于当前Read View都是不可见的 */

dulint up_limit_id; /* 事务号 < up_limit_id ,对于当前Read View都是可见的 */

ulint n_trx_ids; /* Number of cells in the trx_ids array */

dulint* trx_ids; /* Additional trx ids which the read should

not see: typically, these are the active

transactions at the time when the read is

serialized, except the reading transaction

itself; the trx ids in this array are in a

descending order */

dulint creator_trx_id; /* trx id of creating transaction, or

(0, 0) used in purge */

通过定义,可以知道这个数据结构主要包含三个主要的成员变量:low_limit_id、up_limit_id、trx_ids。在并发情况下,一个事务在启动时,trx_sys链表中存在部分还未提交的事务,那么哪些改变对当前事务是可见的,哪些又是不可见的,这个需要通过ReadView来进行判定。

up_limit_id:当前已经提交的事务号 + 1,事务号 < up_limit_id ,对于当前Read View都是可见的。理解起来就是创建Read View视图的时候,之前已经提交的事务对于该事务肯定是可见的。

low_limit_id:当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前Read View都是不可见的。理解起来就是在创建Read View视图之后创建的事务对于该事务肯定是不可见的。

另外,trx_ids为活跃事务id列表,即Read View初始化时当前未提交的事务列表。所以当进行RR读的时候,trx_ids中的事务对于本事务是不可见的(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。理解起来就是创建RV时,将当前活跃事务ID记录下来,后续即使他们提交对于本事务也是不可见的。

example(引用 zhangyachen的GitHub issue)

步骤 1 2 3
begin
begin
insert into test(score) values(1607); 假设此时事务号21
insert into test(score) values(1607); 此时事务号22

此时创建读视图,up_limit_id = 21, low_limit_id = 23 活跃事务列表为(21,22)
insert into test(score) values(1620); 事务号为23
insert into test(score) values(1621); 事务号为24
insert into test(score) values(1622); 事务号为25
select * from test; 此时的up_limit_id 为21,low_limit_id 为26,活跃事务列表为(21,22),故21,22在活跃事务列表不可见
select * from test; 此时low_limit_id为26,up_limit_id 为21,活跃事务列表是(21,22) 22本事务自身可见。21的在活跃事务列表不可见。23,24不在活跃事务列表,可见
十一 select * from test; 事务内readview不变,low_limit_id = 23,up_limit_id = 21,活跃事务列表 (21,22)。故21自身可见,22在活跃事务列表不可见。>=23的都不可见

ReadView的创建时机

  • 在innodb中(默认repeatable read级别), 事务在begin/start transaction之后的第一条select读操作后, 会创建一个快照(read view), 将当前系统中活跃的其他事务记录记录起来;
  • 在innodb中(默认repeatable committed级别), 事务中每条select语句都会创建一个快照(read view);

undo-log

Undo log是InnoDB MVCC事务特性的重要组成部分。当我们对记录做了变更操作时就会产生undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可以使用独立的Undo 表空间。

Undo记录中存储的是老版本数据,当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着undo链找到满足其可见性的记录。当版本链很长时,通常可以认为这是个比较耗时的操作(例如bug#69812)。

大多数对数据的变更操作包括INSERT/DELETE/UPDATE,其中INSERT操作在事务提交前只对当前事务可见,因此产生的Undo日志可以在事务提交后直接删除,而对于UPDATE/DELETE则需要维护多版本信息,在InnoDB里,UPDATE和DELETE操作产生的Undo日志被归成一类,即update_undo

另外, 在回滚段中的undo logs分为: insert undo log 和 update undo log

  • insert undo log : 事务对insert新记录时产生的undolog, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
  • update undo log : 事务对记录进行delete和update操作时产生的undo log, 不仅在事务回滚时需要, 一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。

可见性比较算法

设要读取的行的最后提交事务id(即当前数据行的稳定事务id)为 trx_id_current、当前新开事务id为 new_id、当前新开事务创建的快照read view 中最早的事务id为up_limit_id, 最迟的事务id为low_limit_id(注意这个low_limit_id=未开启的事务id=当前最大事务id+1)

  • 如果记录上的trx_id小于read_view_t->up_limit_id,则说明这条记录的最后修改在readview创建之前,因此这条记录可以被看见。
  • 如果记录上的trx_id大于等于read_view_t->low_limit_id,则说明这条记录的最后修改在readview创建之后,因此这条记录肯定不可以被看见。
  • 如果记录上的trx_id在up_limit_id和low_limit_id之间,且trx_id在read_view_t->descriptors(descriptors, 这是一个数组,里面存了readview创建时候所有全局读写事务的id,除了事务自己做的变更外,此readview应该看不到descriptors中事务所做的变更。)之中,则表示这条记录的最后修改是在readview创建之时,被另外一个活跃事务所修改,所以这条记录也不可以被看见。如果trx_id不在read_view_t->descriptors之中,则表示这条记录的最后修改在readview创建之前,所以可以看到。

当前读和快照读

  1. MySQL的InnoDB存储引擎默认事务隔离级别是RR(可重复读), 是通过 “行排他锁+MVCC” 一起实现的, 不仅可以保证可重复读, 还可以部分防止幻读, 而非完全防止;
  2. 为什么是部分防止幻读, 而不是完全防止?
    • 说明: 在MySQL可重复读的隔离级别中并不是完全解决了幻读的问题,而是解决了读数据情况下的幻读问题。而对于修改的操作依旧存在幻读问题,就是说MVCC对于幻读的解决时不彻底的。
    • 因为在innodb中的操作可以分为当前读(current read)和快照读(snapshot read):
  3. 快照读(snapshot read)

    在RR级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,不是数据库最新的数据。这种读取历史数据的方式,我们叫它快照读 (snapshot read)

    • 简单的select操作(当然不包括 select … lock in share mode, select … for update)
  4. 当前读(current read)

    读取数据库最新版本数据的方式,叫当前读 (current read)

    • select … lock in share mode
    • select … for update
    • insert
    • update
    • delete

但是,需要注意的是,虽然mvcc无法完全防止幻读,但是InnoDB是可以完全防止幻读,在RR级别下当前读的情况下,mysql通过next-key来避免幻读。

MVCC的作用范围

MVCC只在 REPEAZTABLE READ 和 READ COMMITTED 两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容。因为READ UNCOMMITTED 总是读取最新的数据行,而不是符合当前事务版本的数据行;而 SERIABLIZABLE 则会对所有读取的行都加锁。

参考资料