当前位置: 首页 > 科技观察

继续深入数据库,了解数据库的锁机制

时间:2023-03-21 23:53:23 科技观察

在高并发场景下,我们经常会在异常日志中看到“死锁”的错误信息。想了无数的办法,最终没有一个能解决。死锁的原因是什么?要解决这个问题,首先要了解数据库有哪些锁?他们的工作机制是什么?那么,让我们开始今天的学习之旅吧。为什么数据库被锁定?当多个请求同时访问一个数据库资源时,可能会导致数据不一致。因此,需要一种机制来对数据库访问进行排序,以保证数据库数据的一致性。这个我们在《数据库常用的事务隔离级别都有哪些?都是什么原理?》也讲过。事务是一种顺序机制,必须支持事务才能实现其目标。数据库有什么样的锁?由于数据库种类繁多,各个数据库的锁大致相同,细节上略有不同。因此,我们选择MySql/InnoDB作为讲解的对象。InnoDB按照锁的类型来划分,主要分为三类:共享锁(Sharedlock)、排他锁,也叫独占锁(ExclusiveLocks)和意向锁(IntentLocks)。其中,意向锁又分为意向共享和意向排他。因此,严格来说,锁有四种类型,即:共享锁,简称:S锁排他锁(ExclusiveLocks),简称:X锁意向共享锁(IntentSharedlock),简称:IS锁意向排他锁(IntentExclusiveLocks),简称:IX锁接下来,我们来说说这些锁是如何工作的。共享锁共享锁,顾名思义,就是说虽然我锁定了这个资源,但我不会独占它,同时我也允许别人使用这个资源。通常,查询使用共享锁。例如:事务A首先执行查询select*fromtable;在事务A执行之前,事务B执行另一个查询select*fromtablewhereid=1;此时事务B可以先于事务A完成自己的查询,不需要在执行事务B之前先结束事务A,这就是共享锁的作用。独占锁独占锁也称为独占锁。顾名思义,我把它锁起来,这东西就属于我一个人。没有人可以看到它或触摸它。通常,修改操作都是使用独占锁。例如:事务A先执行修改操作updatetablesetStatus=1;事务B在事务A未完成时执行另一个修改操作updatetablesetStatus=0whereid=1;此时事务B只能等到事务A完成后,事务B才能继续,这就是排它锁的作用。意向锁、共享锁和排他锁可以根据其动作的粒度在行级、页级或表级进行锁定。但是意向锁只作用于表级别,主要用来标记一个事务对这张表进行操作的意图。比如:我有一个事务需要使用表锁,那么我需要知道这个表上是否还有其他的锁,如果有的话,我可能需要等待。但是,如果我要排除其他的锁,就需要一条条遍历记录,知道有没有行锁。因此,数据库提出了行锁的另一种机制,即意向锁。如果要对该表上的行进行锁定,首先要对该表加一个意向锁,以方便其他事务查询。因此,意向锁有如下协议:事务在获取表t的一行S锁之前,必须先获取表t的IS锁或更强类型的锁。在事务获取表t中一行的X锁之前,它必须首先获取表t的IX锁。知道了锁的类型,我们再来说说锁的级别。根据锁的颗粒或级别的不同,我们将其分为三个级别:表级锁定、页级锁定和行级锁定。MyISAM和MEMORY存储引擎使用表级锁定;BDB存储引擎使用页级锁,也支持表锁;InnoDB存储引擎既支持行级锁,也支持表锁,但默认使用行锁。行锁包括三种行锁算法,分别是:记录锁(RecordLock)、间隙锁(GapLock)和下一键锁(Next-KeyLock)。这里有一个小知识点:InnoDB的行锁只供Indexentry使用,即InnoDB只有在通过索引检索数据时才使用行锁,其他时候使用表锁。记录锁(RecordLocks)记录锁,顾名思义,就是给一条记录加锁。这是ReadCommitted事务级别的默认锁定级别。记录锁作用于索引,所以当查询不作用于索引时,系统会创建一个隐式聚集索引,然后作用于索引。例如:select*fromtablewhereid=1lockinsharemode;是共享记录锁,select*fromtableforupdatewhereid=1;是独占记录锁。间隙锁(GapLock)间隙锁,它不会锁定索引本身,但它会锁定一个索引的范围。启用它有一个前提条件,那就是数据库隔离级别必须是RepeatableRead(可重复读),这也是InnoDB默认的隔离级别。如果我们将隔离级别降低到ReadCommitted(已提交读),间隙锁就会自动过期。使用间隙锁可以有效防止幻读。例如:如果事务A执行select*fromtablewhereidbetween8and15forupdate;即事务B在事务A执行过程中想插入一条id为10的记录,会被阻塞。因为这样会导致事务A中的多次查询数据不一致。Next-KeyLock(下一键锁)是记录锁+间隙锁的组合。这是默认的锁级别为RepeatableRead(可重复读取)隔离级别。使用临时键锁的一个优点是,假设我们执行一个查询select*fromtablewhereid=100;如果id是一个***索引,那么临时key锁会降级为recordlock,锁定这条记录,而不是去Lockarange。说完这些锁,我们不禁要问,死锁是怎么产生的呢?这是另外一种情况,就是锁的升级。锁升级假设我们先进行查询,找到目标数据,然后修改。在这个事务中,其实在不同的阶段,锁的种类是不一样的。我们在查询的时候,首先认为数据库已经获取了共享锁。当我们要更新这个数据的时候,不是先释放共享锁再获取排他锁,而是升级锁的操作,直接把共享锁升级为排他锁。但是就是因为这个操作,可能会导致死锁。死锁假设:事务A中存在锁升级操作,即先执行select*fromtablewhereid=1再执行updatetablesetStatus=1whereid=1在事务B中,存在同样的情况,先执行select*fromtablewhereid=1再执行updatetablesetName='Bull'whereid=1执行顺序刚好是:事务A获取共享锁,执行查询;事务B获取共享锁,执行查询;事务A需要升级排他锁并执行修改;事务B也需要升级它加锁的行,不能释放共享锁。因此,出现死锁。当然,还有一种交叉死锁的情况,比较常见。你可以自己百度一下。发生死锁时,数据库不会直接检查是否存在死锁。它仅在锁等待超时时被发现,然后终止其中一个请求。如果并发度高,死锁会导致大量线程挂掉,占用大量资源。如何防止死锁?最直接的方法是更新前不选择一次。然而,这种情况是不可避免的。很多时候我们更新的时候,需要先选择。如果不能把所有的地方都选上,那么代码的难度必然呈几何级数增加。另一种方法是通过增加硬件来提高并发能力,从而降低两个事务同时请求的概率。然而,这种方法成本太高。当然我们也可以在查询的时候直接申请最终事务需要的锁级别,避免升级锁的发生,也可以防止死锁。比如直接写select*fromtableforupdate;