MySQL锁机制:行锁、间隙锁与死锁排查
砚边小兔
9 分钟阅读
判断先说:InnoDB 的锁全部建立在索引之上,不在数据行本身。理解锁行为的核心,是把"我锁了哪一行"翻译成"我锁了索引上的哪些记录与间隙"。行锁、间隙锁、临键锁的差异,决定了并发写入会不会互相阻塞,也决定了会不会出现死锁。很多线上"更新卡死"的事故,查到底都是锁的范围比预期大得多。
锁加在索引上,不在数据行上
这是最容易误解的一点。一张没有合适索引的表,执行 UPDATE ... WHERE 时,存储引擎无法定位到少数记录,只能退化成全表扫描,并对扫描到的每一行加锁,实质上锁住了整张表。这正是很多"小更新却卡死全表"的根因——开发者以为只改一行,数据库却给整张表上了锁。
-- 假设 orders 表在 user_id 上有索引,在 status 上没有索引
UPDATE orders SET status = 'paid' WHERE user_id = 1001; -- 只锁 user_id=1001 命中的行
UPDATE orders SET status = 'paid' WHERE status = 'new'; -- 锁住大量甚至全部行
经验上,排查锁问题第一步永远是看 WHERE 条件和对应的索引:能走唯一索引的等值更新,锁范围最小;走普通二级索引,会带上间隙;走不上索引,就是灾难。运维时可以用 EXPLAIN 确认是否走了索引,再决定是否要先补索引。
三种锁的边界
记录锁(Record Lock):锁住某条具体的索引记录。唯一索引的等值匹配命中存在,只加记录锁,不加间隙。它保护的是"这条记录不被别的事务改或删"。
间隙锁(Gap Lock):锁住一条记录与下一条记录之间的"空隙",防止其他事务往里插入,目的不是保护已有数据,而是防止幻读。间隙锁之间互不冲突——两个事务可以同时持有同一段间隙的间隙锁,因为它们都只是"禁止插入",不排斥彼此。这点很反直觉:很多人以为两个事务都锁了同一段,会冲突,实际不会。
临键锁(Next-Key Lock):等于记录锁加它前面的间隙锁,是 InnoDB 在可重复读(RR)隔离级别下的默认加锁方式。它锁定的是一个左开右闭的区间 (a, b]。
索引值: 10, 20, 30
查询 WHERE id <= 20 在 RR 下可能锁定的区间:
(-∞, 10], (10, 20], (20, 30] -- 含 30 是为了防止插入 21~29 造成幻读
可重复读下用临键锁来杜绝幻读;如果把隔离级别降到读已提交(RC),InnoDB 会关闭间隙锁(外键和唯一性检查除外),并发度更高,但会丢失防幻读能力。所以隔离级别的选择直接改变了锁的粒度,不是可以随便调的参数。
死锁为什么发生
死锁是两个事务各自持有对方需要的锁,又同时等待对方释放,形成环路。本质原因是加锁顺序不一致。只要两个事务以不同顺序访问同一组资源,就可能在某一刻相互等待。
事务 A: UPDATE account SET bal=bal-10 WHERE id=1; -- 持有 id=1 的锁
事务 B: UPDATE account SET bal=bal-10 WHERE id=2; -- 持有 id=2 的锁
事务 A: UPDATE account SET bal=bal+10 WHERE id=2; -- 等待 id=2
事务 B: UPDATE account SET bal=bal+10 WHERE id=1; -- 等待 id=1 → 死锁
只要让所有事务按同一固定顺序访问资源(例如永远先锁 id 小的账户再锁 id 大的),这种交叉等待就不会出现。这是工程上最朴素也最有效的防死锁手段,比事后排查更可靠。
排查死锁的现场命令
-- 最近一次死锁的明细,含两个事务各自持有的锁与等待的锁
SHOW ENGINE INNODB STATUS;
-- 当前被阻塞的锁等待(8.0 之后为 performance_schema.data_lock_waits)
SELECT * FROM performance_schema.data_lock_waits;
-- 开启死锁详细日志,便于事后复盘
SET GLOBAL innodb_print_all_deadlocks = ON;
SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段落会给出事务 A、B 各自执行到的 SQL、已经持有的锁(HOLDS THE LOCK(S))和正在等待的锁(WAITING FOR THIS LOCK)。对照这段输出基本能还原加锁顺序,进而定位是哪些 SQL、以什么顺序在抢同一批资源。线上建议在非高峰先打开 innodb_print_all_deadlocks,把每次死锁都落到错误日志,否则只能看到仅存的这一次,复盘样本太少。
缩短持锁时间比规避更现实
业务层能做的是:把锁冲突概率降到最低,并让锁尽早释放。
- 事务尽量小,把非数据库操作(远程调用、本地计算)移到事务外。一个事务里调了外部接口,接口慢了三秒,这三秒里的锁全部白占。
- 热点行写入做合并,例如把多次
UPDATE ... SET cnt=cnt+1 攒批成一次,减少同一行的加锁次数。
- 对"先查后改"的并发,考虑用
SELECT ... FOR UPDATE 显式加锁,让阻塞变为有序等待,而不是各自读到旧值后写回覆盖。
- 设置合理的
innodb_lock_wait_timeout,让一个被卡住的事务快速失败回滚,避免雪崩式堆积。默认值偏长时,一个慢事务就能拖住一大片。
线上出现大量锁等待时,优先定位持有锁时间最长、持锁最多的那个会话,而非盲目杀掉等待方。真正阻塞源往往是那个慢 SQL 或长事务,把它解决掉,等待链才会断。用 SHOW PROCESSLIST 或 information_schema.INNODB_TRX 能直接看到谁持有了长时间未提交的事务。
一个常被忽略的点:增删改都加锁
很多开发者只关心 SELECT ... FOR UPDATE 的锁,却忘了普通的 UPDATE、DELETE、INSERT 同样加锁。INSERT 会在插入间隙上加插入意向锁,与间隙锁可能冲突;DELETE 和 UPDATE 的加锁规则和 SELECT ... FOR UPDATE 基本一致。所以分析锁问题要覆盖所有写操作,不能只看显式加锁的语句。
监控锁等待的常用视图
光知道原理还不够,出问题时得能快速看到现场。除了 SHOW ENGINE INNODB STATUS,information_schema 下的几张表能帮你在不停机的情况下观察锁:INNODB_TRX 列出当前所有活跃事务及其开始时间,能一眼看出哪个事务开得最久;performance_schema.data_locks 显示当前持有的锁;data_lock_waits 显示谁在等谁。定期把运行超过若干秒的事务捞出来告警,能在锁等待堆积成事故前就把长事务干掉。很多团队等到连接池满了才去查,其实平时就该监控长事务占比这个指标,它比死锁次数更能预警。
锁与隔离级别的联动
同样的 SQL,在可重复读和读已提交下加的锁可能完全不同。前者默认临键锁、带间隙锁,后者关闭间隙锁。这意味着把隔离级别往下降,能减少锁的范围、提升并发,代价是可能出现幻读。选型时要权衡业务能否接受幻读,而不是无脑追求高隔离级别。对大多数只读或弱一致的报表类查询,用读已提交、避免不必要的显式加锁,都能显著减少锁争用。理解隔离级别如何改变锁粒度,是做好并发控制的前提。
死锁复盘要留痕
线上死锁复盘要养成习惯:把 SHOW ENGINE INNODB STATUS 的死锁片段连同两条 SQL 存下来,建立小档案。同一对表反复死锁,往往说明加锁顺序在多处不一致,应从代码层面统一访问顺序,而不是每次手动杀会话。临时绕过能止血,但根因不改,死锁会周期性复发。把死锁当作设计缺陷的信号,而非偶发运气问题,系统的锁行为才会越来越可控。
浏览 1
点赞 0
评论 0