mysql事务隔离级别及原理

一、什么是事务

数据库事务是指一组数据操作,事务内的操作要么全部成功,要么全部失败,如果做了一部分但是有一步失败,就要全部回滚。

事务具有原子性、一致性、隔离型、持久性,简称ACID,本篇文章讨论的就是隔离型。

原子性

事务是数据库的逻辑工作单位,不可分割,事务中包含的各操作要么都做要么都不做

一致性

事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。因此当数据库只包含成功事务提交的结果时,就说数据库处于一致性状态。如果数据库系统 运行中发生故障,有些事务尚未完成就被迫中断,这些未完成事务对数据库所做的修改有一部分已写入物理数据库,这时数据库就处于一种不正确的状态,或者说是不一致的状态。

隔离性

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

持续性

也称永久性,指一个事务一旦提交,它对数据库中的数据的改变就应该是永久性的,不能回滚。接下来的其它操作或故障不应该对其执行结果有任何影响。

注意:任何一条DML语句(insert、update、delete)执行,标志事务的开启;commit标志任务结束

事务的加锁是随着事务的进行逐渐加的;

二、Why-为什么要有事务隔离

首先我们从用db用户的角度来理解这个问题, 以下是使用事务时可能遇到的“意外”场景

脏读

A事务读取B事务尚未提交的数据,此时如果B事务发生错误并执行回滚操作,那么A事务读取到的数据就是脏数据。就好像原本的数据比较干净、纯粹,此时由于B事务更改了它,这个数据变得不再纯粹。这个时候A事务立即读取了这个脏数据,但事务B良心发现,又用回滚把数据恢复成原来干净、纯粹的样子,而事务A却什么都不知道,最终结果就是事务A读取了此次的脏数据,称为脏读。

不可重复读

事务A在执行读取操作,由整个事务A比较大,前后读取同一条数据需要经历很长的时间 。而在事务A第一次读取数据,比如此时读取了小明的年龄为20岁,事务B执行更改操作,将小明的年龄更改为30岁,此时事务A第二次读取到小明的年龄时,发现其年龄是30岁,和之前的数据不一样了,也就是数据不重复了,系统不可以读取到重复的数据,成为不可重复读。针对update操作

幻读

事务A在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务B执行了新增数据的操作并提交后,这个时候事务A读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,成为幻读。针对插入和删除操作

我们分别举一个🌰

  • 脏读

  • 不可重复读

 

  • 幻读

 

从数据库的数据一致性角度来看这个问题,在读提交的隔离级别下, 我们同时执行三个事务A,B,C, 操作顺序如下:

 

然而在binlog中的执行顺序如下:

 

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/*所有d=5的行,d改成100*/

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/


表结构
CREATE TABLE `t` (
 `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL, 
  `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), 
  KEY `c` (`c`)) ENGINE=InnoDB;

插入的(1,1,5)这一行,数据库最终的结果是(1,5,5),然而根据binlog的顺序执行出却是(1,5,100),出现了不一致。更多细节可参考20 | 幻读是什么,幻读有什么问题?-极客时间

三、What-事务隔离都有什么

数据库的四个隔离级别

Read uncommitted 读未提交

事务A能够读取到事务B未提交修改的数据,可能出现脏读

Read committed 读提交

读取到其他事务提交后的数据,即我们说的不可重复读

Repeatable read 重复读

一个事务内读取到(普通读取)的值总是一致的

Serializable 序列化(串行化)

Serializable 是最高的事务隔离级别,同时代价也花费最高,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读

 

四、How-怎么做到事务隔离

MVCC

mysql 的innodb通过mvcc机制来实现可重复读的隔离机制;我们重点理解一下他的实现原理以及相关的锁机制;

MVCC带来的好处是?

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题

  • 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
  • 同时还可以解决脏读,幻读(因为innodb的mvcc默认带有间隙锁,所以能够解决幻读),不可重复读等事务隔离问题,但不能解决更新丢失问题

MVCC的实现原理

首先我们需要逐步理解一些概念,undo log、当前读以及快照读

我们都知道innodb有聚簇索引,以主键ID作为索引使用B+Tree的结构组织整个表的数据,如果建表时没有指定主键,innodb会给表加一个隐藏的主键rowid,除此之外,还有其他两个隐藏字段,分别是trx_id和roll_ptr

  • DB_TRX_ID

6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID

  • DB_ROLL_PTR

7byte,回滚指针,指向这条记录的上一个版本, 即undolog(存储于rollback segment里)

  • DB_ROW_ID

6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引

如何验证数据表存在这三个字段,请参考:MySQL:如何快速的查看Innodb数据文件_ITPUB博客

 

undo log主要分为两种:

  • insert undo log

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

  • update undo log

事务在进行update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在所有快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除;

  1. 比如一个有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL

  1. 现在来了一个事务1对该记录的name做出了修改,改为Tom
  • 在事务1修改该行(记录)数据时,数据库会先对该行的索引加排他锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本
  • 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它
  • 事务提交后,释放锁 
  1. 又来了个事务2修改person表的同一个记录,将age修改为30岁
  • 在事务2修改该行数据时,数据库也先为该行加锁
  • 然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
  • 修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录
  • 事务提交,释放锁

从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)

  • 当前读

像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

  • 快照读

Read View(读视图)

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

所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本

如何判断可见性可参考 https://elsef.com/2019/03/09/MVCC%E4%B8%ADRow%E7%9A%84%E5%8F%AF%E8%A7%81%E6%80%A7%E9%97%AE%E9%A2%98%E8%A7%A3%E6%9E%90/

通过mysql的information_shcema库的innodb_trx表能够看到当前活跃的事务集合

mysql> use information_schema;
select * from innodb_trx;

像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,读取的是当前事务id之前的事务已提交的版本。

purge

  • 从前面的分析可以看出,为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除。
  • 为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
show engine innodb status;

 

(图文数据不符,为了更好的说明,文字加了数据)

Trx id counter 1159171#事务计数

Purgedone for trx‘s n:o < 1157813  #事务清空位置#1159171-1157813表示有待清空的事务量

undo n:o< 0#当前清理事务undo位置

state: running but idle  #启动但是闲置

  • MVCC通过间隙锁解决幻读问题
    CREATE TABLE `t` (
      `id` int(11) NOT NULL,
      `c` int(11) DEFAULT NULL,
      `d` int(11) DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `c` (`c`)
    ) ENGINE=InnoDB;
    
    insert into t values(0,0,0),(5,5,5),
    (10,10,10),(15,15,15),(20,20,20),(25,25,25);

    间隙锁是在扫描的时候不仅仅把相关的行锁上(这里说的行均是索引行),同时把行之间的间隙锁上,间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

    mvcc间隙锁的加锁原则

    (以下案例,补充内容以前,引用于mysql45讲之21讲21 | 为什么我只改一行的语句,锁这么多?-极客时间

    包含了两个“原则”、两个“优化”(原文的一个bug,mysql 新版本已经解决)

    原则 1:加锁的基本单位是 next-key lock,next-key lock 是前开后闭区间。

    原则 2:查找过程中访问到的对象才会加锁。

    优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。

    优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。

    案例一:等值查询间隙锁

    由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。

    案例二:非唯一索引等值锁

看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?

这里 session A 要给索引 c 上 c=5 的这一行加上读锁。根据原则 1,加锁单位是 next-key lock,因此会给 (0,5]加上 next-key lock。要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10]加 next-key lock。但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。 执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select d from t where c=5 lock in share mode。你可以自己验证一下效果。

案例三:主键索引范围锁

mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;

 你可能会想,id 定义为 int 类型,这两个语句就是等价的吧?其实,它们并不完全等价。在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让 session A 执行第二个查询语句,来看看加锁效果。

开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果你就能理解了。这里你需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。

案例四:非唯一索引范围锁

这次 session A 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。所以从结果上来看,sesson B 要插入(8,8,8) 的这个 insert 语句时就被堵住了。这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。

案例五:非唯一索引上存在"等值"的例子

mysql> insert into t values(30,10,30);

可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。图中我画出了索引 c 上的主键 id。为了跟间隙锁的开区间形式进行区别,我用 (c=10,id=30) 这样的形式,来表示索引上的一行。 

这时,session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。也就是说,这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。 

 

这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁

案例六:limit 语句加锁

这个例子里,session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间,如下图所示:

可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。

补充内容

当一个字段上面有多个索引的时候,优先顺序怎样,是全部被锁么?

记住上面的原则,查找过程中访问到的记录才加锁,但这不是普适原则,在很多当前读的情况下,db会选择把聚簇索引一同锁上,比如select for updata, delete,以及updata事务。

创建一个表为person

show index from person;

id为主键, id_card唯一索引, id_card和age联合索引 

insert into person(id, id_card, age, name) values(1, 10, 10, 'tom');
insert into person(id, id_card, age, name) values(2, 20, 20, 'tim');
insert into person(id, id_card, age, name) values(3, 30, 30, 'jerry');
  1. 同时拥有单索引以及联合索引
    begin;
    select * from person where id_card=20 for update;

    我们在另一个终端执行

    use performance_schema
    select * from data_locks;

    我们看到一共有三个锁, 分别是表的意向排他锁,id_card的唯一索引锁,因为我们是for update操作,因此顺便锁了主键索引, 同时因为是唯一索引,两个索引行锁都没有gap;

    我们可以得出结论,当查询字段上同时拥有单索引以及联合索引时,联合索引不被锁;如果该字段上同时拥有唯一索引以及普通单字段索引,虽然我们不会这么用,也只有唯一索引会被锁;

  2. 无索引字段
    begin;
    select * from person where age=20 for update;

 表中此时一共有三条数据,虽然age在c_age_idx的联合索引中,但是不符合最左前缀,因此并没有使用到该索引,db选择了全表扫描,这里我们看到有一个叫supremum pseudo-record的锁,他的意思是比索引中所有值都大,但却不存在索引中,相当于最后一行之后的间隙锁;同时后三行的索引没有rec_not_gap标识,表明都是带有间隙锁的next key lock;

    3.联合索引

我们把id_card上的唯一索引删掉,确保查询能够用到联合索引

begin;
select * from person where id_card=20 and age=20 for update;

表的意向锁,一个主键的行锁,联合索引上一个10到20上的next key lock, 还有一个20到30的纯gap锁。从中我们可以看出,每一段都是分开加锁的,并不是连在一起,同时联合索引和主键锁的范围并不一致;

performance_schema库中有很多有意思的表,locks,lock_waits等,更多种请求以及锁的情况,大家可以去自己试试;

 

 

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值