MySQL是怎样运行的(九)

并发事务访问

  并发事务访问相同记录的情况大致可以划分为3种:

  1. 读-读:并发事务相继读取相同的记录。读操作本身不会对记录有任何影响,所以这种情况是被允许的
  2. 写-写:并发事务相继对相同的记录进行改动
  3. 读-写:一个事务进行读取,另一个事务进行改动

写-写

  在写-写情况下会发生脏写现象,任何一种隔离级别都不允许这种现象发生。所以在多个未提交事务相继对同一条记录进行改动时,需要让它们排队执行。这个排队执行的过程是通过对该记录加锁来实现的。

读-写

  在读-写情况下会出现脏读、不可重复读和幻读现象。主流有两种方案可以用来避免这些现象:

  1. 读操作使用MVCC,写操作加锁
    • 通过生成一个ReadView,然后根据ReadView找到符合条件的历史版本(历史版本是由Undo Log构建的)。写操作针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本这两者并不冲突,也就是采用MVCC时,读-写操作并不冲突。
  2. 读、写操作都采用加锁的方式
    • 比如有些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本

一致性读

  事务利用MVCC进行的读取操作称为一致性读(Consistent Read)。所有普通的SELECT语句(plain SELECT)在READ COMMITTEDREPEATABLE READ隔离级别下都是一致性读。一致性读不会对表中的任何记录进行加锁,其它事务可以自由的对表中的记录进行改动。

锁定读

  当使用加锁的方式进行并发事务控制时,由于既要允许读-读情况不受影响,又要使读-写、写-写操作互相阻塞,所以InnoDB提供的锁有多种类型:

  1. 共享锁(Shared Lock):简称S锁。在事务要读取一条记录时,需要先获取该记录的S

  2. 排他锁(eXclusive Lock):简称X锁。在事务要改动一条记录时,需要先获取该记录的X

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

MySQL提供了如下两种特殊的SELECT语法来支持锁定读:

  1. SELECT ... LOCK IN SHARE MODE:如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样可以允许别的事务继续获取这些记录的S锁,但是如果别的事务想获取这些记录的X锁,那么它们会被阻塞,直到当前事务提交之后将这些记录上的S锁释放掉为止
  2. SELECT ... FOR UPDATE:如果当前事务执行了该语句,那么它会为读取到的记录加X锁。假如别的事务想要获取这些记录的S锁或X锁,那么它们会被阻塞,直到当前事务提交之后将这些记录上的X锁释放掉为止

写操作

  1. DELETE:对一条记录执行DELETE操作的过程是先在B+树中定位到这条记录,然后获取这条记录的X锁,最后再执行delete mark
  2. INSERT:一般情况下,新插入的记录受到隐式锁保护,不需要在内存中为其生成对应的锁结构
  3. UPDATEUPDATE操作比较复杂,又分为如下3种情况
    1. 未修改键值:
      • 就地更新:先在B+树中定位到这条记录,然后获取这条记录的X锁,最后在原记录上进行修改
      • 非就地更新:先在B+树中定位到这条记录,然后获取这条记录的X锁,之后将该记录彻底删除掉,最后再插入一条记录,注意这个X锁会转移到新插入的记录上
    2. 修改键值:
      • 相当于在原记录上执行DELETE之后再来一次INSERT,加锁操作需要按照DELETEINSERT的规则进行

在一个事务中加的锁一般在事务提交或中止时才会释放。当然也有特殊情况,后面会说。

多粒度锁

表锁

  前面提到的锁都是针对记录的,一般称为行锁。对一条记录加锁,影响的也只是这条记录而已。其实一个事务也可以在表级别进行加锁,这种锁自然也就称为表锁。对一个表加锁,会影响表中的所有记录。给表加的锁也可以分为共享锁和排他锁。

  1. 给表加共享锁:
    • 别的事务可以继续获得该表的S
    • 别的事务可以继续获得该表中某些记录的S
    • 别的事务不可以继续获得该表的X
    • 别的事务不可以继续获得该表中某些记录的X
  2. 给表加排他锁:
    • 别的事务不可以继续获得该表的S
    • 别的事务不可以继续获得该表中某些记录的S
    • 别的事务不可以继续获得该表的X
    • 别的事务不可以继续获得该表中某些记录的X

意向锁

  我们在对某个表上表锁时,怎么知道表中的记录没有被上行锁呢?比如说表中有些记录上加有排他锁,那么这会阻止其它事务给表加表级锁。逐个检查记录当然可以,不过效率显然不高,因此InnoDB提出了一种称为意向锁(Intention Lock)的东西。

  1. 意向共享锁(Intention Shared Lock):简称IS锁,当事务准备给某条记录上加S锁时,需要先在表级别加一个IS
  2. 意向排他锁(Intention eXclusive Lock):简称IX锁,当事务准备给某条记录加X锁时,需要先在表级别加一个IX

ISIX是表级锁,它们的提出仅仅为了在之后加表级别的S锁或X锁时可以快速判断表中的记录是否有上锁,以避免用遍历的方式来查看表中有没有上锁的记录;也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁也是兼容的。

InnoDB中的行锁和表锁

InnoDB中的表锁

表级S锁、X锁

  InnoDB提供的表级S锁和X锁其实比较鸡肋,只会在一些特殊情况下(比如在系统崩溃恢复时)用到。在autocommit=0innodb_table_locks=1时,要手动获取InnoDB提供的表级S锁或X锁,可以按照下面这样来写语句:

  1. LOCK TABLE tbl READInnoDB会对表tbl加表级S
  2. LOCK TABLE tbl WRITEInnoDB会对表tbl加表级X

InnoDB提供的表级S锁和X锁了解了解就可以了,真要使用它只会降低并发能力,没什么别的好处。

表级IS锁、IX锁

  前面已经分析过,这里就不多提了。

表级AUTO-INC锁

  MySQL中我们可以为表的某个列添加AUTO_INCREMENT属性,这样在书写插入语句时可以不指定该列的值,系统会自动为它赋予递增的值。MySQL在实现这个特性时会采用AUTO-INC锁,具体的,在执行插入语句时加一个表级AUTO-INC锁,然后为每条待插入的AUTO_INCREMENT修饰的列分配递增的值。在该语句执行结束后,再把AUTO-INC锁释放掉。这样一来,一个事务在持有AUTO-INC锁的过程中,其它事务的插入语句会被阻塞,从而保证在一个语句中分配的递增值是连续的。

InnoDB中的行锁

  行级锁,也称为记录锁,InnoDB中的行级锁有多种类型。换句话说,即使对同一条记录加行锁,如果记录的类型不同,起到的效果也是不同的。

Record Lock

  前面提到的行锁指的就是记录锁(Record Lock),也就是仅仅锁住一条记录;Record Lock是有S锁和X锁之分的。

Gap Lock

  使用加锁的方式解决幻读问题需要使用到范围锁(Range Lock),MySQL中称为间隙锁(Gap Lock)。给一条记录加间隙锁只是不允许其它事务向这条记录之前的间隙插入新记录,也就是说间隙锁仅仅是为了防止插入幻影记录而提出的。虽然间隙锁也有共享和独占之分,但是它们起到的作用都是相同的。而且如果对一条记录加了间隙锁(无论是共享的还是独占的),并不会限制其它事务对这条记录加Record Lock或继续加Gap Lock

Next-Key Lock

  有时候我们既想锁住某条记录,又想阻止其它事务在该记录前面的间隙插入新记录,InnoDB为此提供了临键锁(Next-Key Lock)。Next-Key Lock的本质是Record LockGap Lock的组合,所以它既能保护该记录,又能阻止别的事务将新记录插入到被保护记录前面的间隙中。

Insert Intention Lock

  一个事务在插入一条记录时,需要判断插入位置是否已被别的事务加了Gap LockNext-Key Lock也包含Gap Lock)。如果有的话,插入操作需要等待,直到拥有Gap Lock的那个事务提交为止。不过,事务在等待时也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入记录,这个锁就是插入意向锁(Insert Intention Lock)。注意,插入意向锁并不会阻止别的事务继续获取该记录上任何形式的锁。

隐式锁

  在内层中生成锁结构并维护它们并不是一件零成本的事,本着能省就省的原则,InnoDB又双叒叕提出了隐式锁的概念。比如INSERT一条新记录,它的DB_TRX_ID列的值就是插入这条记录的事务ID。如果其它事务此时想对该记录加S锁或X锁,首先会看一下该记录的DB_TRX_ID列代表的事务是否是当前的活跃事务。如果不是的话就可以正常读取;如果是的话,那么就帮助这个事务创建一把X锁,然后为自己也创建一个锁结构,当然,此时自己就会处于阻塞状态。

  由上可知,隐式锁起到了延迟生成锁结构的作用。如果别的事务在执行过程中不需要获取与该隐式锁相冲突的锁,就可以避免在内存中生成锁结构。

语句加锁分析

plain SELECT

  利用MVCC进行读取,不需要加锁。

锁定读

  1. SELECT ... LOCK IN SHARE MODE
  2. SELECT ... FOR UPDATE
  3. UPDATE ...
  4. DELETE ...

其中1、2是MySQL定义的两种锁定读的语法格式,而3、4由于在执行过程中需要首先定位到被改动的记录并给记录加锁,因此也可以认为是一种锁定读。

匹配模式

  在使用索引执行查询时,查询优化器首先会生成若干个扫描区间。如果被扫描的区间是一个单点扫描区间,此时的匹配模式就是精确匹配,否则就是不精确匹配。注意,联合索引最左前缀的列如果是与常量进行等值匹配的话,也算是精确匹配。

唯一性搜索

  如果在扫描某个区间的记录前,就能事先确定该扫描区间最多只包含一条记录的话,那么就把这种情况称为唯一性搜索。只要查询符合下面这些条件,就可以确定某个区间内是否最多只包含一条记录:

  1. 匹配模式为精确匹配
  2. 使用的索引是主键或者唯一二级索引(不能携带xxx is null这个条件)

如何加锁

  了解了匹配模式和唯一性搜索之后,就可以着手分析语句加锁的过程了。不过在语句执行过程中,对记录进行加锁的影响因素太多了(比如事务的隔离级别、使用到的索引,亦或是执行的语句类型等),我们先分析在一般情况下如何对记录加锁,然后再列举一些比较特殊的情况进行分析。

一般情况

  我们把锁定读的执行看成是依次读取若干个扫描区间内的记录。在一般情况下,读取某个扫描区间中的记录的过程如下:

  1. 首先快速地在B+树叶子节点中定位到该扫描区间中的第一条记录,把该记录作为当前记录

  2. 为当前记录加锁。在隔离级别不大于READ COMMITTED时,为当前记录加Record Lock;在隔离级别不小于REPEATABLE READ时,则为当前记录加Next-Key Lock

  3. 判断索引条件下推是否成立,注意ICP是为了减少回表次数而提出的,所以它只对二级索引有效;并且ICP只适用于SELECT语句

    • 如果当前记录符合索引条件下推中的条件,跳到步骤4继续执行
    • 如果不符合,直接获取当前记录所在单链表的下一条记录,将该记录作为新的当前记录,并跳回步骤2

    判断当前记录是否符合形成扫描区间的边界条件,如果不符合,则跳过步骤4、5,直接向Server层返回一个查询完毕的信息。注意,步骤3不会释放锁

  4. 执行回表操作。如果读取的是二级索引记录,则需要进行回表操作,获取到对应的聚簇索引记录并给该记录加Record Lock

  5. 判断边界条件是否成立。如果该记录符合边界条件,则跳到步骤 6继续执行,否则在隔离级别不大于READ COMMITTED时,就要释放掉加在该记录上的锁(隔离级别不小于REPEATABLE READ时不会释放加在该记录上的锁)

  6. Server层继续判断其余搜索条件是否成立。除了索引列上的条件以外,Server层还需要判断其它搜索条件是否成立。如果成立,则将该记录发送到客户端,否则在隔离级别不大于READ COMMITTED时,就要释放掉加在该记录上的锁(隔离级别不小于REPEATABLE READ时不会释放加在该记录上的锁)

  7. 获取当前记录所在单链表的下一条记录,并将其作为新的当前记录,接着跳回步骤2

4种锁定读都符合上述加锁步骤,只不过SELECT ... LOCK IN SHARE MODE加的是S锁,而SELECT ... FOR UPDATE加的是X锁。对于UPDATE ...DELETE ...来说,加的也是X锁,只不过如果更新了二级索引列,也需要为二级索引中的记录加X锁。

特殊情况
  • 当隔离级别不大于READ COMMITTED时,如果匹配模式是精确匹配,则不会为扫描区间后面的下一条记录加锁
  • 当隔离级别不小于REPEATABLE READ时,如果匹配模式是精确匹配,则会为扫描区间后面的下一条记录加Gap Lock
  • 当隔离级别不小于REPEATABLE READ时,如果匹配模式不是精确匹配,则会为扫描区间后面的下一条记录加Next-Key Lock
  • 当隔离级别不小于REPEATABLE READ时,如果使用的是聚簇索引,并且扫描的区间是左闭区间,而且定位到的第一条聚簇索引记录的主键值正好与扫描区间中最小的值相同,那么会为该聚簇索引记录加Record Lock,扫描区间中的其它记录加Next-Key Lock
  • 无论是哪个隔离级别,只要是唯一性搜索,并且读取的记录没有被标记为已删除(deleted_flag = 1),就为读取的记录加Record Lock
  • 当隔离级别不小于REPEATABLE READ且按照从右往左的顺序(ORDER BY xxx DESC)扫描扫描区间中的记录时,会给匹配到的第一条记录的下一条记录加Gap Lock

这些特殊情况要么是为了避免出现幻读现象,要么是根据MySQL的一些固有特点(比如主键不能重复)将部分Next-Key Lock替换成Record Lock,从而尽量减少对其它事务的影响。

半一致性读

  半一致性读(Semi-Consistent Read)是一种夹在一致性读和锁定读之间的读取方式。当隔离级别不大于READ COMMITTED且执行UPDATE语句时将使用半一致性读。

  所谓半一致性读,就是当UPDATE语句读取到已经被其它事务加了X锁的记录时,InnoDB会将该记录的最新版本读出来,然后判断该版本是否与UPDATE语句中的搜索条件相匹配。如果不匹配,则不对该记录加锁,从而跳到下一条记录;如果匹配,则再次读取该记录并对其进行加锁。这样处理只是为了让UPDATE语句尽量少地被别的语句阻塞。

INSERT

  在插入一条记录时,如果该记录的主键值已经存在,则在生成报错信息前,还需要对那条已经存在的记录加S锁,具体的,

  • 当隔离级别不大于READ COMMITTED时,加的是SRecord Lock
  • 当隔离级别不小于REPEATABLE READ时,加的是SNext-Key Lock

如果是唯一索引列的值重复,无论是哪个隔离级别,都会为那条已经存在的唯一二级索引 记录加SNext-Key Lock

Reference

  摘抄自《MySQL是怎样运行的——从根儿上理解MySQL》,小孩子4919 著。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!