MySQL的锁

MySQL 锁全面扫盲

一、按锁的粒度分

1. 全局锁(Global Lock)

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

2. 表级锁(Table-Level Lock)

a) 表锁

代码块SQL · 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 语句级别的表锁

3. 行级锁(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 / 读锁)

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

排他锁(X Lock / 写锁)

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

兼容矩阵

请求 S请求 X
持有 S
持有 X

三、按加锁方式分

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

代码块SQL · 3 行收起展开
UPDATE t SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 影响行数为 0 → 说明被别人改过了,重试

悲观锁(数据库层面)

代码块SQL · 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 = 10 FOR UPDATE仅锁 id=10 这一条(Record Lock)唯一索引等值命中 → 退化为记录锁
WHERE id = 7 FOR UPDATE间隙锁 (5, 10)唯一索引等值未命中 → Next-Key Lock (5,10] 退化为 Gap Lock (5,10)
WHERE id >= 10 AND id < 15 FOR UPDATENext-Key Lock (5,10] + (10,15]范围查询,锁到不满足条件的第一个值

五、死锁

经典死锁场景

代码块SQL · 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;

六、一张图总结

  • 按粒度
    • 全局锁(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

一条链串完所有概念。

起点:两种读

  • 普通 SELECT → 快照读(Snapshot Read):不加锁,走 MVCC
  • SELECT … FOR UPDATE、INSERT / UPDATE / DELETE → 当前读(Current Read):加锁,读最新已提交数据

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

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

  • 快照读 → MVCC 保护
    • 每行有隐藏列 trx_id + roll_pointer
    • undo log 串成版本链
    • ReadView 判断可见性
      • RC:每条 SELECT 新建 ReadView
      • RR:事务内只建一次,后续复用
  • 当前读 → 锁保护
    • Record Lock(锁住确切的行)
    • Gap Lock(锁住间隙,不让插入)
    • Next-Key Lock(Record + Gap)

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

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

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

异常RCRR
脏读(读到未提交数据)✅ 解决(ReadView 只看已提交的)✅ 解决(同左)
不可重复读(同一行两次读不一样)❌ 没解决(ReadView 每次重建,看到新值)✅ 解决(ReadView 固定,看不到新值)
幻读(两次查行数不一样)❌ 没解决(没 Gap Lock 也没固定 ReadView)✅ 基本解决:快照读靠 MVCC 挡住,当前读靠 Gap Lock 挡住

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

代码块PLAINTEXT · 4 行收起展开
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锁)

两者的区别在于放不放别人进来读:

  • FOR SHARE(S 锁):我读的时候,别人也能读,但不能改
  • FOR UPDATE(X 锁):我读的时候,别人不能读也不能改

延伸阅读