MySQL 基础(安装/CRUD/事务概念)见《MySQL 快速入门》,Spring 事务注解见《@Transactional 详解:为什么能回滚》。这篇把事务并发控制讲透:隔离级别、三种并发问题、MVCC 原理、锁。
一、为什么需要隔离级别
两个事务同时操作同一条数据时,会出现三种并发问题(按危害从小到大):
| 并发问题 | 现象 | 类比 |
|---|---|---|
| 脏读 | 读到别人未提交的数据(对方回滚了,你读到的是假的) | 看到草稿就当定稿 |
| 不可重复读 | 同一事务内两次读同一行,值不一样(别人提交了 UPDATE) | 看两遍金额不一样 |
| 幻读 | 同一事务内两次查询,行数不一样(别人 INSERT 了新行) | 数两遍人数不一样 |
隔离级别就是"用并发性能换数据安全"的旋钮——级别越高,防的越多,但并发越差。
二、四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交(RU) | ✕ | ✕ | ✕ | 最低,什么都不防(几乎不用) |
| 读已提交(RC) | ✓ | ✕ | ✕ | Oracle 默认,并发高 |
| 可重复读(RR) | ✓ | ✓ | 靠锁防 | MySQL 默认,MVCC 实现 |
| 串行化(Serializable) | ✓ | ✓ | ✓ | 全表锁,最安全最慢 |
-- 查看/设置隔离级别(会话级)
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;注意:MySQL 的 RR 用 MVCC 实现(不是锁表),所以 RR 下并发依然不错;而 Oracle 的默认 RC 是因为它认为 RR 影响并发——这是 MySQL 和 Oracle 最大的设计差异之一。
三、三种并发问题实跑演示(MySQL 8 实测)
用两个会话(S1/S2)演示"不可重复读":
-- S1:开启事务
START TRANSACTION;
SELECT balance FROM user WHERE id = 1; -- 读到 100
-- S2(另一个会话):修改并提交
UPDATE user SET balance = 200 WHERE id = 1;
COMMIT;
-- S1 再读(RC 下会读到 200,RR 下还是 100)
SELECT balance FROM user WHERE id = 1;- RC 下:S1 第二次读到 200(不可重复读)
- RR 下:S1 第二次还是 100(MVCC 快照保证可重复读)
四、MVCC 原理:可重复读怎么实现的
**MVCC(多版本并发控制)**是 InnoDB 实现隔离级别的核心机制,原理三层:
1. 数据行有多个版本(靠 undo log)
每次 UPDATE 不是覆盖,而是生成新版本,旧版本存 undo log——一行数据有多个版本链:
版本3 (balance=200) ← 版本2 (balance=150) ← 版本1 (balance=100)
最新 最旧2. 每个事务有 ReadView(快照)
事务第一次读时,生成一个 ReadView(记录"哪些版本我可见")——事务开启时已提交的版本可见,之后的版本不可见。
RR vs RC 的区别就在 ReadView 的生成时机:
| ReadView 生成时机 | 效果 | |
|---|---|---|
| RC | 每次 SELECT 都重新生成 | 能看到别人新提交的 → 不可重复读 |
| RR | 事务第一次 SELECT 生成,之后复用 | 一直看最初快照 → 可重复读 |
3. 读哪一版?按 ReadView 找版本链
查询时沿着版本链找第一个满足 ReadView 可见性的版本——这就是"快照读",不加锁、并发高。
五、锁:幻读怎么防(Next-Key Lock)
MVCC 解决脏读/不可重复读(快照读);幻读需要锁(当前读 SELECT ... FOR UPDATE / UPDATE / INSERT 时):
| 锁 | 作用 | 场景 |
|---|---|---|
| 记录锁(Record Lock) | 锁一行 | UPDATE 具体行 |
| 间隙锁(Gap Lock) | 锁一段区间(不含行) | 防区间内插入新行 |
| Next-Key Lock | 记录锁 + 间隙锁 | RR 下防止幻读的核心 |
幻读演示(当前读场景):
-- S1:锁住 balance > 50 的所有行(间隙也被锁)
START TRANSACTION;
SELECT * FROM user WHERE balance > 50 FOR UPDATE;
-- S2:插入 balance=60 的新行 → 被间隙锁挡住,阻塞!
INSERT INTO user(name, balance) VALUES ('new', 60);
-- 直到 S1 提交才执行六、事务隔离级别怎么选
| 场景 | 推荐 |
|---|---|
| 多数业务(订单/用户/库存) | RC(并发高,可重复读需求少) |
| 需要事务内多次读结果一致(对账/报表) | RR(MySQL 默认,省心) |
| 严格串行(金额极端敏感) | Serializable(很少用,性能差) |
Spring 里设置:@Transactional(isolation = Isolation.READ_COMMITTED)(见《@Transactional 详解》的传播行为/隔离级别参数)。
小结
- 三种并发问题:脏读(未提交)、不可重复读(行变)、幻读(行数变)
- 隔离级别:RU→RC→RR→Serializable,MySQL 默认 RR
- MVCC:undo log 版本链 + ReadView 快照——RR 复用快照所以可重复读
- 幻读靠间隙锁/Next-Key Lock(当前读时生效)
- 选型:业务默认 RC(并发高),对账/报表用 RR
下一篇可以看《MySQL 索引详解》(B+Tree、聚簇索引、EXPLAIN——查询为什么快的另一半)。
