理解事务并发问题,最直观的方式就是看三个典型症状:脏读、不可重复读、幻读。它们分别描述了不同严重程度的并发异常,而四种隔离级别,正是为了逐步解决这三个问题而设计的。
脏读:读到了尚未提交的“脏数据”
产生原因
脏读发生在某个事务读取了另一个事务尚未提交的修改数据。由于这些修改随时可能被回滚,读到的数据实际上并不存在于最终持久化的数据库中,就像翻别人草稿纸上的内容,而那张纸最后可能被揉掉。
一个典型场景:
| 时间 | 事务A (转账) | 事务B (查询余额) |
|------|-------------|-----------------|
| T1 | BEGIN; | |
| T2 | UPDATE account SET balance = balance - 100 WHERE id=1; | |
| T3 | | BEGIN; SELECT balance FROM account WHERE id=1; — 读到扣减后的余额 |
| T4 | ROLLBACK; | |
| T5 | | COMMIT; 此时事务B基于一个从未存在的余额做了业务判断 |
事务B在T3读到的余额是事务A尚未提交的中间结果,而事务A最终回滚了。这就导致事务B基于一个“从未真正发生过”的数据进行了后续计算,可能造成错误决策(比如余额不足的误判或超额消费)。
解决机制
将隔离级别提升到读已提交(Read Committed)即可消除脏读。在读已提交级别下,事务只能读取到其他事务已经提交的修改。
MySQL InnoDB 通过 MVCC 实现这一保证:每个读操作都会获取最新的已提交版本,由于 Undo Log 保留了修改前的历史版本,即使另一个事务正在并发修改同一行,读操作也能从 Undo Log 中读到修改前的数据(即已提交的快照),而不会看到未提交的草稿。
实际上,MySQL 默认隔离级别“可重复读”已经更高,因此在默认配置下,脏读不会发生。只有显式设置为最宽松的“读未提交”级别时,脏读才会出现,而这种设置在生产环境极少使用。
不可重复读:同一事务内两次读取同一行,结果不同
产生原因
不可重复读是指在一个事务内,两次读取同一行数据,却得到了不同的结果。原因是在两次读取之间,另一个事务修改了该行并提交了。相对于脏读,不可重复读读到的是“已提交”的数据,问题在于事务内部看到的不是一致的快照。
示例:
| 时间 | 事务A (统计) | 事务B (更新) |
|------|------------|-------------|
| T1 | BEGIN; | |
| T2 | SELECT balance FROM account WHERE id=1; — 返回 1000 | |
| T3 | | BEGIN; UPDATE account SET balance = 800 WHERE id=1; COMMIT; |
| T4 | SELECT balance FROM account WHERE id=1; — 返回 800 | |
| T5 | 事务A基于两次读取的不一致结果做计算 | |
事务A在同一个事务内的两次读取得到两个不同的值,这在需要基于一致性快照做统计分析或复杂计算的场景中会引发混乱。比如在生成报表时,前后数据不一致可能导致汇总金额对不上。
解决机制
将隔离级别提升到可重复读(Repeatable Read)可以解决不可重复读。在可重复读级别下,事务在第一次读取时创建一个一致性快照(Read View),整个事务过程中所有读操作都基于这个快照,而不是去读最新的已提交数据。即使其他事务修改并提交了目标行,事务A依旧看到的是快照里的老版本,这样就保证了“可重复读”。
InnoDB 的实现方式就是利用 MVCC:事务启动(或首次快照读)时,记录当前活跃事务列表,构成 Read View。后续的所有普通 SELECT 都根据 Read View 决定哪条历史版本是可见的。因此,事务A在T4时刻看到的仍然是 T2 时刻的 1000,而不受事务B提交的影响。
注意,可重复读解决的是“同一行数据重复读取值不同”的问题,并不能阻止其他事务插入新的行——那是幻读的范畴。
幻读:同一事务内相同查询条件,返回的行数不同
产生原因
幻读指的是在一个事务内,执行相同的查询条件,却发现某些符合条件的数据行凭空出现或消失了,就像出现了幻觉。与不可重复读针对同一行数据的更新不同,幻读专指其他事务的 INSERT 或 DELETE 操作导致的结果集变化。
典型例子:
| 时间 | 事务A (审核) | 事务B (注册) |
|------|------------|-------------|
| T1 | BEGIN; | |
| T2 | SELECT * FROM users WHERE age > 18; — 返回10行 | |
| T3 | | BEGIN; INSERT INTO users(name, age) VALUES('Tom', 25); COMMIT; |
| T4 | SELECT * FROM users WHERE age > 18; — 返回11行,多出一行! | |
| T5 | UPDATE users SET status='verified' WHERE age > 18; — 影响了11行 | |
事务A在两次查询之间,事务B插入了符合条件的新行并提交。在普通的快照读下(可重复读默认的普通 SELECT),事务A在T4仍然读到的是10行(快照屏蔽了新增),但如果执行的是当前读(如 SELECT ... FOR UPDATE 或直接进行 UPDATE),则会看到事务B插入的行,导致更新影响的真实行数与快照所见不符,这就是幻读。
幻读在实际业务中非常微妙。例如一个事务先查询库存总量,然后基于这个总量做扣减,但这时另一事务插入了新库存记录,导致计算依据失效。更常见的场景是范围更新:你想给所有符合条件的用户发优惠券,结果这个过程中新注册的同条件用户也被更新了,可能不符合业务意图。
解决机制
MySQL 在可重复读隔离级别下,通过间隙锁(Gap Lock)和临键锁(Next-Key Lock)来很大程度上规避幻读。这是 InnoDB 特有的实现,比 SQL 标准规定的可重复读(通过两阶段锁定)更进一步。
具体原理:当事务A执行范围条件的当前读(如 SELECT ... FOR UPDATE 或 UPDATE/DELETE 带有范围条件)时,InnoDB 不仅会在已存在的行上加行锁,还会在索引记录之间的间隙上加间隙锁,阻止其他事务向这些间隙中插入符合条件的新行。例如查询 WHERE id BETWEEN 10 AND 20,InnoDB 会锁住 id 为 10 到 20 之间的所有实际存在记录的间隙,防止插入新行造成幻读。
这种机制有效地避免了幻读,但代价是并发度会降低(因为锁住了并不存在的行区间)。因此,MySQL 的可重复读实现其实已经达到了可序列化(Serializable)的防幻读级别,多数场景下等同于防止了幻读。
真正的可序列化(Serializable)隔离级别则是通过强制所有普通 SELECT 都变成加锁的当前读(相当于隐式加 SELECT ... FOR SHARE),用锁的串行化来彻底避免所有并发异常(包括幻读)。但并发性能最差,仅在极其严格的场景使用。
总结:三个问题的递进与隔离级别的对应关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读(InnoDB实际行为) |
|----------|------|------------|----------------------|
| 读未提交(Read Uncommitted) | 可能 | 可能 | 可能 |
| 读已提交(Read Committed) | 解决 | 可能 | 可能 |
| 可重复读(Repeatable Read)(默认) | 解决 | 解决 | 部分解决(通过间隙锁大幅抑制) |
| 可序列化(Serializable) | 解决 | 解决 | 完全解决 |
对于绝大多数业务来说,MySQL 默认的可重复读已经足够安全。它通过 MVCC 的快照读解决了不可重复读,通过间隙锁机制大幅抑制了幻读。只有在极致的一致性要求下(比如资金汇总、复杂财务稽核),才需要评估是否需要临时提高隔离级别,或在应用层通过乐观锁加以保护。
作为开发者,你要记住的关键点是:选择隔离级别,本质是在一致性、并发性能和系统复杂度之间做取舍。 了解这三个问题是什么,以及你的数据库在什么情况下会出现它们,才能在做设计或排查线上问题时有清晰的判断。