把学习当成一种习惯
选择往往大于努力,越努力越幸运

InnoDB的隔离性(三)

  经过两篇文章 : MySQL的隔离性一MySQL的隔离性二 的理论加实践,已经大致可以清楚的理解InnoDB存储引擎的隔离性,这篇文章再次对InnoDB存储引擎实现的隔离性提供的锁、MVCC进行一次总结.

Lock 与 Latch ---- Lock 和 Latch 都是"锁",但是两者锁住的对象是不一样的

Latch ---- 了解

  Latch是一种轻量级的锁,因为其要求锁定的时间必须非常短,若持续时间长,则应用的性能非常差.
  在InnoDB存储引擎中,Latch锁又可以分为mutex(互斥量)和rwlock(读写锁),其作用就是保证并发线程访问临界资源的正确性,并且通常没有死锁的检测机制.

  • 对象 : 线程
  • 保护 : 内存数据结构,例如缓冲池中的LRU List、Flush List、Free List等
  • 持续时间 : 临界资源
  • 模式 : 读写锁、互斥量
  • 死锁 : 无死锁检测与处理机制,仅通过加锁的执行顺序保证了无死锁的情况发生

查看InnoDB存储引擎中的Latch详细信息


SHOW ENGINE INNODB MUTEX

// 非DEBUG下
+--------+----------------------------+----------+
| Type   | Name                       | Status   |
+--------+----------------------------+----------+
| InnoDB | sum rwlock: buf0buf.cc:780 | waits=16 |
+--------+----------------------------+----------+

DEBUG下可以看到更多的信息 :

  • count : mutex被请求的次数
  • spin_waits : 自旋锁的次数,InnoDB存储引擎Latch在不能获取锁时,首先会进行自旋,如果自旋一段时间后还不能获得锁,线程就会进入阻塞等待状态.
  • spin_rounds : 自旋内部循环的总次数
  • os_waits : 表示操作系统等待的次数.当InnoDB存储引擎的Latch通过自旋不能获取锁后就会进入操作系统阻塞等待状态,等待被唤醒.
  • os_yields : 唤醒操作的次数
  • os_wait_times : 操作系统等待的时间,单位ms

Lock ---- 本文重点

  Lock的对象是事务,用来锁定的是数据库实例中的对象,如表、页、行;由于锁住的对象是事务,所以一般Lock的对象仅在事务COMMIT或ROLLBACK后进行释放,Lock有死锁检测机制.【PS : 不同事务隔离级别释放的时间可能不同】

  • 对象 : 事务
  • 保护 : 数据库实例的内容,例如表、页、行
  • 持续时间 : 整个事务过程,COMMIT或者ROLLBACK
  • 模式 : 表锁、意向锁、行锁
  • 死锁 : 通过waits-for graph \ time out 等机制进行死锁检测与处理

InnoDB存储引擎提供的Lock锁 ---- 锁的模式

共享锁 (S Lock) 和 排他锁 (X Lock)

InnoDB存储引擎实现了如下两种标准的行级锁 :

  1. 共享锁 (S Lock) : 允许事务读一行数据
  • 【PS : 如果一个事务在T1已经获得了行R的共享锁,那么另外的事务T2也可以获得行R的共享锁,也就是读读锁不互斥】
  1. 排他锁 (X Lock) : 允许事务删除或更新一行数据
  • 【PS : 如果其他事务T3想获得行R的排他锁,则其必须等待事务T1 T2释放行R上的共享锁,也就是读写互斥】

共享锁 (S Lock) 和 排他锁 (X Lock) ---- 兼容性

X S
S 不兼容 兼容
X 不兼容 不兼容

【PS : InnoDB存储引擎的S锁、X锁可以说都是基于行锁,兼容性是指同一行数据的兼容性情况】

意向锁(Intention Lock)

  意向锁的作用 : 如果存在第二个事务试图在该表级别上应用共享或排它锁(该表上使用表锁),则受到由第一个事务控制的表级别意向锁的阻塞.第二个事务在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁.
  InnoDB存储引擎支持多粒度锁定,这种锁允许事务在行级上的锁和表级上的锁同时存在,这种锁就是意向锁.
  如果需要对页上的记录行R进行上X锁,那么分别需要先对该表上意向锁IX,最后对记录行R上X锁.

InnoDB存储引擎支持的意向锁为表级锁,其支持两种意向锁 :

  1. 意向共享锁 (IS Lock) : 事务想要获得一张表中某几行的共享锁
  • 【PS : 意向共享锁只与表级锁有冲突,只与表级S锁兼容,不与行锁有冲突】
  1. 意向排他锁 (IX Lock) : 事务想要获得一张表中某几行的排他锁
  • 【PS : 意向排他锁只与表级锁有冲突,与表级S锁\X锁都不兼容,不与行锁有冲突】

意向锁(Intention Lock) 和 共享锁 (S Lock) 和 排他锁 (X Lock)

意向锁与意向锁 :

IX IS
IX 兼容 兼容
IS 兼容 兼容

意向锁与X锁\S锁 :

IX IS
S 不兼容 兼容
X 不兼容 不兼容

【PS : 意向锁只与表级锁有冲突,不与行锁有冲突,所以这里是X锁\S锁指的是表级锁】

查看InnoDB存储引擎中的Lock详细信息

  InnoDB存储引擎提供了 INNODB_TRX \ INNODB_LOCKS \ INNODB_LOC_WAITS 三张表,以便我们来分析当前事务可能存在的锁问题.


SELECT * FROM information_schema.INNODB_TRX\G;

  1. INNODB_TRX :
  • trx_id : InnoDB存储引擎内部唯一的事务ID
  • trx_state : 当前事务的状态 : RUNNING(正在运行)、LOCK WAIT(等待状态)
  • trx_started : 事务的开始时间
  • trx_requested_lock_id : 等待事务的锁ID.例如 : trx_state的状态为LOCK WAIT,那么该值代表当前的事务正在等待之前事务占用锁资源的 ID,如果trx_state不是LOCK WAIT,则该值为NULL
  • trx_wait_started : 事务等待开始的时间
  • trx_weight : 事务的权重.反映了一个事务修改和锁住的行数;在InnoDB存储引擎中,当发生死锁需要回滚时,InnoDB存储引擎会选择该值最小的进行回滚.
  • trx_mysql_thread_id : MySQL中的线程ID
  • trx_query : 当前事务运行的SQL语句

  【PS : INNODB_TRX 只是显示了当前运行的InnoDB事务信息,并不能直接判断锁的一些情况,如果需要查看锁的信息, 还需要访问表 INNODB-LOCKS】


SELECT * FROM information_schema.INNODB-LOCKS\G;

  1. INNODB-LOCKS :
  • lock_id : 锁的ID
  • lock_trx_id : 当前事务ID
  • lock_mode : 锁的模型,例如 X锁 \ S锁
  • lock_type : 锁的类型,使用的是表锁还是行锁
  • lock_table : 对哪张表加锁
  • lock_index : 锁住的索引
  • lock_space : 锁对象的 space id,表空间ID
  • lock_page : 事务锁定页的数量;若是表锁,则该值为NULL
  • lock_rec : 事务锁定行的数量;若是表锁,则该值为NULL
  • lock_data : 事务锁定记录的主键值;若是表锁,则该值为NULL

  【PS : 当事务较小时,用户就可以直观的进行判断了,当事务量非常大,其中锁的等待也时常发生,这个时候就没这么容易判断了,此时需要通过表 INNODB_LOC_WAITS 可以很直观得反应当前事务的等待情况】


SELECT * FROM information_schema.INNODB_LOC_WAITS\G;

  1. INNODB_LOC_WAITS :
  • requesting_trx_id : 申请锁资源的事务ID
  • requesting_lock_id : 申请的锁的ID
  • blocking_trx_id : 阻塞的事务ID
  • blocking_lock_id : 阻塞的锁的ID

  【PS : 通过表INNODB_LOC_WAITS,用户可以清楚直观地看到哪个事务阻塞了另一个事务】

  用户可以根据表INNODB_TRX \ INNODB_LOCKS \ INNODB_LOCK_WAITS 得到更为直观的详细信息,尤其是使用联合查询,如下 :


SELECT 
r.trx_id waiting_trx_id, 
r.trx_mysql_thread_id waiting_thread, 
r.trx_query waiting_query, 
b.trx_id blocking_trx_id, 
b.trx_mysql_thread_id blocking_thread, 
b.trx_query blocking_query 
FROM information_schema.innodb_lock_waits w 
INNER JOIN information_schema.innodb_trx b 
ON b.trx_id = w.blocking_trx_id 
INNER JOIN information_schema.innodb_trx r 
ON r.trx_id = w.requesting_trx_id\G;

一致性非锁定读 ---- 多版本并发控制(MVCC)

  一致性非锁定读 : 指InnoDB存储引擎通过行多版本控制的方法来读取当前执行时间数据库中行的数据;如果读取的行正在执行UPDATE \ DELETE 操作,这时读取不会因此而阻塞了.
  InnoDB存储引擎去读取数据的时候是读取一个快照数据,快照数据是指该行之前的版本的数据,该实现由undo log来完成.【PS : undo log 的作用就是事务中回滚数据,因此快照数据本身没有额外的开销,并且读取快照数据是不需要上锁的,因为没有事务对历史数据进行修改的操作】
  快照数据其实就是读取当前行数据的之前的历史版本,每行记录可能在undo log记录了多个版本.也就是说一个行记录可能有不止一个快照数据,一般称之为行多版本技术,由此带来的并发控制,称之为多版本控制并发(MVCC).

在事务 RC \ RC 的隔离级别下,InnoDB存储引擎才使用多版本控制并发(MVCC).

  • RC : 对于快照数据,非一致性锁读总是读取被锁定行的最新一份快照数据.
  • RR : 对于快照数据,非一致性锁读总是读取事务开始时的行数据版本.

【PS : 对于RC而言,从数据库理论的角度来看,其违反了事务ACID的I的特性,即隔离性】

一致性锁定读 ---- 显示SELECT加锁

【PS : 使用显示SELECT加锁操作时,务必加上BEGIN \ START TRANSACTION \ SET AUTOCOMMIT = 0】
  在事务的RC \ RR 隔离级别下,InnoDB存储引擎的SELECT都是使用一致性非锁定读,也就是说对于普通SELECT是没有加锁的,但是在某些情况下,用户需要显示地对SELECT进行加锁来保证数据的一致性,也就是说对普通SELECT执行加锁操作;InnoDB存储引擎对于普通SELECT语句支持两种一致性的锁读操作 :

  1. SELECT···FOR UPDATE
  • 对读取的行记录加一个X锁,即使是辅助索引查询没有回表查询,也会在对应的聚集索引行记录加X锁.
  • 对于读取的行记录加的X锁,其他事务不能对已锁定的行加上任何锁.
  1. SELECT···LOCK IN SHARE MODE
  • 对读取的行记录加一个S锁,辅助索引查询且没有回表查询,相对应的聚集索引行记录则没有加锁.
  • 对于读取的行记录加S锁,其他事务可以想被锁定的行记录加S锁,但是如果加X锁,则阻塞.

自增值与锁 ---- AUTO-INC Locks

  在InnoDB存储引擎的内存结构中,每个含有自增长值的表都有一个自增长计数器;当对含有自增长的计数器的表进行插入操作时,就需要这个计数器来取得插入数据的自增长值.

  • 为了提高插入的性能,锁不是一个事务完成后才释放,而是完成对自增长插入的SQL语句后立即释放.
  • InnoDB存储引擎引入了一种轻量级互斥量的自增长实现机制来提高自增长插入的性能,并且提供了参数innodb_autoinc_lock_mode来控制自增长的模式,默认值为1.
  • 对自增长插入进行分类 :
    • insert-like : 所有的插入语句
    • simple inserts : 能在插入前就确认插入行数的语句
    • bulk inserts : 插入前不能确认插入行数的语句
    • mixed-mode inserts : 插入中一部分是自增长的,一部分的确认的.
  • 参数innodb_autoinc_lock_mode分析 : 0 \ 1 \ 2
    • 0 : 通过表锁AUTO-INC Locking方式 【PS : 最传统的方式,也是不推荐使用的】
    • 1 : 默认值,对于"simple insert"类型,用了互斥量进行累加操作;对于"bulk inserts"类型,用了表锁AUTO-INC Locking方式.【PS : 如果不考虑回滚操作,对自增长列的值可以保证连续的(基本可以保证连续),还有一点,对于使用AUTO-INC Locking方式去产生自增长的值,而同时进行"simple insert"类型的互斥量操作,此时也会进入阻塞,等待AUTO-INC Locking的释放】
    • 2 : "insert-like"的类型,也就是说对于所有的Insert操作,都是使用互斥量,而没有使用AUTO-INC Locking方式,这是性能最高的方式.【PS : 并发插入,每次的插入可能不是连续的;对于二进制文件主从复制也带来了问题,使用这个模式,任何时候都使用binlog的row格式,可以最大的保证了并发性能】

InnoDB存储引擎中提供行锁的三种算法

InnoDB存储引擎由三种行锁的算法 :

  1. Record Lock : 单个行记录上加锁
  • 【PS : Record Lock 总是会去锁住索引记录】
  1. Gap Lock : 间隙锁,锁定一个范围,但不包含记录本身
  • 【PS : 遵循开区间,不锁住记录本身】
  • 【PS : Gap Lock的作用是为了阻止多个事务将记录插入到同一范围内,而这会导致Phantom Problem(幻读)】
  • 【PS : Gap Lock 与 Gap Lock 之间是兼容的,因为都是防止Insert操纵导致Phantom Problem(幻读)】
  1. Next-Key Lock : Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身
  • 【PS : Next-Key Lock 结合了 Gap Lock 和 Record Lock,采用了Next-Key Lock的锁定技术成为Next-Key Locking;其作用是为了解决Phantom Problem(幻读)】
  • 【PS : 当查询含有唯一索引属性时,InnoDB存储引擎会对Next-Key Lock进行优化,将其降级为Record Lock,即只锁住索引本身,而不是范围锁】

显示关闭Gap Lock :

  • 将事务的隔离级别设置为RC
  • 参数innodb_locks_unsafe_for_binlog = 1

  在上述的配置下,除了外键约束与唯一性检查依然需要Gap Lock,其余情况仅使用Record Lock进行锁定,并且上述配置破坏了事务的隔离性.
【PS : 对于唯一索引的锁定,Next-Key Lock降级为Record Lock仅存在于查询所有的唯一索引列,如果唯一索引是由多个列组成,而查询仅是查询多个唯一索引中的其中一个,那么查询其实是range(范围)类型查询,而不是point类型查询,所以InnoDB存储引擎依然会使用Next-Key Lock进行锁定】

Phantom Preblem ---- 幻读

  Phantom Preblem : 指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次的SQL语句可能会返回之前不存在的行.
  在默认隔离级别下,即RR,InnoDB存储引擎采用Next-Key Locking 机制来解决Phantom Preblem.
  在RC隔离级别下,仅采用Record Lock,并没有解决Phantom Preblem.
【PS : 在RR隔离级别下,如果通过索引查询一个值,并对该值加上一个S锁,那么即使查询该值不在,其锁定的也是一个范围】

多版本控制并发(MVCC) ---- 实现原理

  多版本控制并发的实现是基于undo log来实现的,对于理解多版本控制并发的实现原理的前提下必须理解undo log.
  undo log的核心作用就是回滚操作,数据库进行数据修改时,InnoDB存储引擎不仅会产生redo log,而且还会产生undo log,这样如果在一个事务中执行的语句由于某种原因失败了或者ROLLBACK回滚操作,就可以利用undo log的记录信息进行回滚到修改之前的样子.

  • 对于当前事务执行的DELETE操作,此时回滚操作,InnoDB存储引擎会执行一个INSERT操作.
  • 对于当前事务执行的INSERT操作,此时回滚操作,InnoDB存储引擎会执行一个DELETE操作.
  • 对于当前事务执行的UPDATE操作,此时回滚操作,InnoDB存储引擎会执行一个UPDATE操作.
    【PS : undo log并非用于将数据库物理地恢复到执行语句或事务之前的样子,undo log是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子;例如:一个事务在修改一个页的某几条行数据,同时还有其他事务在对同一个页中另外几条数据行进行修改,此时,有一个事务回滚了,那么肯定是不能回滚整个页的数据的,这样其他事务的数据就被影响了】

  undo log除了回滚操作,还有另一个作用就是MVCC,即在InnoDB存储引擎中MVCC是通过undo log来实现的.
【PS : 当前事务读取一行记录时,若该记录被其他事务占用,当前事务可以通过undo log来读取之前的版本记录信息(以提交记录),以此来实现一致性非锁定读】
【PS : undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久化保护】

undo log 存储管理

  InnoDB存储引擎对undo log的存储管理采用段的方式,每个回滚段(rollback segment)记录了1024个undo log sort,每个undo log sort段中进行undo页的申请.
基本的Undo回滚段布局结构 (128个回滚段):

  • 0(也就是第一个回滚段) : 预留在系统表空间ibdata中;
  • 1 ~ 32 : 这32个回滚段存放于临时表的系统表空间中;
  • 33 ~ 128 : 根据配置存放到独立undo表空间中(如果没有打开独立Undo表空间,则存放于ibdata中)
    每个回滚段维护了一个段头页,在该page中又划分了1024个slot(TRX_RSEG_N_SLOTS),每个slot又对应到一个undo log对象,因此理论上InnoDB存储引擎最多支持 96 * 1024个普通事务.

每个回滚段中的undo log sort的数量根据InnoDB页面大小而有所不同 :

InnoDB页面大小 回滚段中的undo log sort的数量(InnoDB页面大小 / 16)
4096 (4KB) 256
8192 (8KB) 512
16384 (16KB) 1024
32768 (32KB) 2048
65536 (64KB) 4096

Undo结构体

  • 1.所有回滚段都记录在trx_sys->rseg_array,rseg_array数组大小为128,分别对应不同的回滚段,也就是说默认128个回滚段;
  • 2.rseg_array数组类型为trx_rseg_t,用于维护回滚段相关信息;
  • 3.每个回滚段对象trx_rseg_t还要管理undo log信息,对应结构体为trx_undo_t,使用多个链表来维护trx_undo_t信息,也就是说每个回滚段存在1024个trx_undo_t信息;
  • 4.事务开启时,会专门给他分配一个回滚段,以后该事务用到的undo log页,就从该回滚段上分配;
  • 5.事务提交后,需要purge的回滚段会被放到purge队列上

分配回滚段

  分配回滚段 : 当开启一个读写事务时(或者从只读事务转换为读写事务),我们需要预先为事务分配一个回滚段 :
  对于只读事务,如果产生对临时表的写入,则需要为其分配回滚段,使用临时表回滚段(第1~32号回滚段)
【PS : 在MySQL5.7中事务默认以只读事务开启,当随后判定为读写事务时,则转换成读写模式,并为其分配事务ID和回滚段】

普通回滚段的分配方式 :

  • 1.采用round-robin的轮询方式来赋予回滚段给事务,如果回滚段被标记为skip_allocation(这个undo tablespace太大了,purge线程需要对其进行truncate操作),则跳到下一个;
  • 2.选择一个回滚段给事务后,会将该回滚段的rseg->trx_ref_count递增,这样该回滚段所在的undo tablespace文件就不可以被truncate掉;
  • 3.如果事务在只读阶段使用到临时表,随后转换成读写事务,那么会为该事务分配两个回滚段;
    【PS : round-robin轮询算法可百度查看相关资料】

使用回滚段

  当产生数据变更时,我们需要使用Undo log记录下变更前的数据以维护多版本信息;INSERT 和 DELETE/UPDATE 分开记录undo,因此需要从回滚段单独分配undo log slot.
  使用回滚段 :

  • 1.判断当前变更的是否是临时表,如果是临时表,则采用临时表回滚段来分配,否则采用普通的回滚段;
  • 2.临时表操作记录undo时不写redo log;
  • 3.操作类型为TRX_UNDO_INSERT_OP或者TRX_UNDO_MODIFY_OP且未分配undo log sort的条件下来分配sort
    【PS : MySQL 5.7对临时表undo和普通表undo分别做了处理,前者在写undo日志时总是不需要记录redo,后者则需要记录】
    【PS : 该知识点较复杂,这里不作详细的分析】

回滚段 --- 相关知识点

【PS : MySQL5.5版本后默认支持128个回滚段(rollback segment),即128 * 1024 个 undo操作,变量 innodb_undo_logs 就是来自定义多少个rollback segment,默认值为128】
【PS : 回滚段的方式是为了保证事务并发操作时,在写各自的undo log时不产生冲突】

  • innodb_undo_directory : undo log的存储路径,即存放的是独立的表空间,默认值为".",表示当前InnoDB存储引擎的目录.
  • Innodb_undo_truncate : 参数默认打开,这意味着默认情况下,undo tablespace超过1GB(参数innodb_max_undo_log_size来控制)时,就会触发online truncate.
  • innodb_undo_log_encrypt : 用于undo redo加密,默认关闭
  • innodb_undo_tablespaces : 设置rollback segment的文件数量.

SHOW VARIABLES LIKE 'innodb_undo%';

+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_undo_directory    | .\    |
| innodb_undo_log_encrypt  | OFF   |
| innodb_undo_log_truncate | ON    |
| innodb_undo_tablespaces  | 2     |
+--------------------------+-------+

SHOW VARIABLES LIKE 'datadir';

+---------------+---------------------------------+
| Variable_name | Value                           |
+---------------+---------------------------------+
| datadir       | D:\MySQL\MySQL Server 8.0\Data\ |
+---------------+---------------------------------+

  事务提交后并不能马上删除undo log及undo log所在的页,这是因为可能还有其他事务需要通过undo log来得到行记录版本信息,事务提交时会将undo log放入一条链表中,至于undo log及undo log页什么时候删除,由purge线程来做.
【PS : 如果每个事务分配一个undo页,假设某应用的删除和更新操作的TPS为1000,那么一分钟需要1000 * 60,大约需要1GB空间,为了节约空间,InnoDB存储引擎设计对undo log页可进行重用,并且一个undo log页允许多个事务的undo log】

undo log 格式

在InnoDB存储引擎中,undo log分为两种 :

  1. insert undo log
  • 表示insert操作中产生的undo log,由于insert操作只对本事务可见,对其他事务不可见,所以对于insert操作,事务回滚可直接删除,不需要进行purge操作.
    • next : 记录的是下一个undo log的位置,通过该值可知道一个undo log所占的空间字节数
    • type_cmpl : undo的类型
    • undo_no : 事务的ID
    • table_id : 所对应表对象
    • 接着部分就是行记录了
    • 进行ROLLBACK时,根据这些值定位到具体的记录来进行删除
  1. update undo log
  • 表示对delete、update操作产生的undo log,因为需要提供MVCC机制的使用,所以事务提交时,不能删除undo log,而是放入undo log链表中,并且等待purge线程进行最后的删除.

    • next、undo_no、table_id、start同insert undo log是一样的
    • 下面列出了undo log类型(type_cmpl),后续在purge工作线程的时候会针对性的讲述不同的处理方式 :
    • type_cmpl :
      • TRX_UNDO_UPD_EXIST_REC 更新non-delete-mark记录,即将主键和被更新了的字段内容记入日志
      • TRX_UNDO_UPD_DEL_REC 将delete的记录标记为not delete,即当表中有一条被标记为删除的记录和要插入的数据主键相同时,实际的操作是更新这个被标记为删除的记录
      • TRX_UNDO_DEL_MARK_REC 将记录标记为delete,即在删除一条记录时,并不是真正的将数据从数据库中删除,只是标记为已删除
      • TRX_UNDO_INSERT_REC 仅将主键记入日志
  • 对于DELETE操作,实际上不会直接删除,而是将DELETE对象打上delete flag,标记为删除,最终的删除操作是purge线程完成的

  • 对于UPDATE操作,UPDATE的列如果是主键列,UPDATE分两部执行 : 先删除该行,再插入一行目标行;否则,在undo log中记录的是如何UPDATE的.

undo log版本链

  对于DELETE \ UPDATE, 事务提交时,不能删除undo log,而是放入undo log链表中,并且等待purge线程进行最后的删除,那么这条版本链是如何关联起来的呢 : 版本链的每行数据都有 DATA_TRX_ID 和 DATA_ROLL_PTR 这两个隐藏字段 (如上图):

  • DATA_TRX_ID 表示更新这行数据的事务ID
  • DATA_ROLL_PTR 回滚指针,表示指向了之前更新这行数据的undo log

  通过DATA_ROLL_PTR串起来的一条版本链 : 当多个事务串行DELETE \ UPDATE 操作的时候,每修改一行数据,每行就会生成DATA_TRX_ID和DATA_ROLL_PTR,同时通过DATA_ROLL_PTR把同一行数据的多个undo log串联起来(不同事务中对同一行数据的DELETE\UPDATE操作就会形成一条版本链),也就是每行数据都有对应的一条undo log版本链.

ReadView机制

  理解完undo log版本链,再来看看基于undo log版本链实现的ReadView机制,MVCC就是通过ReadView机制实现的一致性非锁读.
ReadView的主要实现由以下四个核心 :

  • m_ids : 表示当前活跃的事务ID列表,也就是生成ReadView的时候其他事务还没提交的事务ID(对同一行数据的DELETE\UPDATE操作的未提交事务ID),其中包括creator_trx_id
  • up_limit_id : m_ids中的最小事务ID
  • low_limit_id : 最大事务ID(MySQL下一个事务要生成的ID)【PS : m_ids中的最大事务ID加1】
  • creator_trx_id : 就是当前事务的ID

【PS : 事务ID是事务开启时InnoDB存储引擎分配的,其大小决定了事务开启的先后顺序,因此我们可以通过ID的大小关系来决定版本记录的可见性】
ReadView基于undo log的具体判断流程如下 :

  1. 查看undo log版本链中最新版本的事务ID(DATA_TRX_ID)
  2. 如果事务ID(DATA_TRX_ID) 小于 up_limit_id,说明该事务是在ReadView生成之间已经提交了,所以该版本可以被当前事务访问
  3. 如果事务ID(DATA_TRX_ID) 大于 low_limit_id,说明该事务是在ReadView生成之后才生成,所以该版本不可以被当前事务访问
  • 【PS : 上面两种情况是基于事务ID(DATA_TRX_ID)的大小不在m_ids活跃列表之间,接下来第三种判断是事务ID(DATA_TRX_ID)的大小在m_ids活跃列表之间,是事务ID的大小在m_ids活跃列表之间,不是事务ID存在m_ids活跃列表之内】</span
  1. 如果事务ID(DATA_TRX_ID)在m_ids列表中最大值和最小值之间,那就需要判断一下事务ID(DATA_TRX_ID)是不是在m_ids列表中,如果存在,创建ReadView时生成该版本所属事务还是活跃的,因此该版本不可以被访问,此时需要查找undo log链找到上一个版本,也就是说再次指向第一步的操作(只是这里是通过回滚指针(DATA_ROLL_PTR)继续向前找);反之,如果不存在m_ids列表中,说明创建ReadView时生成该版本的事务已经提交,该版本可以被访问.
  2. 经过一系列的判断我们已经得到了这条记录相对ReadView来说的可见结果,最后还需要判断这条记录的delete_flag,如果是TRUE,说明这条记录已经被删除,不返回;否则说明这条记录可以安全返回给客户端.

【PS : 判断到的事务ID(DATA_TRX_ID)等于creator_trx_id是可以访问的,因为是自己事务修改的数据,这句话有点废话....】

RC \ RR 的生成ReadView机制

InnoDB存储引擎在RC \ RR 隔离级别下使用了多版本控制并发(MVCC),接下来看看RC \ RR 的ReadView的生成机制.

RC

RC : 该隔离级别下存在不可重复读问题,存在事务A、事务B、事务C对同一行数据进行DML操作,事务的指向顺序为 : 事务A --> 事务B --> 事务C --> 事务B --> 事务C --> 事务B

  • 事务A : 事务ID为200,执行UPDATE操作后事务提交了(假设更新c列值为100),此时undo log的版本链最新版本是事务ID 200.
    • 【DATA_TRX_ID : 200 , DATA_ROLL_PTR : NULL】
  • 事务B : 事务ID为300,执行普通SELECT,由于事务A提交过了,所以事务ID 200是undo log中的最新版本.此时执行事务B,m_ids为[300],由于事务ID 200(DATA_TRX_ID) 小于 事务ID 300(up_limit_id),说明事务B可以读事务ID 200的数据,此时查询到c列的值为100,并且事务B暂不commit.
    • 【DATA_TRX_ID : 200 , DATA_ROLL_PTR : NULL ,m_ids = [300]】
  • 事务C : 事务ID为400,事务B执行完普通查询后,执行事务C,事务C把c列改为200,并且不提交;
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300,400]】
  • 事务B : 再执行一次查询,此时m_ids为[300,400],undo log的版本链最新版本是事务ID 400,由于事务ID 400(DATA_TRX_ID) 在m_ids范围内,说明事务B不能读事务ID 400的数据,此时需要继续向上找,事务ID(DATA_TRX_ID) 400 的 DATA_ROLL_PTR 指向了 事务ID(DATA_TRX_ID) 200,所以此时用事务ID(DATA_TRX_ID) 200 去判断,由于事务ID 200(DATA_TRX_ID) 小于 事务ID 300(up_limit_id),说明事务B可以读事务ID 200的数据,此时查询到c列的值还是100.
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300,400]】
  • 事务C : 事务C提交,此时undo log的版本链最新版本还是事务ID 400
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300]】
  • 事务B : 再次查询,此时m_ids为[300],undo log的版本链最新版本为事务ID 400,由于事务ID 400(DATA_TRX_ID) 大于 事务ID 300(low_limit_id),说明事务B可以读事务ID 400的数据,此时查询到c列的值为200,并且事务B执行commit.

  上面例子中,事务B在同一事务中的三次查询,第一、二次的查询结果都是100(第二次不能读未提交的数据,避免了脏读),第三次的查询结果是200(可以读已提交的数据),这种现象就是不可重复读现象.RC 隔离级别下的每次SELECT,都会生成不同的m_ids,也就是说ReadView一直在变化,造成了不可重复读现象.

RR

RR : 该隔离级别下解决了不可重复读问题,存在事务A、事务B、事务C对同一行数据进行DML操作,事务的指向顺序为 : 事务A --> 事务B --> 事务C --> 事务B --> 事务C --> 事务B

  • 事务A : 事务ID为200,执行UPDATE操作后事务提交了(假设更新c列值为100),此时undo log的版本链最新版本是事务ID 200.
    • 【DATA_TRX_ID : 200 , DATA_ROLL_PTR : NULL ,m_ids = [300]】
  • 事务B : 事务ID为300,执行普通SELECT,由于事务A提交过了,所以事务ID 200是undo log中的最新版本.此时执行事务B,m_ids为[300],由于事务ID 200(DATA_TRX_ID) 小于 事务ID 300(up_limit_id),说明事务B可以读事务ID 200的数据,此时查询到c列的值为100,并且事务B暂不commit.
    • 【DATA_TRX_ID : 200 , DATA_ROLL_PTR : NULL ,m_ids = [300]】
  • 事务C : 事务ID为400,事务B执行完普通查询后,执行事务C,事务C把c列改为200,并且不提交;
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300]】
  • 事务B : 再执行一次查询,此时m_ids为[300],undo log的版本链最新版本是事务ID 400,由于事务ID 400(DATA_TRX_ID) 大于 事务ID 300(low_limit_id),说明事务B不可以读事务ID 400的数据,此时需要继续向上找,事务ID(DATA_TRX_ID) 400 的 DATA_ROLL_PTR 指向了 事务ID(DATA_TRX_ID) 200,所以此时用事务ID(DATA_TRX_ID) 200 去判断,由于事务ID 200(DATA_TRX_ID) 小于 事务ID 300(up_limit_id),说明事务B可以读事务ID 200的数据,此时查询到c列的值还是100.
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300]】
  • 事务C : 事务C提交,此时undo log的版本链最新版本还是事务ID 400
    • 【DATA_TRX_ID : 400 , DATA_ROLL_PTR : 200 ,m_ids = [300]】
  • 事务B : 再次查询,此时m_ids为[300],undo log的版本链最新版本为事务ID 400,由于事务ID 400(DATA_TRX_ID) 大于 事务ID 300(low_limit_id),说明事务B不可以读事务ID 400的数据,此时需要继续向上找,事务ID(DATA_TRX_ID) 400 的 DATA_ROLL_PTR 指向了 事务ID(DATA_TRX_ID) 200,所以此时用事务ID(DATA_TRX_ID) 200 去判断,由于事务ID 200(DATA_TRX_ID) 小于 事务ID 300(up_limit_id),说明事务B可以读事务ID 200的数据,此时查询到c列的值还是100,并且事务B执行commit.

  上面例子中,事务B在同一事务中的三次查询,第一、二、三次的查询结果是100,这种现象就是可重复读现象.RR 隔离级别下的每次SELECT,m_ids是不变的,也就是ReadView不会再变化,m_ids是不变的,解决了不可重复读现象.

RC \ RR 的生成ReadView机制 ---- 小结

  RC、RR两种隔离级别的事务在执行普通的读操作时,通过访问undo log版本链,使得事务间的读写操作得以并发执行,从而提升系统性能.
  RC、RR这两个隔离级别的一个很大不同就是生成ReadView的时间点不同 :

  • 对于RC 隔离级别,每次的SELECT语句前都会生成一个ReadView,事务期间会更新,因此在其他事务提交前后所得到的m_ids列表可能发生变化,使得先前不可见的版本后续又突然可见了.
  • 对于RR 隔离级别,只在事务的第一个SELECT语句时生成一个ReadView,事务操作期间不再更新.

结束语

  • 原创不易
  • 希望看完这篇文章的你有所收获!

相关参考资料


目录