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 会随着新行的插入而单调增加。
  • 如果数据表没有主键,InnoDB 会自动使用 DB_ROW_ID 的值生成聚簇索引;
  • 否则,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 值越大)。

alt MVCC

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 的生成流程如下:

alt Read View

这里,我们以下面的例子来说明读视图的生成过程,假设数据库中,存在一行如下数据:

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 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。


参考:

posted @ 2023-03-24 23:03  LARRY1024  阅读(78)  评论(0编辑  收藏  举报