更多详情内容请访问:MySQL 系列文章导读

1、事务基本知识

1.1 四大特性 ACID

  • 原子性(atomicity)

    指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚

  • 一致性(consistency)

    指事务执行前后,数据从一个合法性状态变换到另外一个合法性状态。这种状态是语义上的,而不是语法上的

    举例:A 账户有 200 元,转账 300 元,此时 A 账户余额 -100 元。此时数据就违反了一致性,因为余额不能小于 0

  • 隔离性(isolation)

    一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰

  • 持久性(durability)

    事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响

    持久性是通过 事务日志 来保证的。日志包括了 重做日志回滚日志 。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执行,从而使事务具有持久性

1.2 事务的状态

  • 活动的(active)

    事务对应的数据库操作正在执行过程中时,我们就说该事务处在 活动的 状态。

  • 部分提交的(partially committed)

    当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并 没有刷新到磁盘 时,我们就说该事务处在 部分提交 的状态。

  • 失败的(failed)

    当事务处在 活动的 或者部分提交的 状态时,可能遇到了某些错误(数据库自身的错误、操作系统 错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。

  • 中止的(aborted)

    如果事务执行了一部分而变为 失败的 状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称 之为 回滚 。当 回滚 操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了 中止的 状态。

  • 提交的(committed)
    当一个处在 部分提交的 状态的事务将修改过的数据都 同步到磁盘 上之后,我们就可以说该事务处在了 提交的 状态。

1.3 Redo Log 与 Undo Log

1.3.1 Undo Log

Undo Log 的原理很简单,为了满足事务的原子性,在操作任何数据之前,首先将数据备份到一个地方(这个存储数据备份的地方称为 Undo Log)。然后进行数据的修改。如果出现了错误或者用户执行了 ROLLBACK 语句,系统可以利用 Undo Log 中的备份将数据恢复到事务开始之前的状态。除了可以保证事务的原子性,Undo Log 也可以用来辅助完成事务的持久化。

用 Undo Log 实现原子性和持久化的事务的简化过程:假设有两个数据 A、B,值分别为 1,2

A. 事务开始

B. 记录 A=1 到 undo log

C. 修改 A=3

D. 记录 B=2 到 undo log

E. 修改 B=4

F. 将 undo log 写到磁盘

G. 将数据写到磁盘

H. 事务提交

这里有一个隐含的前提条件:数据都是先读到内存中,然后修改内存中的数据,最后将数据写回磁盘。之所以能同时保证原子性和持久化,是因为以下特点:

  1. 更新数据前记录 Undo log
  2. 为了保证持久性,必须将数据在事务提交前写到磁盘。只要事务成功提交,数据必然已经持久化
  3. Undo log 必须先于数据持久化到磁盘。如果在 G,H 之间系统崩溃,undo log 是完整的,可以用来回滚事务
  4. 如果在 A-F 之间系统崩溃,因为数据没有持久化到磁盘。所以磁盘上的数据还是保持在事务开始前的状态

每个事务提交前将数据和 Undo Log 写入磁盘,这样会导致大量的磁盘 IO,因此性能很低。如果能够将数据缓存一段时间,就能减少IO提高性能。但是这样就会丧失事务的持久性。因此引入了另外一种机制来实现持久化,即Redo Log

1.3.2 Redo Log

Redo 日志记录某数据块被修改后的值,可以用来恢复未写入 data file 的已成功事务更新的数据

和 Undo Log 相反,Redo Log 记录的是新数据的备份。在事务提交前,只要将 Redo Log 持久化即可,不需要将数据持久化。当系统崩溃时,虽然数据没有持久化,但是 Redo Log 已经持久化。系统可以根据 Redo Log 的内容,将所有数据恢复到最新的状态。

Undo + Redo事务的简化过程:假设有两个数据 A、B,值分别为 1,2

A. 事务开始

B. 记录 A=1 到 undo log

C. 修改 A=3

D. 记录 A=2 到 redo log

E. 记录 B=2 到 undo log

F. 修改 B=4

G. 记录 B=4 到 redo log

H. 将 redo log 写入磁盘

I. 事务提交

2、事务隔离级别

2.1 数据并发问题

2.1.1 脏写

对于两个事务 Session A、Session B,如果事务 Session A 修改了 另一个 未提交 事务Session B 修改过 的数据,那就意味着发生了 脏写

image-20220623221218530

SessionA 和 SessionB 各开启了一个事务,SessionB 中的事务先将 studentno 列为 1 的记录的 name 列更新为“李四”,然后 SessionA 中的事务接着又把这条记录更新为”张三”。如果 SessionB 事务回滚,那么 SessionA 中的更新也将不复存在,这种现象就称之为 脏写

2.1.2 脏读

对于两个事务 Session A、Session B,Session A 读取了已经被 Session B 更新 但还 没有被提交 的字段。之后若 Session B 回滚 ,Session A 读的内容就是临时且无效的

image-20220623222612346

SessionA 和 SessionB 各开启了一个事务,SessionB 中的事务先将列更新为“张三”,然后 SessionA 中的事务再去查询这条的记录,如果读到列 name 的值为“张三“,而 SessionB 中的事务稍后进行了回滚,那么 SessionA 中的事务相当于读到了一个不存在的数据,这种现象就称之为 脏读

2.1.3 不可重复读

对于两个事务 SessionA、SessionB,SessionA 读取 了一个字段,然后 SessionB 更新 了该字段。 之后 SessionA 再次读取同一个字段,值就不同 了。那就意味着发生了不可重复读

image-20220623223324288

2.1.4 幻读

对于两个事务Session A、Session B, Session A 从一个表中 读取 了一个字段, 然后 Session B 在该表中 插入 了一些新的行。 之后, 如果 Session A 再次读取 同一个表, 就会多出几行。那就意味着发生了幻读

image-20220623223348735

如果 SessionB 删除了一些符合 studentno > 0 的记录而不是插入新纪录,SessionA 读取的 记录变少了,这种现象 不属于幻读幻读强调一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录

2.2 MySQL 支持的四种隔离级别

MySQL 在 REPEATABLE READ 隔离级别上就已经解决了幻读的问题(正因为MVCC 机制)

隔离级别 脏写 脏读 不可重复读 幻读
READ UNCONMITED(读未提交)
READ COMMITED(读已提交)
REPEATABLE READ(可重复读)
SERIALIZABLE(串行)

“√”表示已经该隔离级别下解决了的数据并发问题

3、锁的不同角度划分

Image From 第15章_锁

3.1 读锁与写锁

  • 读锁 :也称为 共享锁 、英文用 S 表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。

    select ... lock in share mode;
  • 写锁 :也称为 排他锁 、英文用 X 表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

    select ... for update

举例:事务 T1 获取某行 r 的读锁,那么另一事务 T2 可以获得这个行的读锁,但是事务 T3 想获得 r 的写锁,则必须等待事务 T1、T2 释放行 r 上的读锁才行。

对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上

3.2 表级锁、页级锁、行锁

3.2.1 表锁

  • 共享锁用法:

    LOCK TABLE table_name [ AS alias_name ] READ
  • 排它锁用法:

    LOCK TABLE table_name [AS alias_name][ LOW_PRIORITY ] WRITE
  • 解锁用法:

    unlock tables

除此之外,表锁之中还涵盖了以下几种锁类型

  1. 表级别的 S 锁、X 锁

  2. 意向锁

    如果没有意向锁,当已经有人使用行锁对表中的某一行进行修改时,如果另外一个请求要对全表进行修改,那么就需要对所有的行是否被锁定进行扫描,在这种情况下,效率是非常低的;不过,在引入意向锁之后,当有人使用行锁对表中的某一行进行修改之前,会先为表添加意向互斥锁(IX),再为行记录添加互斥锁(X),在这时如果有人尝试对全表进行修改就不需要判断表中的每一行数据是否被加锁了,只需要通过等待意向互斥锁被释放就可以了。

    • 意向共享锁:事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁
    • 意向排它锁:事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁
    • 意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁
  3. 自增锁

  4. 元数据锁

3.2.2 行锁

  1. 记录锁(Record Lock) :也被称为记录锁,单条索引上加锁,record lock 永远锁的是索引,而非数据本身,如果 innodb 表中没有索引,那么会自动创建一个隐藏的聚集索引,锁住的就是这个聚集索引。

  2. 间隙锁(Gap Lock) :锁定一个范围,不包括记录本身。这也是为什么 Repeatable Read 隔离级别下能防止幻读的主要原因

    举个栗子

    mysql> select * from product_copy;
    +----+--------+-------+-----+
    | id | name   | price | num |
    +----+--------+-------+-----+
    |  1 | 伊利   |    68 |   1 |
    |  2 | 蒙牛   |    88 |   1 |
    |  6 | tom    |  2788 |   3 |
    | 10 | 优衣库 |   488 |   4 |
    +----+--------+-------+-----+
    
    其中id为主键 num为普通索引
    窗口A:
    mysql> select * from product_copy where num=3 for update;
    +----+------+-------+-----+
    | id | name | price | num |
    +----+------+-------+-----+
    |  6 | tom  |  2788 |   3 |
    +----+------+-------+-----+
    1 row in set
    
    窗口B:
    mysql> insert into product_copy values(5,'kris',1888,2);
    这里会等待,直到窗口 A commit才会显示下面结果
    Query OK, 1 row affected
    
    但是下面是不需要等待的
    mysql> update product_copy set price=price+100 where num=1;
    Query OK, 2 rows affected
    
    mysql> insert into product_copy values(5,'kris',1888,5);
    Query OK, 1 row affected

    通过上面的例子可以看出 Gap 锁的作用是在 1,3 的间隙之间加上了锁。而且并不是锁住了表,我更新num=1,5 的数据是可以的,可以看出锁住的范围是(1,3]U[3,4)

    解决幻读的方式很简单,就是需要当事务进行当前读的时候,保证其他事务不可以在满足当前读条件的范围内进行数据操作。

    根据索引的有序性,我们可以从上面的例子推断出满足 where 条件的数据,只能插入在 num=(1,3]U[3,4) 两个区间里面,只要我们将这两个区间锁住,那么就不会发生幻读。

    主键索引 / 唯一索引 + 当前读会加上 Gap 锁吗?

    因为主键索引和唯一索引的值只有一个,所以满足检索条件的只有一行,故并不会出现幻读,所以并不会加上 Gap 锁。

  3. 临键锁(Next-key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。

3.2.3 页锁

页锁就是在 页的粒度 上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我 们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。 页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

3.3 悲观锁与乐观锁

3.3.1 悲观锁

悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上 锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞, 用完后再把资源转让给其它线程 )。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中 synchronizedReentrantLock 等独占锁就是悲观锁思想的实现。

3.3.2 乐观锁

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用 版本号机制 或者 CAS机制 实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在 Java 中 java.util.concurrent.atomic 包下的原子变量类就是使用了乐观锁

1.乐观锁的版本号机制

在表中设计一个版本字段 version ,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时 如果已经有事务对这条数据进行了更改,修改就不会成功。

2.乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

3.3.3 两种锁的适用场景

  1. 乐观锁 适合 读操作多 的场景,相对来说写的操作比较少。它的优点在于 程序实现不存在死锁 问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
  2. 悲观锁 适合 写操作多 的场景,因为写的操作具有 排它性。采用悲观锁的方式,可以在数据库层 面阻止其他事务对该数据的操作权限,防止 读 - 写写 - 写 的冲突。

image-20220411211307532

3.4 InnoDB 锁的特性

  1. 在不通过索引条件查询的时候,InnoDB 使用的确实是表锁
  2. 由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的
  3. 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行;另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁
  4. 即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引。这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划(explain 查看),以确认是否真正使用了索引

4、多版本并发控制 MVCC

MVCC (Multiversion Concurrency Control),多版本并发控制。顾名思义,MVCC 是通过数据行的多个版本管理来实现数据库的 并发控制 。这项技术使得在 InnoDB 的事务隔离级别下执行 一致性读 操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁

4.1 快照读与当前读

MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理 读-写冲突,做到即使有读写冲突时,也能做到 不加锁非阻塞并发读 ,而这个读指的就是 快照读 , 而非 当前读。当前读实际上是一种加锁的操作,是悲观锁的实现。而 MVCC 本质是采用乐观锁思想的一种方式

4.1.1 快照读

快照读又叫一致性读,读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞读;比如这样:

SELECT * FROM player WHERE ... 

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于 MVCC,它在很多情况下,避免了加锁操作,降低了开销。

既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本

快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

4.1.2 当前读

当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。比如:

SELECT * FROM student LOCK IN SHARE MODE; # 共享锁 
SELECT * FROM student FOR UPDATE; # 排他锁
INSERT INTO student values ... # 排他锁
DELETE FROM student WHERE ... # 排他锁
UPDATE student SET ... # 排他锁

4.1.3 快照读、当前读、MVCC 三者关系

MVCC 就是为了实现读(SELECT)-写冲突不加锁,而这个读指的就是快照读而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现

多版本并发控制(MVCC)为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照

4.2 Undo Log 版本链

对于 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含三个必要的隐藏列:

  • trx_id:最新更新这条行记录的 transaction id,每处理一个事务,其值自动 +1
  • roll_pointer回滚指针,指向这条记录的上一个版本。每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo日志 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息
  • row_id:隐藏的主键,如果数据表没有主键,那么 innodb 会自动生成一个 6 字节的 row_id
image-20220121231531819

insert undo 只在事务回滚时起作用,当事务提交后,该类型的 undo 日志就没用了,它占用的 Undo Log Segment 也会被系统回收(也就是该 undo 日志占用的 Undo 页面链表要么被重用,要么被释放)

4.2.1 insert undo log

代表事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃

4.2.2 update undo log

事务在进行 update 或 delete 时产生的 undo log;不仅在事务回滚时需要,在快照读(select,当读的过程中有写的事务开始和提交,会造成读数据的脏读、不可重复读、幻读等)时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。

点击展开详情举例

比如一个有个事务插入 persion 表插入了一条新记录,记录如下:

image-20220625130859562

现在来了一个事务 1 对该记录的 name 属性做出修改,更改为 Tom

在事务 1 修改该行数据(记录),数据库会对该行加 排它锁 后,再把该行数据拷贝到 Undo Log 中,作为旧记录,即在 Undo Log 中有当前行的拷贝副本

拷贝完后,修改该行 name 为 Tom,并且修改隐藏字段的事务 Id 为当前事务 1 的 ID(默认从 1 开始),之后递增,回滚指针指向拷贝到 Undo Log 的副本记录,即表示其上一个版本就是它

事务提交后,释放锁

image-20220625235739678

又来一个事务 2 修改同一表的同一记录,将 age 修改为 30岁

在事务 2 修改该行记录时,数据库也先会为该行加锁,再把该行数据拷贝到 Undo Log 中作为旧记录,但是发现该行已经有 Undo Log 了,那么最新的旧记录作为链表的表头,插在该行记录 Undo Log 的最前面

修改该行 age 为 30 后,并修改隐藏字段的事务 ID 为当前事务 2 的 ID,回滚指针指向刚拷贝到 Undo Log 的副本记录。最后,事务提交,释放锁.

image-20220626000231688

从上面可以看出,不同事务或者相同事务的对同一记录的修改,会导致该行的 Undo Log 称为一条记录版本线性表,即事务链,Undo Log 的链首就是最新的旧记录,链尾就是最早的旧记录

4.3 Read View

4.3.1 何为 Read View

Read View 是事务进行快照读操作的时候产生的读视图,在该事务执行快照读的那一刻,会生成一个数据系统当前的快照,记录并维护系统当前活跃事务(未提交事务)的 ID(当每一个事务开启时,都会被分配一个ID,这个 ID 是递增的,所以最新事务的 ID 也是最大的)

当某个事务执行快照读的时候,对该记录创建 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据

4.3.2 Read View 设计思路

使用 READ UNCOMMITTED 隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。

使用 SERIALIZABLE 隔离级别的事务,InnoDB 规定使用加锁的方式来访问记录。

使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务,都必须保证读到 已经提交了的 事务修改的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是 ReadView 要解决的主要问题。

  • 在 READ COMMITED(读已提交)隔离级别下,每个快照读都会生成并获取最新的 Read View

  • 在 REPEATABLE READ(可重复读)隔离级别下,同一事务中第一个快照读才会创建 Read View,之后的快照读获取的都是同一个 Read View

    按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

    因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

    当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的

4.3.3 Read View 结构

  1. creator_trx_id:表示生成该 Read View 的快照读操作产生的事务 id

    说明:只有在对表中的记录做改动时(执行 INSERT、DELETE、UPDATE 这些语句时)才会为事务分配事务 id,否则在一个只读事务中的事务 id 值都默认为 0。

  2. m_ids:表示在生成 Read View 时当前系统中活跃的读写事务的 事务id列表

  3. min_trx_id:活跃的事务中最小的事务 ID。

  4. max_trx_id:表示生成 Read View 时系统中应该分配给下一个事务的 id 值。low_limit_id 是系统最大的事务 id 值,这里要注意是系统中的事务 id,需要区别于正在活跃的事务ID。

    注意:

    low_limit_id 并不是 trx_ids 中的最大值,事务 id 是递增分配的。比如,现在有 id 为 1, 2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 Read View 时, trx_ids 就包括 1 和 2,up_limit_id 的值就是 1,low_limit_id 的值就是 4。

4.3.4 Read View 判断规则

  1. 从数据的最新版本开始判断(undo log)

  2. 如果被访问版本的 trx_id 属性值与 Read View 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  3. 如果被访问版本的 trx_id 属性值小于 Read View 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 Read View 前已经提交,所以该版本可以被当前事务访问。

  4. 如果被访问版本的 trx_id 属性值大于或等于 Read View 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 Read View 后才开启,所以该版本不可以被当前事务访问。

  5. 数据版本的 trx_idmin_trx_idmax_trx_id 之间,需要判断 trx_id 属性值是不是在 trx_ids 列表中(这条针对 RC 来说,因为 RR 只生成一次 Read View 不会出现这种情况):

    • 如果在,说明创建 Read View 时生成该版本的事务还是活跃的,该版本不可以被访问
    • 如果不在,说明创建 Read View 时生成该版本的事务已经被提交,该版本可以被访问
  6. 如果当前版本不可见,通过 roll_pointer 就找 undo log 链中的下一个版本

4.4 MVCC 整体操作流程

了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过 MVCC 找到它:

  1. 首先获取事务自己的版本号,也就是事务 ID;
  2. 获取 Read View;
  3. 查询得到的数据,然后与 Read View 中的事务版本号进行比较;
  4. 如果不符合 Read View 规则,就需要从 Undo Log 中获取历史快照;
  5. 最后返回符合规则的数据。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,以此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

InnoDB 中,MVCC 是通过 Undo Log + Read View 进行数据读取,Undo Log 保存了历史快照,而 Read View 规则判断当前版本的数据是否可见

在隔离级别为读已提交(Read Committed)时,一个事务中的每一次 SELECT 查询都会重新获取一次 Read View。

事务 说明
begin;
select * from student where id >2; 获取一次 Read View
.........
select * from student where id >2; 获取一次 Read View
commit;

注意,此时同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。

当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:

image-20220121232257642

点击展开详情举例

假设有四个事务同时在执行:

事务 1 事务 2 事务 3 事务 3
事务开始 事务开始 事务开始 事务开始
... ... ... 修改且已提交
进行中 快照读 进行中
... ... ...

当事务 2 对某行数据执行了快照读,数据库为该行数据生成了一个 Read View 视图,此时事务 1 和 事务 3 还在活跃状态,事务 4 在事务 2 快照读的前一刻提交了更新。所以,在 Read View 中记录了当前系统活跃中的事务:事务 1、事务 2、事务 3,将它们三个维护在一个列表中;同时 up_limit_id 为 1,而 low_limit_id 为 5

image-20220625112705483

在上述的例子中,只有事务 4 修改过该行记录,并在事务 2 进行快照读前,就提交了事务,所以该行当前数据的 Undo Log 如下所示∶

image-20220625112925131

当事务 2 在快照读该行记录时,会拿着该行记录的 db_trx_id 去跟 up_limit_dlower_llimit_id 和活跃事务列表进行比较,判读事务 2 能看到该行记录的版本是哪个,具体流程如下:

  1. 先拿该行记录的事务 ID(4)去跟 Read View 中的 up_limit_id 相比较,判断是否小于,通过对比发现不符合条件;
  2. 继续判断 4 是否大于等于 low_limit_id,通过比较发现也不符合
  3. 再去判断事务 4 是否在 trx_list 列表中,发现不在此列表中
  4. 那么符合可见性条件,所以事务 4 修改后提交的最新结果对事务 2 的快照是可见的。因此,事务 2 读到的最新数据记录是事务 4 所提交的版本,而事务 4 提交的版本也是全局角度的最新版本

4.5 如何解决幻读问题

假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图 所示。

image-20220626124038034

假设现在有事务 A 和事务 B 并发执行, 事务A 的事务id为 20事务B 的事务id为 30

步骤 1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。

select * from student where id >= 1;

在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 Read View 的内容如下: trx_ids=[20,30]min_trx_id=20max_trx_id=31creator_trx_id=20

由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 Read View 机制,发现该行数据的 trx_id=10,小于事务 A 的 ReadView 里 min_trx_id,这表示这条数据是事务 A 开 启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。

结论:事务 A 的第一次查询,能读取到一条数据,id=1。

步骤 2:接着事务 B(trx_id=30),往表 student 中新插入两条数据,并提交事务。

 insert into student(id,name) values(2,'李四'); 
 insert into student(id,name) values(3,'王五');

此时表 student 中就有三条数据了,对应的 undo 如下图所示:

image-20220121233225279

步骤 3:接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成 Read View。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据 Read View 机制,判断每条数据是不是都可以被事务 A 看到。

  1. 首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。
  2. 然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之 间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表 示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。
  3. 同理,id=3 的这条数据,trx_id 也为 30,因此也不能被事务 A 看见。

image-20220121233244517

结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样 的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。


END

本文作者:
文章标题:事务与锁
本文地址:https://www.pendulumye.com/mysql/345.html
版权说明:若无注明,本文皆个人学习记录原创,转载请保留文章出处。
最后修改:2022 年 07 月 15 日
千山万水总是情,给个一毛行不行💋