ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一条update到底加多少锁?InnoDB加锁机制全解析

一条update到底加多少锁?InnoDB加锁机制全解析 上周面试官问了一个很经典的问题“一条 update 语句到底加了多少锁”说实话问题很简单但要把每一层的锁都答清楚不少人会卡壳。网上相关的八股文很多可很多人背下来之后只记住了“行锁”两个字真被问到“为什么这条 update 让整个表的插入都阻塞了”照样解释不明白。这篇不打算只给结论我会把 InnoDB 加锁的判断思路、各种情况下的锁范围、以及怎么用 SQL 实际验证锁完整串一遍。核心不是让你背答案而是给你一套“一条 update 落到数据库里锁到底加在哪里、加多少、怎么查”的完整方法。1. 面试题背后的真实考点从一条 update 看 InnoDB 锁模型1.1 面试官问“加了多少锁”到底想听什么面试官问这个问题往往不是真想知道“3 个锁”还是“5 个锁”这种具体数字而是想确认三件事你知不知道 update 属于“当前读”不是“快照读”所以它必须加锁你能不能根据 where 条件和索引情况推断出锁定的具体范围你知不知道锁什么时候释放、会不会阻塞其他事务、会不会造成死锁。所以回答的时候千万别一上来就只丢一句“加的是行锁”。你应该先亮出分析框架索引情况、等值还是范围、隔离级别。这三个因素共同决定一条 update 到底加了哪些锁。一条普通的 update 语句比如update t_user set age 20 where id 1在事务里执行时InnoDB 会先在索引上定位到目标记录然后给这条记录加上排他锁X 锁。但因为间隙锁和临键锁的存在锁的范围可能不是“正好一行”有可能是“一个区间”甚至是整张表。1.2 InnoDB 锁的基础类型要一次说清楚先简单回顾一下 InnoDB 里常见的几种锁类型后面分析会反复用到锁类型含义加锁特点Record Lock记录锁只锁索引记录本身Gap Lock间隙锁锁住两条记录之间的间隙防止其他事务插入Next-Key Lock临键锁记录锁 间隙锁的组合锁住一个左开右闭区间Insert Intention Lock插入意向锁插入时在间隙上设置的意向锁等多个事务协调插入位置很多人会忽略一个关键点InnoDB 的行锁是基于索引实现的。如果表里没有索引那 InnoDB 会通过隐藏的聚簇索引来加锁如果 where 条件压根不走索引那只能扫描主键索引的每一条记录产生大量锁。这也是“一条 update 把表锁住”的根本原因。2. 加锁前先回答三个问题索引、条件、隔离级别2.1 先判断 where 条件走不走索引以及走的是哪种索引这是所有锁分析的第一步。InnoDB 加锁的最小单位是索引记录不是直接锁“数据行”。一条 update 的语句执行时优化器先确定访问路径然后按访问路径逐条扫描并加锁。如果 where 条件是id 1而 id 是主键那 InnoDB 直接在聚簇索引上定位到这一条记录加 Record Lock 即可。如果 where 条件是age 30而 age 上有二级索引那 InnoDB 会先在二级索引上定位加锁然后再回表到主键索引给对应的主键记录也加锁。此时虽然你更新的只有一行但因为回表至少会产生两个索引记录锁。如果 where 条件的列没有索引那就麻烦了。InnoDB 只能对主键索引做全表扫描扫描过程中会把扫过的每一条记录都加上锁而且在 RR 隔离级别下间隙锁也会一起加上实际效果接近“锁表”。2.2 等值条件与范围条件锁的范围完全不同很多人一看到update ... where id 1就觉得只锁一行其实这只是“唯一索引等值查询”的特例。如果 where 条件不是唯一索引等值就要考虑间隙问题。InnoDB 默认的锁单位是 Next-Key Lock也就是“记录锁 间隙锁”。在 RR 隔离级别下如果 update 扫描到某条二级索引记录它会锁住这个记录以及记录前面的间隙。这样做的目的是防止其他事务在这个范围内插入新记录避免当前事务出现幻读。举个例子表中有年龄为 10、20、30、40、50 的五条记录执行update t_user set age age 1 where age 30;在 RR 隔离级别下如果 age 是非唯一二级索引那么这条 update 锁的不仅是 age30 的那条记录还会锁住 age 在 (20, 30] 这个区间以及部分相邻间隙。如果这个间隙里正好允许插入 age25 的记录那么插入会被阻塞。这是很多人实际开发中踩坑最多的地方。2.3 隔离级别是隐藏开关MySQL 默认隔离级别是 RROracle 默认是 RC。这个差别直接决定间隙锁是否启用。在 RC 隔离级别下InnoDB 出于性能考虑会关闭 gap lock只保留 Record Lock。也就是说同一条件在 RC 下可能只锁命中的记录而在 RR 下会额外锁住很多间隙。但注意RC 下并非完全没有间隙锁外键约束和某些特殊场景仍可能触发只是常规 update 基本不会。所以回答“一条 update 加了多少锁”的时候要分隔离级别说。只说一种答案面试官大概率会追问“那隔离级别改成 RC 呢”。不把这条分支讲清楚整套八股就不完整。3. 索引命中情况这条 update 锁一行还是锁一片3.1 场景一主键或唯一索引等值查询这是最简单的场景。begin; update t_user set age 20 where id 5; commit;id 是主键且 id5 这条记录存在那么 InnoDB 只会在主键索引上加一个 X 型的 Record Lock。这里的锁数量就是 1 条索引记录。如果 id5 的记录不存在则会在主键索引的间隙上加一个 Gap Lock防止其他事务插入 id5 的数据。等值查询记录不存在时锁的不是某个记录而是一个空区间。这里有一个很容易忽略的点即使这个事务后面没有提交其他事务也能正常读取 id5 这行记录的快照版本但如果其他事务想修改这一行就得等当前事务提交。因为有 X 锁存在。3.2 场景二二级索引等值查询二级索引等值查询比主键复杂得多。假设表结构create table t_user ( id int primary key, name varchar(20), age int, key idx_age(age) ) engineInnoDB;执行begin; update t_user set name zhangsan where age 30;这一步加锁包括二级索引 idx_age 上 age30 对应的索引记录加 Next-Key Lock如果这条记录需要回表那么主键索引上 id 对应的记录加 Record Lock根据扫描路径可能还会在二级索引上锁住相邻的间隙防止幻读。所以即使最终只更新了一行也至少有两个锁一个在二级索引一个在主键索引。你在performance_schema.data_locks里能看到两条 RECORD 锁记录一条锁在idx_age上一条锁在PRIMARY上。还有一个容易忽略的细节如果 age 列上建立的是普通二级索引那么即使 age30 的记录只有一条InnoDB 也不会自动把它当成唯一索引来处理间隙锁依然存在。这是设计使然因为二级索引本身不保证唯一性InnoDB 无法确定区间内是否还会有其他事务插入 age30 的记录。3.3 场景三范围条件 update范围查询是最容易把锁扩散的场景。update t_user set age age 1 where id 5 and id 9;假设主键有 1、3、5、7、9 五条记录那么这条 update 会锁住主键 id5、id7、id9 三条记录同时还有可能锁住这些记录之间的间隙以及范围边界的间隙。由于主键是唯一的范围查询在 RR 下会退化为Record Lock Gap Lock的组合。也就是说间隙 (5,7)、(7,9) 以及范围边界 (9, ∞) 的一部分都可能被锁住。锁的“数量”不是简单的 3 条记录还要加上若干间隙锁。如果你试图往 id6 插入一条数据就会被阻塞因为 id6 落在间隙锁范围内。3.4 场景四where 条件没有索引这是最需要警惕的情况。update t_user set age 20 where name abc;如果 name 列没有索引InnoDB 不得不全表扫描主键聚簇索引。在扫描过程中它无法提前知道哪条记录叫 abc只能把所有扫过的记录都加锁。在 RR 隔离级别下还会把这些记录之间的间隙都锁上。结果就是一条本来只改一行的 update把整个表的所有记录和所有间隙全部锁住。其他事务不仅改不了数据连插入新数据都会被阻塞。如果表的数据量很大这个 update 还会特别慢因为锁的开销和持有时间都会被放大。实际开发里这种“update 不带索引条件”的操作要过 DBA 评审原因就在这。4. 隔离级别是隐藏开关RR 与 RC 完全两个答案4.1 RR 隔离级别下默认 Next-Key LockMySQL 默认的 RR 隔离级别对“当前读”操作默认加的是 Next-Key Lock也就是记录锁加间隙锁。这样做是为了解决当前读下的幻读问题。这个锁策略用一句话总结就是InnoDB 会锁定所有扫描过的区间不只是最终命中的行。所以你在 RR 隔离级别下并发执行大量 update 操作很容易出现锁等待或死锁因为彼此锁定的间隙互相覆盖。4.2 RC 隔离级别下基本只剩 Record Lock如果全局隔离级别改成 RC情况会明显不一样。InnoDB 在 RC 下会关闭间隙锁update 语句只会对真正命中的记录加 Record Lock。这样做的好处是并发度更高锁范围小坏处是无法防止幻读需要应用层用其他手段兜底。比如同样的语句update t_user set age 20 where age between 30 and 40;RR 下锁住 age30、40 的记录并且锁住 30 到 40 之间的所有间隙其他事务在这段区间内插入 age35 会被阻塞RC 下只会锁住 age30 和 age40 两条已有记录其他事务仍可以插入 age35 的新记录。所以面试里如果被问到“有什么区别”可以拿这个例子直接说明不是 update 语法变了而是 InnoDB 的锁策略跟着隔离级别走。4.3 为什么 update 必须加锁select 却可以不加锁这个问题经常被顺带问到。普通的 select 是快照读走 MVCC不需要加锁但 update 必须先读最新版本然后把数据改掉所以它属于当前读。当前读天然要求看到最新已提交的数据并且防止其他事务同时改这一行。从本质上看update 的加锁不只是为了“防别人改我”还是为了在读最新值和写新值之间建立一个临界区。如果这里不加锁两个事务同时 update 同一行就会出现丢失更新。5. 容易被忽略的连表更新与隐式锁5.1 update 关联表时锁的范围可能超出目标表有些业务会写这种语句update t_order o join t_user u on o.user_id u.id set o.status 1 where u.dept_id 10;很多人只看 set 后面的目标表以为只锁 t_order实际上 InnoDB 在扫描关联表 t_user 时也会给相关行加锁。具体加锁范围取决于执行计划怎么扫描关联表。如果 t_user 走全表扫描那扫描过程中的记录也会被加锁。所以连表更新在锁分析时更容易翻车。我的建议是能分成多条 update 就尽量分开做一条 SQL 看起来简洁但锁的范围非常难估。如果必须用连表更新一定要提前看执行计划确认驱动表和被驱动表的访问路径。5.2 隐式锁插入未提交时对其他事务的阻塞效果隐式锁是另一个容易忽略的点。在 InnoDB 中新插入一条记录但事务未提交时它不会像 update 那样显式生成一条锁记录而是通过隐藏事务 ID 实现的“隐式锁”。当其他事务试图修改这条新记录时InnoDB 会判断插入事务是否还在活跃如果是就把隐式锁转换成显式锁然后等待。这也是为什么你有时查锁表看不到锁记录但另一个事务就是卡住动不了。锁不只在锁表里还在记录的隐藏列里。5.3 插入意向锁为什么 insert 会被 update 阻塞插入意向锁本身其实是一种比较特殊的表级锁但它不是用来限制 update 的而是为了让多个事务在同一个间隙里插入数据时能协调位置。当一个 update 在 RR 下锁住了一个间隙另一个事务想在这个间隙里插入数据时会先申请插入意向锁。插入意向锁和已有的间隙锁互斥于是插入就会阻塞。所以你会发现一条普通的 update 在 RR 下竟然能把一段时间内所有往某个区间插入数据的操作全挡住。理解了这一点很多线上问题就解释得通了明明只是更新了一条记录为什么下游 Kafka 消息里的流水少了因为另一个线程要插入的数据被阻塞应用线程等待超时然后报错了。6. 亲手验证一次一台测试库、两条 SQL、看锁列表6.1 验证环境准备与其全靠背不如实际验证一下。准备一张测试表导入少量数据你就能直观看到一条 update 到底加了多少锁。create table t_user ( id int primary key, name varchar(20), age int, key idx_age (age) ) engineInnoDB; insert into t_user values (1, a, 10), (3, b, 20), (5, c, 30), (7, d, 40), (9, e, 50);注意InnoDB 默认 RR 隔离级别所以接下来看到的现象都是 RR 下的表现。6.2 会话一开启事务执行 update 但不提交-- 会话 A begin; update t_user set name zhangsan where age 30;执行之后不要 commit保持事务打开。此时在另一个会话里查询锁状态。6.3 会话二查看 data_locksMySQL 8.0 里可以通过 performance_schema 直接看锁记录select ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA from performance_schema.data_locks;输出大致会是这样一行INDEX_NAME idx_ageLOCK_MODE XLOCK_DATA 30一行INDEX_NAME PRIMARYLOCK_MODE X,REC_NOT_GAPLOCK_DATA 5。第一行表示在二级索引 idx_age 上加了 X 锁同时可能伴随 gap 语义第二行表示主键索引 id5 这条记录加了记录锁。如果你用的 MySQL 5.7可以查select * from information_schema.innodb_locks;也能看到类似的锁记录只是字段名略有不同。用这个方法你能验证我前面说的“二级索引 update 至少两个锁”并不是理论推测。6.4 验证插入被间隙锁阻塞保持会话 A 的事务不提交再开一个会话执行insert into t_user(id, name, age) values (4, f, 25);这条插入会被阻塞。然后回到 data_locks 看除了之前的 X 锁还能看到一条等待中的插入意向锁记录。这个现象直接展示了 Gap Lock 的真实作用。验证完记得把会话 A rollback避免锁一直挂着影响后续测试。7. 高频追问锁等待、死锁、乐观锁和分布式锁7.1 锁等待一条 update 卡住的时候怎么排查线上遇到 update 卡住先别慌按顺序排查-- 查看当前有哪些锁等待 select * from sys.innodb_lock_waits; -- 查看正在执行的 SQL select * from information_schema.processlist where command ! Sleep; -- 查看 InnoDB 状态 show engine innodb status\Gsys.innodb_lock_waits 会直接告诉你哪个事务在等待哪个事务的锁还会显示等待时长。拿到结果后先定位阻塞事务的会话 ID然后和业务方确认是否可以手动 kill。不要一上来就 kill 所有会话那样容易造成数据异常。另外innodb_lock_wait_timeout默认 50 秒意思是锁等待超过 50 秒就报错回滚。如果线上经常出现锁等待超时最该做的不是调大这个参数而是查清楚锁范围为什么这么大最关键的是给 where 条件加合适的索引。7.2 死锁两把锁互相等死锁的经典场景是两个事务各自持有一行锁然后都去请求对方持有的锁。事务 Abegin; update t_user set age 20 where id 1; update t_user set age 30 where id 3; commit;事务 Bbegin; update t_user set age 40 where id 3; update t_user set age 50 where id 1; commit;如果两个事务并发执行到第二个 update就会互相等待。InnoDB 会检测到死锁并选择一个事务回滚。死锁发生后去看show engine innodb status的 LATEST DETECTED DEADLOCK 部分里面会打印两个事务执行过的 SQL、持有和等待的锁。排查的时候重点看事务里的加锁顺序是否一致调整业务代码让所有事务按相同顺序访问资源基本能避免大多数死锁。7.3 乐观锁和悲观锁在 update 里的用法乐观锁是面试常客。典型做法是给表加一个 version 字段update t_user set age 30, version version 1 where id 5 and version 1;执行后影响行数为 1说明更新成功影响行数为 0说明 version 已经变了需要重新读取再重试。这里隐藏着一个锁相关的坑如果 where 条件里只带 version 不带主键而 version 列又没有索引那么这条 update 会全表扫描并锁大量记录。乐观锁是想提升并发结果因为 SQL 写得不讲究反而把锁范围放大并发更差。正确的做法是 where 里优先带主键或唯一索引再叠加 version 条件。悲观锁则直接用select ... for update或update本身的排他锁。比如先查询再更新begin; select * from t_user where id 5 for update; -- 业务处理 update t_user set age 20 where id 5; commit;在 InnoDB 里for update和 update 的加锁逻辑基本一致都是当前读加 X 锁。7.4 和 Redis 分布式锁的区别有人会把 MySQL 行锁和 Redis 分布式锁放一起比较这是两个不同纬度的东西。MySQL 行锁是单库内部的一致性控制分布式锁是跨服务、跨实例的互斥控制。如果多个应用实例操作同一张 MySQL 表单靠数据库行锁互相之间其实还是能通过行锁互斥的只要所有实例都走同一个库问题不大。但有些场景下资源不仅是数据库还涉及缓存、文件、外部接口就需要分布式锁来保证“全局只有一个线程在跑”。所以面试问“有了 MySQL 行锁为什么还要分布式锁”回答方向是MySQL 行锁只能保护数据库事务内的并发分布式锁保护的是更广义的共享资源不能混为一谈。8. 面试回答模板与工作落地建议8.1 一句话版本怎么说先给一个面试能直接用的精简答案框架一条 update 在 InnoDB 中属于当前读必须加排他锁具体锁多少取决于 where 是否走索引、是等值还是范围、是唯一索引还是二级索引、隔离级别是 RR 还是 RC最简单情况主键等值且记录存在只加一个 Record Lock二级索引等值二级索引记录加 Next-Key Lock再回表给主键记录加 Record Lock锁的数量至少两条范围条件锁住扫描范围内所有索引记录以及相关间隙无索引全表扫描所有记录加锁RR 下还会锁间隙。最后记得补一句锁在事务提交或回滚时释放。这一段说完面试官基本知道你是理解锁机制的而不是背了结论。8.2 给开发和 DBA 的落地检查清单写 update 语句之前建议顺手过一遍这个清单where 条件列上有没有合适的索引没有索引的 update 不要直接上生产。索引列的可选择性高不高如果走索引还会扫出几千行锁范围依然很大。是否在 RR 隔离级别下如果是确认间隙锁会影响到哪些并发插入场景。事务里除了这条 update还有没有其他 SQL事务越长锁持有越久。是否能用主键条件或唯一索引条件收窄范围能用就用。8.3 我踩过的一个真实教训印象很深刻的一次线上故障业务表有几十万数据开发在凌晨跑一个批量更新where 条件用的是某个普通字段字段上有索引但区分度极低一个条件扫出几万行。刚好另一个业务在频繁 insert结果从凌晨一点开始insert 全部阻塞直到定时任务跑完。最后凌晨两点被 DBA 拉起来看锁才发现问题出在低区分度索引加 RR 间隙锁的组合上。从那以后我对所有批量 update 都会加一条铁律先 count 一下影响行数影响行数超过阈值就分批处理必须用唯一索引定位到记录如果确实需要大范围更新尽量安排在低峰期单独开事务不要和业务交易混在一起。这也是为什么我建议你花点时间把 data_locks 这个视图用熟。纸上八股背得再熟不如真正跑一次 update自己亲眼看一条 SQL 产生了哪几条锁记录。看一次比背十遍都管用。
返回列表