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 | INSERT INTO user(id, name) VALUES(1, "a"); |
因为没有使用 START TRANSACTION
将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务。
undo log 主要有两个作用:
- 当事务回滚时用于将数据恢复到修改前的样子,保证事务原子性。
- 另一个作用是实现 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读
快照(ReadView)
ReadView 结构
1 | class ReadView { |
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 再重新开始判断,直到找到可见的快照版本或返回空。
事务可见性示意图:
快照读 & 当前读
快照读
快照读只是针对于目标数据的版本号小于等于当前事务的版本号,也就是说读数据的时候可能读到旧数据,但是这种快照读不需要加锁。也就是说,使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。
1 | select * from table ...; |
当前读
当前读是读取当前数据的最新版本,但是更新等操作会对数据加锁,所以当前读需要获取记录的行锁,存在锁争的问题。以下第一个语句需要加 S 锁,其它都需要加 X 锁。
1 | select * from table where ? lock in share mode; # 加 S 锁 |
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 | (-∞, 10] |
如下图所示:
几个问题
问题一:对主键索引或唯一索引会使用间隙锁吗?
不一定。视情况而定:
如果 where 条件全部命中(不会出现幻读),则不会加间隙锁,只会加记录锁
如果 where 条件部分命中 / 全都不命中,则会加间隙锁
1 | delete from tb where id = 9 |
根据 id=9 条件定位,此时给 id = 9 的索引加上记录锁,根据 name 值(name是主键)到主索引中检索获得记录,再给该记录加上记录锁。
问题二:间隙锁是否用在非唯一索引的当前读中?
是的。
1 | delete from tb1 where id = 9 |
可以看出,在 (6,9]、(9,11] 加了间隙锁。
问题三:间隙锁是否用在不走索引的当前读中?
是的。
1 | delete from tb2 where id = 9 |
此时对所有的间隙都上锁(功能上相当于锁表)。
总结以上三个问题,我们得到如下结论:
主键索引 / 唯一索引:
如果 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;
、insert
、update
、delete
会以 MVCC 当前读的方式读取数据。在当前读的情况下,读取的都是最新的数据,如果存在其它事务插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读。
InnoDB 就使用 Next-key Lock 来防止这种情况:当执行当前读时,在锁定读取到的记录时,也会锁定它们的间隙,防止其它事务在查询范围内插入数据。