MySQL锁机制和事务隔离怎么配合
之前讲过 MySQL 事务隔离级别,这次继续往下拆锁机制。InnoDB 的记录锁、间隙锁、临键锁、意向锁和元数据锁各自解决什么问题?MVCC 和锁是什么关系?为什么同一条 SQL 有时锁一行、有时却卡住一大片?这篇结合事务隔离,把这些问题讲清楚。
之前我们聊过 MySQL 事务隔离级别详解与实践,那篇重点讲不同隔离级别下能不能看到别的事务提交的数据。
但隔离级别只是规则,真正把并发请求挡住、排队和协调起来的,还是数据库里的锁。
很多人第一次遇到 MySQL 锁等待,会觉得特别玄学:明明更新的是不同数据,为什么也会互相等待?
一条查询语句,看起来只是查数据,为什么也可能把别的事务卡住?
同样是 SELECT,普通查询和 SELECT ... FOR UPDATE 到底差在哪里?
这篇就结合事务隔离,把 InnoDB 常见锁机制捋一遍。
本文默认讨论 MySQL 的 InnoDB 存储引擎。
锁到底在解决什么
数据库里的锁,本质上是在多个事务同时访问共享数据时,控制它们的执行顺序。
如果没有任何控制,两个事务同时修改同一条数据,最后结果可能取决于谁最后写入,业务就容易出现覆盖更新、库存超卖和状态错乱。
事务A:读取库存 10
事务B:读取库存 10
事务A:扣减 1,写回 9
事务B:扣减 1,写回 9
正确结果应该是 8,实际却变成了 9
这就是典型的丢失更新。
锁的作用不是让数据库所有操作都串行,而是在可能产生冲突的地方建立约束。
事务A 获取锁
│
▼
修改共享数据
│
├── 提交:释放锁
└── 回滚:释放锁
事务B 在锁释放前等待,或根据语句规则读取旧版本
锁和事务隔离不是两套互不相关的功能。
隔离级别定义事务之间应该看到什么,锁负责在当前读和写操作中控制并发冲突,MVCC 则让一部分一致性读不必直接等待写锁。
先区分快照读和当前读
理解 InnoDB 锁之前,先区分两类读取。
快照读
普通的 SELECT 通常属于一致性读,也叫快照读。
在满足条件的情况下,它会根据事务的 Read View 读取符合隔离级别要求的历史版本,不一定去等待别的事务持有的排他锁。
SELECT * FROM account WHERE id = 1001;
如果另一个事务正在修改这条记录但还没有提交,普通查询可能读取到修改前的版本,而不是直接被锁住。
这正是 MVCC 的价值。
当前读
下面这些操作通常属于当前读:
SELECT * FROM account WHERE id = 1001 FOR UPDATE;
SELECT * FROM account WHERE id = 1001 FOR SHARE;
UPDATE account SET balance = balance - 10 WHERE id = 1001;
DELETE FROM account WHERE id = 1001;
当前读要读取最新版本,并且要遵守当前数据上的锁规则。
如果目标记录被别的事务锁住,就可能等待。
可以简单记成:
普通 SELECT:更偏向读历史版本,通常是快照读
FOR UPDATE / FOR SHARE / UPDATE / DELETE:面向最新数据,属于当前读
这也是为什么同一个事务里,普通查询和 FOR UPDATE 可能得到不同结果。
InnoDB 有哪些常见锁
InnoDB 的锁可以从多个角度分类。
先从最常用的几类开始。
共享锁和排他锁
共享锁通常叫 S 锁,排他锁通常叫 X 锁。
共享锁允许多个事务同时读取同一条数据,但不允许其他事务获得排他锁修改它。
排他锁则更严格,拿到之后其他事务不能再对同一资源加不兼容的锁。
S + S:通常可以共存
S + X:冲突
X + S:冲突
X + X:冲突
在 MySQL 8 中,可以用 FOR SHARE 显式读取并加共享锁:
SELECT * FROM account
WHERE id = 1001
FOR SHARE;
需要读取后马上修改时,常见写法是:
SELECT * FROM account
WHERE id = 1001
FOR UPDATE;
FOR UPDATE 通常会加排他锁,事务提交或回滚之前不会释放。
记录锁
记录锁是加在索引记录上的锁。
注意这里说的是索引记录,不一定只是从概念上理解的那一行数据。
例如表有主键 id,执行:
SELECT * FROM account
WHERE id = 1001
FOR UPDATE;
如果使用了主键索引,通常会锁住 id 为 1001 的索引记录。
其他事务修改同一条记录时就需要等待。
间隙锁
间隙锁不是锁住某一条已经存在的记录,而是锁住索引记录之间的范围。
它的作用是阻止其他事务在这个范围内插入新记录。
索引值:10 20 30 40
[ 20 和 30 之间的间隙 ]
▲
│ 间隙锁
假设一个事务在可重复读隔离级别下锁定某个范围,另一个事务想在范围内插入一条新记录,可能就会被间隙锁挡住。
间隙锁主要用于防止幻读,但也经常是锁等待看起来“锁了不存在的数据”的原因。
临键锁
临键锁,也叫 Next-Key Lock,可以理解为记录锁和间隙锁的组合。
它锁住某条索引记录以及这条记录前面的间隙。
索引值:10 20 30 40
[ 20 前面的间隙 + 20 记录 ]
在默认的可重复读隔离级别下,InnoDB 的范围条件当前读经常会使用临键锁。
它既防止已有记录被并发修改,也防止范围内插入新记录。
意向锁
意向锁是表级锁,用来表示事务准备在表中的某些记录上加行锁。
常见的有意向共享锁 IS 和意向排他锁 IX。
它听起来有点绕,其实可以把它理解成一块“告示牌”:
事务A:我准备在 account 表里的某几行加 X 锁
表级别:留下一个 IX 意向锁
事务B:如果想给整张表加冲突的表锁,先检查这个告示牌
意向锁不会代替行锁,也不是为了锁住整张表。
它的主要作用是让表级锁快速判断表内是否已经存在行锁,避免逐行扫描。
元数据锁
元数据锁通常叫 MDL,保护的是表结构。
只要事务访问了一张表,MySQL 往往会持有相应的元数据锁,避免另一个事务同时修改表结构。
因此下面这种场景很常见:
事务A:开启事务,查询一张大表,但一直不提交
事务B:执行 ALTER TABLE
结果:事务B 长时间等待 MDL
有时候业务 SQL 都不慢,但 ALTER TABLE 一直卡住,问题不是索引,而是前面有长事务占着元数据锁。
事务隔离和锁怎么配合
事务隔离级别会影响读的可见性,也会影响 InnoDB 什么时候需要使用间隙锁和临键锁。
读未提交
读未提交允许事务看到其他事务尚未提交的数据,可能产生脏读。
它对一致性要求最低,实际业务中很少作为默认选择。
读已提交
读已提交只能读取已经提交的数据,同一个事务中前后两次普通查询可能读到不同版本。
在这个隔离级别下,间隙锁的使用范围通常比可重复读更小,但写操作本身仍然需要相应的记录锁。
可重复读
MySQL InnoDB 默认使用可重复读。
普通快照读依靠 MVCC 尽量保持同一事务内的读取视图稳定,范围当前读则可能通过临键锁和间隙锁抑制幻读。
例如:
START TRANSACTION;
SELECT * FROM order_item
WHERE user_id = 1001
AND status = 0
FOR UPDATE;
-- 这里除了锁住已有结果,还可能锁住符合范围的间隙
-- 防止其他事务在范围中插入新记录
COMMIT;
串行化
串行化会进一步收紧并发访问,读操作也可能加锁,吞吐量下降明显。
它适合对一致性要求极高、并发量不大的特定场景,不适合无脑开启。
事务隔离的详细现象可以回看前面的事务隔离文章。
这里记住一个核心关系就够了:
MVCC:让部分快照读读取合适的历史版本
锁:约束当前读、写操作和结构变更之间的冲突
隔离级别:规定事务可以看到什么,以及并发控制的整体边界
索引决定锁住多大范围
这是 MySQL 锁机制里最容易被低估的一点:锁和索引强相关。
InnoDB 的行锁本质上是加在索引上的。
如果查询条件命中唯一索引,锁定范围通常比较精准。
SELECT * FROM account
WHERE id = 1001
FOR UPDATE;
如果条件使用普通二级索引,InnoDB 可能锁住二级索引记录,同时还涉及对应的聚簇索引记录。
如果条件没有合适索引,数据库可能需要扫描大量记录,锁定范围也可能扩大,甚至表现得像整张表都被卡住。
有合适索引:
WHERE id = 1001 -> 精准定位 -> 锁定范围小
缺少合适索引:
WHERE status = 0 -> 扫描大量记录 -> 锁等待范围变大
所以遇到锁等待时,不能只看 SQL 有没有 FOR UPDATE。
还要看执行计划:
EXPLAIN SELECT * FROM order_item
WHERE user_id = 1001
AND status = 0
FOR UPDATE;
索引设计不只是为了让查询变快,也会影响并发写入时锁的粒度。
这也是为什么“加一个索引后锁问题消失”并不奇怪。
锁的生命周期和事务边界
InnoDB 的行锁通常不会在一条 SQL 执行结束后马上释放,而是持续到当前事务提交或回滚。
START TRANSACTION
│
├── SELECT ... FOR UPDATE 获取锁
├── 业务处理、远程调用、用户等待
├── UPDATE ... 继续持有锁
│
└── COMMIT / ROLLBACK 释放锁
这就引出一个非常实际的原则:事务要短。
不要在事务里做远程 HTTP 调用、发送邮件、等待用户输入或执行很慢的文件操作。
事务持续时间越长,锁持有时间越长,其他请求等待的概率越大。
常见的坏代码大概是这样:
开启事务
-> 锁定库存行
-> 调用第三方支付接口
-> 等待 5 秒
-> 更新订单
-> 提交事务
支付接口慢的时候,库存锁也跟着被占用。
更好的做法是缩短锁的持有范围,把外部调用、重计算和数据库事务拆开设计。
死锁为什么会发生
死锁不是数据库坏了,而是多个事务形成了环形等待。
事务A:持有锁 1,等待锁 2
事务B:持有锁 2,等待锁 1
A 等 B,B 等 A
一个常见例子:
事务A:先更新账户 1,再更新账户 2
事务B:先更新账户 2,再更新账户 1
如果两个事务交错执行,就可能互相等待。
解决死锁通常从几个方向入手:
- 所有事务按相同顺序访问资源
- 缩短事务范围和锁持有时间
- 给查询条件建立合适索引,减少锁范围
- 避免在事务中执行不可控的外部调用
- 捕获死锁异常,按业务安全地重试
死锁重试不能当成唯一方案。
如果每次都靠重试掩盖访问顺序不一致,系统压力上来后还是会反复出问题。
怎么排查锁等待
先看当前有哪些事务和锁等待。
MySQL 8 可以查询性能库中的相关视图:
SELECT *
FROM performance_schema.data_lock_waits;
SELECT *
FROM performance_schema.data_locks;
也可以查看 InnoDB 状态:
SHOW ENGINE INNODB STATUS;
重点关注:
- 哪个事务正在等待
- 等待的是哪张表、哪个索引、哪个锁模式
- 持锁事务执行了多久
- 持锁事务最后执行了什么 SQL
- 是否存在长事务或未提交事务
排查时不要只问“哪条 SQL 慢”,还要问“哪条事务一直没有结束”。
很多锁问题的根源不是某条 SQL 本身,而是业务代码忘了提交、异常后没有回滚,或者事务里混入了耗时操作。
实战中怎么选锁
可以按业务意图选择:
只读展示
普通 SELECT 通常就够了,让 MVCC 处理一致性读,不要为了心理安全到处加 FOR UPDATE。
读取后马上修改
需要先读取当前值,再基于这个值修改时,可以使用 FOR UPDATE。
START TRANSACTION;
SELECT stock FROM product
WHERE id = 1001
FOR UPDATE;
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;
COMMIT;
但库存扣减还要检查受影响行数,不能只看 SQL 有没有报错。
允许并发修改
如果不想长时间持有悲观锁,也可以使用版本号做乐观锁:
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1001
AND version = 7
AND stock > 0;
如果影响行数为 0,说明版本已变化或库存不足,需要重新读取并按业务处理。
乐观锁和悲观锁的完整对比,可以结合之前的乐观锁与悲观锁文章一起看。
小结
MySQL InnoDB 锁机制,可以先记住这些核心点:
- 普通
SELECT通常是快照读,FOR UPDATE和写操作属于当前读 - 共享锁允许共享读取,排他锁用于保护修改
- 记录锁锁索引记录,间隙锁锁记录之间的范围,临键锁是两者组合
- 意向锁用于表达表中存在行锁,不是用来替代行锁
- 元数据锁保护表结构,长事务可能让
ALTER TABLE长时间等待 - 隔离级别影响 MVCC 可见性,也会影响范围锁行为
- 索引决定锁定范围,缺少合适索引可能放大锁冲突
- 锁通常持续到事务提交或回滚,事务越长,等待越严重
- 死锁要统一访问顺序,同时保留安全重试机制
一句话总结:MVCC 解决一部分读取冲突,锁解决当前读和写入冲突,索引与事务边界决定冲突会扩大到什么程度。
以后遇到 MySQL 锁等待,先别急着把所有 SQL 改成非事务。
先看事务隔离级别、读操作类型、执行计划、锁等待对象和事务持续时间,通常就能把问题从“数据库玄学”还原成一条清晰的等待链。
