事务是数据库提供的”安全舱”,把一组操作打包成原子单元,要么全部成功,要么全部回滚,让应用层不必操心并发带来的数据混乱。但事务的”要么全做要么全不做”只是表象,背后是 Undo Log、Redo Log、MVCC、锁机制四套系统协同工作的结果。
本文聚焦事务的 ACID 特性与隔离级别,把并发异常(脏读、不可重复读、幻读、写偏序)与隔离级别的对应关系讲清楚,并给出每个隔离级别下的 SQL 示例。MVCC 的版本链与可见性判断算法细节见 MySQL MVCC 原理,锁类型与死锁排查见 MySQL 锁机制与死锁。
前置知识
了解 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 | 已提交数据丢失 |
一致性是 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 读取旧值,恢复为 5000Undo 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 事务状态机
事务在其生命周期中经历多个状态的转换:
状态说明:
- 活跃(Active):事务正在执行 SQL 语句
- 部分提交(Partially Committed):最后一条语句执行完毕,等待 WAL 刷盘
- 已提交(Committed):WAL 刷盘成功,修改持久化
- 失败(Failed):执行出错或刷盘失败,需要回滚
- 已回滚(Aborted):Undo Log 恢复完成,数据库回到事务前状态
部分提交到已提交这一步是持久性的关键,只有 Redo Log 成功刷盘,事务才算真正提交。如果在此期间崩溃,重启后数据库会根据 Redo Log 重做已提交事务、根据 Undo Log 撤销未提交事务,这就是崩溃恢复的 ARIES 算法核心思想。
二、并发异常
当多个事务并发执行时,即使每个事务单独看都是正确的,组合在一起却可能产生各种异常。理解这些异常是理解隔离级别的前提。
2.1 脏读
脏读是指事务 A 读到了事务 B 未提交的修改。如果事务 B 随后回滚,事务 A 读到的就是”脏”数据,从未真正存在过的数据。
-- 脏读复现(MySQL,隔离级别设为 READ UNCOMMITTED)SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 事务 T1BEGIN;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;
-- 事务 T1BEGIN;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 幻读
幻读是指事务内同一范围查询返回的行数发生变化,因为其他事务插入或删除了满足条件的行。与不可重复读的区别在于,幻读关注的是行的”有无”,而非已有行的值变化。
-- 幻读复现(MySQL,REPEATABLE READ 下快照读不会幻读,但当前读会)SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务 T1BEGIN;SELECT * FROM accounts WHERE balance > 4500; -- 返回 2 行
-- 事务 T2INSERT 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 UPDATE、UPDATE、DELETE)仍会看到新插入的行。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写偏序是隔离级别中最容易忽视的异常。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 READ | READ COMMITTED |
| REPEATABLE READ 是否防止幻读 | 快照读防止,当前读通过 Gap Lock 防止 | 防止(PG 的 RR 基于 MVCC 快照,比 SQL 标准更严格) |
| SERIALIZABLE 实现方式 | 严格两阶段锁(S2PL) | SSI |
| READ COMMITTED 实现方式 | MVCC(每条语句新快照) | MVCC(每条语句新快照) |
| 是否支持 READ UNCOMMITTED | 支持,允许脏读 | 不支持 |
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 事务的执行流程
一个事务从 BEGIN 到 COMMIT,InnoDB 内部经历了以下步骤:
理解这个流程对排查锁等待和 Undo 膨胀很有帮助。长事务持有 Undo 段引用,导致旧版本无法被 Purge 线程清理,这是 MVCC 膨胀的根因,详见 MVCC 原理 中的运维章节。
4.2 实战场景:商品库存扣减与超卖
电商下单扣库存是事务最典型的应用场景。核心问题是:多个请求同时下单,如何防止库存扣到负数(超卖)。
-- 方案一:先查后扣(错误,有超卖风险)BEGIN;SELECT stock FROM items WHERE id = 1; -- 返回 1-- 此时另一个事务也查到 stock=1UPDATE items SET stock = stock - 1 WHERE id = 1; -- stock 变成 0COMMIT;-- 另一个事务也执行 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 itemsSET stock = stock - 1, version = version + 1WHERE 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 条件更新 |
| 并发度 | 持锁期间低 | 不持锁,冲突少时高 |
| 死锁风险 | 有(多行加锁顺序不一致时) | 无(不持锁) |
| 冲突多时表现 | 稳定,事务排队 | 频繁重试,可能比悲观锁差 |
| 适用场景 | 写多读少、冲突频繁、需强一致 | 读多写少、冲突稀疏、追求吞吐 |
乐观锁和悲观锁是应用层的并发控制策略,不是 MySQL 的内置概念。MySQL 真正提供的是行锁、间隙锁、MVCC 等机制(见 锁机制与死锁),悲观锁只是把这些机制用起来,乐观锁则是绕开它们用条件更新替代。MVCC 本身在快照读路径上就是乐观的,读不加锁;乐观锁是在写路径上补一层 CAS 校验。结合隔离级别一起看:REPEATABLE READ 下乐观锁能防超卖,但写偏序仍需 SERIALIZABLE 或 SSI,详见 MySQL MVCC 原理。
五、运维参数与故障恢复
5.1 持久性相关参数
事务提交时,InnoDB 的 Redo Log 刷盘行为由两个参数控制,它们决定了崩溃时可能丢失多少已提交数据:
0Optionalinteger
1Requiredinteger
2Optionalinteger
第二个参数是 sync_binlog,控制 binlog 的刷盘策略:
0Optionalinteger
1Requiredinteger
NOptionalinteger
生产环境中 innodb_flush_log_at_trx_commit 和 sync_binlog 都应设为 1,这是双 1 标准。如果两个参数都不是 1,崩溃恢复后可能出现主从数据不一致:Redo Log 有记录但 binlog 没有记录,或者反过来。这在主从复制架构中会导致数据漂移。
5.2 崩溃恢复流程
MySQL 启动时如果发现 Redo Log 中有未完成的事务,会执行崩溃恢复:
这个流程就是 ARIES 算法的核心:Redo 重做已提交但数据页未刷盘的修改,Undo 撤销未提交但数据页已修改的操作。理解这个流程,才能理解为什么 innodb_flush_log_at_trx_commit=1 如此重要:它保证了 Redo Log 在提交时一定落盘,崩溃恢复时一定能重做。
参考资料
- MySQL 8.0 Reference Manual: Transaction Isolation Levels - InnoDB 四种隔离级别的官方说明
- MySQL 8.0 Reference Manual: ACID Model - InnoDB 的 ACID 实现模型
- Berenson et al., A Critique of ANSI SQL Isolation Levels (1995) - 隔离级别的经典论文,定义了各隔离级别下的异常
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






