ARTICLE DETAIL

资讯详情

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

MySQL锁机制详解:行锁、间隙锁与临键锁如何防幻读

MySQL锁机制详解:行锁、间隙锁与临键锁如何防幻读 1. 先搞清楚三把锁的来龙去脉1.1 为什么InnoDB要设计三种锁很多同学一听到MySQL的锁机制第一反应就是背概念行锁锁一行间隙锁锁间隙临键锁锁前面那个区间。但真到了线上死锁排查或者面试追问的时候才发现这三个东西不是孤立存在的它们是同一个整体设计下的三个侧面。先说一个被反复验证的结论MySQL锁机制的核心不是锁住数据而是锁住索引记录和索引记录之间的空隙。为什么要锁空隙因为要防幻读。在InnoDB默认的可重复读RR隔离级别下快照读靠MVCC已经能保证一致性但当前读SELECT ... FOR UPDATE、UPDATE、DELETE必须让事务在物理层面排斥掉别人往我读过的范围里插入新数据的可能。行锁只能锁住已经存在的记录锁不住未来可能插入的记录所以光有行锁拦不住幻读。间隙锁Gap Lock就是专门用来锁住索引记录之间那块真空地带的。而临键锁Next-Key Lock则是行锁和间隙锁的组合体把记录本身和记录前面的空隙作为一个整体来锁定。这三者之间的关系可以这样理解行锁是基础款只锁一条具体记录间隙锁是进阶款只锁一段空档临键锁是组合款同时锁住空档记录。InnoDB在RR隔离级别下默认对索引扫描加的就是临键锁只有当条件命中唯一索引且是等值查询时才会退化成单纯的行锁。1.2 锁的载体是索引不是数据行这是整个MySQL锁机制里最容易踩坑的地方。InnoDB的行锁、间隙锁、临键锁全部都是建立在索引之上的锁定的对象永远是索引记录而不是数据行本身。为什么因为InnoDB的聚簇索引主键索引的叶子节点就保存着整行数据只要在主键索引上锁住了某条索引记录就等于锁住了这行数据。如果走的是二级索引就要在二级索引记录上加锁再回表到主键索引上加锁两层都要锁住。这就引出一个非常实际的问题如果一个UPDATE语句的WHERE条件没有走索引那MySQL会怎么办它会做全表扫描然后把扫描过程中碰到的每一条记录都加锁。表面上看你只是想更新一行数据实际却把整张表的所有记录都锁住了——这就是很多线上死锁和锁等待超时问题的根源。所以判断一条SQL会锁住什么范围第一步永远是看它的执行计划确认它走了哪条索引、扫了多少记录。索引走没走对加锁范围完全不一样。1.3 三把锁的速览与对比表在正式开始逐个拆解之前先用一张表把三者的核心差异列清楚后面每个章节再展开细节。锁类型锁定对象解决什么问题典型触发场景隔离级别行锁Record Lock单条索引记录写写互斥唯一索引等值命中记录后UPDATE/DELETERR、RC 均存在间隙锁Gap Lock两段索引记录之间的空隙防止其他事务向空隙插入数据等值未命中、范围查询、非唯一索引等值查询RR 默认启用临键锁Next-Key Lock间隙 该间隙右侧的第一条记录同时防幻读和写冲突RR 下的索引范围扫描、普通索引等值查询RR 默认使用有一点要提前说明在已提交读RC隔离级别下InnoDB默认不会启用间隙锁和临键锁只保留行锁。为什么很多DBA建议把隔离级别调成RC其中一个重要原因就是RC下锁的粒度更小、死锁概率更低但代价是你要自己处理一部分幻读问题。关于这个取舍后面的常见问题里会专门展开。2. 行锁只锁一条记录但没你想的那么简单2.1 行锁到底锁住了什么东西行锁英文叫Record Lock作用对象是一条具体的索引记录。它很好理解就像图书馆里有人占了某个座位其他人就不能再坐同一个位置。InnoDB的行锁有两种模式共享锁S锁和排他锁X锁。S锁和S锁兼容S锁和X锁不兼容X锁和X锁也不兼容——日常写操作都是X锁。行锁的锁定范围非常精确只有当SQL能够通过索引精准定位到某一条记录时才会只锁这一条。最常见的情形就是主键或唯一索引上的等值查询。比如说按id 5更新数据命中的是主键索引上的唯一一条记录那么锁就只在id 5这一条索引记录上。其他记录完全不受影响其他事务可以随意更新id 3、id 7与这个锁不冲突。不过有一点需要特别注意行锁锁的是索引记录不是整行数据的物理拷贝。同一行数据在主键索引和二级索引里都有对应的索引记录如果一条SQL同时碰了这两个索引那两边的记录都会被锁住。后面讲普通索引场景时你会看到这往往才是加锁范围扩大的真正原因。2.2 行锁加锁的SQL形态与实验用一个最基础的例子演示行锁的实际加锁行为。假设有下面这张表CREATE TABLE t ( id INT PRIMARY KEY, val VARCHAR(20) ) ENGINEInnoDB; INSERT INTO t VALUES (1, a), (2, b), (3, c), (4, d), (5, e);事务A先执行BEGIN; UPDATE t SET val x WHERE id 3;此时事务B执行同样的更新BEGIN; UPDATE t SET val y WHERE id 3;事务B会一直阻塞SQL会卡住不返回直到事务ACOMMIT或ROLLBACK。你可以在另一个会话里用下面的SQL查看锁的具体状态SELECT * FROM performance_schema.data_locks\G输出中你会看到类似这样的关键信息LOCK_TYPE: RECORD LOCK_MODE: X, REC_NOT_GAP LOCK_DATA: 3REC_NOT_GAP表示这是一个纯粹的记录锁不带间隙锁成分。LOCK_DATA: 3表示锁住的是主键索引中值为3的那条索引记录。这说明按照主键等值精确命中时InnoDB确实只加了一把行锁没有任何多余的间隙锁定。这也是InnoDB最理想的加锁状态锁得最少、影响最小、并发最高。注意写操作默认加的是X锁。如果只是为了读取且不希望阻塞别人的写操作可以使用LOCK IN SHARE MODE加S锁。但在绝大多数业务场景下写操作都要互相排他S锁的应用场景比较有限。2.3 行锁的隐式全表扫描陷阱行锁本身不可怕可怕的是行锁被无意识地扩大成了所有行都锁住。最常见的原因就是WHERE条件没有索引。还是用上面这张表假设val列上没有任何索引执行BEGIN; UPDATE t SET val x WHERE val c;这条SQL乍一看只更新一条记录但InnoDB为了找到val c这一行需要对整张表做全表扫描。在扫描过程中它每扫到一条记录就会给这条记录加锁。你从data_locks里会看到表里5条索引记录全部被加上了X, REC_NOT_GAP锁。如果这张表有10万行那就是10万条记录全部锁住。这意味着事务A只是更新一条数据却导致整个表的写入全部停摆。别的会话哪怕只是更新id 1这一行毫不相干的数据也得默默等事务A提交。这个坑我在很多公司的线上环境里都见过。某次一个同事写了一条UPDATE orders SET status 2 WHERE order_no xxx但order_no字段忘了建索引结果整个订单表的写操作全卡住了。排查到最后就是缺失索引导致的锁范围爆炸。所以我在排查锁问题时的第一反应永远不是看死锁日志而是先看这条SQL的执行计划确认它走了索引没。凡是WHERE条件涉及的业务字段如果频繁出现在更新、删除语句里一定要建好索引。宁可多加一个索引也不要让一行更新演变成全表锁死。3. 间隙锁隔离带与幻读之间的较量3.1 间隙锁锁的是什么间隙先说一个核心概念索引记录之间有空隙。假如主键索引里有id 3和id 7两条记录那3和7之间就存在一段空档未来可能有id 4, 5, 6插入进来。间隙锁锁定的就是这段空档。间隙锁的作用非常单一就是阻止其他事务向这个空档里插入新记录。它不锁任何已经存在的记录所以不会阻止其他事务更新已有的记录。这个设计有一点非常反直觉两个间隙锁之间是互相兼容的。事务A锁住了区间(3, 7)事务B也能同时锁住这个区间两者并不冲突。因为A和B锁的都是这段空隙空隙里本来就没有数据谁锁都不会影响谁。真正和间隙锁冲突的是插入意向锁——只要有事务想往这个空隙里插入记录它就得先拿到插入意向锁而插入意向锁与已存在的间隙锁互斥。这个特性是后面许多死锁的根源我们第三节专门讲。为什么需要间隙锁回到幻读的定义事务T1用SELECT ... WHERE id BETWEEN 3 AND 7 FOR UPDATE读取了一批记录事务T2往这个区间插入了新记录T1再执行同样的查询结果多了几行凭空出现的数据这就是幻读。唯一索引等值查询命中记录时行锁就能防住并发修改但未来的记录只能靠间隙锁来防。RR隔离级别下InnoDB要保证当前读不出现幻读所以间隙锁是必须的。3.2 触发间隙锁的典型SQL与加锁区间间隙锁的触发条件比很多同学想的更宽。整理成三类最常见的场景第一类唯一索引等值查询未命中记录。-- id 6 这行不存在表里有 id 5 和 id 7 BEGIN; SELECT * FROM t WHERE id 6 FOR UPDATE;这条SQL锁不住任何已有记录因为没有 id6但它会锁住(5, 7)这个间隙。此时其他事务想插入id 6这条记录就会被阻塞直到事务结束。这就是等值未命中退化为间隙锁的典型情况。第二类非唯一索引等值查询。非唯一索引允许重复值等值查询命中的记录可能不止一条。InnoDB对每一条命中的记录都要加临键锁同时对记录后面的间隙也要加间隙锁。这也是为什么非唯一索引的等值查询加锁范围往往比直觉上大很多。第三类范围查询。WHERE id 3 AND id 7这类范围条件会对扫描到的每条索引记录加临键锁然后对最后一个不满足条件的记录前面的间隙也要处理。其实这里的临键锁已经包含了一部分间隙锁成分范围越大锁的范围越广。为了直观说明假设表里有id 1, 3, 5, 7, 9五条记录。执行BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 7 FOR UPDATE;你会锁住(1, 3]、(3, 5]、(5, 7]三个临键区间并且还有(7, 9)这个间隙锁。也就是说虽然你只想读 3、5、7 三条记录但 id 在 2 到 9 之间不包括9的所有位置都被锁住了其他事务在这个范围内插入任何新记录都会被阻塞。这也是为什么RR模式下长事务里的范围查询常常成为性能瓶颈。一个不当心的范围更新可能锁住一大片空隙把并发写入全部堵死。3.3 间隙锁与死锁的经典组合间隙锁最容易引发的死锁我单独拿出来讲因为这是线上最常见的死锁模型之一。回到间隙锁兼容、插入意向锁冲突这个特性。看下面这个非常经典的场景表里有id 1、5、9三条记录事务A执行BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 7 FOR UPDATE;此时A锁住了(1, 5]和(5, 9)的间隙。事务B执行同样一条查询BEGIN; SELECT * FROM t WHERE id BETWEEN 3 AND 7 FOR UPDATE;因为间隙锁之间兼容B也成功锁住了同样的区间。两个事务都以为自己控制了这片区域。然后事务A尝试INSERT INTO t VALUES (6, a)。插入意向锁与B持有的间隙锁互斥于是A进入等待状态等B释放间隙锁。接着事务B也尝试INSERT INTO t VALUES (6, b)。同样B的插入意向锁与A持有的间隙锁互斥B也进入了等待状态。现在A在等BB在等A两个事务互相死锁。InnoDB的死锁检测器检测到之后会立刻选择回滚其中一个事务让另一个继续执行。你在SHOW ENGINE INNODB STATUS里会看到一条明确的死锁报告指出两个事务各持有什么锁、各自在等什么锁。这类死锁在RR隔离级别下非常常见尤其容易出现在先查后插的业务逻辑里比如判断某个用户名不存在后再插入。两个并发请求同时查都发现不存在然后同时插入间隙锁互相卡住。后面第5章会专门讲怎么用日志定位这类问题。4. 临键锁行锁与间隙锁的复合产物4.1 临键锁的左开右闭区间临键锁Next-Key Lock是InnoDB在RR隔离级别下最重要的锁结构。它本质上是间隙锁 行锁的组合锁住一条索引记录同时锁住这条记录前面的那段间隙。如果索引记录有 1、3、5、7、9那么临键锁会把整个索引空间切成这样的区间(-∞, 1] (1, 3] (3, 5] (5, 7] (7, 9] (9, ∞)注意这些区间的表示方式左开右闭。每一个区间都包括右端的那条记录本身同时包括左端记录到右端记录之间的空隙。这就是为什么临键锁既能锁住已有记录行锁部分又能锁住未来的插入间隙锁部分。在InnoDB里RR隔离级别下的索引扫描默认施加的就是临键锁。MySQL 8.0的data_locks表里临键锁的LOCK_MODE字段会直接显示为X没有特殊的后缀它和行锁的X, REC_NOT_GAP有明显区别。4.2 临键锁的退化与升级规则临键锁并不是对所有SQL都是同一个形态它会根据索引类型和查询条件发生退化。我把规则整理成几条这是面试和实战中都必须记牢的规则一唯一索引等值查询命中记录退化为行锁。原因很直接唯一索引保证了记录的唯一性既然已经精确锁定某一条记录幻读的威胁就不存在了InnoDB优化掉间隙部分只保留记录锁。这是最省锁的方案。规则二唯一索引等值查询未命中记录退化为间隙锁。既然没有命中记录行锁无处安放但为了防止其他事务插入这条不存在的记录造成幻读间隙锁仍然保留。此时LOCK_MODE显示为X, GAP。规则三范围查询和非唯一索引查询保持临键锁。范围无法预测到底有多少记录会被扫描非唯一索引又允许重复值这两种情况都必须同时锁住记录和间隙否则无法保证可重复读语义。规则四表的末尾加锁到 supremum 伪记录。在InnoDB索引中每条索引的末尾都会有一个特殊的伪记录叫supremum pseudo-record代表正无穷大。当范围查询扫到索引尾部时会在supremum上加临键锁称号就是最后的防线锁住(最大记录, ∞)这个区间。如果你在data_locks里看到LOCK_DATA: supremum pseudo-record不要慌这是正常的尾部锁。这套退化规则在面试里几乎是必考题。比如面试官问你RR隔离级别下UPDATE t SET valx WHERE id5id 是主键会加什么锁答案就是等值命中退化为行锁不是临键锁。这题答不出来锁机制基本算白学了。4.3 普通索引场景下最容易被忽视的锁定范围普通索引非唯一索引是线上最容易被低估加锁范围的场景。我见过太多人以为普通索引等值查询也是锁一条记录结果死锁日志一出来锁的范围大得惊人。用一个实际例子说明。假设表里有CREATE TABLE t ( id INT PRIMARY KEY, name VARCHAR(20), KEY idx_name (name) ) ENGINEInnoDB;数据(1, a),(3, a),(5, c),(7, c),(9, e)。事务A执行BEGIN; UPDATE t SET name x WHERE name a;注意这个SQL在二级索引idx_name上等值查询命中了a对应的两条二级索引记录namea且id1、namea且id3。加锁的情况是在二级索引idx_name上对(1, a)和(3, a)两条记录加临键锁同时对后面(3, a)到(5, c)之间的间隙加间隙锁同时回表在主键索引上对id1和id3两条主键记录加行锁。也就是说一条等值更新锁住的不仅仅是a那两条记录连namec的第一条记录之前的空隙都被锁住了。此时任何事务想插入nameb或namea的任意新记录都会被阻塞。这就是普通索引和唯一索引的等值查询加锁范围差距悬殊的根本原因。在做业务开发时如果你知道某个字段虽然加了索引但不是唯一的就要对等值更新会锁住一段间隙有明确的预期尽量把更新范围缩小否则高并发插入同一个索引段时堵车概率极高。5. 锁冲突排查与常见问题速查5.1 线上死锁日志怎么读遇到死锁第一反应不是看业务代码而是先拿到InnoDB的死锁现场。最直接的命令SHOW ENGINE INNODB STATUS\G在输出里找LATEST DETECTED DEADLOCK段落里面会详细列出参与死锁的两个事务。我摘录一个简化的结构来解释*** (1) TRANSACTION: TRANSACTION 2728, ACTIVE 3 sec LOCK WAIT MySQL thread id 10, query id 100 ... UPDATE t SET valx WHERE id6 *** (1) HOLDS THE LOCK(S): X, REC_NOT_GAP -- 事务1持有某条记录的行锁 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: X, GAP -- 事务1在等待某个间隙锁 *** (2) TRANSACTION: TRANSACTION 2729, ACTIVE 3 sec LOCK WAIT ... INSERT INTO t VALUES (6, y) *** (2) HOLDS THE LOCK(S): X, GAP -- 事务2持有间隙锁 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: X, REC_NOT_GAP -- 事务2在等待行锁读这个日志的核心逻辑只有一条看每个事务持有什么锁和在等什么锁然后找出那个循环等待的环。比如上面的日志告诉我们事务1持有行锁、等间隙锁事务2持有间隙锁、等行锁——环就形成了死锁成立。拿到死锁日志之后再回到业务代码里还原两条SQL的执行顺序基本就能锁定问题出在哪一方。90%的情况下都能归结为要么两条SQL加锁顺序不一致要么某条SQL的间隙锁范围过大导致互相等待。5.2 锁等待超时与阻塞事务排查死锁和锁等待超时是两回事。死锁会被InnoDB死锁检测器主动发现并回滚其中一个事务锁等待超时则是一个事务等锁超过阈值后被动放弃报错信息是经典的ERROR 1205 (HY000): Lock wait timeout exceeded默认innodb_lock_wait_timeout是50秒。如果线上经常出现这个报错说明有事务长时间占着锁不放。排查步骤按顺序来第一步查看当前有哪些事务在执行以及运行时长SELECT * FROM information_schema.innodb_trx\G重点看trx_started、trx_state、trx_mysql_thread_id这几个字段。如果发现某个事务已经跑了很久还处于RUNNING状态大概率就是它占着锁阻塞了别人。第二步看锁等待关系SELECT * FROM sys.innodb_lock_waits\G这张视图会直接告诉你哪个事务在等锁、谁阻塞了它。其中waiting_pid是等锁会话的线程IDblocking_pid是持锁会话的线程ID。定位到阻塞事务后可以直接杀掉该会话KILL blocking_pid;不过这里有个提醒KILL之前一定要确认这个事务是否回滚了一半否则杀了大事务可能导致回滚过程漫长在线上一时半会也起不到立竿见影的效果。第三步看锁的细节SELECT * FROM performance_schema.data_locks\G判断当前持锁类型是REC_NOT_GAP行锁还是GAP间隙锁还是普通X临键锁配合前面讲的知识推断加锁范围是否合理。5.3 高频问题速查手册最后整理一份我在实际工作和面试中反复遇到的锁问题速查表直接把结论放在这里问题场景加锁结果说明与注意事项主键等值更新记录存在RR隔离级别行锁X, REC_NOT_GAP唯一索引等值命中临键锁退化性能最佳主键等值更新记录不存在RR隔离级别间隙锁X, GAP锁住该记录前后间隙防止幻读插入普通索引等值更新多条临键锁 间隙锁 对应主键记录锁加锁范围可能远大于预期注意索引设计范围查询更新多个临键锁 末尾间隙锁范围越大锁越多避免长事务大范围更新RC隔离级别下的更新只有行锁无间隙锁死锁概率降低但可能出现幻读需要业务兜底两个事务同时对同一间隙加锁再插入死锁间隙锁兼容、插入意向锁互斥导致的经典模型无索引条件更新全表记录加锁执行计划会全表扫描锁范围爆炸必须避免SELECT ... FOR UPDATE与UPDATE混用取决于索引与隔离级别当前读都会加锁快照读不加锁注意区分关于隔离级别的选择这里多说一句有些团队为了降低死锁概率会把隔离级别从RR调到RC。RC下没有间隙锁和临键锁锁的粒度确实更小并发能力和死锁表现都会更好。但代价是可能出现幻读而且 binlog 格式必须设置为 ROW 模式避免基于 STATEMENT 格式时出现主从数据不一致。这不是一个简单的参数切换需要团队结合业务场景评估。还有一个参数值得一提innodb_locks_unsafe_for_binlog翻译过来就是允许锁不安全以迁就binlog设置后InnoDB会禁用间隙锁只保留行锁。我个人的态度非常明确不要在生产环境打开它。它解决的是历史版本的binlog一致性方案现在 ROW 模式 binlog 已经能正确记录变化没必要为了减少锁竞争牺牲RR隔离级别的幻读保护。与其动这个参数不如把SQL索引优化好、把事务粒度缩小。我个人的体会是锁机制学得好不好不看你能不能背出临键锁的定义而看你在死锁日志面前能不能三分钟之内还原出加锁现场。写业务代码的时候多问自己一句这条SQL有索引吗这个事务会跑多久它会在哪个区间留下锁把这三个问题想清楚绝大多数锁相关的坑都能绕开。
返回列表