直接从一个面试现场开始吧。面试官问出“谈谈 InnoDB 中的表级锁、页级锁、行级锁”这句话实际上是在摸底你平时写 SQL 的时候到底有没有认真想过“并发”这两个字意味着什么。很多候选人能背出“行锁粒度小、表锁粒度大”之类的教科书句子但一旦追问“那你知道 InnoDB 的行锁是锁在索引上的吗”“间隙锁什么时候才会触发”就开始支支吾吾了。这篇文章我不打算给你一份八股答案而是把锁的分类、InnoDB 的真实加锁机制、以及面试官真正想听到的深度一层层拆开讲清楚。1. 锁的分类逻辑粒度只是一个维度不是全部1.1 三种锁粒度的本质区别从粒度上划分锁可以分为表级锁、页级锁、行级锁。这里所谓的“粒度”说到底就是锁定的资源范围大小。表级锁锁住整张表行级锁只锁住某一行记录页级锁则介于两者之间锁住一个数据页通常是 16KB 大小的存储单元。用生活场景来类比就是表级锁相当于整栋楼停水楼里所有住户都得等页级锁相当于一层楼停水只有这一层的住户受影响行级锁相当于你家里水管坏了只有你自己没法洗澡隔壁邻居照常用水。从这个类比就能直观看出三个核心指标之间的博弈关系锁类型锁粒度并发度加锁开销死锁概率表级锁最大最低最小最低页级锁中等中等中等中等行级锁最小最高最大最高表级锁加锁快、开销小但因为整张表被锁住其他事务只能排队等待所以并发能力很差。行级锁刚好反过来加锁时需要精确定位到每一行开销比较大但并发能力最强。页级锁是中间态同时具备表锁和行锁的某些特点。面试官抛出这个问题想听的不只是这三行的含义而是你能不能进一步说出“为什么 InnoDB 默认使用行级锁而 MyISAM 只能使用表级锁”。这个问题的答案隐藏在两个引擎的存储结构差异里MyISAM 的索引和数据是分离的索引叶子节点存储的是行数据的地址而 InnoDB 的聚集索引叶子节点直接存储整行数据数据即索引。InnoDB 之所以有能力做行锁本质上是因为它把数据和索引绑定在了一起事务可以通过索引精确定位到目标行从而只锁那一行。1.2 从锁的模式看“共享”和“独占”在讨论行级锁和表级锁之前还有一个前置概念必须拎出来讲清楚那就是锁的模式Lock Mode。很多面试者会把锁的粒度和锁的模式混为一谈一说行锁就只知道排他锁一说表锁就只知道表级共享锁这是不够全面的。锁的模式分为两种共享锁Shared LockS 锁和排他锁Exclusive LockX 锁。共享锁一个事务对某行数据加了 S 锁之后其他事务依然可以对这个数据加 S 锁但不能再加 X 锁。用大白话说就是大家可以同时读但谁也不能写。排他锁一个事务对某行数据加了 X 锁之后其他事务既不能加 S 锁也不能加 X 锁。用大白话说就是我在这写谁也别想读谁也别想写。这里有一个容易被忽略的细节在默认的隔离级别REPEATABLE READ可重复读下普通的 SELECT 语句是快照读走的是 MVCC 多版本控制不需要加锁。只有明确使用了SELECT ... FOR UPDATE、SELECT ... FOR SHAREMySQL 8.0 之前的LOCK IN SHARE MODE、UPDATE、DELETE这些语句时才会施加行级锁。这个点面试官也经常会顺带考察因为它决定了你写的 SQL 到底会不会阻塞别人。2. InnoDB 的行级锁锁在索引上而不是锁在记录上2.1 行锁的三个变种记录锁、间隙锁、临键锁InnoDB 的行级锁并不是只有一个形态它细分下去其实是三种锁的统称记录锁Record Lock、间隙锁Gap Lock、临键锁Next-Key Lock。记录锁最简单就是锁住索引中的某一条记录锁的是索引项而不是表记录本身。这个认知非常重要InnoDB 的行锁是加在索引上的如果一张表没有显式定义主键InnoDB 会隐式创建一个聚簇索引行锁就锁在这个隐式索引上如果查询没有走索引那行锁就无从谈起可能升级成表锁后面细说。间隙锁是锁住一个区间范围的锁锁的是索引记录之间的“间隙”。这个间隙锁的出现是因为 InnoDB 在 REPEATABLE READ 隔离级别下需要解决幻读问题。MVCC 的快照读能解决普通读的幻读但当前读加了锁的读依然可能产生幻读间隙锁就是用来堵住这个漏洞的。临键锁则是记录锁和间隙锁的组合。它锁定的范围是一个左开右闭的区间既锁住了区间内的记录也锁住了记录之前的间隙。举个例子如果一张表的 id 列上有 1、5、9 三条记录那么临键锁会覆盖(-∞, 1]、(1, 5]、(5, 9]、(9, ∞)这些区间。为什么临键锁要设计成左开右闭因为这样可以把“当前记录”和“记录前面的间隙”一起锁住既能防止其他事务在间隙中插入新记录防幻读又能锁住已有记录不被修改保证当前读的一致性。这是 InnoDB 默认隔离级别下的默认加锁方式。2.2 加锁的触发时机与实操判断方法光背概念没用得知道一个具体的 SQL 语句到底会加什么锁。我把常见场景整理成了一个速查表这是面试时最容易被追问的实战点场景示例 SQL加锁情况主键等值查询命中记录SELECT * FROM t WHERE id 5 FOR UPDATE对 id5 的记录加记录锁X 锁主键等值查询未命中任何记录SELECT * FROM t WHERE id 7 FOR UPDATE对间隙5, 9加间隙锁唯一索引等值查询命中记录SELECT * FROM t WHERE email ab.com FOR UPDATE对唯一索引记录加记录锁同时对对应主键记录加记录锁普通索引等值查询命中记录SELECT * FROM t WHERE name 张三 FOR UPDATE对该普通索引所在区间加临键锁默认向后扫描到第一个不满足条件的值范围查询SELECT * FROM t WHERE id BETWEEN 5 AND 9 FOR UPDATE对范围内所有记录和间隙加临键锁但要注意这些加锁行为还会受到两个因素的影响一是当前事务的隔离级别二是优化器的实际执行计划。在 READ COMMITTED读已提交隔离级别下InnoDB 会禁用间隙锁只保留记录锁。这也是为什么说“RR 下能防幻读RC 下不能防幻读”的根本原因。但千万别以为把隔离级别降到 RC 就万事大吉了binlog 的日志格式还得配合调整否则主从复制会出现数据不一致的风险。这已经是另一个深水区话题了面试中如果主动提到这个说明你真的踩过坑。执行计划对加锁的影响更隐蔽。如果一条 SQL 明明可以用到索引但优化器觉得全表扫描更快比如表中数据量很小那 InnoDB 就会扫描全部聚簇索引记录对每一行都加锁效果上等同于锁全表。也就是说你以为写的是行锁实际上因为没走索引被 InnoDB 静默放大成了类似表锁的锁范围。2.3 插入意向锁一个容易被忽略的特殊锁除了上述三种行级锁InnoDB 还有一种特殊的锁叫插入意向锁Insert Intention Lock。这个锁本质上是一种间隙锁但它的用途很特别当一个事务要往某个间隙中插入记录时需要先获取该间隙的插入意向锁。插入意向锁之间是互相兼容的也就是说多个事务可以同时对同一个间隙持有插入意向锁它们可以并发往同一个间隙里插数据。但是插入意向锁和间隙锁是冲突的一旦有事务对某个间隙加了间隙锁或临键锁其他事务就别想往这个间隙里插任何记录直到锁被释放。这个特性直接决定了两个事务并发插入时的行为。如果两个事务往同一个间隙插入不同位置的数据它们可以并行执行互不干扰但如果一个事务加了间隙锁另一个事务的插入操作就会陷入锁等待。理解了插入意向锁你在排查为什么 insert 语句会“卡住”的时候心里就有数了。3. 表级锁在 InnoDB 中的存在感和 MyISAM 完全不同3.1 真正的表级锁、MDL 锁与自增锁的区分面试官问“InnoDB 中的表级锁”其实是一个有误导性的说法因为 InnoDB 在存储引擎层面主要以行级锁为主表级锁并不是它的主力。在 InnoDB 上遇到的“锁表”问题绝大多数时候其实是以下三种锁导致的需要严格区分开第一种是 MySQL Server 层的元数据锁Metadata LockMDL。这个锁是 MySQL 5.5 之后引入的用于保护表结构定义。执行 DDL 语句ALTER TABLE、DROP TABLE时需要获取 MDL 写锁执行 DML 语句SELECT、UPDATE、DELETE、INSERT时需要获取 MDL 读锁。读锁之间兼容但写锁和读锁互斥。这里的坑在于一个长期未提交的事务会一直持有 MDL 读锁导致后面的 ALTER TABLE 语句一直阻塞。更烦人的是ALTER TABLE 一旦开始排队等待它后面所有的新查询也全都被堵住了。这就是典型的“一条慢查询拖垮整个库”的场景。排查这类问题直接看performance_schema.metadata_locks表就能找到持锁的事务。第二种是 AUTO-INC 锁Auto-Increment Lock。这是一种特殊的表级锁在 INSERT 语句执行时获取在语句执行完成后立即释放。它的作用是保证自增主键的唯一性。InnoDB 在 MySQL 5.7 里引入了轻量级互斥量的优化策略可以通过innodb_autoinc_lock_mode参数控制。这个参数会让 INSERT 语句在大多数情况下不再使用表级 AUTO-INC 锁而是使用互斥量来获取自增值并发插入性能会好很多。第三种是基于存储引擎层面的表级锁。虽然 InnoDB 支持 LOCK TABLES 命令但它实际上是先按 Server 层语义拿到 MDL 锁然后 InnoDB 再对表上所有记录加锁。在业务代码里主动使用 LOCK TABLES 的场景已经非常少了因为这会严重削弱并发能力而且容易跟 InnoDB 自身的行锁形成交叉死锁。3.2 行锁怎么“退化”成表锁的面试中还有一题是“什么时候 InnoDB 的行锁会变成表锁”这个问题的核心答案就是没有走索引。InnoDB 的行锁是建立在索引上的。如果一个 UPDATE 语句的 WHERE 条件没有用到任何索引InnoDB 就只能对聚簇索引进行全表扫描然后把扫描过的每一行记录都加上锁。这个行为在 MySQL 8.0 的官方文档里叫做“Locking Reads”本质上就是锁全表。为了避免这种情况有两个手段一是把 SQL 改造成能走索引的写法。比如UPDATE t SET status 1 WHERE name 张三如果 name 上没有索引就会全表加锁如果给 name 建了索引就只锁住命中的行和相关的间隙。二是利用 MySQL 8.0 引入的SELECT ... FOR UPDATE SKIP LOCKED这类语法来减少锁冲突。这个语法可以让查询跳过已经被其他事务锁定的行直接返回可以处理的行。在任务队列、订单分配这一类业务中非常实用能大幅度降低行锁等待。还有一个隐藏知识点如果 SQL 使用了索引但优化器估算出的扫描行数占比过高比如超过全表的 20%~30%优化器可能放弃索引而选择全表扫描。这是索引选择性导致的问题而不是锁机制本身的问题但也同样会造成行锁退化。4. 页级锁一个半路出家的锁粒度4.1 页级锁的前世今生页级锁出现在 MySQL 的 BDBBerkeley DB存储引擎中以及 SQL Server 等数据库产品中。BDB 引擎在 MySQL 5.1 之前被集成之后随着 Oracle 收购 SunMySQL 逐渐移除了 BDB 引擎。现在如果在 MySQL 里聊页级锁更多是作为一种历史知识来讨论。页级锁锁定的单位是一个“页”默认页大小通常是 16KB。这意味着如果一个事务要更新同一页中的两行数据只需要获取一次页级锁如果要更新跨页的两行数据就需要获取两个页级锁。页级锁的并发度介于表锁和行锁之间但它有一个明显的劣势页的大小是固定的数据在页内的分布不均匀很容易出现“锁了一个页结果大部分行跟我无关”的情况。InnoDB 本身不提供页级锁。它内部有 B 树索引结构也以页为单位组织存储但锁的粒度直接做到了行级别。InnoDB 选择行级锁的理由也很简单对于 OLTP 类型的业务单条 SQL 通常只涉及少量行行级锁可以最大化并发度。代价是锁管理开销变大实现复杂度也高得多。面试官提到页级锁时其实是在考察你对数据库发展历史的了解以及你能不能分辨不同数据库在锁粒度上的选择差异。你可以顺着往下说SQL Server 有一个锁升级机制当行锁数量超过阈值比如单个语句获取了超过 5000 个锁时会自动升级为表锁以减少锁管理开销。而 InnoDB 没有这种锁升级机制页级锁的存在对于 MySQL 来说更多是概念性的。4.2 为什么 InnoDB 不采用页级锁如果从代价模型来看页级锁的加锁成本比行锁低并发能力比表锁强似乎是一个折中的好方案。但 InnoDB 之所以不用它关键原因是 InnoDB 的 MVCC 机制和事务模型与页级锁存在结构性矛盾。InnoDB 通过 undo log 实现多版本并发控制一行数据可能存在多个历史版本。当事务更新一行数据时新版本写入聚簇索引旧版本留在 undo log 中。如果锁粒度是页级那么同一页内不同行的更新会因为共享同一个页锁而互相阻塞这会让事务的并发更新能力大打折扣。换句话说InnoDB 为了更高的并发度选择了实现复杂度更高的行级锁而不是更简单的页级锁。还有一个现实原因InnoDB 的二级索引和聚簇索引是分开存储的更新一条记录可能同时涉及二级索引页和聚簇索引页。如果采用页级锁一个更新操作可能需要同时锁住两个甚至多个页反而增加了死锁的概率。行级锁则可以把锁范围控制到最小降低无谓的竞争。5. 死锁与锁等待面试中的最终压轴题5.1 死锁产生的四个必要条件聊完三种锁粒度面试官大概率会顺势追问“那你怎么排查死锁”这里不需要背操作系统里死锁的四个必要条件但要结合数据库的实际场景来解释。数据库中的死锁可以理解为一群事务互相持有对方需要的锁并且谁都不愿意释放。最常见的一种死锁场景是事务 A 先锁住 id1 的行再锁 id2 的行事务 B 先锁住 id2 的行再锁 id1 的行。两个事务同时执行A 拿到了 id1 的锁等待 id2B 拿到了 id2 的锁等待 id1死锁形成。InnoDB 处理死锁的策略是死锁检测机制。它维护了一个等待图当检测到循环等待时会选择其中一个事务作为牺牲者回滚该事务并抛出ERROR 1213 (40001): Deadlock found when trying to get lock错误。被回滚的事务需要由应用层去重试。要避免死锁核心思路就是让所有事务按相同的顺序访问资源。比如在业务层面约定“先更新主表再更新从表”或者“先更新 id 小的记录再更新 id 大的记录”。如果无法保证顺序至少要让事务持锁时间尽可能短减少和其他事务的交叉概率。5.2 锁等待超时与排查姿势死锁之外更常见的是锁等待超时。默认情况下innodb_lock_wait_timeout的值是 50 秒一个事务等待锁超过这个时间就会报错Lock wait timeout exceeded; try restarting transaction。发生锁等待超时时最直接的排查手段是查information_schema.innodb_trx、information_schema.innodb_lock_waits、information_schema.innodb_locks这三张表。在我的实际经验里最快的定位方法是这样操作的先执行SELECT * FROM information_schema.innodb_lock_waits看一下等待关系。拿到阻塞者和被阻塞者的事务 ID。去information_schema.innodb_trx里查看这两个事务的状态、启动时间、执行语句。如果发现阻塞事务已经执行了很久还没提交可以直接KILL掉该事务对应的 MySQL 线程。这里有一个非常实用的心得排查锁问题的时候SHOW PROCESSLIST往往看不出来什么因为被阻塞的 SQL 依然显示为“Sleep”或“Query”状态。第一时间应该去看information_schema里的三张锁表而不是盯着进程列表发呆。另外一个容易被坑的点innodb_trx里的trx_query字段只记录当前正在执行的语句如果一个事务执行完 UPDATE 之后长时间不提交trx_query会显示 NULL看起来像没有活干实际上它持有的锁一个都没释放。这种“持锁空闲事务”是线上锁等待的常见元凶。6. 面试回答框架这样答才能超出预期这部分算是送给准备面试的读者的福利。我面试过不少候选人把这个问题回答得好的通常会遵循下面这个层次递进的结构先答“是什么”把表级锁、页级锁、行级锁的定义和粒度差异说清楚落点是三者在并发度、加锁开销、死锁概率上的取舍。再答“InnoDB 真实情况怎么样”点出 InnoDB 默认使用行级锁行锁是建立在索引上的进一步细分为记录锁、间隙锁、临键锁并说明它们在 REPEATABLE READ 下如何配合解决幻读。然后答“什么时候会锁表”抛出“没走索引导致行锁退化”这个关键点顺带引出 MDL 锁和自增锁这两种另类表级锁体现对 MySQL Server 层和存储引擎层的分层理解。最后答“出问题了怎么办”给出死锁报错和锁等待超时的排查思路如果能说出查information_schema三张表的操作再加一个“优先 kill 持锁空闲事务”的实战技巧这一题基本就稳了。整个回答如果能控制在三到五分钟并且中间穿插一两个真实事故案例面试官对你的评价绝对不会只停留在“背过八股”的层面。我个人强烈建议你把间隙锁和临键锁的部分多花时间吃透因为这两个概念不仅面试爱考在实际开发中也是排查锁问题最常用的底层知识。很多看起来莫名其妙的“UPDATE 卡住”“INSERT 超时”问题归根到底都是间隙锁在起作用。你能不能用最快时间定位到具体是哪个间隙被锁、哪个事务加的锁直接决定了线上故障的处理速度。写这篇文章的时候我又把 MySQL 8.0 源码里关于锁结构的部分翻了一遍。Lock 结构被设计成一种类似“多级队列”的组织方式每一个锁都挂在一棵哈希索引上同一个事务对同一行加的锁会被折叠合并。这些底层细节在面试中不一定会被问但理解之后你看锁等待监控数据时会有一种“原来如此”的通透感。纸上得来终觉浅锁这个东西只有真在线上堵过几次才算真正学会。