一、锁的本质是什么?🔒
概述
锁是协调多个进程或线程并发访问资源的机制。在多线程环境中,尤其是对敏感数据(如订单、金额等),需要确保同一时刻只有一个线程访问数据,从而保证数据的完整性和一致性。在数据库中,锁的机制用于控制对共享数据的并发操作,并支持不同的隔离级别,同时也是影响数据库性能的重要因素。
MySQL并发事务访问相同记录
并发事务访问相同记录主要分为三种情况:
1. 读-读
多个事务同时读取相同记录,因读取操作不影响数据,允许并发发生。
2. 写-写
多个事务对相同记录进行修改,会发生脏写。为避免此问题,事务需排队执行,通过锁机制实现。锁在内存中生成,当事务尝试修改记录时,会检查是否存在锁结构,并相应地获取或等待锁。
3. 读-写 / 写-读
一个事务进行读取操作,另一个执行修改,这可能导致脏读、不可重复读、幻读等问题。不同数据库对 SQL 标准的支持各异,例如,MySQL 在 REPEATABLE READ 隔离级别下通过 MVCC 解决了幻读问题。
并发问题的解决方案
解决脏读、不可重复读、幻读的方式有两种:
方案一:MVCC(多版本并发控制)
- 读操作通过生成 ReadView 读取已提交的记录版本(历史版本由undo日志构建),避免读取未提交事务的更改。
- 在 READ COMMITTED 隔离级别下,每次 SELECT 操作生成新 ReadView,避免脏读。
- 在 REPEATABLE READ 隔离级别下,第一次 SELECT 生成 ReadView,后续操作复用,避免不可重复读和幻读。
方案二:加锁
在某些业务场景下,必须读取最新版本的记录,如银行存款。执行时,先读取账户余额,然后加上存款金额再写入数据库。在此过程中,需要对余额加锁,防止其他事务在存款完成前访问该余额,也就意味着读操作和写操作也像写-写操作一样排队执行。
这时,读取记录时需要加锁,确保其他事务无法访问数据,从而避免脏读和不可重复读。
幻读的问题在于,当事务首次读取时,可能无法预见未来插入的记录。
- 脏读:当前事务读取了另一个未提交事务的记录。如果在写操作时对该记录加锁,当前事务将无法读取,从而避免脏读。
- 不可重复读:当前事务第一次读取记录后,另一个事务对该记录进行修改并提交。当当前事务再次读取时会得到不同的值。如果在读取时对该记录加锁,就能防止其他事务修改,从而避免不可重复读。
- 幻读:当前事务读取了一定范围的记录,而其他事务在此范围内插入新记录,导致后续读取时发现新记录。解决幻读问题通过加锁较为复杂,因为在首次读取时,幻影记录并不存在,难以确定锁定对象。
小结:
MVCC 允许读-写操作并发执行,性能更高;而加锁方式则需排队执行,影响性能。通常情况下,MVCC 是优先选择,但特定业务场景可能需要加锁。接下来将介绍 MySQL 中不同类别的锁。
二、锁的分类:四大角度解析
1️⃣ 按操作类型划分
读锁与写锁
数据库中的并发事务可分为读-读、写-写、读-写等情况。读-读不会引起问题,但写-写和读-写则需使用MVCC或加锁。
MySQL实现了两种类型的锁:共享锁(Shared Lock,SLock)和排他锁(Exclusive Lock,XLock),也叫读锁(readlock)和写锁(write lock)。
- 读锁(共享锁,S锁):允许多个事务并发读取同一数据,不互相阻塞。
- 写锁(排他锁,X锁):在写操作完成前,阻止其他事务的读锁和写锁,确保只有一个事务能写。
注意:对于InnoDB引擎来说,读锁和写锁可以加在表上,也可以加在行上。
示例:如果事务T1获得了某行的读锁,T2也可获得该行的读锁,但T3如果想获得写锁需等待T1和T2释放读锁。
1. 锁定读
在MySQL中,可以通过以下两种方式对读取记录加锁:
- 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
#或
SELECT ... FOR SHARE;#(8.8新增语法)
在普通的 SELECT 语句后加上 LOCK IN SHARE MODE,当前事务将为读取的记录加上 S 锁。这允许其他事务也获取这些记录的 S 锁(例如,使用 SELECT … LOCK IN SHARE MODE),但不允许获取 X 锁(如通过 SELECT … FOR UPDATE 进行修改)。如果其他事务试图获取 X 锁,将会阻塞,直到当前事务提交并释放这些 S 锁。
- 对读取的记录加X锁:
SELECT ...FOR UPDATE;
在普通的 SELECT 语句后加上 FOR UPDATE,当前事务将为读取的记录加上 X 锁。这会阻止其他事务获取这些记录的 S 锁(如通过 SELECT … LOCK IN SHARE MODE)或 X 锁(如通过 SELECT … FOR UPDATE 进行修改)。其他事务如果尝试获取这些锁,将会被阻塞,直到当前事务提交并释放 X 锁。
MySQL8.0新特性:
在5.7及之前的版本,SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。在8.0版本中,SELECT … FOR UPDATE,SELECT …FOR SHARE 添加NOWAIT、SKIP LOCKED语法,跳过锁等待,或者跳过锁定。
- NOWAIT会立即报错返回
- SKIP LOCKED也会立即返回,只是返回的结果中不包含被锁定的行。
mysql> begin;
mysql> select * from t1 where c1 = 2 for update NOWAIT;
2. 写操作
常见的写操作包括DELETE、UPDATE和INSERT:
- DELETE:先在B+树中定位到这条记录的位置,然后获取这条记录的x锁,再执行delete mark 操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。
- UPDATE:根据修改情况,锁定记录后直接修改或删除并插入新记录,在对一条记录做UPDATE操作时分为三种情况:
- 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读
- 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT 操作提供的隐式锁进行保护。
- 情况3:修改了该记录的键值,则相当于在原记录上做DELETE 操作之后再来一次INSERT操作,加锁操作就需要按照DELETE 和INSERT的规则进行了。
- INSERT:通常不显式加锁,而是通过隐式锁保护新插入记录。
2️⃣ 按锁粒度划分
全局锁、表级锁、页级锁、行锁
锁粒度越小,数据库并发度越高,但管理锁的开销(涉及获取、检查、释放锁等动作)也随之增加。锁的粒度主要分为:
1. 全局锁
全局锁是对整个数据库实例的加锁,用于将数据库置于只读状态。这会阻塞其他线程的更新语句(增删改)、数据定义语句(如建表、修改表结构)和事务提交。全局锁的典型场景是进行全库逻辑备份,以获取一致性视图并确保数据完整性。
-- 全局锁的命令:
Flush tables with read lock;
-- 数据备份的命令,在windows命令行中执行
mysqldump -h ip -uroot -p1234 itcast > itcast.sql
--解锁
unlock tables;
特点
- 在主库备份时,更新操作将暂停,业务几乎停摆。
- 在从库备份时,无法执行主库同步的二进制日志,导致主从延迟。
在InnoDB引擎中,我们可以在备份时加上参数**–single-transaction**参数实现不加锁的一致性备份。
mysqldump --single-transaction -uroot -p1234 itcast > itcast.sql
2. 表锁(Table Lock)
- 表锁会锁定整张表,是MySQL中最基本的锁策略,开销最小。由于锁定粒度大,可以有效避免死锁,但也易导致资源争用,降低并发率。
(1) 表级别的s锁、X锁
MySQL的表级锁有两种模式:(以MyISAM表进行操作的演示)
- S 锁(共享锁):允许多个事务并发读取,但阻止写操作。
- X 锁(排他锁):阻止其他事务的读写操作
命令示例
show open tables where in_use > 0; #显示加锁的表
lock tables tablename read; #加读锁
lock tables tablename write; #加写锁
unlock tables; #解锁
总结:
MyISAM在查询前加读锁,增删改前加写锁;InnoDB不使用表级锁。
(2) 意向锁(intention lock)
InnoDB支持多粒度锁(multiple granularity locking),它允许行级锁与表级锁共存,意向锁就是其中一种表锁。
- 意向锁用于协调行锁和表锁的关系,支持它们的并存。
- 意向锁不与行级锁冲突。
- 意向锁表明某个事务正在或准备持有某些行的锁。
意向锁分为两种:
- 意向共享锁(intention shared lock,lS)︰事务准备对某些行加共享锁,需先获得表的IS锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向排他锁(intention exclusive lock, lX):事务准备对某些行加排他锁,需先获得表的IX锁。
SELECT column FROM table ... FOR UPDATE;
意向锁由存储引擎自动维护,用户无法手动操作。若存在意向锁,事务在申请共享或排他锁时无需检查每个页或行,只需检查表上的意向锁。这简化了锁的管理,确保表级别的锁定信息。
在数据表中,如果某行有排他锁,数据库会自动为该表或数据页加上意向锁,告知其他事务该表的某些记录已被锁定。
(3)自增锁(AUTO-INC锁) — 了解
在MySQL中,可以为表的某个列添加 AUTO_INCREMENT 属性。插入数据时,MySQL会使用自增锁(AUTO-INC锁),这是一种特殊的表级锁。执行插入语句时,表会加上AUTO-INC锁,以确保每条记录的自增值是连续的。持有此锁的事务会阻塞其他事务的插入,导致并发性较低。
为提高性能,InnoDB通过 innodb_autoinc_lock_mode 提供三种锁定模式:
- 0,传统锁定模式
- 1,连续锁定模式
- 2,交错锁定模式(8.0默认值),并发性较好
(4) 元数据锁(MDL锁)
MySQL 5.5引入了元数据锁(MDL锁),自动添加,无需手动干预。MDL锁确保读写操作的正确性。例如,如果一个查询正在遍历表数据,而另一个线程修改表结构,查询结果可能会不一致。
- 对表的增删改查操作会加MDL读锁(共享),而对表结构变更操作会加MDL写锁(排他)。
- 读锁之间不互斥,允许多个线程同时操作;而读写锁及写锁之间是互斥的,确保DML和DDL操作的一致性。
查看元数据锁:
select object_type, object_schema, object_name, lock_type, lock_duration from performance_schema.metadata_locks;
3. 行锁
行锁又称记录锁,是指锁定某一行(记录)。需要注意的是,MySQL服务器层并未实现行锁机制,行级锁仅在存储引擎层实现。
- 优点:锁定粒度小,锁冲突概率低,支持高并发。
- 缺点:锁开销大,加锁速度较慢,易出现死锁。
InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。
- 默认隔离级别REPEATABLE-READ下,InnoDB中行锁默认使用算法 Next-Key Lock
- 隔离级别 READ COMMITED,采用的是Record Lock
注意事项
- 当不通过索引条件查询时,InnoDB会锁定表中所有记录。因此,查询条件字段应加索引以提升性能。
- InnoDB通过索引实现行锁,而非直接锁定记录。操作两条不同记录但具有相同索引时,可能会导致等待。
- 使用主键索引时,InnoDB会锁定主键索引;使用非主键索引时,先锁定非主键索引,再锁定主键索引。
- 当查询使用唯一索引时,InnoDB会将 Next-Key Lock 降级为 Record Lock,即只锁定索引本身,而不锁定范围。
(1) 行锁/记录锁(Record Locks)
行锁是指仅锁定单条记录,官方名称为 LOCK_REC_NOT_GAP。例如,锁定 id 值为 8 的记录,不会影响其他数据。
记录锁分为 S 型和 X 型:
- S 型记录锁:一个事务获取后,其他事务可以继续获取 S 型锁,但不能获取 X 型锁。
- X 型记录锁:一个事务获取后,其他事务既不能获取 S 型锁,也不能获取 X 型锁。
tip:默认情况下,InnoDB在REPEATABLE READ事务隔离级别运行,InnoDB使用next-key锁进行搜索和索引扫描,以防止幻读。
- 针对唯一索引检索时,等值匹配会自动优化为行锁。
- InnoDB 的行锁是针对索引加的,若不通过索引条件检索数据,则会对所有记录加锁,升级为表锁。
查看意向锁及行锁情况
select object_schema, object_name, index_name, lock_type, locak_mode, lock_data from performance_schema.data_locks;
(2) 间隙锁(Gap Locks)
- 间隙锁用于锁定一个范围,但不包括记录本身。MySQL 在 REPEATABLE READ 隔离级别下可以通过两种方式解决幻读问题:MVCC 和加锁方案。加锁方案存在一个问题:当事务第一次读取时,幻影记录尚不存在,无法加锁。
InnoDB 提出了 Gap Locks(官方名称:LOCK_GAP),用于防止其他事务在间隙中插入新记录。
比如,把id值为8的那条记录加一个gap锁的示意图如下。
InnoDB使用next-key锁进行搜索和索引扫描,以防止幻读。
- 索引上的等值查询(唯一 索引),给不存在的记录加锁时,优化为间隙锁。
- 索引上的等值查询(普通索引),向右遍历时最后一个值不满足查询需求时, next-key lock退化为间隙锁。
- 索引上的范围查询(唯一索引),会访问到不满足条件的第一个值为止
注意事项:
- 间隙锁唯一目的是防止其他事务插入间隙。间隙锁可以共存,一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁。
- gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。
select * from student where id = 5 lock in share mode;
# 锁的范围(3,8)
- 当对 id 值为 8 的记录加上间隙锁时,意味着不允许在区间 (3, 8) 内插入新记录。如果另一个事务尝试插入 id 值为 4 的记录,将被阻塞,直到持有间隙锁的事务提交。
- 第2种情况说明 若 age是普通索引,有记录是 1, 3, 7,8,11。。。
操作where age = 3 lock in share mode 会锁住 3这一行 及(3,7)这个区间 - 第3种情况说明 若id是唯一索引,有记录是7,8,11,19,25.。
操作where id >= 19 会对19这一行数据及 25之前的间隙 (25, +∞) 也加锁
(3) 临键锁(Next-Key Locks)
前开后闭 锁定一个范围,并且锁定记录本身
临键锁用于锁定一个范围,并同时锁定记录本身。InnoDB 提出的 Next-Key Locks(官方名称:LOCK_ORDINARY),在可重复读事务隔离级别下使用,既能保护记录,又能阻止其他事务在记录前的间隙插入新记录。
- next-key的本质是记录锁和间隙锁的结合,保护记录并防止在其前的间隙插入。
- InnoDB 对辅助索引的处理特殊,不仅锁定索引值范围,还将下一个键值加上间隙锁。
例如一个索引有9,11,13,20这4个值,那么该索引可能被Next-Key Locking的范围为(左开右闭 ): (- &,9] (9,11] (13,20] (20,+ &)
示例:
CREATE TABLE Z (
a INT,
b INT,
PRIMARY KEY (a),
KEY (b)
);
INSERT INTO ZVALUES (1, 1), (3, 1), (5, 3), (7, 6),(10, 8);
执行
SELECT * FROM Z WHERE b=3 FOR UPDATE;
解读:
通过辅助索引 b 查询时,使用 Next-Key Locks。对聚集索引 a=5 加上记录锁,对辅助索引 (1, 3] 加上临键锁,并加锁下一个键值 (3, 6)。
在新会话中执行以下操作将被阻塞:
SELECT * FROM Z WHERE a=5 LOCK IN SHARE MODE;(被阻塞因主键锁)
INSERT INTO Z SELECT 4, 2;(被阻塞因辅助索引冲突)
INSERT INTO Z SELECT 6, 5;(被阻塞因辅助索引冲突)
结论
临键锁通过阻止多个事务在同一范围内插入记录,防止了幻读问题。例如,如果没有对 (3, 6) 的间隙锁,用户可能插入 b=3 的记录,导致后续查询返回不同的结果,从而产生幻读问题。
(4) 插入意向锁(lisert Intention Locks)
插入意向锁用于事务在插入记录时判断插入位置是否被其他事务的间隙锁(如 Next-Key 锁)占用。如果被占用,插入操作需要等待,直到持有锁的事务提交。InnoDB 在等待期间会生成一种锁结构,表示有事务想在某个间隙插入新记录,这就是插入意向锁(官方名称:LOCK_INSERT_INTENTION)。
插入意向锁是一种间隙锁,在插入操作前由 INSERT 产生。它表示插入意向,允许多个事务在同一区间(gap)插入不同的记录,而不必互相等待。例如,当两个事务分别试图插入值为 5 和 6 的记录时,虽然都会获取 (4, 7) 之间的间隙锁,但由于数据行之间不冲突,事务之间不会阻塞。
当事务 T1 持有间隙锁时,其他事务 T2 和 T3 将生成插入意向锁并处于等待状态。T1 提交后释放锁,T2 和 T3 可以获取插入意向锁并执行插入操作。实际上,插入意向锁不会阻止其他事务获取该记录上的任何类型的锁。
4. 页锁
页锁是在页级别进行的锁定,锁定的数据资源比行锁多,因为一个页可以包含多个行记录。使用页锁可能导致数据浪费,但最多只影响一个页。页锁的开销介于表锁和行锁之间,且可能出现死锁,整体并发度一般。
锁的数量有限,超过阈值时会进行锁升级,即用更大粒度的锁替代多个小粒度的锁。例如,InnoDB中行锁可能升级为表锁。这样可以降低锁空间占用,但会减少数据的并发度。
3️⃣ 按锁态度划分
从对待锁的态度划分:乐观锁、悲观锁
锁可以分为乐观锁和悲观锁,这两种锁代表了不同的数据并发处理思维方式。
1. 悲观锁(Pessimistic Locking)
悲观锁持保守态度,假设数据会被其他事务修改,因此在每次访问数据时都会加锁。这种方式确保了数据操作的排他性,但会导致其他线程阻塞。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
示例:秒杀场景
商品秒杀过程中,库存数量的减少,避免出现超卖的情况。
#第1步: 查出商品库存
select quantity from items where id = 1001 for update;
#第2步:如果库存大于0,则根据商品信息生产订单
insert into orders(item_id) values(1001);
#第3步:修改商品的库存,num表 示购买数量
update items set quantity = quantity-num where id = 1001;
在使用 SELECT … FOR UPDATE 时,相关行会被锁定,其他事务必须等当前事务完成后才能访问。这种方式确保数据不被其他事务修改,但可能影响性能,尤其在长事务中。
2. 乐观锁
乐观锁假设并发操作是小概率事件,因此不在每次操作时加锁,而是在更新时检查数据是否被修改。常通过版本号或时间戳来实现。在Java中java.util.concurrent.atomic包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。
- 版本号机制:在表中添加version字段,更新时检查版本是否和第一次读取时一致。
- 时间戳机制:在更新时比较当前时间戳与之前获取的时间戳。
示例:秒杀场景
#第1步:查出商品库存
select quantity from items where id = 1001;
#第2步:如果库存大于0,则根据商品信息生产订单
insert into orders(item_ id) values(1001);
#第3步:修改商品的库存,num表示购买数量
update items set quantity = quantity-num where id = 1001 and quantity-num>0;
3. 两种锁的适用场景
- 乐观锁:适合读操作较多的场景,避免死锁问题,但无法阻止数据库内其他事务的操作。
- 悲观锁:适合写操作较多的场景,有效阻止其他事务对数据的操作,防止冲突。
通过以上总结,可以更好地选择合适的锁策略。
4️⃣ 按加锁方式划分
显式锁、隐式锁
1.隐式锁
隐式锁在事务执行时自动加锁。例如,当执行 INSERT 操作时,如果插入位置被其他事务加了 gap 锁,则当前 INSERT 会阻塞,并在该位置加插入意向锁。通常情况下,INSERT 操作不加锁。
如果一个事务插入了一条记录,另一个事务立即试图使用 SELECT … LOCK IN SHARE MODE 或 SELECT … FOR UPDATE 来获取该记录的锁,可能会导致脏读或脏写问题。这时,事务 ID 会发挥作用:
聚簇索引:每条记录有一个隐藏的 trx_id 列,记录最后修改该记录的事务 ID。如果其他事务想对该记录加锁,会检查 trx_id 是否属于当前活跃事务。如果是,则为当前事务创建相应锁结构。
2. 显示锁
显式锁需要用户手动添加,通常用于控制事务的并发访问。
三、死锁:成因与破解方法 💀
1. 概念
死锁是指两个事务互相持有对方需要的锁,导致双方都无法释放自己的锁。
2. 产生死锁的必要条件
- 两个或多个事务。
- 每个事务持有锁并申请新的锁。
- 锁资源只能被一个事务持有,且不兼容。
- 事务间因持有和申请锁形成循环等待。
死锁的关键在于事务加锁的顺序不一致。
3. 如何处理死锁?
- 方式1:等待超时
当事务互相等待,超过设定的阈值(如 innodb_lock_wait_timeout=50s)时,回滚其中一个事务。这个方法简单有效,但在在线服务中,等待时间可能不可接受。
那将此值修改短一些,比如1s,0.1s是否合适?
不合适,容易误伤到普通的锁等待。
4. 如何避免死锁?
- 合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。
- 调整业务逻辑SQL执行顺序,避免update/delete长时间持有锁的SQL在事务前面。
- 拆分大事务,将大事务分解为多个小事务,缩短锁定资源的时间。
- 避免显式加锁:在高并发系统中,尽量避免在事务中显式加锁。
- 降低隔离级别:如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。
四、高频面试题速答 🚀
InnoDB默认隔离级别如何解决幻读?
- 临键锁(Next-Key Lock):锁记录+间隙,阻止其他事务插入范围数据
间隙锁有什么用?
- 防止其他事务在范围内插入新数据(如 WHERE age>20 锁住20~30的空白区)
表锁和行锁如何选择?
- 高并发写用行锁(如订单支付)
- 批量修改用表锁(如历史数据归档)
参考:尚硅谷视频学习