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 改成非事务。

先看事务隔离级别、读操作类型、执行计划、锁等待对象和事务持续时间,通常就能把问题从“数据库玄学”还原成一条清晰的等待链。

更多推荐

章节目录