MySQL 事务的隔离级别的实现

多版本并发控制-MVCC

多版本并发控制(Multi-Version Concurrency Control, MVCC)以乐观锁为理论基础,和基于锁的并发控制最大的区别和优点是:读不加锁,读写不冲突

事务版本号

每次事务开启前都会从数据库获得一个自增长的事务 id,可以从事务 id 判断事务的执行先后顺序。

隐藏字段

InnoDB 存储引擎为每行记录都添加了 3 个隐藏字段:

  • DB_ROW_ID:数据行 id,用于标识一行数据。并不是必要的,如果创建的表中有主键或者非 NULL 唯一键时都不会包含 DB_ROW_ID 列

  • DB_TRX_ID:表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头中的 deleted_flag 字段将其标记为已删除

  • DB_ROLL_PTR:当前数据记录的上一个版本的指针。每次对某条数据记录进行改动时,都会把旧版本数据记录按照一定格式写入到回滚日志 (undo log) 中,而 DB_ROLL_PTR 列则保存了该旧版本数据记录在回滚日志中的位置,相当于一个指针。

undo log

MVCC 将每一个更新的数据标记一个版本号,在更新时进行版本号的递增,插入时新建一个版本号,同时旧版本数据存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

假设在 MySQL 创建一个表 user,包含主键 id 和一个字段 name。我们先插入一个数据行,然后对该数据行执行两次更新操作。

1
2
3
INSERT INTO user(id, name) VALUES(1, "a");
UPDATE name SET name="b" WHERE id=1;
UPDATE name SET name="c" WHERE id=1;

因为没有使用 START TRANSACTION 将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务。

MySQL_8

undo log 主要有两个作用:

  • 当事务回滚时用于将数据恢复到修改前的样子,保证事务原子性。
  • 另一个作用是实现 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读

快照(ReadView)

ReadView 结构

1
2
3
4
5
6
7
8
class ReadView {
private:
trx_id_t m_low_limit_id; /* 大于这个 id 的事务均不可见 */
trx_id_t m_up_limit_id; /* 小于这个 id 的事务均可见 */
trx_id_t m_creator_trx_id; /* 创建该 ReadView 的事务 id */
ids_t m_ids; /* 创建 ReadView 时的活跃事务列表 */
m_closed; /* 标记 ReadView 是否 close */
}

ReadView 主要有以下字段:

  • m_creator_trx_id:创建该 ReadView 的事务 id

  • m_ids:ReadView 创建时其他未提交的活跃事务 id 列表。创建 ReadView 时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的数据,对于当前事务也是不可见的。注意 m_ids 不包括当前事务自己和已提交的事务(正在内存中)

  • m_low_limit_id:目前出现过的最大的事务 id+1,即下一个将被分配的事务 id。

  • m_up_limit_id :活跃事务列表 m_ids 中最小的事务 id,如果 m_ids 为空,则 m_up_limit_id 等于 m_low_limit_id。

数据可见性算法

InnoDB 存储引擎在开启一个新事务后,执行每个 select 语句前,都会创建一个 ReadView,ReadView 中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 id 号,即系统中当前不应该被该事务看到的其他事务 id 列表 m_ids。当用户在该事务读取某行记录时,InnoDB 会将该行记录的 DB_TRX_ID 与 ReadView 中的一些变量及当前事务 id 进行比较,判断是否满足可见性条件:

  • DB_TRX_ID < m_up_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的

  • DB_TRX_ID >= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见

  • m_ids 为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的

  • m_up_limit_id <= DB_TRX_ID < m_up_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行二分查找(m_ids 是有序的):

    • 在 m_ids 中找到 DB_TRX_ID,表明在当前事务创建快照前,该记录行的值被事务 id 为 DB_TRX_ID 的事务修改了,但没有提交;或者在当前事务创建快照后,该记录行的值被事务 id 为 DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的
    • 在 m_ids 中找不到 DB_TRX_ID,表明是事务 id 为 DB_TRX_ID 的事务在修改该记录行的值后,在当前事务创建快照前就已经提交了,所以记录行对当前事务可见

在记录行快照不可见的情况下,在该记录行的 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 DB_TRX_ID 再重新开始判断,直到找到可见的快照版本或返回空。

事务可见性示意图:

MySQL_9

快照读 & 当前读

快照读

快照读只是针对于目标数据的版本号小于等于当前事务的版本号,也就是说读数据的时候可能读到旧数据,但是这种快照读不需要加锁。也就是说,使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。

1
select * from table ...;

当前读

当前读是读取当前数据的最新版本,但是更新等操作会对数据加锁,所以当前读需要获取记录的行锁,存在锁争的问题。以下第一个语句需要加 S 锁,其它都需要加 X 锁。

1
2
3
4
5
select * from table where ? lock in share mode; # 加 S 锁
select * from table where ? for update;
insert;
update;
delete;

MySQL 中事务隔离级别的实现

1. 可串行化(SERIALIZABLE)

读加共享锁(S),写加排他锁(X),读写互斥。

使用的是悲观锁的理论,实现简单,数据更加安全。

2. 提交读(READ COMMITTED) 和可重复读(REPEATABLE READ)

RR 是 InnoDB 存储引擎的默认事务隔离级别。

RC 和 RR 都是基于 MVCC 实现的,但生成快照的时机不同:

  • RC 级别下。每次 select 查询前都会生成一个 ReadView。有可能会出现一个事务中两次读到了不同的结果。
  • RR 级别下。只在事务开始后, 第一次 select 查询前生成一个ReadView。

3. 未提交读(READ UNCOMMITTED)

总是读取最新的数据行,无需使用 MVCC。

InnoDB 行锁算法

Next-Key Locks 是 MySQL 的 InnoDB 存储引擎的一种锁实现。

Record Locks

锁定一个记录上的索引,而不是记录本身

如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用。

Gap Locks

锁定索引之间的间隙,但是不包含索引本身

例如当一个事务执行以下语句,其它事务就不能在 c 中插入 15。

1
SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;

Next-Key Locks

它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙,是一个前开后闭区间。例如一个索引包含以下值:10, 11 and 20,那么就需要锁定以下区间:

1
2
3
4
(-∞, 10]
(10, 11]
(11, 20]
(20, +∞)

如下图所示:

MySQL_10.png

几个问题

问题一:对主键索引或唯一索引会使用间隙锁吗?

不一定。视情况而定:

  • 如果 where 条件全部命中(不会出现幻读),则不会加间隙锁,只会加记录锁

  • 如果 where 条件部分命中 / 全都不命中,则会加间隙锁

1
2
3
delete from tb where id = 9
-- Table: tb(name primary key,id unique key)
-- key 是唯一索引

根据 id=9 条件定位,此时给 id = 9 的索引加上记录锁,根据 name 值(name是主键)到主索引中检索获得记录,再给该记录加上记录锁。

db_14

问题二:间隙锁是否用在非唯一索引的当前读中?

是的。

1
2
3
delete from tb1 where id = 9
-- Table: tb1(name primary key,id key)
-- key 是非唯一索引

db_15

可以看出,在 (6,9]、(9,11] 加了间隙锁。

问题三:间隙锁是否用在不走索引的当前读中?

是的。

1
2
3
delete from tb2 where id = 9
-- Table: tb2(name primary key,id)
-- 没有为 id 建立索引

db_16

此时对所有的间隙都上锁(功能上相当于锁表)。

总结以上三个问题,我们得到如下结论:

  • 主键索引 / 唯一索引:

    如果 where 条件全部命中(不会出现幻读),则不会加间隙锁,只会加记录锁

    如果 where 条件部分命中 / 全都不命中,则会加间隙锁

  • 非唯一索引:

    会加间隙锁

  • 不走索引:

    对所有间隙都加间隙锁,相当于锁表

解决幻读问题

InnoDB 存储引擎在 RR 级别下通过 MVCC + Next-key Lock 来解决幻读问题:

  • 执行 select * from table ...; 会以 MVCC 快照读的方式读取数据。

    在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 ReadView,使用至事务提交。 在生成 ReadView 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”。

  • 执行 select * from table where ? lock in share mode/for update; insertupdatedelete 会以 MVCC 当前读的方式读取数据。

    在当前读的情况下,读取的都是最新的数据,如果存在其它事务插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读。

    InnoDB 就使用 Next-key Lock 来防止这种情况:当执行当前读时,在锁定读取到的记录时,也会锁定它们的间隙,防止其它事务在查询范围内插入数据。