MySQL的锁 · MySQL


MySQL 锁全面扫盲

一、按锁的粒度分

  1. 全局锁(Global Lock)
代码块JAVA · 2 行收起展开
  FLUSH TABLES WITH READ LOCK; -- 加全局读锁
  UNLOCK TABLES;               -- 释放
  • 整个数据库实例变为只读,所有写操作、DDL、更新事务全部阻塞
  • 使用场景:全库逻辑备份(mysqldump)
  • 问题:业务完全停摆
  • 更好的替代:mysqldump —single-transaction(利用 MVCC 一致性快照,仅限 InnoDB)

  1. 表级锁(Table-Level Lock)

a) 表锁

代码块JAVA · 2 行收起展开
  LOCK TABLES t READ;   -- 表读锁(共享)
  LOCK TABLES t WRITE;  -- 表写锁(排他)
  • MyISAM 默认用表锁,InnoDB 一般不用这个
  • 粒度大,并发低

b) 元数据锁(MDL, Metadata Lock)

  • 不需要显式加,MySQL 5.5+ 自动加
  • 对表做 CRUD → 自动加 MDL 读锁
  • 对表做 DDL(ALTER TABLE 等)→ 自动加 MDL 写锁
  • 经典坑:一个长事务持有 MDL 读锁不提交 → 你去 ALTER TABLE → 被阻塞且后续所有查询全部排队 → 线上雪崩

c) 意向锁(Intention Lock)

  • InnoDB 特有的表级标记锁
  • 意向共享锁(IS):事务打算对某些行加共享锁前,先在表上加 IS
  • 意向排他锁(IX):事务打算对某些行加排他锁前,先在表上加 IX
  • 作用:让表级锁判断能快速知道”表里有没有行锁”,避免逐行扫描
  • IS/IX 之间互不冲突,只与表级 S/X 锁冲突

d) AUTO-INC 锁

  • 对自增列插入时加的特殊表级锁
  • MySQL 8.0 默认 innodb_autoinc_lock_mode = 2(交叉模式),只在分配自增值时短暂持有一个轻量级互斥量,不再持有整条 INSERT 语句级别的表锁

  1. 行级锁(Row-Level Lock)— InnoDB 核心

这是你重点要掌握的部分。InnoDB 的行锁都是加在索引上的,不是加在数据行上。

a) 记录锁(Record Lock)

  • 锁住索引上的一条确切记录
  • SELECT * FROM t WHERE id = 5 FOR UPDATE; → 对 id=5 这条索引记录加 X 型记录锁

b) 间隙锁(Gap Lock)

  • 锁住索引记录之间的间隙,不锁记录本身
  • 是一个开区间 (a, b)
  • 唯一目的:防止其他事务在间隙中插入新记录 → 解决幻读
  • 间隙锁之间不冲突(两个事务可以同时对同一间隙加 Gap Lock)
  • 只在 RR(REPEATABLE READ) 隔离级别下生效,RC 下没有间隙锁

c) 临键锁(Next-Key Lock)

  • Record Lock + Gap Lock = 左开右闭区间 (a, b]
  • InnoDB 在 RR 级别下,默认加的就是 Next-Key Lock
  • 举例:索引上有记录 5, 10, 15,那么 Next-Key Lock 可能锁的区间是: (-∞, 5] (5, 10] (10, 15] (15, +∞)

d) 插入意向锁(Insert Intention Lock)

  • 一种特殊的间隙锁,在 INSERT 操作时加
  • 多个事务向同一个间隙的不同位置插入时互不阻塞
  • 但如果间隙上已有 Gap Lock,INSERT 意向锁会被阻塞 → 这就是很多死锁的根源

二、按锁的模式/兼容性分

共享锁(S Lock / 读锁)

代码块JAVA · 2 行收起展开
  SELECT ... LOCK IN SHARE MODE;   -- MySQL 5.x
  SELECT ... FOR SHARE;            -- MySQL 8.0+
  • 允许其他事务也加 S 锁,阻止其他事务加 X 锁

排他锁(X Lock / 写锁)

代码块JAVA · 2 行收起展开
  SELECT ... FOR UPDATE;
  INSERT / UPDATE / DELETE;  -- 自动加 X 锁
  • 阻止其他事务加任何锁

兼容矩阵

代码块PLAINTEXT · 1 行收起展开
      请求 S    请求 X

持有 S ✅ ❌ 持有 X ❌ ❌


三、按加锁方式分

乐观锁(应用层面,不是 MySQL 内置)

代码块JAVA · 2 行收起展开
  UPDATE t SET stock = stock - 1, version = version + 1
  WHERE id = 1 AND version = 5;

— 影响行数为 0 → 说明被别人改过了,重试

悲观锁(数据库层面)

代码块JAVA · 3 行收起展开
  SELECT * FROM t WHERE id = 1 FOR UPDATE;  -- 先锁住
  UPDATE t SET stock = stock - 1 WHERE id = 1;
  COMMIT;

四、行锁加锁规则(面试高频)

InnoDB 在 RR 下的加锁规则可以总结为几条:

两个原则:

  1. 加锁的基本单位是 Next-Key Lock(左开右闭)
  2. 只有访问到的索引记录才会加锁

两个优化:

  1. 唯一索引上的等值查询,命中记录 → Next-Key Lock 退化为 Record Lock
  2. 普通索引上的等值查询,向右遍历到第一个不满足条件的值 → Next-Key Lock 退化为 Gap Lock

一个 bug(特性):

  • 唯一索引上的范围查询,会访问到不满足条件的第一个值为止

举例说明

表数据 id (主键): 5, 10, 15, 20

┌──────────────┬──────────────┬──────────────────────────────────────────┐ │ 查询 │ 加锁范围 │ 解释 │ ├──────────────┼──────────────┼──────────────────────────────────────────┤ │ WHERE id = │ 仅锁 id=10 │ │ │ 10 FOR │ 这一条 │ 唯一索引等值命中 → 退化为记录锁 │ │ UPDATE │ (Record │ │ │ │ Lock) │ │ ├──────────────┼──────────────┼──────────────────────────────────────────┤ │ WHERE id = 7 │ 间隙锁 (5, │ 唯一索引等值未命中 → Next-Key Lock │ │ FOR UPDATE │ 10) │ (5,10] 退化为 Gap Lock (5,10) │ ├──────────────┼──────────────┼──────────────────────────────────────────┤ │ WHERE id >= │ Next-Key │ │ │ 10 AND id < │ Lock (5,10] │ 范围查询,锁到不满足条件的第一个值 │ │ 15 FOR │ + (10,15] │ │ │ UPDATE │ │ │ └──────────────┴──────────────┴──────────────────────────────────────────┘


五、死锁

经典死锁场景

代码块JAVA · 4 行收起展开
  事务A: SELECT * FROM t WHERE id = 1 FOR UPDATE;  -- 锁住 id=1
  事务B: SELECT * FROM t WHERE id = 2 FOR UPDATE;  -- 锁住 id=2
  事务A: SELECT * FROM t WHERE id = 2 FOR UPDATE;  -- 等 B 释放
  事务B: SELECT * FROM t WHERE id = 1 FOR UPDATE;  -- 等 A 释放 → 死锁!

InnoDB 处理方式

  • 等待超时:innodb_lock_wait_timeout(默认 50s)
  • 死锁检测:innodb_deadlock_detect = ON(默认开启),主动回滚代价最小的事务
  • 查看最近死锁:SHOW ENGINE INNODB STATUS;

六、一张图总结

MySQL 锁 ├── 按粒度 │ ├── 全局锁 (FTWRL) │ ├── 表级锁 │ │ ├── 表锁 (LOCK TABLES) │ │ ├── 元数据锁 (MDL) │ │ ├── 意向锁 (IS/IX) │ │ └── AUTO-INC 锁 │ └── 行级锁 (InnoDB only, 加在索引上) │ ├── 记录锁 (Record Lock) │ ├── 间隙锁 (Gap Lock) │ ├── 临键锁 (Next-Key Lock) │ └── 插入意向锁 (Insert Intention Lock) ├── 按模式 │ ├── 共享锁 (S) │ └── 排他锁 (X) └── 按思想 ├── 乐观锁 (版本号/CAS, 应用层) └── 悲观锁 (FOR UPDATE, DB层)


七、面试常问问题速答

┌────────────────────────┬────────────────────────────────────────────────┐ │ 问题 │ 答案 │ ├────────────────────────┼────────────────────────────────────────────────┤ │ InnoDB │ 索引,不是数据行。没有索引会退化为表锁 │ │ 行锁锁的是什么? │ │ ├────────────────────────┼────────────────────────────────────────────────┤ │ 间隙锁解决了什么问题? │ 幻读(Phantom Read) │ ├────────────────────────┼────────────────────────────────────────────────┤ │ RC │ 没有,只有 Record Lock │ │ 隔离级别有间隙锁吗? │ │ ├────────────────────────┼────────────────────────────────────────────────┤ │ 死锁怎么排查? │ SHOW ENGINE INNODB STATUS 看 LATEST DETECTED │ │ │ DEADLOCK │ ├────────────────────────┼────────────────────────────────────────────────┤ │ 怎么减少锁冲突? │ 控制事务大小、合理使用索引、降低隔离级别(RC) │ └────────────────────────┴────────────────────────────────────────────────┘


核心记住一句话:InnoDB 的锁加在索引上,没索引就锁全表;RR 级别默认是 Next-Key Lock,等值查询会根据情况退化。把第四部分的加锁规则吃透,面试基本够用了。

4.11 rc & rr & 幻 & 读2

● 一条链串完所有概念

起点:两种读

你写的 SQL │ ├── 普通 SELECT → 快照读(Snapshot Read) │ 不加锁,走 MVCC │ └── SELECT … FOR UPDATE → 当前读(Current Read) INSERT / UPDATE / DELETE 加锁,读最新已提交数据

这是一切的分水岭,后面所有东西都从这里分叉。


第二层:每种读用什么机制保护

快照读 ──► MVCC 保护 │ ├── 每行有隐藏列 trx_id + roll_pointer ├── undo log 串成版本链 └── ReadView 判断可见性 │ ├── RC: 每条 SELECT 新建 ReadView └── RR: 事务内只建一次,后续复用

当前读 ──► 锁 保护 │ ├── Record Lock(锁住确切的行) ├── Gap Lock(锁住间隙,不让插入) └── Next-Key Lock(Record + Gap)


第三层:RC 和 RR 到底差在哪

代码块PLAINTEXT · 2 行收起展开
           RC                              RR
      ┌──────────────┐              ┌──────────────┐

快照读 │ 每次SELECT │ │ 第一次SELECT │ │ 新建ReadView │ │ 建一次,后续复用│ │ │ │ │ │ → 能看到别人 │ │ → 看不到别人 │ │ 中途提交的 │ │ 中途提交的 │ ├──────────────┤ ├──────────────┤ 当前读 │ 只有Record │ │ Next-Key Lock │ │ Lock │ │ (Record+Gap) │ │ │ │ │ │ → 没有间隙锁 │ │ → 间隙也锁住 │ └──────────────┘ └──────────────┘


第四层:三大读异常,谁解决了谁

代码块PLAINTEXT · 2 行收起展开
                      RC              RR
                  ──────────      ──────────

脏读 ✅ 解决 ✅ 解决 (读到未提交数据) (ReadView只 (同左) 看已提交的)

不可重复读 ❌ 没解决 ✅ 解决 (同一行两次读不一样) (ReadView每次 (ReadView固定 重建,看到新值) 看不到新值)

幻读 ❌ 没解决 ✅ 基本解决 (两次查行数不一样) (没Gap Lock ├─ 快照读: MVCC挡住 也没固定RV) └─ 当前读: Gap Lock挡住


第五层:RR 下幻读的唯一漏网之鱼

T1: SELECT(快照读) → 2行 T2: INSERT age=25; COMMIT; T1: UPDATE(当前读) → 碰到了25这行,trx_id变成T1自己的 T1: SELECT(快照读) → 3行! 幻读!

原因: UPDATE 是当前读 → 无视 ReadView → 改到了新插入的行 → 新版本的 trx_id = T1 自己 → 后续快照读判断 creator_trx_id 匹配 → 可见

这是唯一的漏网场景:快照读中间穿插了当前读。纯快照读或纯当前读(加了锁)都不会 幻读。


最终总结:一张决策图

你要执行一条 SELECT │ ├── 需要加锁吗? │ │ │ ├── 不需要 → 普通 SELECT → 快照读 → MVCC │ │ │ │ │ ┌────────────┤ │ │ RC: 新ReadView → 能看到别人提交的 │ │ RR: 复用ReadView → 看不到别人提交的 │ │ │ └── 需要 → FOR UPDATE → 当前读 → 加锁 │ │ │ ┌────────────┤ │ RC: 只加Record Lock → 防不了幻读 │ RR: 加Next-Key Lock → 间隙锁住,防幻读 │ └── UPDATE / DELETE / INSERT → 天然就是当前读 → 同上加锁逻辑

记住两句话就够了:

  • 快照读靠 MVCC 判”能不能看见”,RC 和 RR 的区别就是 ReadView 建几次
  • 当前读靠锁判”让不让进来”,RC 和 RR 的区别就是有没有 Gap Lock

当前读

  • SELECT ... FOR UPDATE → 当前读,加排他锁(X锁)
  • SELECT ... LOCK IN SHARE MODE / FOR SHARE → 当前读,加共享锁(S锁)
代码块JAVA · 2 行收起展开
  FOR SHARE (S锁):我读的时候,别人也能读,但不能改-->为了
  FOR UPDATE (X锁):我读的时候,别人不能读也不能改