Learn
MySQL/16-locks

锁机制

并发写同一行会怎样?答案是:锁。锁是隔离性的实现手段之一,也是线上「更新卡住不动」「Deadlock found」报错的根源。本章按粒度从大到小讲清 MySQL 的锁体系,最后给出死锁的分析方法与预防守则。

1. 锁的全景图

按粒度:
  全局锁 ──▶ 表级锁 ──▶ 行级锁
  (整库)     (表锁/MDL/意向锁)   (记录锁/间隙锁/临键锁)
 
按模式:
  S 锁(共享/读锁):可以多个事务同时持有
  X 锁(排他/写锁):独占,与任何锁互斥

兼容矩阵(行锁层面):

已持有 \ 想加SX
S兼容冲突
X冲突冲突

2. 全局锁与表级锁

2.1 全局锁

FLUSH TABLES WITH READ LOCK;   -- 整库只读,逻辑备份老方案
UNLOCK TABLES;

整库只读期间所有写入阻塞,主库上代价极大。现代备份用 mysqldump --single-transaction(靠 MVCC 快照,不锁库,第 19 章)或物理备份代替。

2.2 表锁与 MDL

LOCK TABLES orders READ;    -- 显式表锁,InnoDB 时代很少手动用
UNLOCK TABLES;

更常打交道的是 MDL(元数据锁),它是自动的:任何增删改查都持有 MDL 读锁,DDL 需要 MDL 写锁。第 4 章讲过的「长事务堵 DDL、DDL 再堵所有查询」连锁事故就是 MDL 排队造成的。

2.3 意向锁:表级与行级的协调员

事务要给某行加 X 锁前,会先给表加 IX(意向排他)锁;加行 S 锁前先加表 IS 锁。意向锁之间几乎全兼容,它存在的唯一目的:让「给整表加锁」的操作不用逐行检查——看到表上有 IX,就知道里面有行被写锁着,直接等待即可。你几乎不用管它,但死锁日志里会出现它的名字,认识即可。

3. 行锁:InnoDB 的核心

行锁加在索引记录上——这句话是理解一切行锁行为的钥匙。

-- 会话 A
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;   -- 对 id=1 的索引记录加 X 锁
 
-- 会话 B(阻塞,直到 A 提交或锁等待超时)
UPDATE products SET stock = stock - 1 WHERE id = 1;

锁等待超时由 innodb_lock_wait_timeout 控制(默认 50 秒),超时报错 Lock wait timeout exceeded。

3.1 没走索引 = 锁全表的行

WHERE 走不走索引,决定锁多少行
-- phone 列没有索引:type=ALL,扫过的每一行都会被加锁
EXPLAIN UPDATE users SET status = 2 WHERE phone = '13800000001';
 
-- email 上有唯一索引:只锁命中的那一条记录
EXPLAIN UPDATE users SET status = 2 WHERE email = 'alice@example.com';

InnoDB 只能全表扫描,扫过的每一行都会加锁(RR 级别下还加间隙锁),效果接近锁全表。这是「一条不起眼的 UPDATE 让整个系统卡死」的经典案例——UPDATE/DELETE 的 WHERE 条件必须能走索引。

3.2 显式加锁读

显式加锁读
BEGIN;
 
SELECT id, name, stock FROM products WHERE id = 1 LOCK IN SHARE MODE;  -- S 锁
SELECT id, name, stock FROM products WHERE id = 1 FOR UPDATE;          -- X 锁
 
-- 抢不到锁怎么办:NOWAIT 立即报错,SKIP LOCKED 跳过被锁的行
SELECT id, name FROM products WHERE status = 1 LIMIT 3 FOR UPDATE SKIP LOCKED;
 
-- 当前事务锁了多少行
SELECT trx_state, trx_rows_locked, trx_isolation_level
FROM information_schema.innodb_trx;
 
COMMIT;
ℹ️Playground 只有一个会话

本章大量示例需要两个会话互相阻塞才能观察,而 Playground 是单会话一次性容器(被阻塞会直接卡到超时)。上面的示例只演示语法与自身持锁情况;阻塞、间隙锁、死锁请在本地开两个 mysql 客户端窗口按章节里的时序复现。

FOR UPDATE 是「先锁再改」的悲观并发控制;SKIP LOCKED 是实现「多个 worker 抢任务队列」的利器(谁锁到算谁的,其他 worker 跳过去拿下一条)。

4. 间隙锁与临键锁(RR 级别)

在默认隔离级别 REPEATABLE READ 下,锁不仅加在记录上,还加在记录之间的空隙上,用来阻止幻读(防止有人在范围里插入新行):

索引上现有值:      5        10        15
可锁对象:
  记录锁 Record Lock:  锁住 10 本身
  间隙锁 Gap Lock:     锁住 (5,10) 这个开区间——不能往里插新值
  临键锁 Next-Key Lock:记录+前面的间隙 = (5,10],InnoDB 的默认单位

示例:

-- 会话 A(RR 级别)
BEGIN;
SELECT * FROM products WHERE id BETWEEN 5 AND 15 FOR UPDATE;
-- 锁住范围内所有记录 + 间隙
 
-- 会话 B:阻塞!id=7 落在被锁的间隙里
INSERT INTO products (id, category_id, name, price) VALUES (7, 2, 'X', 1.00);

规则速记(细节繁多,记住主干):

  • 加锁基本单位是临键锁(左开右闭区间);
  • 等值查询命中唯一索引的存在记录时,退化为纯记录锁;
  • 等值查询未命中时,退化为间隙锁;
  • 范围查询会锁住覆盖范围的一串临键锁。
⚠️间隙锁是死锁与阻塞的高发区

两个事务可以同时持有同一个间隙的间隙锁(间隙锁之间互相兼容),但都想往里 INSERT 时就互相等待对方释放——这是线上最常见的死锁模式之一(先 SELECT FOR UPDATE 不存在的行再 INSERT)。绕法:用唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE 代替「查了再插」;或将隔离级别降为 READ COMMITTED(间隙锁基本消失,第 17 章讨论代价)。

5. 死锁:分析与避免

5.1 一个最小死锁

-- 会话 A                          -- 会话 B
BEGIN;                             BEGIN;
UPDATE products SET stock=stock-1  UPDATE products SET stock=stock-1
  WHERE id = 1;   -- 锁住行1        WHERE id = 2;   -- 锁住行2
 
UPDATE products SET stock=stock-1  UPDATE products SET stock=stock-1
  WHERE id = 2;   -- 等 B 的行2      WHERE id = 1;   -- 等 A 的行1
                                   -- ERROR 1213: Deadlock found...

A 等 B、B 等 A,构成环。InnoDB 的死锁检测(innodb_deadlock_detect=ON)会立刻发现并回滚代价较小的一方,另一方继续执行——所以死锁报错不是灾难,但频繁死锁说明设计有问题。

5.2 看死锁日志

SHOW ENGINE INNODB STATUS\G
-- 找 LATEST DETECTED DEADLOCK 段落:
-- 两个事务各自「HOLDS THE LOCK(S)」和「WAITING FOR THIS LOCK TO BE GRANTED」
-- 精确到哪个索引的哪条记录,照着还原业务时序即可定位

也可以查实时锁等待:

SELECT * FROM performance_schema.data_lock_waits;   -- 谁在等谁
SELECT * FROM sys.innodb_lock_waits;                -- 可读性更好的视图

5.3 避免死锁的守则

  1. 统一加锁顺序:所有代码路径按同样的顺序更新多行/多表(比如永远按主键升序处理),环就构不成;
  2. 事务小而短:持锁时间越短,撞车概率越低;
  3. 一次锁够:批量更新用一条带 IN 的语句(按主键排序)代替循环多条;
  4. WHERE 走索引,缩小加锁范围;
  5. 热点行争抢(秒杀库存)考虑:队列串行化、Redis 预扣、或拆分热点行;
  6. 应用层捕获 1213 错误自动重试(幂等前提下)——死锁回滚是常态化事件,代码要兜住。

小结

  • 锁粒度:全局 → 表(表锁/MDL/意向锁)→ 行;模式分 S/X
  • 行锁加在索引记录上;WHERE 不走索引等于锁全表的行
  • RR 级别默认临键锁(记录 + 间隙),阻止幻读也带来更多阻塞与死锁
  • SKIP LOCKED / NOWAIT 是 8.0 处理锁竞争的新工具
  • 死锁被自动检测并回滚一方;预防靠统一顺序 + 短事务,应用要重试 1213
🎯练习
  1. 复现本章的最小死锁,在 SHOW ENGINE INNODB STATUS 输出里找到 LATEST DETECTED DEADLOCK 段落,指出各自持有和等待的锁。
  2. 在 RR 级别下用 SELECT ... WHERE id BETWEEN 5 AND 15 FOR UPDATE 锁一个范围,另开会话向间隙中 INSERT 验证被阻塞;切到 READ COMMITTED 再试一次对比。
  3. 用两个会话模拟任务队列:表里 5 条待处理任务,两个 worker 各自 SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 抢任务,观察互不阻塞。