1. 完整回答
MySQL 的锁主要用于控制并发事务对数据的访问,避免多个事务同时修改数据造成冲突,从而保证数据一致性和事务隔离。
以常用的 InnoDB 存储引擎为例,锁可以从两个角度理解。
首先是锁的粒度,主要包括表级锁和行级锁。表级锁影响整张表,并发能力相对较低;行级锁只锁定相关的索引记录,并发能力更高。
其次是锁的模式,主要分为共享锁和排他锁。共享锁允许多个事务同时读取受保护的数据,排他锁用于控制独占修改,两者存在互斥关系。
InnoDB 的行锁主要通过索引实现,具体又包括记录锁、间隙锁和临键锁。
记录锁锁定已有的索引记录;间隙锁限制其他事务向索引间隙插入数据;临键锁结合了前两者,可以同时保护记录和索引范围,主要用于解决加锁读场景中的幻读问题。
在 MySQL 默认的可重复读隔离级别下,普通 SELECT 通常通过 MVCC 进行快照读,不需要加行锁;UPDATE、DELETE 和 SELECT ... FOR UPDATE 等操作需要根据实际访问的索引范围加锁。
实际使用中,索引和事务执行时间对锁的影响比较大。如果 SQL 没有使用合适的索引,可能扫描并锁住大量记录;如果事务长时间不提交,也容易造成锁等待甚至死锁。
所以在库存扣减、订单状态更新等并发场景中,通常会结合合理索引、短事务和行级锁保证数据正确性,同时尽量缩小锁范围,提高并发性能。
2. 核心原理与关键概念
2.1 InnoDB 的加锁流程
InnoDB 加锁主要围绕索引、隔离级别和事务展开。
步骤 A:确定访问范围
MySQL 根据 SQL 条件和执行计划选择索引,确定需要扫描的记录或范围。
步骤 B:确定锁类型
InnoDB 根据操作类型、索引条件和事务隔离级别决定锁:
-
普通
SELECT:在读已提交和可重复读级别下,通常使用 MVCC 快照读。 -
SELECT ... FOR SHARE:对相关记录加共享锁。 -
SELECT ... FOR UPDATE、UPDATE、DELETE:通常需要排他锁。 -
范围加锁:在可重复读级别下,可能涉及间隙锁和临键锁。
步骤 C:获取锁并处理冲突
需要行锁时,事务会先获取对应的表级意向锁,再获取相关索引记录上的锁。
如果其他事务已经持有不兼容的锁,当前事务通常需要等待;出现死锁时,InnoDB 可以主动检测并回滚其中一个事务。
步骤 D:释放锁
事务提交或回滚后,释放持有的事务级数据锁,等待的事务才能继续获取相关锁。
整体流程:
确定索引访问范围 → 判断锁类型 → 获取意向锁和数据锁 → 处理锁冲突 → 提交或回滚释放锁
2.2 关键锁类型
| 锁类型 | 核心作用 | 典型场景 |
|---|---|---|
| 表级锁 | 控制整张表的并发访问 | 显式表锁操作 |
| 记录锁(Record Lock) | 锁定已有的索引记录 | 主键等值更新 |
| 间隙锁(Gap Lock) | 阻止其他事务向指定索引间隙插入记录 | 范围加锁 |
| 临键锁(Next-Key Lock) | 记录锁与前方间隙锁的组合 | 可重复读下的范围查询加锁 |
| 意向锁(IS、IX) | 在表级标识事务准备获取的行锁类型 | 行级加锁前的协调 |
| 插入意向锁 | 表示事务准备向索引间隙插入记录 | 并发 INSERT |
| 元数据锁(MDL) | 保护表结构,协调数据操作与结构变更 | SELECT、UPDATE、ALTER TABLE |
此外,InnoDB 还存在用于自增值分配的 AUTO-INC 锁,其使用方式受到 innodb_autoinc_lock_mode 影响。
2.3 共享锁和排他锁的兼容关系
| 当前持有的锁 | 请求共享锁 S | 请求排他锁 X |
|---|---|---|
| 共享锁 S | 兼容 | 冲突 |
| 排他锁 X | 冲突 | 冲突 |
共享锁主要用于保护读取期间的数据不被其他事务修改;排他锁主要用于保证同一数据不会被多个事务同时修改。
这里的读锁指显式加锁读取。普通快照读通常可以直接读取历史版本,不受这些行锁的常规互斥限制。
3. 高频面试追问
3.1 普通 SELECT 会加锁吗?快照读和当前读有什么区别?
在 InnoDB 的读已提交和可重复读隔离级别下,普通 SELECT 通常不加行锁,而是使用 MVCC 进行快照读。
快照读通过 Read View 判断哪些数据版本可见,再结合 Undo Log 获取符合条件的历史版本,所以读取时通常不会阻塞其他事务修改数据。
当前读则需要读取当前可用的数据版本,并通过加锁协调并发修改。
常见的当前读包括:
-
SELECT ... FOR UPDATE -
SELECT ... FOR SHARE -
UPDATE -
DELETE
例如,一个事务正在修改用户余额但还没有提交,另一个事务执行普通 SELECT,通常仍然可以读取符合快照规则的旧余额;如果执行 SELECT ... FOR UPDATE,则可能等待前一个事务释放排他锁。
另外,普通 SELECT 虽然通常不加行锁,但仍会获取必要的元数据锁。
3.2 SELECT FOR UPDATE 和 FOR SHARE 有什么区别?
核心区别是锁模式不同,FOR UPDATE 使用排他锁,FOR SHARE 使用共享锁。
FOR SHARE 允许其他事务对相同记录继续获取共享锁,但会阻止其他事务获取冲突的排他锁。
FOR UPDATE 则会对相关记录获取排他锁,阻止其他事务执行冲突的加锁读取或修改。
例如,业务需要先读取库存,再根据库存判断是否更新,就可以在事务中使用 SELECT ... FOR UPDATE 锁定对应记录。
这样可以避免读取库存后、真正修改前,其他事务抢先修改同一条库存记录。
需要注意,显式事务中的加锁读取才具有持续保护后续操作的意义。如果使用自动提交模式,语句结束后事务就会提交,锁也随之释放。
3.3 为什么说 InnoDB 的行锁依赖索引?索引失效会锁全表吗?
InnoDB 的记录锁实际上锁定的是索引记录,SQL 使用什么索引、扫描多大范围,会直接影响加锁范围。
例如:
UPDATE user SET age = 20 WHERE id = 10;
如果 id 是主键,并且查询通过主键准确定位记录,通常只需要锁定对应的索引记录。
如果使用没有合适索引的字段作为条件,MySQL 可能需要扫描大量记录。扫描过程中,InnoDB 可能对大量索引记录及相关间隙加锁。
因此,即使 SQL 最终只更新一条数据,也可能影响其他很多事务。
这里有两个关键点:
第一,索引不合适可能造成大范围行锁,并不意味着 InnoDB 自动把行锁升级成表锁。
第二,锁范围取决于实际执行计划,不能只根据 SQL 中是否出现索引字段判断。
实际排查时,可以通过 EXPLAIN 查看索引访问方式,再结合锁监控确认加锁情况。
3.4 记录锁、间隙锁和临键锁有什么区别?为什么需要间隙锁?
记录锁保护已有记录,间隙锁限制新记录插入,临键锁同时保护记录和前面的间隙。
例如,某个索引已有三个值:
10、20、30
如果对索引值 20 加记录锁,主要保护值为 20 的索引记录。
如果对区间 (10,20) 加间隙锁,其他事务就不能在这个间隙插入 15,但仍可以对已有记录执行不冲突的操作。
如果对 20 加临键锁,锁定范围通常表示为:
(10,20]
这样既保护了记录 20,也限制在 10 和 20 之间插入新记录。
间隙锁存在的主要原因是:只锁住已有记录,无法阻止其他事务插入符合查询条件的新记录。
在可重复读隔离级别下,InnoDB 使用临键锁等机制保护加锁读取的索引范围,减少幻读问题。
继续追问:间隙锁之间会互相冲突吗?
一般不会。普通间隙锁主要用于阻止插入,同一个间隙上的共享间隙锁和排他间隙锁可以共存。
但是插入意向锁可能与已有间隙锁冲突,导致插入事务等待。
3.5 唯一索引和普通索引的加锁方式有什么区别?
在可重复读隔离级别下,唯一索引等值查询如果准确命中已有记录,通常只需要记录锁;普通索引查询或范围查询通常涉及更大的锁定范围。
原因是唯一索引能够保证匹配记录最多只有一条,因此准确命中时,只需保护对应记录。
普通索引允许重复值,等值查询可能匹配多条记录,还需要考虑这些记录之间及附近的索引间隙。
例如:
-
主键
id = 10且记录存在:通常只需要记录锁。 -
普通索引
age = 20:可能涉及多条记录及相关间隙。 -
范围查询
id > 10:在可重复读级别下,通常需要对扫描范围使用临键锁或间隙锁。
继续追问:唯一索引等值查询一定不会加间隙锁吗?
不一定。如果查询的唯一值不存在,在可重复读级别下进行加锁读取,仍可能锁住对应的索引间隙,防止其他事务插入该值。
所以需要区分记录是否存在、索引是否唯一,以及实际执行的查询条件。
3.6 可重复读和读已提交的加锁有什么区别?如何解决幻读?
主要区别是快照生成规则和间隙锁的使用方式不同。
在**读已提交(READ COMMITTED)**级别下,每次一致性读取通常都会建立新的 Read View,因此同一个事务多次普通查询,可能看到其他事务新提交的数据。
在加锁方面,读已提交对一般的索引搜索和扫描通常不使用间隙锁,但外键检查和重复键检查仍可能使用。
在**可重复读(REPEATABLE READ)**级别下,同一事务中的普通一致性读取通常使用首次一致性读建立的快照,因此多次执行相同查询,通常可以得到一致的结果。
对于当前读,InnoDB 可以通过临键锁和间隙锁限制其他事务向已锁定范围插入记录。
因此,可重复读主要通过两种机制处理幻读:
-
快照读: MVCC 保持读取视图稳定。
-
当前读: 通过范围加锁限制新记录插入。
需要注意,快照读和当前读的可见性规则不同。两者混合使用时,当前读可能看到快照读看不到的新提交记录,因此不能认为可重复读下所有读取都共享完全相同的结果。
3.7 意向锁和插入意向锁有什么区别?为什么需要意向锁?
两者的作用和粒度不同。意向锁属于表级锁,用于协调表锁和行锁;插入意向锁属于特殊的间隙锁,用于协调并发插入。
InnoDB 的意向锁包括:
-
IS: 表示事务准备对表中的某些记录加共享锁。
-
IX: 表示事务准备对表中的某些记录加排他锁。
例如,事务准备通过 SELECT ... FOR UPDATE 锁定某条记录,会先获取表级 IX 锁。
这样其他事务如果需要获取整张表的排他锁,就能通过意向锁判断存在冲突,不需要逐条检查所有行锁。
不同事务之间的 IX 锁通常是兼容的,所以多个事务仍然可以修改同一张表中的不同记录。
插入意向锁则用于插入过程。
例如,索引中存在 10 和 20,两个事务分别准备插入 12 和 15。只要没有其他锁冲突,这两个插入通常可以并发执行,不需要因为处于同一个间隙就相互等待。
3.8 MySQL 为什么会发生死锁?如何解决?
死锁通常是多个事务持有对方需要的锁,形成循环等待,导致事务都无法继续执行。
例如有两条记录 A 和 B:
事务 1:
锁定 A → 尝试锁定 B → 等待事务 2
事务 2:
锁定 B → 尝试锁定 A → 等待事务 1
两个事务相互等待,就形成了死锁。
InnoDB 默认启用死锁检测,发现死锁后,会选择其中一个事务进行回滚,让其他事务继续执行。
实际处理主要从几个方面入手:
-
让多个事务按照相同顺序访问和更新数据,减少循环等待。
-
尽量缩短事务执行时间,避免事务中包含耗时操作。
-
为查询和更新条件建立合理索引,减少扫描和加锁范围。
-
在应用层捕获死锁异常,对整个失败事务进行有限次数重试。
继续追问:死锁和锁等待超时有什么区别?
死锁是事务之间形成循环等待;锁等待超时则是某个事务等待锁的时间超过限制,即使没有循环等待也可能发生。
默认情况下,InnoDB 检测到死锁会回滚被选中的整个事务;发生行锁等待超时时,通常只回滚当前失败语句,事务可能仍然处于活动状态。
因此,处理锁等待超时时,还需要明确事务是否继续执行或主动回滚,避免遗留长事务。
3.9 为什么 ALTER TABLE 有时候会一直阻塞?
通常与 MySQL 的元数据锁 MDL 有关。
元数据锁用于保护表结构,避免一个事务正在使用某张表时,另一个事务直接修改表结构,造成数据和元数据不一致。
例如,事务 A 执行了:
BEGIN; SELECT * FROM user;
虽然普通查询通常不会加行锁,但事务仍会持有这张表的元数据锁,直到事务结束。
如果此时事务 B 执行:
ALTER TABLE user ...
修改表结构需要获取相应的元数据锁。如果与事务 A 已持有的锁冲突,就必须等待事务 A 提交或回滚。
而等待中的 DDL 还可能导致后续访问该表的 SQL 排队,放大阻塞影响。
所以生产环境中执行 DDL 时,需要关注是否存在长事务,以及 DDL 的锁等待情况。
3.10 实际项目中如何使用锁防止库存超卖?如何排查锁等待?
库存扣减可以直接使用带条件的原子更新,让 InnoDB 在数据库层面保证并发修改的正确性。
例如:
UPDATE product
SET stock = stock - 1
WHERE id = 100 AND stock > 0;
这条 SQL 同时完成库存判断和扣减。
如果多个事务并发更新相同的商品记录,InnoDB 会通过排他锁协调修改,后续事务需要等待冲突锁释放后再判断更新条件。
业务通过受影响行数判断扣减是否成功,就可以避免并发扣减导致库存变成负数。
这种方案适合单条记录的原子扣减。如果还需要同时创建订单、更新支付状态等,就要通过事务保证多个操作的一致性。
同时,需要确保商品 ID 有合适的索引,并尽量缩短事务时间。对于极高并发的热点库存,还可能需要结合限流、队列等方式降低数据库竞争。
继续追问:线上出现大量锁等待,如何排查?
通常按照下面的流程处理:
定位阻塞事务 → 查看等待关系 → 分析执行 SQL → 检查索引和事务持续时间 → 优化或处理阻塞源
具体可以使用:
-
SHOW ENGINE INNODB STATUS:查看最近一次死锁信息。 -
information_schema.INNODB_TRX:查看活动事务及其状态。 -
performance_schema.data_locks:查看已持有和正在请求的数据锁。 -
performance_schema.data_lock_waits:查看锁等待与阻塞关系。 -
performance_schema.metadata_locks:排查元数据锁等待。 -
EXPLAIN:分析 SQL 是否扫描了过多记录。
如果锁等待集中在少量热点行,就需要考虑减少事务持锁时间、优化更新方式,或者在业务层进行削峰处理。
4. 易混淆概念对比
4.1 表级锁与行级锁
| 对比项 | 表级锁 | 行级锁 |
|---|---|---|
| 锁定粒度 | 整张表 | 索引记录及相关范围 |
| 并发能力 | 通常较低 | 通常较高 |
| 管理开销 | 相对较小 | 相对较大 |
| 典型场景 | 显式锁表 | InnoDB 数据更新 |
4.2 记录锁、间隙锁与临键锁
| 对比项 | 记录锁 | 间隙锁 | 临键锁 |
|---|---|---|---|
| 锁定对象 | 已有索引记录 | 索引记录之间的间隙 | 索引记录及其前方间隙 |
| 阻止修改已有记录 | 根据共享或排他模式决定 | 不直接阻止 | 根据共享或排他模式决定 |
| 阻止间隙内插入 | 通常不阻止 | 阻止 | 阻止 |
| 主要作用 | 保护已有记录 | 限制插入 | 同时保护记录和范围 |
4.3 意向锁与插入意向锁
| 对比项 | 意向锁 | 插入意向锁 |
|---|---|---|
| 锁粒度 | 表级 | 索引间隙 |
| 主要作用 | 协调表锁与行锁 | 协调并发插入 |
| 常见类型 | IS、IX | 插入意向间隙锁 |
| 核心特点 | 不同事务的 IX 锁通常兼容 | 不同位置的插入通常可以并发 |
4.4 快照读与当前读
| 对比项 | 快照读 | 当前读 |
|---|---|---|
| 读取版本 | 符合 Read View 的可见版本 | 当前可用版本 |
| 实现方式 | MVCC | 加锁协调并发 |
| 行锁 | 通常不加 | 根据操作获取相关行锁 |
| 典型 SQL | 普通 SELECT |
SELECT ... FOR UPDATE、UPDATE |
4.5 读已提交与可重复读
| 对比项 | 读已提交(RC) | 可重复读(RR) |
|---|---|---|
| Read View | 每次一致性读通常重新创建 | 同一事务通常复用首次一致性读的快照 |
| 普通 SELECT | 可能看到新提交的数据 | 通常保持读取结果稳定 |
| 间隙锁 | 一般搜索和扫描中基本不使用,保留必要例外 | 范围加锁时常使用 |
| 并发特点 | 范围插入限制相对较少 | 范围加锁可能产生更多竞争 |
4.6 死锁与锁等待超时
| 对比项 | 死锁 | 锁等待超时 |
|---|---|---|
| 发生原因 | 多个事务循环等待 | 等待锁超过限制时间 |
| 是否必须循环等待 | 是 | 否 |
| 默认处理 | 检测后回滚选中的整个事务 | 回滚当前失败语句 |
| 主要优化方向 | 统一加锁顺序、缩短事务 | 排查阻塞源、优化索引和事务 |
5. 总结
MySQL 的锁主要用于控制并发事务对数据的访问,保证数据一致性和事务隔离。InnoDB 通过基于索引的行级锁实现细粒度并发控制,主要包括记录锁、间隙锁和临键锁,并通过意向锁协调表锁与行锁。在默认可重复读隔离级别下,普通查询通常通过 MVCC 实现快照读,加锁读取则通过记录锁和范围锁控制并发修改。实际应用中,锁的范围与索引、隔离级别和执行计划密切相关,因此需要合理设计索引、缩短事务时间,并处理可能出现的锁等待和死锁问题。