锁:

事务的隔离性实现

概述

是计算机协调多个进程或线程并发访问某一资源的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某数据时,尤其是针对一些敏感的数据(比如订单、全额等),就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性

为保证数据的一致性,需要对并发操作进行控制,因此出现。同时锁机制为实现MySQL的各个隔离级别提供保证。锁冲突是影响数据库并发访问性能的一个重要因素。

MySQL并发事务访问相同记录

读-读情况

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

写-写情况

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

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

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有时会在内存中生成锁结构与之关联。比如,事务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,事务需要等待,不可以继续执行操作

读-写或写-读情况

  • 读-写写-读,即一个事务进行读取操作,另一个进行改动操作,该情况下可能发生脏读不可重 复读幻读 问题
  • 各个数据库厂商对 SQL标准 的支持都可能不一样。比如MySQL在 REPEATABLE READ隔离级别上就已经解决幻读问题

并发问题的解决方案

解决脏读不可重复读幻读

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

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

普通的SELECT语句在READ COMMITTEDREPEATABLE READ隔离级别下会使用到MVCC读取记录:

  • READ COMMITTED隔离级别下,事务在执行过程中每次执行SELECT操作时都会生成一 个ReadView,ReadView的存在本身就保证事务不可以读取到未提交的事务所做的更改 ,避免了脏读现象
  • REPEATABLE READ隔离级别下,事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都 复用该ReadView,避免了不可重复读和幻读的问题

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

如果业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。在读取记录时需要对其进行加锁操作,这意味操作和操作也像写-写操作那样排队执行。

脏读产生是因为当前事务读取另一个未提交事务写的一条记录,如果另一个事务在写记录时就给这条记录加锁,那么当前事务就无法继续读取该记录,就不会有脏读问题的产生

不可重复读产生是因为当前事务先读取一条记录,另外一个事务对该记录做改动并提交后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,也不会发生不可重复读

幻读问题产生是因为当前事务读取一个范围的记录,然后另外的事务向该范围内插入新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题有些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取时加锁就有点尴尬(因为并不知道给谁加锁)

小结对比

  • 采用MVCC方式,读-写操作彼此并不冲突, 性能更高
  • 采用加锁方式,读-写操作彼此需要排队执行 ,影响性能

一般情况下采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况 下,要求必须采用加锁方式执行

锁的不同角度分类

锁的分类

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

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

  • 读锁 ( 共享锁 、S):针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞
  • 写锁 (排他锁 、X):当前写操作没有完成前,它会阻断其他写锁和读锁,确保在给定时间只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源

注意:对于InnoDB引擎来说,读锁和写锁可以加在表上,也可以加在行上

举例(行级读写锁)

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

此处兼容是指对同一张表或记录的锁的兼容性情况

锁定读

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

对读取的记录加S锁

1
2
3
SELECT ... LOCK IN SHARE MODE;
-- 或
SELECT ... FOR SHARE; -- MySQL 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锁释放

MySQL 8.0 新特性

在5.7及之前的版本,SELECT ... FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。

在8.0版本中,SELECT ... FOR UPDATESELECT ...FOR SHARE添加NOWAITSKIP LOCKED语法,跳过锁等待,或者跳过锁定。

  • 通过添加NOWAITSKIP LOCKED语法,能够立即返回。如果查询的行已经加锁:
    • 那么NOWAIT会立即报错返回
    • SKIP LOCKED会立即返回,只是返回的结果中不包含被锁定的行

写操作

DELETE

对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。

UPDATE

  • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读
  • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除(即把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护

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

INSERT

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

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

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

对一条记录加锁影响的只是该记录说明锁的粒度比较细,事务也可以在表级别进行加锁(表级锁或者表锁),对一个表加锁影响整个表中的记录说明锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。

表锁(Table Lock)

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

表级别的S锁、X锁

在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。

在对某个表执行一些诸如 ALTER TABLE 、 DROP 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锁

意向锁 (intention lock)

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

  1. 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存
  2. 意向锁是一种不与行级锁冲突表级锁,表明某个事务正在某些行持有锁或该事务准备去持有锁

    意向锁分类:

  • 意向共享锁(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 会先获取该数据行所在数据表的对应意向锁 。

意向锁解决的问题

有两个事务T1T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁,就是给更大一级别的空间示意里面是否已经上过锁。

在数据表的场景中,如果给某行数据加上排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经上了排它锁,当其他人想要获取数据表排它锁时,只需了解是否有人已经获取这个数据表的意向排他锁即可。

  • 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁
  • 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁

这时意向锁会告诉其他事务已经有人锁定了表中的某些记录。

意向锁的并发性

意向锁不会与行级的共享 / 排他锁互斥,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性

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

自增锁(AUTO-INC锁)

所有插入数据的方式总共分为三类 :

  • Simple inserts
  • Bulk inserts
  • Mixed-mode inserts

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锁的竞争会 限制并发 能力

innodb_autoinc_lock_mode = 1 连续锁定模式 8.0 前默认

此锁定模式下bulk inserts使用AUTO-INC表级锁并保持到语句结,适用于所有INSERT ... SELECTREPLACE ... SELECTLOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC

innodb_autoinc_lock_mode = 2 错锁定模式 8.0 后默认

此锁定模式下自动递增值保证在所有并发执行的所有类型的insert语句中是唯一且单调递增 的,由于多个语句可以同时生成数字,为任何给定语句插入的行生成的值可能 不是连续的

元数据锁(MDL锁)

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

InnoDB中的行锁

行锁(Row Lock)称为记录锁,即锁住某一行(某条记录row),MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

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

记录锁(Record Locks)

记录锁仅是把一条记录锁上,类型为 LOCK_REC_NOT_GAP 。比如把id值为8的 记录加锁,仅仅是锁住id值为8的记录,对周围的数据没有影响

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

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

间隙锁(Gap Locks)

MySQL 在REPEATABLE READ隔离级别下可以解决幻读问题,解决方案有两种

  1. 使用MVCC方 案解决
  2. 采用加锁方案解决

但是在使用加锁方案解决时有个大问题,事务在第一次执行读取操作时,那些幻影记录尚不存在,因此无法给这些 幻影记录加上记录锁

InnoDB提出了Gap Locks锁,类型为LOCK_GAPGap Locks提出仅是为防止插入幻影记录,如果对一条记录加Gap Locks(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加Gap Locks

栗子,比如把id值为8的记录加一个gap锁:

图中id值为8的记录加了gap锁,不允许别的事务在id值为8的记录前边的间隙插入新记录 ,假设其他事务再想插入id值为4的新 记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有gap锁,就会阻塞插入操作,直到拥有gap锁的事务提交后,id列的值在区间(3, 8)中的新记录才可以被插入

临键锁(Next-Key Locks)

有时既想锁住某条记录 ,又想阻止其他事务在该记录前边的间隙插入新记录 ,InnoDB提出了Next-Key Locks,类型为LOCK_ORDINARYNext-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁, innodb默认的锁就是Next-Key locks

Next-Key locks本质是记录锁Gap Locks的结合,既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。

插入意向锁(Insert Intention Locks)

事务在 插入 一条记录时需要判断一下插入位置是不是被别的事务加了Gap Locks,如果是,插入操作则需要等待,直到拥有Gap Locks的事务提交。

但是InnoDB规定事务在等待时需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁称为Insert Intention Locks,类型为LOCK_INSERT_INTENTION,插入意向锁是Gap Locks锁 ,不是意向锁,在insert 操作时产生。

插入意向锁的特性可以分成两部分:

  • 插入意向锁是一种特殊的Gap Locks,可以锁定开区间内的部分记录
  • 插入意向锁之间互不排斥,即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引引)不冲突,那么事务间就不会出现冲突等待

注意:插入意向锁并不属于意向锁而属于Gap Locks,因为意向锁是表锁而插入意向锁是行锁

栗子

T1为id值为8的记录加Gap Locks,然后T2T3分别想向student表中插入id值分别为4、5的两条记录:

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

页锁

页锁是在页的粒度上进行锁定,它锁定的数据资源比行锁多,一个页中可以有多个行记录。当使用页锁时会出现数据浪费,但浪费最多是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

每个层级的锁数量是有限制的,因为锁会占用内存空间, 锁空间的大小有限。当某个层级的锁数量超过这个层级的阈值时,就会进行锁升级,即用更大粒度的锁替代多个更小粒度的锁

乐观锁和悲观锁

乐观锁和悲观锁并不是锁,而是锁的设计思想

悲观锁 (Pessimistic Locking)

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据时都会上 锁,别人想拿这个数据就会阻塞直到它拿到锁。

比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。

栗子1

Java中synchronizedReentrantLock等独占锁就是悲观锁 。

栗子2

使用悲观锁可以解决该问题,商品信息从查询出来到修改,中间有一个生成订单的过程。当查询items信息后就把当前的数据锁定,直到修改完毕后再解锁。

那么整个过程中,因为数据被锁定,就不会有其他人来对其进行修改,这样做的前提是需要将要执行的SQL语句放在同一个事务中,否则达不到锁定数据行的目的。

注意

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

不足

悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程席的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受,此时就需要乐观锁。

乐观锁(Optimistic Locking)

乐观锁认为对同一数据的并发操作不会总发生,不用每次都对数据上锁,但是在更新时会判断在此期间别人是否更新了数据,不用数据库自身的锁机制,而是通过程序来实现。

在程序上,可以采用版本号机制或者CAS机制实现。乐观锁适用于多读的应用类型, 可以提高吞吐量。juc中原子类使用的是CAS机制实现。

乐观锁的版本号机制

在表中设计版本字段version,第一次读时会获取version值,然后对数据进行更新或删除时,会执行UPDATE ... SET version=version+1 WHERE version=version。如果已经有事务修改了数据,本次修改就不会成功。

乐观锁的时间戳机制

时间戳和版本号机制一样,在更新提交时将当前数据的时间戳更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

栗子

两种锁的适用场景

乐观锁

乐观锁适合读操作多的场景,相对来说写的操作比较少,优点在于程序实现 , 没有死锁问题

悲观锁

悲观锁 适合写操作多的场景,写的操作具有排它性 。采用悲观锁的方式,可以在数据库层 面阻止其他事务对该数据的操作权限,从而防止读 - 写写 - 写的冲突

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

隐式锁

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

  • 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条记录去获取该记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条记录去获取该记录的X锁,如果允许这种情况的发生,那么可能产生脏读问题
  • 立即修改该记录去获取这条记录的X锁,如果允许这种情况的发生,那么可能产生脏写问题

一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id存在,相当于加了隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁存在,会先帮助当前事务生成锁结构,然后自己再生成锁结构后进入等待状态。隐式锁采用一种延迟加锁的机制,从而来减少加锁的数量。

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

隐式锁的逻辑过程

A:InnoDB的每条记录中都有隐含的trx_id字段,它存在于聚簇索引的B+Tree中

B:在操作一条记录前,首先根据记录中trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁(即为该事务添加一个锁)

C:检查是否有锁冲突,如果有冲突则创建锁,并设置为waiting状态;如果没有冲突则不加锁并跳到E

D:等待加锁成功,要么被唤醒,或者超时

E:写数据,并将自己的trx_id写入trx_id字段

显式锁

一般通过特定的语句进行加锁称显示加锁

1
2
3
4
5
-- 显示加共享锁:
SELECT ... LOCK IN SHARE MODE;

-- 显示加排它锁:
SELECT ... FOR UPDATE

全局锁

全局锁是对整个数据库实例 加锁,当需要让整个库处于只读状态时,可以使用该命令,之后其他线程的以下语句会被阻塞:

  • 数据更新语句(数据的增删改)
  • 数据定义语句(建表、修改表结构)
  • 更新类事务的提交语句

全局锁的典型使用场景:做全库逻辑备份

1
2
-- 全局锁的命令:
FLUSH TABLES WITH READ LOCK;

死锁

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。

栗子1

事务1在等待事务2释放id=2的行锁,而事务2在等待事务1释放id=1的行锁。事务1事务2在互相等待对方的资源释放,于是进入死锁状态

栗子2

死锁产生必要条件

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

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

处理死锁

等待直到超时(innodb_lock_wait_timeout=50s)

即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时就将其回滚,另一个事务继续进行

使用死锁检测进行死锁处理
Innodb提供wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时都会触发该算法。它一种较为主动的死锁检测机制,要求数据库保存锁的信息链表事务等待链表两部分信息。

死锁检测的原理:

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

一旦检测到回路、有死锁,这时InnoDB存储引擎会选择回滚undo量最小的事务,让其他事务继续执行(innodb_deadlock_detect=on表示开启这个逻辑)

缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致死锁,该操作时间复杂度是 $O(N)$ 。如果100个并发线程同时更新同一行,意味着要检测100*100= 1万次,1万个线程就会有1千万次检测。

解决方案

  • 方式1:关闭死锁检测,但可能会出现大量的超时,会导致业务有损
  • 方式2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作。

避免死锁

  • 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争
  • 调整业务逻辑SQL执行顺序
  • 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小
  • 在并发高的系统,不要显式加锁,特别是是在事务里显式加锁
  • 降低隔离级别

锁的内存结构

InnoDB中锁结构

锁所在的事务信息

  • 不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了该锁结构 ,这里就记录该事务的信息

  • 此锁所在的事务信息 在内存结构中是指针,通过指针可以找到内存中关于该事务的更多信息

索引信息

  • 对于行锁来说,需要记录一下加锁的记录是属于的索引,它也是一个指针

表锁/行锁信息

  • 表锁记载着是对哪个表加的锁,还有其他的一些信息
  • 行锁记载三个重要的信息:
    • Space ID: 记录所在表空间
    • Page Number:记录所在页号
    • 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
LOCK_WAIT (十进制的256 当第9个比特位置为 1 时,表示 is_waiting为 true(当前事务尚未获取到锁,处在等待状态);当这个比特位为 0 时,表示 is_waiting为 false (当前事务获取锁成功)

在InnoDB存储引擎中LOCK_ISLOCK_IXLOCK_AUTO_INC都算是表级锁的模式,LOCK_SLOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

锁的类型lock_type,占用第5~8位

  • LOCK_TABLE(十进制的16),当第5个比特位置为1时,表示表级锁
  • LOCK_REC(十进制的32),当第6个比特位置为1时,表示行级锁

行锁的具体类型rec_lock_type,使用其余的位来表示。只有在lock_type值为LOCK_REC时,即只有在该锁为行级锁时,才会被细分为更多的类型:

属性 说明
LOCK_ORDINARY (十进制的0 表示next-key
LOCK_GAP(十进制的512 当第10个比特位置为1时,表示Gap Locks
LOCK_REC_NOT_GAP (十进制的1024 是当第11个比特位置为1时,表示记录
LOCK_INSERT_INTENTION (十进制的2048 当第12个比特位置为1时,表示插入意向锁

锁监控

一般可以通过检查InnoDB_row_lock等状态变量来分析系统上的行锁的争夺情况

1
SHOW STATUS LIKE 'innodb_row_lock%';

状态量 说明
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

1
2
3
4
5
6
7
8
-- 查询正在被锁阻塞的SQL
SELECT * FROM information_schema.INNODB_TRX;

-- 查询锁等待情况
SELECT * FROM data_lock_waits;

-- 查询锁的情况(MySQL 8.0)
SELECT * from performance_schema.data_locks;

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

MySQL 8.0 删除了 information_schema.INNODB_LOCKS,添加了performance_schema.data_locks,可 以通过performance_schema.data_locks查看事务的锁情况,

和MySQL 5.7 及之前不同,performance_schema.data_locks不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

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