mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4111 字
11 分钟
MySQL 事务原理与隔离级别
2024-06-10

事务是数据库提供的”安全舱”,把一组操作打包成原子单元,要么全部成功,要么全部回滚,让应用层不必操心并发带来的数据混乱。但事务的”要么全做要么全不做”只是表象,背后是 Undo Log、Redo Log、MVCC、锁机制四套系统协同工作的结果。

本文聚焦事务的 ACID 特性与隔离级别,把并发异常(脏读、不可重复读、幻读、写偏序)与隔离级别的对应关系讲清楚,并给出每个隔离级别下的 SQL 示例。MVCC 的版本链与可见性判断算法细节见 MySQL MVCC 原理,锁类型与死锁排查见 MySQL 锁机制与死锁

前置知识#

Important
  • 了解 InnoDB 的 Buffer Pool 与 Redo Log 机制,便于理解持久性实现,参见 InnoDB 架构与实现

  • 基本的并发编程概念:锁、竞态条件、死锁

一、事务基础与 ACID#

1.1 什么是事务#

事务(Transaction)是数据库操作的逻辑工作单元,由一组 SQL 语句组成。事务的发明源于一个朴素的需求:一组操作要么全部生效,要么全部不生效。经典的例子是银行转账,从 A 账户扣款并向 B 账户入账必须作为一个整体完成,不能出现”扣了款但没入账”的中间状态。

-- 银行转账:从账户 1 向账户 2 转账 1000 元
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;
-- 如果任何一步失败,执行 ROLLBACK 即可撤销所有修改

1.2 ACID 四大特性#

ACID 是事务必须满足的四个属性,它们共同保证了事务在并发和故障场景下的正确性。

特性含义实现机制违反后果
原子性(Atomicity)事务中的操作要么全部执行,要么全部不执行Undo Log部分修改持久化,数据不一致
一致性(Consistency)事务将数据库从一个一致状态转换到另一个一致状态应用约束 + 数据库约束违反业务规则或完整性约束
隔离性(Isolation)并发事务的执行互不干扰锁 / MVCC并发异常(脏读、幻读等)
持久性(Durability)事务提交后,修改永久保存,即使系统崩溃也不丢失Redo Log + fsync已提交数据丢失
Note

一致性是 ACID 中最特殊的属性,它不是数据库单方面保证的,而是数据库机制(原子性、隔离性)与应用层约束(业务规则)共同作用的结果。数据库提供的是”如果事务满足约束地开始,也满足约束地结束”的承诺。

1.3 原子性的实现#

原子性的关键问题是:事务执行到一半崩溃了怎么办?InnoDB 通过 Undo Log 实现回滚。修改数据前,先将旧值写入 Undo Log,崩溃恢复时根据 Undo Log 撤销未完成事务的修改。

-- InnoDB 的 Undo Log 工作原理(简化示意)
-- 事务 T1 修改 accounts 表中 id=1 的行
-- 1. 写入 Undo Log:记录旧值 {id:1, balance:5000}
-- 2. 修改数据页:将 balance 改为 4000
-- 3. 写入 Redo Log:记录新值 {id:1, balance:4000}
-- 如果事务提交:Redo Log 保证持久性
-- 如果事务回滚:从 Undo Log 读取旧值,恢复为 5000

Undo Log 除了用于回滚,还是 MVCC 版本链的载体。事务的隔离级别越高,Undo Log 中需要保留的旧版本就越多。关于 Undo Log 如何串联成版本链、如何参与可见性判断,详见 MySQL MVCC 原理

1.4 持久性的实现#

持久性依赖 WAL(Write-Ahead Logging)机制:数据页的修改必须先写入日志,再写入磁盘。提交事务时,数据库确保 Redo Log 刷盘(fsync)成功后才返回提交确认。即使数据页尚未写回磁盘,崩溃恢复时也能从 Redo Log 重做已提交的修改。

关于 WAL 的存储层细节,可参考 InnoDB 架构与实现 中的 Redo Log 与 Log Buffer 章节。

1.5 事务状态机#

事务在其生命周期中经历多个状态的转换:

stateDiagram-v2 [*] --> Active: BEGIN Active --> Partial: 最后一条SQL执行完毕 Partial --> Committed: COMMIT / WAL刷盘成功 Partial --> Failed: WAL刷盘失败 Active --> Failed: SQL执行错误 Committed --> [*] Failed --> Aborted: ROLLBACK / Undo恢复 Aborted --> [*]

状态说明

  • 活跃(Active):事务正在执行 SQL 语句
  • 部分提交(Partially Committed):最后一条语句执行完毕,等待 WAL 刷盘
  • 已提交(Committed):WAL 刷盘成功,修改持久化
  • 失败(Failed):执行出错或刷盘失败,需要回滚
  • 已回滚(Aborted):Undo Log 恢复完成,数据库回到事务前状态
Important

部分提交到已提交这一步是持久性的关键,只有 Redo Log 成功刷盘,事务才算真正提交。如果在此期间崩溃,重启后数据库会根据 Redo Log 重做已提交事务、根据 Undo Log 撤销未提交事务,这就是崩溃恢复的 ARIES 算法核心思想。

二、并发异常#

当多个事务并发执行时,即使每个事务单独看都是正确的,组合在一起却可能产生各种异常。理解这些异常是理解隔离级别的前提。

2.1 脏读#

脏读是指事务 A 读到了事务 B 未提交的修改。如果事务 B 随后回滚,事务 A 读到的就是”脏”数据,从未真正存在过的数据。

sequenceDiagram participant T1 as 事务T1 participant DB as 数据库 participant T2 as 事务T2 Note over T1,T2: 初始余额 A=5000 T1->>DB: BEGIN T1->>DB: UPDATE accounts SET balance=4000 WHERE id=1 T2->>DB: BEGIN (READ UNCOMMITTED) T2->>DB: SELECT balance FROM accounts WHERE id=1 DB-->>T2: 4000 读到未提交数据 T1->>DB: ROLLBACK 余额恢复为5000 Note over T2: T2 基于4000做决策但5000才是正确值
-- 脏读复现(MySQL,隔离级别设为 READ UNCOMMITTED)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 事务 T1
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- 此时未提交
-- 事务 T2(另一个连接)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 返回 4000(脏读)
COMMIT;
-- 事务 T1 回滚
ROLLBACK; -- T2 读到的 4000 从未真正存在

2.2 不可重复读#

不可重复读是指事务内同一查询在不同时间点返回不同结果,因为其他事务在期间修改并提交了数据。

-- 不可重复读复现(MySQL,隔离级别设为 READ COMMITTED)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 事务 T1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 返回 5000
-- 事务 T2(另一个连接)
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
COMMIT;
-- 事务 T1 再次查询
SELECT balance FROM accounts WHERE id = 1; -- 返回 4000,同一事务内两次查询结果不同
COMMIT;

2.3 幻读#

幻读是指事务内同一范围查询返回的行数发生变化,因为其他事务插入或删除了满足条件的行。与不可重复读的区别在于,幻读关注的是行的”有无”,而非已有行的值变化。

sequenceDiagram participant T1 as 事务T1 participant DB as 数据库 participant T2 as 事务T2 Note over T1,T2: accounts 表中 balance > 4500 的行有2条 T1->>DB: BEGIN (REPEATABLE READ) T1->>DB: SELECT * FROM accounts WHERE balance > 4500 DB-->>T1: 2行 T2->>DB: BEGIN T2->>DB: INSERT INTO accounts VALUES(3, 'Charlie', 4800) T2->>DB: COMMIT T1->>DB: SELECT * FROM accounts WHERE balance > 4500 DB-->>T1: 2行 快照读基于 MVCC 不返回新行 T1->>DB: UPDATE accounts SET balance=balance-100 WHERE balance > 4500 Note over T1: 当前读看到3行,UPDATE 影响行数与快照读的2行不一致
-- 幻读复现(MySQL,REPEATABLE READ 下快照读不会幻读,但当前读会)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务 T1
BEGIN;
SELECT * FROM accounts WHERE balance > 4500; -- 返回 2 行
-- 事务 T2
INSERT INTO accounts VALUES (3, 'Charlie', 4800);
COMMIT;
-- 事务 T1:快照读不会幻读
SELECT * FROM accounts WHERE balance > 4500; -- 仍返回 2 行(MVCC 快照)
-- 事务 T1:当前读会幻读
SELECT * FROM accounts WHERE balance > 4500 FOR UPDATE; -- 返回 3 行
COMMIT;

MySQL 的 REPEATABLE READ 通过 MVCC 让快照读避免了幻读,但当前读(SELECT ... FOR UPDATEUPDATEDELETE)仍会看到新插入的行。InnoDB 在当前读时用 Next-Key Lock 来防止幻读,这个机制在 锁机制与死锁 中详细展开。

2.4 写偏序#

写偏序是一种更隐蔽的异常:两个事务各自读取重叠数据集,然后修改不重叠的部分,单独看每个事务都没问题,但组合起来违反了业务约束。

-- 写偏序示例:值班医生系统,要求至少 1 人值班
-- 初始:Alice 值班,Bob 值班
-- 事务 T1:Alice 请假
BEGIN;
SELECT COUNT(*) FROM on_call WHERE doctor = 'Alice' AND on_duty = true; -- 2人值班
-- 检查:2 > 1,可以请假
UPDATE on_call SET on_duty = false WHERE doctor = 'Alice';
COMMIT;
-- 事务 T2(并发):Bob 请假
BEGIN;
SELECT COUNT(*) FROM on_call WHERE doctor = 'Bob' AND on_duty = true; -- 2人值班
-- 检查:2 > 1,可以请假
UPDATE on_call SET on_duty = false WHERE doctor = 'Bob';
COMMIT;
-- 结果:无人值班,违反了"至少1人值班"的业务约束
-- REPEATABLE READ 无法防止写偏序,需要 SERIALIZABLE 或 SSI
Warning

写偏序是隔离级别中最容易忽视的异常。REPEATABLE READ 能防止脏读、不可重复读和部分幻读,但不能防止写偏序。许多生产事故的根因就是误以为 REPEATABLE READ 足够安全,却忽略了写偏序的风险。

2.5 异常对比#

异常类型描述产生条件危害等级
脏读读到未提交数据读未提交隔离级别
不可重复读同一查询结果不同其他事务修改并提交
幻读范围查询行数变化其他事务插入/删除行
写偏序并发修改违反约束各自读重叠集、改不重叠集中高
丢失更新覆盖他人修改两个事务同时读-改-写同一行

三、隔离级别#

SQL 标准定义了四种隔离级别,每种级别解决特定的并发异常。隔离级别越高,一致性保证越强,但并发性能越低。

3.1 四种隔离级别#

READ UNCOMMITTED(读未提交)

最低隔离级别,允许事务读取未提交的修改。几乎没有数据库将其作为默认级别,因为脏读的危害太大。

READ COMMITTED(读已提交)

只允许读取已提交的数据,解决了脏读问题。这是 Oracle 和 PostgreSQL 的默认隔离级别。每次 SELECT 都获取新的快照,因此同一事务内可能读到不同结果(不可重复读)。

REPEATABLE READ(可重复读)

保证同一事务内多次读取同一行数据结果一致,解决了不可重复读问题。这是 MySQL 的默认隔离级别。在 MySQL 的实现中,快照读通过 MVCC 也避免了幻读,但当前读仍可能出现幻读(除非配合 Next-Key Lock)。

SERIALIZABLE(可串行化)

最高隔离级别,保证并发事务的效果等价于某种串行执行顺序,解决所有异常。MySQL 的 InnoDB 通过严格两阶段锁(S2PL)实现 SERIALIZABLE,所有读操作加共享锁;PostgreSQL 则用 SSI(Serializable Snapshot Isolation)在 MVCC 快照基础上检测写偏序,只回滚真正产生冲突的事务。SSI 的实现细节见 MySQL MVCC 原理 中的 SSI 章节。

3.2 隔离级别与异常的关系#

隔离级别脏读不可重复读幻读写偏序实现方式
READ UNCOMMITTED可能可能可能可能无锁
READ COMMITTED防止可能可能可能短锁 / MVCC
REPEATABLE READ防止防止部分防止可能MVCC + Gap Lock
SERIALIZABLE防止防止防止防止S2PL / SSI

3.3 MySQL 与 PostgreSQL 的默认隔离级别差异#

对比维度MySQL (InnoDB)PostgreSQL
默认隔离级别REPEATABLE READREAD COMMITTED
REPEATABLE READ 是否防止幻读快照读防止,当前读通过 Gap Lock 防止防止(PG 的 RR 基于 MVCC 快照,比 SQL 标准更严格)
SERIALIZABLE 实现方式严格两阶段锁(S2PL)SSI
READ COMMITTED 实现方式MVCC(每条语句新快照)MVCC(每条语句新快照)
是否支持 READ UNCOMMITTED支持,允许脏读不支持
Note

MySQL 的默认隔离级别是 REPEATABLE READ,比标准 SQL 要求的更强,通过 Next-Key Lock(记录锁 + 间隙锁)在当前读时防止幻读,而标准 SQL 的 REPEATABLE READ 并不要求防止幻读。PostgreSQL 虽然默认是 READ COMMITTED,但其 REPEATABLE READ 实现的是快照隔离,事务内所有读(包括 SELECT ... FOR UPDATE)都基于事务开始时的快照,同样能防止幻读,也比标准更严格。

3.4 各隔离级别下的 SQL 示例#

-- READ COMMITTED:每次 SELECT 获取新快照
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务修改并提交后
SELECT balance FROM accounts WHERE id = 1; -- 4000(新快照)
COMMIT;
-- REPEATABLE READ:事务内快照一致
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务修改并提交后
SELECT balance FROM accounts WHERE id = 1; -- 5000(同一快照)
COMMIT;
-- SERIALIZABLE:完全可串行化
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM accounts WHERE balance > 4500; -- 加范围锁
-- 另一事务无法插入 balance>4500 的新行
COMMIT;

四、事务生命周期与实战场景#

4.1 事务的执行流程#

一个事务从 BEGINCOMMIT,InnoDB 内部经历了以下步骤:

flowchart TD A(["BEGIN"]) --> B["分配事务 ID\n分配 Undo 段"] B --> C["执行 SQL\n修改前写 Undo Log\n修改数据页\n写 Redo Log 到 Log Buffer"] C --> D{"COMMIT 或 ROLLBACK"} D -->|COMMIT| E["Redo Log 刷盘 fsync\n释放 Undo 段引用\n提交完成"] D -->|ROLLBACK| F["读 Undo Log 回滚\n恢复数据页\n释放 Undo 段引用"] E --> G(["事务结束"]) F --> G

理解这个流程对排查锁等待和 Undo 膨胀很有帮助。长事务持有 Undo 段引用,导致旧版本无法被 Purge 线程清理,这是 MVCC 膨胀的根因,详见 MVCC 原理 中的运维章节。

4.2 实战场景:商品库存扣减与超卖#

电商下单扣库存是事务最典型的应用场景。核心问题是:多个请求同时下单,如何防止库存扣到负数(超卖)。

-- 方案一:先查后扣(错误,有超卖风险)
BEGIN;
SELECT stock FROM items WHERE id = 1; -- 返回 1
-- 此时另一个事务也查到 stock=1
UPDATE items SET stock = stock - 1 WHERE id = 1; -- stock 变成 0
COMMIT;
-- 另一个事务也执行 UPDATE,stock 变成 -1,超卖
-- 方案二:原子扣减(正确,利用 WHERE 条件防止超卖)
BEGIN;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
-- affected_rows = 1 表示扣减成功
-- affected_rows = 0 表示库存不足,业务层返回失败
COMMIT;

方案二利用了 UPDATE 语句的原子性:WHERE stock >= 1 条件检查和数据修改在 InnoDB 中是一个原子操作,配合行级排他锁,不会出现两个事务同时通过条件检查的情况。如果扣减失败,事务回滚不会留下任何副作用。

-- 方案三:SELECT FOR UPDATE 加悲观锁(需要先查询再扣减的场景)
BEGIN;
SELECT stock FROM items WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 此时 stock=1,且其他事务无法修改这行
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT;

方案三用 FOR UPDATE 加当前读的排他锁,保证查询和扣减之间没有其他事务插入。但锁的代价是并发度降低,高并发场景下方案二的原子扣减更优。

4.3 悲观锁与乐观锁#

方案二和方案三其实是两种并发控制思路的缩影:方案二在提交时才校验冲突,方案三在访问数据前就先加锁。这就是数据库领域常说的乐观锁与悲观锁。

悲观锁假设冲突一定会发生,操作数据前先加锁,让其他事务阻塞等待。SELECT ... FOR UPDATE 加行级排他锁就是典型实现,操作期间其他事务无法修改这一行。悲观锁依赖数据库的锁机制,写多读少、冲突概率高的场景下用它最直接,缺点是持锁期间并发度低,还可能死锁。

乐观锁假设冲突很少发生,不加锁,而是在更新时检查数据有没有被别人改过。常见做法是给表加一个版本号字段,更新时把版本号条件带上,靠 affected_rows 判断是否成功:

-- 乐观锁:用 version 字段做 CAS(Compare And Swap)
-- 表结构:items(id, stock, version)
-- 1. 先读出当前库存和版本号(不加锁,快照读)
SELECT stock, version FROM items WHERE id = 1; -- 返回 stock=1, version=5
-- 2. 业务层判断库存足够后,带版本号更新
UPDATE items
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- affected_rows = 1:没人改过,扣减成功
-- affected_rows = 0:版本号对不上,说明期间被别的事务改过,重试或返回失败

乐观锁没有真正持锁,读和写之间留有窗口,靠版本号校验兜底。冲突少时几乎不重试,吞吐高;但冲突多时会频繁失败重试,反而比悲观锁更差。

用库存超卖把两种锁放在一起对比:

-- 悲观锁解法:先锁后扣
BEGIN;
SELECT stock FROM items WHERE id = 1 FOR UPDATE; -- 加排他锁,其他事务阻塞
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT; -- 释放锁
-- 乐观锁解法:带条件 CAS 扣减(即 4.2 方案二)
BEGIN;
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
-- 等价于把 stock 当版本号:stock>=1 既校验库存又防止并发超扣
COMMIT;

两种锁的取舍要看业务特征:

维度悲观锁乐观锁
冲突假设假设冲突频繁假设冲突很少
实现机制数据库锁(FOR UPDATE、表锁)版本号/CAS 条件更新
并发度持锁期间低不持锁,冲突少时高
死锁风险有(多行加锁顺序不一致时)无(不持锁)
冲突多时表现稳定,事务排队频繁重试,可能比悲观锁差
适用场景写多读少、冲突频繁、需强一致读多写少、冲突稀疏、追求吞吐
Tip

乐观锁和悲观锁是应用层的并发控制策略,不是 MySQL 的内置概念。MySQL 真正提供的是行锁、间隙锁、MVCC 等机制(见 锁机制与死锁),悲观锁只是把这些机制用起来,乐观锁则是绕开它们用条件更新替代。MVCC 本身在快照读路径上就是乐观的,读不加锁;乐观锁是在写路径上补一层 CAS 校验。结合隔离级别一起看:REPEATABLE READ 下乐观锁能防超卖,但写偏序仍需 SERIALIZABLE 或 SSI,详见 MySQL MVCC 原理

五、运维参数与故障恢复#

5.1 持久性相关参数#

事务提交时,InnoDB 的 Redo Log 刷盘行为由两个参数控制,它们决定了崩溃时可能丢失多少已提交数据:

0Optionalinteger

每秒刷盘一次。崩溃可能丢失 1 秒数据,性能最高。只适用于允许丢数据的非核心业务。

1Requiredinteger

每次提交都 fsync。不丢数据,性能最低。生产环境必须设为 1,这是 InnoDB 持久性保证的基石。

2Optionalinteger

每次提交写入 OS 缓存,每秒 fsync。MySQL 进程崩溃不丢数据,但操作系统崩溃可能丢数据,性能中等。

第二个参数是 sync_binlog,控制 binlog 的刷盘策略:

0Optionalinteger

由操作系统决定何时刷盘,性能最高,崩溃可能丢失 binlog 事件。

1Requiredinteger

每次提交都 fsync binlog。不丢 binlog,适合主从复制场景,生产环境推荐。

NOptionalinteger

每 N 次提交才 fsync。N 越大性能越好,崩溃可能丢失 N 个事务的 binlog。
Warning

生产环境中 innodb_flush_log_at_trx_commitsync_binlog 都应设为 1,这是双 1 标准。如果两个参数都不是 1,崩溃恢复后可能出现主从数据不一致:Redo Log 有记录但 binlog 没有记录,或者反过来。这在主从复制架构中会导致数据漂移。

5.2 崩溃恢复流程#

MySQL 启动时如果发现 Redo Log 中有未完成的事务,会执行崩溃恢复:

flowchart TD A(["MySQL 启动"]) --> B["扫描 Redo Log\n重做所有已提交事务"] B --> C["扫描 Undo Log\n回滚所有未提交事务"] C --> D["恢复完成\n数据库进入一致状态"]

这个流程就是 ARIES 算法的核心:Redo 重做已提交但数据页未刷盘的修改,Undo 撤销未提交但数据页已修改的操作。理解这个流程,才能理解为什么 innodb_flush_log_at_trx_commit=1 如此重要:它保证了 Redo Log 在提交时一定落盘,崩溃恢复时一定能重做。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL 事务原理与隔离级别
https://blog.souloss.cn/posts/middleware/db/mysql-transaction-and-isolation/
作者
Souloss
发布于
2024-06-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时