MySQL锁

1、概述

  • 是计算机协调多个进程或线程并发访问某一资源的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性一致性。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样很重要。
  • 在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了。同时锁机制也为实现MySQL的各个隔离级别提供了保证。锁冲突也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。

2、MySQL并发事务访问相同记录

2.1 读-读情况

  • 读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。

2.2 写-写情况

  • 写-写情况,即并发事务相继对相同的记录做出改动。

  • 在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:

  • 当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比如,事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:

    • 在锁结构里有很多信息,为了简化理解,只把两个比较重要的属性拿了出来:

      • trx信息:代表这个锁结构是哪个事务生成的。
      • is_waiting :代表当前事务是否在等待。
  • 当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

  • 在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true ,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,图示:

  • 在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:

  • 小结几种说法:

    • 不加锁。
      • 意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。
    • 获取锁成功,或者加锁成功。
      • 意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务
        可以继续执行操作。
    • 获取锁失败,或者加锁失败,或者没有获取到锁。
      • 意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务
        需要等待,不可以继续执行操作。

2.3 读-写或写-读情况

  • 读-写写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读不可重复读幻读的问题。

  • 各个数据库厂商对SQL标准的支持都可能不一样。比如MySQL在REPEATABLE READ隔离级别上就已经解决了幻读问题。

2.4 并发问题的解决方案

  • 怎么解决脏读不可重复读幻读这些问题呢?其实有两种可选的解决方案:

    • 方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。

      • 所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

      普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。

      • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;

      • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。

    • 方案二:读、写操作都采用加锁的方式。

      • 如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着操作和操作也像写-写操作那样排队执行。
      • 脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。
      • 不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。
      • 幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。
  • 小结对比发现:

    • 采用MVCC方式的话,读-写操作彼此并不冲突,性能更高
    • 采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。
    • 一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。

3、锁的不同角度分类

  • 锁的分类图,如下:

3.1 从数据操作的类型划分:读锁、写锁

  • 对于数据库中并发事务的读-读情况并不会引起什么问题。对于写-写读-写写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写读-写或写-读情况中的操作相互阻塞,所以MySQL实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为**共享锁(Shared Lock,SLock)排他锁(Exclusive Lock,XLock),**也叫读锁(readlock)和写锁(write lock)。

    • 读锁:也称为共享锁、英文用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。

    • 写锁:也称为排他锁、英文用X表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

  • 需要注意的是对于InnoDB引擎来说,读锁和写锁可以加在表上,也可以加在行上。

  • 举例(行级读写锁)∶如果一个事务T1已经获得了某个行r的读锁,那么此时另外的一个事务T2是可以去获得这个行r的读锁的,因为读取操作并没有改变行r的数据;但是,如果某个事务T3想获得行r的写锁,则它必须等待事务T1、T2释放掉行r上的读锁才行。

  • 总结:这里的兼容是指对同一张表或记录的锁的兼容性情况。

    X锁 S锁
    X锁 不兼容 不兼容
    S锁 不兼容 兼容

3.1.1 锁定读

  • 在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:

  • 对读取的记录加S锁:

    1
    2
    3
    SELECT ... LOCK IN SHARE MODE;
    # 或
    SELECT ... FOR SHARE; # (8.0新增语法)
    • 在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHAREMODE语句来读取这些记录),但是不能获取这些记录的X锁(比如使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的x锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。
  • 对读取的记录加X锁:

    1
    SELECT ... FOR UPDATE;
    • 在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁(比方说别的事务使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),也不允许获取这些记录的X锁(比如使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的s锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
  • MySQL8.0新特性:

    • 在5.7及之前的版本,SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。在8.0版本中,SELECT. FOR UPDATE,SELECT …FOR SHARE添加NOWAITSKIP LOCKED语法,跳过锁等待,或者跳过锁定。

      • 通过添加NOWAIT、SKIP LOCKED语法,能够立即返回。如果查询的行已经加锁:
        • 那么NOWAIT会立即报错返回(获取不到锁立即返回)。
        • 而SKIP LOCKED也会立即返回,只是返回的结果中不包含被锁定的行。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      CREATE TABLE `account` (
      `id` int NOT NULL AUTO_INCREMENT,
      `name` varchar(32) DEFAULT NULL,
      `balance` decimal(10,2) DEFAULT NULL,
      PRIMARY KEY (`id`)
      ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb3;

      insert into account(name, balance) VALUES
      ("张三", 40),
      ("李四", 0),
      ("王五", 100);

      # session 1
      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      # session 1
      mysql> select * from account for update;
      +----+--------+---------+
      | id | name | balance |
      +----+--------+---------+
      | 4 | 张三 | 40.00 |
      | 5 | 李四 | 0.00 |
      | 6 | 王五 | 100.00 |
      +----+--------+---------+
      3 rows in set (0.00 sec)

      # session 2
      mysql> select * from account for update nowait;
      ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.

      # session 2
      mysql> select * from account for update skip locked;
      Empty set (0.00 sec)

3.1.2 写操作

  • 平常所用到的写操作无非是DELETEUPDATEINSERT这三种:
    • DELETE:
      对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁锁定读

    • UPDATE:在对一条记录做UPDATE操作时分为三种情况:

      • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化

        则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁锁定读

      • 情况2∶未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。

        则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取×锁锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。

      • 情况3∶修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETEINSERT的规则进行了。

    • INSERT :

      • 一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问

3.2 从数据操作的粒度划分:表级锁、页级锁、行锁

  • 为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)(越小消耗越大)。因此数据库系统需要在高并响应系统性能两方面进行平衡,这样就产生了“锁粒度(Lock granularity)”的概念。

  • 对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。

3.2.1 表锁(Table Lock)

  • 该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎(不管你是MySQL的什么存储引擎,
    对于表锁的策略都是一样的),并且表锁是开销最小的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣

3.2.1.1 表级别的S锁、X锁

  • 在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。在对某个表执行一些诸如ALTER TABLEDROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)结构来实现的。

  • 一般情况下,不会使用InnoDB存储引擎提供的表级别S锁X锁。只会在一些特殊情况下,比方说崩溃恢复过程中用到。比如,在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写:

    • LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。
    • LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。
  • 不过尽量避免在使用InnoDB存储引擎的表上使用LOCK TABLES这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的行锁,关于InnoDB表级别的S锁和X锁大家了解一下就可以了。

  • 举例:下面我们讲解MyISAM引擎下的表锁。

    • ①创建表并添加数据。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      CREATE TABLE mylock(
      id INT NOT NULL PRIMARY KEY auto_increment,
      NAME VARCHAR(20)
      )ENGINE myisam; # 存储引擎使用InnoDB也可以,只是不建议

      # 插入一条数据
      mysql> INSERT INTO mylock(NAME) VALUES('a');
      Query OK, 1 row affected (0.00 sec)

      # 查看表中所有数据
      mysql> select * from mylock;
      +----+------+
      | id | NAME |
      +----+------+
      | 1 | a |
      +----+------+
      1 row in set (0.00 sec)
    • ②查看表上加过的锁。

      1
      2
      3
      4
      5
      6
      SHOW OPEN TABLES; # 主要关注In_use字段的值
      # 或者
      SHOW OPEN TABLES where In_use > 0;

      mysql> SHOW OPEN TABLES where In_use > 0;
      Empty set (0.00 sec)
      • 表明当前数据库中没有被锁定的表。
    • ③手动添加表锁。

      1
      2
      LOCK TABLES t READ; # 存储引擎会对表t加表级别的共享锁。共享锁也叫读锁或S锁(Share的缩写
      LOCK TABLES t WRITE; # 存储引擎会对表t加表级别的排他锁。排它锁也叫独占锁、写锁或X锁(是eXclusive的缩写)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      mysql> LOCK TABLES mylock READ;
      Query OK, 0 rows affected (0.00 sec)

      mysql> SHOW OPEN TABLES where In_use > 0;
      +----------+--------+--------+-------------+
      | Database | Table | In_use | Name_locked |
      +----------+--------+--------+-------------+
      | dbtest | mylock | 1 | 0 |
      +----------+--------+--------+-------------+
      1 row in set (0.00 sec)
    • ④释放表锁。

      1
      2
      3
      4
      5
      6
      7
      UNLOCK TABLES; # 使用此命令解锁当前加锁的表

      mysql> UNLOCK TABLES;
      Query OK, 0 rows affected (0.00 sec)

      mysql> SHOW OPEN TABLES where In_use > 0;
      Empty set (0.00 sec)
    • ⑤加读锁。我们为mylock表加read锁(读阻塞写),观察阻塞的情况,流程如下:

    • ⑥加写锁。为mylock表加write锁,观察阻塞的情况,流程如下:

  • 总结:

    • MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。

    • MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)

      • 表共享读锁(Table Read Lock)
      • 表独占写锁(Table Write Lock)
      锁类型 自己可读 自己可写 自己可操作其他表 他人可读 他人可写
      读锁 否,等
      写锁 否,等 否,等

3.2.1.2 意向锁 (intention lock)

  • InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,而 意向锁 就是其中的一种表锁

    • 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。
    • 意向锁是一种不与行级锁冲突表级锁,这一点非常重要。
    • 表明“某个事务正在某些行持有了锁或该事务准备去持有锁”。
  • 意向锁分为两种:

    • 意向共享锁 (intention shared lock, IS):事务有意向对表中的某些行加 共享锁 (S锁

      1
      2
      -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 
      SELECT column FROM table ... LOCK IN SHARE MODE;
    • 意向排他锁 (intention exclusive lock, IX):事务有意向对表中的某些行加 排他锁 (X锁)

      1
      2
      -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
      SELECT column FROM table ... FOR UPDATE;
  • 即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InooDB会先获取该数据行所在数据表的对应意向锁

  • 意向锁要解决的问题

    • 现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。
    • 在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了(不这么做的话,想上表锁的那个程序,还要遍历有没有行锁,性能低下),这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
      • 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁
      • 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁
    • 这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。
  • 举例:创建表teacher,插入6条数据,事务的隔离级别默认为Repeatable-Read,如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    CREATE TABLE `teacher` (
    `id` INT NOT NULL,
    `name` VARCHAR ( 255 ) NOT NULL,
    PRIMARY KEY ( `id` )
    ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

    INSERT INTO `teacher` VALUES
    ('1','zhangsan'),
    ('2','lisi'),
    ('3','wangwu'),
    ('4','zhaoliu'),
    ('5','songhongkang'),
    ('6','leifengyang');

    mysql> SELECT @@transaction_isolation;
    +-------------------------+
    | @@transaction_isolation |
    +-------------------------+
    | REPEATABLE-READ |
    +-------------------------+
    1 row in set (0.00 sec)
    • 假设事务A获取了某一行的排他锁,并未提交,语句如下所示。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      mysql> SELECT * FROM teacher WHERE id = 6 FOR UPDATE;
      +----+-------------+
      | id | name |
      +----+-------------+
      | 6 | leifengyang |
      +----+-------------+
      1 row in set (0.01 sec)
    • 事务B想要获取teacher表的表读锁,语句如下。

      1
      2
      3
      4
      5
      6
      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      # 阻塞
      mysql> LOCK TABLES teacher READ;

      • 因为共享锁与排他锁互斥,所以事务B在试图对teacher表加共享锁的时候,必须保证两个条件。
        • 当前没有其他事务持有teacher表的排他锁。
        • 当前没有其他事务持有teacher表中任意一行的排他锁。
      • 为了检测是否满足第二个条件,事务B必须在确保teacher表不存在任何排他锁的前提下,去检测表中的每一行是否存在排他锁。很明显这是一个效率很差的做法,但是有了意向锁之后,情况就不一样了。
      • 此时事务B检测事务A持有teacher表的意向排他锁,就可以得知事务A必然持有该表中某些数据行的排他锁,那么事务B对teacher表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排他锁。
  • 意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。

    意向共享锁(lS) 意向排他锁(IX)
    意向共享锁(IS) 兼容 兼容
    意向排他锁(IX) 兼容 兼容
  • 即意向锁之间是互相兼容的,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥。

    意向共享锁(lS) 意向排他锁(IX)
    共享锁(S)表 兼容 互斥
    排他锁(X)表 互斥 互斥
    • 注意这里的排他/共享锁指的都是表锁,意向锁不会与行级的共享/排他锁互斥。
  • 意向锁的并发性

    • 意向锁不会与行级的共享/排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了)

    • 我们扩展一下上面 teacher表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可能存在多种不同锁,但是这里我们只着重表现意向锁)。

    • 例如,事务A先获取了某一行的排他锁,并未提交:

      1
      2
      BEGIN;
      SELECT * FROM teacher WHERE id=6 FOR UPDATE;
      • 事务A获取了teacher表上的意向排他锁,事务A获取了id为6的数据行上的排他锁。之后事务B想要获取teacher表的共享锁。

        1
        2
        BEGIN;
        LOCK TABLES teacher READ;
      • 事务B检测到事务A持有 teacher表的意向排他锁。事务B对teacher表的加锁请求被阻塞(排斥)。最后事务C也想获取teacher表中某一行的排他锁。

        1
        2
        BEGIN;
        SELECT * FROM teacher WHERE id=5 FOR UPDATE;
      • 事务C申请teacher表的意向排他锁。事务C检测到事务A持有teacher表的意向排他锁。因为意向锁之间并不互斥,所以事务C获取到了teacher表的意向排他锁。因为id为5的数据行上不存在任何排他锁,最终事务C成功获取到了该数据行上的排他锁。

  • 从上面的案例可以得到如下结论:

    • InnoDB 支持多粒度锁,特定场景下,行级锁可以与表级锁(意向锁)共存。
    • 意向锁之间互不排斥,但除了 IS 与 S 兼容外,意向锁会与 共享锁 / 排他锁 互斥
    • IX,IS是表级锁,不会和行级的X,S锁发生冲突。只会和表级的X,S发生冲突。
    • 意向锁在保证并发性的前提下,实现了行锁和表锁共存满足事务隔离性的要求。

3.2.1.3 自增锁(AUTO-INC锁)

  • 在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性。举例:

    1
    2
    3
    4
    5
    CREATE TABLE `teacher` (
    `id` int NOT NULL AUTO_INCREMENT,
    `name` varchar( 255 ) NOT NULL,
    PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
  • 由于这个表的id字段声明了AUTO_INCREMENT,意味着在书写插入语句时不需要为其赋值,SQL语句修改
    如下所示。

    1
    INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi');
  • 上边的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,结果如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
    mysql> select * from teacher;
    +----+----------+
    | id | name |
    +----+----------+
    | 1 | zhangsan |
    | 2 | lisi |
    +----+----------+
    2 rows in set (0.00 sec)
  • 现在我们看到的上面插入数据只是一种简单的插入模式,所有插入数据的方式总共分为三类,分别是“Simple inserts”,“Bulk inserts”和“Mixed-mode inserts”。

    • ①“Simple inserts” (简单插入):可以预先确定要插入的行数(当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()REPLACE语句。比如我们上面举的例子就属于该类插入,已经确定要插入的行数。
    • ②“Bulk inserts” (批量插入)事先不知道要插入的行数(和所需自动递增值的数量)的语句。比如INSERT … SELECT,REPLACE… SELECT和LOAD DATA语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。
    • ③“Mixed-mode inserts” (混合模式插入):这些是“Simple inserts”语句但是指定部分新行的自动递增值。例如INSERT INTO teacher (id,name) VALUES (1,’a’), (NULL,’b’), (5,’c’), (NULL,’d’);只是指定了部分id的值。另一种类型的“混合模式插入”是 INSERT ... ON DUPLICATE KEY UPDATE
  • 对于上面数据插入的案例,MySQL中采用了自增锁的方式来实现,AUTO-INC锁是当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。也正因为此,其并发性显然并不高,当我们向一个有AUTO_INCREMENT关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所以innodb通过innodb_autoinc_lock_mode的不同取值来提供不同的锁定机制,来显著提高SQL语句的可伸缩性和性能。

  • innodb_autoinc_lock_mode有三种取值,分别对应与不同锁定模式:

    • innodb_autoinc_lock_mode = 0(“传统”锁定模式):在此锁定模式下,所有类型的insert语句都会获得一个特殊的表级AUTO-INC锁,用于插入具有AUTO_INCREMENT列的表。这种模式其实就如我们上面的例子,即每当执行insert的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的auto_increment为顺序,且在binlog中重放的时候,可以保证master与slave中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert的时候,对于AUTO-INC锁的争夺会限制并发能力
    • innodb_autoinc_lock_mode = 1(“连续”锁定模式):
      • 在MySQL 8.0之前,连续锁定模式是默认的。
      • 在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT …SELECT,REPLACE … SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。
      • 对于“Simple inserts”(要插入的行数事先已知),则通过在mutex(轻量锁)的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC锁,则“Simple inserts”等待AUTO-INC锁,如同它是一个“bulk inserts”。
    • innodb_autoinc_lock_mode = 2(“交错”锁定模式):
      • 从 MySQL 8.0 开始,交错锁模式是默认设置。
      • 在这种锁定模式下,所有类INSERT语句都不会使用表级AUTO-INC锁,并且可以同时执行多个语句。这是最快和最可扩展的锁定模式,但是当使用基于语句的复制或恢复方案时,从二进制日志重播SQL语句时,这是不安全的。(主从复制id可能不一致)
      • 在此锁定模式下,自动递增值保证在所有并发执行的所有类型的insert语句中是唯一且单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号), 为任何给定语句插入的行生成的值可能不是连续的。
      • 如果执行的语句是“simple inserts”,其中要插入的行数已提前知道,除了“Mixed-mode inserts”之外,为单个语句生成的数字不会有间隙。然而,当执行“bulk inserts”时,在由任何给定语句分配的自动递增值中可能存在间隙。

3.2.1.4 元数据锁(MDL锁)

  • MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

  • 因此, 当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显式使用,在访问一个表的时候会被自动加上。

  • 举例:元数据锁的使用场景模拟

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    # 会话A:从表中查询数据
    mysql> begin;
    Query OK, 0 rows affected (0.00 sec)

    # 默认加了MDL读锁
    mysql> select count(1) from teacher;
    +----------+
    | count(1) |
    +----------+
    | 6 |
    +----------+
    1 row in set (0.00 sec)

    # 证明加了MDL读锁
    mysql> show processlist;
    +----+-----------------+-----------+--------+---------+--------+---------------------------------+---------------------------------+
    | Id | User | Host | db | Command | Time | State | Info |
    +----+-----------------+-----------+--------+---------+--------+---------------------------------+---------------------------------+
    | 5 | event_scheduler | localhost | NULL | Daemon | 675599 | Waiting on empty queue | NULL |
    | 82 | root | localhost | dbtest | Sleep | 17341 | | NULL |
    | 83 | root | localhost | dbtest | Sleep | 14300 | | NULL |
    | 85 | root | localhost | dbtest | Query | 0 | init | show processlist |
    | 86 | root | localhost | dbtest | Query | 86 | Waiting for table metadata lock | alter table teacher add age int |
    +----+-----------------+-----------+--------+---------+--------+---------------------------------+---------------------------------+
    5 rows in set (0.00 sec)
    1
    2
    3
    4
    5
    6
    7
    8
    # 会话B
    mysql> begin;
    Query OK, 0 rows affected (0.00 sec)

    # 这个时候就会阻塞在这里,有其他事务在读数据。
    # 修改表结构需要加MDL写锁,此时读写锁互斥,阻塞。
    mysql> alter table teacher add age int;

    1
    2
    3
    4
    5
    6
    7
    # 会话C
    mysql> begin;
    Query OK, 0 rows affected (0.00 sec)

    # 原本MDL读锁之间不互斥,但因为上方的会话B想对teacher表加MDL写锁但在阻塞中(排队),导致此时再加读锁时也被阻塞。
    mysql> select count(1) from teacher;

  • 上方会话C即解释元数据锁可能带来的问题:降低了并发性。

    Session A Session B Session C
    begin;
    select count(1) from teacher;
    alter table teacher add age int;
    select count(1) from teacher;
    • 我们可以看到session A会对表teacher加一个MDL读锁,之后sessionB要加MDL写锁会被blocked,因为sessionA的MDL读锁还没有释放,而session C要在表teacher上新申请MDL读锁的请求也会被sessionB阻塞。前面我们说了,所有对表的增删改查操作都需要先申请MDL读锁,就都被阻塞,等于这个表现在完全不可读写了。

3.2.2 InnoDB中的行锁

  • 行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要的注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

    • 优点:锁定力度小,发生锁冲突概率低,可以实现的并发度高
    • 缺点:对于锁的开销比大,加锁会比较慢,容易出现死锁情况。
  • InnoDB与MylSAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。

  • 首先我们创建表如下:

    1
    2
    3
    4
    5
    6
    CREATE TABLE student (
    id INT,
    name VARCHAR(20),
    class varchar(10),
    PRIMARY KEY (id)
    ) Engine=InnoDB CHARSET=utf8;
  • 向这个表里插入几条记录:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    INSERT INTO student VALUES 
    (1,'张三','一班'),
    (3,'李四','一班'),
    (8,'王五','二班'),
    (15,'赵六','二班'),
    (20,'钱七','三班');

    mysql> select * from student;
    +----+--------+--------+
    | id | name | class |
    +----+--------+--------+
    | 1 | 张三 | 一班 |
    | 3 | 李四 | 一班 |
    | 8 | 王五 | 二班 |
    | 15 | 赵六 | 二班 |
    | 20 | 钱七 | 三班 |
    +----+--------+--------+
    5 rows in set (0.00 sec)
  • student表中的聚簇索引的简图如下所示。

    • 这里把B+树的索引结构做了一个超级简化,只把索引中的记录给拿了出来,下面看看都有哪些常用的行锁类型。

3.2.2.1 记录锁(Record Locks)

  • 记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP。比如我们把id值为8的
    那条记录加一个记录锁的示意图如图所示。仅仅是锁住了id值为8的记录,对周围的数据没有影响。

  • 举例如下:

  • 记录锁是有S锁和X锁之分的,称之为S型记录锁X型记录锁

    • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
    • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。

3.2.2.2 间隙锁(Gap Locks)

  • MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁。比如,把id值为 8 的那条记录加一个gap锁的示意图如下。

  • 图中id值为8的记录加了gap锁,意味着不允许别的事务在id值为8的记录前边的间隙插入新记录,其实就是id列的值(3,8)这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为4的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(3,8)中的新记录才可以被插入。

  • gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享gap锁独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。

  • 举例:

    session 1 session 2
    select * from student where id =8 lock in share mode;
    select * from student where id = 8 for update;
    • 这里session2会阻塞,因为id=8记录存在,读写锁互斥。
    session 1 session 2
    select * from student where id = 5 lock in share mode;
    select * from student where id = 5 for update;
    • 这里session2并不会被堵住。因为表里并没有id=5这个记录,因此session1加的是间隙锁(3,8)。而 session2也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但它们之间是不冲突的。

    • 注意,给一条记录加了gap锁只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是student表中id值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入id值在(20,+oo)这个区间的新记录呢?这时候我们在讲数据页时介绍的两条伪记录派上用场了:

      • Infimum记录,表示该页面中最小的记录。
      • Supremum记录,表示该页面中最大的记录。
    • 为了实现阻止其他事务插入id值在(20,+oo)这个区间的新记录,我们可以给索引中的最后一条记录,也就是id值为20的那条记录所在页面的Supremum记录加上一个gap锁,如图所示。

      1
      2
      mysql> select * from student where id > 20 lock in share mode;
      Empty set (0.00 sec)

      检测:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      mysql> SELECT * FROM performance_schema.data_locks\G
      …………
      *************************** 5. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730418904:28:4:1:140131629604928
      ENGINE_TRANSACTION_ID: 421606707129560
      THREAD_ID: 158
      EVENT_ID: 19
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629604928
      LOCK_TYPE: RECORD
      LOCK_MODE: S
      LOCK_STATUS: GRANTED
      LOCK_DATA: supremum pseudo-record
      5 rows in set (0.01 sec)

      这样就可以阻止其他事务插入id值在(20,+oo)这个区间的新记录。

    session 1 session 2
    select * from student where id =5 lock in share mode;
    insert into student(id, name, class) values (6, ‘tom’, ‘三班’);
    • 这里就会阻塞了,因为在(3,8)之间加了间隙锁。
  • 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。下面的例子会产生死锁

    • ①session1执行select…for update语句,由于id=5这一行并不存在,因此会加上间隙锁(3,8);
    • ②session2执行select..…for update语句,同样会加上间隙锁(3,8),间隙锁之间不会冲突,因此这个语句可以执行成功;
    • ③session2试图插入一行(5,宋红康,二班),被session1的间隙锁挡住了,只好进入等待;
    • ④session1试图插入一行(5,’宋红康,二班),被session2的间隙锁挡住了。至此,两个session进入互相等待状态,形成死锁。当然,lnnoDB的死锁检测马上就发现了这对死锁关系,让session1的insert 语句报错返回。

3.2.2.3 临键锁(Next-Key Locks)

  • 有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。比如,我们把id值为8的那条记录加一个next-key锁的示意图如下:

  • next-key锁的本质就是一个记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙

    1
    2
    begin;
    select * from student where id <= 8 and id > 3 for update;

3.2.2.4 插入意向锁(Insert Intention Locks)

  • 我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁next-key锁
    也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规
    定事务在等待的时候也需要在内存中生成一个锁结构
    ,表明有事务想在某个间隙插入新记录,但是
    现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。插入意向锁是一种Gap锁,不是意向锁,在insert
    操作时产生。

  • 插入意向锁是在插入一条记录行前,由INSERT操作产生的一种间隙锁。该锁用以表示插入意向,当多个事务在同一区间(gap)插入位置不同的多条数据时,事务之间不需要互相等待。假设存在两条值分别为4和7的记录,两个不同的事务分别试图插入值为5和6的两条记录,每个事务在获取插入行上独占的(排他)锁前,都会获取(4,7)之间的间隙锁,但是因为数据行之间并不冲突,所以两个事务之间并不会产生冲突(阻塞等待)。总结来说,插入意向锁的特性可以分成两部分:

    • ①插入意向锁是一种特殊的间隙锁——间隙锁可以锁定开区间内的部分记录。
    • ②插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待。
  • 注意,虽然插入意向锁中含有意向锁三个字,但是它并不属于意向锁而属于间隙锁,因为意向锁是表锁而插入意向锁是行锁

  • 比如,把id值为8的那条记录加一个插入意向锁的示意图如下:

  • 比如,现在T1为id值为8的记录加了一个gap锁,然后T2和T3分别想向student表中插入id值分别为4、5的两条记录,所以现在为id值为8的记录加的锁的示意图就如下所示:

    • 从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到id值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

3.2.2.5 页锁

  • 页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。 页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

  • 每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

3.3 从对待锁的态度划分:乐观锁、悲观锁

  • 从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想

3.3.1 悲观锁(Pessimistic Locking)

  • 悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

  • 悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

  • select..…for update是MySQL中悲观锁。此时在items表中,id为1001的那条数据就被我们锁定了,其他的要执行select quantity from items where id =1001for update;语句的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

  • 注意,当执行select quantity from items where id=1001 for update;语句之后,如果在其他事务中执行select quantity from items where id=1001;语句,并不会受第一个事务的影响,仍然可以正常查询出数据。

  • 注意:select……for update语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。

  • 悲观锁不适用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受,这时就需要乐观锁。

3.3.2 乐观锁(Optimistic Locking)

  • 乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是 不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。 乐观锁适用于多读的应用类型,这样可以提高吞吐量 。在Java中java.util.concurrent.atomic包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
  • 乐观锁的版本号机制
    • 在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
    • 这种方式类似我们熟悉的SVN、CVS版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。
  • 乐观锁的时间戳机制
    • 时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行
      比较,如果两者一致则更新成功,否则就是版本冲突。
    • 你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或
      者时间戳),从而证明当前拿到的数据是否最新。

3.3.3 两种锁的适用场景

  • 从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:

    • 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
    • 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写写 - 写的冲突。
  • 我们把乐观锁和悲观锁总结如下图所示。

3.4 按加锁的方式划分:显式锁、隐式锁

3.4.1 隐式锁

  • 一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有在内存生产与该记录关联的锁结构),然后另一个事务:

    • 立即使用SELECT...LOCK IN SHARE MODE语句读取这条记录,也就是要获取这条记录的S锁,或者使用SELECT..FOR UPDATE语句读取这条记录,也就是要获取这条记录的X锁,怎么办?如果允许这种情况的发生,那么可能产生脏读问题。
    • 立即修改这条记录,也就是要获取这条记录的X锁,怎么办?如果允许这种情况的发生,那么可能产生脏写问题。
  • 这时候我们前边提过的事务id又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下:

    • 情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者×锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
    • 情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。
  • 即:一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种延迟加锁的机制,从而来减少加锁的数量。

  • 隐式锁在实际内存对象中并不含有这个锁信息。只有当产生锁等待时,隐式锁转化为显式锁。

  • InnoDB的insert操作,对插入的记录不加锁,但是此时如果另一个线程进行当前读,类似以下的用例,session2会锁等待session1,那么这是如何实现的呢?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    # session 1
    mysql> begin;
    Query OK, 0 rows affected (0.00 sec)

    mysql> select * from student;
    +----+---------+--------+
    | id | name | class |
    +----+---------+--------+
    | 1 | 张三1 | 一班 |
    | 3 | 李四 | 一班 |
    | 8 | 王五 | 二班 |
    | 15 | 赵六 | 二班 |
    | 20 | 钱七 | 三班 |
    +----+---------+--------+
    5 rows in set (0.00 sec)

    mysql> insert INTO student VALUES(2,"周八","二班");
    Query OK, 1 row affected (0.00 sec)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # session 2
    # 由于此时是隐式锁,查看不到
    mysql> SELECT * FROM performance_schema.data_lock_waits\G
    Empty set (0.00 sec)

    mysql> begin;
    Query OK, 0 rows affected (0.00 sec)

    # 执行完,当前事务被阻塞
    mysql> select * from student lock in share mode;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # session 3
    # 发现隐式锁变成了显式锁
    mysql> SELECT * FROM performance_schema.data_lock_waits\G
    *************************** 1. row ***************************
    ENGINE: INNODB
    REQUESTING_ENGINE_LOCK_ID: 140131730419712:28:4:2:140131629611824
    REQUESTING_ENGINE_TRANSACTION_ID: 122591
    REQUESTING_THREAD_ID: 168
    REQUESTING_EVENT_ID: 20
    REQUESTING_OBJECT_INSTANCE_BEGIN: 140131629611824
    BLOCKING_ENGINE_LOCK_ID: 140131730418904:28:4:2:140131629604928
    BLOCKING_ENGINE_TRANSACTION_ID: 122590
    BLOCKING_THREAD_ID: 168
    BLOCKING_EVENT_ID: 18
    BLOCKING_OBJECT_INSTANCE_BEGIN: 140131629604928
    1 row in set (0.00 sec)
  • 隐式锁的逻辑过程如下:

    • InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。
    • 在操作一条记录前,首先根据记录中的trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁(就是为该事务添加一个锁)。
    • 检查是否有锁冲突,如果有冲突,创建锁,并设置为waiting状态。如果没有冲突不加锁,跳到E。
    • 等待加锁成功,被唤醒,或者超时。
    • 写数据,并将自己的trx_id写入trx_id字段。

3.4.2 显式锁

  • 通过特定的语句进行加锁,我们一般称之为显示加锁,例如:
    • 显示加共享锁:

      1
      select ....  lock in share mode
    • 显示加排它锁:

      1
      select ....  for update

3.5 其它锁之:全局锁

  • 全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份

  • 全局锁的命令:

    1
    Flush tables with read lock

3.6 其它锁之:死锁

  • 死锁是指两个事务都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁,从而造成死锁。死锁示例:

    • 举例一:

      事务1 事务12
      1 start transaction; update account set money=10 where id=1; start transaction;
      2 update account set money=10 where id=2;
      3 update account set money=20 where id=2;
      4 update account set money=20 where id=1;
      • 这时候,事务1在等待事务2释放id=2的行锁,而事务2在等待事务1释放id=1的行锁。事务1和事务2在互
        相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略
        • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置。
        • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。将参数innodb_deadlock_detect设置为on,表示开启这个逻辑。
    • 举例二:用户A给用户B转账100,在此同时,用户B也给用户A转账100。这个过程,可能导致死锁。

      1
      2
      3
      4
      5
      6
      # 事务1
      update account set balance = balance - 100 where name='A';# 操作1
      update account set balance = balance + 100 where name='B';# 操作3
      # 事务2
      update account set balance = balance - 100 where name='B';# 操作2
      update account set balance = balance + 100 where name='A';# 操作4
      1651374062908
  • 产生死锁的必要条件:

    • 两个或者两个以上事务。
    • 每个事务已经持有锁并且申请新的锁。
    • 锁资源同时只能被同一个事务持有或者不兼容。
    • 事务之间因为持有锁和申请锁导致彼此循环等待。

    死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。

  • 如何处理死锁:

    • 方式1:等待,直到超时(innodb_lock_wait_timeout=50s)。

      • 即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。

      • 缺点:对于在线服务来说,这个等待时间往往是无法接受的。

      • 那将此值修改短一些,比如1s,0.1s是否合适?不合适,容易误伤到普通的锁等待。

      1
      2
      3
      4
      5
      6
      7
      mysql> show variables like 'innodb_lock_wait_timeout';
      +--------------------------+-------+
      | Variable_name | Value |
      +--------------------------+-------+
      | innodb_lock_wait_timeout | 50 |
      +--------------------------+-------+
      1 row in set (0.00 sec)
    • 方式2:使用死锁检测进行死锁处理。

      • 方式1检测死锁太过被动,innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法都会被触发。

      • 这是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表事务等待链表两部分信息。

      • 基于这两个信息,可以绘制wait-for graph(等待图)。

        1651374907315

      死锁检测的原理是构建一个以事务为顶点、锁为边的有向图,判断有向图是否存在环,存在即有死锁。

      • 一旦检测到回路、有死锁,这时候InnoDB存储引擎会选择回滚undo量最小的事务,让其他事务继续执行(innodb_deadlock-detect=on表示开启这个逻辑)。
      • 缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是O(n)。如果100个并发线程同时更新同一行,意味着要检测100*100=1万次,1万个线程就会有1千万次检测。
      • 如何解决?
        • 方式1:关闭死锁检测,但意味着可能会出现大量的超时,会导致业务有损。
        • 方式2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作。
      • 进一步的思路:
        • 可以考虑通过将一行改成逻辑上的多行来减少锁冲突。比如,连锁超市账户总额的记录,可以考虑放到多条记录上。账户总额等于这多个记录的值的总和。
  • 如何避免死锁?

    • 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。
    • 调整业务逻辑SQL 执行顺序,避免update/delete长时间持有锁的SQL在事务前面。
    • 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
    • 在并发比较高的系统中,不要显式加锁,特别是是在事务里显式加锁。如select..…for update语句,如果是在事务里运行了start transaction 或设置了autocommit等于0,那么就会锁定所查找到的记录。
    • 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。

4、锁的内存结构

  • 我们前边说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比如:

    1
    2
    # 事务T1
    SELECT * FROM user LOCK IN SHARE MODE;
  • 理论上创建多个锁结构没问题,但是如果一个事务要获取10000条记录的锁,生成10000个锁结构也太崩溃了!所以决定在对不同记录加锁时,如果符合下边这些条件的记录会放到一个锁结构中。

    • 在同一个事务中进行加锁操作。
    • 被加锁的记录在同一个页面中。
    • 加锁的类型是一样的。
    • 等待状态是一样的。
  • InnoDB存储引擎中的锁结构如下:

    • 锁所在的事务信息

      • 不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记录这个事务的信息。
      • 锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。
    • 索引信息

      • 对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。
    • 表锁/行锁信息表锁结构行锁结构在这个位置的内容是不同的:

      • 表锁:记载着是对哪个表加的锁,还有其他的一些信息。

      • 行锁:记载了三个重要的信息:

        • Space ID:记录所在表空间。

        • Page Number:记录所在页号。

        • n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。

          n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后也不至于重新分配锁结构。

    • type_mode:这是一个32位的数,被分成了lock_modelock_typerec_lock_type三个部分,如图所示:

      • 锁的模式(lock_mode),占用低4位,可选的值如下:

        • LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁。
        • LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁。
        • LOCK_S(十进制的2):表示共享锁,也就是S锁。
        • LOCK_X(十进制的3):表示独占锁,也就是X锁。
        • LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁。

        在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

      • 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:

        • LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
        • LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
      • 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在1ock-type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:

        • LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
        • LOCK_ORDINARY(十进制的0):表示next-key锁。
        • LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁。
        • LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁。
        • LOCK_INSERT_INTENTION(十进制的 2048 ):也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
      • is_waiting属性呢?基于内存空间的节省,所以把is_waiting属性放到了type_mode这个32位的数字中:

        • LOCK_WAIT(十进制的256) :当第9个比特位置为1时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为 0 时,表示is_waiting为false,也就是当前事务获取锁成功。
    • 其他信息:为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

    • 一堆比特位:如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射
      到页内的一条记录。

5、锁监控

  • 关于MySQL锁的监控,我们一般可以通过检查InnoDB_row_lock等状态变量来分析系统上的行锁的争
    夺情况:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    mysql> show status like 'innodb_row_lock%';
    +-------------------------------+--------+
    | Variable_name | Value |
    +-------------------------------+--------+
    | Innodb_row_lock_current_waits | 0 |
    | Innodb_row_lock_time | 129147 |
    | Innodb_row_lock_time_avg | 16143 |
    | Innodb_row_lock_time_max | 45238 |
    | Innodb_row_lock_waits | 8 |
    +-------------------------------+--------+
    5 rows in set (0.00 sec)
    • Innodb_row_lock_current_waits:当前正在等待锁定的数量;
    • Innodb_row_lock_time:从系统启动到现在锁定总时间长度;(等待总时长)
    • Innodb_row_lock_time_avg:每次等待所花平均时间;(等待平均时长)
    • Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
    • Innodb_row_lock_waits:系统启动后到现在总共等待的次数;(等待总次数)
  • 尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手指定优化计划。

  • 其它监控方法:

    • MySQL把事务和锁的信息记录在了information_schema库中,涉及到的三张表分别是INNODB_TRXINNODB_LOCKSINNODB_LOCK_WAITS

    • MySQL5.7及之前,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。

    • MySQL8.0删除了information_schema.INNODB_LOCKS,添加了performance_schema.data_locks,可以通过performance_schema.data_locks查看事务的锁情况,和MySQL5.7及之前不同,performance_schema.data_locks不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

    • 同时,information_schema.INNODB_LOCK_WAITS也被performance_schema.data_lock_waits所代替。

    • 我们模拟一个锁等待的场景,以下是从这三张表收集的信息,锁等待场景,我们依然使用记录锁中的案例,当事务2进行等待时,查询情况如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      # 事务1
      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      mysql> select * from student for update;
      +----+---------+--------+
      | id | name | class |
      +----+---------+--------+
      | 1 | 张三1 | 一班 |
      | 2 | 周八 | 二班 |
      | 3 | 李四 | 一班 |
      | 8 | 王五 | 二班 |
      | 15 | 赵六 | 二班 |
      | 20 | 钱七 | 三班 |
      +----+---------+--------+
      6 rows in set (0.00 sec)

      1
      2
      3
      4
      5
      6
      7
      # 事务2
      mysql> begin;
      Query OK, 0 rows affected (0.00 sec)

      # 阻塞
      mysql> select * from student for update;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      174
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      197
      198
      199
      200
      201
      202
      203
      204
      205
      206
      207
      208
      209
      210
      211
      212
      213
      214
      215
      216
      217
      218
      219
      220
      221
      222
      223
      224
      225
      226
      227
      228
      229
      230
      231
      232
      233
      234
      235
      # 查询正在被锁阻塞的sql语句
      mysql> SELECT * FROM information_schema.INNODB_TRX\G
      *************************** 1. row ***************************
      trx_id: 122593
      trx_state: LOCK WAIT
      trx_started: 2022-05-01 03:56:08
      trx_requested_lock_id: 140131730418904:28:4:7:140131629605272
      trx_wait_started: 2022-05-01 03:58:05
      trx_weight: 2
      trx_mysql_thread_id: 105
      trx_query: select * from student for update
      trx_operation_state: starting index read
      trx_tables_in_use: 1
      trx_tables_locked: 1
      trx_lock_structs: 2
      trx_lock_memory_bytes: 1128
      trx_rows_locked: 2
      trx_rows_modified: 0
      trx_concurrency_tickets: 0
      trx_isolation_level: REPEATABLE READ
      trx_unique_checks: 1
      trx_foreign_key_checks: 1
      trx_last_foreign_key_error: NULL
      trx_adaptive_hash_latched: 0
      trx_adaptive_hash_timeout: 0
      trx_is_read_only: 0
      trx_autocommit_non_locking: 0
      trx_schedule_weight: 1
      *************************** 2. row ***************************
      trx_id: 122592
      trx_state: RUNNING
      trx_started: 2022-05-01 03:55:20
      trx_requested_lock_id: NULL
      trx_wait_started: NULL
      trx_weight: 2
      trx_mysql_thread_id: 106
      trx_query: NULL
      trx_operation_state: NULL
      trx_tables_in_use: 0
      trx_tables_locked: 1
      trx_lock_structs: 2
      trx_lock_memory_bytes: 1128
      trx_rows_locked: 7
      trx_rows_modified: 0
      trx_concurrency_tickets: 0
      trx_isolation_level: REPEATABLE READ
      trx_unique_checks: 1
      trx_foreign_key_checks: 1
      trx_last_foreign_key_error: NULL
      trx_adaptive_hash_latched: 0
      trx_adaptive_hash_timeout: 0
      trx_is_read_only: 0
      trx_autocommit_non_locking: 0
      trx_schedule_weight: NULL
      2 rows in set (0.02 sec)

      # 查询锁等待情况
      mysql> select * from performance_schema.data_lock_waits\G
      *************************** 1. row ***************************
      ENGINE: INNODB
      REQUESTING_ENGINE_LOCK_ID: 140131730418904:28:4:7:140131629605960
      REQUESTING_ENGINE_TRANSACTION_ID: 122593
      REQUESTING_THREAD_ID: 167
      REQUESTING_EVENT_ID: 24
      REQUESTING_OBJECT_INSTANCE_BEGIN: 140131629605960
      BLOCKING_ENGINE_LOCK_ID: 140131730419712:28:4:7:140131629611136
      BLOCKING_ENGINE_TRANSACTION_ID: 122592
      BLOCKING_THREAD_ID: 168
      BLOCKING_EVENT_ID: 26
      BLOCKING_OBJECT_INSTANCE_BEGIN: 140131629611136
      1 row in set (0.00 sec)

      # 查询所有锁的情况
      mysql> SELECT * from performance_schema.data_locks\G
      *************************** 1. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730418904:1093:140131629607840
      ENGINE_TRANSACTION_ID: 122593
      THREAD_ID: 167
      EVENT_ID: 21
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: NULL
      OBJECT_INSTANCE_BEGIN: 140131629607840
      LOCK_TYPE: TABLE
      LOCK_MODE: IX
      LOCK_STATUS: GRANTED
      LOCK_DATA: NULL
      *************************** 2. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730418904:28:4:7:140131629606304
      ENGINE_TRANSACTION_ID: 122593
      THREAD_ID: 167
      EVENT_ID: 25
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629606304
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: WAITING
      LOCK_DATA: 1
      *************************** 3. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:1093:140131629614048
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: NULL
      OBJECT_INSTANCE_BEGIN: 140131629614048
      LOCK_TYPE: TABLE
      LOCK_MODE: IX
      LOCK_STATUS: GRANTED
      LOCK_DATA: NULL
      *************************** 4. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:1:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: supremum pseudo-record
      *************************** 5. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:2:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 2
      *************************** 6. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:3:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 3
      *************************** 7. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:4:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 8
      *************************** 8. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:5:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 15
      *************************** 9. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:6:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 20
      *************************** 10. row ***************************
      ENGINE: INNODB
      ENGINE_LOCK_ID: 140131730419712:28:4:7:140131629611136
      ENGINE_TRANSACTION_ID: 122592
      THREAD_ID: 168
      EVENT_ID: 26
      OBJECT_SCHEMA: dbtest
      OBJECT_NAME: student
      PARTITION_NAME: NULL
      SUBPARTITION_NAME: NULL
      INDEX_NAME: PRIMARY
      OBJECT_INSTANCE_BEGIN: 140131629611136
      LOCK_TYPE: RECORD
      LOCK_MODE: X
      LOCK_STATUS: GRANTED
      LOCK_DATA: 1
      10 rows in set (0.00 sec)
    • 从锁的情况可以看出来,两个事务分别获取了IX锁,我们从意向锁章节可以知道,IX锁互相时兼容的。所以这里不会等待,但是事务1同样持有X锁,此时事务2也要去同一行记录获取X锁,他们之间不兼容,导致等待的情况发生。

6、附录

  • 间隙锁是在可重复读隔离级别下才会生效的:next-key lock实际上是由间隙锁加行锁实现的,如果切换到读提交隔离级别(read-committed)的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。而在读提交隔离级别下间隙锁就没有了,为了解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。也就是说,许多公司的配置为:读提交隔离级别加binlog_format=row。业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。

  • 总结的加锁规则里面,包含了两个“原则” 、两个“优化”和一个“bug”。

    • 原则1:加锁的基本单位是next-key lock。next-key lock是前开后闭区间。
    • 原则2:查找过程中访问到的对象才会加锁。任何辅助索引上的锁,或者非索引列上的锁,最终
      都要回溯到主键上,在主键上也要加一把锁。
    • 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁。也就是说如果
      InnoDB扫描的是一个主键、或是一个唯一索引的话,那InnoDB只会采用行锁方式来加锁。
    • 优化2:索引上(不一定是唯一索引)的等值查询,向右遍历时且最后一个值不满足等值条件的
      时候,next-keylock退化为间隙锁。
    • 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
  • 我们以表test作为例子,建表语句和初始化语句如下:其中id为主键索引。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    CREATE TABLE `test`(
    `id` int(11) NOT NULL,
    `col1` int(11) DEFAULT NULL,
    `col2` int(11) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `col1`(`col1`)
    ) ENGINE=InnoDB;

    insert into test values
    (0, 0, 0),
    (5, 5, 5),
    (10, 10, 10),
    (15, 15, 15),
    (20, 20, 20),
    (25, 25, 25);
  • 案例一:唯一索引等值查询间隙锁。

    • 由于表test中没有id=7的记录。根据原则1,加锁单位是next-key lock,session A加锁范围就是(5,10];同时根据优化2,这是一个等值查询 (id=7) ,而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10)。
  • 案例二:非唯一索引等值查询锁。

    • 这里session A要给索引col1上col1=5的这一行加上读锁。
    • 根据原则1,加锁单位是next-key lock,左开右闭,5是闭上的,因此会给(0,5]加上next-key lock。
    • 要注意col1是普通索引,因此仅访问col1=5这一条记录是不能马上停下来的(可能有col1=5的其他记录),需要向右遍历,查到col1=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock。
    • 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足col1=5这个等值条件,因此退化成间隙锁(5,10)。
    • 根据原则2, 只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么session B的update语句可以执行完成。
    • 但session C要插入一个(7,7,7)的记录,就会被session A的间隙锁(5,10)锁住,这个例子说明,锁是加在索引上的。
    • 执行for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
    • 如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,因为
      覆盖索引不会访问主键索引,不会给主键索引上加锁。
  • 案例三:主键索引范围查询锁。

    • 上面两个例子是等值查询的,这个例子是关于范围查询的,也就是说下面的语句

      1
      2
      select * from test where id = 10 for update;
      select * from test where id >= 10 and id < 11 for update;
    • 这两条查询语句肯定是等价的,但是它们的加锁规则不太一样。

    • 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。

    • 它是范围查询,范围查找就往后继续找,找到id=15这一行停下来,不满足条件,因此需要加next-key lock(10,15]。

    • session A这时候锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。首次session A定位查找id=10的行的时候,是当做等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。

  • 案例四:非唯一索引范围查询锁。

    • 与案例三不同的是,案例四中查询语句的where部分用的是字段col1,它是普通索引。

    • 这两条查语句肯定是等价的,但是它们的加锁规则不太一样。

    • 在第一次用col1=10定位记录的时候,索引col1上加了(5,10]这个next-key lock后,由于索引col1是非唯一索引,没有优化规则,也就是说不会退化为行锁,因此最终session A加的锁是,索引col1上的(5,10]和(10,15]这两个next-keylock。

    • 这里需要扫描到col1=15才停止扫描,是合理的,因为InnoDB要扫到col1=15,才知道不需要继续往后找了。

  • 案例五:唯一索引范围查询锁bug。

    • session A是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id=15这一行就应该停止了。
    • 但是实现上,InnoDB会往前扫描到第一个不满足条件的行为止,也就是id=20。而且由于这是个范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上。照理说,这里锁住id=20这一行的行为,其实是没有必要的。因为扫描到id=15,就可以确定不用往后再找了。
  • 案例六:非唯一索引上存在”等值”的例子。

    • 这里,我给表test插入一条新记录:insert into t values(30,10,30);也就是说,现在表里面有两个col1=10的行。

    • 但是它们的主键值id是不同的(分别是10和30),因此这两个col1=10的记录之间,也是有间隙的。

    • 这次我们用delete语句来验证。注意,delete语句加锁的逻辑,其实跟select … for update是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

    • 这时,session A在遍历的时候,先访问第一个col1=10的记录。同样地,根据原则1,这里加的是(col1=5,id=5)到(col1=10,id=10)这个next-key lock。

    • 由于col是普通索引,所以继续向右查找,直到碰到(col1=15,id=15)这一行循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(col1=10,id=10)到(col1=15,id=15)的间隙锁。

    • 这个delete语句在索引col1上的加锁范围,就是上面图中蓝色区域覆盖的部分。这个蓝色区域左右两边都
      是虚线,表示开区间,即(col1=5,id=5)和(col1=15,id=15)这两行上都没有锁。

  • 案例七:limit语句加锁。

    • 例子6也有一个对照案例,场景如下所示:

    • sessionA的delete语句加了limit 2。你知道表test里col1=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的。但是加锁效果却不一样。

    • 这是因为,案例七里的delete语句明确加了limit 2的限制,因此在遍历到(col1=10,id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引col1上的加锁范围就变成了从(col1=5,id=5)到( col1=10,id=30)这个前开后闭区间,如下图所示:

    • 这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。

    • 这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。

  • 案例八:一个死锁的例子。

    • session A启动事务后执行查询语句加lock in share mode,在索引col1上加了next-keylock(5,10]和间隙锁(10,15)(索引向右遍历退化为间隙锁)。
    • session B的update语句也要在索引col1上加next-key lock(5,10],进入锁等待;实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加col1=10的行锁,因为sessionA上已经给这行加上了读锁,此时申请死锁时会被阻塞。
    • 然后session A要再插入 (8,8,8) 这一行,被session B的间隙锁锁住。由于出现了死锁,InnoDB让session B回滚。
  • 案例九:order by索引排序的间隙锁1。

    • 如下面一条语句:

      1
      2
      begin;
      select * from test where id > 9 and id < 12 order by id desc for update;
    • 下图为这个表的索引id的示意图。

      • 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个id<12的值”。
      • 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只是最终没找到,但找到了(10,15)这个间隙。(id=15不满足条件,所以next-key lock退化为了间隙锁(10,15) )
      • 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到id=5这一行,又因为区间是左开右闭的,所以会加一个next-key lock(0,5]。也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。
  • 案例十:order by索引排序的间隙锁2。

    • 由于是order by col1 desc,第一个要定位的是索引col1上“最右边的”col1=20的行。这是一个非唯一索引的等值查询。
    • 左开右闭区间,首先加上next-key lock(15,20]。向右遍历,col1=25不满足条件,退化为间隙锁所以会加上间隙锁(20,25)和next-key lock(15,20]。
    • 在索引col1上向左遍历,要扫描到col1=10才停下来。同时又因为左开右闭区间,所以next-key
      lock会加到(5,10],这正是阻塞session B的insert语句的原因。
    • 在扫描过程中,col1=20、col1=15这两行行都存在值,由于是select *,所以会在主键id上加两个行锁。因此,session A的select语句锁的范围就是:
      • 索引col1上(5, 25)。
      • 主键索引上id=15、20两个行锁。
  • 案例十一:update修改数据的例子-先插入后删除。

    • 注意:根据col1>5查到的第一个记录是col1=10,因此不会加(0,5]这个next-key lock。

    • session A的加锁范围是索引col1上的(5,10]、(10,15]、(15,20]、(20,25]和(25,supremum]。

    • 之后session B的第一个update语句要把col1=5改成col1=1,你可以理解为两步:

      • 插入 (col1=1, id=5) 这个记录;
      • 删除 (col1=5, id=5) 这个记录。
    • 通过这个操作,session A的加锁范围变成了如下所示的样子:

    • 接下来session B要执行update test set col1 = 5 where col1 = 1这个语句了,一样地可以拆成两步:

      • 插入 (col1=5, id=5) 这个记录;
      • 删除 (col1=1, id=5) 这个记录。第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。