拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MySQL锁机制详解:从表锁、行锁到InnoDB的RR隔离级别如何防幻读

做后端这几年我最怕的不是慢SQL而是线上突然“锁表”。有一次同事在订单表上跑了个更新数据量不大条件字段也有索引结果整张表的写入全被卡住业务报警一片。最后查原因才发现那条SQL因为隐式类型转换导致索引失效InnoDB从“行锁”一路退化成了“全表加锁”一个本来毫秒级的事务硬生生拖成了几十秒。这种问题面试里问工作里踩理解了底层机制才能彻底避开。今天就把表锁、行锁、InnoDB存储引擎下RR隔离级别怎么避免幻读这整条链路一次性捋清楚附上我整理好的对比表格顺便把“本该走行锁却变成表锁”的典型坑也一并拆了。这篇文章适合后端开发、DBA、以及正在准备MySQL面试的人。看完你可以回答清楚三个问题MySQL的表锁和行锁到底怎么选InnoDB的行锁为什么能锁在“行”上RR隔离级别下幻读到底是被谁拦下来的1. 先说结论锁的粒度与并发是跷跷板1.1 为什么要有锁事务并发下的隔离刚需数据库里的事务要保证ACID其中IIsolation隔离性是所有锁存在的根本原因。当多个事务同时操作同一份数据时如果没有锁去协调就会出现脏写、脏读、不可重复读、幻读这一连串问题。锁的本质是“秩序”和“并发”之间的一个权衡锁的粒度越粗秩序越好维护数据库引擎实现起来越简单但并发能力越差因为无关数据也会跟着被锁住。锁的粒度越细并发能力越强但加锁要维护的信息就越多死锁检测的逻辑也更复杂。MySQL到底支持什么粒度的锁取决于存储引擎。MyISAM引擎只支持表锁所以它写并发很差一旦有写操作整个表的读都会被阻塞。而InnoDB引擎既支持表锁也支持行锁这才是它在高并发OLTP场景下成为主流引擎的核心原因。换句话说选择InnoDB本身就意味着你接受了“行锁”这套更复杂的并发控制机制也意味着你需要真正弄懂它什么时候生效、什么时候失效。1.2 表锁与行锁的差异对比附表格先给结论性的一张对比表后面所有细节都围绕这张表展开对比维度表锁Table Lock行锁Row Lock锁定粒度整张表单条索引记录锁竞争程度高一个写锁阻塞所有读写低不同行之间可并发读写并发能力差写并发尤其差好支持并发度高的OLTP加锁开销小维护成本低大需要定位索引记录、维护锁信息死锁风险低一个事务一次锁整表高多个事务互相持有对方需要的行锁支持引擎MyISAM、InnoDB均可仅InnoDB典型场景全表批量操作、DDL单点更新、范围更新、高频小事务这张表里需要特别留意的是“加锁开销”这一行。表锁开销小是因为它本质就是给表加一个标志位行锁开销大是因为InnoDB要先通过索引定位到具体记录再在每条记录上加锁结构。换句话说InnoDB的行锁并不是挂在“数据行”上的而是挂在“索引记录”上的这个前提极其关键后面讲的很多坑都是从这句话衍生出来的。2. InnoDB行锁的实现基础与“行锁变表锁”的坑2.1 行锁不是锁在“行”上而是锁在“索引”上很多新手第一次知道InnoDB支持行锁后会自动脑补成“锁住某一行的数据”这么理解在功能上没错但机制上会误导你。InnoDB的行锁实际是锁在索引项上的当你执行一条UPDATE或DELETEInnoDB会先从索引中找到符合条件的记录然后在这些索引记录上打锁。这里有三个关键细节值得展开。第一如果条件列上没有索引InnoDB就只能全表扫描把每一行都锁住因为你压根无法快速定位到目标记录。这种全表加锁虽然锁的类型还是行锁但效果和表锁几乎一样其他事务的写入全部会阻塞。第二如果通过二级索引非主键索引定位记录InnoDB会先在二级索引上加锁然后回表到聚簇索引主键索引上再锁一次。这两次加锁缺一不可因为修改记录需要更新主键索引里的数据。第三如果条件列有唯一索引且等值命中那还能精准锁一行如果是非唯一索引那锁定的可能就不止一条记录甚至包括记录之间的“间隙”。这些间隙锁就是RR隔离级别消除幻读的关键武器下一章再展开。2.2 哪些情况会让行锁“退化”成表锁这应该是实际工作中出现频率最高的坑也是热词里“本来应该行锁的结果变成了表锁”指向的问题。我梳理了五类最常见的情况每一项都能写成面试里的反面教材。第一类条件列没有索引。最典型的就是在一个没有索引的字段上做UPDATE或DELETEInnoDB只能全表扫描等于把全表记录全部锁住。比如一个十万行的日志表你用 UPDATE t SET status 1 WHERE name xxx而name列没有索引这条SQL就会锁光所有行。第二类索引失效。索引存在但失效的情况非常多对索引列使用函数或计算比如 WHERE YEAR(create_time) 2024发生隐式类型转换比如字段是varchar类型条件却写 WHERE id 123数字MySQL会把字符串列转成数字比较索引就废了或者前导模糊查询 WHERE name LIKE %abc以及OR条件里有一个字段没有索引也可能让整条查询放弃走索引。第三类优化器选择错误。数据量很小的时候优化器可能认为全表扫描比走索引更快于是选择扫描全表同样导致全表加锁。我之前见过一张只有几十行数据的配置表加了索引也经常被全表锁定就是因为优化器统计信息认为全扫更划算。第四类范围过大。即使走索引如果用大于号、小于号条件筛选出全表80%以上的数据优化器也常常放弃索引直接全扫。这种场景就算加了索引也救不回来需要从业务上拆分条件。第五类显式的LOCK TABLES。InnoDB虽然支持行锁但如果你在业务里手动执行 LOCK TABLES t WRITE那它也会变成直接锁整张表。判断一条SQL到底有没有走索引最直接的办法就是看执行计划EXPLAIN SELECT * FROM account WHERE balance 1500;如果type字段是ALL说明全表扫描行锁大概率已退化成全表加锁如果type是range、ref或const并且key字段有值说明索引生效行锁才有意义。2.3 表锁 vs 行锁的实操选择建议既然行锁并发好是不是所有场景都该优先用InnoDB的行锁不是。表锁在特定场景下依然有它的优势。第一个场景是“读多写极少”的系统。比如纯配置类表、字典表写操作一天一次用MyISAM或显式表锁完全没有问题。第二个场景是“批量操作”。你要对一张表做全量初始化比如清空重灌数据这时如果一条条走行锁不仅慢还会产生大量锁冲突不如直接锁表干脆利落。第三个场景是“报表型查询”。一个复杂的聚合查询要扫描大量数据用表锁能保证数据一致性快照而且不会因为行锁阻塞影响太多并发。但如果你在做一个真正意义上的OLTP系统比如订单、库存、余额请老老实实用InnoDB行锁并确保UPDATE、DELETE的条件列都建了索引。这是我踩过坑之后才真正服气的一句话行锁永远是一种“精度换性能”的资源它值得你用精心设计的索引去维护。3. RR隔离级别到底怎么避免幻读3.1 先搞清楚幻读是什么MySQL标准隔离级别里有四种读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read以下简称RR、串行化Serializable。MySQL的默认隔离级别是RR而Oracle的默认隔离级别是RC。之所以MySQL敢把默认设为RR是因为InnoDB在RR下并没有牺牲太多并发性能手段就是MVCC加锁配合。所谓幻读是指一个事务内执行两次相同的查询第二次却“多出”了一些之前不存在的行。这些行是其他事务在两次查询之间插入并提交的。它跟“不可重复读”容易混淆不可重复读针对的是同一行的值变了UPDATE幻读针对的是结果集的行数变了INSERT。举个例子事务A在订单表里查询所有金额大于100的订单第一次查到10条事务B这时插入了一条金额为200的新订单并提交事务A再次执行相同查询如果多出了事务B插入的那一行就是幻读。幻读之所以难处理是因为要避免它不仅要锁住已有的记录还得锁住“记录与记录之间的空隙”否则新数据总能从缝隙里插进来。3.2 快照读MVCC让普通SELECT自建“平行世界”InnoDB处理幻读的第一条路径是让普通的SELECT语句“不加锁”而是读一个一致性快照。这就是MVCC多版本并发控制。InnoDB会在每行记录后面维护隐藏列包括事务ID、回滚指针等每条记录被修改时旧版本并不会立刻删除而是通过undo log形成一个版本链。当事务开始第一次普通SELECT时InnoDB会生成一个ReadView记录当前活跃事务ID的区间。后续读取数据时凡是事务ID在ReadView中不可见的版本就沿着版本链继续往后找直到找到对当前事务可见的版本为止。这意味着在RR隔离级别下一个事务的普通SELECT只会在第一次查询时生成一次ReadView整个事务期间都用这一个快照。所以即使其他事务提交了新的INSERT对当前事务的普通SELECT来说这些新事务的ID根本不在自己的可见范围内读到的还是第一眼看到的那个世界。这就是“快照读”避免幻读的机制——它压根不理会别人后来插入的数据。3.3 当前读Next-Key Lock锁住的不只是记录问题来了普通SELECT可以靠快照但UPDATE、DELETE、SELECT ... FOR UPDATE这类操作必须读到最新数据因为它们要修改数据不能拿旧快照去覆盖新数据。这类操作叫“当前读”每次都是读最新版本并且必须加锁。那么RR下当前读怎么避免幻读答案是Next-Key Lock不是单纯的记录锁。InnoDB在RR下做当前读时默认加的是“Next-Key Lock”。它等于“记录锁Record Lock 间隙锁Gap Lock”。Record Lock锁住已经存在的索引记录Gap Lock锁住的是这条记录与前一条记录之间的间隙防止其他事务在这个间隙里插入新记录。两者合在一起的锁定范围是一个左开右闭的区间 (a, b]。拿一条实际的SQL举例事务A执行 SELECT * FROM account WHERE balance BETWEEN 1200 AND 2200 FOR UPDATE如果balance列有非唯一索引InnoDB会对命中的记录加Record Lock同时对这条记录之前的间隙也加Gap Lock。这样事务B尝试往这个区间插入新记录时因为目标插入位置落在了被锁的间隙里就会被直接阻塞直到事务A提交或回滚。幻读的核心是“新插入的行”而Next-Key Lock把新插入的路给堵死了。这里有两种需要单独记忆的情况。第一种如果查询命中唯一索引的等值条件比如 WHERE id 100因为唯一索引本身不会插入重复的id值所以InnoDB只加Record Lock不加Gap Lock也没必要加。第二种如果当前读没有命中任何记录比如 WHERE balance 99999 查不到数据InnoDB仍会锁住目标值所在间隙也就是把这个范围前后都给锁住防止其他事务向这个空位插入数据。3.4 RR避免幻读的完整机制总结附表格为了看起来足够直观我把RR下两种读取方式对应的防幻读机制完整汇总成一张表读取类型SQL示例是否加锁核心机制怎样避免幻读快照读普通 SELECT不加锁MVCC ReadView整个事务复用第一次生成的快照看不到其他事务新插入的行当前读SELECT ... FOR UPDATE、UPDATE、DELETE加锁Next-Key LockRecord Lock Gap Lock锁住已有记录及其间隙阻止其他事务在范围内插入新行这张表基本就是面试官想听到的答案框架。如果继续追问“为什么很多文章说RR已经解决了幻读但偶发情况还会出现”请记住一个边界InnoDB的RR防幻读前提是“要么一直用快照读要么一直用当前读”。如果你在一个RR事务里先执行了普通SELECT然后再执行当前读因为当前读永远读最新版本其他事务新插入并提交的行可能会在第二次当前读时出现。但这并不等于严格意义上的幻读因为两次查询的读取方式不同一个读快照一个读当前版本。面试答到这个层次基本就过关了。4. 实操复盘亲手演示一次RR下的幻读拦截4.1 建表与数据准备光讲理论容易虚这里动手做一次完整演示。假设我们有一张账户表结构如下CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(20), balance INT, KEY idx_balance (balance) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO account VALUES (1, Alice, 1000), (10, Bob, 2000), (20, Charlie, 3000);注意balance列上创建了非唯一索引idx_balance这是演示Next-Key Lock能够生效的关键。如果去掉这个索引下面的实验会直接变成全表加锁效果少了很多细节。MySQL默认隔离级别就是RR不需要额外设置。4.2 模拟两个事务一个加锁一个插入开启两个MySQL会话一个作为事务A一个作为事务B。事务A先执行当前读-- 会话1事务A BEGIN; SELECT * FROM account WHERE balance BETWEEN 1200 AND 2200 FOR UPDATE;此刻事务A会命中balance2000的一行id10InnoDB给这条记录加了Record Lock同时给(1000, 2000)和(2000, 3000)这两个间隙加了Gap Lock合并起来就是Next-Key Lock。然后在会话2中事务B尝试插入一条balance1500的新记录-- 会话2事务B BEGIN; INSERT INTO account VALUES (15, David, 1500);这条INSERT会卡住因为1500落在间隙(1000, 2000)里正好被事务A的Gap Lock挡住。你会在会话2看到SQL一直处于等待状态直到超时或事务A提交。整个交互过程如下表步骤事务A会话1事务B会话21BEGIN; 执行 SELECT ... FOR UPDATE命中 id10BEGIN;2持有 id10 的记录锁以及(1000,2000)、(2000,3000)的间隙锁执行 INSERT (15, David, 1500)3继续执行其他SQLSQL阻塞等待获取间隙锁4COMMIT; 释放所有锁插入成功事务B继续4.3 从演示中得到的结论这个实验清楚说明了一件事在RR下一个当前读的范围查询不仅锁住了已有的行还把未来可能插入数据的位置一并锁死。正因为如此那个试图往缝隙里“塞数据”的INSERT才会被拦下来事务A后续再次执行范围查询时结果集和第一次保持一致幻读没有发生。实验中还会出现一个常见问题锁是事务级别的不是语句级别的。事务A必须执行COMMIT或ROLLBACK才能释放所有锁如果你只是在新窗口执行了SELECT却没有提交锁会一直攥在手里直到超时。这跟你是否关闭SQL客户端窗口无关只要你的事务没结束锁就不会释放。很多“数据库突然变慢”的线上事故本质就是某些长事务捂着锁一直不提交。5. 常见问题与排查技巧实录5.1 如何排查锁等待与死锁先给两条日常排查命令一条是看当前有哪些锁和锁等待关系另一条是看最近一次死锁信息-- 查看锁等待 SELECT * FROM sys.innodb_lock_waits\G -- 查看死锁最近记录 SHOW ENGINE INNODB STATUS\G在MySQL 8.0中更底层的锁信息在performance_schema.data_locks表里你可以按线程、按表分组查看具体哪些记录被锁了。遇到“Lock wait timeout exceeded”的错误码1205优先按这个顺序排查先用sys.innodb_lock_waits找到阻塞源头看看是不是有长事务没提交再找到对应事务的SQL判断是不是走了全表扫描导致行锁退化成了全表加锁最后确认业务上能不能优化事务长度把大事务拆成小事务。死锁则不同它通常是两个事务互相等待对方持有的锁。InnoDB的死锁检测机制会主动回滚其中一个事务并报错。真正要解决的不是通过延长超时时间而是从程序层面试着固定操作顺序比如多个事务都按照“先更新主键小的记录再更新主键大的记录”这个顺序来死锁概率会大大降低。如果真的出现死锁优先处理代码逻辑而不是用重试机制去掩盖问题。5.2 面试高频点RR下真的完全没有幻读吗这个问题我在面试新人时几乎必问因为它能区分一个人是背了概念还是真的理解了机制。标准说法是InnoDB在RR隔离级别下通过MVCC解决了快照读的幻读问题通过Next-Key Lock解决了当前读的幻读问题。但这句话不能背完就结束你得知道边界。边界就在于“混合使用快照读和当前读”。假设事务A先执行一个普通SELECT查到balance在1200到2200之间只有一条数据然后事务B插入一条balance1500的记录并提交这时事务A再执行SELECT ... FOR UPDATE会因为当前读看到最新数据而“多”出一条。这算幻读吗严格定义下不算因为两次SQL的读取方式不同。但如果你在系统里真的碰到这种不一致也不能说MySQL骗了你——RR保证的是“同一种读取方式下”的一致性不是跨读取方式的强一致。因此在实际开发中如果你要在一个RR事务里做“先查再改”的操作建议统一用SELECT ... FOR UPDATE进行当前读或者干脆把隔离级别调到Serializable彻底杜绝歧义。RR是一个默认合理但需要你理解它的游戏规则之后才能用好的隔离级别。5.3 避坑心得与建议结合我自己线上踩过的坑最后分享三条心得。第一条建索引不是最终答案你得看执行计划。很多时候索引建了WHERE条件写复杂一点就失效了。养成每次发布前跑一遍EXPLAIN的习惯看到type为ALL就要警惕它不是慢SQL的代名词它是“锁全表”的代名词。第二条锁的范围和事务的边界要一起优化。哪怕一条SQL只锁一行如果它被包在一个10秒的大事务里执行这行锁就持续10秒如果同一行被高频访问系统吞吐量立刻掉一半。减少锁持有时间比减少锁粒度更重要这也是“行锁性能好”背后的隐藏条件。第三条不要以为MyISAM彻底没用了。做报表、日志记录这类读多写少到极致的场景MyISAM的压缩特性和简单结构反而有优势。但绝大多数业务系统请优先选择InnoDB因为行锁、MVCC、崩溃恢复、外键这些能力才是OLTP世界的真正基石。表锁有表锁的舞台行锁有行锁的战场搞清楚它们各自的核心约束你也就真正搞懂了MySQL锁机制最核心的二分法。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门