mysql锁详解

      Mysql的默认存储引擎是InnoDB,因此这里主要讨论InnoDB下各种锁的实现。在java中,为了支持多线程对共享资源的有效访问,java为我们提供了轻量级锁,重量级锁,偏向锁等。同样,InnoDB为了控制并发访问为我们提供了大量的锁机制,这些锁按照不同层面又可以分为:表锁,行锁,乐观锁,悲观锁,共享锁,排他锁,意向锁,记录锁,gap锁,next-key锁等等。

     InnoDB提供了行级的两种锁实现:共享锁(S锁)和排它锁(X锁)。共享锁允许事务持有该锁对加锁行数据进行访问,其他事务如果需要访问被加共享锁的记录行,那么也会在这条记录上加且只能加共享锁;但如果某个事务要更新或删除一条记录,就会在该行数据上加排他锁,此时必须保证,改行数据上没有其他共享锁,否则会导致持有共享锁的事务读取到的数据不一致。排他锁也称为独占锁,在锁持有期间,其他事务对该行数据既不能访问也不能修改。

     我们可以对共享锁和排他锁进行实践。上篇博文提到,为了支持并发访问,InnoDB提供了多版本并发访问控制,即MVCC,在这种情况下,我们使用常规的select语句查询到的所有数据都是历史数据(readview创建时数据库中所有已提交的事务的记录的一个’快照‘中的数据),这也被称为no locking read,因为读的是历史数据,所以不可能出现读取不一致的情况,也不用担心其他事务对数据进行修改,因此并不会进行加锁处理。要读取当前数据库的数据进行加锁演示,我们可以使用select ... lock in share mode和select ... for update,前者会为读取的当前数据库中的某行记录加上共享锁,后者加上排它锁。(这里其实还会加一个意向锁)。

如下 :


      在执行B操作时,session 2中的事务将会对记录(2,3)加上排他锁,由于MVCC,A操作读取的时历史数据,因此session 1中的事务并不会持有(2,3)上的共享锁,此时session 2加锁成功。


这里因为A操作读取的是当前最新的数据库中的数据,因此为了保证读取数据的一致性,session 1将会为(2,3)加上共享锁,此时B操作将一直阻塞直到(2,3)上的锁释放。前面我们就说过,select ... lock in share mode和select ... for update并不是简单的为某行记录添加S锁和X锁,实际上还会为该记录所在的表上添加一个表级的意向锁,详细介绍如下:

                    意向共享锁:该锁现在或之后将有一个事务对该表中的某一行加共享锁。

                    意向排他锁该锁现在或之后将有一个事务对该表中的某一行加排他锁。

 这里为何要另外设计一个表级的意向锁呢?简而言之,意向锁可以大概看作'哨兵'。比如,现在事务T1持有表a上记录(1,1)的共享锁,此时事务T2执行alter操作,为了执行成功,必须确定表a中的每一行数据上都没有锁,此时如果一行行检查,一旦表中记录数一多,检查锁花费的时间将是巨大的。为了避免这一耗时操作,这时只要查看表a上是否有意向锁即可,如果没有,事务T2顺利执行,如果存在,则表明表a中的某条记录正在被使用,事务T2就会一直阻塞直到意向锁撤除,这大大缩减了判断时间。

     这里必须明确一点,上面说的对某行记录加锁,其实是对聚簇索引上的记录加锁,如果是查询条件是根据二级索引,那么会将二级索引上的那个记录和该二级索引指向的主键在聚簇索引上的记录给锁住,如果查询的条件没有任何索引,那么就会进行全表扫描,并且将该表中的每一行数据对应的聚簇索引上的记录加锁,这是很糟糕的一种情况,意味着其他任何事务都不能对该表进行过多操作。这里引出record locks,也就是记录锁。

      record lock:上面我们提到的共享锁,排他锁实际上都属于记录上,也就是给索引上的记录加的锁。例如:select * from test where number=2 for update(这里number上的索引是二级索引)将会对number对应的二级索引上的键值为2的这个记录以及该索引指向的主键的聚簇索引上的记录加上X锁。可能有些人认为只需要为这个二级索引上的2对应的记录加上X锁就行,为什么还要对主键的聚簇索引上的记录加X锁呢?我们不妨假设这样一种情况,假如该表有多个列,id为主键,number列和其他列上有二级索引,如果其他列(假设列名为name)的二级索引的一个记录刚好和number索引上的值为2的记录指向的是同一个主键,由于此时主键对应的聚簇索引上的记录没有加锁,此时我们根据name列来更新这条数据,那么就会造成脏读。

      大部分的锁已经介绍完毕,这些锁都只是对某个记录或这表进行加锁,这些锁的合理运用已经能解决脏读,不可重复读的问题,但还有最后一个问题,幻读。幻读就是说在某个事务在某个范围内插入一条记录,导致其他事务前后两次读取到的数据的行数出现不一致,多出来或少掉的那行就叫幻行。为了解决幻读的问题,InnoDB使用了gap锁。顾名思意,gap锁就是指锁住的不是一条记录而是一个区间。我们首先看下面一个实例:


        test这张表中只有三个记录(2,2),(3,3),(4,4);当session 1中的事务执行A操作时,delete语句为当前读(delete,update,insert内部都有一个select的当前读的操作),因为当前读的状态下,数据是最新的,如果此时有其他事务添加一行number=5的记录,比如(5,5),那么在session 1事务中如果再执行一次A类似的操作,就会发现实际上delete语句执行内部已经检索出一条其他事务新插入的(5,5)记录,而之前,这条记录是根本不存在的,这就是幻读,这行数据也就是幻行。为了解决这个问题,也就是避免其他事务插入number=5的记录,InnoDB将number这个二级索引上的键值为5的左右开区间(因为索引采用B+树存储,B+树叶子节点上的键值是排序的)加锁,其他事务此时如果想要在这区间插入一条记录,那么就会一直阻塞等待锁的释放。在这里和5相邻且比5小的键值为4, 这里没有和5相邻且比5大的键值,因此默认为正无穷,也就是说(4,正无穷)的区间都被加锁,其他事务此时插入number值在这区间的记录将会阻塞。

       next-key锁就是record lock+gap lock, 锁定范围也就是上面例子中实际锁住的范围,而gap锁锁住的范围不包括(5,5).


      现在最关键的一点是要确定什么时候才会产生间隙锁。间隙锁设计一开始就是为了解决幻读问题,因此有可能产生幻读的地方,就会加上间隙锁,比如:

                    select * from test where number=4 lock in share mode;

这条语句执行时是否会产生间隙锁呢?我们从上面知道,该读操作是当前读,因此就必定可能存在并发读写的问题,这里要分两种情况:

       1.number列的索引为普通索引,此时有另一个事务B插入一条number=4的记录(假设记录为(3,4)),那么这条语句执行在事务B执行插入语句前后读取到 的结果将会不一致,也就是说,第二次读会多出一条匹配的记录,这就产生了幻读,此时InnoDB在number列的二级索引上,将键值为4的记录以及该4的左右开区间的间隙给锁上,在主键的聚簇索引上,将键值为4的记录给锁上。

       2.number列的索引为唯一索引,此时在高并发访问下,仍然能保证该语句查询到的结果不会产生幻行,因为其他事务无法插入number=4的记录

       3.number列不加索引,这时只能全表扫描,此时为保证不产生幻读,将锁住主键聚簇索引上的所有记录+记录之间的间隙。这时最糟糕的情况。

因为delete,update,insert(insert比较特殊,可能触发unique key的冲突检查,因此也会有一个当前读)等操作都是当前读操作,因此对它们的分析也是和上面类似。其实很好判断,如果根据当前读的where条件可能先后查询出的结果不一样,那么就一定会加next-key锁(gap锁+record锁)防止幻读。

ps:在公司项目中,平时编写大量使用了uuid(非主键)来作为查找和更新条件,同时uuid列的索引被设计成普通索引,导致产生了间隙,InnoDB为这些间隙加了很多gap锁,在少量并发的情况下,这并没有出现什么问题,但并发量一旦增长,新生成的记录的uuid经过排序后如果恰好在间隙中,那么就会一直阻塞。这大概花了我们三天时间才排查清楚,而解决方案也很简单,更新删除等where条件全部使用主键代替,或者将uuid列上的索引设计成unique类型。


猜你喜欢

转载自blog.csdn.net/summermangozz/article/details/78174286