InnoDB 多版本控制
InnoDB 多版本并发控制
InnoDB 是一个多版本(multi-version)存储引擎,它会保存被修改行的旧版本信息,以支持并发和回滚等事务功能,这些信息存储在撤消表空间(undo tablespaces)中称为回滚段(rollback segment)的数据结构中。
InnoDB 使用回滚段中的信息来执行事务回滚中所需的撤消操作,它还使用这些信息来构建行的早期版本以实现一致性读(Consistent Nonlocking Reads,快照读)。
在 InnoDB 引擎内部,每一行除了包含用户定义的字段外,还会额外包含三个隐式字段:
字段 | 名称 | 长度 | 作用 |
---|---|---|---|
DB_TRX_ID | 事务ID | 6字节 | 插入或更新该行的最后一个事务的事务标识符。 此外,删除在内部被视为更新,其中行中的特殊位被设置为将其标记为已删除。 |
DB_ROLL_PTR | 回滚指针 | 7字节 | 回滚指针指向回滚段中的一条撤销日志记录。 如果该行已更新,则撤消日志记录包含在更新之前重建该行内容所需的信息。 |
DB_ROW_ID | 隐藏自增主键 | 6字节 | DB_ROW_ID 会随着新行的插入而单调增加。
|
回滚段中的的撤消日志(undo log)分为:
-
插入撤消日志(insert undo logs)
仅在事务回滚时需要,并且,可以在事务提交后立即丢弃。
-
更新撤消日志(update undo logs)
用于一致性读取(快照读),但只有在不存在 InnoDB 为其分配快照的事务之后,才可以丢弃它们,该快照在一致读取中,可能需要 update undo log 中的信息来构建数据库行的早期版本。
因此,我们应该定期提交事务,包括仅发出一致性读的事务。否则,InnoDB 无法丢弃更新撤消日志中的数据,并且回滚段可能会变得太大,填满其所在的撤消表空间。
回滚段中撤消日记录的物理大小,通常小于相应插入或更新的行,可以使用此信息来估算回滚段所需的空间。
在 InnoDB 多版本控制方案中,当使用 SQL 语句删除一行时,并不会立即从数据库中物理删除它。InnoDB 只有在丢弃为删除而写入的 update undo log 记录时,才会从物理上删除相应的行及其索引记录。这种删除操作称为 purge(清除),速度相当快,通常与执行删除的 SQL 语句的时间顺序相同。
如果我们在表中以大致相同的速率插入和删除小批量行,清除线程(purge thread)可能开始滞后,并且由于所有“死亡”行,表可能变得越来越大,使得所有内容都受磁盘限制并且非常慢。
在这种情况下,需要限制新行操作,可以通过增大 innodb_max_purge_lag
配置,为清除线程分配更多资源。
多版本控制和二级索引
InnoDB 多版本并发控制 (MVCC) 对待二级索引(secondary Index)的方式与对待聚集索引(clustered index)的方式不同:
-
聚集索引中的记录会就地更新,聚集索引的隐藏系统列指向撤消日志条目,可以从中重建早期版本的记录。
-
二级索引记录由于不包含隐藏的系统列,所以不会就地更新。
当更新二级索引列时,旧的二级索引记录将被标记为删除,新记录将被插入,并最终清除(purge)标记为删除的记录。
当二级索引记录被删除标记或二级索引页被较新的事务更新时,InnoDB 会在聚集索引中查找数据库记录。在聚集索引中,将检查记录的 DB_TRX_ID,如果在启动读取事务后修改了记录,则从撤消日志中检索记录的正确版本。
如果二级索引记录被标记为删除或者二级索引页被较新的事务更新,InnoDB 会在聚集索引中查找记录,而不会使用覆盖索引(covering index)技术,因此,InnoDB 不会直接从索引结构返回查询结果。
但是,如果启用了索引条件下推 (index condition pushdown,ICP)优化,并且部分 WHERE 条件能使用索引中的字段进行评估,MySQL 服务器仍然会将这部分 WHERE 条件下推到存储引擎,在存储引擎中使用索引对其进行评估。如果没有匹配的记录,则可以避免聚集索引查找。如果存在匹配的记录,即使聚集索引中某些记录带有删除标记,InnoDB 也会在聚集索引中查找该记录。
共享锁和排它锁
-
共享锁(S锁)
用于不更改或不更新数据的操作(只读操作),例如:SELECT 语句。
如果事务 T 仅对数据 A 进行读取,那么会对数据 A 加上共享锁,之后则其他事务如果要读取数据 A 的话可以对其继续加共享锁,但是不能加排他锁(也就是无法修改数据)。获得共享锁的事务只能读数据,不能修改数据。
-
排他锁(X锁)
用于数据修改操作,例如:INSERT、UPDATE 或 DELETE 语句。确保不会同时同一资源进行多重更新。
如果事务 T 对数据 A 要进行修改,则需要对其添加排它锁,加上排他锁后,则其他事务不能再对 A 加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。
当前读和快照读
当前读
当前读:每次读取的都是数据库记录的最新版本,会对当前读取的数据进行加锁,防止其他事务修改数据,是悲观锁的一种操作。
像 select xx from t_xx lock in share mode(共享锁)、select for update、update、insert、delete(排他锁)这些操作都是一种当前读。
因为,它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,因此,会对读取的记录进行加锁。
以下几种情况都是当前读:
-
select 语句加锁
# 共享锁 select name from t_user where id = 1 lock in share mode; # 排他锁 select name from t_user where id = 1 for update;
-
update、insert、delete 语句
# 排他锁 update t_user set a = a + 1 where id = 1;
快照读
快照读:读写不冲突,每次读取的都是快照数据。
像不加锁的 select 操作就是快照读,即不加锁的非阻塞读。快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即 MVCC,可以认为 MVCC 是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销。
因为它是基于多版本实现,因此,快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本,简而言之,MVCC 就是为了实现读-写冲突不加锁,而这个读指的就是快照读。
以下几种情况都是快照读:
- 在RR隔离级别下,不加锁的select语句:
# select 语句 select name from t_user where id = 1;
注意:
-
隔离级别为 Repeatable Read 时:有可能读取的不是最新的数据;
-
隔离级别为 Read Committed 时:快照读和当前读读取的数据是一样的,都是最新的。
Read View
Read View 就是事务进行快照读操作的时候生产的读视图,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分配一个 ID , 这个 ID 是递增的,所以最新的事务,ID 值越大)。
Read View 主要是用来做可见性判断的,即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,即可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
对于使用 read-committed 和 repeatable-read 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。
我们可以把Read View简单的理解成有三个全局属性:
-
trx_list:未提交事务ID列表,用来维护Read View生成时刻系统正活跃的事务ID;
-
up_limit_id:记录trx_list列表中事务ID最小的ID;
-
low_limit_id:Read View生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1。
Read Veiw 的生成流程
Read Veiw 的生成流程如下:
这里,我们以下面的例子来说明读视图的生成过程,假设数据库中,存在一行如下数据:
id | name | amount |
---|---|---|
5 | Tom | 500 |
同一时刻,同时开启了三个事务A、B、C:
时刻 | 事务A | 事务B | 事务C |
---|---|---|---|
1 | begin | begin | begin |
2 | select amount from t_order where id = 5 | select amount from t_order where id = 5 | |
3 | update t_order set amount = 400 where id = 5 | ||
4 | commit | ||
5 | select amount from t_order where id = 5 | select amount from t_order where id = 5 | |
6 | select amount from t_order where id = 5 lock in share mode | select amount from t_order where id = 5 lock in share mode | |
7 | commit | commit |
查询结果及分析:
-
对于事务 B,前两次查询出来的结果为 500,最后一次查询出来的结果为 400 ;
因为事务 B 的快照读,是在事务 A 提交之前进行操作的,所以事务 B 的快照读,读取到的还是旧的数据,而事务 B 的最后一次读取为当前读,所以,能读取到最新的数据。
-
对于事务 C,两次查询出来的结果为 400。
因为事务 C 的第一次快照读,是在事务 A 提交之后进行操作的,所以,事务 C 的快照读,能读取到最新的数据,而事务C的最后一次读取为当前读,所以,也能读取到最新的数据。
RR、RC 生成Read View的时机:
-
RC隔离级别下,每个快照读都会生成并获取最新的Read View,因此可能出现在同一个事务中两次查询的结果不一致的情况;
-
RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View,之后的查询就不会重复生成了,所以同一一个事务的查询结果每次都是一样的。
总结
多版本并发控制是一种用来解决读-写冲突的无锁并发控制技术。它在 RC 和 RR 隔离级别下,为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。
所以,MVCC可以为数据库解决以下问题在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能 同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。
MVCC 解决的问题如下:
-
并发读-写时:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作。
-
解决脏读、幻读、不可重复读等事务隔离问题,但不能解决写-写更新丢失问题。因此需要下面提高并发性能的组合:
-
MVCC + 悲观锁:MVCC解决读写冲突,悲观锁解决写写冲突
-
MVCC + 乐观锁:MVCC解决读写冲突,乐观锁解决写写冲突
-
MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象,解决的方案有两种:
-
针对快照读(普通
select
语句):是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 -
针对当前读(
select ... for update
等语句):是通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读,因为当执行select ... for update
语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
参考: