当两个事务同时读写同一批数据时,数据库必须用「事务隔离级别」来划定彼此看得见什么。隔离级别定得越低,越容易出现脏读、不可重复读、幻读这三类并发异常;定得越高,又越牺牲并发度。理解隔离级别与背后的 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 顺带防幻读 |
| PostgreSQL | Read Committed | 每行提交后才可见 |
| SQL Server | Read Committed | 可用 RCSI 行版本化 |
| Oracle | Read 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 回收。下次遇到「数据对不上」,先想清楚你处在哪个隔离级别、用的是快照读还是当前读,问题往往迎刃而解。




