数据库锁和事务隔离级别

一:数据库事务

事务的的属性 (ACID)

  • 原子性(Atomic):事务必须是一个完整的操作单元,对于一个事务,要么全都执行,要么全都不执行。比如A用户给B用户转帐100元是一个事务,那么A账户扣减100元,B账户增加100元是一个原子操作,要么都成功,要么都失败。

  • 一致性(Consistency):事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。这种特性称为事务的一致性。比如A用户给B用户转帐100元是一个事务,如果A账户增加了100元,那么B用户账户一定扣减100元,事务执行前后状态一致。

  • 隔离性(Isolation):由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,到底是另一个事务执行之前的状态还是中间某个状态,相互之间存在什么影响,是可以通过隔离级别的设置来控制的。

  • 持久性(Durability):事务提交后,事务处理的结果必须能够得到固化,即事务提交写入数据库文件后即使机器宕机数据也不会丢失。

事务隔离级别

读未提交(read uncommitted)

概念:一个事务还没有提交时,它的变更就能被其他事务看到。

存在的问题:脏读、不可重复读、幻读

读提交(read committed)

概念:一个事物提交之后,它的变更才会被其他事务看到。

存在的问题:不可重复读、幻读

可重复读(repeatable read)

概念:一个事物执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。未提交变更对其他事务也是不可见的。

存在的问题:幻读

串行化(serializable)

对于同一行记录,写会加“写锁”,读会加“读锁”,当出现锁冲突时,后访问的事务需要等前一个事务执行完成后才能继续执行,也就是事务是串行的。

表格总结

隔离级别脏读不可重复读幻读
读未提交(read-uncommitted)可能可能可能
读已提交(read-committed)不可能可能可能

可重复读(repeatable-read) 

(mysql默认)

不可能不可能可能
可串行化(serializable)不可能不可能不可能

问题说明展示:

脏读:

概念:事务A读取到事务B已经修改但尚未提交的事务,如果事务B回滚,则A读到的数据无效,为脏数据,不符合一致性。

 演示脏读:

事务一
begin:
update account set balance=3500 where id=3;
rollback;
select balace from account where id=3;

事务二:
begin:
select balance from account where id=3;
select balance from account where id=3;

shi可以发现在第二个事务查询时,第一个事务未提交的脏数据给第二个事务读到了,之后第一个事务回滚了事务,这时第二个事务读取到的就是脏数据。

不可重复读:

概念:事务A读到了事务B已经提交的修改数据,不符合隔离性

 演示:不可重复读

有两个事务操作同一账户,A事务读取到账户余额100,B事务在之后修改了账户余额为50并提交了事务,A事务再去读取发现余额为50,一个事务范围内两个相同的查询却返回了不同数据,这就是不可重复读。

第一个事务第一次查询结果为460,第二个事务修改为400并提交,第一个事务再读发现结果为400,两次同样的查询语句结果却不同,这就是不可重复读,这会引起程序出现问题

幻读:

概念:事务A读取到了事务B提交的新增数据,不符合隔离性

例如:两个事务操作同一张表,A事务第一次查询结果为10条记录,B事务再之后增加了10条记录并提交,A事务再次查询时发现结果为20条记录,出现了不一致,这就是幻读。

演示:幻读

当balance-50后会直接变为300(如下图),这就是MVCC机制,即,再repeatable-read隔离级别下,多次读,后面读出来的数据永远和第一次读出来的数据相同,读取的是快照中的值。而修改数据则是修改数据库的数值。

当第一个事务更新第二个事务新添加的数据后再次查询却不是第一次查询的内容了,出现了幻读。

二:数据库锁

数据库4大特性和隔离级别主要依靠数据库锁来实现。

InnDB引擎的锁可以分为行级锁和表锁

行级锁:

共享锁/排它锁、记录锁、间隙锁、临键锁、插入意向锁、自增锁

表锁:

意向共享锁/意向排它锁

2.1共享/排他锁

共享锁(简称S锁)、排他锁(简称X锁)是InnoDB实现的两种标准行级锁

  • 共享锁:简称为S锁,在事务要读取一条记录时,需要先获取该记录的S锁。
  • 排他锁:简称X锁,在事务需要改动一条记录时,需要先获取该记录的X锁。

如果事务T1持有行R的S锁,那么另一个事务T2请求访问这条记录时,会做如下处理:

  • T2 请求S锁立即被允许,结果T1和T2都持有R行的S
  • T2 请求X锁不能被立即允许,此操作会阻塞

如果T1持有行R的X锁,那么T2请求R的X、S锁都不能被立即允许,T2必须等待T1释放X锁才可以,因为X锁与任何的锁都不兼容。

2.2 意向锁

什么是意向锁呢?意向锁是一种不与行级锁冲突的表级锁。未来的某个时刻,事务可能要加共享或者排它锁时,先提前声明一个意向。注意一下,意向锁,是一个表级别的锁

为什么需要意向锁呢? 或者换个通俗的说法,为什么要加共享锁或排他锁时的时候,需要提前声明个意向锁呢呢?

因为InnoDB是支持表锁和行锁共存的,如果一个事务A获取到某一行的排他锁,并未提交,这时候事务B请求获取同一个表的表共享锁。因为共享锁和排他锁是互斥的,因此事务B想对这个表加共享锁时,需要保证没有其他事务持有这个表的表排他锁,同时还要保证没有其他事务持有表中任意一行的排他锁

然后问题来了,你要保证没有其他事务持有表中任意一行的排他锁的话,去遍历每一行?这样显然是一个效率很差的做法。为了解决这个问题,InnoDb的设计大叔提出了意向锁。

意向锁是如何解决这个问题的呢? 我们来看下

意向锁分为两类:

  • 意向共享锁:简称IS锁,当事务准备在某些记录上加S锁时,需要现在表级别加一个IS锁。
  • 意向排他锁:简称IX锁,当事务准备在某条记录上加上X锁时,需要现在表级别加一个IX锁。

比如:

  • select ... lock in share mode,要给表设置IS锁;
  • select ... for update,要给表设置IX锁;

意向锁又是如何解决这个效率低的问题呢:

如果一个事务A获取到某一行的排他锁,并未提交,这时候表上就有意向排他锁和这一行的排他锁。这时候事务B想要获取这个表的共享锁,此时因为检测到事务A持有了表的意向排他锁,因此事务A必然持有某些行的排他锁,也就是说事务B对表的加锁请求需要阻塞等待,不再需要去检测表的每一行数据是否存在排他锁啦。

意向锁仅仅表明意向的锁,意向锁之间不会互斥,是可以并行的,整体兼容性如下:

2.3 记录锁(Record Lock)

记录锁是最简单的行锁,仅仅锁住一行。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE,如果C1字段是主键或者是唯一索引的话,这个SQL会加一个记录锁(Record Lock)

记录锁永远都是加在索引上的,即使一个表没有索引,InnoDB也会隐式的创建一个索引,并使用这个索引实施记录锁。它会阻塞其他事务对这行记录的插入、更新、删除。

一般我们看死锁日志时,都是找关键词,比如lock_mode X locks rec but not gap),就表示一个X型的记录锁。记录锁的关键词就是rec but not gap。以下就是一个记录锁的日志:

RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t` 
trx id 10078 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 8000000a; asc     ;;
 1: len 6; hex 00000000274f; asc     'O;;
 2: len 7; hex b60000019d0110; asc        ;;
复制代码

2.4 间隙锁(Gap Lock)

为了解决幻读问题,InnoDB引入了间隙锁(Gap Lock)。间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。它锁住的是一个区间,而不仅仅是这个区间中的每一条数据。

比如lock_mode X locks gap before rec表示X型gap锁。以下就是一个间隙锁的日志:

RECORD LOCKS space id 177 page no 4 n bits 80 index idx_name of table `test2`.`account` 
trx id 38049 lock_mode X locks gap before rec
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 3; hex 576569; asc Wei;;
 1: len 4; hex 80000002; asc     ;;
复制代码

2.5 临键锁(Next-Key Lock)

Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。说得更具体一点就是:临键锁会封锁索引记录本身,以及索引记录之前的区间,即它的锁区间是前开后闭,比如(5,10]

如果一个会话占有了索引记录R的共享/排他锁,其他会话不能立刻在R之前的区间插入新的索引记录。

If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.

2.6 插入意向锁

插入意向锁,是插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号。 它解决的问题:多个事务,在同一个索引,同一个范围区间插入记录时,如果插入的位置不冲突,不会阻塞彼此。

假设有索引值4、7,几个不同的事务准备插入5、6,每个锁都在获得插入行的独占锁之前用插入意向锁各自锁住了4、7之间的间隙,但是不阻塞对方因为插入行不冲突。以下就是一个插入意向锁的日志:

RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000066; asc    f;;
 1: len 6; hex 000000002215; asc     " ;;
 2: len 7; hex 9000000172011c; asc     r  ;;...

复制代码

锁模式兼容矩阵(横向是已持有锁,纵向是正在请求的锁):

2.7 自增锁

自增锁是一种特殊的表级别锁。它是专门针对AUTO_INCREMENT类型的列,对于这种列,如果表中新增数据时就会去持有自增锁。简言之,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。

官方文档是这么描述的:

An AUTO-INC lock is a special table-level lock taken by transactions inserting into tables with AUTO_INCREMENT columns. In the simplest case, if one transaction is inserting values into the table, any other transactions must wait to do their own inserts into that table, so that rows inserted by the first transaction receive consecutive primary key values.

假设有表:

mysql> create table t0 (id int NOT NULL AUTO_INCREMENT,name varchar(16),primary key ( id));

mysql> show variables like '%innodb_autoinc_lock_mode%';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_autoinc_lock_mode | 1     |
+--------------------------+-------+
1 row in set, 1 warning (0.01 sec)
复制代码

设置事务A和B交替执行流程如下:

通过上图我们可以看到,当我们在事务A中进行自增列的插入操作时,另外会话事务B也进行插入操作,这种情况下会发生2个奇怪的现象:

  • 事务A会话中的自增列好像直接增加了2个值。(如上图中步骤7、8)
  • 事务B会话中的自增列直接从2开始增加的。(如上图步骤5、6)

自增锁是一个表级别锁,那为什么会话A事务还没结束,事务B可以执行插入成功呢?不是应该锁表嘛?这是因为在参数innodb_autoinc_lock_mode上,这个参数设置为1的时候,相当于将这种auto_inc lock弱化为了一个更轻量级的互斥自增长机制去实现,官方称之为mutex

innodb_autoinc_lock_mode还可以设置为0或者2,

  • 0:表示传统锁模式,使用表级AUTO_INC锁。一个事务的INSERT-LIKE语句在语句执行结束后释放AUTO_INC表级锁,而不是在事务结束后释放。
  • 1: 连续锁模式,连续锁模式对于Simple inserts不会使用表级锁,而是使用一个轻量级锁来生成自增值,因为InnoDB可以提前直到插入多少行数据。自增值生成阶段使用轻量级互斥锁来生成所有的值,而不是一直加锁直到插入完成。对于bulk inserts类语句使用AUTO_INC表级锁直到语句完成。
  • 2:交错锁模式,所有的INSERT-LIKE语句都不使用表级锁,而是使用轻量级互斥锁。
  • INSERT-LIKE:指所有的插入语句,包括: INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT,LOAD DATA等。
  • Simple inserts:指在插入前就能确定插入行数的语句,包括:INSERT、REPLACE,不包含INSERT…ON DUPLICATE KEY UPDATE这类语句。
  • Bulk inserts: 指在插入钱不能确定行数的语句,包括:INSERT … SELECT/REPLACE … SELECT/LOAD DATA。

对非索引字段进行 select .. for update、update 或者 delete 操作,由于没有索引,走全表查询,就会对所有行记录 以及 所有间隔 都进行上锁。而对于索引字段进行上述操作,只有索引字段本身和附近的间隔会被加锁。

总结下 MySQL 解决幻读的手段:

隔离级别:可重复读

  • 快照读 MVCC + 当前读 Next-Lock Key(只在可重复读隔离级别下生效)

隔离级别:SERIALIZABLE

在这个隔离级别下,事务在读操作时,先加表级别的共享锁,直到事务结束才释放;事务在写操作时,先加表级别的排它锁,直到事务结束才释放。也就是说,串行化锁定了整张表,幻读不存在的。

三:MVCC多版本并发控制

上面说到数据库读写操作会进行加锁,从而保证数据的ACID等特性,但是如果数据库操作全部需要加锁,必然导致并发操作性能很低,为此有了MVCC多版本并发控制机制。

MVCC机制的全称为Multi-Version Concurrency Control,即多版本并发控制技术,主要是为了提升数据库并发性能而设计的,其中采用更好的方式处理了读-写并发冲突,做到即使有读写冲突时,也可以不加锁解决,从而确保了任何时刻的读操作都是非阻塞的。

不过在MySQL中仅RC读已提交级别、RR可重复读级别才会使用MVCC机制,因为如果是RU读未提交级别,读写操作都无需加锁,在并发情况下不存在读写冲突等问题,因此无需MVCC介入。如果是Serializable串行化级别,因为会将所有的并发事务串行化处理,也就是不论事务是读操作,亦或是写操作,都会被排好队一个个执行,这也不存在并发问题了,自然也无需MVCC介入。

关键点:MVCC机制在MySQL中,仅有InnoDB引擎支持,而在该引擎中,MVCC机制只对RC、RR两个隔离级别下的事务生效。

MVCC机制主要通过隐藏字段、Undo-log日志、ReadView这三种东西实现的。

3.1、InnoDB表的隐藏字段

   当你基于InnoDB引擎建立一张表后,MySQL除开会构建你显式声明的字段外,通常还会构建一些InnoDB引擎的隐藏字段,在InnoDB引擎中主要有DB_ROW_ID、DB_Deleted_Bit、DB_TRX_ID、DB_ROLL_PTR这四个隐藏字段,挨个简单介绍一下。

3.1.1、隐藏主键 - ROW_ID(6Bytes)

InnoDB引擎表数据是按照聚簇索引的格式存储,因此通常都会选择主键作为聚簇索引列,然后基于主键字段构建索引树,但如果表中未定义主键,则会选择一个具备唯一非空属性的字段,作为聚簇索引的字段来构建树。当两者都不存在时,InnoDB就会隐式定义一个顺序递增的列ROW_ID来作为聚簇索引列。

因此如果你选择的引擎是InnoDB,就算你的表中未定义主键、索引,其实默认也会存在一个聚簇索引,只不过这个索引在上层无法使用,仅提供给InnoDB构建树结构存储表数据。

3.1.2、删除标识 - Deleted_Bit(1Bytes)

对于一条delete语句而言,当执行后并不会立马删除表的数据,而是将这条数据的Deleted_Bit删除标识改为1/true,后续的查询SQL检索数据时,如果检索到了这条数据,但看到隐藏字段Deleted_Bit=1时,就知道该数据已经被其他事务delete了,因此不会将这条数据纳入结果集。

但设计Deleted_Bit这个隐藏字段的好处是什么呢?主要是能够有利于聚簇索引,比如当一个事务中删除一条数据后,后续又执行了回滚操作,假设此时是真正的删除了表数据,会发生什么情况呢?

  • ①删除表数据时,有可能会破坏索引树原本的结构,导致出现叶子节点合并的情况。
  • ②事务回滚时,又需重新插入这条数据,再次插入时又会破坏前面的结构,导致叶子节点分裂。

综上所述,如果执行delete语句就删除真实的表数据,由于事务回滚的问题,就很有可能导致聚簇索引树发生两次结构调整,这其中的开销可想而知,而且先删除,再回滚,最终树又变成了原状,那这两次树的结构调整还是无意义的。

所以,当执行delete语句时,只会改变将隐藏字段中的删除标识改为1/true,如果后续事务出现回滚动作,直接将其标识再改回0/false即可,这样就避免了索引树的结构调整。

但如若事务删除数据之后提交了事务呢?总不能让这条数据一直留在磁盘吧?毕竟如果所有的delete操作都这么干,就会导致磁盘爆满~,显然这样是不妥的,因此删除标识为1/true的数据最终依旧会从磁盘中移除,啥时候移呢?

在之前讲《Nginx-缓存清理》时,曾经提到过purger这一系列的参数,通过配置该系列参数后,Nginx后台中会创建对应的purger线程去自动删除缓存数据。而MySQL中也不例外,同样存在purger线程的概念,为了防止“已删除”的数据占用过多的磁盘空间,purger线程会自动清理Deleted_Bit=1/true的行数据。

当然,为了确保清理数据时不会影响MVCC的正常工作,purger线程自身也会维护一个ReadView,如果某条数据的Deleted_Bit=true,并且TRX_IDpurge线程的ReadView可见,那么这条数据一定是可以被安全清除的(即不会影响MVCC工作)。

对于上述最后一段大家可能会有些许疑惑,这是因为还未曾介绍ReadView,因此有些不理解可先跳过,后续理解了ReadView后再回来看会好很多。

3.1.3、最近更新的事务ID - TRX_ID(6Bytes)

TRX_ID全称为transaction_id,翻译过来也就是事务ID的意思,MySQL对于每一个创建的事务,都会为其分配一个事务ID,事务ID同样遵循顺序递增的特性,即后来的事务ID绝对会比之前的ID要大,比如:

此时事务T1准备修改表字段的值,MySQL会为其分配一个事务ID=1,当事务T2准备向表中插入一条数据时,又会为这个事务分配一个ID=2......

但有一个细节点需要记住:MySQL对于所有包含写入SQL的事务,会为其分配一个顺序递增的事务ID,但如果是一条select查询语句,则分配的事务ID=0

不过对于手动开启的事务,MySQL都会为其分配事务ID,就算这个手动开启的事务中仅有select操作。

表中的隐藏字段TRX_ID,记录的就是最近一次改动当前这条数据的事务ID,这个字段是实现MVCC机制的核心之一。

3.1.4、回滚指针 - ROLL_PTR(7Bytes)

ROLL_PTR全称为rollback_pointer,也就是回滚指针的意思,这个也是表中每条数据都会存在的一个隐藏字段,当一个事务对一条数据做了改动后,都会将旧版本的数据放到Undo-log日志中,而rollback_pointer就是一个地址指针,指向Undo-log日志中旧版本的数据,当需要回滚事务时,就可以通过这个隐藏列,来找到改动之前的旧版本数据,而MVCC机制也利用这点,实现了行数据的多版本。

3.2、InnoDB引擎的Undo-log日志

   MySQL事务机制是基于Undo-log实现的,同时在刚刚在聊回滚指针时,聊到了Undo-log日志中会存储旧版本的数据,但要注意:Undo-log中并不仅仅只存储一条旧版本数据,其实在该日志中会有一个版本链,啥意思呢?举个例子:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;

比如上述这段SQL隶属于trx_id=1T1事务,其中对同一条数据改动了两次,那Undo-log日志中只会存储一条旧版本数据吗?NO,答案是两条旧版本的数据,如下图:


从上图中可明显看出:不同的旧版本数据,会以roll_ptr回滚指针作为链接点,然后将所有的旧版本数据组成一个单向链表。但要注意一点:最新的旧版本数据,都会插入到链表头中,而不是追加到链表尾部。

细说一下执行上述update语句的详细过程:
①对ID=1这条要修改的行数据加上排他锁。
②将原本的旧数据拷贝到Undo-logrollback Segment区域。
③对表数据上的记录进行修改,修改完成后将隐藏字段中的trx_id改为当前事务ID
④将隐藏字段中的roll_ptr指向Undo-log中对应的旧数据,并在提交事务后释放锁。

为什么Undo-log日志要设计出版本链呢?两个好处:一方面可以实现事务点回滚(这点回去参考事务篇),另一方面则可以实现MVCC机制(这点后面聊)。

与之前的删除标识类似,一条数据被delete后并提交了,最终会从磁盘移除,而Undo-log中记录的旧版本数据,同样会占用空间,因此在事务提交后也会移除,移除的工作同样由purger线程负责,purger线程内部也会维护一个ReadView,它会以此作为判断依据,来决定何时移除Undo记录。

3.3、MVCC核心 - ReadView

   MVCC在前面聊到过,它翻译过来就是多版本并发控制的意思,对于这个名词中的多版本已经通过Undo-log日志实现了,但再思考一个问题:如果T2事务要查询一条行数据,此时这条行数据正在被T1事务写,那也就代表着这条数据可能存在多个旧版本数据,T2事务在查询时,应该读这条数据的哪个版本呢?此时就需要用到ReadView,用它来做多版本的并发控制,根据查询的时机来选择一个当前事务可见的旧版本数据读取。

那究竟什么是ReadView呢?就是一个事务在尝试读取一条数据时,MVCC基于当前MySQL的运行状态生成的快照,也被称之为读视图,即ReadView,在这个快照中记录着当前所有活跃事务的ID(活跃事务是指还在执行的事务,即未结束(提交/回滚)的事务)。

当一个事务启动后,首次执行select操作时,MVCC就会生成一个数据库当前的ReadView,通常而言,一个事务与一个ReadView属于一对一的关系(不同隔离级别下也会存在细微差异),ReadView一般包含四个核心内容:

  • creator_trx_id:代表创建当前这个ReadView的事务ID
  • trx_ids:表示在生成当前ReadView时,系统内活跃的事务ID列表。
  • up_limit_id:活跃的事务列表中,最小的事务ID
  • low_limit_id:表示在生成当前ReadView时,系统中要给下一个事务分配的ID值。

上面四个值很简单,值得一提的是low_limit_id,它并不是目前系统中活跃事务的最大ID,因为之前讲到过,MySQL的事务ID是按序递增的,因此当启动一个新的事务时,都会为其分配事务ID,而这个low_limit_id则是整个MySQL中,要为下一个事务分配的ID值。

下面上个ReadView的示意图,来好好理解一下它:


假设目前数据库中共有T1~T5这五个事务,T1、T2、T4还在执行,T3已经回滚,T5已经提交,此时当有一条查询语句执行时,就会利用MVCC机制生成一个ReadView,由于前面讲过,单纯由一条select语句组成的事务并不会分配事务ID,因此默认为0,所以目前这个快照的信息如下:

{
    "creator_trx_id" : "0",
    "trx_ids" : "[1,2,4]",
    "up_limit_id" : "1",
    "low_limit_id" : "6"
}
复制代码

OK~,简单明白ReadView的结构后,接着一起来聊一聊MVCC机制的实现原理。

 将“MVCC三剑客”的概念阐述完毕后,再结合三者来谈谈MVCC的实现,其实也比较简单,经过前面的讲解后已得知:

  • ①当一个事务尝试改动某条数据时,会将原本表中的旧数据放入Undo-log日志中。
  • ②当一个事务尝试查询某条数据时,MVCC会生成一个ReadView快照。

其中Undo-log主要实现数据的多版本,ReadView则主要实现多版本的并发控制,还是以之前的例子来举例说明:

-- 事务T1:trx_id=1
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;
复制代码
-- 事务T2:trx_id=2
SELECT * FROM `zz_users` WHERE user_id = 1;
复制代码

目前存在T1、T2两个并发事务,T1目前在修改ID=1的这条数据,而T2则准备查询这条数据,那么T2在执行时具体过程是怎么回事呢?如下:

  • ①当事务中出现select语句时,会先根据MySQL的当前情况生成一个ReadView
  • ②判断行数据中的隐藏列trx_idReadView.creator_trx_id是否相同:
    • 相同:代表创建ReadView和修改行数据的事务是同一个,自然可以读取最新版数据。
    • 不相同:代表目前要查询的数据,是被其他事务修改过的,继续往下执行。
  • ③判断隐藏列trx_id是否小于ReadView.up_limit_id最小活跃事务ID
    • 小于:代表改动行数据的事务在创建快照前就已结束,可以读取最新版本的数据。
    • 不小于:则代表改动行数据的事务还在执行,因此需要继续往下判断。
  • ④判断隐藏列trx_id是否小于ReadView.low_limit_id这个值:
    • 大于或等于:代表改动行数据的事务是生成快照后才开启的,因此不能访问最新版数据。
    • 小于:表示改动行数据的事务IDup_limit_id、low_limit_id之间,需要进一步判断。
  • ⑤如果隐藏列trx_id小于low_limit_id,继续判断trx_id是否在trx_ids中:
    • 在:表示改动行数据的事务目前依旧在执行,不能访问最新版数据。
    • 不在:表示改动行数据的事务已经结束,可以访问最新版的数据。

说简单一点,就是首先会去获取表中行数据的隐藏列,然后经过上述一系列判断后,可以得知:目前查询数据的事务到底能不能访问最新版的数据。如果能,就直接拿到表中的数据并返回,反之,不能则去Undo-log日志中获取旧版本的数据返回。

注意:假设Undo-log日志中存在版本链怎么办?该获取哪个版本的旧数据呢?

如果Undo-log日志中的旧数据存在一个版本链时,此时会首先根据隐藏列roll_ptr找到链表头,然后依次遍历整个列表,从而检索到最合适的一条数据并返回。但在这个遍历过程中,是如何判断一个旧版本的数据是否合适的呢?条件如下:

  • 旧版本的数据,其隐藏列trx_id不能在ReadView.trx_ids活跃事务列表中。

因为如果旧版本的数据,其trx_id依旧在ReadView.trx_ids中,就代表着产生这条旧数据的事务还未提交,自然不能读取这个版本的数据,以前面给出的例子来说明:


这是由事务T1生成的版本链,此时T2生成的ReadView如下:

{
    "creator_trx_id" : "0",
    "trx_ids" : "[1]",
    "up_limit_id" : "1",
    "low_limit_id" : "2"
}
复制代码

结合这个ReadView信息,经过前面那一系列判断后,最终会得到:不能读取最新版数据,因此需要去Undo-log的版本链中读数据,首先根据roll_ptr找到第一条旧数据:


此时发现其trx_id=1,位于ReadView.trx_ids中,因此不能读取这条旧数据,接着再根据这条旧数据的roll_ptr找到第二条旧版本数据:

这时再看其trx_id=null,并不位于ReadView.trx_ids中,null表示这条数据在上次MySQL运行时就已插入了,因此这条旧版本的数据可以被T2事务读取,最终T2就会查询到这条数据并返回。

OK~,最后再来看一个场景!即范围查询时,突然出现新增数据怎么办呢?如下:

SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 猫熊      | 女       | 8888     | 2022-09-27 17:22:59 |
|       9 | 黑竹      | 男       | 9999     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+

-- T1事务:查询ID >= 3 的所有用户信息
select * from  `zz_users` where user_id >= 3;

-- T2事务:新增一条 ID = 6 的用户记录
INSERT INTO `zz_users` VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");
复制代码

此时当T1事务查询数据时,突然蹦出来一条ID=6的数据,经过判断之后会发现新增这条数据的事务还在执行,所以要去查询旧版本数据,但此时由于是新增操作,因此roll_ptr=null,即表示没有旧版本数据,此时会不会读取最新版的数据呢?答案是NO,如果查询数据的事务不能读取最新版数据,同时又无法从版本链中找到旧数据,那就意味着这条数据对T1事务完全不可见,因此T1的查询结果中不会包含ID=6的这条新增记录。

附加说明:如果这个一个修改数据的事务正好快照生成结束后才开启的,并且多次修改了目前select操作要读取的目标数据行,因此在Undo版本链中会产生一系列旧数据,但根据前面的一系列判断,最终select事务会去版本链中找数据,此时后面这个修改事务的ID,恰巧不在快照到trx_ids列表中怎么办呢?
面对于这种情况,当MVCC发现旧版本的数据,其隐藏列的trx_id大于目前快照的最大事务ID时,MVCC会自动跳过该版本的数据,Why?因为MySQL在分配事务ID时,都是以递增的顺序分配,所以当旧版本上的trx_id大于快照的最大事务ID时,说明这条旧版本数据是在快照生成之后产生的,所以会跳过对应的旧版本数据不读取。

3.5、RC、RR不同级别下的MVCC机制

   3.4阶段已经将MVCC机制的具体实现过程剖析了一遍,接下来再思考一个问题:

ReadView是一个事务中只生成一次,还是每次select时都会生成呢?

这个问题的答案跟事务的隔离机制有关,不同级别的隔离机制也并不同,如果此时MySQL的事务隔离机制处于RC读已提交级别,那此时来看一个例子:

-- 开启一个事务T1:主要是修改两次ID=1的行数据
begin;
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
UPDATE `zz_users` SET user_sex = "男" WHERE user_id = 1;

-- 再开启一个事务T2:主要是查询ID=1的行数据
SELECT * FROM `zz_users` WHERE user_id = 1;

-- 此时先提交事务T1
commit;

-- 再次在事务T2中查一次ID=1的行数据
SELECT * FROM `zz_users` WHERE user_id = 1;
复制代码

先说明一点,为了方便理解,因此我将两个事务的代码贴在了一块,但如若你要做实际的实验,请切记将T1、T2用两个连接来写。

OK~,再来看看上述这个案例,如果是处于RC级别的情况下,T2事务中的查询结果如下:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 竹子      | 男       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
复制代码

为什么两次查询结果不一样呢?因为RC级别下,MVCC机制是会在每次select语句执行前,都会生成一个ReadView,由于T2事务中第二次查询数据时,T1已经提交了,所以第二次查询就能读到修改后的数据,这是啥问题?不可重复读问题。

接着再来看看RR可重复级别下的MVCC机制,SQL代码和上述一模一样,但查询结果如下:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊猫      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
复制代码

这又是为啥?为啥明明在T2事务第二次查询前,T1已经提交了,T2依旧查询出的结果和第一次相同呢?这是因为在RR级别中,一个事务只会在首次执行select语句时生成快照,后续所有的select操作都会基于这个ReadView来判断,这样也就解决了RC级别中存在的不可重复问题。

最后简单提一嘴:实际上InnoDB引擎中,是可以在RC级别解决脏读、不可重复读、幻读这一系列问题的,但是为了将事务隔离级别设计的符合DBMS规范,因此在实现时刻意保留了这些问题,然后放在更高的隔离级别中解决~

四、MVCC机制篇总结

   MVCC多版本并发控制,听起来似乎蛮高大上的,但实际研究起来会发现它并不复杂,其中的多版本主要依赖Undo-log日志来实现,而并发控制则通过表的隐藏字段+ReadView快照来实现,通过Undo-log日志、隐藏字段、ReadView快照这三玩意儿,就实现了MVCC机制,过程还蛮简单的~

到这里,其实对于MySQL的事务隔离机制,已经拨开一部分迷雾了,下篇《MySQL事务与锁机制原理篇》中,则会彻底讲清楚MySQL锁是怎么实现的,以及不同的事务隔离级别,又是如何借助锁+MVCC处理客户端SQL

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值