mysql之事务、锁、隔离级别与MVCC

您所在的位置:网站首页 锁的级别和安全度D mysql之事务、锁、隔离级别与MVCC

mysql之事务、锁、隔离级别与MVCC

2023-06-03 11:07| 来源: 网络整理| 查看: 265

 参考文章

《正确的理解MySQL的MVCC及实现原理》

《【MySQL】当前读、快照读、MVCC》

《MySQL 8.0 MVCC 源码解析》

《mysql幻读》

《细谈数据库表锁和行锁》

《什么是乐观锁,什么是悲观锁》

《MySQL事务和隔离》

写在开头:本文为学习后的总结,可能有不到位的地方,错误的地方,欢迎各位指正。

一、 事务的定义

        一段连续的不可拆分的业务逻辑的组合称为事务。

        举例,比如银行转账,A的账户-50,B的账务+50,那么这2个操作必须都完成才行,称为事务。基于上面的例子,事务的基本属性A(原子性)C(一致性)I(隔离性)D(持久性)

二、事务的隔离级别分为 (1) 读未提交

        会出现脏读,会读取到其他事务未提交的数据

(2)读已提交

        事务A的2个查询期间事务B对一条数据做了修改,导致2次数据的读取不一致。

        解决方案是加锁,表锁、行锁都可以,行锁颗粒度小,执行开销大,好处是并发度大

(3)可重复读(mysql的默认隔离级别)

        当前读模式下,因为加的是行锁,因此没办法防止住insert语句,导致2次查询的语句不一致。对于快照读,使用MVCC解决,对于当前读,使用间隙锁解决.

(4)串行化

        这是最高的隔离级别,它强制事务都是串行执行的,使之不可能相互冲突,从而解决幻读问题。换言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。

三、 锁的

        从概念上分有2种锁,悲观锁与乐观锁

        悲观锁,是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:

        1. 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前上锁。

        2. Java 里面的同步 synchronized 关键字的实现。

        悲观锁主要分为共享锁和排他锁:

        共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。

        排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。

        行锁、表锁是共享锁、排他锁的执行粒度。

1、表锁(解锁语句 UNLOCK TABLES) (1)读锁 lock table table_name read

对该表加读锁后,自己也不能对其进行修改;自己和其他线程只能读取该表

(2)写锁 lock table table_name write

当对某个表执加上写锁后,该线程可以对这个表进行读写,其他线程对该表的读和写都受到阻塞

2、行锁

        行锁是在引擎层由各个引擎自己实现的,有的引擎并不支持行锁,比如MyISAM就不支持行锁,这意味着并发控制只能使用表锁,对于这种引擎(MyISAM)的表,同一张表上任何时刻只能有一个更新在执行,这严重影响了并发度;

        (1)InnoDB是支持行锁的,这也是MyISAM被InnoDB代替的主要原因;

        (2)InnoDB的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则都会从行锁升级为表锁

update table1 set bbb='2' where aaa='1'

        事务开启后,事务A更新table1的1行,但未提交,则事务B中无法更新这一行(会阻塞,事务A提交后才行,阻塞一段时间后还未获得行锁则会自动放弃更新),但可以更新其他行。需注意,上面这条语句改成

update table1 set bbb='2' where ccc='1'

        则不会加行锁,但是事务B依然会阻塞,

        原因是行锁降级成了表锁,因此事务B依然无法update(参考上文的写锁)。

        在InnoDB事物中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事物提交了才会释放,这个就是两阶段锁协议。

3. 间隙锁

        首先要了解下幻读这个问题,我们开启事务A后读取一条sql得到了10行数据,此时事务B无法对这10行做update、delete操作,但是此时使用的是行锁,也就是锁的粒度是加在每个数据行上的,这也就意味着我们可以向这些数据中进行插入操作。

        比如事务B向这张表插入了1条数据,导致事务A再次查询时得到了11条记录,这就是幻读。mysql的应对方案是增加间隙锁。

        将两行记录间的空隙加上锁,阻止新记录的插入;这个锁称为间隙锁。

悲观锁的特性:

        悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,

        但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:

CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。版本号控制(MVCC):一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。

        当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

        乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

        在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。

        1️. 响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。

乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。

        2️. 冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。

        3. 重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。

        4️. 如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。

随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。

四、MVCC

多版本并发控制MVCC,利用3个隐式字段,undo日志 ,Read View 来实现的。

(1)隐式字段

        每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

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

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

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

        实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了

        如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID是当前操作该记录的事务ID,而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本。

(2)undo日志

        undo log主要分为两种:

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

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

        下图展示了1条记录经过多次事务修改的流程图

(3)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所在的旧记录就是当前事务能看见的最新老版本

        readview有以下3个主要属性

        trx_list:一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID)

        up_limit_id:记录trx_list列表中事务ID最小的ID)

        low_limit_id:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1)

        (1)首先比较DB_TRX_ID < up_limit_id, 如果小于,则当前事务能看到DB_TRX_ID 所在的记录,如果大于等于进入下一个判断

        (2)接下来判断 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断。

        (3)判断DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID),如果在,则代表我Read View生成时刻,你这个事务还在活跃,

        还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的。

        简单总结一下,就是给每条记录增加3个隐藏字段,1个隐藏主键,1个标记当前事务ID(DB_TRX_ID),1个标记上本记录的上一个版本(DB_ROLL_PTR)。

        DB_ROLL_PTR指向的上一版本记录存储在undo log中,undo log中的每条记录也都有,DB_ROLL_PTR字段,于是每条记录的修改记录连在一起就形成了1个修改记录链表,最早的版本在链尾。

        当我们快照读时,对于readview中记录的活跃的事务,拿这条记录当前的DB_TRX_ID,比对他们的版本,再从undo log中找出符合可见性的记录版本,然后读取。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3