MySQL 隔离级别:解决脏读、不可重复读、幻读 – wiki基地


数据库并发控制的基石:深入理解 MySQL 隔离级别与常见并发问题

在现代数据库系统中,尤其是在高并发的应用场景下,多个事务同时访问和修改数据是常态。如果不对这些并发操作进行适当的管理,可能会导致数据的不一致性、丢失更新等严重问题。为了确保数据的完整性、一致性和隔离性,数据库引入了“事务(Transaction)”的概念。事务是一系列操作的逻辑单元,这些操作要么全部成功提交,要么全部失败回滚。

事务的四大特性 ACID(原子性 Atomicity、一致性 Consistency、隔离性 Isolation、持久性 Durability)中,隔离性(Isolation)是解决并发访问问题的关键。它定义了多个并发事务之间互相影响的程度。理想的隔离状态是事务之间完全互不干扰,就好像它们是串行执行的一样。然而,完全的隔离会牺牲系统的并发性能,因为一个事务可能需要等待另一个事务释放资源。

为了在数据一致性和并发性能之间取得平衡,标准 SQL 定义了四种隔离级别。不同的隔离级别允许不同程度的并发冲突发生,从而在性能和一致性之间进行权衡。MySQL,特别是其默认存储引擎 InnoDB,提供了这四种标准隔离级别的实现。

本文将详细探讨数据库并发环境下可能遇到的三种经典问题:脏读(Dirty Read)、不可重复读(Non-Repeatable Read)和幻读(Phantom Read),并深入分析 MySQL 的四种隔离级别(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)如何解决或允许这些问题,特别是 InnoDB 在实现 REPEATABLE READ 时如何避免幻读。

并发事务带来的问题:脏读、不可重复读、幻读

在理解隔离级别之前,我们首先需要明确并发事务可能导致哪些问题。假设有两个并发执行的事务 A 和事务 B。

1. 脏读 (Dirty Read)

  • 定义: 脏读是指一个事务(事务 A)读取了另一个事务(事务 B)尚未提交(即回滚或仍在进行中)的数据。如果事务 B 最终回滚,那么事务 A 读取到的数据就是“脏”数据,因为它从未真正存在于数据库中。
  • 场景举例:
    • 事务 A 开始。
    • 事务 B 读取一条记录 id=1,其 statuspending
    • 事务 B 将记录 id=1status 更新为 processing,但尚未提交。
    • 事务 A 读取记录 id=1,看到了 statusprocessing(这是事务 B 未提交的数据)。
    • 事务 B 发生错误,回滚了其更新操作,记录 id=1status 恢复为 pending
    • 此时,事务 A 已经基于 statusprocessing 这个信息做出了某些判断或操作,但实际上这个状态从未被最终确认。事务 A 读取到了脏数据。
  • 危害: 脏读可能导致基于错误数据进行决策,造成逻辑上的错误和数据的不一致。这是最不被允许的并发问题之一。

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

  • 定义: 不可重复读是指在一个事务(事务 A)中,多次读取 同一条记录,但在这多次读取之间,另一个事务(事务 B)提交了对该记录的修改,导致事务 A 后续读取到的数据与之前不同。
  • 场景举例:
    • 事务 A 开始。
    • 事务 A 第一次读取记录 id=1,其 amount 为 100。
    • 事务 B 开始。
    • 事务 B 更新记录 id=1amount 为 200 并提交。
    • 事务 A 再次读取记录 id=1,发现其 amount 变为了 200。
    • 在一个事务内部,同一条记录的值发生了变化,这就是不可重复读。
  • 危害: 如果一个事务需要基于某个数据值进行多次计算或判断,而这个值在事务执行期间发生了变化,可能导致计算结果错误或违反业务逻辑。例如,一个事务在统计报表时,多次读取某个总金额字段,如果中间有其他事务修改并提交了这个金额,最终统计结果就会不准确。注意,不可重复读是针对 同一条记录 的修改。

3. 幻读 (Phantom Read)

  • 定义: 幻读是指在一个事务(事务 A)中,多次执行 同一查询(通常是范围查询或基于某个条件的查询),但由于另一个事务(事务 B)在两次查询之间 插入删除 了满足查询条件的记录,导致事务 A 后续的查询结果集发生了变化(就像出现了“幻影”记录)。
  • 场景举例:
    • 事务 A 开始。
    • 事务 A 执行查询 SELECT COUNT(*) FROM orders WHERE status = 'pending'; 结果为 5。
    • 事务 B 开始。
    • 事务 B 插入一条新的订单记录,其 statuspending,并提交。
    • 事务 A 再次执行查询 SELECT COUNT(*) FROM orders WHERE status = 'pending'; 结果为 6。事务 A 第一次查询时不存在的记录“幻影般”地出现了。
  • 危害: 幻读问题通常发生在需要对某个范围内的数据进行完整性检查或进行聚合操作时。例如,一个事务先检查某个条件下是否存在记录,如果不存在就插入一条;或者先统计某个条件下记录数量,然后基于这个数量进行后续操作。如果在此期间有其他事务插入或删除了满足条件的记录,可能导致事务 A 的逻辑失败(例如,本以为不存在而尝试插入,但实际上已经存在,导致唯一性冲突)或基于错误的集合进行操作。注意,幻读是针对 整个结果集 的变化(主要是记录的增减),而不可重复读是针对 单条记录 的值的变化。

理解了这三个问题后,我们就可以看到不同的隔离级别是如何选择性地防止或允许它们发生的。

标准 SQL 的四种隔离级别

标准 SQL 定义了四种隔离级别,从低到高依次是:

  1. READ UNCOMMITTED (读未提交)
  2. READ COMMITTED (读已提交)
  3. REPEATABLE READ (可重复读)
  4. SERIALIZABLE (可串行化)

隔离级别越高,越能保证数据的完整性和一致性,但付出的代价是更高的并发控制开销(例如更多的锁、更长的锁持有时间),从而可能降低数据库的并发处理能力和性能。反之,隔离级别越低,并发性能可能越高,但可能面临更多的并发问题。

下面我们详细分析每种隔离级别允许和防止哪些问题,以及 MySQL (InnoDB) 是如何实现的。

1. READ UNCOMMITTED (读未提交)

  • 特点: 这是最低的隔离级别。一个事务可以读取到其他事务尚未提交的数据。
  • 并发问题:
    • 允许脏读 (Dirty Read)。
    • 允许不可重复读 (Non-Repeatable Read)。
    • 允许幻读 (Phantom Read)。
  • MySQL (InnoDB) 实现: 在这个级别下,SELECT 操作是以非阻塞方式进行的,不加任何锁。它直接读取最新的数据,而不管这些数据是否已经提交。这提供了最高的并发性能,但几乎不保证数据一致性。
  • 应用场景: 极少使用,除非在对数据一致性要求极低、而对性能要求极高的特定场景下,但这通常不推荐用于大多数 OLTP (在线事务处理) 应用。

2. READ COMMITTED (读已提交)

  • 特点: 一个事务只能读取到其他事务已经提交的数据。这是许多数据库系统(如 Oracle, SQL Server)的默认隔离级别。
  • 并发问题:
    • 防止脏读 (Dirty Read)。 因为它只读取已提交的数据。
    • 允许不可重复读 (Non-Repeatable Read)。 同一个事务内,如果其他事务提交了对同一条记录的修改,后续读取会看到新的值。
    • 允许幻读 (Phantom Read)。同一个事务内,如果其他事务提交了插入或删除操作,后续范围查询会看到不同的结果集。
  • MySQL (InnoDB) 实现:READ COMMITTED 级别下,InnoDB 使用 MVCC (Multi-Version Concurrency Control) 来实现。每次 SELECT 语句执行时,都会创建一个新的 Read View(读视图)。这个 Read View 记录了当前系统中所有活跃(未提交)的事务 ID。SELECT 查询会读取最新版本的记录,但只有当该版本是由一个已提交的事务生成,或者是由当前事务自身生成时,才会被看到。未提交的事务生成的版本则不可见。
    • 如何防止脏读: 因为只读取已提交的版本,所以不会看到其他事务未提交的修改。
    • 如何允许不可重复读: 每次 SELECT 语句都生成新的 Read View。如果在同一个事务内,第一次 SELECT 后,另一个事务提交了修改,那么第二次 SELECT 生成新的 Read View 时,会看到那个已提交的修改,导致不可重复读。
  • 应用场景: 这是许多应用可以接受的隔离级别。它防止了脏读这一最严重的问题,同时允许不可重复读和幻读,但在许多业务场景下,这种程度的不一致性可以被应用逻辑或业务流程接受。性能相对较高。

3. REPEATABLE READ (可重复读)

  • 特点: 在一个事务开始后,该事务在整个执行期间看到的数据库状态是一致的。多次读取 同一条记录 会得到相同的结果,即使其他事务已经修改并提交了该记录。
  • 并发问题:
    • 防止脏读 (Dirty Read)。
    • 防止不可重复读 (Non-Repeatable Read)。
    • 标准 SQL 定义中,REPEATABLE READ 允许 幻读。然而,MySQL (InnoDB) 的实现增强了此隔离级别,在大多数情况下防止了幻读。 这是 MySQL 的一个重要特性,也是其默认隔离级别的原因之一。
  • MySQL (InnoDB) 实现: REPEATABLE READ 也是通过 MVCC 来实现的,但与 READ COMMITTED 不同的是,一个事务在第一次执行 SELECT 语句时生成一个 Read View,这个 Read View 在整个事务期间都保持不变。随后的 SELECT 语句都会使用这个相同的 Read View。
    • 如何防止脏读:READ COMMITTED 类似,只读取当前 Read View 下可见的已提交版本。
    • 如何防止不可重复读: 由于事务期间 Read View 不变,事务 A 只能看到在它启动时已经提交的事务所做的修改。如果在事务 A 启动后,事务 B 修改并提交了数据,事务 A 的 Read View 看不到事务 B 的提交,因此重复读取同一条记录时,看到的仍是事务启动时的旧版本。
    • 如何防止幻读 (MySQL/InnoDB 特有): 标准 SQL 的 REPEATABLE READ 允许幻读,因为它只保证同一条记录的值不变,但不阻止其他事务插入新记录。InnoDB 通过结合 MVCCNext-Key Locks 来防止幻读。
      • 对于纯粹的 SELECT 语句 (快照读),InnoDB 使用事务开始时的 Read View,这可以防止幻读的发生,因为新插入的行对于这个 Read View 是不可见的。
      • 对于当前读 (SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE, INSERT, UPDATE, DELETE),InnoDB 会使用 行锁 (Record Locks)间隙锁 (Gap Locks) 的组合,形成 Next-Key Locks。Next-Key Lock 会锁定被扫描到的索引记录本身(Record Lock)以及索引记录之间的“间隙”(Gap Lock)。通过锁定间隙,可以防止其他事务在该间隙中插入新的记录,从而避免幻读。例如,如果一个事务执行 SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE;,InnoDB 会锁定 ID 在 10 到 20 之间的现有记录,同时锁定这些记录之间的间隙以及边界间隙(例如,锁定 (9, 10], (10, 11], …, (19, 20], (20, …) 这样的间隙)。这样,其他事务就无法在 ID 10 到 20 之间插入新的记录,也就避免了幻读。
    • 关于 InnoDB 幻读防护的补充: 虽然 InnoDB 的 REPEATABLE READ 在大多数 DML (INSERT, UPDATE, DELETE) 和 SELECT ... FOR UPDATE/LOCK IN SHARE MODE 场景下能有效防止幻读,但在某些复杂的 SELECT 语句组合或特定情况下,幻读 理论上仍有可能 发生(例如,如果一个事务先使用快照读检查不存在,然后用当前读尝试插入)。但对于绝大多数常见的业务场景,InnoDB 的 REPEATABLE READ 已经提供了足够的幻读防护。
  • 应用场景: 这是 MySQL (InnoDB) 的默认隔离级别,它在性能和数据一致性之间提供了一个很好的平衡。它保证了事务内部数据的一致性视图,适用于大多数业务应用,特别是需要在一个事务内进行多次查询并确保结果一致的报表、统计或复杂业务逻辑。

4. SERIALIZABLE (可串行化)

  • 特点: 这是最高的隔离级别。它通过强制事务串行执行来完全避免并发问题。每个事务都必须等到上一个事务完成后才能开始。
  • 并发问题:
    • 防止脏读 (Dirty Read)。
    • 防止不可重复读 (Non-Repeatable Read)。
    • 防止幻读 (Phantom Read)。
    • 完全避免了所有并发问题。
  • MySQL (InnoDB) 实现:SERIALIZABLE 级别下,InnoDB 会隐式地将所有的普通 SELECT 语句转换为 SELECT ... LOCK IN SHARE MODE,即对读取的行加共享锁。对于 INSERT, UPDATE, DELETE 操作,会加排他锁。通过全程加锁,强制事务串行执行,从而避免了所有并发问题。
  • 应用场景: 极少使用,因为性能开销非常大,并发能力极低。只有在数据一致性要求达到极致、完全不能容忍任何并发问题、且可以接受性能大幅下降的场景下才会考虑使用,例如涉及非常敏感的金融计算或审计操作。

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

下表总结了四种隔离级别分别可以解决哪些并发问题(√ 表示该级别可以防止该问题,× 表示该级别允许该问题发生):

隔离级别 脏读 (Dirty Read) 不可重复读 (Non-Repeatable Read) 幻读 (Phantom Read)
READ UNCOMMITTED × × ×
READ COMMITTED × ×
REPEATABLE READ √ (MySQL InnoDB) / × (Standard SQL)
SERIALIZABLE

注意: 表格中特别注明了 MySQL InnoDB 在 REPEATABLE READ 级别下对幻读的处理是标准 SQL 的增强。这是 MySQL 使用过程中一个非常重要的细节。

MySQL (InnoDB) 隔离级别实现的底层机制

为了更好地理解隔离级别如何在 MySQL 中工作,有必要简单了解 InnoDB 的关键技术:

  1. MVCC (Multi-Version Concurrency Control 多版本并发控制):

    • InnoDB 的核心并发控制机制。
    • 它为数据库中的每一行记录保存多个版本。
    • 每个事务在启动时会得到一个事务 ID,并且会创建一个 Read View(读视图)。
    • Read View 决定了当前事务能够看到哪些版本的记录。通常,它只能看到在该事务启动时已经提交的事务所做的修改。未提交的或在事务启动后才提交的修改对于当前事务的快照读是不可见的。
    • MVCC 主要用于实现非阻塞的读操作(快照读),大大提高了并发读取的性能,是实现 READ COMMITTEDREPEATABLE READ 的基础。
  2. Locking (锁):

    • InnoDB 使用各种类型的锁来管理并发写操作以及在 SERIALIZABLE 或当前读场景下的读操作。
    • 共享锁 (Shared Locks, S 锁): 允许多个事务同时读取同一资源。与排他锁不兼容。
    • 排他锁 (Exclusive Locks, X 锁): 只允许一个事务持有,用于写操作。持有排他锁的事务可以读写该资源,其他事务不能再加任何锁(共享锁或排他锁)。
    • 行锁 (Record Locks): 锁定单条索引记录。
    • 间隙锁 (Gap Locks): 锁定索引记录之间的间隙,防止其他事务在锁定的间隙中插入新的记录。间隙锁只阻止插入操作。
    • Next-Key Locks (临键锁): 是行锁和间隙锁的组合。它锁定索引记录本身以及它之前的间隙。例如,在一个索引 (10, 20, 30) 中,对 20 的 Next-Key Lock 会锁定 (10, 20] 这个范围(即锁定 20 这条记录以及 10 到 20 之间的间隙)。Next-Key Locks 主要用于 REPEATABLE READ 级别,以防止幻读。
    • 锁机制主要用于管理写操作(INSERT, UPDATE, DELETE)和特定读操作(如 SELECT ... FOR UPDATE),确保在这些操作期间的数据一致性,并在不同隔离级别下防止相应的并发问题。

READ COMMITTED 级别,MVCC 的 Read View 是语句级别的,每次 SELECT 都会更新。
REPEATABLE READ 级别,MVCC 的 Read View 是事务级别的,整个事务使用同一个 Read View。
SERIALIZABLE 级别,主要依赖严格的锁机制,MVCC 的作用相对减弱。

如何设置和查看 MySQL 的隔离级别

可以在不同级别设置 MySQL 的隔离级别:

  1. 全局级别 (Global): 影响所有新的客户端连接。需要 SUPER 权限。
    sql
    SET GLOBAL transaction isolation level [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE];

    修改后,新的连接会使用新的全局设置,现有连接不受影响。

  2. 会话级别 (Session): 影响当前客户端连接的所有后续事务。
    sql
    SET SESSION transaction isolation level [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE];

    这个设置会覆盖全局设置,只对当前会话有效。会话结束,设置失效。

  3. 下一个事务级别: 仅影响紧随其后的 START TRANSACTION 语句开启的那个事务。
    sql
    SET transaction isolation level [READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE];
    START TRANSACTION; -- 或 BEGIN;

    这个设置优先级最高,只对下一个事务有效。

查看当前的隔离级别:

  • 查看全局隔离级别:
    sql
    SELECT @@global.transaction_isolation;

    或者在 MySQL 5.7.20+ / 8.0+ 中:
    sql
    SELECT @@global.transaction_ISOLATION; -- 注意 ISOLATION 大写
  • 查看当前会话隔离级别:
    sql
    SELECT @@session.transaction_isolation;

    或者在 MySQL 5.7.20+ / 8.0+ 中:
    sql
    SELECT @@session.transaction_ISOLATION;

    或者简写:
    sql
    SELECT @@transaction_isolation; -- 或 @@transaction_ISOLATION;

注意: 在 MySQL 5.7.20 及其更高版本中,transaction_isolation 系统变量名变为了 transaction_ISOLATION (大写 ISOLATION)。为了兼容性,通常建议使用大写形式。

如何选择合适的隔离级别?

选择合适的隔离级别是一个权衡的过程,需要根据具体的应用场景来决定:

  1. 优先考虑默认级别 (REPEATABLE READ): 对于大多数 MySQL (InnoDB) 应用来说,REPEATABLE READ 是一个很好的选择。它有效防止了脏读和不可重复读,并且通过 Next-Key Locks 很大程度上避免了幻读,在保证较高数据一致性的同时,提供了不错的并发性能。
  2. 如果可以容忍不可重复读和幻读,且需要更高并发: 可以考虑 READ COMMITTED。例如,某些报表或统计功能,如果允许在生成过程中看到其他事务提交的新数据,并且性能是首要考虑,可以降级到此级别。许多非 MySQL 数据库默认就是这个级别。
  3. 如果对数据一致性要求极低,且极度追求性能: 极少情况下会使用 READ UNCOMMITTED。几乎不推荐用于重要的业务系统。
  4. 如果对数据一致性要求极高,且可以牺牲大量性能: 使用 SERIALIZABLE。只有在非常特殊的、对完整性要求极致的场景下才考虑。

在实际开发中,应首先理解业务需求对数据一致性的要求,然后根据 MySQL (InnoDB) 的特性(特别是 REPEATABLE READ 对幻读的处理)来选择合适的隔离级别。如果不确定,通常维持默认的 REPEATABLE READ 是一个相对安全的起点。

总结

并发控制是数据库管理的核心挑战之一,而事务隔离级别是解决这一挑战的关键机制。通过理解脏读、不可重复读和幻读这三种并发问题,以及 MySQL (InnoDB) 四种隔离级别(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE)如何利用 MVCC 和锁机制来防止或允许这些问题,我们可以更好地设计和实现数据库应用,确保在高并发环境下的数据正确性和一致性。

MySQL 的默认隔离级别 REPEATABLE READ 在标准 SQL 的基础上进行了增强,通过 Next-Key Locks 有效防止了幻读,使其成为一个功能强大且实用的默认选项。开发者应根据具体的业务场景需求,权衡一致性与性能,选择最合适的隔离级别,并通过 SET 语句进行配置,确保事务行为符合预期。深入理解这些概念,是构建健壮、高效数据库应用的基础。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部