一、锁的分类1.1 锁模式1.2 锁粒度1.2.1 全局锁1.2.2 表级锁1.2.3 页级锁1.2.4 行锁1.3 锁范围1.3.1 记录锁1.3.2 间隙锁(Gap)1.3.3 临键锁1.3.4 意向锁(IS、IX)1.4 兼容性二、加锁机制2.1 加锁规则2.2 加锁分析2.2.1 无索引等值查询2.2.2 无索引范围查询2.2.3 无索引未命中2.2.4 主键索引等值查询2.2.5 主键索引范围查询2.2.6 主键索引查询未命中2.2.7 二级唯一索引等值查询2.2.8 二级唯一索引范围查询2.2.9 二级唯一索引未命中2.2.10 二级非唯一索引等值查询2.2.11 二级非唯一索引范围查询2.2.12 二级非唯一索引未命中2.2.13 delete的加锁分析三、数据库死锁3.1 死锁定义3.2 死锁必要条件3.3 如何预防/避免死锁?3.4 出现死锁怎么解决?3.5 死锁案例分析3.5.1并发互相转账引起死锁(加锁顺序不一致导致的死锁)3.5.2 insert唯一健冲突导致更新死锁3.5.3 index merge(索引合并)引起的死锁3.5.4 非聚集索引更新导致死锁四、参考文献
一、锁的分类
1.1 锁模式
乐观锁:相对悲观锁而言,乐观锁假定数据一般不产生冲突。仅在提交更新时才检测数据冲突,若冲突则向用户返回错误信息,由用户决定后续操作。适用于读多写少的场景,大量写操作会增大写冲突几率,使业务层频繁重试,大幅降低系统性能。
悲观锁:具有强烈独占和排他性,假设每次访问数据都会被他人修改,对数据修改持保守态度。故在整个数据处理过程中会先对数据加锁,适用于写操作多、数据竞争激烈、数据一致性要求高的场景。
1.2 锁粒度
1.2.1 全局锁
在 MySQL 中,全局锁会锁定整个数据库实例,阻止其他会话对数据库进行某些操作。 当使用mysqldump进行全库备份时,为了确保备份的数据一致性,会使用全局锁让整个库处于只读状态。在备份过程中,如果不锁定数据库,可能会出现备份的数据前后不一致的情况,因为在备份过程中可能会有其他事务对数据进行修改。
全局锁使用方式:
加锁
:可以使用FLUSH TABLES WITH READ LOCK来添加全局锁。执行该命令后,数据库就处于只读状态,其他事务只能进行读操作,写操作会被阻塞。
释放锁
:使用UNLOCK TABLES;命令来释放全局锁。
1.2.2 表级锁
表锁的作用对象是数据库的整张表。主要用于禁止其他线程对特定表进行写操作或者读写操作,从而保证当前线程对表的独占访问,MySQL 中常用的 MyISAM 和 InnoDB 存储引擎都支持表级锁定。在 MySQL 里,表级别的锁共有三种,分别是表锁、元数据锁(MetaData Lock,简称 MDL)以及自增锁(Auto - Inc)。
表锁:
表锁可通过lock tables … read(共享锁)或lock tables … write(排他锁)的方式来使用。例如,当线程 A 执行lock tables t1 read, t2 write命令后,其他线程对 t1 表的写操作以及对 t2 表的读、写操作都会被阻塞。需要着重指出的是,当线程 A 获取表锁后,其他线程对 t1 表的写操作以及对 t2 表的读写操作是被阻塞,而非报错返回。在进行表迁移或者数据库迁移时,若需要长时间禁止对某几张表进行写操作,采用锁表的方式会引发活跃会话数量过高的问题。
元数据锁(MDL):
MDL 的主要作用是保证数据的一致性和完整性。在对表进行操作时,使用 MDL 可以避免不同事务对表结构和数据的并发修改引发的数据混乱。例如,当一个查询正在遍历表数据时,如果另一个事务同时修改了表结构,可能会导致查询结果出错,而 MDL 能够防止这种情况发生。
【加锁时机与类型】:
MDL 读锁
:当对一个表执行增删改查(DML)操作时,会自动加上 MDL 读锁。多个事务可以同时持有同一个表的 MDL 读锁,这意味着多个查询操作可以并发执行。
MDL 写锁
:当对表执行结构变更(DDL)操作,如创建索引、修改列定义、添加或删除表的列等,会自动加上 MDL 写锁。MDL 写锁具有排他性,同一时间只能有一个事务持有表的 MDL 写锁,并且在持有写锁期间,其他事务无法对该表获取读锁或写锁,从而保证表结构变更操作的原子性。
自增锁:
自增锁是一种表级锁,其作用对象为包含自增列的表。自增列是指在创建表时被定义为AUTO_INCREMENT属性的列,MySQL 会为插入操作自动生成唯一且递增的值填充到该列中。自增锁的主要目的是保证自增列的值具有唯一性和连续性。在多事务并发插入数据到包含自增列的表时,如果没有自增锁,可能会出现多个事务获取到相同自增值的情况,导致数据冲突。通过使用自增锁,同一时间只有一个事务能获取自增列的下一个值,从而确保自增列的值不会重复。
【加锁和释放机制】:
加锁时机
:当一个事务向包含自增列的表中插入新数据时,就会获取自增锁。
释放时机
:在 MySQL 中,自增锁的释放时机取决于参数innodb_autoinc_lock_mode的设置。
1.2.3 页级锁
页锁是一种锁定粒度介于表锁和行锁之间的锁。它并非由用户直接控制,而是 InnoDB 存储引擎内部用于防止数据页被并发修改的重要手段。在编写 SQL 语句时,我们无需手动对页锁进行操作,InnoDB 会在后台自动完成页锁的获取与释放。具体而言,当执行查询语句时,InnoDB 会自动为涉及的数据页加上共享锁,这使得多个查询事务可以同时访问这些数据页,从而提高并发性能。而当执行更新语句时,InnoDB 会自动为该页加上独占锁,以确保在更新过程中数据的一致性和完整性,防止其他事务对该页进行读写操作。当事务完成提交或者回滚操作后,之前获取的所有页锁都会被自动释放,以便其他事务可以继续对相应的数据页进行操作。
1.2.4 行锁
行级锁可以精确地控制对数据行的访问,确保在一个事务对某一行数据进行修改时,其他事务无法同时修改该行数据,从而保证数据的一致性和完整性。相比于表级锁,行级锁的粒度更细。表级锁会锁定整张表,在锁定期间其他事务无法对该表进行任何读写操作,这会严重影响数据库的并发性能。而行级锁只锁定需要操作的数据行,不同事务可以同时对表中的不同行进行操作,这一特性使得行级锁发生锁冲突的概率相对较低,能够支持较高的并发度。然而,它也存在一些缺点。行级锁加锁过程相对较慢,锁管理的开销较大,并且更容易引发死锁现象。
【行级锁使用方式】:
显式加锁
:使用SELECT ... WHERE ID = 1 FOR UPDATE
语句可为查询结果集中的数据行加上排他锁。其他事务无法对这些行进行读或写操作,直到持有锁的事务提交或回滚。
隐式加锁
:如更新操作UPDATE t SET col = xx WHERE id = 1
会对满足条件的数据行加排他锁
1.3 锁范围
1.3.1 记录锁
记录锁是锁住一行记录,以阻止其他事务插入更新或者删除id=xx这一行数据。
如执行SQL:select * from t where id = xxx for update
它会在id = xxx 记录上加上记录锁,记录锁又称行锁。
1.3.2间隙锁(Gap)
间隙锁是 InnoDB 存储引擎在可重复读(REPEATABLE READ)隔离级别下为了解决幻读问题而引入的一种锁。它锁定的不是具体的数据行,而是索引记录之间的间隙。例如一个有索引的表中记录的值为 1、4、7、10
间隙锁划分出了以下几个间隙:(-∞, 1),(1, 4),(4, 7),(7, 10),(10, +∞)。如果一个事务在该索引列上执行范围查询或其他可能导致幻读的操作时,会对相应的间隙加上间隙锁。
例如,当事务执行SELECT * FROM table WHERE id > 5 AND id < 9;
时,它会对 (4, 7) 和 (7, 10) 这两个间隙加间隙锁,以防止其他事务在这两个间隙内插入新的数据。
1.3.3 临键锁
临键锁(Next - key Lock)是 MySQL 的 InnoDB 存储引擎在可重复读(REPEATABLE READ)隔离级别下使用的一种锁机制。它实际上是间隙锁(Gap Lock)和记录锁(Record Lock)的组合。记录锁用于锁定索引记录本身,而间隙锁用于锁定索引记录之间的间隙。临键锁既锁定一个记录,又锁定该记录前面的间隙,是一个左开右闭区间。如图片所示,对于索引列值为 1、4、7、10 的情况,形成的临键锁区间分别为 (-∞, 1]、(1, 4]、(4, 7]、(7, 10] 。临键锁其目的主要是为了避免幻读问题,保证事务在并发环境下的数据一致性和隔离性。
在可重复读(RR) 隔离级别下执行SELECT * FROM table WHERE id BETWEEN 5 AND 8;
由于对 (4, 7] 和 (7, 10] 等相关区间加了临键锁,其他事务就无法在这些区间内插入满足条件的新数据.
1.3.4 意向锁(IS、IX)
意向锁(Intention Lock)是数据库中一种重要的锁机制,主要用于协调不同粒度锁(如行级锁和表级锁)之间的关系,提高数据库的并发性能和操作效率。
意向共享锁(IS)
和意向排他锁(IX)
:此类意向锁的作用对象为数据库中的表。当表中存在行锁时,数据库会自动为该表添加意向锁。具体来说,若表中存在共享行锁(S),则表会被加上共享意向锁(IS);若存在排他行锁(X),表会被加上排他意向锁(IX)。这样的好处是:有事务 A 持有表中某些行的行锁, MySQL 会自动为该表添加相应的意向锁。当事务 B 想要申请对整个表的写锁时,无需逐行遍历去判断是否存在行锁,只需检查该表是否存在意向锁即可,这种机制显著提升了数据库的性能。
插入意向锁
:它主要用于管理间隙锁(Gap Lock)和行锁(Record Lock)之间的兼容性,在插入新记录之前,InnoDB引擎会查找适当的插入位置,并在该位置所在的间隙上申请插入意向锁。然后,检查该间隙是否已经有其他事务持有锁(如间隙锁、临键锁)。如果没有,则继续执行插入操作。如果有,则需要等待其他事务释放锁后才能进行插入操作。
1.4 兼容性
共享锁(S)
:共享锁也称作读锁。当事务 A 对特定数据加上读锁之后,其他事务仅能对该数据添加读锁,而不能进行任何修改操作,也就是说无法添加写锁。共享锁的出现主要是为了支持并发的数据读取操作。使用方式:lock table ... read锁定某张表,select …lock in share mode 锁定某行。
排他锁(X)
:排他锁也称写锁。当一个事务对特定数据加上写锁之后,其他事务既无法对该数据添加读锁,也不能添加写锁,也就是说写锁与其他类型的锁都是互斥的。可以使用LOCK TABLE ... WRITE语句来对某张表加上排他锁;通过SELECT … FOR UPDATE语句能够显式地对查询结果中的某行记录加上排他锁,在 MySQL 的 InnoDB 引擎里,UPDATE、DELETE、INSERT操作默认会自动给涉及到的数据加上排他锁
共享锁(S) 与排他锁 (X) 的兼容性如下图:
二、加锁机制
2.1 加锁规则
InnoDB存储引擎加锁基本单位是next-key lock,
对于唯一索引,当进行等值查询时,next-key lock会退化为行锁,
查找过程中访问到的对象才会加锁,这意味着在查询过程中只有被实际访问到的记录或间隙才会被加锁。
在二级索引上加锁后,通常回表到主键索引时也需要加锁.
对于非唯一索引等值查询时,如果向右遍历时最后一个值不满足等值条件,则next-key lock会退化为间隙锁,锁定该值之前的间隙。举个例子有数据80,90,90,95,100,RR隔离级别下执行SELECT * FROM student WHERE score = 90 FOR UPDATE;数据库首先会根据score索引找到第一个score = 90的记录,对该记录及其所在的 Next - Key Lock 区间加锁。这里第一个score = 90记录所在的 Next - Key Lock 区间可能是(80, 90]。然后向右遍历索引,找到下一个score = 90的记录,同样对其加锁。继续向右遍历,当遇到score = 95时,发现该值不满足score = 90的条件。此时,Next - Key Lock 会退化为间隙锁,只锁定(90, 95)这个间隙。
2.2 加锁分析
2.2.1 无索引等值查询
select * from student where score = 95 for update;
图(上)中展示了 MySQL 在读已提交(RC)隔离级别下,无索引等值查询的加锁情况,因score列无索引,数据库需全表顺序扫描。扫描时对每行记录加锁检查,不满足score = 95条件的记录(如score为 100、80、90、66 的记录)检查后释放锁;满足条件的记录(score为 95,对应name为 Emma,id为 5 的记录)加行级排他锁(X REC_LOCK)。
这张图(下)展示了 MySQL 在可重复读(RR)隔离级别下,无索引等值查询的加锁情况。由于score列无索引,数据库需进行全表顺序扫描。在扫描过程中,对每一行数据都加 X NEXT - KEY Lock。例如,从第一行score为 100 的记录开始,到最后一行score为 100 的记录,都会被加上该锁。在事务结束之前,这些锁都不会被释放。即使某一行的score值不等于 95(如score为 100、80、90、66 的行)其加的锁也不会释放。这是为了防止幻读.
2.2.2 无索引范围查询
select * from student where score >= 95 for update;
这张图片(上)展示了 MySQL 在读已提交(RC)隔离级别下,针对student表进行无索引范围查询时的加锁情况,数据库从表的第一行开始,逐行扫描数据并对每行记录加锁。当扫描到某一行的score值不满足score >= 95条件时,如score为 80、90、66 的记录,检查完后会释放对该行所加的锁(图中绿色虚线框表示曾加锁但已释放)。当扫描到score值满足score >= 95条件的记录时,如score为 95 和 100 的记录,会对这些记录加行级排他锁(X REC_LOCK,图中红色实线框表示加锁状态),且这些锁会保持到事务结束或者执行相关解锁操作,以保证在当前事务对这些记录处理期间,其他事务无法修改它们。
如图(下)在可重复读(RR)隔离级别下,针对student表执行的一条带有排他锁(for update)的范围查询语句,由于score列没有索引,数据库无法通过索引快速定位数据,只能进行全表顺序扫描,在扫描过程中,会逐行对每条记录都加 X NEXT - KEY Lock。且无论记录的score值是否满足score >= 95的条件,在事务结束之前,这些锁都不会被释放,这是因为 RR 隔离级别要保证事务内数据的一致性和隔离性,防止在事务执行过程中,其他事务插入新的数据导致幻读现象的发生。
2.2.3 无索引未命中
select * from student where score = 10 for update;
这张图片(上)展示了在 MySQL 的读已提交(RC)隔离级别下,针对student表执行无索引且未命中查询时的加锁过程,由于score列没有索引,数据库从表的第一行开始逐行扫描数据,当扫描到某一行的score值不等于 10 时(在图中的示例数据里,所有记录的score值都不等于 10),检查完该行数据后,会立即释放对该行所加的锁。经过全表扫描加锁和不满足条件时的锁释放操作后,最终没有任何记录被持续锁定
在 RR 隔离级别下,由于score列没有索引,在扫描过程中,会逐一对每一行记录都加X NEXT - KEY Lock,(-∞, 66]、(66, 80]、(80, 90]、(90, 95]、(95, 100]、(100, +∞)。尽管表中没有score值等于 10 的记录(即未命中),但在事务结束之前,对每一行所加的X NEXT - KEY Lock都不会释放。
2.2.4 主键索引等值查询
select * from student where id = 4 for update;
在读已提交(RC)隔离级别,直接对命中的id = 4这条记录加行级排他锁(X REC_LOCK)。其他未命中的记录不会加锁
在 RR 隔离级别下,对于主键索引等值查询Next - Key Lock 会退化为行锁,因为主键索引保证了唯一性,不存在幻读风险,所以仅需锁定具体的记录行即可
2.2.5 主键索引范围查询
select * from student where id >= 4 and id <= 5 for update;
在读取已提交(RC)隔离级别下,直接对这两条命中的记录(id = 4和id = 5)分别加行级排他锁(X REC_LOCK)。其他未命中的记录(id为 1、2、3、6 的记录)不会加锁。这是因为在 RC 隔离级别下,不解决幻读问题。
在 RR 隔离级别下,对id为 4 和id为 5 的命中记录分别加排他性的记录锁,这保证了在当前事务持有锁期间,其他事务不能对这两条记录进行修改、删除等操作。同时会对相关间隙加间隙锁。两者合起来就是临键锁的概念,图中标注了三个区间(3,4]、(4,5]、(5,6)。对这些间隙加锁是为了防止其他事务在这些间隙内插入新的id值。从而避免幻读情况的发生
2.2.6 主键索引查询未命中
select * from student where id = 10 for update;
在 RC 隔离级别下,对于这种未命中的主键索引查询,不会对任何记录加锁
在 RR 隔离级别下,为了防止幻读,会对未命中记录的下一个间隙加间隙锁(X GAP_LOCK)。表中最大的id值为 6,所以会对间隙(6, +∞)加间隙锁。这意味着在当前事务结束前,其他事务无法在该间隙内插入id值大于 6 的数据,从而避免了在事务执行过程中,由于其他事务插入符合条件的数据而导致的幻读现象。
2.2.7 二级唯一索引等值查询
select * from student where no = '006' for update;
读取已提交(RC)隔离级别,no = '006'的记录加行级排他锁(X REC_LOCK),以防止其他事务对该索引记录进行修改。根据获取的主键id值,在主键索引上找到id = 3的记录,并对其加行级排他锁(X REC_LOCK)。这是因为最终要获取完整的行数据,所以需要锁定主键索引上对应的记录,确保在事务处理期间,其他事务不能修改该记录的任何信息。
可重复读(RR)隔离级别,对二级唯一索引中no = '006'的记录加行级排他锁(X REC_LOCK),锁定该索引记录。在主键索引上找到id = 3的记录,同样对其加行级排他锁(X REC_LOCK)。
2.2.8 二级唯一索引范围查询
select * from student where no >= '006' and no <= '008' for update;
读取已提交(RC)隔离级别,对二级唯一索引中no为'006'和'008'的记录分别加行级排他锁(X REC_LOCK),锁定这些索引记录,防止其他事务对其进行修改。根据获取的主键id值,在主键索引上找到id为 3 和 4 的记录,并分别对其加行级排他锁(X REC_LOCK),确保在事务处理期间,其他事务不能修改这两条记录的任何信息。
可重复读(RR)隔离级别,对二级唯一索引中相关的间隙加临键锁。具体的间隙为('002', '006]、('006', '008]、('008', '009')。在主键索引上找到id为 3 和 4 的记录,也对其加行级排他锁(X REC_LOCK)
2.2.9 二级唯一索引未命中
select * from student where no = '007' for update;
在 RC 隔离级别下,对于这种未命中的二级唯一索引查询,不会对任何记录或间隙加锁
在 RR 隔离级别下,为防止幻读,会对未命中记录所在的间隙加间隙锁(X GAP_LOCK)。表中no列已有的值为001、002、006、008、009、010,'007'处于'006'和'008'之间,因此会对间隙('006', '008')加间隙锁。这确保在当前事务结束前,其他事务无法在该间隙内插入no值为'007'或其他处于该范围内的数据,避免了在事务执行过程中因其他事务插入符合条件的数据而产生幻读现象。
2.2.10 二级非唯一索引等值查询
select * from student where age = 18 for update;
RC隔离级别,对二级非唯一索引中age = 18的记录分别加行级排他锁(X REC_LOCK),锁定这些索引记录,防止其他事务对其进行修改。根据获取的主键id值,在主键索引上找到id为 3 和 6 的记录,并分别对其加行级排他锁(X REC_LOCK)
在 RR隔离级别下,对二级非唯一索引中age = 18的记录加行级排他锁(X REC_LOCK)。对二级非唯一索引中相关的间隙加锁。会对(16, 18]加 Next - Key Lock(行锁和间隙锁的组合),对(18, 20)加间隙锁(X GAP_LOCK)。接着在主键索引上找到id为 3 和 6 的记录,也对其加行级排他锁(X REC_LOCK)。
2.2.11 二级非唯一索引范围查询
select * from student where age >= 16 and age <= 18 for update;
在(RC)隔离级别,对二级非唯一索引中符合范围条件的记录(age为 16、18、18 )加行级排他锁(X REC_LOCK),锁定这些索引记录,防止其他事务对其进行修改。根据获取的主键id值在主键索引上找到id为 3、5、6 的记录,并分别对其加行级排他锁(X REC_LOCK),确保在事务处理期间,其他事务不能修改这些记录的任何信息。未命中该范围条件的记录不会加锁。
在RR隔离级别,对(14, 16]和(16, 18]加 Next - Key Lock(行锁和间隙锁的组合),对(18, 20)加间隙锁(X GAP_LOCK),在主键索引上找到id为 3、5、6 的记录也对其加行级排他锁(X REC_LOCK)
2.2.12 二级非唯一索引未命中
select * from student where age = 17 for update;
在 RC 隔离级别下,基于age这个二级非唯一索引进行查询,没有命中符合条件的记录,那么数据库不会对任何记录或间隙加锁。
RR 隔离级别下,在遍历索引过程中,对于第一个不满足条件(age=17)的位置,对其所在间隙加间隙锁。目的是为了防止幻读.图片中标注了对间隙(16, 18)加间隙锁(X GAP_LOCK)
2.2.13 delete的加锁分析
由于RC级别只加REC_LOCK,不加GAP_LOCK和Next-Key Lock的锁,其实删除逻辑和select * from student for update
一致,所以此处不分析.
删除主键索引数据存在
:数据库通过主键索引快速定位到id = 3的记录。然后对该记录加排他性行锁(X REC_LOCK),在持有该锁期间,其他事务无法对id = 3这条记录进行任何修改、删除等写操作,从而保证了当前事务删除操作的原子性和数据一致性。
删除主键索引数据不存在
:数据库通过主键索引发现不存在id = 3的记录。由于id值为 2 和 4 的记录存在,所以会对间隙(2, 4)加排他性间隙锁(X GAP_LOCK)。这意味着在当前事务结束前,其他事务无法在该间隙插入新的数据。
删除二级唯一索引数据存在
:数据库先通过二级唯一索引定位到no = '006'的记录,对该索引记录加排他性行锁(X REC_LOCK),防止其他事务对该二级索引记录进行修改。根据二级唯一索引记录获取对应的主键id值(这里id = 3),然后在主键索引上找到id = 3的记录,对其也加排他性行锁(X REC_LOCK),保证在事务处理期间,其他事务不能修改该主键索引记录以及对应的行数据。
删除二级唯一索引数据不存在
:数据库通过二级唯一索引查找,发现不存在no = '006'的记录。由于no列存在'002'和'008'的值,所以会对间隙('002', '008')加排他性间隙锁(X GAP_LOCK)。这表明在当前事务结束前,其他事务无法在该间隙插入no值为'006'或处于'002'和'008'之间的值。
删除二级非唯一索引数据存在
:数据库先通过二级非唯一索引定位到name = "Lisa"的记录,对二级非唯一索引中相关间隙加锁。对(Emma, Lisa]加排他性临键锁(X Next - Key Lock),对(Lisa, Mark)加排他性间隙锁(X GAP_LOCK)。这是为了防止在 RR 隔离级别下出现幻读现象。根据二级非唯一索引记录获取对应的主键id值(这里id = 6),然后在主键索引上找到id = 6的记录,对其也加排他性行锁(X REC_LOCK),确保在事务处理期间,其他事务不能修改该主键索引记录以及对应的行数据。
删除二级非唯一索引数据不存在
:数据库通过二级非唯一索引查找,发现不存在name = "Lisa"的记录。由于name列存在Emma和Mark,且Lisa在二者之间,所以会对间隙(Emma,Mark)加排他性间隙锁(X GAP_LOCK)。这表明在当前事务结束前,其他事务无法在该间隙插入name值为Lisa或处于Emma和Mark之间的值。
三、数据库死锁
3.1 死锁定义
数据库死锁是指在数据库系统中,两个或多个事务在执行过程中,请求锁定对方占用的资源,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些事务都将无法推进下去,从而导致系统陷入僵持状态。
3.2 死锁必要条件
互斥条件(Mutual Exclusion)
:资源在某一时刻只能被一个事务占用,即事务对资源的访问是排他性的。例如,事务 T1 正在使用某一行数据进行更新操作,那么在操作完成之前,其他事务不能同时对同一行数据进行修改。
请求与保持条件(Hold and Wait)
:事务已经持有了至少一个资源,但又提出了对新资源的请求,而新资源已被其他事务占有,此时请求事务被阻塞,但对自己已获得的资源保持不释放。比如事务 T1 已经获取了资源 R1,同时又请求资源 R2,而资源 R2 被事务 T2 持有,T1 就会处于等待状态且不释放 R1。
不可抢占条件(No Preemption)
:事务已获得的资源,在未使用完之前,不能被其他事务强行抢占,只能由获得该资源的事务自己释放。例如事务 T1 获得了锁 L,那么在 T1 主动释放锁 L 之前,其他事务无法抢占锁 L。
循环等待条件(Circular Wait)
:存在一个事务等待队列 {T1, T2,..., Tn},其中 T1 等待 T2 占用的资源,T2 等待 T3 占用的资源,以此类推,Tn 等待 T1 占用的资源,形成一个循环等待链。
3.3 如何预防/避免死锁?
Mysql中如何避免死锁,总结起来其实就是降低并发度、合理的加锁顺序、尽可能的减小锁持有时间:
1、缩小事务范围
尽可能减小事务的规模,因为小事务发生锁冲突的可能性更低。通过减少单个事务锁定的资源数量以及缩短资源锁定的时长,能够有效降低死锁风险。
2、统一加锁顺序
在多个会话(session)获取锁时,要对加锁顺序进行严格控制,使其按照相同的顺序来获取锁,避免因加锁顺序混乱导致死锁。
3、一次性资源锁定
在同一个事务里,尽量一次性锁定所需的全部资源。这种方式能够减少事务在执行过程中多次获取锁的情况,进而降低死锁发生的概率。
4、调整隔离级别
若业务场景允许,可以考虑降低事务的隔离级别。例如,将隔离级别从可重复读(RR)调整为读已提交(RC),这样能避免许多因间隙锁(gap 锁)引发的死锁问题。
5、合理运用表锁
针对某些特定的事务,可使用表锁来提升处理速度,同时也能在一定程度上减少死锁出现的可能性。
3.4 出现死锁怎么解决?
1、等待超时策略
该策略是让事务直接进入等待状态,直至超时。超时时间可通过参数innodb_lock_wait_timeout进行设置。在 InnoDB 存储引擎里,innodb_lock_wait_timeout的默认值为 50 秒。这就表明,若采用此策略,当死锁发生后,首个被锁住的线程需等待 50 秒才会因超时而退出,之后其他线程才有可能继续执行。但对于在线服务而言,如此长的等待时间通常是难以接受的。
2、死锁检测策略
此策略会主动发起死锁检测,一旦发现死锁,便会主动回滚死锁链条中的某个事务,从而让其他事务能够继续执行。你可以将参数innodb_deadlock_detect设置为on,以此开启这一逻辑。
3.5 死锁案例分析
3.5.1 并发互相转账引起死锁(加锁顺序不一致导致的死锁)
在事务并发执行的场景中,当出现循环资源依赖的情况,也就是涉及的线程都在等待其他线程释放其所持有的资源时,这几个线程就会陷入无限等待的僵局,进而产生死锁。
下面以转账
为例进行说明,有账户余额表,其中user_id是唯一索引,用户A和用户B互相转账SQL执行过程如下表:
死锁分析
:在执行过程中,事务 A 首先对user_id = 1的行数据加锁并进行更新操作(余额扣减100),接着尝试对user_id = 2的行数据加锁(余额增加100)。而事务 B 先对user_id = 2的行数据加锁并更新(余额扣减200),随后尝试对user_id = 1的行数据加锁(余额增加200)。此时,事务 A 处于等待事务 B 释放user_id = 2的行锁的状态,同时事务 B 也在等待事务 A 释放user_id = 1的行锁。事务 A 和事务 B 相互等待对方释放资源,从而进入了死锁状态。
3.5.2 insert唯一健冲突导致更新死锁
在数据库操作中,INSERT操作若遇到唯一键冲突,可能会引发更新死锁问题。下面通过一个具体的示例来详细说明这种情况。
我们创建了如下的表t:
CREATE TABLE `test` ( `id` int(11) NOT NULL AUTO_INCREMENT, `a` int(11) NOT NULL, `b` int(11) NOT NULL, PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
插入了几行数据:(1,1,1)、(3,3,3)、(9,9,9),操作流程与死锁产生过程
在INSERT操作里,如果执行成功,会对插入的记录及其对应的索引加上行锁;要是出现唯一索引冲突就会对对应区间加上next - key lock(此种情况加锁为什么会这样,可以查看官方文档以及对应的 bug 描述,此处知道这个结论就好)。
死锁具体分析如下:
Session A
:
执行insert (30,30,30)操作,会对id = 30对应的行加上行锁。依据两阶段锁协议,这些锁要等到commit或者rollback操作(也就是在 T3 时刻)才会释放。
Session B
:
再次尝试插入id = 30的数据,由于该行已经被 Session A 加上了行锁,所以操作会被阻塞。同时Session B 会对区间(9, supremum]加上next - key lock,这里的supremum是一个理论上的最大值。
Session C
:
同样也会对相应区间加上next - key lock。因为间隙锁之间不会相互阻塞,所以 Session C 的加锁操作能够成功。
等待状态
:
此时,Session B 和 Session C 都在等待id = 30的行锁释放。
死锁形成
:
当 Session A 执行rollback操作后,Session B 和 Session C 中有一个会成功获取到行锁。然而,无论哪一个获取到锁并执行INSERT操作,都会被对方的间隙锁阻塞。至此,死锁便产生了!
3.5.3 index merge(索引合并)引起的死锁
在 MySQL 的InnoDB存储引擎中,当一条 SQL 语句的WHERE子句涉及多个普通索引时,可能会触发索引合并(Index Merge)优化策略。有表结构如下
CREATE TABLE `test_index_merge` (`id` int(11) NOT NULL AUTO_INCREMENT,`trans_id` varchar(21) NOT NULL,`status` int(11) NOT NULL,PRIMARY KEY (`id`),KEY `idx_trans_id` (`trans_id`),KEY `idx_status` (`status`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
插入了几行数据(1,'a',0)、(2,'b',0)、(3,'c',1)、(4,'d',1)、(5,'e',2),执行SQL如下:
UPDATE test_index_merge SET `status` = 1 WHERE `trans_id` = 'a' AND `status` = 0;
在这个例子里,UPDATE语句UPDATE test_index_merge SET status = 1 WHERE trans_id = 'a' AND status = 0;
用到了idx_trans_id和idx_status两个索引,这意味着数据库可能会分别从两个索引中查找符合条件的记录,然后取交集来定位最终需要操作的记录。
(这里假设数据库使用了索引合并的策略优化SQL执行,具体索引合并详细解释见对应章节内容),死锁产生过程
结合上表SQL执行情况和下图加锁关系看
-
T1
:两个会话分别在idx_trans_id索引上对不同的trans_id值加锁,此时不会产生冲突。 -
T2
:两个会话回表到主键索引,对不同的id值加锁,也不会冲突。 -
T3
和T4
:Session A 先对idx_status索引中status为0的索引项加锁,当 Session B 尝试对同样的索引项加锁时,就会被阻塞,因为该索引项已经被 Session A 锁住。 -
T5
:Session A 尝试对主键索引中id为2的索引项加锁,而这个索引项已经被 Session B 在 T2 时刻锁住了。这样就形成了循环等待,即 Session A 等待 Session B 释放id = 2的主键索引锁,而 Session B 等待 Session A 释放status = 0的idx_status索引锁,从而导致死锁。
当WHERE条件里存在多个普通索引时,建议建立联合索引。
例如,对于这个表结构,可以创建一个联合索引CREATE INDEX idx_trans_id_status ON test_index_merge (trans_id, status);
使用联合索引的好处在于,数据库在查找符合条件的记录时,只需要扫描一个索引,而不是分别扫描多个索引再取交集。这样可以减少加锁的范围和顺序的复杂性,从而降低死锁发生的概率。因为在使用联合索引时,事务加锁的顺序更加一致,不容易出现循环等待的情况。
3.5.4 非聚集索引更新导致死锁
在非聚集索引更新操作引发死锁的场景里,如下图每个会话(session)仅执行 1 条 SQL 语句。其中,会话 1 率先获取了id = 1的行锁,接着需要获取id = 6的行锁;与此同时,会话 2 已经获取到了id = 6的行锁,正等待获取id = 1的行锁。如此一来,两个会话相互等待对方释放锁资源,死锁便形成了。
四、参考文献
https://www.cnblogs.com/caibaotimes/p/17958671https://blog.csdn.net/huyuyang6688/article/details/123508245https://blog.csdn.net/yzx3105/article/details/129728659https://github.com/WilburXu/blog/blob/master/MySQL/MySQL%20%E4%BD%A0%E5%A5%BD%EF%BC%8C%E6%AD%BB%E9%94%81.mdhttps://blog.csdn.net/weixin_44844089/article/details/115532014https://z.itpub.net/article/detail/6FBBD180C376F42A19688938B2264D8A
原创 梨有奶香味 布兜