秋招—MySQL下

1. MySQL 事务

1.1 何谓事务?数据库事务?

何为事务? 一言蔽之,事务是逻辑上的一组操作,要么都执行,要么都不执行。

大多数情况下,我们在谈论事务的时候,如果没有特指分布式事务,往往指的就是数据库事务

那数据库事务有什么作用呢?

简单来说,数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行

#显式事务
# 开启一个事务
START TRANSACTION;后面可以跟read only/ read write/ with consistent snapshot
# 多条 SQL 语句
SQL1,SQL2...
## 提交事务
COMMIT;
savepoint:保存点

#隐式事务
autocommit

另外,关系型数据库(例如:MySQLSQL ServerOracle 等)事务都有 ACID 特性:

在这里插入图片描述
原子性Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;

一致性Consistency): 执行事务前后,数据保持一致,就是从一个合法状态转到另一个合法状态,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;

隔离性Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;

持久性Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

原子性是基础,隔离性是手段,一致性是约束条件,而持久性是目的

1.2 并发事务带来的问题

脏读(Dirty read)

​ 一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的,即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据,这也就是脏读的由来。

例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A = 19,事务 1 回滚导致对 A 的修改并为提交到数据库, A 的值还是 20。
在这里插入图片描述
丢失修改(Lost to modify)

在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。

例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。
在这里插入图片描述
不可重复读(Unrepeatable read)

指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。

例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。
在这里插入图片描述
幻读(Phantom read)

幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。
在这里插入图片描述

1.3 不可重复读和幻读的区别
  • 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改;
  • 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。

幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样。

举个例子:执行 deleteupdate 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。

1.4 SQL 标准定义的事务隔离级别

SQL 标准定义了四个隔离级别:

  • READ-UNCOMMITTED(读取未提交) : 最低的隔离级别,一个事务在执行过程中,**既可以访问其他事务未提交的新插入的数据,又可以访问未提交的修改数据。**如果一个事务已经开始写数据,则另外一个事务不允许同时进行写操作,但允许其他事务读此行数据。此隔离级别可防止丢失修改,可能会导致脏读、幻读或不可重复读。
  • READ-COMMITTED(读取已提交) : 一个事务在执行过程中,既可以访问其他事务成功提交的新插入的数据,又可以访问成功修改的数据。读取数据的事务允许其他事务继续访问该行数据但是未提交的写事务将会禁止其他事务访问该行此隔离级别可有效防止脏读,但是幻读或不可重复读仍有可能发生。
  • REPEATABLE-READ(可重复读) :一个事务在执行过程中,可以访问其他事务成功提交的新插入的数据,但不可以访问成功修改的数据。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。此隔离级别可有效防止不可重复读和脏读对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,但幻读仍有可能发生。
  • SERIALIZABLE(可串行化) : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
隔离级别脏读不可重复读幻读
READ-UNCOMMITTED
READ-COMMITTED×
REPEATABLE-READ××
SERIALIZABLE×××
1.5 MySQL 的隔离级别是基于什么实现的?

MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。

SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。

1.6 MySQL 的默认隔离级别是什么?

MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)
我们可以通过SELECT @@tx_isolation;命令来查看;MySQL 8.0 该命令改为SELECT@@transaction_isolation;

1.7 并发事务的控制方式有哪些?

MySQL 中并发事务的控制方式无非就两种:MVCC。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。

方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。

所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。

tips: 这里幻读没解决!!!

方案二:读和写都加锁。

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着操作和操作也像写-写操作那样排队执行。

脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。

不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。

幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。

小结对比发现:

  • 采用MVCC方式的话,读-写操作彼此并不冲突,性能更高
  • 采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。

一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。下面就讲解下MySQL中不同类别的锁。

控制方式下会通过锁来显示控制共享资源而不是通过调度手段,MySQL 中主要是通过 读写锁 来实现并发控制。

  • 共享锁(S 锁) :又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
  • 排他锁(X 锁) :又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。

需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。
在这里插入图片描述
读写锁可以做到读读并行,但是无法做到写读、写写并行。另外,根据根据锁粒度的不同,又被分为 表级锁(table-level locking)行级锁(row-level locking) 。InnoDB 不光支持表级锁,还支持行级锁,默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类。

  • 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
#或
SELECT ... FOR SHARE;#(8.0新增语法)
  • 对读取的记录加X锁:
SELECT ... FOR UPDATE;

平常所用到的写操作无非是DELETEUPDATEINSERT这三种:

  • DELETE: 对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark.操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁锁定读

  • UPDATE∶在对一条记录做UPDATE操作时分为三种情况:

    • 情况1: 未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化

      则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁锁定读

    • 情况2∶未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。

      则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取×锁锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。

    • 情况3∶修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETEINSERT的规则进行了。

  • INSERT :一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问

MVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本。通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。

MVCC 在 MySQL 中实现所依赖的手段主要是: 隐藏字段、read view、undo log

  • undo log : undo log 用于记录某行数据的多个版本的数据。
  • read view 和 隐藏字段 : 用来判断当前版本数据的可见性。

2. MySQL 锁

锁是一种常见的并发事务的控制方式。

在写-写的情况下:

锁结构:
在这里插入图片描述
锁结构和事务相关的,有几个事务就有几个锁结构。

在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,图示:
在这里插入图片描述
在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:
小结几种说法:

  • 不加锁

    意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。

  • 获取锁成功,或者加锁成功

    意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务可以继续执行操作。

  • 获取锁失败,或者加锁失败,或者没有获取到锁

    意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务需要等待,不可以继续执行操作。

2.1 从数据操作的类型划分:共享锁和排他锁

见上文。

2.2 从数据操作的粒度划分表级锁和行级锁以及页级锁

MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。

行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。

表级锁和行级锁对比

  • 表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁优点:实现简单,资源消耗也比较少,加锁快,不会出现死锁。缺点,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁
  • 行级锁: MySQL 中锁定粒度最小的一种锁,是 针对索引字段加的锁 ,只针对当前操作的行记录进行加锁。 优点:行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高;缺点但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的

表级别的S锁、X锁

MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。

MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)

  • 表共享读锁(Table Read Lock)
  • 表独占写锁(Table Write Lock)
    在这里插入图片描述
2.3 意向锁 (intention lock)

InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,而 意向锁 就是其中的一种表锁

1、意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。

2、意向锁是一种不与行级锁冲突表级锁,这一点非常重要。

3、表明“某个事务正在某些行持有了锁或该事务准备去持有锁”

意向锁分为两种:

  • 意向共享锁 (intention shared lock, IS):事务有意向对表中的某些行加 共享锁 (S锁
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 
-- 会自动加,不用管
SELECT column FROM table ... LOCK IN SHARE MODE;
  • 意向排他锁 (intention exclusive lock, IX):事务有意向对表中的某些行加 排他锁 (X锁)
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
-- 会自动加,不用管
SELECT column FROM table ... FOR UPDATE;

即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁

意向锁要解决的问题

现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。

如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了

  • 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁
  • 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁

意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。

根据查询不同的行是兼容的!
在这里插入图片描述
即意向锁之间是互相兼容的,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥。
在这里插入图片描述
意向锁的并发性

意向锁不会与行级的共享 / 排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了)

结论:

  1. InnoDB 支持多粒度锁,特定场景下,行级锁可以与表级锁共存。
  2. 意向锁之间互不排斥,但除了 IS 与 S 兼容外,意向锁会与 共享锁 / 排他锁 互斥
  3. IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。
  4. 意向锁在保证并发性的前提下,实现了行锁和表锁共存满足事务隔离性的要求。
2.4 自增锁和元数据锁(MDL锁)

自增锁(AUTO-INC锁)

在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性。举例:

CREATE TABLE `teacher` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar( 255 ) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

由于这个表的id字段声明了AUTO_INCREMENT,意味着在书写插入语句时不需要为其赋值,SQL语句修改 如下所示。

INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi');

如果一个事务正在插入数据到有自增列的表时,会先获取自增锁,拿不到就可能会被阻塞住。这里的阻塞行为只是自增锁行为的其中一种,可以理解为自增锁就是一个接口,其具体的实现有多种。具体的配置项为 innodb_autoinc_lock_mode (MySQL 5.1.22 引入),可以选择的值如下:
在这里插入图片描述
交错模式下,所有的“INSERT-LIKE”语句(所有的插入语句,包括: INSERTREPLACEINSERT…SELECTREPLACE…SELECTLOAD DATA等)都不使用表级锁,使用的是轻量级互斥锁实现,多条插入语句可以并发执行,速度更快,扩展性也更好。

不过,如果你的 MySQL 数据库有主从同步需求并且 Binlog 存储格式为 Statement 的话,不要将 InnoDB 自增锁模式设置为交叉模式,不然会有数据不一致性问题。这是因为并发情况下插入语句的执行顺序就无法得到保障。

元数据锁(MDL锁)

MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此, 当对一个表做增删改查操作的时候,加 MDL读锁(默认);当要对表做结构变更操作的时候,加 MDL 写锁。

读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显式使用,在访问一个表的时候会被自动加上。

并发问题:两个读就无法并发执行了!

事务1 。加 读锁。
事务2   加  写锁

事务3  加 读锁 # 这个时候会阻塞在这里。
2.5 InnoDB中的行锁

行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要的注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

优点: 锁定力度小,发生锁冲突概率低,可以实现的并发度高

缺点: 对于锁的开销比大,加锁会比较慢,容易出现死锁情况。

InnoDB与MylSAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。

InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。当我们执行 UPDATEDELETE 语句时,如果 WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!

① 记录锁(Record Locks)

记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。比如我们把id值为 8 的 那条记录加一个记录锁的示意图如图所示。仅仅是锁住了id值为 8 的记录,对周围的数据没有影响。
在这里插入图片描述
记录锁是有S锁和X锁之分的,称之为S型记录锁X型记录锁

  • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
  • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。

② 间隙锁(Gap Locks)

MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁。比如,把id值为 8 的那条记录加一个gap锁的示意图如下。
在这里插入图片描述
可以理解为图中id值为 8 的记录加了gap锁,意味着不允许别的事务在id值为 8 的记录前边的间隙插入新记录,其实就是id列的值( 3 , 8 )这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为 4 的新记录,它定位到该条新记录的下一条记录的id值为 8 ,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间( 3 , 8 )中的新记录才可以被插入。

gap锁的提出仅仅是为了防止插入幻影记录而提出的 。虽然有共享gap锁独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。

这里间隙锁,会加到小的数据上。。比如查询 4~8 的id 间隙锁会放到8记录里面。

在这里插入图片描述
如果上面两个事务再继续插入,则都会阻塞,死锁了 都上了间隙锁。

针对插入的!

如果锁的id值大于最大的id值,则范围是(最大值,正无穷)

③ 临键锁(Next-Key Locks)

有时候我们**既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,**所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。

在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。

Next-Key锁本质是一个记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新纪录插入到被保护记录前边的间隙。

④ 插入意向锁(Insert Intention Locks)

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁next-key锁 也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是 InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构 ,表明有事务想在某个间隙插入新记录,但是 现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。插入意向锁是一种Gap锁,不是意向锁,在insert 操作时产生。

插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁。

事实上 插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
在这里插入图片描述
如果A事务在插入位置设置了gap锁,B和C两个事务分别在这个区间进行插入。则会阻塞,分别生成插入意向锁。如果这两个事务在插入的位置不同,等A结束后。两个互不影响。

2.6 页锁(了解)

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

2.7 从对待锁的态度划分:乐观锁、悲观锁
1. 悲观锁(Pessimistic Locking)

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

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

2. 乐观锁(Optimistic Locking)

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

1.乐观锁的版本号机制

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

2. 乐观锁的时间戳机制

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

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

3. 两种锁的适用场景

从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁

问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

  1. 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层

面阻止其他事务对该数据的操作权限,防止读 - 写写 - 写的冲突。

2.8 按加锁的方式划分:显式锁、隐式锁
1. 隐式锁

如果一个事务在插入一条数据以后,还没提交,另一个事务来进行增删改查的时候,会加入一个隐式锁。

而插入意向锁是在有间隙锁的情况下有的。
在这里插入图片描述
对于一个行记录来说,有三个隐藏列:roll_id,trx_id,回滚字段。

  • 情景一: 对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
  • 情景二: 对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的PageHeader部分有一个PAGE_MAX_TRX_ID属性该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

隐式锁是一种延迟加载的机制。

隐式锁的逻辑过程如下:

A. InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。

B. 另一个事务在操作一条记录前,首先根据记录中的trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁(就是为该事务添加一个锁)。

C. 检查是否有锁冲突,如果有冲突,创建锁,并设置为waiting状态。如果没有冲突不加锁,跳到E。

D. 等待加锁成功,被唤醒,或者超时。

E. 写数据,并将自己的trx_id写入trx_id字段。

2. 显式锁

通过特定的语句进行加锁,我们一般称之为显示加锁。

2.9 其它锁之:全局锁

全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份

2.10 其它锁之:死锁

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。

这时候,事务 1 在等待事务 2 释放id=2的行锁,而事务 2 在等待事务 1 释放id=1的行锁。 事务 1 和事务 2 在互 相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置。
  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。将参数innodb_deadlock_detect设置为on,表示开启这个逻辑。

第二种策略的成本分析

方法 1 :如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。 但是这种操作本身带有 一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后 通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是 业务有损的。

方法 2 :控制并发度。 如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测 的成本很低,就不会出现这个问题。

这个并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;甚至有能力修改MySQL 源码的人,也可以做在MySQL里面。基本思路就是,对于相同行的更新,在进入引擎之前排队,这样在 InnoDB内部就不会有大量的死锁检测工作了。

3. MVCC(多版本并发控制)

3.1 什么是MVCC

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

3.2 快照读与当前读

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

读-写冲突:读是快照读,写是当前读

1.快照读

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

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

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

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

2.当前读

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

3.3 再谈隔离级别

我们知道事务有 4 个隔离级别,可能存在三种并发问题:
在这里插入图片描述
在MySQL中,默认的隔离级别是可重复读,可以解决脏读和不可重复读的问题,如果仅从定义的角度来看,它并不能解决幻读问题。如果我们想要解决幻读问题,就需要采用串行化的方式,也就是将隔离级别提升到最高,但这样一来就会大幅降低数据库的事务并发能力。

MVCC 可以不采用锁机制,而是通过乐观锁的方式来解决不可重复读和幻读问题!它可以在大多数情况下替代行级锁,降低系统的开销。
在这里插入图片描述

3.4 隐藏字段、Undo Log版本链

回顾一下undo日志的版本链,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(字段)。

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列

  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息

    假设插入该记录的事务id8,那么此刻该条记录的示意图如下所示:
    在这里插入图片描述

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

假设之后两个事务id分别为 10 、 20 的事务对这条记录进行UPDATE操作,操作流程如下:
在这里插入图片描述

能不能在两个事务中交叉更新同一条记录呢?不能!这不就是一个事务修改了另一个未提交事务修改过的数据,脏写。

InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。

每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表:
在这里插入图片描述
对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。

每个版本中还包含生成该版本时对应的事务id

3.5 MVCC实现原理

MVCC 的实现依赖于: 隐藏字段、Undo Log、Read View

1. 什么是ReadView

在MVCC机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在Undo Log里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到ReadView了,它帮我们解决了行的可见性问题。

ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃"指的就是,启动了但还没提交)。

2. 设计思路

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

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

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

ReadView中主要包含 4 个比较重要的内容,分别如下:

  1. creator_trx_id,创建这个 Read View 的事务 ID。

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

  2. trx_ids,表示在生成ReadView时当前系统中活跃的读写事务的事务id列表

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

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

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

3. ReadView的规则

有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的up_limit_id,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的low_limit_id,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的up_limit_id和low_limit_id之间,那就需要判断一下trx_id属性值是不是在trx_ids列表中。
  • 如果,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
  • 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
4. MVCC整体操作流程

当查询一条记录的时候,系统如何通过MVCC找到它:

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

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

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

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

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

当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:
在这里插入图片描述
MVCC只能在READ COMMITTED和REPEATABLE READ两个隔离级别下工作。接下来看一下READ COMMITTEDREPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。

5. READ COMMITTED隔离级别下

READ COMMITTED :每次读取数据前都生成一个ReadView

现在有两个事务id分别为 1020 的事务在执行:

# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;

# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...

说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被 分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新些别的表的记录,目的是让它分 配事务id。

此刻,表student 中id为 1 的记录得到的版本链表如下所示:
在这里插入图片描述
假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 10、 20 未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'

这个SELECT1的执行过程如下:

步骤1: 在执行SELECT语句时会先生成一个ReadView , ReadView的 trx_ids列表的内容就是[10,20]up_limit_id为10, low_limit_id为21, creator_trx_id为0。(查询语句,默认为0)

步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。

步骤3:下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。

步骤4:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘张三’的记录。

之后,我们把事务id10 的事务提交一下:

然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:

# Transaction 20
BEGIN;

# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id= 1 ;
UPDATE student SET name="宋八" WHERE id= 1 ;

此刻,表student中id为 1 的记录的版本链就长这样:
在这里插入图片描述
然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id为 1 的记录,如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 10、 20 均未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'

# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'王五'

这个SELECT2的执行过程如下:

步骤1:在执行SELECT语句时会又会单独生成一个ReadView,该ReadView的trx_ids列表的内容就是[20],up_limitid为.20,low_limit_id为21, creator_trx_id0

步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是‘宋八‘,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。

步骤3:下一个版本的列name的内容是‘钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。

步骤4:下一个版本的列name的内容是’王五’,该版本的trx_id值为10,小于ReadView中的up_limit_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘王五‘的记录。

以此类推,如果之后事务id为20的记录也提交了,再次在使用READ COMMITTED隔离级别的事务中查询表student中id值为1的记录时,得到的结果就是‘宋八’了,具体流程我们就不分析了。

强调: 使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。

6. REPEATABLE READ隔离级别下

使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。

比如,系统里有两个事务id分别为 10 、 20 的事务在执行:

# Transaction 10
BEGIN;
UPDATE student SET name="李四" WHERE id= 1 ;
UPDATE student SET name="王五" WHERE id= 1 ;

# Transaction 20
BEGIN;
# 更新了一些别的表的记录
...

此刻,表student 中id为 1 的记录得到的版本链表如下所示:
在这里插入图片描述
假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 10、 20 未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'

这个SELECT1的执行过程如下(第一个ReadView和读已提交是一样的):

步骤1: 在执行SELECT语句时会先生成一个ReadView , ReadView的 trx_ids列表的内容就是[10,20]up_limit_id为10, low_limit_id为21, creator_trx_id为0。

步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。

步骤3:下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不

符合要求,继续跳到下一个版本。

步骤4:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘张三’的记录。

之后,我们把事务id10 的事务提交一下:

然后再到事务id为 20 的事务中更新一下表student中id为 1 的记录:

# Transaction 20
BEGIN;

# 更新了一些别的表的记录
...
UPDATE student SET name="钱七" WHERE id= 1 ;
UPDATE student SET name="宋八" WHERE id= 1 ;

此刻,表student 中id为 1 的记录的版本链长这样:
在这里插入图片描述
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为 1 的记录,如下:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 10、 20 均未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值为'张三'

# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1 ; # 得到的列name的值仍为'张三'

这个SELECT2的执行过程如下:

步骤1:在执行SELECT语句时会继续使用之前的ReadView,该ReadView的trx_ids列表的内容就是[10,20]up_limit_id为10, low_limit_id为21, creator_trx_id为0。

步骤2:然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’宋八’,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。

步骤3:下一个版本的列name的内容是’钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。

步骤4∶下一个版本的列name的内容是’王五’,该版本的trx_id值为10,而trx_ids列表中是包含值为10的事务id的,所以该版本也不符合要求,同理下一个列name的内容是’李四’的版本也不符合要求。继续跳到下一个版本。

步骤5∶下一个版本的列name的内容是‘张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为‘张三’的记录。

两次SELECT查询得到的结果是重复的,记录的列c值都是‘张三’,这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是‘张三’,具体执行过程大家可以自己分析一下。

3.6 如何解决幻读

假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。
在这里插入图片描述
假设现在有事务 A 和事务 B 并发执行,事务 A 的事务 id 为 20 ,事务 B 的事务 id 为 30 。

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

select * from student where id >= 1 ;

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

由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView 机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_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 如下图所示:
在这里插入图片描述
步骤 3 :接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据ReadView 机制,判断每条数据是不是都可以被事务 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 看见。
在这里插入图片描述
结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样 的,因此没有出现幻读现象,所以说在 MySQL 的可重复读隔离级别下,不存在幻读问题。

3.7 总结

这里介绍了MVCCREAD COMMITTDREPEATABLE READ这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

核心点在于 ReadView 的原理,READ COMMITTDREPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同:

  • READ COMMITTD每一次进行普通SELECT操作前都会生成一个ReadView
  • REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。

说明: 我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCc服务的。

通过MVCC我们可以解决:

  1. 读写之间阻塞的问题。通过MVCC可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事 务并发处理能力。
  2. 降低了死锁的概率。这是因为MVCC采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁 定必要的行。
  3. 解决快照读的问题。当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结 果,而不能看到这个时间点之后事务提交的更新结果。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值