ARTICLE DETAIL

资讯详情

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

MySQL事务隔离级别详解:从MVCC到生产环境配置实践

MySQL事务隔离级别详解:从MVCC到生产环境配置实践 做后端开发和数据库运维的同学应该都经历过这种场景线上一条慢 SQL 把整张表锁住或者某个事务里先查后改结果两条相同条件的 SQL 查出来的数据不一样最后对账对到怀疑人生。这些破事十有八九都和事务隔离级别有关。MySQL 的事务隔离级别是 InnoDB 引擎里最核心也最容易讲糊的一个概念。说它核心是因为它直接决定了并发场景下数据的可见性和一致性说它容易讲糊是因为网上教程要么只丢出四种隔离级别和三个问题的对照表要么一上来就是 ReadView、版本链、间隙锁这种劝退术语。这篇内容我打算用实际踩坑的经验把这些概念串起来从并发问题到 MVCC 实现再到生产环境怎么选尽量讲得能直接上手用。这篇内容适合谁刚准备面试的 Java 后端被线上死锁折磨的运维以及写了好几年 SQL 但一被问到“RR 和 RC 到底差在哪”就卡壳的开发同学。看完之后你至少能搞清楚三件事四种隔离级别分别解决什么问题、MySQL 默认的可重复读为什么不会像教科书里说的那样产生幻读、以及生产环境里隔离级别到底应该怎么配。1. 先搞清楚事务隔离级别到底在解决什么问题1.1 事务的 ACID 与隔离性的位置要理解隔离级别得先回到事务本身。事务是数据库执行逻辑的最小单元ACID 四个特性里A 是原子性、C 是一致性、I 是隔离性、D 是持久性。原子性靠 undo log 实现持久性靠 redo log 实现而隔离性靠的就是我们今天要聊的锁和 MVCC。很多人把隔离性理解成“事务之间完全互不干扰”这个说法不准确。完全互不干扰是最高级别的隔离但数据库为了性能和并发度默认允许事务之间存在一定程度的“干扰”。隔离级别就是一套用来权衡“数据一致性”和“并发性能”的规则级别定得越高数据越安全但并发能力越差级别定得越低吞吐量越大但可能读到乱七八糟的中间状态。MySQL 里 InnoDB 支持四种隔离级别按严格程度从低到高依次是隔离级别英文名称脏读不可重复读幻读读未提交READ UNCOMMITTED可能可能可能读已提交READ COMMITTED不会可能可能可重复读REPEATABLE READ不会不会可能InnoDB 实际解决了串行化SERIALIZABLE不会不会不会注意这张表里的“可能”和“不会”它描述的是理论上的隔离标准。但 MySQL 的 InnoDB 引擎在可重复读级别下通过 MVCC 和间隙锁把幻读也基本解决了这和教科书里讲的“可重复读会存在幻读”有明显差异。这个点后面单独展开。1.2 并发事务会闯什么祸三个经典读异常在动手调隔离级别之前先把三个基础问题彻底吃透。这三个问题是理解隔离级别的钥匙。脏读指的是一个事务读到了另一个事务还没提交的数据。打个比方A 事务给商品价格从 100 改成 80改完之后还没提交B 事务这时候去读价格读到了 80。结果 A 事务后悔了回滚价格变回 100。B 事务手里的 80 就是个根本不存在过的数据。这种情况只能发生在读未提交级别下只要把级别提到读已提交脏读立刻消失。不可重复读指的是同一个事务内同一条 SELECT 语句执行两次结果不一样。原因是有别的事务在这两次查询之间提交了修改。比如 C 事务第一次查价格是 100这时候 D 事务把 100 改成了 80 并提交C 事务第二次查价格变成了 80。同一个事务里两次读对不上这在账务类系统里是要出大问题的。读已提交级别允许这种情况发生可重复读级别保证同一个事务里多次读的结果一致。幻读指的是同一个事务内执行同一条范围查询两次得到的结果集行数不一样。比如 E 事务查订单表里金额大于 100 的订单第一次查出来 5 条这时候 F 事务插入了一条金额 200 的订单并提交E 事务再查就变成 6 条。多出来的那条就是“幻影”数据。注意幻读和不可重复读的区别不可重复读是同一行数据的内容变了幻读是结果集的行数变了重点在“多出来”的记录上。这三个问题里脏读是底线任何靠谱的系统都不能容忍不可重复读和幻读则是很多业务场景的硬性要求。接下来看四种隔离级别各自的处理方式。2. 四种隔离级别逐个拆解2.1 READ UNCOMMITTED裸奔的读读未提交是最低级别的隔离它的规则就一条一个事务可以读到其他事务还没有提交的数据。这个级别基本不在生产环境出现存在的意义更多是理论上的完整性。实际会发生什么A 事务 UPDATE 了一条记录但不提交B 事务 SELECT 这条记录直接就能看到修改后的值。如果 A 事务回滚B 事务已经基于一个不存在的数据做了后续操作整个逻辑链路就全错了。这玩意儿在金融、订单这种对数据准确性要求极高的业务里就是灾难。但读未提交也并不是完全没有可用场景。有些对实时性要求极高、对准确性容忍度也高的场景比如统计在线人数的缓存表、非关键性的计数展示可以用它减少锁开销。印象里有段时间我做活动页的实时在线人数用的就是读未提交因为那个数据多一百少一百没人看得出来但延迟必须毫秒级。绝大多数业务系统不建议碰这个级别。2.2 READ COMMITTED行业最常见的默认选择读已提交解决了脏读但允许不可重复读和幻读。它的核心规则是一个事务只能读到其他事务已经提交的数据。这条规则是通过“每次 SELECT 都重新生成一次 ReadView”实现的这也是它和可重复读最根本的区别。Oracle 和 PostgreSQL 的默认隔离级别就是读已提交。为什么这么多数据库把它当默认因为它在数据一致性和并发性能之间找到了一个很好的平衡点很多业务场景下我们并不需要在一个事务内多次读取完全一致的结果。比如电商系统里一个查询商品列表的操作第一次读到库存 10 件另一个事务同时扣了 2 件库存并提交第二次读到 8 件。这在大多数业务场景下是完全能接受的——库存本来就是实时变动的你甚至会希望它每次读到的都是最新值。读已提交的价值就在这里每个 SELECT 都拿到最新已提交数据避免读到过期的旧数据。我实际经验里不少银行核心系统反而用读已提交需要强一致性的地方用显式锁或串行化兜底。读已提交还有一个好处是锁范围小InnoDB 在 RC 级别下只加记录锁不加间隙锁死锁概率和锁竞争的概率都会低不少。2.3 REPEATABLE READMySQL 的“主场”隔离级别可重复读是 MySQL 的默认隔离级别。它的核心规则同一个事务内第一次 SELECT 之后后续所有 SELECT 都基于同一个快照读到的数据完全一致不受其他事务提交的影响。这是咋做到的呢靠 MVCC 多版本并发控制。InnoDB 在事务第一次执行快照读时生成一个 ReadView这个 ReadView 相当于给事务拍了一张“数据快照”记录下当时哪些事务已提交、哪些正在活跃。后续所有的 SELECT 都基于这个 ReadView 判断数据的可见性所以无论其他事务怎么改这个事务看到的都是第一次查询时的样子。这里有个关键点很多新手会误解可重复读只是保证“快照读”一致不保证“当前读”一致。快照读就是普通的 SELECT当前读是带锁的读操作比如 SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE。当前读永远读最新版本的数据不受快照限制。这就是为什么可重复读级别下一个事务里先快照读再 UPDATE然后 SELECT 还是能看到自己改完的数据——因为 UPDATE 走的是当前读改了以后数据的版本链变了但快照读还是按第一次的 ReadView 来判断可见性而自己修改过的数据版本事务 ID 和自己一致所以照样可见。这个机制看着绕实际体验下来是很顺滑的。MySQL 为什么把可重复读当默认历史原因从 5.0 之前就定下来了因为早期 binlog 只有 statement 格式为了保证主从数据一致需要可重复读配合间隙锁来避免幻读导致的主从不一致。现在 binlog 已经有了 row 格式理论上默认可以改成读已提交但 MySQL 至今没改原因很简单兼容性。改默认值会影响所有存量系统的行为对数据库这种底层软件来说默认行为的稳定性远比理论上的更优重要。2.4 SERIALIZABLE用性能换绝对一致串行化是最高隔离级别读和写之间完全互斥。规则所有事务按顺序执行事务之间没有任何并发。实现上InnoDB 在 SERIALIZABLE 级别下所有普通 SELECT 也会自动加上共享锁也就是变成当前读写操作加排他锁。读读不阻塞读写、写写全部阻塞。效果是数据绝对安全但并发能力极低。一个慢查询能堵住后面所有事务这在互联网高并发场景下基本不可用。什么时候用数据一致性要求极高、并发量极低的系统比如记账核心的某些批处理环节、对账任务里的关键事务。我曾经在做一个数据迁移工具时迁移最后一步的校验阶段把它临时切成串行化保证迁移过程没有任何并发写入校验结果完全可信跑完再切回来。日常线上业务不建议全局设置串行化。3. MySQL 凭什么在 RR 下搞定幻读MVCC 和锁3.1 快照读与当前读先分清这两个学 MySQL 事务隔离级别最容易被绕进去的就是没搞懂快照读和当前读。快照读是最常见的普通 SELECT。它不加锁直接通过 MVCC 读取版本链上的历史版本理论上性能最高。在可重复读级别下快照读的数据来自事务第一次 SELECT 时生成的 ReadView所以多次读取结果一致。在读已提交级别下每次 SELECT 都重新生成 ReadView所以每次读到的都是最新已提交数据。当前读是带锁的读常见的包括 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE。这些操作必须读取数据的最新版本并且会对读取的记录加锁防止其他事务并发修改。当前读在可重复读级别下还得依赖间隙锁来解决幻读问题。理解这两者的区别特别重要因为很多线上问题都出在快照读和当前读混用上。最常见的一个坑事务里先 SELECT 查了一条记录然后用 UPDATE 去改这条记录结果 UPDATE 发现记录被别的事务锁住了直接锁等待超时。原因是这两个操作一个用的是快照读一个用的是当前读锁行为完全不同。3.2 版本链和 ReadViewMVCC 的工作方式MVCC 翻译成中文是多版本并发控制核心思想是同一行记录同时存在多个历史版本不同事务通过判断版本可见性读取适合自己的数据。每个数据行上InnoDB 会隐藏两个关键字段trx_id最近修改该行的事务 ID和 roll_pointer指向上一个版本的 undo log 地址。每一次 UPDATEInnoDB 不会直接覆盖旧数据而是把旧数据写到 undo log通过 roll_pointer 串成一条版本链最新版本在最上面。ReadView 是事务执行快照读时生成的一个视图里面保存了四个关键信息创建这个视图的事务 ID、当前活跃事务 ID 列表、活跃事务列表里最小的 ID、以及下一个将要分配的事务 ID。判断一行数据是否对当前事务可见规则是这样的如果数据的 trx_id 等于当前事务 ID说明是自己改的可见如果 trx_id 小于活跃事务最小 ID说明该事务已提交可见如果 trx_id 大于等于下一个分配的事务 ID说明是快照之后才开启的事务不可见如果 trx_id 在活跃事务列表里说明这个事务还没提交不可见如果 trx_id 不在活跃事务列表里但小于下一个分配 ID说明已经提交了可见这个判断规则看着复杂其实就是问一个问题这个数据版本是不是在我这个事务开启之前就已经提交了的是就能看不是就不能看。沿着版本链往回找直到找到一个可见的版本。在可重复读级别下ReadView 只在第一次快照读时生成一次后续复用。在读已提交级别下每次快照读都重新生成。这就是两种隔离级别在快照读表现上差异的本质原因理解了这一层很多面试题都能迎刃而解。3.3 间隙锁和临键锁当前读如何防幻读快照读靠 MVCC 解决了可重复读下的普通查询问题但当前读呢比如一个事务里执行 SELECT ... FOR UPDATE 查某个范围的订单另一个事务往这个范围里插入一条新订单如果不加额外机制这个事务第二次执行同样的当前读会多出一条数据这就是幻读。InnoDB 的解决方案是间隙锁和临键锁。间隙锁锁的是一个开区间范围比如 id 在 (10, 20) 之间的“空隙”防止其他事务在这个空隙里插入数据。临键锁是记录锁加间隙锁的合体锁的是一个左开右闭区间比如 (10, 20]既锁住 20 这条记录又锁住 10 到 20 之间的空隙。在可重复读级别下InnoDB 对当前读默认使用临键锁范围查询时锁范围覆盖到所有可能插入新数据的间隙。实际效果是什么一个事务执行 SELECT * FROM orders WHERE amount 100 FOR UPDATE另一个事务想 INSERT 一条 amount 150 的订单会被阻塞住直到第一个事务提交。这样第一个事务就算反复执行同样的当前读结果集也不会增加幻读被物理上杜绝了。但间隙锁有个明显的副作用锁范围可能比实际需要大很多导致并发插入被卡住。比如一个简单的等值查询定位不到记录比如查 id 1000 但表里没有这条记录InnoDB 会把 (上一个记录, 1000) 的间隙也锁住。这在生产环境里是死锁和锁等待的重灾区后面专门聊排查方法。读已提交级别没有间隙锁只用记录锁这个问题会好很多这也是为什么很多高并发系统会主动从可重复读切到读已提交的原因。4. 实操怎么查看和设置隔离级别4.1 查看当前隔离级别动手验证之前先学会查当前会话和全局的隔离级别。MySQL 5.7 及以后版本用这条 SQLSELECT session.transaction_isolation; SELECT global.transaction_isolation;MySQL 5.6 及更老的版本变量名是 tx_isolation 而不是 transaction_isolationSELECT session.tx_isolation;如果你不确定你用的版本支持哪个可以直接用 SHOW VARIABLESSHOW VARIABLES LIKE transaction_isolation; SHOW VARIABLES LIKE tx_isolation;两条都执行一下哪条不报错用哪条。执行结果会显示类似 REPEATABLE-READ 这样的值注意中间是连字符不是空格。MySQL 8.0 和 5.7 在默认安装情况下会话级和全局级都是 REPEATABLE-READ。4.2 修改隔离级别修改有两种维度全局级别和会话级别。全局级影响之后新建立的连接不影响当前已存在的连接会话级只影响当前连接。-- 全局级对所有新连接生效 SET GLOBAL transaction_isolation READ-COMMITTED; -- 会话级只对当前连接生效 SET SESSION transaction_isolation READ-COMMITTED;MySQL 支持对下一个事务单独设置也可以不加 SESSION 关键字直接设置-- 立即生效只对下一个事务有效 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;生产环境如果要修改线上数据库的隔离级别两个思路一是直接在数据库上执行 SET GLOBAL但要注意这个操作不会自动持久化到配置文件重启后失效二是改 my.cnf 里的 transaction-isolation 参数在 [mysqld] 段下面加上一行[mysqld] transaction-isolation READ-COMMITTED动态改和配置改的区别类似于热更新和永久修改建议线上操作先动态改验证效果确认稳定后再写入配置文件避免重启丢失配置。4.3 用两个会话亲手验证四种隔离级别纸上谈兵一百遍不如亲手跑一遍。下面是我自己验证隔离级别时用的方法开两个 MySQL 客户端会话分别叫会话 A 和会话 B用一张简单的测试表配合执行。先建一张表插入测试数据CREATE TABLE test_isolation ( id int(11) NOT NULL AUTO_INCREMENT, amount decimal(10,2) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO test_isolation (amount) VALUES (100.00), (200.00), (300.00);验证脏读把会话 A 设置为读未提交然后执行更新但不提交-- 会话 A SET SESSION transaction_isolation READ-UNCOMMITTED; START TRANSACTION; UPDATE test_isolation SET amount 80.00 WHERE id 1;然后到会话 B同样设置读未提交查询-- 会话 B SET SESSION transaction_isolation READ-UNCOMMITTED; START TRANSACTION; SELECT * FROM test_isolation WHERE id 1;会看到 amount 显示为 80.00但这条数据其实来自未提交事务。然后回会话 A 执行 ROLLBACK再在会话 B 重新查询数据又变回 100.00脏读验证完毕。验证不可重复读把两个会话都切到读已提交。会话 A 开启事务查询一次会话 B 更新并提交然后会话 A 再查一次。读已提交级别下会话 A 两次查到的数据不一样-- 会话 A SET SESSION transaction_isolation READ-COMMITTED; START TRANSACTION; SELECT * FROM test_isolation WHERE id 1; -- 100.00 -- 会话 B SET SESSION transaction_isolation READ-COMMITTED; START TRANSACTION; UPDATE test_isolation SET amount 90.00 WHERE id 1; COMMIT; -- 会话 A 再次查询 SELECT * FROM test_isolation WHERE id 1; -- 90.00同一个事务里两次读结果从 100.00 变成了 90.00这就是不可重复读。然后把两个会话都切到可重复读重复同样的操作会发现会话 A 第二次查询依然是 100.00说明可重复读生效了。验证幻读可重复读级别下用当前读配合间隙锁才是重点。会话 A 开启事务执行带锁的范围查询然后会话 B 尝试插入一条新记录会看到插入操作被阻塞-- 会话 A SET SESSION transaction_isolation REPEATABLE-READ; START TRANSACTION; SELECT * FROM test_isolation WHERE id 1 FOR UPDATE; -- 会话 B会被阻塞除非超时 SET SESSION transaction_isolation REPEATABLE-READ; START TRANSACTION; INSERT INTO test_isolation (amount) VALUES (400.00);如果会话 A 不提交会话 B 的 INSERT 会一直等。等会话 A 提交之后INSERT 才执行成功。这就是间隙锁和临键锁在起作用也是 InnoDB 在可重复读级别下解决幻读的直接证据。把隔离级别切到读已提交再重复这个操作会发现 INSERT 不会被阻塞因为 RC 级别没有间隙锁。这套验证流程尽量在自己本机的测试环境跑一遍感受会完全不一样。尤其是二次查询结果不一致、插入被阻塞这两个现象文字描述再多都不如亲眼看到一次记得牢。5. 生产环境怎么选隔离级别几个真实场景的经验5.1 MySQL 默认 RR 的取舍与历史包袱先说结论如果你的系统没有明确的一致性需求且对并发性能比较敏感用 MySQL 默认的可重复读完全没问题官方默认一定是最稳妥的选择。但你要清楚它相对读已提交在性能上的代价。可重复读的最大代价是间隙锁。间隙锁锁的是范围而不是具体行在高并发插入场景下很容易出现锁范围互相覆盖导致死锁。举个我实际碰到过的例子一个订单流水表业务上按用户维度记录操作日志多个用户并发下单插入记录时如果某个事务先执行了范围查询的当前读比如查某个时间段的订单间隙锁会把相邻用户的数据插入也挡住导致大量插入操作排队。而且死锁排查起来比想象中难。两个事务各自持有一把间隙锁又同时等对方释放MySQL 会自动检测死锁并回滚一个事务但业务侧收到的是 Deadlock found when trying to get lock 错误需要重试机制兜底。很多团队处理这类问题的第一反应就是加索引或改 SQL但真正有效的办法往往是把隔离级别降到读已提交。读已提交不产生间隙锁锁冲突概率显著下降。如果你的业务对同一事务内多次查询结果一致性没有硬性要求切到 RC 通常能肉眼可见地减少锁等待和死锁发生频率。5.2 什么时候必须用 RR什么时候可以降级到 RC我总结了一套自己的判断逻辑不一定绝对严谨但实际用下来比较靠谱。必须保留可重复读的场景一是业务里有“先查后写”且需要基于查询结果做判断的逻辑比如读到一个账户余额判断够不够扣款然后再执行扣款整个操作在一个事务里。如果不在同一事务内保持一致读取可能发生余额被其他事务改掉、判断失效的情况。二是报表统计类场景一张报表在生成过程中如果数据源持续变化统计结果会是拼凑出来的不同时间点的数据混在一起这种场景必须保证整个事务内看到一致的数据快照。三是金融交易类对账、清算、账务处理凡是涉及金额计算的历史数据读取一律用可重复读宁可性能差一点也不能数据出错。可以降级到读已提交的场景一是纯写入型业务事务里基本都是 INSERT、UPDATE只有很少的查询不需要跨多次查询一致性。二是查询实时性要求高的场景比如库存查询、价格查询希望每次读到的都是最新的已提交数据业务上也不关心同一事务内两次查询是否一致。三是高并发插入型业务比如日志入库、消息记录、流水写入这种场景下间隙锁是纯负担RC 能省掉大量锁冲突。我踩过的坑是本来库存扣减系统用的可重复读并发一高就频繁死锁排查了两天才锁定是间隙锁互相覆盖导致。切换到读已提交后死锁基本消失因为库存扣减本身用 UPDATE ... WHERE 走的是记录锁根本不需要间隙锁快照读的一致性在这个场景也没意义。这个案例典型说明了隔离级别不是越高越好而是越合适越好。5.3 常见问题与排查技巧实录最后整理几个我在实际运维和面试辅导中高频遇到的问题都有对应的排查方法和处理思路。第一个是锁等待超时。报错信息通常是 Lock wait timeout exceeded默认时长是 50 秒。排查三步走先执行 SHOW ENGINE INNODB STATUS看 LATEST DETECTED DEADLOCK 和 TRANSACTIONS 部分找到持锁事务的 SQL再查 information_schema.innodb_trx 表找到长时间未提交的事务最后找到源头多半是有事务没提交或者长事务里带着范围查询的当前读。处理手段也分轻重紧急情况直接 KILL 掉持锁事务治本需要查代码里有没有事务忘提交、有没有大事务以及考虑降隔离级别。第二个是死锁。死锁和锁等待超时的区别是死锁是因为两个事务互相持有对方需要的锁MySQL 会自动回滚其中一个应用层会收到 Deadlock found 错误。排查思路是看错误日志里展示的两个事务的 SQL分析各自的锁顺序。常见的修复方式调整 SQL 的执行顺序让所有事务按相同的顺序访问表和行缩小事务范围减少持锁时间如果死锁集中在范围查询上优先考虑降隔离级别到读已提交。第三个是事务里查询结果和预期不符。比如同一个事务里先 SELECT 查出来的数据是老值然后执行 UPDATE 把值改掉再 SELECT 发现是新值但另一个查询还是老值。这个现象我见过很多次本质是没分清快照读和当前读。排查时先确认 SQL 里是否用了 FOR UPDATE 或者是否混用了普通 SELECT 和 DML再看隔离级别设置。如果是可重复读普通 SELECT 用快照读数据可能和最新值不一致需要更新数据时直接走 UPDATE不要先 SELECT 再凭快照里的值判断。第四个是 binlog 格式和隔离级别的配合问题。MySQL 5.7 默认的 binlog_format 是 ROW这种情况下读已提交可以安全使用。如果你还在用老的 binlog_formatSTATEMENT并且想降隔离级别主从复制可能出问题因为语句格式下可重复读是保证主从一致性比较稳妥的前提。要进行改造的话先把 binlog_format 改成 ROW再降隔离级别顺序别搞反。这套隔离级别的知识光靠背概念是记不牢的。我自己的体会是先搭一个本地 MySQL把四个级别分别跑一遍脏读、不可重复读、幻读的实验再把生产环境里遇到过的一两个死锁日志翻出来对着分析效果比看十篇理论文章都强。你在排查线上问题的时候拿到一个锁等待的报错先别急着改 SQL想想事务隔离级别在这个场景里扮演了什么角色往往答案就出来了。如果后续你的业务在 RR 和 RC 之间纠结我的建议是先用 RC 做压测观察死锁和锁等待的数量变化同时让业务方确认是否接受同一事务内多次查询结果可能不一致。大多数业务场景接受这个变化切换后你会明显感觉到锁相关的报错少了很多。
返回列表