事务隔离级别实战:脏读、不可重复读、幻读与 MVCC

当两个事务同时读写同一批数据时,数据库必须用「事务隔离级别」来划定彼此看得见什么。隔离级别定得越低,越容易出现脏读、不可重复读、幻读这三类并发异常;定得越高,又越牺牲并发度。理解隔离级别与背后的 MVCC 多版本并发控制,是写出正确并发代码的地基,也是排查「数据怎么对不上」这类诡异问题的一把钥匙。

一、并发事务的三类典型异常

SQL 标准定义了三种经典读异常,本质都是「一个事务读到了另一个事务的中间状态」:

  • 脏读(Dirty Read):读到了别的事务尚未提交的数据,对方一回滚,你读到的就作废了。
  • 不可重复读(Non-repeatable Read):同一事务内两次读同一行,结果被别的事务的 UPDATE/DELETE 改成了不同值。
  • 幻读(Phantom Read):同一事务内两次范围查询,第二次多出了别的事务 INSERT 进来的「幻影行」。

三者层层递进:脏读最严重(读到根本没生效的数据),幻读相对最轻(读到多/少了行,但每行本身都是已提交的)。

二、四种标准隔离级别对照

隔离级别脏读不可重复读幻读
Read Uncommitted(读未提交)可能可能可能
Read Committed(读已提交)杜绝可能可能
Repeatable Read(可重复读)杜绝杜绝可能*
Serializable(串行化)杜绝杜绝杜绝

* 注意一个常见误区:标准 SQL 里 Repeatable Read 不保证防住幻读,但 MySQL InnoDB 在 RR 下通过 Gap Lock(间隙锁)顺带把幻读也防住了。所以「RR 会不会幻读」在不同数据库里答案不同,面试和排障时别一概而论。

三、脏读复现(以 MySQL 为例)

把隔离级别降到最低,脏读立刻可见:

-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 此时尚未提交

-- 会话 B
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;  -- 读到 -100 的未提交值(脏读)
-- 会话 A 随后 ROLLBACK,B 读到的钱「凭空消失」

生产环境几乎从不会用 Read Uncommitted,但理解它能帮你看清「隔离级别到底在防什么」。

四、不可重复读与幻读的区别

-- 事务 T1
BEGIN;
SELECT * FROM orders WHERE user_id = 7;   -- 返回 3 行

-- 事务 T2(并发执行)
UPDATE orders SET amount = 999 WHERE user_id = 7 AND id = 2;  -- 改已有行
INSERT INTO orders(user_id, amount) VALUES (7, 50);            -- 插新行
COMMIT;

-- 事务 T1 再次查询
SELECT * FROM orders WHERE user_id = 7;
-- 不可重复读:id=2 的金额变了
-- 幻读:多出了刚 INSERT 的那一行

一句话区分:不可重复读是「已有行的值变了」,幻读是「结果集的行数变了」。两者都靠 RR 及以上级别解决,但幻读还需要锁住「不存在的间隙」。

五、MVCC:快照读与当前读

现代数据库用 MVCC(多版本并发控制)在不加锁的前提下实现高并发读:每行数据保留多个历史版本,读事务拿到一个「快照」,看到的是它开始时已提交的数据,写事务互不阻塞。

  • PostgreSQL:以事务 ID(xmin/xmax)标记版本,依靠可见性规则判断哪个版本对当前事务可见,旧版本由 VACUUM 回收。
  • MySQL InnoDB:在聚簇索引行上维护隐藏的 DB_TRX_ID 与回滚指针,通过 undo log 构造历史版本,ReadView 决定可见性。

在 InnoDB 里,每次 UPDATE 不会直接覆盖旧值,而是把旧值写入 undo log,行上的回滚指针串成一条版本链;事务开始时拿到的 ReadView 记录了「当前活跃的事务列表」,顺着版本链找到第一个「对自己可见」的版本。正因如此,长事务会拖住 undo log 的清理——这也是 DBA 总强调「别开长事务」的底层原因。PostgreSQL 同理,长事务会阻止 VACUUM 回收旧行版本,导致表膨胀。

两者都把普通 SELECT 默认做成快照读(无锁、读历史版本),而 UPDATE、DELETE 以及 SELECT ... FOR UPDATE当前读(读最新版并加锁)。这正是 RR 下还能高并发读、又不丢一致性的原因。

六、各数据库默认隔离级别

数据库默认隔离级别备注
MySQL(InnoDB)Repeatable Read靠 Gap Lock 顺带防幻读
PostgreSQLRead Committed每行提交后才可见
SQL ServerRead Committed可用 RCSI 行版本化
OracleRead Committed无真正的 RR 隔离

一个经典迁移坑:从 MySQL 迁到 PostgreSQL 时,默认级别从 RR 降到 RC,原本「可重复读」的假设不再成立,可能读出中间态。关于两者的语法与迁移差异,可参考 PostgreSQL vs SQL Server 差异梳理 的思路做对齐。

七、生产实践怎么选

  • 绝大多数 OLTP 用数据库默认级别即可:MySQL 用 RR,PostgreSQL/SQL Server 用 RC,不必盲目调高。
  • 需要「读到别的事务已提交的最新值」时,用 RC,避免 RR 下长事务持有快照过久、undo/旧版本膨胀。
  • 库存扣减、余额变更等强一致场景,显式加锁SELECT ... FOR UPDATE,或必要时提升到 Serializable(但并发会骤降,慎用)。正确姿势是「在事务里先锁行再更新」,而不是「先读再算再写」——后者在并发下必然出现超卖。
  • 锁能否精准落在行上,取决于索引。缺少索引时锁会升级、间隙锁蔓延,拖垮并发——这与 MySQL 索引底层原理PG 慢查询优化 息息相关。

八、查看与设置隔离级别

-- MySQL:查看与设置
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- PostgreSQL:查看与设置
SHOW transaction_isolation;
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 排查 PG 长事务(快照过久会拖慢 VACUUM)
SELECT pid, state, now() - xact_start AS dur, query
FROM pg_stat_activity
WHERE state <> 'idle' ORDER BY dur DESC;

把隔离级别和时区、字符集一起纳入上线检查清单,能挡掉一大类「本地正常、线上诡异」的 bug。字符集与时区配置的坑可看 MySQL 字符集与时区踩坑,主从环境下还要留意 主从复制与读写分离 对一致性的影响。

九、Next-Key Lock:MySQL 如何用锁防住幻读

MySQL 在 RR 下之所以能防幻读,靠的是 Next-Key Lock,它等于「记录锁(锁住已存在的行)+ 间隙锁(锁住行与行之间的空隙)」。比如对某一范围加锁时,不仅锁住范围内的行,还锁住相邻的间隙,别的事务想插入新行会被阻塞,幻读自然无从发生。

-- 事务 A(RR 下)
BEGIN;
SELECT * FROM orders WHERE user_id = 7 FOR UPDATE;
-- 不仅锁住 user_id=7 的已有行,还锁住「下一条 user_id=7 可能插入的间隙」

-- 事务 B
INSERT INTO orders(user_id, amount) VALUES (7, 50);  -- 被间隙锁阻塞,直到 A 提交

COMMIT;  -- A 提交后,B 才能插入

代价是并发度下降与潜在死锁,所以只在必要处显式加锁,而非盲目提升全局隔离级别。一旦遇到「明明加了唯一索引还是偶发重复」「插入莫名卡住」,多半是间隙锁在起作用。

十、小结

隔离级别不是越高越好:它在「正确性」和「并发度」之间做权衡。记住三件事就够了——脏读最危险、靠 RC 就能杜绝;不可重复读与幻读靠 RR 解决,其中 MySQL 还靠 Next-Key Lock 防幻读;而这一切的高效实现都建立在 MVCC 之上。最后别忘:长事务是 MVCC 的天敌,会拖慢 undo/VACUUM 回收。下次遇到「数据对不上」,先想清楚你处在哪个隔离级别、用的是快照读还是当前读,问题往往迎刃而解。

上一篇 Java 日志体系实战:SLF4J + Logback 配置与最佳实践
下一篇 大模型数据投毒与后门攻击防御实战