ARTICLE DETAIL

资讯详情

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

幻读到底“幻“在哪:理解数据库隔离级别的真正分界线

幻读到底“幻“在哪:理解数据库隔离级别的真正分界线 一个让人困惑的问题学数据库隔离级别时不可重复读和幻读是最容易混为一谈的两个概念。它们的表象确实很像——事务里读了两次两次结果不一样而且看到的都是别人已经提交的数据。那为什么要把它们分成两个现象分别用两个隔离级别去解决这不是学术上的吹毛求疵。背后的原因很实际消除这两种现象的技术手段完全不同代价也差了一个数量级。先把现象说清楚不可重复读事务 A 事务 B ────────────── ────────────── BEGIN; SELECT price FROM products WHERE id 42; → price 100 UPDATE products SET price 80 WHERE id 42; COMMIT; SELECT price FROM products WHERE id 42; → price 80 ← 同一行值变了你盯着的那一行被人改了。幻读事务 A 事务 B ────────────── ────────────── BEGIN; SELECT * FROM products WHERE category book; → 3 行 INSERT INTO products (category, ...) VALUES (book, ...); COMMIT; SELECT * FROM products WHERE category book; → 4 行 ← 多了一行从没见过的数据你查的那个范围里凭空冒出了一个新东西。两者的区别不在于是否已提交而在于不可重复读 → 已存在的行内容被改了 幻读 → 行本身是新出现的为什么这个区别如此重要答案藏在一个简单的事实里你能锁住已经存在的东西但你锁不住还不存在的东西。这就是整个问题的核心。当你执行SELECT ... WHERE id 42时id42 这行是确定的、物理存在的。数据库引擎可以很容易地对这行加一个锁阻止别人修改它或者记住这行此刻的版本号后续总是读这个版本不可重复读就这样被解决了。代价不大——你锁的是一个确定的对象。但当你执行SELECT ... WHERE category book时你查的是一个条件而不是一组确定的行。未来可能有无数行满足这个条件但它们现在还不存在。你怎么对一个不存在的东西加锁这就好比锁住你家门 → 防止有人进来偷东西能做到锁住未来所有可能敲你家门的人 → 你得把整条街封了封整条街当然能做到但代价比锁一扇门高得多。这就是为什么 SQL 标准把这两种保护放在了不同的隔离级别。可重复读做了什么没做什么可重复读的承诺是你读过的数据在你事务结束前不会变。注意措辞——“你读过的数据”。这意味着✅ id1 的 balance 字段不会从 1000 变成 500 ✅ id2 的记录不会凭空消失 ❌ 但不保证不会有新的 id999 满足你的查询条件冒出来可重复读给已读的行拍了快照或加了锁但它管不了那些查询时尚未出生的行。用更技术的话说可重复读 行级别的保护point lock / row snapshot 串行化 范围级别的保护range lock / predicate lock从锁的视角理解隔离级别的递进隔离级别 锁的粒度 保护范围 ────────────────── ────────────── ───────────────────── Read Committed 不持锁 只保证读到的是已提交数据 Repeatable Read 行锁/行快照 保护已读的行 Serializable 范围锁/间隙锁 保护整个查询谓词覆盖的空间范围锁是什么概念看一下索引中的数据排列索引: ... | book,id3 | book,id7 | book,id12 | game,id5 | ... ↕ ↕ ↕ 行锁 行锁 行锁 → 可重复读做的事 ←───────────────────────────────────────→ 整个 categorybook 的范围 → 串行化做的事 包括行与行之间的「间隙」 新数据无法插入这个范围间隙锁锁住的不是数据而是数据可能出现的位置。这就是消除幻读的代价——你不得不锁住一段虚空。MySQL InnoDB 的实际行为MySQL 的可重复读比 SQL 标准描述的更强。它用了两套机制来应对幻读但各有边界。快照读MVCC 天然免疫普通的SELECT不加 FOR UPDATE / LOCK IN SHARE MODE走 MVCC 快照。事务开始时建立一致性视图之后无论别人插入、删除、修改了什么你看到的始终是那个时间点的世界。事务 A 事务 B ────────────── ────────────── BEGIN; SELECT * FROM orders WHERE user_id 7; → 3 行 INSERT INTO orders (user_id) VALUES (7); COMMIT; SELECT * FROM orders WHERE user_id 7; → 仍然 3 行 ← MVCC 快照生效新行对你不可见在纯快照读的场景下InnoDB 的可重复读不会出现幻读。这也是很多人说MySQL 可重复读已经解决了幻读的原因。但这只是故事的一半。当前读间隙锁介入当你使用SELECT ... FOR UPDATE、UPDATE、DELETE时走的是当前读——读最新版本不走快照。此时 InnoDB 会加 next-key lock行锁 间隙锁阻止其他事务在锁定范围内插入新行-- 这条语句不仅锁住已有的 user_id7 的行-- 还会锁住索引中 user_id7 这个范围的间隙SELECT*FROMordersWHEREuser_id7FORUPDATE;别的事务尝试INSERT INTO orders (user_id) VALUES (7)时会被阻塞。幻读被消除。漏网之鱼快照读与当前读的混用事务 A 事务 B ────────────── ────────────── BEGIN; SELECT * FROM orders WHERE user_id 7; → 3 行快照读没加任何锁 INSERT INTO orders (id, user_id) VALUES (999, 7); COMMIT; UPDATE orders SET status 1 WHERE user_id 7; → 影响 4 行当前读看到了 id999 SELECT * FROM orders WHERE user_id 7; → 4 行 ← 幻读出现了发生了什么第一次 SELECT 是快照读没有加锁事务 B 的 INSERT 不会被阻塞UPDATE 是当前读它能看到事务 B 已提交的新行并且修改了它被自己修改过的行在 MVCC 中变得可见了因为最新版本是自己写的第三次 SELECT 快照读看到了自己修改过的 id999这就是 InnoDB 可重复读下幻读仍然可能发生的经典场景。规避方式如果你的业务逻辑依赖范围内不能有新数据第一次查询就应该用SELECT ... FOR UPDATE让间隙锁从一开始就生效。更本质的理解方式与其死记不可重复读是修改、幻读是插入不如从这个角度理解不可重复读是数据完整性问题——你看到的一行数据前后不一致。幻读是集合完整性问题——你看到的结果集成员身份变了。不可重复读集合的元素还是那些元素但某个元素的属性变了 幻读 集合本身的成员变了多了或少了保护元素的属性只需要针对每个元素加锁。保护集合的成员关系则需要锁住成员资格的判定条件——这是一个逻辑谓词而不是一个物理行。这也是为什么 Serializable 级别的理论实现叫谓词锁predicate lock——它锁的是WHERE子句代表的逻辑空间。间隙锁是谓词锁在 B 树索引上的工程近似。回到最初的问题不都是读到了已经提交的内容吗是的。但问题不在于读到的是否已提交而在于数据库引擎需要锁住什么才能阻止这种情况发生。锁住具体的行 → 能防止已有数据被改解决不可重复读锁住一个范围/条件 → 能防止新数据被插入解决幻读前者像给每扇门装锁后者像在整个小区设门禁。成本不在一个量级所以被设计成不同的隔离级别来分别应对。这不是概念上的洁癖而是工程上的务实妥协。一张表总结不可重复读幻读现象同一行数据前后读到不同的值同一条件查询前后得到不同的行集合原因别人修改/删除了你读过的行别人在你查询范围内插入了新行保护手段行锁 / MVCC 行快照间隙锁 / 谓词锁 / MVCC 范围快照解决它的隔离级别Repeatable ReadSerializable标准/ RR 间隙锁InnoDB核心难点低——对象是确定的高——对象尚不存在类比给书架上的书加锁锁住书架上所有空位不让新书被放进来最后一句隔离级别不是越高越好。绝大多数业务场景下可重复读 合理使用SELECT FOR UPDATE就足够了。串行化能给你最强的正确性保证但它的并发性代价常常是不可接受的。理解每个级别防了什么、没防什么比机械地调高隔离级别有用得多。
返回列表