学完这篇,你能在两个 MySQL 会话里亲手复现行锁、间隙锁和 MVCC 快照读,并在锁等待发生时,通过系统表找到是哪条语句锁住了谁。
第一步:先把环境确认清楚
锁机制只对 InnoDB 生效,MyISAM 只有表锁。先确认存储引擎和隔离级别:
SHOW ENGINES; -- 认准 InnoDB 是 DEFAULT
SELECT @@transaction_isolation; -- MySQL 8.0
SELECT @@tx_isolation; -- MySQL 5.7 早期版本用这个
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认 50 秒
准备一张实验表,主键 + 一个普通索引,后面的例子都基于它:
CREATE TABLE t_demo (
id INT PRIMARY KEY,
uid INT,
amount INT,
KEY idx_uid (uid)
) ENGINE=InnoDB;
INSERT INTO t_demo VALUES (1,10,100),(5,20,200),(10,30,300);
接下来开两个客户端窗口(A 和 B),全程手动 START TRANSACTION; 控制事务,别依赖自动提交。
第二步:观察行锁到底锁了什么
A 窗口执行完不要提交:
-- 会话 A
START TRANSACTION;
UPDATE t_demo SET amount=110 WHERE id=5;
B 窗口做两件事:
-- 会话 B:这条会卡住
UPDATE t_demo SET amount=1 WHERE id=5;
-- 另开一次:这条立刻成功
UPDATE t_demo SET amount=1 WHERE id=1;
第二条能成功,就说明锁的粒度是行而不是表。此时在第三个窗口查锁:
SELECT * FROM performance_schema.data_locks\G -- MySQL 8.0
SELECT * FROM information_schema.innodb_locks\G -- MySQL 5.7
你会看到 LOCK_TYPE=RECORD、LOCK_MODE=X,REC_NOT_GAP,即主键 id=5 上的排他记录锁。
注意:UPDATE 的 WHERE 条件如果没有走索引,InnoDB 会扫描全表并锁住扫描到的每一行,还会补上间隙锁,效果上跟锁表差不多。这是线上最容易被误判成“表锁”的场景——先看执行计划 EXPLAIN,再看锁。
第三步:复现 MVCC 快照读
MVCC 让“读”不用等“写”。在默认的 REPEATABLE READ 下继续:
-- 会话 A
START TRANSACTION;
SELECT amount FROM t_demo WHERE id=1; -- 返回 100
-- 会话 B
UPDATE t_demo SET amount=999 WHERE id=1;
COMMIT;
-- 回到会话 A
SELECT amount FROM t_demo WHERE id=1; -- 仍然是 100(快照读)
UPDATE t_demo SET amount=amount+1 WHERE id=1;
SELECT amount FROM t_demo WHERE id=1; -- 变成 1000(当前读)
原因:普通 SELECT 是快照读,走 undo log 拼出事务开始时的版本;UPDATE / DELETE / SELECT ... FOR UPDATE 是当前读,读最新已提交版本并加锁。RR 下 ReadView 在第一次快照读时生成并复用整个事务,所以两次结果一致;切到 READ COMMITTED 每次快照读重建 ReadView,第二次就能看到 999。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
第四步:间隙锁与 next-key lock
A 窗口对一段“不存在的区间”加锁:
-- 会话 A
START TRANSACTION;
SELECT * FROM t_demo WHERE id BETWEEN 6 AND 9 FOR UPDATE;
-- 会话 B:会被阻塞
INSERT INTO t_demo VALUES (7,1,1);
锁表里对应记录显示 LOCK_MODE=X,GAP。规则记住三条:
- 唯一索引等值命中已存在的行 → 只加记录锁;
- 等值命中不存在的行 → 间隙锁;
- 范围查询、非唯一索引等值 → next-key lock(记录锁 + 前面的间隙)。
注意:间隙锁只在 RR 及以上隔离级别存在,RC 下基本不加,所以从 RC 改到 RR 时,死锁概率会明显上升。另外间隙锁之间不互斥,两个事务可以对同一间隙各加一把 gap 锁,随后各自插入才互撞,死锁往往就是这么来的。
第五步:表锁与 MDL
InnoDB 里显式表锁用得少,真正天天坑人的是 MDL(元数据锁):
-- 会话 A
START TRANSACTION;
SELECT * FROM t_demo LIMIT 1; -- 持有 MDL 共享读锁直到事务结束
-- 会话 B:卡住
ALTER TABLE t_demo ADD COLUMN c INT;
-- 第三个窗口
SELECT * FROM performance_schema.metadata_locks;
一个没提交的长事务就能让 DDL 排不上队,后面的查询再排队,表现为“整张表突然查不动了”。
注意:所有 DDL 都会隐式提交当前事务;LOCK TABLES 也会隐式提交,且用完后必须 UNLOCK TABLES,别在事务里随手加。
第六步:锁等待时怎么定位
SELECT * FROM sys.innodb_lock_waits\G -- 谁等谁,一目了然
SELECT * FROM information_schema.innodb_trx\G -- 看 trx_started、trx_query
SHOW ENGINE INNODB STATUS\G -- 搜 LATEST DETECTED DEADLOCK
看 sys.innodb_lock_waits 里的 waiting_query(被卡的语句)和 blocking_query(持锁的语句),以及 blocking_trx_id,多数问题当场就能定位。处理办法是让持锁事务提交或回滚,而不是急着 KILL 连接。
小结
- 锁只认 InnoDB;先确认隔离级别和索引,再谈锁行为。
- 普通
SELECT 是快照读走 MVCC,FOR UPDATE 与写操作是当前读并加锁。
- RR 下快照在第一次读时生成并全事务复用,RC 下每次重建。
- 唯一索引命中只加记录锁,范围/未命中会带间隙锁或 next-key lock。
- WHERE 不走索引会退化成大面积加锁,这是“假表锁”的元凶。
- 长事务会把 MDL 拖住导致 DDL 与查询排队,排查用
sys.innodb_lock_waits 和 SHOW ENGINE INNODB STATUS。