面试MySQL所需要知道的:事务、锁(锁类型、Next-Key Lock详解)、MVCC

    技术2024-01-11  104

    事务

    根据之前的存储引擎了解,常用的两个存储引擎MyISAM和InnoDB。我们知道,其中InnoDB是支持事务的。二者有这些方面的差异。

    存储引擎MyISAMInnoDB事务不支持支持外键不支持支持锁粒度表锁行锁索引非聚簇索引聚簇索引缓存只缓存索引缓存索引和数据关注点性能事务表具体行数保存有表的具体行数没有保存

    那么什么是事务?

    事务是数据库执行操作的最小单元,可以由一个SQL语句或多个SQL语句组成,由于事务的特性保证了事务要么全成功执行,要么都不执行。


    启动事务:start transaction/begin

    提交事务:commit/set autocommit(设置事务的自动提交)

    回滚事务:rollback


    事务的特性

    事务的四个特性也就是常说的ACID:

    原子性(Atomicity):一个事务的所有操作要么全成功,要么都不成功,不会只完成一部分。一致性(Consistency):事务开始之前和结束之后,数据库的完整性没有被破坏隔离性(Isolation):数据库允许多个并发事务对数据进行读写,事务的隔离性要求每个读写事务与其他事务相互隔,可以防止多个事务的交叉执行导致数据不一致持久性(Durability):事务一旦执行,数据的修改就是永久的,即便系统出现故障也不会丢失该修改。

    并发问题和隔离级别

    由于事务使用的隔离级别的不同,在并发操作下,可能有这些并发问题的出现。


    脏读:事务读取到了另一个事务未提交的数据

    时间顺序事务A事务B1开始事务2开始事务3查询余额:200元4取款100元,余额:100元5查询余额:100元6系统出错,事务回滚,余额:200元7存入500元,余额:600元8提交事务

    正常逻辑:回滚后余额200元,存入500元,最终余额应该为700元


    不可重读:一个事务内的两次相同查询,结果却不同

    时间顺序事务A事务B1开始事务2开始事务3查询张三性别:男4修改张三性别:女5提交事务6查询张三性别:女

    对于事务A来说,就出现下面这种情况

    start transaction select sex from user where name = '张三' -- 显示 男 select sex from user where name = '张三' -- 显示 女

    正常逻辑:事务A的第一次查询和第二次查询结果应该一样

    不可重读和脏读不同的是,事务B提交了事务,也就是说这些数据是有效的,并不是脏数据。


    幻读:事务两次查询的的结果集数目不同

    时间顺序事务A事务B1开始事务2开始事务3查询名叫张三的人数:1条记录4增加了10条名叫张三的数据5提交事务6查询名叫张三的人数:11条记录

    正常逻辑:事务A的第一次查询和第二次查询的结果集数目应该一样

    幻读和不可重读有些类似,不过幻读针对的是记录数的差异。同样的,幻读读取到的数据,也是有效数据。


    InnoDB提供了四种隔离级别,从上到下:隔离性越高,并发性越低

    隔离级别脏读不可重读幻读读未提交(READ UNCOMMITTED)YYY读已提交(READ COMMITTED)NYY可重复读(REPEATABLE READ)NNY可序列化(SERIALIZABLE)NNN

    不过在InnoDB中,行锁使用了Next-Key Lock的机制,可以用来解决幻读的问题。这样可重复读的隔离级别,不仅保证了隔离性,同时并发性优于序列化级别。

    所以可重复读是InnoDB的默认隔离级别。

    在之前Java中,我们分析了很多Java中的锁和一些锁的思想,其中某些类型的锁和MySQL中的锁类似----->Java中的锁。

    在InnoDB中,不同隔离级别有不同的并发问题。为了保证事务的并发安全问题,所以也需要用到锁来解决。

    比如在可重复读隔离级别下,事务A在更新表字段时,在没有提交之前,事务B在尝试去更新时就会导致阻塞。

    锁类型

    共享锁和排他锁

    共享锁,也叫读锁,简称S锁(share lock)

    被加锁的对象只能被持有锁的事务进行读操作,其他事务也只能对该对象加共享锁进行读取,而不能修改该对象


    排他锁,也叫写锁,简称X锁(exclusive lock)

    被加了排他锁的对象只能被持有锁的事务读取和修改,其他事务不能读取和修改该对象。

    共享锁排他锁共享锁兼容不兼容排他锁不兼容不兼容

    意向锁

    意向共享锁,简称 IS 锁(Intention Shared Locks)意向排他锁,简称 IX 锁(Intention Exclusive Locks)

    这两种锁都是表级锁,是InnoDB自己维护的,并不需要人为加锁。

    事务准备给在数据行上加S锁之前,会先获取数据行所在表的IS锁;事务准备给在数据行上加X锁之前,会先获取数据行所在表的IX锁

    他的作用就是让其他事务在准备锁该表时,可以通过意向锁快速判断锁的存在

    意向共享锁和意向排他锁并不互斥

    意向共享锁意向排他锁意向共享锁兼容兼容意向排他锁兼容兼容

    但是他和读/写的表锁是互斥的

    意向共享锁意向排他锁表级共享锁兼容不兼容表级排他锁不兼容不兼容

    锁粒度:

    根据加锁的范围有对应不同锁的粒度:全局锁、表锁、行锁、字段锁、页锁等

    全局锁

    全局锁是指对整个数据库加锁。在MySQL中提供了给数据库全局加读锁的命令:flush tables with read lock;。使用这个命令之后,数据库就进入只读状态,DDL语句、数据的更新语句、更新事务的提交都会被阻塞住。

    这个锁通常是MyISAM引擎用来备份,避免数据一致性被破坏,阻止其他线程修改数据,将当前的数据备份下来。

    对于InnoDB引擎,可以通过获取一致性视图(见MVCC)获取到过去时间点的数据,所以不需要用这条命令。

    表锁

    在没有更细粒度的锁时,表锁往往是用来解决并发安全的最常用锁。

    顾名思义,表锁是对整个表进行加锁操作,不过lock tables限制了其他线程对加锁表的操作,也限制了持锁线程在unlock tables 之前,只能操作加锁的表。

    比如线程1:lock tables user write, city read; 这样对user表加写锁,city表加读锁。

    此时线程1只能读写user表,读city表(不能写)。且其他线程不能读写user表,也不能写city表(只能读)。

    表锁的粒度过粗,所以对支持行锁的InnoDB来说,表锁并不是最优选。


    此外在MySQL5.5之后,引入了一个MDL(metadata lock)元数据锁也是表级锁。MDL用来保证读写的正确,这个锁并不是显式使用,在访问表时会系统默认自动加上。

    比如线程1在遍历表数据,线程2删除了该表的一个字段。这是不允许的

    在对表进行增删改查操作时,会默认添加MDL读锁(其他线程也能增删改查)在对表结构进行更新操作时,会默认添加MDL写锁(其他线程会被阻塞)

    MDL会在事务提交之后才释放,所以值得注意,不要因为事务不提交,使得MDL发生死锁。这也是不要使用长事务的原因之一。

    行锁

    MyISAM是不支持行锁的,在并发时只能使用表锁。而InnoDB是支持行锁的,在高并发场景下,性能优于表锁,所以这也是MyISAM被取代的原因之一。

    和表锁与之对应,行锁就是对一行记录加锁。比如事务1在更新一行记录,此时事务B也来更新这行记录,就会被阻塞住。


    两阶段锁协议(Two-phase locking ,2PL):

    InnoDB的事务加锁,遵循两阶段锁协议,分为两个阶段:加锁阶段和解锁阶段。

    加锁阶段:可以不断地获取锁,但是不能解锁。读操作前需要获取S锁,写操作前需要获取X锁。

    解锁阶段:事务可以释放任意锁,但是不能加锁。

    也就是在InnoDB事务中,进行操作时会进行加锁,但并不能释放,只有在事务结束才释放。

    加锁方式:执行SQL语句时会自动添加对应的锁。或者手动加写锁:select * from user where id=10 for update; 这样就锁定该行,让其他事务不能进行修改。


    通过了解两阶段锁协议,我们可以选择在事务中,将可能发生并发冲突的锁放在后面。

    不过两阶段锁协议并没有解决死锁的问题。


    值得注意的是:行锁的使用和索引是相关联的!

    InnoDB的行锁是在索引上,通过给索引项加锁来实现。也就是说只有通过索引,InnoDB才使用行锁。否则,在索引失效的情况下,行锁将升级为表锁。借此也说明了索引的重要性。


    行锁机制

    记录锁(Record Lock)

    记录锁很好理解,是给某个单行记录上锁。

    当SQL语句按照唯一性索引(主键索引或唯一索引)进行等值查询的检索,索引命中时,这条记录就会添加上Record lock,锁住具体的索引项。

    比如select * from user where id =1 for update;

    此时就不允许其他事务对id为1的行进行更新操作。


    间隙锁(Gap lock)

    间隙锁则是锁定的记录之间的间隙,或者是第一个记录之前和最后一个记录之后的范围,

    当SQL语句使用范围条件(between and < > 等等),而非等值查询进行检索时,会锁定一个范围。

    比如:select * from user where age > 10 and age <20 for update; 就会锁定(10,20)这个范围。

    其他事务对这个范围的插入操作就会被阻塞,比如插入一个age=18的记录就不被允许。

    间隙锁是用来权衡并发和性能,适用于RR(REPEATABLE READ)隔离级别


    临键锁(Next-Key lock)

    临键锁可以说是间隙锁+记录锁,不仅会锁定了间隙,还锁定了记录本身,临键锁解决了幻读的问题。

    临键锁是加锁的基本单位,会根据不同的情况,可退化为记录锁和间隙锁

    所以临键锁也是有区间的,这些区间都是左开右闭的。

    根据官方文档的例子

    一个索引包含10、11、13、20,那么临键锁的区间分别是:

    (-∞,10],(10,11],(11,13],(13,20],(20,supernum]

    这个supernum是为了表明左开右闭区间,因为+∞取不到

    用官方例子举例,建立一张表t

    CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(10,10),(11,11),(13,13),(15,20); idc11051110131520

    如果唯一索引等值查询到数据时,临键锁会退化为记录锁。如果没有找到,会向后找到下一个索引,临键锁会退化为间隙锁

    比如select * from t where id = 10 for update;

    因为能够找到唯一索引id=10,原本是区间的临键锁,会退化为记录锁。

    Session ASession Bselect * from t where id = 10 for update;INSERT INTO t VALUES (6,6); /Query ok/UPDATE t SET c=c+1 where id = 10; /Blocked/

    而select * from t where id = 8 for update;

    会找到id=10,不满足条件id=8,(5,10]退化为间隙锁(5,10)

    Session ASession Bselect * from t where id = 8 for update;UPDATE t SET c=c+1 where id = 10; /Query ok/INSERT INTO t VALUES (6,6); /Blocked/

    如果非唯一索引等值查询到数据,会向右遍历索引(判断是否还会有相同值),到下一个不满足等值条件的索引,临键锁会退化为间隙锁。

    这个就有些不同了,比如Session A执行:select * from t where c = 13 for update;

    按照分析:会锁住(11,13]+(13,20]的临键锁,索引的下一项为20,不满足c=13,所以会退化为间隙锁,也就是在 (11,20) 这个区间会有间隙锁,又因为for update语句,系统会给c=13这一行加上记录锁,那么我们就来测试:

    Session ASession Bselect * from t where c = 13 for update;INSERT INTO t VALUES (4,11); /Query ok/INSERT INTO t VALUES (6,11); /Blocked/INSERT INTO t VALUES (16,11); /Blocked/INSERT INTO t VALUES (4,20); /Blocked/INSERT INTO t VALUES (6,20); /Blocked/INSERT INTO t VALUES (16,20); /Query ok/

    咋一看很诡异,怎么一会儿c=11能插入,一会儿又不能插入,c=20也是这种情况。

    其实这就要分析一下这种情况了,索引c是二级索引,所以在索引c中,存储着主键id:

    他的间隙其实是如图所示的,而我们知道索引是有序的,所以当插入的时候,是会按顺序插入。

    也就是说,对于c=11的数据,id>5的都会排在右边,属于间隙锁区间内。

    对于c=20的数据,id<15的都会排在左边,也属于间隙锁区间内。

    这样就对显而易见的知道为何会有时能插入,有时不能插入了。

    死锁

    死锁已经不陌生了,在操作系统的书籍中就有死锁的详细介绍,在Java并发编程时,也用不同的锁来实现过死锁。现在就看看MySQL中的死锁。


    在并行执行的多个事务之间,各有事务分别占有了对方所需要的资源,两个事务都不陷入阻塞。

    不过好在的是,这种死锁并不会一直锁住,InnoDB提供了两种方式来应对死锁的发生

    超时退出主动检测

    第一种超时退出是在出现死锁时,第一个被锁住的事务会等待一段时间(默认为50s),时间到了会退出这个事务,让其他事务能够继续执行。不过长时间的阻塞,对于业务来说很致命,而如果时间设置很短,可能会误伤不是死锁的锁阻塞情况。

    而第二种方式就是自己检测死锁的,当发现死锁阻塞后,会主动回滚死锁状态中,占用资源较少的事务,让另一个事务得以执行。看似比第一种方式更加合理。 不过死锁检测并不是完美的,因为检测本身也需要消耗资源,即使检测出没有死锁,也会消耗大量CPU资源,所以也是有利有弊的。

    最好还是要正确设计的事务内语句占有资源的顺序,同时避免长事务可能导致的死锁。

    MVCC

    多版本并发控制,简称MVCC(Multi-Version Concurrency Control)。

    通过MVCC,来看看可重复读隔离级别的实现逻辑。

    在可重复读隔离级别下,解决了不可重读的问题,即事务A即使修改了数据未提交,事务B也不会看到这个数据的修改,有点像JUC中的CopyOnWriteArrayList类的情况。

    这是因为,在RR隔离级别下,事务启动时,会创建一个”视图“

    这里可能会误解为另一种视图,因为在MySQL中,还有一个视图view,是一个用于查找的虚拟表。

    而MVCC使用的”视图“是时点一致性读视图,通俗来说,就是在事务启动时,为数据库拍了一个”快照“。但是这个快照并不会存储数据。在每行数据更新时,并不会覆盖数据,而是把数据标记为旧数据,同时把事务id赋值给新的数据版本。这样每行记录就会存储多个版本的数据。

    在InnoDB中每个事务开始时会申请到一个事务id,且这个事务id是严格按照顺序的,即开启的事务id一定<后开启的事务id。

    在建表时,表中会有隐藏字段,其中就包括了MVCC相关的两列:

    数据行版本号(DB_TRX_ID)删除版本号 (DB_ROLL_PT)

    这两列会记录哪些事务操作过该数据行。但这只能看见最近有哪些事务修改过,所以就有了回滚日志 (Undo Log)。在每次语句更新时,会记录回滚日志,通过回滚日志就可以获取到之前版本的数据。

    比如Version3想要获取到Version1的数据,就需要按照顺序U2、U1以此执行。

    当系统判断再无事务需要回滚日志时,就会删除这个回滚日志。


    那如何判断一个数据版本对于一个事务而言是可见的呢?很简单

    只要该数据版本提交于快照创建之前,就是可见的。对于事务自己做的修改,也一定是可见的。而视图创建之后,其他事务更新的操作,当前事务是读不到。


    但是写操作又不一样了。假如事务B的更新操作,事务A是不可见的,那不是有可能导致更新丢失。所以对于写操作而言,获取的值一定是最新值。

    比如有表 t,有一行记录:id=1,value=0

    Session ASession BBEGINBEGINupdate t set value = value +1 where id = 1;commit;select value from t where id =1 /value=0/update t set value = value +1 where id = 1;select value from t where id =1 /value=2/

    读已提交的隔离级别和可重复读隔离级别的差别是,二者创建视图的时间不同。

    RC是在每条语句执行时创建一致性视图RR则是事务开启时才创建一致性视图。

    所以对于RC隔离级别,已经提交的事务语句,对于当前事务的语句是可见的。

    Processed: 0.019, SQL: 9