MySQL的锁及其MVCC

摘要

  在当下互联网技术的发展状态下,数据的高并发是随处可见,那么数据库如何解决高并发所带来的问题呢。锁便是计算机用于解决多进程或多线程并发服务中保持数据一致性的关键。本文主要介绍MySQL中的锁。

  本人还写了MySQL相关博文,有兴趣的研友可以点击如下链接,请各位研友指正并留言。
   MySQL索引及优化
   MySQL事务与隔离级别

一、乐观锁与悲观锁

  乐观锁与悲观锁并不是实际的锁,它是对锁的一种抽象。本文要讲解的MySQL中的锁都属于悲观锁,MVCC机制则属于乐观锁。

1.1 乐观锁

  乐观锁认为本次服务对数据进行操作时,其他服务不会修改数据,所以乐观锁在操作数据时,并不会加锁抵抗其他服务修改数据,而是在对数据操作完之后再去校验是否有其他服务修改了数据。比如在数据中添加一列版本号,每次服务提取数据与版本号,完成服务时,版本号加一,当提交本次服务时,若本次服务版本号大于数据库中的版本号则持久化,若本次服务版本号低于数据库版本号则认为其他服务已对数据进行了更新,应放弃本次服务的修改;Redis中的锁就是一种乐观锁。本文最后讲解的MVCC也是一种乐观锁。

1.2 悲观锁

  悲观锁认为本次对数据进行操作时,其他服务会修改数据,所以悲观锁在读取数据时对数据进行上锁,其他服务只能等待其锁释放,从而达到了一种串行化执行顺序,保证了数据的一致性,但由于服务进行串行执行,所以吞吐量慢,同时易造成死锁。MySQL中的锁,如共享读和排他写都属于悲观锁。

二、MySQL锁分类

  MySQL中的锁按访问粒度可分为:

  1. 表锁:锁定一张表。
  2. 行锁:锁定一张表的某一页的某一行。
  3. 页面锁:锁定一张表的某一页。

  本部分只讲解常用的表锁与行锁,页面锁的效率间于表锁与行锁之间。

2.1 表锁

  MySQL的存储引擎MyISAM只支持表锁,即对表中的所有行进行限定访问。当用户只是更新一行数据时,也会造成其他行不可访问,由此可见表锁的粒度比较大,在高并发情况下,数据吞吐量很低,但数据的一致性得到最大化。由于访问数据的服务由于表锁需要排队访问,所以不会存在死锁的问题。
  表锁的特点:开销小,加锁快;不会出现死锁;锁定粒度大,并发度最低。
  表锁分为两种:

  1. 共享读锁 :不会阻塞其他服务对同一表的读请求,但会阻塞对同一表的写请求;
  2. 排它写锁:会阻塞其他服务对同一表的读和写操作;

  由定义可知:若一个线程对数据加了共享读锁之后,其他线程可以继续加共享读锁,但是其他线程想加排它写锁时,需要等待当前线程释放共享读锁;若一个线程对数据加了排它写锁之后,不允许其他线程加任何锁,其他线程只能等待当前线程释放排它锁之后才能获取加锁权限,若等待的线程中既有共享读锁或排它写锁,那么MySQL释放锁之后,谁先获取呢?MySQL中默认排它写锁的优先级高于共享读锁,所以默认情况下其他排它锁会优先加锁。
   这样的表锁机制会带来什么问题呢?在高并发的互联网应用场景中,由于排它写锁优先级高于共享读锁,所以有可能导致共享读书没有机会执行。
  MyISAM存储引擎支持并发插入,以减少给定表的读和写操作之间的争用:如果MyISAM表在数据文件中间没有空闲块,则行始终插入数据文件的末尾, 在这种情况下,并发使用INSERT和SELECT语句是不需要加锁的,即可以在其他线程进行读操作的时候,同时将行插入到MyISAM表中。 当数据文件由于删除或更新而产生空闲块时,则并发插入机制会自动关闭,若数据空闲块被填充后,并发插入机制又会开启。
  MyISAM引擎中锁的设置:
  1、共享读锁:

lock table xuebao read;

  2、排它写锁:

lock table xuebao write;

  3、释放锁:

unlock tables;

2.2 行锁

  MySQL的InnoDB引擎支持行级锁,但InnoDB的行锁并不是真实的在一张表中对某一行进行加锁,而是对索引的键进行加锁,所以一条SQL在实际执行时,若没有用到索引,则该SQL不会使用到行锁而是升级为表锁。
  InnoDB引擎的锁可以分为如下几类:
  1、意向共享锁;
  2、意向排他锁;
  3、共享锁;
  4、排他锁;
  5、间隙锁;

2.2.1 意向共享锁

  意向共享锁是自动添加的,不需要用户的干预。InnoDB事务在获取共享锁之前,需要先获取到该数据的意向共享锁。

2.2.2 意向排他锁

  意向排他锁是自动添加的,不需要用户的干预。InnoDB事务在获取排他锁之前,需要先获取到该数据的意向排他锁。

2.2.3 共享锁

  共享锁允许多个事务的共享锁去读取一行数据,但会阻塞其他事务的排他锁。
  MySQL的SELECT语句并没有默认加共享锁或排他锁,所以SELECT加共享锁如下所示:

SELECT id from student_table where age = 16 LOCAL IN SHARE MODE;

2.2.4 排他锁

  排他锁会阻塞其他任何锁,处于独占数据的状态。
  MySQL的UPDATE、DELETE 和 INSERT语句会自动添加排他锁。SELECT语句也可以添加排他锁,如下所示:

SELECT id from student_table where age = 16 FOR UPDATE;

需要注意的是没有加任何锁的SELECT语句,并不会与读锁或写锁产生锁竞争关系,所以某一行已经加排他锁了,但没有加任何锁的SELECT语句还是可以访问到数据。

2.2.5 间隙锁

  间隙锁也是InnoDB引擎管理的,不需要用户的干预。间隙锁产生的原因需要从InnoDB产生锁讲起。因为InnoDB引擎的锁时针对索引实现的,而索引是一种数据结构,无论实现该数据结构的是B-Tree还是B+Tree,一个索引键值与另一个键值之间是用链表实现的,那么我们在使用行锁时,若指定的行不是具体某一行,而是一个范围时,InnoDB实际锁定的是对应的一个索引范围,所以链表之间的指针也被锁定了不准修改,其他事务想在该范围内插入数据也就行不通了,这就形成了间隙锁。如下所示:

SELECT * from student_table where id > 20 FOR UPDATE;

  id为主键,id>20为范围条件,所以该条语句会自动添加间隙锁。
  间隙锁的缺点:从间隙锁的锁定粒度来看,要大于行锁的锁定粒度,那么在高并发情景下,间隙锁的数据吞吐量就会小于行锁。所以用户访问数据时,尽量使用相等条件,少用范围条件。

需要注意的是当对不存在的一行添加锁时,InnoDB引擎同样会使用间隙锁,导致其他事务想插入该条不存在的行数据时被阻塞。

三、锁的常见问题

  锁的常见问题有两种:
  1、行锁升级为表锁
  2、死锁
  3、锁的性能优化

3.1 行锁升级为表锁

  在介绍行锁时,已经指出InnoDB的行锁是基于索引实现的,如果访问数据的语句实际没有用到索引,那么就不可能用到行锁。如何知道访问语句有没有使用到索引呢?需要用户使用explain对单条SQL进行排查,其方法详见文献《MySQL索引及优化》。当SQL语句使用不到索引而不用加行锁时,InnoDB会自动将行锁升级为表锁。
  避免措施:从上对行锁升级为表锁的介绍可知,用户需要加锁的SQL语句一定要通过explain检测是否使用了索引,从而可避免行锁升级表锁。

3.2 死锁

  两个事务A、B,都需要获取两个资源X、Y,A事务先获取到X资源,B事务先获取到Y资源,此时事务A、B并不冲突,现在A事务开始请求Y资源,因Y资源正被B事务加锁占用,从而A事务阻塞并等待B事务释放Y资源,同时,B事务开始请求X资源,因X资源正被A事务加锁占用,从而B事务阻塞并等待A事务释放X资源。我们把这种A、B事务同时阻塞并等待对方资源的状态称为死锁。

3.2.1 InnoDB处理死锁的方式

  在数据发生死锁之后,InnoDB引擎一般都能自动检测到,并使一个事务释放锁并回退,另一个事务从而获得锁,得以完成事务。但在涉及外部锁,或涉及表锁的情况下,InnoDB 并不能完全自动检测到死锁, 这需要通过设置锁等待超时参数 innodb_lock_wait_timeout 来解决,默认值是50s,参数innodb_deadlock_detect可以控制这个逻辑,默认开启。

3.2.2 InnoDB避免死锁的方法

  1. 可以在事务开始时通过为预期要修改的每个行使用SELECT … FOR UPDATE语句来获取必要的锁,即使这些行的更改语句是在之后才执行的。
  2. 在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应在事务中先申请共享锁、更新时再申请排他锁,因为这时候当用户再申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。比如事务A中有两条操作,一条是获取数据,一条是修改数据,那么事务A没有必要在获取数据时申请共享锁,在修改数据时申请排他锁,只需要在事务开始时,获取排他锁即可。
  3. 如果事务需要修改或锁定多个表,则应在每个事务中以相同的顺序使用加锁语句。 在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。比如两个事务A、B,都需要获取两个资源X、Y时,若事务A、B都以相同的顺序来访问资源X、Y,那么就不会产生死锁。
  4. 改变事务隔离级别。

3.3 锁的性能优化

  关于优化的问题,其实都是具体场景具体分析,此处只给出了一些常用的经验,具体措施,还需回归场景。

  1. 尽量使用较低的隔离级别;
  2. 合理设计索引,尽量让SQL使用到索引,才能使用粒度更小的行锁,使得加锁更精确, 从而减少锁冲突的机会;
  3. 合理设计事务大小,小事务间发生锁冲突的机率也更小;
  4. 合理设计事务,要避免事务间产生死锁;
  5. 尽量用相等条件访问数据,从而避免间隙锁对并发插入的影响;
  6. 不要申请超过实际需要的锁级别;
  7. 在COMMITTED READ(读提交)和REPEATABLE READ(可重复读)两种隔离级别下,因为有MVCC机制,所以没有特殊情况,SELECT语句不要显示加锁。

四、MVCC机制

  多版本并发控制,Multi-Version Concurrency Control,MVCC。MVCC 是一种并发控制的方法,实现对数据库的并发访问。从前文可知锁就是控制并发操作的,但是系统开销较大,而MVCC可以在大多数情况下代替行级锁,以降低其系统开销。

4.1 MVCC机制的技术背景

  在InnoDB中有四种隔离级别,其中REPEATABLE READ级别满足事务之间互不影响,同时具有高并发性。这种效果是不能由上面介绍的悲观锁实现的,因为一旦使用悲观锁,高并发性大大降低。本章开头提到了一种乐观锁,它系统开销小,并发性强,所以REPEATABLE READ隔离级别便使用了乐观锁的思想来实现,也就是本节介绍的MVCC。

4.2 MVCC机制的实现

  基于乐观锁的思想,MVCC也采用数据的版本号来保证数据的一致性。MVCC在每一行数据的后面隐式添加两列数据,一列为行数据创建时间,一列为行数据删除时间,存储的是对该行数据操作的事务系统版本号,版本号是按照事务开启的顺序递增的。为了方便讲解,此处的系统版本号用事务ID替代,如下所示:

id name 行数据创建时间 行数据删除时间
1 Damon 1 2

  该行数据表示,该行是由事务1创建的,同时被事务2删除了。
  多事务的情况下,如何保证一个事务能访问到有效数据呢?需要当前事务的系统版本号大于等于创建时间,同时小于删除时间或删除时间为空,那么就表示事务可以访问到该行数据。

4.3 MVCC运行实例

  本节将以实际的SQL操作介绍MVCC的运行机制,为了讲解清晰,系统版本号用事务ID替代。

4.3.1 MVCC运行Insert操作

  当事务1执行如下操作:

start transaction;
insert into student_table values(1,'Damon');
insert into student_table values(2,'Tom');
insert into student_table values(3,'Xuebao');
commit;

  事务1完成后,表中的数据如下所示:

id name 行数据创建时间 行数据删除时间
1 Damon 1 undefined
2 Tom 1 undefined
3 Xuebao 1 undefined

  因为事务1只进行了插入操作,所以表中各行数据只有创建时间被标记为1。

4.3.2 MVCC运行Delete操作

  事务2开始执行如下操作:

start transaction;
Delete from student_table where id = 3;
commit;

  事务2完成后,表中的数据如下所示:

id name 行数据创建时间 行数据删除时间
1 Damon 1 undefined
2 Tom 1 undefined
3 Xuebao 1 2

  因为事务2只进行了删除操作,所以表中id为3的行数据删除时间被标记为2。

4.3.3 MVCC运行Select操作

  事务3开始执行如下操作:

start transaction;
Select * from student_table where id = 1;
Select * from student_table where id = 3;
commit;

  事务3的第一句Select:版本号为3大于id为1的行数据的创建时间1且删除时间未定义,所以该语句可以访问到id为1的数据。
  事务3的第二句Select:版本号为3大于id为3的行数据的创建时间1,但大于删除时间2,所以该语句不能访问到id为3的数据。
  所以事务3执行完后,应返回如下表数据:

id name 行数据创建时间 行数据删除时间
1 Damon 1 undefined
2 Tom 1 undefined

4.3.4 MVCC运行Update操作

  InnoDB引擎在执行Update操作时,其实是新添加了一行数据,同时更新老数据行的删除时间。
  事务4开始执行如下操作:

start transaction;
Update student_table Set name=Tom2 where id = 2;
commit;

  事务4执行后,首先将id为2的行数据删除时间设置为事务4的系统版本号4,同时新插入一行原数据,创建时间为4,删除时间未定义,如下表所示:

id name 行数据创建时间 行数据删除时间
1 Damon 1 undefined
2 Tom 1 undefined
3 Xuebao 1 2
2 Tom2 4 undefined

五、总结

  本文介绍了MySQL中锁相关的知识点,以乐观锁、悲观锁为主线,依次介绍了MySQL中的5种悲观锁,意向共享锁、意向排他锁、共享锁、排他锁、间隙锁;然后分析了使用悲观锁时常见的三个问题,并给出了锁的性能优化策略;最后详细分析了MySQL的乐观锁,即MVCC机制,并给出增删改查四种情况下MVCC的分析过程。

发布了21 篇原创文章 · 获赞 0 · 访问量 1811
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章