前两篇我们学会了 MySQL 怎么存数据、怎么查得快。但你有没有想过一个问题:当你和同事同时操作同一条数据时,数据库是怎么保证不出乱子的?
试试脑补这个场景:你在转账 100 元,系统先从你的账户扣 100,再给对方加 100。如果扣完 100 之后,服务器突然断电——这 100 元是凭空消失,还是会神奇地回到你的账户?
这就是本篇要解决的问题。事务、锁、MVCC 是 MySQL 中最"硬核"的模块,也是面试中区分初级和高级开发的分水岭。别被它们的名字吓到,我们一个一个来。
一、事务:让一组操作同生共死
1.1 事务是什么?
事务(Transaction)是一组要么全部成功、要么全部失败的数据库操作。它和我们日常理解的"一批活"很像。
用 Java 代码表示:
@Transactional // Spring 声明式事务
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.decrease(fromId, amount); // 扣钱
// 如果这里抛异常,扣的钱会自动退回
accountMapper.increase(toId, amount); // 加钱
}
@Transactional 注解告诉 Spring:这两个操作绑定成一个事务,如果任何一个失败,前面的操作也全部撤销。
1.2 ACID:事务的四个铁律
| 原子性 (Atomicity) | 要么全做,要么全不做 | undo log(记录反操作,失败时回滚) |
| 一致性 (Consistency) | 数据始终满足业务规则 | 其他三个特性共同保证 |
| 隔离性 (Isolation) | 并发事务互不干扰 | MVCC + 锁(本篇后面重点讲) |
| 持久性 (Durability) | 提交后数据不丢失 | redo log(崩溃后用它恢复) |
小白理解技巧:把事务想象成 Git 的 commit——没 commit 之前,修改都在暂存区,随时可以撤销。COMMIT 就是 git push,提交了就永久保存了。ROLLBACK 就是 git reset –hard,回到修改前的状态。
了解事务的基本概念后,下一个问题自然浮现:如果两个事务同时操作同一份数据,会出什么问题?数据库提供了什么机制来应对?
二、隔离级别:解决并发的"度"
2.1 三种并发问题
假设有两个事务 T1 和 T2 同时运行,可能出现三种问题:
脏读(Dirty Read)
T1:修改 name='张三' → '李四',还没提交
T2:读到了 '李四' ← 脏读!
T1:回滚,name 还是 '张三'
T2 读到的 '李四' 是没提交的"脏数据",这个数据从来没真正存在过
不可重复读(Non-Repeatable Read)
T1:第一次读 name = '张三'
T2:修改 name = '李四' 并提交
T1:第二次读 name = '李四' ← 不可重复读!
同一事务内两次读同一行,结果不同
幻读(Phantom Read)
T1:SELECT * FROM user WHERE age > 20 → 5 行
T2:INSERT INTO user(age) VALUES(25) → 插入并提交
T1:SELECT * FROM user WHERE age > 20 → 6 行 ← 幻读!
"像幻觉一样多了一行数据"
快速区分:脏读是读了别人没提交的数据;不可重复读是同一行数据被人修改了;幻读是有人插入/删除了数据导致结果集行数变化。
2.2 四种隔离级别
— 查看当前级别
SELECT @@transaction_isolation;
— 设置级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
| READ UNCOMMITTED | ✅ 发生 | ✅ 发生 | ✅ 发生 | 几乎没人用 |
| READ COMMITTED | ❌ 不会 | ✅ 发生 | ✅ 发生 | 每次查询生成新 ReadView |
| REPEATABLE READ(MySQL 默认) | ❌ 不会 | ❌ 不会 | 部分解决 | 事务开始时生成一个 ReadView + 间隙锁 |
| SERIALIZABLE | ❌ 不会 | ❌ 不会 | ❌ 不会 | 读写全加锁,完全串行 |
面试常见追问:为什么 MySQL 默认是 RR 而不是 RC?
历史原因:早期 binlog 只有 STATEMENT 格式(记录 SQL 语句本身),RC 级别下主库和从库执行同一条 SQL 可能得到不同的结果。虽然现在推荐 ROW 格式了,但 RR 已成为事实标准。另外 RR 配合间隙锁能解决幻读,而 RC 做不到。
搞清楚了"并发会出什么问题",接下来的问题是——数据库具体是怎么实现隔离性的?答案就在锁和 MVCC 之中。
三、锁机制:并发控制的"交警"
3.1 锁的分类地图
┌── 共享锁(S 锁 / 读锁):允许别人读,不让别人写
┌── 行锁 ─┤
│ └── 排他锁(X 锁 / 写锁):别人既不能读也不能写
│
│ ┌── 记录锁(Record Lock):锁住一行
│ ├── 间隙锁(Gap Lock):锁住行与行之间的间隙
│ └── 临键锁(Next-Key Lock):记录锁 + 间隙锁
锁 ───┤
│ ┌── 意向共享锁(IS 锁)
└── 表锁 ─┼── 意向排他锁(IX 锁)
└── 自增锁(AUTO-INC)
先从行锁入手——这是 InnoDB 并发能力的核心,也是 Java 开发最常打交道的锁类型。
3.2 共享锁与排他锁:读写博弈
— 共享锁(S 锁):读的时候加,允许别人也读,但不允许别人写
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
— 排他锁(X 锁):写的时候加,别人既不能读也不能写
SELECT * FROM user WHERE id = 1 FOR UPDATE;
— INSERT / UPDATE / DELETE 默认就加 X 锁
锁兼容矩阵(记住这个就懂了所有的锁冲突):
| S 锁 | ✅ 可以共存 | ❌ S 锁和 X 锁冲突 |
| X 锁 | ❌ X 锁排斥一切 | ❌ X 锁排斥一切 |
3.3 临键锁(Next-Key Lock):解决幻读的利器
这是面试中最容易被问到的锁概念。在 REPEATABLE READ 隔离级别下,InnoDB 默认使用 临键锁 = 记录锁 + 间隙锁。
— 假设 user 表有 id = 5, 10, 15 三行
— 间隙分布:(-∞, 5), [5, 10), [10, 15), [15, +∞)
SELECT * FROM user WHERE id > 8 AND id < 13 FOR UPDATE;
这条语句会锁住哪些范围?
实际加锁范围: [5, 10) + [10, 15) + {记录 10}
↑ 间隙锁 ↑ 间隙锁 ↑ 记录锁
效果:既不能修改 id=10 的行,也不能在 5~15 之间插入新行
为什么需要间隙锁? 因为如果没有它,虽然你锁住了 id=10 这行,但别的事务可能在你查询的范围中插入一条 id=11 的记录,导致你的"结果集"在你不知情的情况下被改变了——这就是幻读。间隙锁就像在每个行之间拉了一条"警戒线"。
3.4 死锁:并发编程的经典陷阱
// 事务 A
BEGIN;
UPDATE account SET balance = balance – 100 WHERE id = 1; // 锁住 id=1
UPDATE account SET balance = balance + 100 WHERE id = 2; // 等待锁 id=2
COMMIT;
// 事务 B(同时执行!)
BEGIN;
UPDATE account SET balance = balance – 100 WHERE id = 2; // 锁住 id=2
UPDATE account SET balance = balance + 100 WHERE id = 1; // 等待锁 id=1
COMMIT;
// → 死锁!A 等 B 放锁,B 等 A 放锁,循环等待
// InnoDB 会检测到死锁,回滚其中一个事务,报错:
// Deadlock found when trying to get lock; try restarting transaction
避免死锁的三条黄金法则
锁解决了"写-写冲突"和"写-读部分冲突",但光靠锁的话,所有的读都要排队等写——性能就崩了。MySQL 是怎么让"读"和"写"可以同时进行的?答案是 MVCC。
四、MVCC:读写不冲突的秘密
4.1 一句话理解 MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想很简单:读操作不加锁,通过查看数据的旧版本来获取一致性视图。
就像你修改一个 Word 文档——你正在编辑的内容别人看不到,别人看到的还是旧的版本。你保存之后别人才看到新版。MVCC 在数据库里做了同一件事。
4.2 隐藏在每一行背后的两个"幽灵"字段
InnoDB 的每一行数据都有三个你平时看不到的隐藏字段:
┌──────────────────────────────────────────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR │
├──────────────────────────────────────────────────┤
│ 1 │ 张三 │ 25 │ 100 │ 0x… │
└──────────────────────────────────────────────────┘
↑ ↑
最近修改这一行的事务ID 指向 undo log 的指针
每次更新数据时,旧版本被存入 undo log,通过 DB_ROLL_PTR 指针串成版本链:
当前行(name='王五', trx=300)
↓ DB_ROLL_PTR
undo log v1(name='李四', trx=200)
↓ DB_ROLL_PTR
undo log v2(name='张三', trx=100)
4.3 ReadView:事务的"快照相机"
ReadView 是事务在读取数据时创建的"快照",记录了这个瞬间有哪些事务正在活跃(还没提交)。
| m_ids | 创建快照时所有"活跃中"的事务 ID |
| min_trx_id | m_ids 中最小的那个 |
| max_trx_id | 下一个待分配的事务 ID |
| creator_trx_id | 创建这个 ReadView 的事务自己 |
4.4 可见性判断:我该看到哪个版本?
当一条数据的 trx_id = 150,你的事务看这条数据时,按以下规则判断:
规则 1: trx_id == creator_trx_id → ✅ 自己的修改当然可见
规则 2: trx_id < min_trx_id → ✅ 修改者在快照前就提交了
规则 3: trx_id >= max_trx_id → ❌ 修改者在快照后才开始,不可见
规则 4: trx_id IN m_ids → ❌ 修改者在快照时还没提交,不可见
规则 5: 以上都不满足 → ✅ 修改者在快照前已提交,可见
4.5 RC vs RR:ReadView 的生成时机决定了一切
这是面试中最能体现深度的问题之一。两种隔离级别的唯一区别就是 ReadView 的生成时机:
READ COMMITTED(读已提交):
事务中每次 SELECT → 生成新的 ReadView
→ 能看到别的事务新提交的数据 → 导致"不可重复读"
REPEATABLE READ(可重复读):
事务中第一次 SELECT → 生成 ReadView,之后复用
→ 看不到别的事务新提交的数据 → 保证"可重复读"
有了 MVCC 的理解基础,你再回头看第一节的三种并发问题(脏读、不可重复读、幻读),应该会豁然开朗——它们本质上都是"事务在什么时候、看到了什么版本的数据"的问题。
本篇回顾
学完这一篇,你应该能回答:
- 事务的 ACID 四个特性分别由什么机制保证?
- 脏读、不可重复读、幻读的区别是什么?
- MySQL 四种隔离级别各自解决了什么问题?
- 共享锁和排他锁的兼容矩阵是什么?
- 临键锁是如何防止幻读的?间隙锁锁住的是什么?
- 死锁是怎么产生的?如何避免?
- MVCC 的 ReadView 是如何判断数据可见性的?
- RC 和 RR 的本质区别是什么?
这篇内容可能是整个系列中最"烧脑"的。如果你第一遍没完全理解 MVCC 和锁机制——完全正常。建议把第四节(MVCC)读两遍,然后尝试自己画出 ReadView + 版本链的判断过程。一旦通了,整个事务和锁的知识就串起来了。
下一篇,我们将走出单机范围,探索 MySQL 的日志系统和高可用架构——redo log 和 binlog 为什么需要两阶段提交?主从复制是怎么实现的?分库分表该怎么做?
【上一篇:Java开发必知必会的MySQL核心知识点(二)-索引探秘:让你的查询快如闪电】





