MySQL 事务与并发控制:ACID、隔离级别与 MVCC 深度解析
摘要:熊大给光头强转 100 块钱,钱扣了但熊大“被杀”了,这钱到底转没转?多个用户同时读写数据库,为什么你读到的数据时而对时而不对?本文从 ACID 四大特性讲起,用生活化的比喻带你理解事务的完整生命周期,再深入剖析并发场景下的四大问题、四种隔离级别,以及 MySQL 实现“非阻塞读”的核心武器——MVCC 与 ReadView。
一、从熊大转账说起:为什么需要事务?
假设熊大要给光头强转账 100 元,数据库里有两条记录:
SELECT*FROMaccountWHEREname='熊大';-- balance = 200SELECT*FROMaccountWHEREname='光头强';-- balance = 100转账需要两步操作:
UPDATEaccountSETbalance=balance-100WHEREname='熊大';-- 熊大变成 100UPDATEaccountSETbalance=balance+100WHEREname='光头强';-- 光头强变成 200如果第一步执行完,熊大的余额已经扣了 100,但就在这时——服务器挂了——第二步还没来得及执行。光头强没收到钱,熊大的钱却没了。这就是没有事务保护的后果。
事务(Transaction)要做的事情很简单:把多个操作打包成一个“不可分割”的整体,要么全部成功,要么全部失败。
转账事务: 开始事务 | v 熊大余额 -100 ──┐ | │ 这两步是一个整体 v │ 任何一步失败,全部回滚 光头强余额 +100 ──┘ | v 提交事务(Commit)→ 数据永久生效二、ACID:事务的四大铁律
事务之所以可靠,是因为它遵循 ACID 四大特性。我们可以用“银行转账”来类比理解。
2.1 Atomicity(原子性)
定义:一个事务中的所有操作,要么全部完成,要么全部不完成,不存在“做了一半”的状态。
熊大视角:熊大扣钱和光头强加钱这两件事,必须一起发生。如果光头强没收到,熊大的钱必须原封不动退回来。
MySQL 如何实现:利用undo log(回滚日志)。如果事务执行到一半失败了,MySQL 会根据 undo log 把已经修改的数据恢复回去,就像什么都没发生过。
原子性的保障 —— undo log: 事务执行 UPDATE balance=100 | v 先写 undo log:"把 balance 改回 200" | v 再修改数据页:balance = 100 | v 如果事务失败/回滚 → 读 undo log → 恢复 balance=2002.2 Consistency(一致性)
定义:事务执行前后,数据库必须处于“合法状态”。所有的约束(外键、唯一性、CHECK 等)都必须满足。
熊大视角:转账前,两人余额加起来是 300;转账后,加起来还是 300。钱不会凭空消失或凭空产生。
注意:一致性是原子性、隔离性、持久性共同作用的结果,它更像是一个“目标”,而不是独立实现的机制。
2.3 Isolation(隔离性)
定义:多个事务并发执行时,一个事务内部的操作不应该被其他事务干扰。
熊大视角:熊大转账的同时,光头强也在查余额。隔离性保证光头强看到的结果要么是转账前的,要么是转账后的,不会看到“熊大已经扣了钱但光头强还没收到”这种中间状态。
MySQL 如何实现:主要通过锁和MVCC(多版本并发控制)来实现。MVCC 让读操作不阻塞写操作,写操作也不阻塞读操作,大大提高了并发性能。
2.4 Durability(持久性)
定义:一旦事务提交,它对数据库的修改就是永久性的,即使系统随后崩溃,数据也不会丢失。
熊大视角:转账成功后,即使银行服务器下一秒全部断电,光头强卡里的 200 元也不会变回 100。
MySQL 如何实现:利用redo log(重做日志)。事务提交时,先把修改记录写到 redo log 并刷盘,然后再慢慢把数据页写到磁盘。即使断电,重启后也可以通过 redo log 恢复数据。
持久性的保障 —— redo log: 事务提交 | v 写 redo log(顺序写磁盘,极快) | v 返回"提交成功"给客户端 | v 后台线程慢慢把脏页刷到数据文件(随机写,较慢) | v 即使此时断电 → 重启后读取 redo log → 恢复数据三、事务的一生:状态与语法
3.1 事务的状态转换
一个事务从诞生到结束,会经历以下状态:
事务状态转换图: 开始事务 | v +---------+ | active | ← 事务正在执行中 +---------+ | | 正常执行完毕 v +-----------------+ | partially | ← 部分提交(已执行完最后一条语句, | committed | 但修改还没真正刷盘) +-----------------+ | | 成功写入 redo log v +---------+ |committed| ← 完全提交,永久生效 +---------+ 开始事务 | v +---------+ | active | +---------+ | | 遇到错误 / 用户主动回滚 v +---------+ | failed | +---------+ | v +---------+ | aborted | ← 回滚完成,事务结束(所有修改被撤销) +---------+3.2 基本语法
-- 方式1:显式开启事务BEGIN;-- 或STARTTRANSACTION;-- 执行一系列操作UPDATEaccountSETbalance=balance-100WHEREname='熊大';UPDATEaccountSETbalance=balance+100WHEREname='光头强';-- 提交事务(让修改永久生效)COMMIT;-- 或者回滚事务(撤销所有修改)ROLLBACK;3.3 SAVEPOINT(保存点)
如果一个大事务里只想回滚一部分,可以用保存点:
BEGIN;UPDATEaccountSETbalance=balance-100WHEREname='熊大';SAVEPOINTsp1;-- 设置保存点UPDATEaccountSETbalance=balance+100WHEREname='光头强';-- 突然发现光头强的账号填错了!ROLLBACKTOsp1;-- 只回滚到保存点,熊大的扣款保留COMMIT;-- 熊大扣了 100,光头强没收到(等重新操作)SAVEPOINT 的作用: BEGIN | v 熊大 -100 ←────── 这个修改保留 | v SAVEPOINT sp1 ←── 在这里打个“书签” | v 光头强 +100 ←────── 这个修改被撤销 | v ROLLBACK TO sp1 | v COMMIT3.4 autocommit:自动提交
MySQL 默认开启autocommit = ON,这意味着每一条单独的 SQL 语句都会被当作一个事务自动提交。
-- 查看当前设置SHOWVARIABLESLIKE'autocommit';-- 关闭自动提交SETautocommit=OFF;建议:在应用程序中,通常使用显式的BEGIN ... COMMIT来控制事务,而不是依赖autocommit。只有在执行单条查询时才让 autocommit 自动处理。
3.5 隐式提交(Implicit Commit)
某些语句会自动把前面未提交的事务悄悄提交掉,这称为“隐式提交”。常见的触发场景包括:
- DDL 语句:
CREATE TABLE、ALTER TABLE、DROP TABLE - 数据库管理:
CREATE USER、GRANT - 加载数据:
LOAD DATA - 锁表:
LOCK TABLES
BEGIN;UPDATEaccountSETbalance=100WHEREname='熊大';CREATETABLEt(idINT);-- ⚠️ 隐式提交!上面的 UPDATE 被自动提交了ROLLBACK;-- 对 CREATE TABLE 之后的新事务无效,UPDATE 已经无法回滚重要经验:不要在事务里混用 DML(增删改)和 DDL(建表、改表),否则你的事务边界会被悄悄打破。
四、并发带来的四大麻烦
数据库通常需要同时服务多个客户端。多个事务同时读写数据时,如果没有适当的隔离机制,就会出现各种问题。
4.1 脏写(Dirty Write)
场景:两个事务同时修改同一条记录,一个事务覆盖了另一个事务未提交的修改。
脏写示意图: 时间线 ──────────────────────────────> 事务 A:读取 balance = 200 | v 事务 B:读取 balance = 200 | | 事务 A 修改 balance = 100(未提交) | | v v 事务 B 修改 balance = 300(覆盖了 A 的修改!) | v 事务 A 回滚 → balance 应该恢复为 200 | v 但 B 已经覆盖了!最终 balance = 300(A 的回滚把 B 的改丢了)后果:一个事务的回滚会“抹掉”另一个事务已经提交的修改。
严重程度:🔴 最高。所有隔离级别都禁止脏写。
4.2 脏读(Dirty Read)
场景:一个事务读到了另一个事务还未提交的修改。
脏读示意图: 事务 A 事务 B ────────────────────────────────────────── UPDATE balance=100 (未提交) | v SELECT balance → 读到 100(脏读!) | v ROLLBACK balance 恢复 200 | v 业务基于 balance=100 做了错误决策后果:事务 B 基于一个“不存在”的数据做了决策,如果 A 最终回滚,B 的决策就是错误的。
严重程度:🔴 高。
4.3 不可重复读(Non-Repeatable Read)
场景:在同一个事务内,两次读取同一条记录,结果不一样。
不可重复读示意图: 事务 A 事务 B ────────────────────────────────────────────── SELECT balance → 200 | v UPDATE balance = 100 COMMIT | v SELECT balance → 100 ← 同一个事务里,两次读取结果不同!后果:事务 A 内部的数据一致性被破坏。如果 A 在做统计或校验,结果可能前后矛盾。
严重程度:🟡 中。某些业务场景可以接受,但做报表、对账时必须避免。
4.4 幻读(Phantom Read)
场景:在同一个事务内,两次执行相同的条件查询,第二次读到了第一次没有的行(或者原本有的行消失了)。
幻读示意图: 事务 A 事务 B ────────────────────────────────────────────── SELECT * FROM account WHERE balance > 50 → 结果:2 条记录(熊大 200,光头强 100) | v INSERT INTO account VALUES ('兔宝', 80); COMMIT | v SELECT * FROM account WHERE balance > 50 → 结果:3 条记录(多了个兔宝!) ↑ 同一个事务,同样的查询条件,结果集变多了 = 幻读注意:幻读强调的是“结果集的行数/内容变了”,而不可重复读强调的是“某一条具体记录的值变了”。
严重程度:🟡 中。
五、四种隔离级别:在性能与正确性之间取舍
SQL 标准定义了四种事务隔离级别,每种级别解决不同的问题:
| 隔离级别 | 脏写 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 禁止 | ⚠️ 允许 | ⚠️ 允许 | ⚠️ 允许 |
| READ COMMITTED | ❌ 禁止 | ❌ 禁止 | ⚠️ 允许 | ⚠️ 允许 |
| REPEATABLE READ | ❌ 禁止 | ❌ 禁止 | ❌ 禁止 | ⚠️ 允许* |
| SERIALIZABLE | ❌ 禁止 | ❌ 禁止 | ❌ 禁止 | ❌ 禁止 |
* MySQL 的 InnoDB 在 REPEATABLE READ 下通过 MVCC + 间隙锁基本解决了幻读问题,这是 MySQL 的一大特色。
隔离级别与并发问题的关系: READ UNCOMMITTED ── 什么都不防,性能最好,数据最不靠谱 | v READ COMMITTED ── 防脏读(Oracle/SQL Server 默认) | v REPEATABLE READ ── 防不可重复读(MySQL InnoDB 默认) | v SERIALIZABLE ── 全部防死,性能最差,数据最靠谱5.1 READ UNCOMMITTED(读未提交)
事务可以读到其他事务尚未提交的数据。性能最好,但数据一致性最差。生产环境几乎不用。
5.2 READ COMMITTED(读已提交)
事务只能读到其他事务已经提交的数据。解决了脏读,但仍然存在不可重复读和幻读。
这是 Oracle 和 SQL Server 的默认隔离级别。如果你的业务对“同一次事务内数据必须完全一致”要求不高,可以考虑使用它来获得更好的并发性能。
5.3 REPEATABLE READ(可重复读)
MySQL InnoDB 的默认隔离级别。在同一个事务内,多次读取同一条记录的结果是始终一致的。
MySQL 的 REPEATABLE READ 几乎解决了幻读:InnoDB 通过 MVCC 保证快照读看不到幻行,通过间隙锁(Gap Lock)保证当前读也不产生幻读。
5.4 SERIALIZABLE(串行化)
最严格的隔离级别。所有事务串行执行,完全避免所有并发问题,但并发性能极差。除非对数据一致性有极端要求(如金融核心的对账),否则不建议使用。
-- 查看当前隔离级别SELECT@@transaction_isolation;-- 设置会话隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;六、MVCC:没有锁也能实现隔离
6.1 为什么需要 MVCC?
如果完全用锁来实现隔离,最简单的办法是:一个事务读数据时加读锁,写数据时加写锁。但这样读和写就会互相阻塞,并发性能很差。
MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:写操作不覆盖旧数据,而是生成一个新版本;读操作根据情况选择合适版本读取,从而实现“读写不互斥”。
有无 MVCC 的对比: 无 MVCC(纯锁机制): 事务 A 读记录 R → 加读锁 事务 B 写记录 R → 必须等 A 释放读锁 ← 读写阻塞! 有 MVCC: 事务 A 读记录 R → 读某个历史版本(不用加锁) 事务 B 写记录 R → 生成新版本 R'(也不用等 A) ← 读写互不阻塞!6.2 版本链: undo log 串联起来的历史
InnoDB 的每行记录都隐藏了两个系统列:
trx_id:最后修改这行记录的事务 IDroll_pointer:指向 undo log 的指针,用来找到上一个版本
当一个事务修改某行数据时,MySQL 不会直接覆盖原数据,而是:
- 把原数据拷贝到 undo log 中
- 修改数据页中的记录,把
trx_id设为当前事务 ID - 把
roll_pointer指向刚才写入的 undo log
版本链的结构: 数据页中的记录(最新版本) +----------------+----------------+----------+----------+ | trx_id = 100 | roll_pointer ──┼──> undo log 1 | name = '熊大' | balance = 100 | | +----------------+----------------+----------+----------+ | +------------------------+ v undo log 1(上一个版本) +----------------+----------------+----------+ | trx_id = 80 | roll_pointer ──┼──> undo log 2 | balance = 200 | | | +----------------+----------------+----------+----------+ | +------------------------+ v undo log 2(更早版本) +----------------+----------------+ | trx_id = 50 | roll_pointer = NULL | balance = 0 | | +----------------+----------------+ 通过 roll_pointer 串联起来,就是一条"版本链"小知识:undo log 不仅用于回滚事务,还用于构建历史版本供其他事务读取。只有当没有任何事务需要访问这些旧版本时,undo log 才会被清理(purge)。
6.3 事务 ID 的生成
每个事务在启动时都会分配一个唯一递增的事务 ID(transaction id)。这个 ID 决定了“谁先谁后”——ID 小的事务先于 ID 大的事务发生。
事务 ID 的分配: 事务 1 启动 → trx_id = 1 事务 2 启动 → trx_id = 2 事务 3 启动 → trx_id = 3 | v 修改某行时,该行的 trx_id 就写上自己的事务 ID七、ReadView:判断“我能看见哪个版本”
版本链解决了“历史数据存放在哪”的问题,但还有一个关键问题:一个事务应该读哪个版本?
InnoDB 用ReadView(读视图)来解决这个问题。
7.1 ReadView 的四个核心字段
当一个事务执行 SELECT(快照读)时,InnoDB 会生成一个 ReadView,它记录了当前系统中的“事务快照”:
| 字段 | 含义 |
|---|---|
m_ids | 生成 ReadView 时,所有活跃(未提交)事务的 ID 列表 |
min_trx_id | m_ids中的最小值 |
max_trx_id | 生成 ReadView 时,系统即将分配的下一个事务 ID |
creator_trx_id | 生成这个 ReadView 的事务自己的 ID |
7.2 可见性判断规则
拿到 ReadView 后,InnoDB 沿着版本链从最新版本开始遍历,用下面的规则判断“这个版本我能看到吗”:
版本可见性判断流程: 对于版本链上的某个版本(trx_id = V): 1. 如果 V == creator_trx_id → 是我自己的修改,当然可见 ✅ 2. 如果 V < min_trx_id → 这个版本在 ReadView 生成前就已经提交了,可见 ✅ 3. 如果 V >= max_trx_id → 这个版本在 ReadView 生成后才开始,不可见 ❌ 4. 如果 min_trx_id <= V < max_trx_id → 检查 V 是否在 m_ids 中: - 在 m_ids 中 → 事务还没提交,不可见 ❌ - 不在 m_ids 中 → 事务已提交,可见 ✅ReadView 判断示例: 当前系统中:事务 10、20、30 已提交;事务 40、50 正在执行;事务 60 尚未启动 事务 50 生成 ReadView: m_ids = {40, 50} ← 活跃事务 min_trx_id = 40 max_trx_id = 60 ← 下一个要分配的事务 ID creator_trx_id = 50 版本链上的各个版本: trx_id=65 → 65 >= 60 → 不可见(未来事务的修改) ↓ trx_id=50 → 50 == creator_trx_id → 可见(自己的修改)✅ ↓ trx_id=40 → 40 在 m_ids 中 → 不可见(未提交)❌ ↓ trx_id=30 → 30 < 40 → 可见(已提交)✅ ↓ trx_id=20 → 20 < 40 → 可见(已提交)✅7.3 一句话总结 ReadView
ReadView 就像你站在某个时间点拍了一张系统的“快照”。在这张快照里,未提交的事务对你不可见,未来发生的事对你也不可见,你只能看到已经尘埃落定的事实。
八、RC 与 RR 的本质区别:ReadView 何时生成?
这是 MySQL 面试中最常考的问题之一,也是理解隔离级别差异的关键。
8.1 READ COMMITTED(RC)—— 每次 SELECT 都生成新 ReadView
在 RC 隔离级别下,事务中的每一条 SELECT 语句都会生成一个新的 ReadView。
RC 下的 ReadView 生成时机: 事务 A(RC) 事务 B ───────────────────────────────────────── SELECT → 生成 ReadView1 | v 读到 balance = 200 UPDATE balance = 100 COMMIT | v SELECT → 生成 ReadView2 ← 新的 ReadView! | v 读到 balance = 100 ← 能读到 B 已提交的修改!结果:RC 解决了脏读(因为未提交的事务在 m_ids 中,不可见),但无法解决不可重复读——因为第二次 SELECT 时事务 B 已经提交,新生成的 ReadView 能看到它。
8.2 REPEATABLE READ(RR)—— 第一次 SELECT 时生成 ReadView,之后复用
在 RR 隔离级别下,事务在第一次执行 SELECT 时生成 ReadView,之后整个事务期间都复用这一个 ReadView。
RR 下的 ReadView 生成时机: 事务 A(RR) 事务 B ───────────────────────────────────────── 第一次 SELECT → 生成 ReadView1 | v 读到 balance = 200 UPDATE balance = 100 COMMIT | v 第二次 SELECT → 复用 ReadView1 ← 还是原来的快照! | v 读到 balance = 200 ← 仍然是 200,保证了可重复读结果:即使事务 B 后来提交了,因为 ReadView1 中的m_ids和max_trx_id没有变,事务 A 仍然看不到 B 的修改。这就保证了在同一个事务内,多次读取结果始终一致。
8.3 RC vs RR 对比图
RC vs RR 的核心差异: ┌─────────────────────────────────────────────────────────────┐ │ READ COMMITTED │ │ │ │ SELECT #1 → ReadView A → 看到已提交数据 │ │ | │ │ | 其他事务提交 │ │ v │ │ SELECT #2 → ReadView B → 看到新提交的修改(不可重复读) │ └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ REPEATABLE READ │ │ │ │ SELECT #1 → ReadView A → 看到已提交数据 │ │ | │ │ | 其他事务提交 │ │ v │ │ SELECT #2 → ReadView A → 仍然看到原来的数据(可重复读) │ │ │ │ 整个事务只用一个 ReadView! │ └─────────────────────────────────────────────────────────────┘8.4 “当前读”与“快照读”的区别
前面讲的 SELECT 生成 ReadView,都属于快照读(Snapshot Read),读的是历史版本。
但某些操作需要读最新的、已提交的数据,这称为当前读(Current Read)。当前读不依赖 ReadView,而是直接读取最新版本,必要时还会加锁。
触发当前读的语句包括:
SELECT...FORUPDATE;-- 加排他锁,读最新版本SELECT...FORSHARE;-- 加共享锁,读最新版本UPDATE...;-- 修改前必须读最新版本DELETE...;-- 删除前必须读最新版本INSERT...;-- 插入操作注意:在 RR 级别下,快照读可以避免幻读(因为 ReadView 不变),但当前读仍然可能遇到幻读。InnoDB 通过**间隙锁(Gap Lock)**来解决当前读的幻读问题,这是另一个话题了。
九、总结与实战速查
9.1 核心概念速查表
| 概念 | 一句话解释 |
|---|---|
| 事务 | 把多个操作打包成“要么全成、要么全败”的整体 |
| ACID | 原子性、一致性、隔离性、持久性 |
| undo log | 回滚日志,用于事务回滚和构建历史版本 |
| redo log | 重做日志,用于崩溃恢复,保证持久性 |
| 脏写 | 覆盖了别人未提交的修改 |
| 脏读 | 读到了别人未提交的修改 |
| 不可重复读 | 同一事务内两次读同一条记录,值不同 |
| 幻读 | 同一事务内两次条件查询,结果集行数不同 |
| MVCC | 多版本并发控制,用版本链实现读写不阻塞 |
| 版本链 | 通过 roll_pointer 串联的 undo log 链条 |
| ReadView | 事务的快照,决定能看到哪些版本 |
| 快照读 | 普通的 SELECT,读历史版本 |
| 当前读 | FOR UPDATE / UPDATE / DELETE,读最新版本 |
9.2 隔离级别选择建议
隔离级别选择决策树: 是否需要最强的数据一致性? | ├── 是 → 考虑 SERIALIZABLE(但先确认性能是否可接受) | └── 否 → 是否有“同事务内多次读取必须一致”的要求? | ├── 是 → REPEATABLE READ(MySQL 默认,推荐) | └── 否 → 是否允许读到其他事务未提交的数据? | ├── 否 → READ COMMITTED(Oracle 默认) | └── 是 → READ UNCOMMITTED(几乎不用)9.3 实战 checklist
事务使用 checklist: □ 事务是否包含了最小必要的操作?(事务越大,持有锁越久) □ 事务中是否混用了 DDL?(DDL 会触发隐式提交) □ 是否正确处理了异常并执行 ROLLBACK? □ 是否需要 SAVEPOINT 来部分回滚? □ 隔离级别是否满足业务需求? □ 长事务是否会导致 undo log 膨胀?(大查询也可能导致) □ UPDATE/DELETE 是否带 WHERE 条件?(防止误改全表)9.4 常见误区澄清
误区 1:REPEATABLE READ完全不会幻读。
正解:RR 通过 ReadView 避免了快照读的幻读,但当前读(SELECT ... FOR UPDATE)在没有间隙锁保护的情况下仍可能幻读。InnoDB 的间隙锁机制补上了这个缺口。
误区 2:BEGIN之后立刻就分配了事务 ID。
正解:在 MySQL 中,事务 ID 是在第一次执行修改操作(INSERT/UPDATE/DELETE)时才分配的,纯读事务(只执行 SELECT)通常不分配事务 ID。
误区 3:ROLLBACK会把所有修改都撤销。
正解:ROLLBACK 只能撤销当前事务内的修改。如果事务中发生了隐式提交(如执行了 CREATE TABLE),隐式提交之前的修改已经无法回滚。
延伸阅读:
- MySQL 官方文档:Transaction Isolation Levels
- 本文配套博客:EXPLAIN 完全指南:一张图看懂 MySQL 执行计划
- 本文配套博客:MySQL 查询优化器的双重人格:成本计算与查询重写