一条UPDATE语句在InnoDB中到底加了多少锁?MySQL锁机制全解析
上周面了一个五年经验的后端简历上写着“精通 MySQL”我问他一条 update 语句到底加了多少锁他从表锁聊到行锁最后甩出一句“反正会锁表”但追问锁在哪几条记录、间隙锁是怎么产生的、为什么有时候一个等值更新也会锁住一个区间就开始打太极了。这个问题之所以经典是因为它不只是一个“知识点”而是把 InnoDB 索引结构、事务隔离级别、锁对象和线上排障经验全部串在了一起。这篇文章我就把面试里能问到的“全套八股文”整理出来从基础锁类型到各种 update 场景的加锁行为再到死锁排查和分布式锁对比。准备面试后端岗位的朋友可以直接照着背正在线上排查锁问题的开发也可以当手册翻。1. 先搞懂InnoDB 到底锁的是什么1.1 锁不是锁表是锁索引记录很多人在面试时脱口而出“update 会锁表”这句话不能算全错但一定不准确。InnoDB 的行锁和我们平时口头说的“锁表”是两码事。InnoDB 的行锁实际上是加在索引记录上的不是加在“一行物理数据上”。举个例子你执行UPDATE t SET name abc WHERE id 5;如果 id 是主键InnoDB 会先在主键索引树上定位 id5 的记录然后对这棵索引树上对应的那一条记录加锁。如果表中没有主键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]不仅锁住了 id6 这条记录也堵住了在 4 和 6 之间插入新记录的可能。一张表就概括清楚了锁类型锁定范围解决什么问题典型场景Record Lock单条索引记录行更新互斥主键等值更新记录存在Gap Lock索引记录之间的空隙防止间隙插入幻读RR 下等值更新不存在的记录、范围扫描Next-Key Lock记录 前方间隙防止幻读 行更新互斥RR 下范围扫描、普通索引等值扫描需要额外提醒的是间隙锁不是排他的两个事务可以同时持有同一段间隙锁因为它们都只为了防止插入新记录而不是互斥禁止访问已有记录。这就解释了为什么有时候两个事务都加了间隙锁却不会互相阻塞但如果其中一个事务要插入一条新记录到该间隙就会被另一个事务的间隙锁挡住。2. 一条 update 的完整加锁流程2.1 主键等值更新最简单的情况也得分两种面试最常问的第一道题就是执行UPDATE t SET name abc WHERE id 5;在可重复读隔离级别下加了多少锁答案是取决于 id5 的记录是否存在。如果 id5 这条记录存在就比较清晰只需要对主键索引上 id5 的这一条记录加 X 型记录锁。此时对应字段的二级索引如果有更新还会对二级索引记录加锁但主键锁的对象就是那一条。如果 id5 的记录不存在情况就不一样了。假设表里现有 id 为 3 和 8那么执行上述 update 时InnoDB 会找到第一个大于 5 的索引记录 id8然后对 (3, 8) 这个区间加Gap Lock。注意这里不是 Record Lock因为记录本身不存在不需要锁记录但要锁住这个间隙防止其他事务插入 id5 的新记录。因为如果允许插入那么当前这个“认为 id5 不存在”的事务可能产生幻读。很多人会忽略这个细节以为 update 一定只锁定目标行。其实“更新不存在的记录也要加间隙锁”是线上死锁的高发原因之一。两个事务分别更新不存在的同一条记录都会申请间隙锁间隙锁本身可以共存但一旦其中一方尝试插入这条记录就会和另一方的间隙锁冲突从而引发死锁。这种情况在业务代码里非常隐蔽通常表现为偶发死锁而且很难复现。不过要强调一点只有隔离级别是 REPEATABLE READRR时才有间隙锁。如果事务隔离级别是 READ COMMITTEDRCInnoDB 会把间隙锁禁用只保留记录锁所以 RC 下更新不存在的记录不会加间隙锁但代价是可能产生幻读。2.2 非唯一索引等值更新锁的范围一下子变大面试如果只问了主键的情况那就是在放水。真实业务里 update 的 where 条件往往不是主键而是一个普通索引列比如UPDATE t SET name abc WHERE idx_no 100;这里 idx_no 是普通索引假设表里有三条记录 idx_no100而且索引叶子节点附近的值为 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_no100 的行记录所以面试答“锁了一行”肯定不对普通索引等值更新在实际中会锁住一个连续索引区间甚至可能出现间隙锁和一个不匹配的值101附近的锁。这个动作的核心目的是防止这个范围内再插入新的 idx_no100 记录保证这个事务对 idx_no100 的“当前读”是稳定的。如果你在业务里发现一条按普通索引更新的 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 会对 id5、7、9 三条记录加记录锁。但这还不够InnoDB 还要防止在 5 到 10 之间插入新记录因此会对 (3,5]、(5,7]、(7,9]、(9,11] 这些区间加临键锁或间隙锁。注意id11 并不满足条件但第一个超过上界的记录需要被锁定在范围内防止后续插入 id10 或 id10.5 之类的新记录。所以最终的锁范围不是 5 到 10而是锁住 id3 的后方间隙一直到 id11 这里括号开闭取决于实现但“比条件范围更大”这一点是确定的。这带来的实际问题是如果你在事务里执行了一个范围很小的 update比如id 100哪怕当前只有一条记录满足条件只要范围内有间隙附近其他记录的插入都会被阻塞。很多开发者在最外层事务里写了这种 SQL结果整个业务模块的写操作全部堵住查锁才发现是一条范围 update 的间隙锁在“一夫当关”。2.4 最危险的情况条件没走索引前面讲的无论主键还是普通索引本质上都是走了索引查找。如果 where 条件里的列没有索引那 InnoDB 就只能执行全表扫描。注意这个场景下加锁的严重程度UPDATE t SET name abc WHERE name old;在 name 没有索引的情况下InnoDB 会从聚簇索引的第一条记录开始一条一条扫描对扫描到的每一行记录判断是否满足 nameold不管是否满足条件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 怎么选除了普通 updateSELECT ... FOR UPDATE是面试必考。它本质上是把“读”变成“当前读”并且加的是排他锁锁定的范围和同条件 update 完全一致。也就是说SELECT * FROM t WHERE id 1 FOR UPDATE在 RR 下不仅会锁住 id1 的记录如果记录不存在还会锁住相应间隙。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 statuspending的区分度差虽然最终只返回一行但扫描范围内的间隙锁可能不止一个。如果 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_waitsMySQL 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.0data_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 才是最有说服力的答案。