24. 事务的隔离级别与MVCC
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
:::info MySQL服务器可以让多个客户端连接进来,并发进行操作,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的 隔离性 ,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,鱼和熊掌不可得兼,舍一部分 隔离性 而取性能者也。 :::
事务并发执行遇到的问题
脏写(Dirty Write) :::danger 如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了 脏写 ; :::
脏读( Dirty Read ) :::info 如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了 脏读 ; :::
不可重复读(Non-Repeatable Read)
如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了 不可重复读
幻读(Phantom) :::info 如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了 幻读 。幻读 强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。 :::
SQL标准中的四种隔离级别
我们上边介绍了几种并发事务执行过程中可能遇到的一些问题,这些问题也有轻重缓急之分 :::warning 脏写 > 脏读 > 不可重复读 > 幻读 :::
EAD UNCOMMITTED :未提交读。
- READ COMMITTED :已提交读。
- REPEATABLE READ :可重复读。
- SERIALIZABLE :可串行化。
隔离级别越低越容易发生严重的错误
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ UNCOMMITTED | Possible | Possible | Possible |
READ COMMITTED | Not Possible | Possible | Possible |
REPEATABLE READ | Not Possible | Not Possible | Possible |
SERIALIZABLE | Not Possible | Not Possible | Not Possible |
脏写 是怎么回事儿?怎么里边都没写呢?这是因为脏写这个问题太严重了,不论是哪种隔离级别,都不允许脏写的情况发生。
MVCC原理
版本链
对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列
trx_id
:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的 事务id 赋值给 trx_id 隐藏列。roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo日志 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
由此可以构成版本链。
mysql> SELECT * FROM hero;
+--------+--------+---------+
| number | name | country |
+--------+--------+---------+
| 1 | 刘备 | 蜀 |
+--------+--------+---------+
例如对上述表中记录进行更新,假设有两个事务
最终可以生成如下的版本链
版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的 事务id
ReadView
对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;
对于使用 SERIALIZABLE 隔离级别的事务来说,规定使用加锁的方式来访问记录;
:::info
对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。
:::
因此依靠ReadView,ReadView一般包含几个比较重要的内容
m_ids
:表示在生成 ReadView 时当前系统中活跃的读写事务的 事务id 列表。min_trx_id
:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的 事务id ,也就是 m_ids 中的最小值。max_trx_id
:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。creator_trx_id
:表示生成该 ReadView 的事务的 事务id 。如何判断
- 如果被访问的版本的
trx_id
和creator_trx_id
相同,那么表示就是在访问自己修改的版本,可以直接读 - 如果访问版本的trx_id比ReadView中的
min_trx_id
还要小,说明这是已经提交的版本,可以直接读 - 如果被访问版本的
trx_id
属性值大于 ReadView 中的max_trx_id
值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的 trx_id 属性值在 ReadView 的
min_trx_id
和max_trx_id
之间,那就需要判断一下,trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。
在 MySQL 中, READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。
- READ COMMITTED —— 每次读取数据前都生成一个ReadView
- REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
25. 锁
解决并发事务带来问题的两种基本方式
事务并发访问的情况大概分为3种:
- 读-读:读取操作本身不会对记录有影响,并不会引起什么问题,所以允许这种情况的发生
- 写-写:在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过 锁 来实现的。
读-写 or 写-读:这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。这种情况下
trx信息 :代表这个锁结构是哪个事务生成的。
-
锁的工作原理
一开始是没有锁的,所以事务T1建立锁结构后,is_waiting=false。因此T1可以执行,此场景称为获取锁成功或者加锁成功。
当T1未提交时,若是另一个事务T2也对此记录进行操作,同样的他也会建立锁结构,但是在建立时会检测是否已经存在锁结构,若是有则is_waiting=true,此场景称为获取锁失败或者加锁失败。
:::warning 利用锁是解决写-写情况下脏写问题的 ::: 读-写 或 写-读 情况:也就是一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。如何解决呢? 方案一:读操作利用多版本并发控制( MVCC ),写操作进行 加锁 。 :::info 之前讲过可以MVCC实际上是利用了ReadView,其实就像是在生成 ReadView 的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC 时, 读-写 操作并不冲突。 :::
方案二:读、写操作都采用 加锁 的方式。这样在读取记录的时候也就需要对其进行 加锁 操作,这样也就意味着 读 操作和 写 操作也像 写-写 操作那样排队执行。
一致性读(Consistent Reads)
事务利用 MVCC 进行的读取操作称之为 一致性读 ,或者 一致性无锁读 ,有的地方也称之为 快照读 。所有普通的 SELECT 语句( plain SELECT )在 READ COMMITTED 、 REPEATABLE READ 隔离级别下都算是 一致性读 。
一致性读 并不会对表中的任何记录做 加锁 操作,其他事务可以自由的对表中的记录做改动
锁定读(Locking Reads)
在写-写和读-写的场景下,需要通过加锁来避免脏写的情况发生,对于这些锁有一些分类
- 共享锁 ,英文名: Shared Locks ,简称 S锁 。在事务要读取一条记录时,需要先获取该记录的 S锁 。
- 独占锁 ,也常称 排他锁 ,英文名: Exclusive Locks ,简称 X锁 。在事务要改动一条记录时,需要先获取该记录的 X锁 。
锁的兼容性:
- S锁和S锁是兼容的,很容易理解因为读操作和读操作互不影响
- S锁和X锁是不兼容的,即如果一个记录被加了S锁或者X锁,那么它不能再被加上X锁或是S锁,也很容易理解,因为读的过程中不允许被修改。
- X锁和X锁不兼容,因为不能同时修改一条记录。
锁的语句
对读取的记录加 S锁 :SELECT ... LOCK IN SHARE MODE;
对读取的记录加 X锁 :SELECT ... FOR UPDATE;
写操作
平常所用到的 写操作 无非是 DELETE 、 UPDATE 、 INSERT 这三种:
- DELETE:
对一条记录做 DELETE 操作的过程其实是先在 B+ 树中定位到这条记录的位置,然后获取一下这条记录的 X锁 ,然后再执行 delete mark 操作。
- UPDATE:
- 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在 B+ 树中定位到这条记录的位置,然后再获取一下记录的 X锁 ,最后在原记录的位置进行修改操作。
- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+ 树中定位到这条记录的位置,然后获取一下记录的 X锁 ,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。
- 如果修改了该记录的键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就需要按照 DELETE 和 INSERT 的规则进行了。
- INSERT:
一般情况下,新插入一条记录的操作并不加锁,InnoDB通过一种称之为 隐式锁 来保护这条新插入的记录在本事务提交前不被别的事务访问
多粒度锁
我们前边提到的 锁 都是针对记录的,也可以被称之为 行级锁 或者 行锁 ,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在 表 级别进行加锁,自然就被称之为 表级锁 或者 表锁 ,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。
表级锁
表的锁也分为共享锁(S锁)和独占锁(X锁)
如果一个事务给表加了 S锁 ,那么:
- 别的事务可以继续获得该表的 S锁
- 别的事务可以继续获得该表中的某些记录的 S锁
- 别的事务不可以继续获得该表的 X锁
- 别的事务不可以继续获得该表中的某些记录的 X锁
如果一个事务给表加了 X锁 (意味着该事务要独占这个表),那么:
- 别的事务不可以继续获得该表的 S锁
- 别的事务不可以继续获得该表中的某些记录的 S锁
- 别的事务不可以继续获得该表的 X锁
- 别的事务不可以继续获得该表中的某些记录的 X锁
但是在加锁前要先知道表中的某些行是否已经被加锁了,如果已经被加行锁了,那么加表锁的时候可能会有问题:
- 如果想要给表上S锁,那么需要保证表内记录没有X锁,如果有的话要等他解锁了才能上锁
-
意向锁
如果遍历表中每一个记录去查找锁的话,会比较浪费时间,因此有着意向锁的概念。
意向共享锁,英文名: Intention Shared Lock ,简称 IS锁 。当事务准备在某条记录上加 S锁 时,需要先在表级别加一个 IS锁 。
- 意向独占锁,英文名: Intention Exclusive Lock ,简称 IX锁 。当事务准备在某条记录上加 X锁 时,需要先在表级别加一个 IX锁 。
IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。
InnoDB存储引擎中的锁
其实 MySQL 支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。
InnoDB 存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。
InnoDB中的表级锁
- 表级S锁、X锁
在执行INSERT、UPDATE、DELETE时,不会自动给表加表级锁
在执行LTER TABLE 、 DROP TABLE 这类的 DDL 语句时,其他事务对这个表并发执行诸如 SELECT 、 INSERT 、 DELETE 、 UPDATE 的语句会发生阻塞,同理,某个事务中对某个表执行SELECT 、 INSERT 、 DELETE 、 UPDATE 语句时,在其他会话中对这个表执行 DDL 语句也会发生阻塞,这是通过元数据锁实现的
- 表级别的 IS锁 、 IX锁
某些记录加 S锁 之前,那就需要先在表级别加一个 IS锁 ,当我们在对使用 InnoDB 存储引擎的表的某些记录加 X锁 之前,那就需要先在表级别加一个 IX锁 。 IS锁 和 IX锁的使命只是为了后续在加表级别的 S锁 和 X锁 时判断表中是否有已经被加锁的记录
- 表级别的 AUTO-INC锁
对于某个列,可以给他增加自增属性,这时插入时会自动给记录加上AUTO-INC锁
- 然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。这样一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
采用一个轻量级的锁,在为插入语句生成 AUTO_INCREMENT 修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的 AUTO_INCREMENT 列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
InnoDB中的行级锁
Record Locks:
我们前边提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,分为S锁和X锁,基本情况和之前讲的一样
- Gap Locks :
我们说 MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用 加锁 方案解决。
如图中为 number 值为 8 的记录加了 gap锁 ,意味着不允许别的事务在 number 值为 8 的记录前边的 间隙插入新记录,其实就是 number 列的值 (3, 8) 这个区间的新记录是不允许立即插入的。
这个 gap锁 的提出仅仅是为了防止插入幻影记录而提出的,虽然有 共享gap锁 和 独占gap锁 这样的说法,但是它们起到的作用都是相同的。
- Next-Key Locks :
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的 间隙 插入新记录,所以提出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY , :::tips next-key锁 的本质就是一个 正经记录锁 和一个 gap锁 的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的 间隙 :::
- Insert Intention Locks :
当事务想要在某记录前面或者后面插入记录时,如果遇到gap锁,那么会阻塞等待。事务在等待的时候也需要在内存中生成一个 锁结构 ,表明有事务想在某个 间隙 中插入新记录,但是现在在等待。这种类型的锁命名为 Insert Intention Locks ,官方的类型名称为: LOCK_INSERT_INTENTION ,我们也可以称为 插入意向锁
:::warning
插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁( 插入意向锁 就是这么鸡肋)。
:::
- 隐式锁
之前讲INSERT一般是不加锁的,这是怎么回事?
如果在执行插入的同时有事务对该记录加上S锁或者X锁,不就形成了脏读或者脏写了吗?
主要是依靠事务id来起作用,事务id相当于一个隐式的锁
- 对于聚簇索引记录而言,该记录的 trx_id 隐藏列代表的的就是当前事务的事务id ,如果其他事务此时想对该记录添加 S锁 或者 X锁 时,首先会看一下该记录的 trx_id 隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个 X锁 (也就是为当前事务创建一个锁结构, is_waiting 属性是 false ),然后自己进入等待状态(也就是为自己也创建一个锁结构, is_waiting 属性是 true )
- 对于非聚簇索引记录,比如是二级索引而言,在二级索引页面的 Page Header 部分有一个 PAGE_MAX_TRX_ID 属性,该属性代表对该页面做改动的最大的 事务id ,如果PAGE_MAX_TRX_ID 属性值小于当前最小的活跃 事务id ,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复 情景一 的做法。
简单来说就是其他事务会根据事务id对当前事务创建一个锁,因此insert不用自己加锁
InnoDB锁的内存结构
我们前边说对一条记录加锁的本质就是在内存内创建一个锁结构与记录相关联,如果记录数很多的话岂不是要建立很多锁结构?这是一种浪费,因此当如下情况下,可以把锁放在同一个锁结构中
在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的