在MySQL InnoDB的知识体系里,锁机制绝对是最能检验“到底懂没懂”的一块。很多同学基础其实不错,SQL也会写,但一聊到记录锁、间隙锁、临键锁、意向锁,就只能背概念;我也见过线上死锁排查半天,最后发现是几条UPDATE的WHERE条件没走索引,锁的范围从一个点变成整张表。这类问题靠看文档很难形成直觉,所以我决定直接把环境搭出来,用真实的事务并发操作一遍,亲眼观察每一类锁什么时候卡住、什么时候放行、锁的范围到底有多大。这篇内容就是完整实验过程的记录,包含建表语句、两个会话的SQL操作顺序、阻塞现象的观测方法,以及后来在排查中踩过的坑,适合正在准备数据库面试的同学,也适合需要处理线上死锁和慢事务的后端工程师、DBA参考。
1. 实验环境与整体设计思路
1.1 为什么非要把锁机制“跑”出来
InnoDB锁机制的理论资料其实很多,官方文档也写了Record Lock、Gap Lock、Next-Key Lock这些概念,但只靠读,很难建立真正的判断力。因为锁是“动态”的东西,它在事务执行过程中产生,在事务提交或回滚后释放,两个事务互相排队、互相等待的现象只有在真实并发场景里才会出现。这也是为什么面试官爱问锁机制——光背书很难应付追问,比如“你确定这条UPDATE只锁这一行吗?为什么有时它会锁表?”“间隙锁到底是在哪段间隙上?”“两个事务同时操作不同行,为什么还会死锁?”这些问题,只有亲手跑过实验,才能答得有底气。
我这次的目的很直接:把InnoDB里常见的锁类型,通过两个会话的SQL操作逐个触发出来,然后用系统自带的事务视图、锁等待视图去验证锁的存在和范围。整个过程不依赖任何特殊工具,一个MySQL实例加两个终端窗口就够。
1.2 环境准备:一条命令搞定MySQL实例
实验环境我用了MySQL 8.0,因为8.0的performance_schema里提供了更直观的data_locks视图,能看到表级锁和记录锁的明细,比5.7时代查innodb_locks要方便。如果你本地还没装MySQL,又想快速测试,用Docker是最省事的方案,一条命令就能起一个干净的实例:
bash复制docker run --name mysql-lock-test -p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=123456 -d mysql:8.0
如果你本机已经有MySQL在跑3306端口,建议把映射端口改成3307再启动,或者直接连本地已有的MySQL。启动后进入容器执行客户端:
bash复制docker exec -it mysql-lock-test mysql -uroot -p
这里有一个新手很容易踩的坑:用Navicat或Workbench这类图形客户端开两个查询窗口,看起来是“两个会话”,但如果你没手动BEGIN,客户端可能默认处于自动提交模式,很多语句瞬间提交,锁根本观察不到。所以我更推荐直接用命令行开两个终端连接同一个实例,或者用图形客户端但明确在每条测试前先执行BEGIN。
1.3 测试表设计:留一个“空位”方便观察间隙锁
为了把锁机制验证清楚,我建了一张学生课程成绩表,字段比较简单,但索引刻意做了区分:主键、普通索引各一个。这样既能测主键上的记录锁,也能测普通索引对锁范围的影响。
sql复制CREATE DATABASE IF NOT EXISTS lock_test DEFAULT CHARSET utf8mb4;
USE lock_test;
CREATE TABLE student_score (
id INT NOT NULL AUTO_INCREMENT,
student_id INT NOT NULL COMMENT '学生编号',
course_id INT NOT NULL COMMENT '课程编号',
score TINYINT NOT NULL COMMENT '成绩',
PRIMARY KEY (id),
KEY idx_student_id (student_id),
KEY idx_course_id (course_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生课程成绩表';
插入数据时我特意留了空位:
sql复制INSERT INTO student_score (id, student_id, course_id, score) VALUES
(1, 1001, 201, 90),
(2, 1002, 201, 85),
(4, 1001, 202, 70),
(5, 1003, 201, 95),
(7, 1002, 202, 80);
id值有1、2、4、5、7,中间缺了3和6。这个“缺号”是故意的,后面测间隙锁时,必须找一个“当前不存在但区间存在”的记录去锁,才能观察锁落在哪个范围。如果表中没有空位,间隙锁实验根本做不出来。
1.4 实验方法论:两个会话加三个观测语句
实验统一遵循一个套路:会话A先做某个加锁操作,保持事务不提交;会话B再执行另一个操作,观察它是立即执行,还是卡住等待。卡住等待,说明锁冲突;立即执行,说明锁兼容或锁范围没覆盖到。
为了精确判断“卡住”是锁等待,而不是SQL本身慢,我建议一边执行一边查系统视图。三个观测语句是实验的“显微镜”:
sql复制-- 1. 查看当前所有事务状态
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx\G
sql复制-- 2. 查看锁等待关系(MySQL 5.7及以上有sys库)
SELECT * FROM sys.innodb_lock_waits\G
sql复制-- 3. 查看锁明细(8.0推荐,5.7用information_schema.innodb_locks)
SELECT * FROM performance_schema.data_locks\G
第一个视图看事务状态:如果trx_state是LOCK WAIT,说明这个事务正在等锁;第二个视图直接列出谁在等谁、等的是哪把锁;第三个视图能看到锁的类型、锁模式、锁所在的库表,以及是记录锁还是间隙锁。这套组合拳基本能满足所有锁实验的观测需求。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 行级锁实测:共享锁与排他锁的互斥规则
2.1 共享锁与共享锁之间为什么能同时存在
InnoDB行级锁有两类基础类型:共享锁(S锁)和排他锁(X锁)。S锁之间是兼容的,S锁和X锁互斥,X锁和任何锁都互斥。这是理论,我们来亲手验证。
先开两个会话窗口,都执行:
sql复制-- 会话A
BEGIN;
SELECT * FROM student_score WHERE id = 1 LOCK IN SHARE MODE;
sql复制-- 会话B
BEGIN;
SELECT * FROM student_score WHERE id = 1 LOCK IN SHARE MODE;
执行B的查询时,SQL立刻返回结果,没有任何阻塞。这说明两个S锁可以同时存在,多个事务可以同时读同一行数据,而且都带着“防止数据被修改”的意图。
那如果会话B想在持有S锁的同时,去更新这行数据呢?执行:
sql复制-- 会话B
UPDATE student_score SET score = 89 WHERE id = 1;
这一下就卡住了。因为UPDATE需要获取X锁,而会话A持有S锁没释放,S锁和X锁互斥。所以共享锁的实际语义是:我允许大家一起来读,但不允许任何人对这条记录动手。
2.2 排他锁的“独占”体现在哪里
接着验证X锁。先让会话A执行:
sql复制-- 会话A
BEGIN;
UPDATE student_score SET score = 88 WHERE id = 2;
再让会话B执行:
sql复制-- 会话B
BEGIN;
SELECT * FROM student_score WHERE id = 2 FOR UPDATE;
这条SELECT会阻塞。因为FOR UPDATE请求的是X锁,而会话A的UPDATE已经在这行上持有了X锁。X锁与X锁互斥。
那会话B换成普通SELECT呢?
sql复制-- 会话B
SELECT * FROM student_score WHERE id = 2;
这条不会阻塞,而且它读到的还是UPDATE之前的旧值(如果会话A尚未提交)。这就要引出InnoDB一个非常重要的机制:普通SELECT是快照读,走的是MVCC多版本控制,压根不去申请当前行的锁,而是直接读历史版本;而SELECT ... FOR UPDATE、UPDATE、DELETE这类语句是当前读,必须拿最新版本,所以必须申请X锁。理解了这一点,很多“为什么我查询不卡,更新却卡了”的疑问就迎刃而解。
建议你在实验时顺便观察一下锁等待时的事务状态,执行:
sql复制SELECT trx_id, trx_state, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx\G
会看到会话B对应的事务trx_state为LOCK WAIT,trx_query里是正在等待的那条SQL。这就是后面排查线上锁等待的基础。
2.3 锁等待事务的判断方法
判断一个事务到底是不是在等锁,不能只靠“SQL卡住了”这个现象,还要确认卡在哪。我自己排查线上问题时就遇到过好几次:SQL慢不是因为锁,是因为数据量太大扫描耗时。所以建议把上面的事务视图当成标准动作,每次实验卡住后都查一下。
另外一个实用技巧是看锁等待时间。InnoDB有一个参数innodb_lock_wait_timeout,默认50秒,代表一个事务等待锁超过50秒就直接报错。实验时为了不干等,可以临时调小一点:
sql复制SET SESSION innodb_lock_wait_timeout = 5;
这样如果锁冲突,5秒后就报ERROR 1205,不用一直等。但要注意,这只是当前会话生效,而且线上环境不建议把全局值调太小,否则大事务稍微排队就大量报错。我在实验会话里调小它,纯粹是图方便。
3. 锁的作用范围验证:记录锁、间隙锁与临键锁
3.1 记录锁:只锁命中行,不锁身边行
记录锁(Record Lock)是锁在索引记录上的行锁,最常见的触发方式是等值查询命中唯一索引或主键。
会话A执行:
sql复制BEGIN;
SELECT * FROM student_score WHERE id = 2 FOR UPDATE;
此时会话B执行:
sql复制UPDATE student_score SET score = 60 WHERE id = 2;
这条UPDATE会阻塞,因为id=2这一行已经被会话A的X锁锁住了。但同一个事务里,如果会话B去更新另一行呢?
sql复制UPDATE student_score SET score = 60 WHERE id = 4;
这条立即执行成功,完全不阻塞。说明记录锁锁定的范围就是“id=2这一条索引记录”,它不会顺手把周围的行也锁住。这是InnoDB行锁的核心特点:锁是锁在索引项上的,不是锁在整张表的物理数据块上。
在MySQL 8.0里,还可以通过data_locks看到这把锁的完整信息:
sql复制SELECT OBJECT_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks\G
关键看LOCK_TYPE是RECORD,LOCK_MODE是X,REC_NOT_GAP,LOCK_DATA是2。X表示排他锁,REC_NOT_GAP表示这是一个只锁记录本身、不锁间隙的记录锁。
3.2 间隙锁:等值查不存在的记录,锁住的是“空位”
间隙锁是InnoDB里特别容易让人懵的一个概念。举一个最经典的场景:我这张表的id有1、2、4、5、7,唯独没有3。现在会话A执行:
sql复制BEGIN;
SELECT * FROM student_score WHERE id = 3 FOR UPDATE;
注意,id=3这一行不存在,普通理解下“没有行,也就没有锁”。但结果会很反直觉:会话B往这个位置插入数据时,直接被卡住。
sql复制-- 会话B
INSERT INTO student_score (id, student_id, course_id, score)
VALUES (3, 1001, 201, 80);
这条INSERT会阻塞。原因是会话A的SELECT虽然没有命中任何记录,但InnoDB在RR隔离级别下,为了阻止其他事务往这个“空位”里插入数据,会在id=2和id=4之间的间隙上加上一把间隙锁。间隙锁锁的是一个范围区间,不是具体某一行,它的作用是防幻读。
这个间隙的范围可以理解为(2, 4),即不包含2也不包含4,因为2和4两条记录本身并没有被锁。可以验证一下:会话B执行UPDATE id=2或id=4,都不会被阻塞;但INSERT id=3就会卡住。这也是为什么我建表时故意留了一个缺号,没有缺号的话这个实验根本没法做。
间隙锁只在RR隔离级别下生效,如果把事务隔离级别改成RC,间隙锁会失效,但同时也意味着可能出现幻读。面试里经常问“RR和RC区别”,间隙锁就是要害。
3.3 临键锁:范围查询时锁定的“记录加间隙”
临键锁(Next-Key Lock)可以理解为“记录锁 + 间隙锁”的组合。它锁定的是当前记录以及记录之前的间隙,所以它是一个左开右闭的区间,比如(2, 4]代表从2到4之间的开区间,同时包含4这条记录。
实践中最容易触发临键锁的场景是范围查询。我们做一个回退实验:会话A执行:
sql复制BEGIN;
SELECT * FROM student_score WHERE id BETWEEN 2 AND 4 FOR UPDATE;
然后会话B尝试插入id=3:
sql复制INSERT INTO student_score (id, student_id, course_id, score)
VALUES (3, 1001, 201, 80);
不用想,肯定阻塞。因为范围查询覆盖了(2,4)这个间隙。
再试下插入id=6?根据表里现有记录,6在id=5和7之间的间隙里。这条会不会阻塞,取决于范围查询执行计划实际扫描到的边界。我这次实验中,BETWEEN 2 AND 4走了主键范围,最终锁到id=4这条记录后,并不会把(4,5)这个间隙也锁上,所以insert id=6是可以成功的。但如果你把范围改成BETWEEN 2 AND 5,那id=5也会被作为一个边界记录处理,情况又不一样。
这里想说明一个重点:锁的范围不是根据你的WHERE条件语义来划的,而是根据索引扫描实际访问到的记录和间隙来定的。同样的SQL,走不同索引,锁的范围可能天差地别。所以做锁分析时,一定要先用EXPLAIN看执行计划,确认它到底扫了哪些索引段。
临键锁的完整表达式是X锁加在前一个记录和当前记录之间的间隙上。比如锁住(2,4],代表不仅锁住了4这条记录本身,还防止任何会话把新数据插入(2,4)这个区间。这套机制是防止幻读的核心:即使在RR隔离级别下事务两次SELECT的结果严格一致。
3.4 没有索引的UPDATE,为什么会变成“锁全表”
这一节算是我在线上踩过印象最深的一个坑。有一次业务反馈某个更新接口特别慢,大量请求堆积,我去查的时候发现一条UPDATE把整张表的事务全堵住了。原因很简单:UPDATE的WHERE条件字段没有索引。
InnoDB的行锁虽然是锁在索引上的,但如果一条UPDATE语句的WHERE条件没有任何索引可用,它就只能走全表扫描。全表扫描意味着存储引擎要逐行读取、逐行判断是否满足条件,而在这个过程里,InnoDB会悲观地给所有扫描过的记录都加上锁。结果就是,一条本该只更新几行的SQL,实际把全表记录的锁都拿了一遍,其他事务想更新任何一行都会被堵住。
可以做个模仿实验:先给score字段(无索引)做一个等值更新:
sql复制-- 会话A
BEGIN;
UPDATE student_score SET score = 50 WHERE score = 90;
注意,这张表的score字段没有索引。此时去data_locks里看,你会发现大量RECORD锁,LOCK_DATA对应表里的每一行。虽然最后只修改了id=1这一行,但扫描过程中经过的每一行都被锁了一遍。
这个实验给我一个非常深刻的教训:生产环境写UPDATE和DELETE前,必须检查WHERE条件是否命中索引,尤其是数据量很大的表。否则你以为自己在做行级锁操作,实际上已经把表锁死了。这也是“MySQL锁表机制”里在真实场景中最常见的成因之一。
4. 意向锁与表级锁:锁协作机制验证
4.1 意向锁怎么证明它真的存在
意向锁是个很“幕后”的锁,业务SQL不会显式请求它,但它默默参与每一次行锁操作。它的作用是告诉别人:“这个表上已经有人加了行级锁,你如果想加表级锁,可能得等等。”
来看证据。会话A执行:
sql复制BEGIN;
SELECT * FROM student_score WHERE id = 1 FOR UPDATE;
然后立刻查data_locks:
sql复制SELECT OBJECT_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks\G
结果里会看到两行锁记录:
- 一行是TABLE级别,LOCK_MODE为IX,表示这个表上存在意向排他锁;
- 一行是RECORD级别,LOCK_MODE为X,REC_NOT_GAP,表示id=1这条记录上存在排他锁。
这就直观验证了:InnoDB在给一行加X锁之前,会先在表级别加一把IX锁。它的价值在于,当另一个事务想给整张表加锁时,可以直接通过判断表上是否有IX/IS锁,快速知道表里有没有行锁,而不用遍历所有行去检查。没有意向锁的话,表级锁和行级锁的冲突判断会变得非常低效。
4.2 DDL为啥也会被行锁阻塞
意向锁的冲突场景,最典型的是LOCK TABLES这类表级锁操作。还是让会话A持有id=1的X锁,然后会话B执行:
sql复制LOCK TABLES student_score WRITE;
这条语句会阻塞,因为WRITE表锁需要拿到表级别的排他锁,但表上已经存在会话A的IX锁,二者冲突。会话A提交后,这条LOCK TABLES才会执行成功。
还有一个更常见的场景是ALTER TABLE。虽然ALTER TABLE用的是MDL锁(元数据锁),不是传统的意向锁,但效果类似:会话A对表里的某一行加了行锁,事务没提交;会话B执行:
sql复制ALTER TABLE student_score ADD COLUMN tmp_col INT;
这条DDL往往也会卡住。因为执行ALTER TABLE需要拿到这张表的MDL写锁,而会话A的事务还没结束,事务持有的MDL读锁还没释放。很多人在线上执行大表DDL时,会碰到“一直等待、状态是Waiting for table metadata lock”的情况,十有八九就是前面有一个长事务没结束。这个问题的排查方法和行锁等待很像,关注information_schema.innodb_trx里有没有长时间未提交的事务即可。
4.3 自增锁批量插入时的行为差异
自增锁是表级锁里的特殊成员,它只服务AUTO_INCREMENT列。InnoDB有一个参数innodb_autoinc_lock_mode,默认值是1,表示“连续模式”:当执行批量插入时,为保证自增值连续,会持有AUTO-INC锁直到语句结束,其他会话如果要批量插入,就得排队等。
如果业务里有大量并发批量INSERT,这种排队会拉高延迟。那是不是改成2(交错模式)就万事大吉?不一定。交错模式下自增值会有空洞,而且和binlog复制格式有配合要求,不是想改就能改的。我的建议是:默认保持1就好,大部分业务根本不会因为自增锁产生性能瓶颈;真出现瓶颈,先检查是不是批量插入太多,而不是盲目调整参数。自增锁这块很容易被面试官拿来当“表面理解”的试金石,能说出默认值和不同模式的含义,会比只会答“表锁”高出不少。
5. 死锁与锁等待:问题排查与踩坑实录
5.1 亲手制造一个死锁
理论讲够了,我们实际操作一把死锁。死锁的产生需要两个事务形成“循环等待”:A等B释放锁,B又等A释放锁。制造死锁的经典手法是两个事务按相反顺序更新两条记录。
整套操作顺序如下:
sql复制-- 会话A
BEGIN;
UPDATE student_score SET score = 91 WHERE id = 1;
sql复制-- 会话B
BEGIN;
UPDATE student_score SET score = 81 WHERE id = 4;
此时A持有id=1的锁,B持有id=4的锁。接着让二者交叉申请对方锁:
sql复制-- 会话A,会阻塞,等待B释放id=4
UPDATE student_score SET score = 92 WHERE id = 4;
sql复制-- 会话B,触发死锁检测,报错回滚
UPDATE student_score SET score = 82 WHERE id = 1;
执行会话B的最后一条UPDATE时,InnoDB的死锁检测机制会立即发现循环等待,然后主动回滚其中一个事务,避免两边无限等下去。你会看到类似这样的报错:
text复制ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
报错之后,会话A之前被阻塞的UPDATE会继续执行成功。这就是死锁的完整闭环:系统检测到循环等待,牺牲一个事务,保全另一个。
死锁和锁等待不一样,锁等待是单方向排队,只要前面事务提交,后面事务会继续;死锁是循环等待,如果没有检测机制,两边会一直卡住。InnoDB通过等待图算法主动检测死锁,检测到就回滚代价较小的事务。
5.2 死锁日志怎么看
死锁报错之后,第一件事是去查死锁日志。执行:
sql复制SHOW ENGINE INNODB STATUS\G
在输出里找到LATEST DETECTED DEADLOCK这一段,会看到两个事务的详细信息:当前执行的SQL、持有和等待的锁、锁在哪条记录上。日志里一般会标出类似WE ROLL BACK TRANSACTION (2)的信息,告诉你它回滚了哪个事务。
读死锁日志的经验是不要只看报错SQL,重点看锁描述。比如日志里会出现两行RECORD LOCKS相关记录,分别显示两个事务各自持有的锁和等待的锁。通过这几行,能反推出循环等待的链条,从而找到问题的根源。绝大多数死锁案例,最后都会归因到“两条SQL对同一组记录加锁的顺序不一致”。优化方向也基本固定:让所有事务都按同样的顺序访问资源,比如先更新小id再更新大id,或者先操作主表再操作子表。
5.3 锁等待排查工具箱
锁等待超时和死锁还不一样,死锁是系统主动回滚,锁等待是正常排队。线上如果一堆事务卡住,很容易表现为接口超时,这时候需要快速定位卡点。我的习惯是分三步走。
第一步,看当前哪些事务在等待:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图会列出等待事务和被等待事务,以及它们分别执行的SQL、锁类型、等待时长,是排查锁问题最快的信息来源。
第二步,看具体锁对象:
sql复制SELECT * FROM performance_schema.data_locks\G
这个视图能看到锁的归属事务、锁模式、锁类型、锁在哪个表哪条记录上。如果锁的LOCK_MODE里有GAP字样,说明是间隙锁在挡插入。
第三步,看长事务:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;
先处理跑了一直没提交的老事务。很多锁等待问题,真正源头就是一个空闲事务占着锁不释放,后面的全在排队。遇到这种情况,确认事务来源后,可以KILL对应连接,锁自然释放。
5.4 面试问锁机制,按什么思路答
考虑到标题相关的热搜词里有大量“mysql面试题”“mysql锁表机制”这类查询,我在最后补一下面试答题思路。面试官问InnoDB锁,最好按照锁粒度、锁类型、锁对象、锁与事务隔离级别的关系这条线来说,顺着主线把实验中的现象串进去。
- 先说行锁和表锁,指出InnoDB默认走行锁,但行锁是锁在索引上的,没有索引就会退化为类似表锁的效果;
- 再说S锁和X锁的兼容性,补充快照读与当前读的区别,MVCC的存在让普通SELECT不加锁;
- 然后展开记录锁、间隙锁、临键锁,重点说明间隙锁在RR隔离级别下是为了防幻读;
- 最后提意向锁和死锁,讲一下循环等待和死锁检测机制。
整个回答一定要有“现场感”。比如解释间隙锁时直接说“如果表里id有1、2、4、5,你去对id=3加锁,实际上是把2到4之间的空位锁住了,别人想往这个位置插数据会被卡住”,面试官一听就知道你是真的理解,而不是背的八股。
我个人在实际操作中的体会是:锁机制这个东西,背十遍概念不如亲手跑一遍现象。你可以不需要前面那些复杂步骤,就单纯开两个终端,按这篇文章的实验顺序逐步操作一遍,观察锁等待和时间消耗,很快就能形成直觉。以后再遇到线上死锁或者接口飙慢、大量连接堆积的时候,你至少能快速判断出是行锁冲突、间隙锁拦截,还是索引缺失导致的全表锁,然后再决定是KILL长事务、调整SQL逻辑,还是补一个索引。
最后再分享一个小技巧:实验过程中如果被某个会话的锁卡住了,又搞不清现状,最快的办法不是重启MySQL,而是执行KILL <trx_mysql_thread_id>,或者直接KILL掉那个持有锁的会话连接。这个动作在生产上要谨慎,但实验环境里能帮你快速脱困,折腾起来心里不慌。
