适用版本:MySQL 8.0.x(本文示例基于 InnoDB 存储引擎)
一、背景
很多 MySQL 性能问题、阻塞问题、超时问题,最后都会追到“锁”上。
例如:
- 为什么一条很简单的
UPDATE会卡住几十秒? - 为什么两个看起来不相关的事务会互相等待?
- 为什么明明是按主键更新,却还是出现范围阻塞?
- 为什么查询语句加了
FOR UPDATE之后,系统并发突然下降?
这些问题背后,核心都与 InnoDB 锁机制 有关。
如果把事务隔离级别理解为“并发规则”,那么锁就是这些规则落地时最直接的控制手段。理解 InnoDB 的锁,不能只停留在“表锁、行锁”这两个简单概念上,还要进一步理解:
- 共享锁与排他锁;
- 意向锁;
- 记录锁、间隙锁、临键锁;
- 当前读与加锁行为;
- 索引条件与锁范围的关系。
这篇文章就从这些核心点展开。
二、为什么数据库需要锁
数据库允许多个事务并发执行,但并发并不意味着可以“毫无约束地同时改同一份数据”。
如果没有锁,可能出现:
- 两个事务同时修改同一行,后写覆盖前写;
- 一个事务读到另一个事务尚未完成的中间状态;
- 范围查询刚读完,另一个事务又插入了满足条件的新记录。
因此,锁的作用可以概括为两点:
- 保证数据一致性;
- 协调并发访问顺序。
但是锁并不是越多越好。锁越强、范围越大,冲突就越多,系统吞吐量也可能越差。所以学习锁机制,本质上是在学习 MySQL 如何在“一致性”和“并发性能”之间做平衡。
三、核心机制:锁的分类总览
从学习路径上看,可以把 InnoDB 锁大致分成两层:
第一层:按锁模式划分
- 共享锁(S 锁)
- 排他锁(X 锁)
- 意向共享锁(IS 锁)
- 意向排他锁(IX 锁)
第二层:按锁定对象划分
- 表级意向锁
- 记录锁(Record Lock)
- 间隙锁(Gap Lock)
- 临键锁 / Next-Key Lock
- 插入意向锁(Insert Intention Lock)
理解时可以先记住一句话:
InnoDB 常说“行锁”,但它本质上锁的是索引记录及其范围,而不是抽象意义上的数据行。
这句话非常关键。
四、共享锁与排他锁
4.1 共享锁(S 锁)
共享锁允许多个事务同时持有同一资源的读锁,但不允许其他事务对该资源加排他锁。
可以理解为:
- 我可以读;
- 你也可以读;
- 但谁都不能改。
示例
SELECT * FROM account WHERE id = 1 FOR SHARE;
在 MySQL 8.0 中,更推荐 FOR SHARE 来申请共享锁。
4.2 排他锁(X 锁)
排他锁表示某个事务独占该资源,其他事务既不能加共享锁,也不能加排他锁。
可以理解为:
- 我正在改;
- 你不能读锁我,也不能改我。
示例
SELECT * FROM account WHERE id = 1 FOR UPDATE;
或者:
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE、DELETE 这类当前读语句在执行时,通常都会对命中的索引记录加排他锁。
五、意向锁:表级协调机制
很多初学者听到“意向锁”会觉得抽象,其实它的作用很直接:
告诉系统:某个事务准备在某张表里的某些行上加锁。
InnoDB 在给记录加行锁之前,会先在表级别加意向锁,用于协调“表锁”和“行锁”的兼容关系。
两种常见意向锁
- IS(意向共享锁):事务准备给某些行加共享锁;
- IX(意向排他锁):事务准备给某些行加排他锁。
为什么要有意向锁
假设没有意向锁,当有人要给整张表加表锁时,就必须逐行检查这张表上有没有行锁,成本会非常高。
有了意向锁之后,只要先看表级意向锁状态,就能快速判断:
- 这张表里是否已经存在行级锁活动;
- 是否允许继续加表锁。
要点
- 意向锁是 表级锁;
- 它不阻止正常的行级读写;
- 它主要用于锁兼容性判断,而不是直接替代行锁。
六、核心机制:记录锁、间隙锁、临键锁
这部分是 InnoDB 锁机制的重点。
6.1 记录锁(Record Lock)
记录锁锁定的是索引中的某条已存在记录。
例如主键查询:
SELECT * FROM user WHERE id = 10 FOR UPDATE;
如果 id 是主键或唯一索引,并且条件精确命中一条记录,那么 InnoDB 往往会在该索引记录上加记录锁。
典型特征
- 锁定已存在的索引记录;
- 适合点查命中;
- 锁范围相对最小。
6.2 间隙锁(Gap Lock)
间隙锁锁定的不是某条已存在记录,而是索引记录之间的空隙区间。
它的主要作用是:
- 防止其他事务在该范围内插入新记录;
- 从而避免范围查询时出现幻读。
举例理解
假设索引值有:
10, 20, 30
那么可能存在的间隙包括:
(-∞, 10)(10, 20)(20, 30)(30, +∞)
如果某事务锁住了 (10, 20) 这个 gap,那么其他事务就不能往这个区间插入值 11~19。
特点
- 锁的是“区间”,不是已有记录;
- 主要影响
INSERT; - 常出现在范围条件的当前读中。
6.3 临键锁(Next-Key Lock)
临键锁是 记录锁 + 间隙锁 的组合。
可以理解成:
既锁住命中的记录本身,也锁住记录前面的间隙。
在 REPEATABLE READ 隔离级别下,InnoDB 为了避免范围查询产生幻读,常常会使用 next-key lock。
为什么临键锁重要
因为它解释了很多“看起来只锁了一行,实际却影响了一片范围”的现象。
例如:
SELECT * FROM orders WHERE amount >= 100 AND amount < 200 FOR UPDATE;
如果 amount 上有索引,那么这条语句不只是锁住已存在的记录,还可能锁住该范围相关的索引间隙,导致其他事务无法在区间内插入新值。
七、插入意向锁是什么
插入意向锁是一种特殊的 gap 级协调锁,主要在 INSERT 时使用。
它的作用不是直接阻塞所有并发插入,而是告诉系统:
我想在某个 gap 中插入一条记录。
如果多个事务要在同一个 gap 中插入不同位置的数据,插入意向锁之间并不一定互斥;但如果该 gap 已被别的事务用 gap lock 或 next-key lock 封住,那么插入就需要等待。
典型理解
- 插入意向锁是插入动作申请过程中的一种标记;
- 真正影响它的,通常是别人是否已经锁住了对应 gap。
八、行锁为什么说本质上锁的是索引
这是 InnoDB 锁理解中最容易被忽视、但又最关键的一点。
InnoDB 的行锁是通过索引项来实现的。如果 SQL 没有命中合适索引,MySQL 可能需要扫描大量记录,这时加锁范围也可能随之扩大。
示例 1:按主键更新
UPDATE account SET balance = balance + 100 WHERE id = 1;
如果 id 是主键,那么只会锁住相关索引记录,影响范围通常较小。
示例 2:按非索引列更新
UPDATE account SET balance = balance + 100 WHERE name = 'Alice';
如果 name 没有索引,InnoDB 可能需要扫描整张表来判断哪些行满足条件。扫描过程中可能接触并锁定更多记录,阻塞范围显著增大。
结论
- 锁范围与索引命中强相关;
- SQL 写法相同,索引不同,锁行为也会不同;
- 设计索引不仅影响性能,也直接影响并发冲突。
九、示例:不同 SQL 的加锁差异
9.1 主键等值命中
SELECT * FROM account WHERE id = 5 FOR UPDATE;
如果 id 是主键且记录存在,通常加的是记录锁。
9.2 唯一索引等值命中
SELECT * FROM user WHERE email = 'a@test.com' FOR UPDATE;
如果 email 是唯一索引且精确命中,通常也可以缩小为记录锁。
9.3 普通索引范围查询
SELECT * FROM orders WHERE amount BETWEEN 100 AND 200 FOR UPDATE;
这类范围当前读通常会对相关索引范围加 next-key lock,既锁记录,也锁间隙。
9.4 无索引条件更新
UPDATE orders SET status = 'DONE' WHERE remark = 'urgent';
若 remark 无索引,这条语句的扫描和加锁成本会明显上升,很容易放大阻塞问题。
十、锁兼容关系的直观理解
不必死记所有兼容矩阵,但有几个规则最好记住:
10.1 S 与 S 兼容
多个事务可以同时持有共享锁。
10.2 X 与任何强锁通常都不兼容
排他锁强调独占,因此它与 S、X 通常不兼容。
10.3 意向锁主要服务于表锁判断
- IS 与 IX 之间通常可以共存;
- 但表级强锁与意向锁之间会进行兼容性判断。
从业务角度可以简单记:
- “大家一起读”通常能共存;
- “有人要改”就容易冲突;
- “有人锁范围”时,插入最容易被挡住。
十一、常见问题
11.1 为什么是“行锁”却锁住了插入?
因为 InnoDB 并不只是简单锁一行,它可能加的是 gap lock 或 next-key lock。范围锁住后,别人即使插入的是“新行”,也可能被阻塞。
11.2 为什么 SELECT 有时也会加锁?
普通 SELECT 通常是快照读,不加记录锁;但以下语句属于当前读:
SELECT ... FOR UPDATESELECT ... FOR SHARE
它们会参与锁竞争。
11.3 为什么有索引后阻塞明显减少?
因为索引让 InnoDB 能更精准地定位并锁定目标记录或范围,避免扫描和锁定无关数据。
11.4 间隙锁是不是所有隔离级别都会出现?
不是。
在 InnoDB 中,间隙锁与 next-key lock 主要与 REPEATABLE READ 下的当前读、防幻读需求有关。不同隔离级别、不同 SQL 语句,表现会有所差异。
11.5 为什么业务代码里容易误用 FOR UPDATE?
因为很多人把它当成“更安全的查询”,但没有意识到它会:
- 读取当前版本;
- 加锁;
- 带来等待和死锁风险。
只有在确实需要“锁住后再更新”时,才适合使用。
十二、实践建议
12.1 优先让更新走索引
这是控制锁范围最有效的方式之一。尤其是:
- 更新语句;
- 删除语句;
FOR UPDATE范围查询;
都要重点检查是否真正命中索引。
12.2 尽量避免大范围当前读
例如:
SELECT * FROM big_table WHERE status = 0 FOR UPDATE;
如果命中记录很多,这条语句会持有大量锁,极易放大冲突。
12.3 把事务做短,锁持有时间就会短
SQL 执行完不及时提交,锁就一直不释放。很多锁等待并不是 SQL 本身慢,而是事务迟迟不提交。
12.4 对热点行要考虑竞争设计
对库存、账户、计数器等热点数据,要评估:
- 是否会形成热点更新;
- 是否需要分片或拆分热点;
- 是否要使用更细粒度的业务控制。
12.5 排查锁问题时,先看“谁锁了谁”
生产中遇到锁等待时,应优先确认:
- 被阻塞的 SQL 是什么;
- 阻塞它的事务是谁;
- 阻塞方是否是长事务;
- 是否由于缺索引导致锁范围扩大。
锁问题很少只是“数据库突然变慢”,多数时候都有明确的竞争链条。
十三、小结
InnoDB 锁机制的学习重点,不在于把所有术语都死记硬背,而在于建立一套有层次的理解框架:
- 锁模式:S、X、IS、IX;
- 锁对象:记录锁、间隙锁、临键锁、插入意向锁;
- 实现基础:锁的是索引,而不只是抽象的数据行;
- 行为关键:快照读和当前读的加锁行为不同;
- 性能关键:索引质量决定锁范围,事务时长决定锁持有时间。
可以把本文归纳为一句更实用的话:
MySQL 并发问题,很多时候表面上看是“锁冲突”,本质上却是“SQL + 索引 + 事务边界”共同作用的结果。
真正理解了 InnoDB 锁,后续再看:
- 死锁;
- 锁等待超时;
- 幻读控制;
- 热点更新优化;
就会更容易定位问题。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!