本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
事务与并发(ACID / 隔离级别 / 锁 / MVCC) · ACID 与隔离级别

并发事务可能产生哪些问题?(脏读、不可重复读、幻读、丢失更新)

What problems can arise from concurrent transactions? (Dirty Read, Non-repeatable Read, Phantom Read,

答案语言

考察要点

这道题面试官想看你是否深刻理解数据库并发控制的核心,即在多用户同时读写数据时,为了维护数据一致性可能遇到的问题。这不仅是考察你对 ACID 中“隔离性 (Isolation)”的理论认知,更是检验你是否能将理论与实践相结合,理解不同隔离级别是如何解决这些问题的,以及在性能和一致性之间如何做权衡。

核心概念

并发事务可能引发四种主要的数据一致性问题。下面我们以一个银行账户表 accounts 为例进行说明。

sql
1CREATE TABLE accounts (
2 id INT PRIMARY KEY,
3 user_name VARCHAR(50),
4 balance DECIMAL(10, 2)
5);
6INSERT INTO accounts VALUES (1, 'Alice', 1000.00);

1. 脏读 (Dirty Read)

定义:一个事务(T1)读取到了另一个事务(T2)尚未提交的修改。如果 T2 最终回滚(Rollback),那么 T1 读取到的数据就是“脏”的,因为它在数据库中从未正式存在过。

场景示例:

时间事务 A (ID: T1)事务 B (ID: T2)数据状态 (id=1)
1BEGIN;balance = 1000
2BEGIN;balance = 1000
3UPDATE accounts SET balance = balance - 100 WHERE id = 1;balance = 900 (内存中,未提交)
4SELECT balance FROM accounts WHERE id = 1; (读取到 900)balance = 900
5(基于 900 的余额进行后续操作...)balance = 900
6ROLLBACK;balance = 1000 (恢复)
7COMMIT;balance = 1000

后果:事务 A 基于一个从未实际发生过的“900 余额”做出了业务决策,导致业务逻辑错误。例如,系统可能错误地允许了本应失败的另一笔取款。


2. 不可重复读 (Non-Repeatable Read)

定义:一个事务(T1)内,对同一行数据的两次读取结果不同。这是因为在两次读取之间,有另一个事务(T2)提交了对该行数据的 UPDATE 或 DELETE。重点在于数据内容的变更。

场景示例:

时间事务 A (ID: T1)事务 B (ID: T2)数据状态 (id=1)
1BEGIN;balance = 1000
2SELECT balance FROM accounts WHERE id = 1; (读取到 1000)balance = 1000
3BEGIN;balance = 1000
4UPDATE accounts SET balance = 2000 WHERE id = 1;balance = 2000
5COMMIT;balance = 2000 (持久化)
6SELECT balance FROM accounts WHERE id = 1; (再次读取,得到 2000)balance = 2000
7COMMIT;balance = 2000

后果:事务 A 在一个事务内部,前后两次读取到的数据不一致,可能导致其内部逻辑混乱。例如,第一次读取余额用于校验,第二次读取用于计算,不一致的数据会导致计算错误。


3. 幻读 (Phantom Read)

定义:一个事务(T1)内,按相同条件的两次范围查询(Range Query)返回的行数不同。这是因为在两次查询之间,有另一个事务(T2)提交了 INSERT 或 DELETE 操作,导致查询结果集“幻影般地”增加或减少了行。重点在于结果集行数的变更。

场景示例:

时间事务 A (ID: T1)事务 B (ID: T2)数据状态 (accounts 表)
1BEGIN;1 条记录
2SELECT COUNT(*) FROM accounts WHERE balance > 500; (得到 1)1 条记录
3BEGIN;1 条记录
4INSERT INTO accounts VALUES (2, 'Bob', 2000.00);2 条记录
5COMMIT;2 条记录 (持久化)
6SELECT COUNT(*) FROM accounts WHERE balance > 500; (再次查询,得到 2)2 条记录
7COMMIT;2 条记录

后果:事务 A 以为自己已经处理了所有符合条件的记录,但新插入的“幻影”记录却逃过了处理,破坏了事务的业务逻辑完整性。例如,批量更新所有高余额账户的等级,但新加入的高余额账户被遗漏。


4. 丢失更新 (Lost Update)

定义:两个事务(T1 和 T2)同时读取同一数据,各自修改,然后先后提交,导致其中一个事务的修改被另一个覆盖,从而“丢失”。

场景示例(经典的“读取-修改-写回”模式):

时间事务 A (ID: T1)事务 B (ID: T2)数据状态 (id=1)
1BEGIN;balance = 1000
2SELECT balance FROM accounts WHERE id = 1; (应用层读到 1000)balance = 1000
3BEGIN;balance = 1000
4SELECT balance FROM accounts WHERE id = 1; (应用层读到 1000)balance = 1000
5(应用层计算 1000 + 100 = 1100)balance = 1000
6(应用层计算 1000 + 200 = 1200)balance = 1000
7UPDATE accounts SET balance = 1100 WHERE id = 1;balance = 1100
8COMMIT;balance = 1100 (持久化)
9UPDATE accounts SET balance = 1200 WHERE id = 1;balance = 1200
10COMMIT;balance = 1200 (持久化)

后果:事务 A 的更新(增加 100)被事务 B 的更新(增加 200)完全覆盖了。最终余额是 1200,而不是预期的 1000 + 100 + 200 = 1300。事务 A 的更新丢失了。

SQL 隔离级别与并发问题的关系

隔离级别脏读 (Dirty Read)不可重复读 (Non-Repeatable Read)幻读 (Phantom Read)
读未提交 (Read Uncommitted)可能可能可能
读已提交 (Read Committed)解决可能可能
可重复读 (Repeatable Read)解决解决可能 (标准) / 解决 (MySQL InnoDB)
可串行化 (Serializable)解决解决解决

注意:MySQL 的 InnoDB 存储引擎在可重复读 (Repeatable Read) 级别下,通过 Next-Key Lock(行锁 + 间隙锁)机制,额外解决了幻读问题,这超出了 SQL 标准的要求。

常见低分回答

  1. 混淆不可重复读和幻读:这是最常见的错误。低分回答会说“它们都是一个事务里两次查询结果不一样”。高分回答必须明确指出:不可重复读是针对已存在行的数据内容(UPDATE),幻读是针对符合某范围条件的行集合(INSERT/DELETE)。
  2. 只背诵定义,不解释危害:仅仅说“脏读是读到未提交数据”,但没有解释为什么这是个问题(例如,基于一个从未存在过的数据做决策),这样的回答缺乏深度。
  3. 忽略“丢失更新”:很多候选人只知道前三个问题,但“丢失更新”是并发场景下非常致命且常见的问题,尤其在应用层逻辑中。能主动提出并解释它,是加分项。
  4. 对隔离级别的理解流于表面:例如,简单地说“隔离级别越高越好”,而没有提到其带来的性能代价,如锁竞争加剧、并发度下降等。
  5. 不了解 MySQL 的特殊性:直接套用 SQL 标准,说“可重复读无法解决幻读”。这在面试 MySQL 相关岗位时是明显的知识盲点,暴露了候选人缺乏对常用数据库的深入实践。

工程实践与优化

  1. 合理选择隔离级别:绝大多数场景下,数据库默认的隔离级别(Oracle/PostgreSQL 默认为 Read Committed,MySQL 默认为 Repeatable Read)是最佳实践。不要轻易为了“绝对安全”而全局设置为 Serializable,这会严重牺牲系统吞吐量。只有在对一致性有极端要求的特定业务模块,才考虑提升隔离级别。
  2. 理解并利用 MVCC:现代数据库(如 InnoDB, PostgreSQL)大多采用多版本并发控制(MVCC)来实现 RC 和 RR 隔离级别。核心思想是“读写不阻塞”,读操作读取数据的某个历史版本快照,写操作创建新版本。这极大地提升了并发性能。理解 MVCC 是理解 RC 和 RR 的关键。
  3. 使用锁机制解决“丢失更新”:
    • 悲观锁:认为冲突总会发生,在读取数据时就加锁,防止他人修改。适用于写多读少的场景。
      sql
      1-- 在事务中,读取时就锁定该行
      2SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
      3-- 后续的 UPDATE...
    • 乐观锁:认为冲突很少发生,不加锁,但在更新时检查数据是否被修改过。通常通过增加 version 字段或使用时间戳实现。适用于读多写少的场景。
      sql
      1-- 1. 读取数据及版本号
      2SELECT balance, version FROM accounts WHERE id = 1;
      3-- 2. 应用层计算新 balance
      4-- 3. 更新时检查版本号
      5UPDATE accounts SET balance = 1100, version = version + 1 WHERE id = 1 AND version = 123;
      6-- 如果 UPDATE 影响行数为 0,说明数据已被修改,应用层需要重试。
  4. 保持事务简短:大事务(长事务)会长时间持有锁或维持旧版本快照,增加锁冲突概率,消耗 undo log 空间,应尽量将耗时长的非数据库操作(如网络请求、复杂计算)移出事务边界。

常见追问与延伸题目

  1. 追问:你说 MySQL 的 RR 级别解决了幻读,能具体讲讲是怎么做到的吗?
    • 回答要点:这主要归功于 InnoDB 的 Next-Key Lock。当我执行一个范围查询时,比如 WHERE age > 20,InnoDB 不仅会锁定满足条件的现有记录(Record Lock),还会锁定这些记录之间的“间隙”(Gap Lock),以及最后一个满足条件的记录之后的那个间隙。这样,任何试图在这个范围内 INSERT 新记录的事务都会被阻塞,从而防止了幻读的发生。
  2. 追问:那 MVCC 是如何工作的?Read Committed 和 Repeatable Read 在 MVCC 实现上有什么区别?
    • 回答要点:MVCC 依赖于每行记录的隐藏字段(如创建版本号、删除版本号)和 Undo Log。当事务开始时,会创建一个数据快照(Read View)。
    • Read Committed (RC):每次 SELECT 都会创建一个新的 Read View。这意味着在同一个事务内,后续的查询可以看到其他事务在此期间已提交的修改,所以会发生不可重复读。
    • Repeatable Read (RR):事务开始时的第一个 SELECT 创建一个 Read View,并且整个事务期间都复用这个 Read View。因此,事务内部后续的查询都看不到其他事务的新提交,保证了可重复读。
  3. 追问:如果一个业务场景绝对不能容忍任何并发问题,包括幻读,但又不想用 Serializable 级别,因为性能太差,你有什么解决方案?
    • 回答要点:可以继续使用 RR 级别,但在关键的读操作上手动加锁。
      • 方案一:使用 SELECT ... FOR UPDATE 或 SELECT ... LOCK IN SHARE MODE。对读取的范围显式加排他锁或共享锁。例如,SELECT * FROM accounts WHERE balance > 500 FOR UPDATE; 会锁住所有 balance > 500 的记录及相关间隙,其他事务无法在此范围插入或修改,效果等同于串行化,但只针对这一个操作,影响范围可控。
      • 方案二:如果业务逻辑允许,可以将范围判断的逻辑放到一个已有唯一索引的列上,将范围锁退化为行锁,减少锁冲突。但这需要业务改造,不通用。
      • 核心思想:通过在应用层面对关键代码路径进行“手动串行化”,来代替数据库全局的串行化,实现性能和一致性的精准平衡。