上周面了一个五年经验的后端,简历上写着“精通 MySQL”,我问他:一条 update 语句到底加了多少锁?他从表锁聊到行锁,最后甩出一句“反正会锁表”,但追问锁在哪几条记录、间隙锁是怎么产生的、为什么有时候一个等值更新也会锁住一个区间,就开始打太极了。
这个问题之所以经典,是因为它不只是一个“知识点”,而是把 InnoDB 索引结构、事务隔离级别、锁对象和线上排障经验全部串在了一起。这篇文章我就把面试里能问到的“全套八股文”整理出来,从基础锁类型到各种 update 场景的加锁行为,再到死锁排查和分布式锁对比。准备面试后端岗位的朋友可以直接照着背,正在线上排查锁问题的开发也可以当手册翻。
1. 先搞懂:InnoDB 到底锁的是什么
1.1 锁不是锁表,是锁索引记录
很多人在面试时脱口而出“update 会锁表”,这句话不能算全错,但一定不准确。InnoDB 的行锁和我们平时口头说的“锁表”是两码事。InnoDB 的行锁实际上是加在索引记录上的,不是加在“一行物理数据上”。
举个例子,你执行:
UPDATE t SET name = 'abc' WHERE id = 5;如果 id 是主键,InnoDB 会先在主键索引树上定位 id=5 的记录,然后对这棵索引树上对应的那一条记录加锁。如果表中没有主键?InnoDB 会隐式创建一个 6 字节的 ROWID 作为聚簇索引;如果表里还有二级索引,更新操作还需要同时维护二级索引,所以二级索引上对应的记录也会被加锁。
理解这一点很关键,因为它直接决定了后续所有加锁规则。面试官问“一条 update 加了多少锁”时,本质上是在考你是否清楚 InnoDB 是索引组织表,锁的粒度是“索引记录 + 记录之间的间隙”,而不是一个抽象的“表”。
我之前见过有人把线上慢 SQL 归因于“行锁升级成表锁了”,其实 InnoDB 并不存在行锁升级表锁这种机制,真正的表锁是手动LOCK TABLE才会出现的。很多长事务把表“锁”住,只是因为这个事务扫描了全表,给所有聚簇索引记录都加了锁,并且间隙锁连成片,导致其他更新全部阻塞,表现看起来和表锁一样。
1.2 记录锁、间隙锁、临键锁三件套
InnoDB 的锁类型,面试常考的是三个:Record Lock(记录锁)、Gap Lock(间隙锁)、Next-Key Lock(临键锁)。这三者不是并列关系,而是“组合”关系。
记录锁最简单,就是只锁某一条索引记录,比如主键等值更新且记录存在时,加的就是 X 型记录锁(排他锁)。间隙锁锁的是“索引记录之间的空隙”,比如记录 id 分别为 4 和 6,那间隙锁可以锁住 (4, 6) 这个开区间,注意不包括两端的记录。为什么需要间隙锁?主要是在可重复读(RR)隔离级别下防止幻读。所谓幻读,就是同一个事务内两次相同范围查询,后一次多出几行。如果不把记录之间的间隙锁住,其他事务就可以在这个间隙里插入新记录。
临键锁则是“记录锁 + 间隙锁”的合体,它锁的是左开右闭区间,还是用 4 和 6 的例子,如果对 6 这一条加临键锁,实际锁的范围是 (4, 6],不仅锁住了 id=6 这条记录,也堵住了在 4 和 6 之间插入新记录的可能。
一张表就概括清楚了:
| 锁类型 | 锁定范围 | 解决什么问题 | 典型场景 |
|---|---|---|---|
| Record Lock | 单条索引记录 | 行更新互斥 | 主键等值更新,记录存在 |
| Gap Lock | 索引记录之间的空隙 | 防止间隙插入幻读 | RR 下等值更新不存在的记录、范围扫描 |
| Next-Key Lock | 记录 + 前方间隙 | 防止幻读 + 行更新互斥 | RR 下范围扫描、普通索引等值扫描 |
需要额外提醒的是,间隙锁不是排他的,两个事务可以同时持有同一段间隙锁,因为它们都只为了防止插入新记录,而不是互斥禁止访问已有记录。这就解释了为什么有时候两个事务都加了间隙锁,却不会互相阻塞,但如果其中一个事务要插入一条新记录到该间隙,就会被另一个事务的间隙锁挡住。
2. 一条 update 的完整加锁流程
2.1 主键等值更新:最简单的情况也得分两种
面试最常问的第一道题就是:执行UPDATE t SET name = 'abc' WHERE id = 5;,在可重复读隔离级别下,加了多少锁?
答案是:取决于 id=5 的记录是否存在。
如果 id=5 这条记录存在,就比较清晰,只需要对主键索引上 id=5 的这一条记录加 X 型记录锁。此时对应字段的二级索引如果有更新,还会对二级索引记录加锁,但主键锁的对象就是那一条。
如果 id=5 的记录不存在,情况就不一样了。假设表里现有 id 为 3 和 8,那么执行上述 update 时,InnoDB 会找到第一个大于 5 的索引记录 id=8,然后对 (3, 8) 这个区间加Gap Lock。注意这里不是 Record Lock,因为记录本身不存在,不需要锁记录,但要锁住这个间隙,防止其他事务插入 id=5 的新记录。因为如果允许插入,那么当前这个“认为 id=5 不存在”的事务可能产生幻读。
很多人会忽略这个细节,以为 update 一定只锁定目标行。其实“更新不存在的记录也要加间隙锁”是线上死锁的高发原因之一。两个事务分别更新不存在的同一条记录,都会申请间隙锁,间隙锁本身可以共存,但一旦其中一方尝试插入这条记录,就会和另一方的间隙锁冲突,从而引发死锁。这种情况在业务代码里非常隐蔽,通常表现为偶发死锁,而且很难复现。
不过要强调一点:只有隔离级别是 REPEATABLE READ(RR)时才有间隙锁。如果事务隔离级别是 READ COMMITTED(RC),InnoDB 会把间隙锁禁用,只保留记录锁,所以 RC 下更新不存在的记录不会加间隙锁,但代价是可能产生幻读。
2.2 非唯一索引等值更新:锁的范围一下子变大
面试如果只问了主键的情况,那就是在放水。真实业务里 update 的 where 条件往往不是主键,而是一个普通索引列,比如:
UPDATE t SET name = 'abc' WHERE idx_no = 100;这里 idx_no 是普通索引,假设表里有三条记录 idx_no=100,而且索引叶子节点附近的值为 99、100、100、100、101。在 RR 级别下,这个 update 加锁的范围远不止那三条记录。
InnoDB 会在二级索引 idx_no 上找到第一个等于 100 的索引记录,从它开始向后扫描,直到遇到第一个大于 100 的值(101)时才会停下来。扫描过程中,对每一个值为 100 的二级索引记录加记录锁,并且对这个范围内相邻记录之间的间隙加间隙锁,同时对值为 101 的那条记录的前方间隙也加间隙锁,最终锁住的区间大约是从 99 之后到 101 之前。
然后还有一步:因为要更新 name 字段,而 name 可能不在二级索引 idx_no 上,需要通过二级索引回表到聚簇索引,拿到完整行记录后修改。回表过程中,对每一行对应的聚簇索引记录也要加记录锁。
用一个例子说明锁范围:
- 二级索引记录:99, 100, 100, 100, 101
- 间隙锁范围: (99, 100), (100, 100), (100, 100), (100, 101)
- 聚簇索引记录:三条 idx_no=100 的行记录
所以面试答“锁了一行”肯定不对,普通索引等值更新在实际中会锁住一个连续索引区间,甚至可能出现间隙锁和一个不匹配的值(101)附近的锁。这个动作的核心目的是防止这个范围内再插入新的 idx_no=100 记录,保证这个事务对 idx_no=100 的“当前读”是稳定的。
如果你在业务里发现一条按普通索引更新的 SQL 频繁造成锁等待,不要觉得奇怪,先去看这个普通索引的区分度和范围大小。
2.3 范围条件 update:间隙锁最容易漏
范围条件的 update 比等值更复杂,也是面试官加深追问的方向:
UPDATE t SET status = 1 WHERE id BETWEEN 5 AND 10;假设 id 是主键,表中 id 记录为 1, 3, 5, 7, 9, 11。可重复读下,这条 SQL 会对 id=5、7、9 三条记录加记录锁。但这还不够,InnoDB 还要防止在 5 到 10 之间插入新记录,因此会对 (3,5]、(5,7]、(7,9]、(9,11] 这些区间加临键锁或间隙锁。注意,id=11 并不满足条件,但第一个超过上界的记录需要被锁定在范围内,防止后续插入 id=10 或 id=10.5 之类的新记录。
所以最终的锁范围不是 5 到 10,而是:锁住 id=3 的后方间隙一直到 id=11 这里,括号开闭取决于实现,但“比条件范围更大”这一点是确定的。
这带来的实际问题是:如果你在事务里执行了一个范围很小的 update,比如id > 100,哪怕当前只有一条记录满足条件,只要范围内有间隙,附近其他记录的插入都会被阻塞。很多开发者在最外层事务里写了这种 SQL,结果整个业务模块的写操作全部堵住,查锁才发现是一条范围 update 的间隙锁在“一夫当关”。
2.4 最危险的情况:条件没走索引
前面讲的无论主键还是普通索引,本质上都是走了索引查找。如果 where 条件里的列没有索引,那 InnoDB 就只能执行全表扫描。注意这个场景下加锁的严重程度:
UPDATE t SET name = 'abc' WHERE name = 'old';在 name 没有索引的情况下,InnoDB 会从聚簇索引的第一条记录开始,一条一条扫描,对扫描到的每一行记录判断是否满足 name='old',不管是否满足条件,InnoDB 都会对“访问过的记录”加锁。而且因为全表扫描过程中,记录之间所有间隙都会被锁到,最终相当于整个表上所有聚簇索引记录和所有间隙全部被锁住。
这实际上就是“锁表效果”。别以为只是一条更新,它会把整张表的其他增删改全部阻塞。生产环境出现这种情况通常就是线上事故,比如大半夜跑数据更新,忘加索引,第二天业务全部写入超时。
如果你在面试中遇到“无索引 update 加了多少锁”,标准的回答是:会锁住聚簇索引上的所有记录以及所有间隙,等价于把所有行锁/间隙锁串起来,而不是加了一个表级锁。但如果你能补一句“需要用EXPLAIN看执行计划,确认 type 不是 ALL 才能避免”,面试官会觉得你有实战经验。
3. 更进阶的八股:多表 update 和 SQL 变体
3.1 update 关联表时锁加在哪张表
MySQL 支持多表更新,比如:
UPDATE t1 JOIN t2 ON t1.id = t2.t_id SET t1.status = 1 WHERE t2.order_no = 'A001';这种语句加锁会同时涉及两张表。InnoDB 会根据执行计划选一张表作为驱动表,另一张作为被驱动表。驱动表每扫描一条记录,就会对驱动表对应的记录加锁,然后去被驱动表查找匹配记录,匹配到后也对被驱动表记录加锁。所以实际上两张表里凡是参与关联、符合 where 条件的记录,都会被加锁。
这里有个坑:如果关联字段没有索引,MySQL 可能对被驱动表做全表扫描,那么被驱动表的访问范围会放大,锁的范围也会放大。所以多表 update 最好先通过EXPLAIN确认关联字段的索引使用情况,否则等于亲手把两个表都拖下水。
面试官如果问“关联 update 怎么加锁”,可以这样展开:先看优化器的执行计划,再看哪张是驱动表,然后分析每张表的扫描类型。与单表 update 不同,多表更新里每张表的加锁规则都遵循前面说的记录锁、间隙锁逻辑,只是扫描范围更大,锁等待概率更高。顺带还可以提一句:MySQL 的UPDATE ... JOIN和DELETE ... JOIN行为类似,对于并发写入敏感的表,尽量避免这种复杂更新在高峰期执行。
3.2 for update、nowait、skip locked 怎么选
除了普通 update,SELECT ... FOR UPDATE是面试必考。它本质上是把“读”变成“当前读”,并且加的是排他锁,锁定的范围和同条件 update 完全一致。也就是说,SELECT * FROM t WHERE id = 1 FOR UPDATE在 RR 下不仅会锁住 id=1 的记录,如果记录不存在,还会锁住相应间隙。
FOR UPDATE默认会等待其他事务释放锁,等待时间由innodb_lock_wait_timeout决定,默认 50 秒。很多业务接受不了 50 秒的等待,所以 MySQL 8.0 增加了两个重要语法:NOWAIT和SKIP LOCKED。
FOR UPDATE NOWAIT:如果目标锁被其他事务持有,立刻返回报错,不等待。适合快速失败重试的场景。FOR UPDATE SKIP LOCKED:如果目标锁被其他事务持有,跳过已经锁定的行,返回其他未被锁定的行。适合任务队列、批量领取等场景。
注意一点:NOWAIT和SKIP LOCKED不能同时使用,而且 MySQL 8.0 才支持,之前版本(包括 MySQL 5.7)只能用等待或超时。如果你在面试中能主动说出这个版本差异,会非常加分。
NOWAIT不是完全没用等待时间,它是直接把innodb_lock_wait_timeout改成 0 的执行方式,遇到锁直接报Lock wait timeout exceeded或者NOWAIT错误。开发时不要靠NOWAIT当万能药,因为如果业务本身逻辑要求必须拿到锁,还是需要重试机制。
3.3 limit 1 for update skip locked 锁住的是 1 条还是所有 where 条件
这个点是最近工作里常被问到的:用SELECT ... FOR UPDATE SKIP LOCKED配合LIMIT 1做任务队列,到底能锁住多少行?
很多人以为加了LIMIT 1就只锁一行,这种想法太天真。加锁范围取决于扫描路径。看一个例子:
SELECT * FROM task_queue WHERE status = 'pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;如果 status 没有索引,MySQL 大概率是全表扫描,按 id 排序取第一个。扫描过程中,它访问到的所有行其实都有可能加锁,但SKIP LOCKED会跳过其他事务已经锁定的行。最终可能在扫描若干行后,选择第一条未被锁定的行并对其加锁。这里要注意,如果WHERE status='pending'的区分度差,虽然最终只返回一行,但扫描范围内的间隙锁可能不止一个。
如果 status 有索引,且使用该索引扫描,情况会好一些,但范围扫描的间隙锁依然可能存在。比如 status 索引值重复,InnoDB 为了阻止幻读,仍会对这个重复值的索引范围加间隙锁,然后利用SKIP LOCKED跳过去选择一行。所以严谨的说法是:LIMIT 1 FOR UPDATE SKIP LOCKED返回的只有一行,但实际加的锁范围由执行计划决定的索引扫描范围来决定,不要拿“只锁一行”做并发上限评估。
这个坑在任务队列里很典型:多个消费者同时消费,如果表里 pending 记录很多,每个消费者去执行这条 SQL,表面看起来各拿一条,但间隙锁可能导致并发度下降,尤其在高并发下会有大量锁等待。真正要撑住高并发,一般会引入 Redis 分布式锁或消息队列,而不是全靠数据库SKIP LOCKED扛。
4. 从数据库锁到分布式锁
4.1 乐观锁和悲观锁,面试常考
聊完数据库锁,面试官通常会把问题引到“乐观锁 vs 悲观锁”。这个是基础中的基础,但能问出花来。
悲观锁的思路是:先假设并发冲突一定发生,所以在操作数据之前先加锁。数据库里典型的实现就是SELECT ... FOR UPDATE,在事务里先锁定记录,别的事务想改只能等。优点是能保证强一致性,缺点是加锁时间过长会导致锁等待和死锁。
乐观锁的思路是:先假设并发冲突不常发生,更新的时候才校验版本。常见实现是在表里加一个version字段,更新时带上版本号:
UPDATE t SET amount = amount - 100, version = version + 1 WHERE id = 1 AND version = 5;如果影响行数是 1,说明更新成功;如果影响行数是 0,说明 version 已经变了,需要重试。这是一种非常常见的“无锁化”方案,适合读多写少、冲突概率低的场景。
面试时我会建议大家对比这两个方案的适用条件:悲观锁适合并发写非常多、冲突严重的场景,比如银行转账;乐观锁适合并发冲突少、重试成本低的场景,比如商品库存扣减中的一部分。另外要注意,乐观锁不是完全没有锁,它只是把数据库层面的锁移到了业务层,用版本号做条件判断。
4.2 数据库分布式锁为什么总被吐槽,Redis 怎么补充
分布式锁是另一个高频考点,跟 update 语句的锁有关系,又不完全是同一个层面。基于数据库实现分布式锁,常见做法就是利用唯一索引 +INSERT或GET_LOCK()函数,但它的短板很明显:数据库行锁在高并发下容易变成瓶颈;如果一个事务在持锁期间崩溃,锁可能要等事务超时才能释放;数据库本身的性能也远不如缓存。
所以现在业务里更常见的是用 Redis 分布式锁。Redis 实现的核心是SET key value NX EX timeout,利用 NX 保证只有第一个请求能设置成功,EX 设置过期时间避免死锁。加锁、释放锁都需要特别注意原子性和删锁时的标识校验,防止误删别人的锁。
不过用 Redis 分布式锁也要处理两难:如果业务执行时间超过了锁的过期时间,锁过期了,另一个线程就能拿到锁,导致两个线程同时执行临界区。这时候要靠 Redisson 这类库的看门狗机制自动续期,或者把过期时间设置得足够长。不过即便续期,Redis 主从切换时也可能出现锁丢失,这是面试官爱问的“红锁 vs 普通锁”争议点。
需要区分的是,数据库的update锁是数据库内部的并发控制机制,保证的是事务 ACID 属性;分布式锁是业务层面的互斥机制,保护的是跨进程临界区资源。把它们混在一起聊很容易答偏。碰到这种问题,先确认对方问的是哪个层面,再往下讲。
5. 线上排查:锁等待和死锁怎么查
5.1 查看当前锁信息
面试通过后,进入工作,最常遇到的不是背八股,而是线上突然出现Lock wait timeout exceeded报警。这时候第一件事不是重启应用,而是查数据库当前的锁等待情况。
MySQL 5.7 可以查information_schema.innodb_trx、sys.innodb_lock_waits;MySQL 8.0 更推荐用performance_schema.data_locks和data_lock_waits。下面几条 SQL 是排障必备:
-- 查看当前所有事务 SELECT * FROM information_schema.innodb_trx\G -- 查看锁等待关系(8.0 推荐) SELECT * FROM performance_schema.data_lock_waits\G -- 查看当前持有和请求的锁 SELECT * FROM performance_schema.data_locks\G如果用的是 MySQL 8.0,data_locks里会直接显示每个锁对应的索引名、锁类型(RECORD/GAP/INSERT_INTENTION)、锁模式(X 锁还是 S 锁)以及锁的LOCK_DATA。通过LOCK_DATA能看到锁在哪条索引记录上,再结合innodb_trx里的trx_query,就能定位是哪条 SQL 卡住了。
要注意:查这些表本身可能也有性能开销,线上高峰期不要频繁执行。一般先看一眼innodb_trx里trx_state为 RUNNING 且执行时间很长的 SQL,再针对性查锁等待。
5.2 锁表如何解锁
很多同事口里的“表被锁了”,绝大多数情况是“多行记录被长事务的锁卡住了”。解锁的思路不是去删表,而是找到持锁事务并让它结束。
第一步,找到锁等待关系。用 MySQL 8.0 的sys库最简单:
SELECT * FROM sys.innodb_lock_waits\G这个视图会告诉你:请求锁的线程 ID、阻塞它的线程 ID、请求的 SQL、阻塞的 SQL、等待时间。
第二步,如果确定某个阻塞事务是缓存了很久的僵尸事务,可以杀掉它:
-- 根据 innodb_trx 里的 trx_mysql_thread_id SHOW PROCESSLIST; KILL <thread_id>;但一定要谨慎,KILL之后事务会回滚,如果这个事务已经执行了一半,回滚可能也需要时间,而且会造成业务数据变化。最好的做法是先和业务方确认,再看trx_started判断事务是不是异常长时间未提交。
第三步,如果是频繁死锁,用SHOW ENGINE INNODB STATUS\G查看最近一次死锁的详细信息。它会在LATEST DETECTED DEADLOCK段里打印两条事务的 SQL、持有的锁、等待的锁,以及WE ROLL BACK TRANSACTION表明被回滚的一方。把这段日志保存下来,基本能重现死锁链路。
排查锁问题最重要的是不要只看一个问题,比如只看到一条慢 SQL 就把锅甩给“锁了”。完整链路是:先看事务状态,再看锁等待,然后看死锁日志,最后回关联业务代码,找出事务范围是否开太大、where 条件是否没走索引、隔离级别是否不合理。只有这样,才能避免每次都是重启数据库或杀线程的治标不治本。
我在实际面试中一般会追一个问题:“你线上遇到过最隐蔽的一次锁等待是怎么排查出来的?”有候选人说是某条update忘记提交事务,导致所有请求都卡在同一批行记录上;也有人说是事务里先查后改,但查询走的是二级索引,更新时回表,二级索引间隙锁和聚簇索引锁互相叠加,死锁日志里两把锁完全对称。这些经验比背八股有用得多。
最后分享一个我自己排查时的小技巧:如果业务允许,尽量把事务隔离级别从 REPEATABLE READ 调成 READ COMMITTED,同时确认所有 update 条件都走索引,这样能减少一大部分间隙锁带来的无谓阻塞。但不要盲目使用 RC,如果业务确实需要范围锁来防幻读,比如某些对账场景,还是老老实实开 RR,并为每条更新 SQL 加注释说明为什么必须这么做。面试题背熟练只是第一步,真正到生产环境,你手里的锁日志和 EXPLAIN 才是最有说服力的答案。