ARTICLE DETAIL

资讯详情

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

一文讲透MySQL事务隔离级别:脏读、不可重复读与幻读

一文讲透MySQL事务隔离级别:脏读、不可重复读与幻读 平时用MySQL的朋友肯定都被问过或者百度过这么一个问题脏读、不可重复读、幻读到底有什么区别我在做后端开发和数据库支持这几年遇到过不少因为搞不清这三个概念导致的线上事故和面试翻车现场。这篇文章我把三种异常现象从原理到复现再到MySQL锁与MVCC的解决方式完整串一遍看完你应该能直接跟同事讲清楚也能应付绝大多数面试题。这篇文章适合正在学MySQL事务的初学者也适合写业务代码时被数据不一致问题折磨的开发同学以及准备数据库面试的求职者。我会结合InnoDB引擎的实际行为来讲而不是只背理论因为MySQL的默认隔离级别和SQL标准不完全是一回事很多网上文章在这一块讲得含混导致越看越乱。1. 事务隔离级别脏读、不可重复读、幻读到底防什么1.1 四个隔离级别先记住这张表要理解这三个异常现象先得清楚它们是在什么背景下出现的。数据库事务有ACID四个特性其中I是隔离性Isolation意思是多个事务并发执行时彼此之间应该互不干扰。现实世界里数据库不可能真的把所有事务一个个排队执行那样性能太差所以SQL标准定义了四个隔离级别允许开发者根据业务需求在数据一致性和并发性能之间做取舍。四种隔离级别解决三种异常问题的关系可以用下面这张表概括隔离级别脏读不可重复读幻读READ UNCOMMITTED读未提交可能发生可能发生可能发生READ COMMITTED读已提交不会发生可能发生可能发生REPEATABLE READ可重复读不会发生不会发生可能发生InnoDB实际已解决SERIALIZABLE串行化不会发生不会发生不会发生这里有一个非常关键的点这张表是按SQL标准来的但MySQL的InnoDB引擎在REPEATABLE READ级别下通过MVCC和Next-Key Lock实际上已经解决了幻读问题这一点后面我会专门讲。很多人面试时背了“可重复读会幻读”这句话结果被深问InnoDB的行为就答不上来原因就是把标准理论和平时的数据库实现混在一起了。1.2 隔离级别和锁、MVCC的关系隔离级别不是凭空实现的底层靠的是两套机制锁和MVCCMulti-Version Concurrency Control多版本并发控制。锁很好理解就是给数据加读写锁。读锁和读锁之间不冲突读锁和写锁互相排斥写锁和写锁更排斥。锁用得越狠隔离性越好并发度就越低。SERIALIZABLE级别基本就是全程加锁事务基本串行执行。MVCC则是另一种思路它不直接锁住记录而是通过保存数据的历史版本让读操作和写操作互不阻塞。每行记录除了业务字段还有隐藏的DB_TRX_ID事务ID和DB_ROLL_PTR回滚指针。当事务修改一行数据时不会直接覆盖旧值而是把旧值写入undo log形成一条版本链。读操作根据不同的规则选择版本链上合适的版本读取这样就实现了“读不加锁、写不加锁”的高并发能力。隔离级别的差异本质上是读操作在什么时机生成Read View读视图以及当前读使用什么锁策略的问题。后面讲不可重复读和幻读时我会把这条线索落到底。1.3 MySQL默认用可重复读的原因MySQL默认隔离级别是REPEATABLE READ它也是InnoDB引擎的默认值。看到这里你可能想为什么不直接用READ COMMITTED并发性能不是更好吗历史原因占了很大成分。在MySQL 5.0及更早版本binlog二进制日志的逻辑复制格式不够完善如果主库使用READ COMMITTED级别从库在回放binlog时可能会因为执行顺序问题出现主从数据不一致。REPEATABLE READ配合Next-Key Lock能更好地保证binlog记录的执行结果和主库一致。后来binlog推出了ROW格式这个问题缓解了很多但MySQL为了兼容历史行为默认级别一直没有变。实际项目中我也见过很多团队主动把隔离级别改成READ COMMITTED因为RC级别下锁范围小并发能力更强而业务里很多数据表其实不需要可重复读的语义。所以“MySQL默认是RR”只代表默认配置不代表你的业务必须用它关键还是看数据一致性的要求有多高。2. 脏读读到未提交的数据后果有多严重2.1 一个典型的脏读事故脏读指的是一个事务读到了另一个事务尚未提交的数据。最经典的场景就是转账和余额查询。假设事务A要把Alice账户扣掉100元它执行了UPDATE语句但还没COMMIT。这时候事务B去查Alice账户余额如果它处于READ UNCOMMITTED级别就能看到已经被扣掉100元的结果。问题来了事务A因为某种原因执行了ROLLBACK那100元根本没有真正扣减账应该还是原样。但事务B已经拿那个未经确认的数据去做后续操作了要么给用户显示了错误的余额要么把错误数据写进了别的报表。这种情况在财务系统里是绝对不允许的因为财务数据要求每一步操作都必须基于已经落库的确定事实而不是一个可能被撤回的中间状态。2.2 复现脏读把隔离级别调到READ UNCOMMITTED我自己验证脏读的时候习惯开两个MySQL终端窗口一个模拟写事务一个模拟读事务。先用下面这段SQL准备一张表CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50), balance DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO account(name, balance) VALUES (Alice, 1000.00), (Bob, 500.00);然后在两个会话里执行以下步骤步骤会话A写事务会话B读事务1设置会话隔离级别为READ UNCOMMITTED设置会话隔离级别为READ UNCOMMITTED2START TRANSACTION;START TRANSACTION;3UPDATE account SET balance balance - 100 WHERE name Alice;不操作4不操作SELECT balance FROM account WHERE name Alice;5ROLLBACK;再次SELECT余额发现数据“变”回去了关键在于第4步会话B查到的balance是900.00。但实际上事务A还没提交如果事务A回滚余额仍然是1000.00。我让你在步骤5再查一次就是为了对比B第一次查到的数据是临时性的、不能依赖的。这个900就是脏数据。注意MySQL 8.0里查看和设置隔离级别用的是transaction_isolation5.7及更早版本是tx_isolation。查看当前会话级别可以执行SELECT transaction_isolation;全局查询执行SELECT global.transaction_isolation;。2.3 解决脏读的正确姿势解决脏读非常简单把隔离级别至少提升到READ COMMITTED。在READ COMMITTED级别下事务B只能读取到已经提交的数据事务A没提交的修改对B不可见。很多人会问那我把事务的自动提交关掉或者说每个事务里先LOCK TABLE是不是也行可以是可以但没必要因为你如果已经升级到了READ COMMITTED脏读天然就不会发生。再说锁表这种操作影响面太大了一个事务锁住整张表其他会话的读写全部被卡住线上基本不可能接受。实操中我一般建议这样验证把两个会话都设置成READ COMMITTED再走一遍上面的步骤会话B在第4步查到的余额仍然是1000.00直到事务A真正COMMIT之后B再查询才会看到900。这个对比非常直观做一次就永远忘不了。3. 不可重复读同一行数据两次读不一样3.1 不可重复读的危害场景不可重复读描述的是同一个事务内两次执行同一条SELECT语句读到的结果不一样。它和脏读最大的区别在于另一个事务已经提交了修改而不是还在悬挂状态。举一个常见的业务例子。有个订单统计任务先在事务内查询了订单总额然后去查询其他表的数据最后再回头查一遍订单总额发现两次查到的金额不一样。原因就是中间有另一个事务提交了一笔新订单改变了总额。如果这个统计任务要在事务里做基于总数的一致性判断结果就不可信了。另一个容易被忽略的场景是乐观锁。如果用版本号字段做并发控制不可重复读会导致两次读取的版本号不一致从而影响业务判断。3.2 复现不可重复读RC级别下必现在READ COMMITTED级别下不可重复读是稳定复现的。继续用上面的account表把两个会话都设成READ COMMITTED然后按下表操作步骤会话A读事务会话B写事务1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;2START TRANSACTION;START TRANSACTION;3SELECT balance FROM account WHERE name Alice;不操作4不操作UPDATE account SET balance 888.00 WHERE name Alice;5不操作COMMIT;6再次SELECT balance不操作会话A在步骤3查到的balance是1000.00步骤6再查就变成888.00。同一事务内同一条SQL两次结果不同这就是典型的不可重复读。这个现象的本质是READ COMMITTED级别下事务内每次执行普通SELECT时都会生成一个新的Read View。也就是说事务A在第3步执行前生成一个快照第6步执行前又生成一个新快照而新快照能看到事务B已提交的更新所以两次结果自然不同。3.3 MVCC如何保证可重复读把两个会话的隔离级别改成REPEATABLE READ再次执行上面的步骤。你会发现会话A在步骤6仍然读到1000.00而不是888.00。这就是可重复读的效果。原因在于InnoDB的MVCC在REPEATABLE READ级别下的特殊行为事务内第一次执行普通SELECT时生成Read View之后整个事务期间后续所有普通SELECT都复用它不再重新生成。Read View里记录了事务启动瞬间所有活跃事务的ID列表通过比较行数据上的DB_TRX_IDInnoDB就能判断该行当前版本对于当前事务是否可见。如果不可见就沿着undo log版本链往前找直到找到一条对当前事务可见的版本。这里有个理解上的关键点可重复读不是把行数据“冻结”成某个固定值而是通过Read View这个快照机制让当前事务始终看到事务开始那个时间点的数据状态。这也是为什么我说MVCC是“读不加锁”的因为读操作不需要跟写操作互相等待大家各看各的版本。4. 幻读行数凭空多出来4.1 注意幻读和不可重复读是两回事幻读是三种异常现象里最容易被误解的一个。不可重复读针对的是同一行数据的内容发生了变化比如一行余额从1000变成888。而幻读针对的是查询结果集的行数发生了变化上一次查到2条记录下一次查到3条记录多出来的那一条就是“幻影行”。这个区别在工作里很重要。如果你只盯着某一行的值那很多场景下可重复读就够了但如果你的业务是先SELECT某个范围内的数据然后根据结果集决定后续操作比如批量处理、库存预占、区间统计那就必须关心是否会发生幻读。我见过一个真实案例一个库存模块判断某商品的剩余库存是否充足先查询库存记录发现还有3条可用库存记录于是开始逐条处理。处理到一半另一个事务插入了一条新库存记录。虽然单条记录没变化但整体处理逻辑被打乱了。这就是幻读在实际业务里的破坏力。4.2 快照读下的幻读与当前读下的幻读这里必须分两种情况否则你没法理解InnoDB的行为。先说快照读也就是普通的SELECT语句。在REPEATABLE READ级别下由于事务全程复用同一个Read View两次普通SELECT查出来的行数是一致的。也就是说快照读下InnoDB已经解决了幻读问题。网上很多文章说“RR会幻读”其实指的是下面这种情况当前读。当前读包括SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE语句。当前读读的是最新已提交数据并且会加锁。在REPEATABLE READ级别下如果只用MVCC的快照读机制当前读是没有快照可用的它必须读到最新数据。所以如果不加额外措施当前读在两次执行之间另一个事务插入了一条新记录第二次当前读就会多出一行导致幻读。我用account表演示一下快照读场景步骤会话A会话B1设置RR级别START TRANSACTION;设置RR级别START TRANSACTION;2SELECT COUNT(*) FROM account WHERE balance 500;不操作3不操作INSERT INTO account(name, balance) VALUES (David, 800.00); COMMIT;4再次SELECT COUNT(*) FROM account WHERE balance 500;不操作在RR级别下第4步的结果和第2步一样不会多出行数。如果改成RC级别第4步就会多出一行。所以从快照读角度来看RR确实挡住了幻读RC挡不住。再看当前读场景。把第2步的语句换成SELECT COUNT(*) FROM account WHERE balance 500 FOR UPDATE;在RR级别下重复上面流程。你会发现第4步在执行时极大概率会被阻塞一直等待到事务B提交或超时。这正是Next-Key Lock在起作用它把balance 500这个范围锁住了事务B的插入操作无法通过。4.3 InnoDB怎么用Next-Key Lock封死幻读Next-Key Lock是InnoDB在REPEATABLE READ级别下解决当前读幻读的核心手段。它不是一种独立的锁而是记录锁Record Lock和间隙锁Gap Lock的组合。记录锁锁的是索引记录本身间隙锁锁的是索引记录之间的间隙也就是一段范围让别的事务无法在这个间隙里插入新记录。Next-Key Lock同时锁住记录和它前面的间隙形成一个左开右闭的区间既锁住了已存在的记录也禁止在记录之前插入新行。还是用account表演示。表里id为主键假设已有id为1、2、3三条记录。如果会话A执行SELECT * FROM account WHERE id 2 FOR UPDATE;在RR级别下InnoDB不仅锁住id2这一行还会对(1, 2)和(2, 3)这些间隙加间隙锁阻止其他事务插入id为0.5到2.5之间的新记录。这个行为保证了即使当前读不止一次结果集也不会突然多出新记录。注意Next-Key Lock依赖索引。如果表没有索引InnoDB会退化到锁全表的间隙并发性能瞬间下跌。所以使用FFOR UPDATE或者大范围UPDATE时务必确保WHERE条件能命中合适的索引。我排查线上锁等待问题时见过太多因为索引缺失导致的“诡异”阻塞最后都指向这个原因。5. 隔离级别选型与性能权衡5.1 四种隔离级别的并发性能对比隔离级别越高数据一致性越好但并发性能通常越低。实际执行计划里锁的粒度和范围决定了阻塞程度。我整理了一个对比供参考隔离级别锁的使用情况并发能力典型使用场景READ UNCOMMITTED读不加锁写正常加锁但可能读到未提交数据很高几乎不用除非对数据准确性完全无要求READ COMMITTED普通读不加锁写入只对命中行加锁无间隙锁较高大多数业务系统可考虑报表、日志、会话信息REPEATABLE READ普通读通过MVCC实现快照读当前读加行锁间隙锁中等资金类、订单类、需要重复读取稳定的场景SERIALIZABLE读加共享锁写加排他锁事务之间强制串行最低数据强一致、低并发场景通常很少用注意这个对比是定性的真实性能还受索引、事务大小、锁等待时间等因素影响。我遇到过一些团队在RC和RR之间反复横跳最后发现瓶颈根本不是隔离级别而是某个SQL没走索引导致锁范围爆炸。所以选级别之前先把慢查询和锁等待分析清楚再动手调整。5.2 生产环境常见配置问题MySQL隔离级别可以在多个层级设置全局、会话、以及单个事务。生产环境里最常见的配置问题有两个。第一个是全局设置和会话设置不一致。比如DBA把全局级别改成了READ COMMITTED但应用连接池里的连接可能建立得很早每个连接保存的还是会话级视图导致新旧连接行为不一致。我建议改完隔离级别之后先检查现有连接必要时重启应用服务或者让连接池强制刷新连接。第二个是事务没及时提交。很多开发同学写代码时习惯性地在一个事务里执行很多SQL哪怕只是几条无关的查询也包在事务里。这样事务持有锁的时间变长在RR级别下还会持有间隙锁的时间变长非常容易引发连锁的锁等待。我处理过一次线上故障一个定时任务里开了一个大事务先做了几十万行的UPDATE然后又执行了5分钟的数据比对最后才COMMIT导致所有访问相关表的接口全部卡死。后来把事务拆小问题立刻消失。5.3 工作中的实际选型经验我现在的团队里有这么一套不成文的约定核心资金类、订单类表使用REPEATABLE READ因为这些场景要求同一事务内多次读取结果一致幻读也必须被杜绝统计报表、日志查询、内容管理这些非核心只读场景使用READ COMMITTED提升并发能力也减少间隙锁带来的意外阻塞。真正常见的情况是大部分业务根本不需要SERIALIZABLE除非是极少数强一致且低并发的配置类数据。之前我帮一个客户排查问题他们的支付确认接口用了SERIALIZABLE觉得这样最安全结果QPS稍微一上去就开始出现超时后来改成REPEATABLE READ再配合幂等性设计问题就解决了。所以选隔离级别不是越严格越好而是在业务要求的事务边界、锁成本和并发能力之间找平衡点。你可以在测试环境用压测工具模拟多事务并发观察锁等待时间和吞吐量用数据来决定配置而不是拍脑袋。6. 面试与实战高频问题排查6.1 常见问题排查速查表我把日常被问到最多的问题和排查思路整理成一张速查表遇到类似情况可以直接对照查现象可能原因排查思路同一事务内两次查询结果不一致隔离级别是READ COMMITTED改成REPEATABLE READ或者确认业务是否真的需要在事务内重复读查询明明刚提交另一个连接却迟迟看不到连接可能用了旧快照或当前读被间隙锁阻塞检查当前会话隔离级别和事务开始时间手动提交事务后再查UPDATE/DELETE长时间卡住行锁或间隙锁冲突用SHOW ENGINE INNODB STATUS查看锁等待批量插入数据时报死锁事务之间加锁顺序不一致统一业务操作的SQL顺序避免循环等待主从数据不一致binlog格式或事务内SQL顺序问题确认binlog格式是否ROW检查事务隔离级别排查这类问题的前提是先搞清楚当前会话的事务状态。我喜欢在应用代码里把事务边界日志打出来包括事务开始时间、隔离级别、影响行数、提交时间没有这些信息任何数据库层面的分析都像盲人摸象。6.2 用MySQL自带的工具分析锁和事务MySQL自带了不少能直接看锁和事务状态的命令排查效率远高于反复猜。最常用的就是SHOW ENGINE INNODB STATUS它有一段LATEST DETECTED DEADLOCK输出能显示最近一次死锁的事务和锁等待细节。我在处理线上问题时会先把这条命令的输出保存下来然后根据事务ID去information_schema.innodb_trx表里查每个事务的状态、开始时间和执行时间。如果要看具体有哪些锁在等待可以查performance_schema.data_lock_waits和performance_schema.data_locks。这两个表能列出锁的持有者和等待者以及锁的类型和对象。比如你想知道一个UPDATE到底阻塞在哪个表哪一行就可以通过这两个表快速定位。窗口不友好的情况下我还会用一条SQL查当前所有事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;这条命令能快速看到有哪些事务在跑运行多久了。如果发现有事务很长没提交基本就能确认是它持有了锁导致其他事务排队。6.3 我踩过的几个坑第一个坑是测试环境忘记切换隔离级别。有一次我在本地按RC级别验证逻辑全部通过结果上到测试环境后同样的代码在RR级别下出现了不可预期的锁等待。后来我把所有环境的隔离级别统一用配置管理并在代码初始化时显式设置不再依赖数据库默认值。第二个坑是SELECT不加锁但UPDATE加了锁导致业务逻辑里看似相同的两次查询结果不同。有一个活动签到模块先查询用户是否签到然后插入签到记录。因为查询走的是快照读而插入走的是当前读并发情况下会出现重复签到。最后我把查询改成了SELECT ... FOR UPDATE让查询和插入处于同一个锁范畴问题才解决。第三个坑是在RR级别下使用了ORDER BY但没走索引结果间隙锁范围失控。有一个分页查询接口执行了SELECT * FROM log WHERE create_time BETWEEN ... ORDER BY id LIMIT 10create_time字段没建索引InnoDB只能全表扫描锁范围扩大到整个表导致并发写入全部卡死。后来给create_time建了索引锁范围才恢复正常。这些坑有一个共同点问题都不是靠背理论解决的而是靠看懂锁的状态、理解事务隔离级别的实际行为后用合理的SQL设计和事务边界调整慢慢解决的。所以我建议你从现在开始本地多开几个终端把文中的每一步都手动跑一遍踩过一次坑比看十遍文章都管用。最后再分享一个小技巧我给团队做事务隔离级别培训时总是让每个人先在navicat或MySQL命令行里复制一遍文中的复现步骤然后把观察到的结果截图发出来。因为只有亲手看到脏读、不可重复读和幻读出现你才会真正理解下次写代码时脑子里自然就有那根弦了。
返回列表