创建日期:2026-09-08 | 最近更新:2026-09-08 本机 MySQL 8.4.0 实测(两个并发会话演示隔离级别差异),输出真实。
MySQL 深潜 4:事务与隔离级别——并发下数据怎么「不错乱」
转账、下单这类「好几步操作要一起成功/一起失败」的场景,靠事务保证。事务是 MySQL 8 里 InnoDB 的看家本领,也是「初级 → 进阶」的分水岭。这篇用两个并发会话实测把 ACID 和隔离级别讲透。
1. ACID:事务的四个承诺
| 字母 | 含义 | 大白话 |
|---|---|---|
| A | Atomicity 原子性 | 要么全做,要么全不做 |
| C | Consistency 一致性 | 做完后数据仍符合规则 |
| I | Isolation 隔离性 | 并发事务互不干扰(有级别之分) |
| D | Durability 持久性 | 提交后不丢(靠日志+崩溃恢复) |
2. 怎么用:BEGIN / COMMIT / ROLLBACK
默认 MySQL 每条语句自动提交(autocommit=1)。要手动控制就开事务:
START TRANSACTION; -- 或 BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 两步一起生效
-- 如果第二步出错:
-- ROLLBACK; -- 全部撤销,回到事务开始前
SAVEPOINT a; 可以在事务里设回滚点,ROLLBACK TO a; 只回滚到某点而不放弃整个事务——大事务里局部纠错用。
查当前隔离级别:
SELECT @@transaction_isolation;(默认REPEATABLE-READ,见 §4)。
3. 先看结果:同样的流程,两个隔离级别的表现不同(实测)
准备一个订单 id=1,金额初始 21.37。会话 A 开启事务后先查一次,睡 5 秒再查一次;期间会话 B 把金额 +100 并提交。看 A 两次读到什么:
REPEATABLE-READ(MySQL 默认)
会话 A 第一次读 = 21.37;B 提交(变成 121.37)后,A 第二次读仍是 21.37:
rr_before 21.37 ← A 事务开始时看到的值
rr_after 21.37 ← B 已提交 100,但 A 还是看到旧的
结论:同一事务内多次读,结果一致(可重复读)——A 的事务一开始就拍了一张「快照」,之后都读快照。
READ COMMITTED
同样的流程:A 第一次读 = 121.37(此时 B 上一轮已 +100);A 睡 5 秒期间 B 又 +100 并提交;A 第二次读 = 221.37:
rc_before 121.37 ← 事务开始时的值
rc_after 221.37 ← 期间别人提交了,A 看到了新值
结论:每次读都看「最新已提交」——所以同一事务内两次读可能不同(不可重复读)。
这就是隔离级别的意义:默认 REPEATABLE-READ 帮你「读得稳定」,代价是背后有快照机制(MVCC)和多一点开销。多数业务用默认即可。
4. 四个隔离级别(一张表背下来)
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 能读到别人未提交的数据(几乎不用) |
| READ COMMITTED | 不会 | 可能 | 可能 | 读已提交;Oracle 默认 |
| REPEATABLE READ | 不会 | 不会 | InnoDB 下基本不会 | MySQL 默认(快照读) |
| SERIALIZABLE | 不会 | 不会 | 不会 | 串行化,最安全最慢 |
名词解释(面试三兄弟):
- 脏读:读到别人还没提交、可能回滚的数据;
- 不可重复读:同一事务里两次读同一行,值变了(因为别人提交了);
- 幻读:同一事务里两次范围查询,行数变了(多出来/少了行)。InnoDB 在 REPEATABLE READ 用间隙锁 + MVCC 基本杜绝了幻读。
设置级别(会话级常用):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 全局(谨慎)
5. 背后机制:MVCC + 锁(够用的直觉)
- MVCC(多版本并发控制):每个事务读写时基于一个「快照版本」,读不阻塞写、写不阻塞读——这就是 RR 下 A 能「稳定读到旧值」而 B 能正常提交的原因;
- 行锁:写同一行才互相等待(
SELECT ... FOR UPDATE会显式加行锁); - 间隙锁:范围条件锁住「区间」,防别的插入造成幻读;
- 死锁:两个事务互相等对方持有的锁。MySQL 会自动检测并回滚一方(报
Deadlock found),你的代码要能重试被回滚的事务。
直觉版:读走快照(MVCC),写走锁。SELECT 普通读不堵人;要「读了之后锁住防止别人改」才用
SELECT ... FOR UPDATE。
6. 实战心法(给你写业务用)
- 事务要短:别在事务里做网络请求、慢查询——锁持有越久越容易死锁/阻塞;
- 改多行按固定顺序(比如都按 id 升序),可显著降低死锁概率;
- 扣库存/转账这类读改写,用
SELECT ... FOR UPDATE或原子 SQL(UPDATE ... SET x = x - n WHERE x >= n)避免超卖; - 一次事务里读一致性有要求 → 用默认 RR 就好;追求更低锁开销、能接受读到新值 → READ COMMITTED(很多互联网团队这么配);
- 连接池/框架事务注解别滥用:只有「真需要一起成功」的多步写才开事务。
动手(用 shop,开两个终端)
- 两个终端都连上,各
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; - A:
BEGIN; SELECT amount FROM orders WHERE id=1; - B:
UPDATE orders SET amount=amount+1 WHERE id=1; COMMIT; - A 再查一次——值没变(RR 快照);
- 把两边隔离级别改成 READ COMMITTED 重试——A 第二次能看到新值。
自测
- 事务的 BEGIN / COMMIT / ROLLBACK 各干什么?
- MySQL 默认隔离级别是?它怎么保证「同一事务内两次读一致」?
- 脏读 / 不可重复读 / 幻读分别是什么?
- 为什么事务里别做慢请求?
- 出现死锁报错后,业务代码该做什么?
下一篇:表设计与范式——别让表设计成为项目的债。