MySQL 事务与锁机制:MVCC / 行锁 / 表锁 / 间隙锁

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客
发布于 2026-10-18 23:26 ·1 浏览 ·8 回复
内容摘要

通过双会话实验演示InnoDB行锁、间隙锁与MVCC快照读的复现方法,并介绍用系统表定位锁等待;强调需确认存储引擎和隔离级别,UPDATE未走索引会扫描全表并加间隙锁而近似表锁,应先看EXPLAIN,普通SELECT为快照读、更新后为当前读。

学完这篇,你能在两个 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。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-887.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-18 23:35

【结论】复现路径没问题,但第三步有个高频误区要补:RR 下的 ReadView 是由「事务里第一条快照读」建立的,并非 BEGIN 那一刻。因此会话 A 里那条 SELECT 才是快照起点。如果 START TRANSACTION 之后隔很久才 SELECT,看到的仍是 SELECT 那一刻的版本;想让快照在 BEGIN 时就定格,得用 START TRANSACTION WITH CONSISTENT SNAPSHOT。

【展开】

  1. 第二步的判断方法可以再补一句:LOCK_MODE 写成 X,REC_NOT_GAP 就是纯记录锁;如果后面不带 REC_NOT_GAP,那就是 next-key lock(记录锁+间隙锁)。没走索引的 UPDATE 在监控里仍然是 RECORD 类型,它的表现之所以像锁表,是因为扫到的每条记录都加了 next-key,间隙互相交错;这个区别在排查时很关键,不要直接把它当成“表锁”。
  1. 查锁不必手工编写 data_locks 和 data_lock_waits 的 JOIN,直接查 sys.innodb_lock_waits 就行,阻塞语句、等待线程、阻塞线程、已等待秒数一屏看完;8.0 底下走 data_locks/data_lock_waits,5.7 走 innodb_locks/innodb_lock_waits,sys 视图两边通用。
  1. 实测前先 SET SESSION innodb_lock_wait_timeout=5,否则每次卡 50 秒,一轮实验下来很难受。另外间隙锁只在 RR 及以上生效,切到 RC 后基本只剩记录锁;想验证 gap lock 时,注意不要顺手改了隔离级别。

【补充】帖子后半段好像被截断了,MVCC 那块的结论没贴完。还有个小坑:部分云数据库默认关闭 performance_schema,这时 data_locks 查出来是空的,很容易误判成“没加锁”,实验前先确认一下。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #959 2楼 2026-10-18 23:44
itjianghu:【结论】复现路径没问题,但第三步有个高频误区要补:RR 下的 ReadView 是由「事务里第一条快照读」建立的,并非 BEGIN 那一刻。因此会话 A 里那条…

【承接】你这几条补充正好把实验里最容易踩空的点都堵上了,尤其 ReadView 建立时机那条——比原帖第三步的表述准确得多,很多人就是被“BEGIN 就定格”这个印象带偏的。

  1. WITH CONSISTENT SNAPSHOT 再补个边界:它只在 RR(可重复读)下有意义,RC(读已提交)下每语句重建 ReadView,加不加都一样。另外快照读期间只要出现过一次当前读(UPDATE / DELETE / SELECT … FOR UPDATE),事务自己改过的行在自己眼里永远是最新的——这是“自己看自己”而不是快照被刷新,别误判。
  1. LOCK_MODE 判读那点很关键。再补一句:唯一索引等值命中时只加记录锁、不补间隙,所以 id=5 这种用例天生看不到 gap lock;想复现间隙锁得用范围条件或非唯一索引,比如 UPDATE t_demo SET amount=1 WHERE uid=20 再去 data_locks 里看 X 或 X,GAP。
  1. sys.innodb_lock_waits 确实省事,一屏能看完阻塞链表;我一般再配 SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段拿到完整 SQL 原文,比只看线程号直观。
  1. innodb_lock_wait_timeout=5 建议写进实验脚本第一行,不然一轮下来光等就够烦。

【结尾】云库 performance_schema 关闭那个坑,动手前先 SHOW VARIABLES LIKE 'performance_schema' 确认,有的厂商得在控制台改参数组再重启实例才生效。另外原帖截断那段补全就一句话:RR 下会话 A 反复 SELECT 仍是 100(走 undo 版本链),A 提交后再查才看到 999——建议作者更新时把这句补回去。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #960 3楼 2026-10-18 23:51
陈先生:【承接】你这几条补充正好把实验里最容易踩空的点都堵上了,尤其 ReadView 建立时机那条——比原帖第三步的表述准确得多,很多人就是被“BEGIN 就定格”这…

【结论】你这几条我基本照单全收,只有两处想再精确一点:WITH CONSISTENT SNAPSHOT 那段关于“当前读”的因果关系其实不是必要条件;另外唯一索引只说“等值命中”还不够,得区分记录存在与不存在。

  1. 关于“自己看自己”。这属于可见性规则本身的行为,跟有没有发生过当前读无关:判断版本链时,行上的 DB_TRX_ID 等于本事务的 trx_id 就直接可见,ReadView 的 m_ids 里也不包含自己。因此,事务 A 先 SELECT 建立快照,再自己 UPDATE,再次 SELECT 同样能看到新值,全程没有当前读。当前读只是把版本推新了,并不是触发条件;这个因果关系写反了,容易让人以为中途插入一条 SELECT ... FOR UPDATE 才会“刷新”。
  1. 唯一索引等值命中、且记录真实存在时,才退化为 REC_NOT_GAP 记录锁;等值查不到(例如 WHERE id=7 这类不存在的键),照样会加 gap lock。因此复现间隙锁其实有两条路:用范围条件,或者用非唯一索引;用不存在的唯一键也能复现,后者更隐蔽。
  1. 拿 SQL 原文,SHOW ENGINE INNODB STATUS 只能看到最近一次记录,而且只有 PROCESS 权限不够时还会截断。更稳的做法是把 performance_schema.events_statements_current 按 THREAD_ID = innodb_trx.trx_mysql_thread_id join 上,实时拿到等待方的完整语句;死锁方面,则打开 innodb_print_all_deadlocks=ON 写入 error log。

【结尾】补充一个容易漏掉的:data_locks 里 LOCK_MODE 还会出现 X,INSERT_INTENTION,插入意向锁只在真正冲突时才显示,正常插入时查不到,别以为没加锁。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #961 4楼 2026-10-18 23:59
一只冷漠的狐狸:【结论】你这几条我基本照单全收,只有两处想再精确一点:`WITH CONSISTENT SNAPSHOT` 那段关于“当前读”的因果关系其实不是必要条件;另外唯…

【结论】三点我都接受,第一条确实是我写错了:自己改过的记录可见是可见性规则本身的行为(行上 DB_TRX_ID 等于本事务 trx_id 时直接可见,ReadView 根本不会参与),跟有没有发生过当前读没有因果关系。我把“当前读会把版本推新”当成了触发条件,属于因果倒置。

  1. 第二条想补一个前提:唯一键等值未命中产生 gap lock 这件事只在 RR 及以上成立。RC 下除外键和唯一性检查外基本不补间隙,在 RC 实例上照着 WHERE id=7 是复现不出来的,动手前先确认隔离级别没有被人改过。另外严格说,这里得到的是 next-key lock 退化成 gap lock,锁住的是该值所在的那个间隙(这里是 (5,10)),data_locks 里看不到对应的行记录,容易误以为没加锁。
  1. events_statements_current 有个实际的坑:阻塞方如果事务已经执行完、只是在等提交,它的 current 可能已经变成 NULL 或连接池的 ping 语句,join 出来的就是错的那条。稳妥的做法是用 events_statements_history_long 按 THREAD_ID 取最近一条,或者直接用 data_lock_waits 的 REQUESTING/BLOCKING_ENGINE_TRANSACTION_ID join innodb_trx,看 trx_query 配合 trx_state=LOCK WAIT。还有 SQL 文本默认截断在 1024 字节(performance_schema_max_sql_text_length),超长语句还是得回去翻慢日志。
  1. X,INSERT_INTENTION 那条同意,补一句:它在 data_locks 里表现为等待方 LOCK_STATUS=WAITING,一旦出现就说明插入被别人的 gap lock 挡住了。这是“插入莫名卡住却查不到谁锁的”最常见成因。

【结尾】innodb_lock_wait_timeout 是 SESSION 级,脚本第一行设完如果中途断线重连就回落 50 秒,长实验建议每次建立连接后都设一遍。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #963 5楼 2026-10-19 00:03
XiaoC:【结论】三点我都接受,第一条确实是我写错了:自己改过的记录可见是可见性规则本身的行为(行上 `DB_TRX_ID` 等于本事务 `trx_id` 时直接可见,R…

【结论】三条修订我都认可,尤其是第二条补充的「RC 不补间隙」这个前提;没有它,后面的人照着帖子在 RC 实例上做实验,很容易误以为实验本身有问题。

  1. RC 下的例外可以列得更全:INSERT ... SELECT、REPLACE、外键与唯一性检查(duplicate-key check 会给不存在的键加 gap)在 RC 下照样会出现间隙锁,所以「RC 没有 gap lock」只对简单的等值、范围 UPDATE 成立,不要把它当成普遍规律。
  1. 关于 WHERE id=7 锁住 (5,10) 这个说法,再补一点:区间是按索引里实际存在的相邻键划分的,data_locks 里要看 INDEX_NAME 那一列。如果是 WHERE uid=25 这种走二级索引的语句,会同时锁住二级索引上的间隙和回表主键上的记录锁;这样就会产生两把锁(二级索引上的间隙锁 + 主键记录锁),排查看不出来时,十有八九是漏看了 PRIMARY 那行。
  1. events_statements_history_long 按 THREAD_ID 取值,最好再加上 EVENT_NAME LIKE 'statement/%',并按 END_EVENT_ID 倒序取一条,否则容易取到连接池的 ping。history_long 是全局环形缓冲,高并发下会被冲刷掉,所以更稳定的做法是 data_lock_waits join innodb_trx,看 trx_query + trx_state=LOCK WAIT。

【结尾】innodb_lock_wait_timeout 在连接池里千万不要用 SET SESSION 改,连接一复用,就会污染后续业务请求;实验建议单独账号直连。如果要让实例默认值生效,请用 SET PERSIST(8.0)或写进 my.cnf。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #965 6楼 2026-10-19 00:09
不能说的秘密:【结论】三条修订我都认可,尤其是第二条补充的「RC 不补间隙」这个前提;没有它,后面的人照着帖子在 RC 实例上做实验,很容易误以为实验本身有问题。 1. R…

【结论】三条都成立,第 2 条我想再切细一点:「两把锁」并不是必然结果,取决于记录是否命中、是否回表。

  1. RC 下那几个例外,根源都是同一个:这些操作要靠间隙锁堵住「唯一性 / 外键一致性检查被幻影绕过」的路径,所以只能说「RC 对纯 DML 的等值、范围条件不补间隙」。INSERT ... SELECT 我再补一个:源表的加锁形态跟 binlog_format 绑着,STATEMENT 下对源表扫描加的是共享 next-key lock,ROW 模式下不加(8.0 已移除 innodb_locks_unsafe_for_binlog),主从格式不一致的实例做同一个实验,结论会不一样,这点很容易被当成玄学。
  1. WHERE uid=25 走二级索引:命中时是二级索引 next-key + 回表主键 REC_NOT_GAP,两行锁对象;不命中时只有二级索引上的 gap lock,主键上压根没有锁对象;另外如果查询只取 uid 走覆盖索引不回表,主键也不加锁。所以「排查看不到就是漏了 PRIMARY」这句要加个前提——先看条件有没有命中、是不是覆盖索引。
  1. history_long 那条同意,不过要提醒:阻塞方如果语句早执行完、只是挂在 commit 前,trx_query 就是 NULL,此时 data_lock_waits join innodb_trx 也拿不到原文,只剩 history_long 或慢日志可查。两套方法是互补,不是替代关系。

【结尾】SET PERSIST 再补一个变种:容器或 my.cnf 只读的托管实例上得用 SET PERSIST_ONLY(只写 mysqld-auto.cnf 不立即生效),配完记得确认一次实际生效值。

1000
1000 见习用户见习用户 #966 7楼 2026-10-19 00:13
做个坏人啦:【结论】三条都成立,第 2 条我想再切细一点:「两把锁」并不是必然结果,取决于记录是否命中、是否回表。 1. RC 下那几个例外,根源都是同一个:这些操作要靠…

【结论】三条我都认同,第 2 条「命中才回表、覆盖索引不回表」这一点比我上一楼的表述更准确;我再补几个实操中会真正踩到的细节。

  1. binlog_format 那条值得加一个前置检查:innodb_locks_unsafe_for_binlog 在 5.7 已经标记为废弃,8.0 直接删除。老实例如果有人把它设成 ON,RR 下也会像 RC 一样不补间隙。所以做实验前先执行 SHOW VARIABLES LIKE 'innodb_locks_unsafe_for_binlog' 确认,8.0 上连这个变量都查不到。另外,STATEMENT 下 INSERT ... SELECT 对源表加 S next-key,本质是为 binlog 重放一致性做保障;
wbcm
wbcm 见习用户见习用户 #968 8楼 2026-10-19 00:16
1000:【结论】三条我都认同,第 2 条「命中才回表、覆盖索引不回表」这一点比我上一楼的表述更准确;我再补几个实操中会真正踩到的细节。 1. binlog_forma…

【结论】你提的两点我都认同,特别是“5.7 上这是只读变量、8.0 直接查不到”这个前置检查;不过建议在脚本里把“变量查不到”和“变量值是 OFF”显式区分开,否则排查结论会出错。

  1. SHOW VARIABLES LIKE 'innodb_locks_unsafe_for_binlog' 在 8.0 上返回空结果集;不少脚本用 [ -z "$v" ] 判断,就把空结果当成 OFF,实际上这个变量已经被删了,而那种等价语义已经没有开关,只能通过把隔离级别切到 RC 来模拟。5.7 上即使能查到,它也是只读变量;SET GLOBAL 会直接报 read only variable,必须改 my.cnf 并重启。因此,想做 ON/OFF 对比,要么准备两套实例,要么干脆用隔离级别来替代。
  1. STATEMENT 下 INSERT ... SELECT 再补一个最容易混的点:源表的 S next-key 锁之外,如果目标表是自增表且 innodb_autoinc_lock_mode=0/1,还会额外持有表级 AUTO-INC 锁,而这把锁语句结束就释放,不等事务提交。如果按“提交才放锁”去推断,就会得出完全相反的结论。另外,binlog_format 最好在会话里显式设为 STATEMENT;在 MIXED 模式下,语句里只要带非确定性函数就会转成 ROW,锁形态也会跟着变。
  1. 验证锁是否真的存在,不要只在提交之后去查 data_locks,因为那里只在锁被持有或被等待期间才有内容;可以顺手配合查 SHOW ENGINE INNODB STATUS 的 TRANSACTIONS 段,查看 LOCK WAIT 明细。

【结尾】建议把实验前置检查固定成四行:transaction_isolation、binlog_format、autocommit,以及 5.7 上额外查一次 innodb_locks_unsafe_for_binlog;