MySQL 事务隔离级别¶
💡 一句话摘要
隔离级别就是数据库给并发事务「装多厚的隔板」:从薄到厚依次是 READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE,隔板越厚越安全但并发越差;MySQL(InnoDB) 默认 REPEATABLE READ,靠 MVCC 快照读 + 临键锁 挡住幻读,而 Oracle / PostgreSQL 默认 READ COMMITTED。本文把三大异常、四级矩阵、RR 幻读破功的边角场景、以及生产上 RC vs RR 怎么选,一次讲清。
🔑 核心概念¶
- 事务(Transaction) — 一组「要么全做、要么全不做」的 SQL。ACID 一句话:**原子性**靠 undo log(能回滚)、**持久性**靠 redo log + WAL(提交了就不丢)、**隔离性**靠锁 + MVCC(并发互不干扰)、**一致性**是前三者加上约束共同达成的最终结果。
- 三大并发异常 — 脏读(读到别人**未提交**的数据)、不可重复读(同一**行**两次读值不同,由 UPDATE 引起)、幻读(同一条件两次查**行数**不同,由 INSERT / DELETE 引起)。
- 四种隔离级别 — RU → RC → RR → SERIALIZABLE,隔离强度递增、并发度递减;MySQL InnoDB 默认 RR,Oracle / PostgreSQL / SQL Server 默认 RC,RU 基本没人用,SERIALIZABLE 太贵也很少用。
- MVCC(多版本并发控制) — 普通 SELECT 不加锁,靠行上的
DB_TRX_ID+DB_ROLL_PTR串成 undo 版本链,再用 Read View 挑一个「对我可见」的旧版本读出来,于是读写不互相阻塞。 - 快照读 vs 当前读 — 普通 SELECT 是快照读(走 MVCC,不加锁);
SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT是当前读(读最新已提交版本并加锁)。RR 的幻读防护 = 快照读一致性 + 当前读的临键锁,两条腿缺一不可。
📝 详解¶
1. 先把事务和 ACID 说人话¶
事务就是「一捆 SQL 绑成一个不可分割的动作」。最典型的例子是转账:扣 A 的钱和加 B 的钱必须一起成功,只成一半就是事故。
ACID 四性,每性一句话,外加「InnoDB 用什么实现的」——面试常连着问:
| 特性 | 大白话 | InnoDB 靠什么 |
|---|---|---|
| A 原子性 Atomicity | 要么全做,要么全不做,能反悔 | undo log(记录「怎么改回去」) |
| C 一致性 Consistency | 事务前后数据符合业务规则、不破坏约束 | 主键/唯一/外键/NOT NULL 等约束 + 上面三个一起兜底 |
| I 隔离性 Isolation | 并发事务之间互不打扰 | 锁 + MVCC(本文主角) |
| D 持久性 Durability | 提交了就永久保存,断电也不丢 | redo log + WAL(先刷日志再刷数据页) |
隔离性是四性里唯一一个「可以调档位」的——这个档位就叫**隔离级别**。其余三性没有开关,InnoDB 直接给你做满。
2. 并发三大异常:脏读、不可重复读、幻读¶
一句话抓本质:
- 脏读:我读到了你**还没提交**的改动,你一 ROLLBACK,我读到的东西就等于从来没存在过——数据是「脏」的。
- 不可重复读:我在同一个事务里读**同一行**两次,值变了——因为你在中间 UPDATE 并 COMMIT 了。
- 幻读:我在同一个事务里用**同一个条件**查两次,行数变了——因为你在中间 INSERT(或 DELETE)并 COMMIT 了,多出来的行像幻觉。
三者最容易混的是后两个,用这张表一刀切开:
| 脏读 | 不可重复读 | 幻读 | |
|---|---|---|---|
| 本质 | 读到未提交数据 | 同一行的**值**变了 | 结果集的**行数**变了 |
| 元凶语句 | 对方 UPDATE 且**没提交** | 对方 UPDATE + COMMIT | 对方 INSERT / DELETE + COMMIT |
| 变化对象 | 数据的「真实性」 | 已存在的某一行 | 一批行(区间) |
| 要防住它,得 | 只读已提交版本 | 锁住已存在的行 | 锁住「还不存在的空隙」(间隙锁) |
| 严重性 | 最严重 | 中 | 最轻(但最难防) |
记忆口诀
脏读看提交状态,不可重复读看「行内值」,幻读看「行数」。 防「行数变化」必须锁住空隙,这就是间隙锁(Gap Lock)存在的唯一理由——普通行锁只能锁住「已经有的行」,锁不住「还不存在的行」。
3. 四级隔离:异常矩阵¶
ISO/ANSI SQL 定义了四档,从「几乎没有隔板」到「全串行」:
| 隔离级别 | 中文 | 脏读 | 不可重复读 | 幻读 | 备注 |
|---|---|---|---|---|---|
READ UNCOMMITTED |
读未提交 | ⚠️ 允许 | ⚠️ 允许 | ⚠️ 允许 | 基本等于没隔离,生产**禁用** |
READ COMMITTED |
读已提交 | ✅ 禁止 | ⚠️ 允许 | ⚠️ 允许 | Oracle / PostgreSQL / SQL Server 默认 |
REPEATABLE READ |
可重复读 | ✅ 禁止 | ✅ 禁止 | ⚠️ 标准允许,InnoDB 基本已解决 | MySQL InnoDB 默认 |
SERIALIZABLE |
串行化 | ✅ 禁止 | ✅ 禁止 | ✅ 禁止 | 普通 SELECT 隐式加共享锁,并发最差 |
两个必须记住的点:
- 默认值不一样。MySQL 默认 RR,Oracle 和 PostgreSQL 默认 RC。所以「MySQL 会不会幻读」和「Oracle 会不会幻读」是两个完全不同的问题,面试时先问清楚是哪个数据库。
- RR 那一格是 InnoDB 的「超纲发挥」。按 SQL 标准,RR 是允许幻读的(只有 SERIALIZABLE 才禁止);但 InnoDB 用 MVCC + 临键锁在 RR 下把幻读基本堵住了。所以「RR 能不能防幻读」的标准答案是:标准说不能,InnoDB 说基本能,但混用快照读和当前读时会破功(见第 7 节)。
各级别的行为差异,根子上是 InnoDB 的实现方式不同:
| 级别 | 快照读怎么走 | 当前读怎么走 | 间隙锁 |
|---|---|---|---|
| RU | 不加锁,直接读最新数据页(能看到未提交) | 加锁 | 基本不用 |
| RC | 每次 SELECT 新建 Read View,所以能看到最新已提交 | 加锁,读最新已提交 | 基本关闭(仅外键/唯一键检查时用) |
| RR | 事务内**第一条 SELECT 建 Read View,之后全程复用** | 加锁,读最新已提交 | 开启(Next-Key Lock) |
| SERIALIZABLE | 普通 SELECT 被强制转成加共享锁的读 | 加锁 | 开启 |
RC 还有个细节优化叫**半一致读(semi-consistent read)**:UPDATE 扫描时遇到不满足 WHERE 条件的行,会提前释放锁,因此 RC 下 UPDATE 的锁范围明显更小、锁等待更少——这也是很多互联网公司偏爱 RC 的原因之一。
4. 时序图:脏读怎么发生(RU)¶
场景:stocks 表初始 id=1, price=100,会话 A 改成 50 但**没提交**,会话 B 在 RU 下读到了 50,随后 A 回滚。
sequenceDiagram
autonumber
participant A as 会话A
participant DB as InnoDB
participant B as 会话B
Note over A,B: 隔离级别 READ UNCOMMITTED,stocks 表 id=1 price=100
A->>DB: BEGIN
A->>DB: UPDATE stocks SET price=50 WHERE id=1
Note right of A: 只改了内存页 + 写 undo,尚未 COMMIT
B->>DB: SELECT price FROM stocks WHERE id=1
DB-->>B: 返回 50
Note over B: B 拿着 50 去算收益 / 展示给用户
A->>DB: ROLLBACK
DB-->>A: 用 undo log 把 price 改回 100
Note over A,B: 磁盘上从来没有 50 这个值,B 读的是脏数据
同样的场景换成 RR,B 读到的是 100:因为 B 的快照读用 Read View 判断,A 的事务 ID 还在「未提交集合 m_ids」里 → 这个版本不可见 → 沿 DB_ROLL_PTR 回溯到上一个已提交版本 100。脏读的本质是「可见性判断缺失」,MVCC 就是可见性判断。
5. 时序图:不可重复读怎么发生(RC)¶
场景:scores 表初始 id=1, score=80,会话 B 在同一事务里读两次,中间会话 A 改成了 90 并提交。
sequenceDiagram
autonumber
participant B as 会话B
participant DB as InnoDB
participant A as 会话A
Note over A,B: 隔离级别 READ COMMITTED,scores 表 id=1 score=80
B->>DB: BEGIN
B->>DB: SELECT score FROM scores WHERE id=1
DB-->>B: 80
Note right of B: 新建 ReadView ①,看到已提交的 80
A->>DB: BEGIN
A->>DB: UPDATE scores SET score=90 WHERE id=1
A->>DB: COMMIT
Note over A: A 的修改已提交,trx_id 不在任何人的未提交集合里了
B->>DB: SELECT score FROM scores WHERE id=1
DB-->>B: 90
Note right of B: 新建 ReadView ②,看到最新已提交的 90
B->>DB: COMMIT
Note over B: 同一事务、同一行、两次读值不同 = 不可重复读
关键就一句:RC 每次快照读都新建 Read View,RR 全程复用第一个 Read View。
-- 把上面的会话 B 换成 RR,结果就变成 80 / 80
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT score FROM scores WHERE id=1; -- 80,此刻建立 Read View
-- (会话 A 在这里 UPDATE 成 90 并 COMMIT)
SELECT score FROM scores WHERE id=1; -- 还是 80:复用同一 Read View,A 对我不可见
COMMIT;
6. MVCC 与锁:InnoDB 的两条腿¶
隔离性 = 锁 + MVCC,分工非常清楚:
| 机制 | 解决的冲突 | 特点 |
|---|---|---|
| MVCC | 读 vs 写 | 快照读不加锁,读旧版本,读写并行不阻塞 |
| 锁 | 写 vs 写、当前读的幻读 | Record Lock 锁行,Gap Lock 锁空隙,Next-Key Lock = 两者组合 |
MVCC 的三个零件:
- 每行两个隐藏列:
DB_TRX_ID(最后修改这行的事务 ID)、DB_ROLL_PTR(指向 undo log 里这行的旧版本)。 - 版本链:多次修改产生的旧版本靠
DB_ROLL_PTR串成一条链,最新的在链头。 - Read View:四个字段——
m_ids(建快照这一刻还没提交的事务 ID 集合)、min_trx_id(m_ids里最小的)、max_trx_id(下一个将要分配的事务 ID)、creator_trx_id(自己的事务 ID)。
可见性判断顺序(拿行的 trx_id 依次比):
① trx_id == creator_trx_id → 自己改的 → 可见
② trx_id < min_trx_id → 建快照前就已提交 → 可见
③ trx_id >= max_trx_id → 建快照后才启动的事务 → 不可见
④ trx_id ∈ m_ids → 还没提交 → 不可见
⑤ 其余 → 可见
↓ 当前版本不可见,就顺着 DB_ROLL_PTR 往更旧的版本继续判断
锁的三种形态:
| 锁 | 锁什么 | 作用 |
|---|---|---|
| Record Lock(行锁) | 某一条具体索引记录 | 防别人改/删这一行 |
| Gap Lock(间隙锁) | 两条记录之间的**空隙**(不含记录本身) | 防别人往空隙里 INSERT → 专为防幻读而生 |
| Next-Key Lock(临键锁) | 记录 + 它前面的间隙,左开右闭区间 | RR 下当前读的默认加锁单位 |
举例:orders 表已有 id=2 和 id=4(id 不连续),RR 下执行
InnoDB 不只锁住 2 和 4 这两行记录,还会用 Next-Key Lock 把它们各自**前面的间隙**一起锁上(大致是 (-∞, 2] 和 (2, 4] 这样的左开右闭区间)。此时另一个会话执行 INSERT INTO orders(id) VALUES(3),因为 3 落在被锁住的间隙 (2, 4) 里,会**卡住阻塞**,直到前一个事务提交或回滚——幻读被物理性地堵死了。
具体锁到哪几个间隙,跟索引类型、WHERE 是等值还是范围、记录是否存在都有关(比如唯一索引上的等值命中会退化成纯 Record Lock,不锁间隙)。想看真实结果别靠背,直接开一个事务执行完语句,另一个窗口查
performance_schema.data_locks(见第 14 节)。注意:间隙锁只在 RR 下开启。切到 RC,间隙锁基本关闭(只剩外键、唯一键冲突检查时用),并发插入不再被阻塞,代价就是幻读回来了,同时死锁概率明显下降。
7. RR 真的没有幻读了吗?——边角场景¶
InnoDB 在 RR 下靠两条路防幻读:
- 全程快照读:复用同一个 Read View,别人新插入的行对我永远不可见 → 不会有幻行。
- 全程当前读:
FOR UPDATE/UPDATE/DELETE加 Next-Key Lock,把区间空隙锁住,别人根本插不进来 → 不会有幻行。
问题是「混用」:普通 SELECT 不加任何锁,它挡不住别人插入;等你后面执行 UPDATE 时走的是当前读,读的是最新已提交数据,就能碰到别人插进来的那行。经典案例:
时间 →
会话 A (RR) 会话 B (RR)
──────────────────────────────────────────────────────────────────
BEGIN;
SELECT * FROM t WHERE id = 5; → 空集
(A 在此建立 Read View,且不加锁)
BEGIN;
INSERT INTO t(id,name) VALUES(5,'bob');
COMMIT; ← 新行已提交,A 的锁压根不存在
UPDATE t SET name='alice' WHERE id=5;
→ Rows matched: 1 Changed: 1 ❗当前读看到了 B 插入的行
SELECT * FROM t WHERE id = 5; → (5,'alice') ❗幻读出现
COMMIT;
纯 SQL 版本,可以直接拿去两个客户端窗口对着跑:
-- 建表 + 初始数据(id=5 故意不存在)
CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(20)) ENGINE=InnoDB;
-- ===== 会话 A =====
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM t WHERE id = 5; -- Empty set,建立 Read View
-- ===== 会话 B(趁 A 还没提交)=====
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
INSERT INTO t(id, name) VALUES (5, 'bob');
COMMIT; -- 成功,没人锁这个间隙
-- ===== 回到会话 A =====
UPDATE t SET name = 'alice' WHERE id = 5; -- Rows matched: 1 ❗A 竟然改到了 B 的行
SELECT * FROM t WHERE id = 5; -- (5, 'alice') ❗幻读:多出来一行
COMMIT;
为什么最后一次快照读也能看见?因为 UPDATE 是 A 自己执行的,改完这行的 DB_TRX_ID 就变成了 A 自己的 ID,命中可见性规则第 ① 条「自己改的永远可见」,于是快照读也能读到它。
这个「幻读」算 bug 吗
不算,这是 InnoDB 的设计取舍:UPDATE/DELETE 必须读最新已提交数据,否则会出现「基于旧快照覆盖别人的写入」这种更严重的丢失更新问题。真正的解法不是抱怨隔离级别,而是:要读就直接用当前读(SELECT ... FOR UPDATE),把间隙一起锁住;或者用唯一索引 + 捕获重复键错误 + 重试来兜底。
同理,「先快照读统计、再 UPDATE」这类写法(比如先 SELECT COUNT(*) FROM coupon WHERE status=0 判断库存,再 UPDATE coupon SET status=1 WHERE status=0 LIMIT 1)在 RR 下同样可能改到统计时还不存在的行——这也是抢券/秒杀超卖的经典成因之一。
8. SERIALIZABLE:最强也最少人用¶
SERIALIZABLE 的做法非常粗暴:把所有普通 SELECT 隐式改成 SELECT ... LOCK IN SHARE MODE,也就是快照读全部退化成当前读,再配合间隙锁把区间锁死。结果就是读和读之间也要排队。
| RR | SERIALIZABLE | |
|---|---|---|
| 普通 SELECT | 快照读,走 MVCC,不加锁 | 隐式加共享锁,变成当前读 |
| 两个事务同时读同一行 | 互不阻塞(各读各的快照) | 都能拿到共享锁,不阻塞 |
| 一个读 + 一个写 | 不阻塞(读写并行是 MVCC 的红利) | 互相阻塞 |
| 三大异常 | 脏读/不可重复读禁止,幻读基本防住 | 全部禁止 |
| 并发度 | 高 | 最低,锁等待与死锁明显增多 |
为什么几乎没人用:它牺牲掉的正是 InnoDB 最值钱的那个特性——读写不互斥。真要那么强的保证,用 RR + 精确的 SELECT ... FOR UPDATE 只锁你关心的那几行,代价小得多、可控得多。SERIALIZABLE 更适合「一个事务里跑一堆复杂只读查询、要求这几条查询看到的是同一个时刻的世界」这种批处理/报表场景,而且还得接受它拖慢并发写。
面试常问:SERIALIZABLE 是「真的串行执行」吗
不是。名字叫串行化,实现上是**两阶段锁(读加共享锁、写加排他锁,锁到事务结束才释放)**,事务之间仍然是交错执行的,只是通过锁让最终结果**等价于**某种串行顺序。它不是 PostgreSQL 那种 SSI(可串行化快照隔离,靠检测读写依赖来回滚冲突事务)。
9. 隔离级别管不到的事:丢失更新¶
一个高频误区:以为把隔离级别调到 RR 就不会有并发问题。不会。 隔离级别只定义了三大异常的行为,而「丢失更新」在 RC 和 RR 下都可能发生:
-- 账户余额 100,两个会话同时执行「读出来 +10 再写回」
-- 会话 A -- 会话 B
BEGIN; BEGIN;
SELECT balance FROM acc WHERE id=1; -- 读到 100
SELECT balance FROM acc WHERE id=1; -- 也读到 100
UPDATE acc SET balance = 110 WHERE id=1;
COMMIT;
UPDATE acc SET balance = 110 WHERE id=1;
COMMIT; -- A 的 +10 被覆盖了,最终 110 而不是 120
即使在 RR 下,B 的 UPDATE 会被 A 的行锁挡住一会儿,但 A 提交后 B 照样把自己算出来的 110 写进去——B 的业务逻辑是基于旧值算的,隔离级别救不了它。
四种正确写法,按推荐度排序:
| 方案 | 写法 | 适用 |
|---|---|---|
| 原子更新(首选) | UPDATE acc SET balance = balance + 10 WHERE id=1 AND balance + 10 >= 0 |
能用 SQL 表达式搞定就别读出来算,天然无竞态 |
| 悲观锁 | SELECT balance FROM acc WHERE id=1 FOR UPDATE 再改 |
需要读出来做复杂判断时;RR 下还能锁间隙防插入 |
| 乐观锁 | UPDATE acc SET balance=?, version=version+1 WHERE id=1 AND version=?,检查 affected rows |
冲突少、想避免锁等待;冲突了要重试 |
| 唯一索引 | 靠 INSERT 撞 Error 1062 判重 |
幂等去重、防重复下单 |
// 乐观锁的正确姿势:靠 affected rows 判断有没有被别人改过,而不是靠错误
res, err := tx.ExecContext(ctx,
"UPDATE acc SET balance = ?, version = version + 1 WHERE id = ? AND version = ?",
newBalance, id, oldVersion)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrConcurrentUpdate // 版本没对上 = 被别人改过,业务层重试或返回失败
}
10. 怎么查看和修改隔离级别¶
查看(8.0 和 5.7 变量名不同,这是最容易踩的坑):
-- MySQL 8.0+
SELECT @@global.transaction_isolation; -- 实例默认
SELECT @@session.transaction_isolation; -- 当前连接
SELECT @@transaction_isolation; -- 等价于 session 那个
SHOW VARIABLES LIKE 'transaction_isolation';
-- MySQL 5.7 及更早(8.0 里 tx_isolation 已被移除,执行会报 Unknown system variable)
SELECT @@tx_isolation;
SELECT @@global.tx_isolation;
修改(三个作用域,语义完全不同):
-- ① 只影响「之后新建」的连接,当前连接不变;需要 SYSTEM_VARIABLES_ADMIN/SUPER 权限
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- ② 影响当前连接后续所有事务(连接归还连接池后设置会跟着走,见常见陷阱)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- ③ 只影响「下一个」事务,事务结束后自动恢复
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 顺手还能设只读事务
SET SESSION TRANSACTION READ ONLY;
**持久化**要写进配置文件,SET GLOBAL 重启就没了:
[mysqld]
transaction-isolation = READ-COMMITTED # 8.0 写法;5.7 用 tx-isolation = READ-COMMITTED
binlog_format = ROW # 用 RC 必须配 ROW,见第 12 节
Go 侧对应的常量(database/sql):
sql.IsolationLevel |
对应 SQL |
|---|---|
sql.LevelDefault |
不发送设置语句,用数据库默认(MySQL 即 RR) |
sql.LevelReadUncommitted |
READ UNCOMMITTED |
sql.LevelReadCommitted |
READ COMMITTED |
sql.LevelRepeatableRead |
REPEATABLE READ |
sql.LevelSerializable |
SERIALIZABLE |
Go 侧的写法核心是 sql.TxOptions{Isolation: ...},它是**事务级**的,最安全;只有确实需要「整条连接都换个级别」时才用 db.Conn() 拿独占连接去 SET SESSION:
// 推荐:事务级指定,只影响这一个事务
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
// 次选:会话级指定,必须自己管好这条独占连接的归还与复原
func runWithReadCommitted(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
conn, err := db.Conn(ctx) // 从池里取一条独占连接,用完必须归还
if err != nil {
return err
}
defer conn.Close()
// 注意:连接归还后会带着 RC 设置回到池里,污染后续使用者。
// 严格做法是任务结束前再 SET SESSION 恢复成默认级别。
if _, err = conn.ExecContext(ctx,
"SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"); err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil) // 这条连接上的事务就是 RC
if err != nil {
return err
}
defer tx.Rollback() // 提交成功后 Rollback 返回 sql.ErrTxDone,可安全忽略
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}
11. 为什么 MySQL 默认 RR,别人默认 RC¶
这不是口味问题,是**历史包袱**。MySQL 早年默认的 binlog 格式是 STATEMENT,而 STATEMENT 格式的 binlog 在 RC 下无法保证从库重放得到相同结果(RC 关掉了间隙锁,主从上并发语句的加锁与执行顺序可能不同)。为了让「默认配置就能安全地做主从复制」,MySQL 把默认隔离级别定成了 RR。
后来 binlog 默认格式在 5.7.7 之后变成了 ROW,这个约束消失了,RC 才重新变成可行的选项——所以今天很多互联网公司敢把线上改成 RC,前提是 binlog 用 ROW。Oracle 和 PostgreSQL 从一开始就不依赖语句级日志做复制,所以默认 RC 没这个包袱。
理解这条脉络,你就能答出「MySQL 为什么默认 RR」这种看起来无厘头的问题:因为默认的 STATEMENT binlog 配 RC 会破坏主从一致性。
12. 生产实践:RC 还是 RR?¶
这是真实线上会做的决策,也是面试高频追问。
| 维度 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| 谁在用 | Oracle / PostgreSQL / SQL Server 默认,很多互联网线上 MySQL 也改成 RC | MySQL InnoDB 默认 |
| 间隙锁 | 基本关闭 → 锁范围小、锁等待少、死锁概率低 | 开启 → 范围锁可能锁很大,死锁排查更费劲 |
| UPDATE 行为 | 有半一致读,不匹配的行提前放锁 | 扫到的间隙都要锁 |
| 长事务影响 | 每次读新建 Read View,undo 版本链能被及时 purge | 一个长事务的老 Read View 会**拖住 purge**,undo 膨胀、SHOW ENGINE INNODB STATUS 里 history list length 飙升 |
| 幻读防护 | 无(要靠 FOR UPDATE 或业务乐观锁) |
有(快照读 + 临键锁),但混用当前读会破功 |
| 语义直觉 | 「读到的就是已提交的最新值」,符合大多数业务代码的直觉 | 「事务内读到的是一致快照」,写对账/批处理时更省心 |
| binlog 要求 | 必须 binlog_format = ROW(或 MIXED),否则写语句直接报错 |
STATEMENT / ROW 都能跑 |
RC 与 STATEMENT binlog 是不兼容的
RC 下语句级 binlog 无法保证主从一致(同一条 SQL 在主从上因加锁顺序/半一致读不同,可能产生不同结果),所以 MySQL 会**直接拒绝执行**:
ERROR 1665 (HY000): Cannot execute statement: impossible to write to binary log
since BINLOG_FORMAT = STATEMENT and at least one table uses a storage engine
limited to row-based logging.
MySQL 5.7.7+ 与 8.0 的 binlog_format 默认已是 ROW,但**很多老库、自建镜像、云厂商旧实例仍是 STATEMENT**。改隔离级别前先 SELECT @@binlog_format; 确认一遍。
怎么选,判断标准按这个顺序过一遍:
| 你的情况 | 建议 |
|---|---|
| 高并发互联网业务(订单、账户、内容),死锁敏感,写多 | RC,配 binlog_format=ROW,需要防幻读的地方显式 SELECT ... FOR UPDATE 或用版本号乐观锁 |
| 团队沿用 MySQL 默认配置,不想改全局参数,业务里有对账/批量统计这类需要「一致快照」的逻辑 | RR(保持默认),代价是注意间隙锁和长事务 |
| 用了 Canal / Flink CDC 等订阅 binlog 的组件 | 必须 ROW 格式;此时 RC 反而更顺手 |
| 强一致要求极端(金融核心账务),能接受吞吐下降 | 别上 SERIALIZABLE(读也加锁,代价太大),用 RR + 显式 FOR UPDATE 精确控制锁范围 |
| READ UNCOMMITTED | 任何生产环境都别用 |
一句总结:RC 是「用业务代码的显式加锁换并发度」,RR 是「用数据库的自动加锁换省心」。 两者都能做对,区别在于防幻读的责任落在谁身上——选哪个不重要,重要的是团队里统一一个、并且代码写法与之匹配。
13. 一起影响并发的几个参数¶
隔离级别不是孤立存在的,下面这几个参数经常和它一起决定线上表现:
| 参数 | 默认值 | 说明 |
|---|---|---|
autocommit |
ON |
每条语句自成一个事务。BEGIN 会临时关掉它直到 COMMIT/ROLLBACK |
transaction_isolation |
REPEATABLE-READ |
本文主角 |
binlog_format |
ROW(5.7.7+/8.0) |
RC 必须 ROW,否则写语句报错 |
innodb_lock_wait_timeout |
50 秒 |
等锁超过这个时间抛 ERROR 1205 Lock wait timeout exceeded,只回滚当前语句 |
innodb_deadlock_detect |
ON |
主动检测死锁并回滚代价小的那个事务,报 ERROR 1213 Deadlock found |
还有一个极易被误解的点:BEGIN 并不会立刻建立 Read View。
-- RR 下这两者的快照时机不同
BEGIN;
SELECT ...; -- Read View 是在【这里】才建立的,不是 BEGIN 那一刻
START TRANSACTION WITH CONSISTENT SNAPSHOT;
-- 上面这句会立刻建立 Read View 并拿到一致性快照,哪怕你一条 SELECT 都还没执行。
-- 做「多张表必须读同一时刻数据」的对账/日切时用它。
-- 注意:它只在 RR 下有意义;RC 下每条语句仍会新建 Read View,拿不到跨语句的一致快照。
顺带一提:SELECT ... LOCK IN SHARE MODE 在 MySQL 8.0 里已标记为过时写法,官方推荐等价但更好读的 SELECT ... FOR SHARE,两者行为一致(都加共享锁)。
14. 线上排查:看事务、看锁、看 undo¶
真出事的时候,下面这几条 SQL 比背概念有用得多(②③⑤ 需要 MySQL 8.0):
-- ① 现在有哪些事务在跑?重点看 trx_started(跑得久 = 长事务)、
-- trx_rows_locked(锁了多少行,RR 下范围锁会让这个数字很大)
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_sec,
trx_mysql_thread_id, trx_rows_locked, trx_rows_modified,
LEFT(trx_query, 120) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- ② 谁在等谁的锁?(MySQL 8.0;5.7 用 information_schema.innodb_lock_waits)
SELECT * FROM performance_schema.data_lock_waits\G
-- ③ 当前到底加了哪些锁、是 Record 还是 Gap/Next-Key?
SELECT engine_transaction_id, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
ORDER BY engine_transaction_id;
-- ④ 顺手确认一下隔离级别和 binlog 格式(改配置前后都要看一眼)
SELECT @@global.transaction_isolation, @@session.transaction_isolation, @@binlog_format;
-- ⑤ undo 有没有被长事务拖住(history list length 持续上涨就是信号)
SHOW ENGINE INNODB STATUS\G
lock_mode 字段读法:X 是排他锁、S 是共享锁;带 ,REC_NOT_GAP 表示**只锁记录不锁间隙**(Record Lock);带 ,GAP 表示**只锁间隙**(Gap Lock);不带后缀的 X/S 就是 Next-Key Lock(记录 + 间隙)。看到一堆 GAP 锁基本就说明你跑在 RR 下且有范围查询。
💻 代码示例¶
Go:database/sql 指定事务隔离级别¶
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"sort"
_ "github.com/go-sql-driver/mysql"
)
// ErrInsufficientBalance 余额不足
var ErrInsufficientBalance = errors.New("余额不足")
// transfer 转账:用 RR + SELECT ... FOR UPDATE 锁住两行,避免并发扣款
func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int) error {
// BeginTx 会驱动 go-sql-driver 发出事务级的
// "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ",
// 只作用于这一个事务,不会长期改变连接上的 SESSION 设置。
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead, // 可选:LevelReadCommitted / LevelSerializable ...
ReadOnly: false, // true 时驱动会发 SET TRANSACTION READ ONLY
})
if err != nil {
return fmt.Errorf("开启事务失败: %w", err)
}
defer tx.Rollback() // 提交成功后再 Rollback 只会返回 sql.ErrTxDone,可安全忽略
// 关键:按固定顺序(id 升序)加锁,避免两个并发转账互相持锁等待造成死锁
ids := []int64{from, to}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
balance := map[int64]int{}
for _, id := range ids {
var b int
// 当前读:FOR UPDATE 会对这一行加排他锁,别的事务改不了也删不了
if err := tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = ? FOR UPDATE", id).Scan(&b); err != nil {
return fmt.Errorf("锁定账户 %d 失败: %w", id, err)
}
balance[id] = b
}
if balance[from] < amount {
return ErrInsufficientBalance
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from); err != nil {
return fmt.Errorf("扣款失败: %w", err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); err != nil {
return fmt.Errorf("入账失败: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交失败: %w", err)
}
return nil
}
func main() {
dsn := "app:pass@tcp(127.0.0.1:3306)/demo?parseTime=true&charset=utf8mb4"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := transfer(context.Background(), db, 1001, 1002, 50); err != nil {
log.Fatal(err)
}
}
GORM 版本¶
// 需要 import "database/sql" / "gorm.io/gorm/clause" / "time"
// GORM 的 Begin 直接接受 *sql.TxOptions
func payOrder(ctx context.Context, db *gorm.DB, orderID uint64) error {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
if tx.Error != nil {
return tx.Error
}
// 出错就回滚(包括 panic)
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
// 当前读:Clauses(clause.Locking{Strength: "UPDATE"}) 会拼出 FOR UPDATE
var order Order
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&order, orderID).Error; err != nil {
tx.Rollback()
return err
}
if order.Status != StatusUnpaid {
tx.Rollback()
return ErrOrderAlreadyPaid // 幂等保护:状态不对直接退出
}
if err := tx.WithContext(ctx).Model(&order).
Updates(map[string]any{"status": StatusPaid, "paid_at": time.Now()}).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
⚠️ 常见陷阱¶
陷阱一:以为 RR 完全杜绝幻读
现象:RR 下先普通 SELECT 判断某行不存在,再 UPDATE / INSERT,结果 UPDATE 竟然影响到了 1 行,或者 SELECT 之后又冒出一行新数据。
原因:普通 SELECT 是快照读,不加任何锁,挡不住别的事务往这个区间插入;等你执行 UPDATE 时走的是当前读,读的是最新已提交数据,自然能看到那行新数据。而且这行被你改过后带上了你自己的 trx_id,之后的快照读也可见了——幻读就这么破功了。
解法:要「判断存在性再操作」的场景,第一次读就用 SELECT ... FOR UPDATE(RR 下会连带锁住间隙,别人插不进来);或者依赖唯一索引 + 捕获 Error 1062 重复键 + 重试;或者干脆用版本号乐观锁 UPDATE ... WHERE id=? AND version=?,靠 affected rows 判断成败。
陷阱二:改隔离级别只改了 SESSION,或者忘了 binlog_format=ROW
现象 A:SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; 跑通了,上线后大部分事务还是 RR。
原因 A:SESSION 级设置**只对当前这一条连接生效**。Go 的连接池有几十条连接,你改的只是恰好被复用的那一条;而且连接归还池后设置还会「污染」后续使用者,导致同一服务里不同请求跑在不同级别下,问题极难复现。
解法 A:单事务指定用 db.BeginTx(ctx, &sql.TxOptions{Isolation: ...});要全实例生效就改配置文件 [mysqld] transaction-isolation = READ-COMMITTED 并重启(SET GLOBAL 只影响之后新建的连接,且重启丢失)。
现象 B:改成 RC 之后,写操作直接报 ERROR 1665 ... BINLOG_FORMAT = STATEMENT,或者更糟——主从数据悄悄不一致。
原因 B:RC 与 STATEMENT 格式 binlog 不兼容,从库重放同一条语句可能得到不同结果。
解法 B:改隔离级别之前先 SELECT @@binlog_format;,确保是 ROW;主库改完还要检查所有从库和中转实例的配置一致。
陷阱三:把「不可重复读」和「幻读」搞混
现象:面试或代码评审时说「RC 下会幻读,所以同一行两次读值不同」——概念错位。 原因:两者的区分点不在「读几次」,而在**变的是什么**:
- 不可重复读 = 同一**行**的**值**变了,元凶是别人
UPDATE后提交; - 幻读 = 同一条件查出的**行数**变了,元凶是别人
INSERT/DELETE后提交。
解法:记住「UPDATE 改值 → 不可重复读;INSERT/DELETE 改行数 → 幻读」。防前者锁住**已存在的行**(Record Lock 就够),防后者必须锁住**还不存在的空隙**(Gap Lock / Next-Key Lock),这也是为什么 RC 关掉间隙锁之后幻读就回来了。
陷阱四:RR 下的长事务把 undo 撑爆
现象:SHOW ENGINE INNODB STATUS 里 History list length 持续增长,磁盘上涨、查询变慢。
原因:RR 下一个长事务持有早期的 Read View,purge 线程就不能清理比它更老的 undo 版本,版本链越拖越长,快照读要回溯的版本越来越多。
解法:事务里**不要夹 HTTP 调用、不要夹人工审批、不要跑大批量循环**;BEGIN 之后尽快做完尽快 COMMIT;监控 information_schema.innodb_trx 里 trx_started 很久的事务并告警。
🏋️ 练习题¶
练习 1:MySQL 默认 RR 下,会话 A 执行 SELECT * FROM t WHERE id=5 返回空集,此时会话 B 插入 id=5 并提交,然后 A 执行 UPDATE t SET name='x' WHERE id=5 会影响 1 行。这算幻读吗?为什么 RR 没挡住?怎么修?
提示:想清楚普通 SELECT 和 UPDATE 分别走的是哪条读路径、有没有加锁。
答案
算幻读,而且是 RR 下最经典的破功场景。
原因:RR 的幻读防护有两条腿——快照读靠 MVCC 复用 Read View,当前读靠 Next-Key Lock 锁间隙。但**普通 SELECT 是快照读,不加任何锁**,所以它既看不到新行,也拦不住别人插入新行。等 A 执行 UPDATE 时,这是**当前读**,必须读最新已提交数据,于是命中了 B 插入的那一行;而这一行被 A 改过之后,它的 DB_TRX_ID 变成 A 自己的事务 ID,按可见性规则「自己改的永远可见」,A 后面再用普通 SELECT 也能查到它——幻读就此显形。
修法(三选一):
1. 第一次读就用当前读:SELECT * FROM t WHERE id=5 FOR UPDATE,RR 下会连带锁住间隙,B 的 INSERT 会被阻塞到 A 提交;
2. 靠唯一索引兜底:直接 INSERT,捕获 Error 1062 重复键后走更新或重试分支;
3. 乐观锁:UPDATE t SET ... WHERE id=5 AND version=?,用 affected rows 判断是否被别人动过。
练习 2:不可重复读和幻读到底差在哪?在 READ COMMITTED 下,SELECT COUNT(*) FROM orders WHERE uid=1 在同一事务里执行两次结果不同,这属于哪一种?
提示:先看变的是「值」还是「行数」,再看是谁的 SQL 造成的。
答案
核心区别是**变化的对象不同**:
| 不可重复读 | 幻读 | |
|---|---|---|
| 变化 | 同一行的**值** | 结果集的**行数** |
| 元凶 | 别人 UPDATE + COMMIT |
别人 INSERT / DELETE + COMMIT |
| 防护手段 | Record Lock 锁住已存在的行 | Gap Lock / Next-Key Lock 锁住空隙 |
COUNT(*) 两次结果不同,说明**行数**变了,因此属于**幻读**(如果两次查同一行 SELECT price FROM orders WHERE id=1 值变了,那才是不可重复读)。
在 RC 下这很正常:RC 只禁止脏读,每次普通 SELECT 都新建 Read View,能看到别人最新提交的插入,同时 RC 下间隙锁基本关闭,FOR UPDATE 也锁不住空隙,所以幻读完全可能发生。要在 RC 下避免它,得自己在业务层用唯一约束、乐观锁,或者把这个事务单独提到 RR(db.BeginTx 里指定 sql.LevelRepeatableRead)并用 SELECT ... FOR UPDATE 锁区间。
练习 3:你要把线上 MySQL 的默认隔离级别从 RR 改成 RC,上线前的检查清单有哪些?改完能拿到什么好处、要自己补上什么?
提示:至少涉及 binlog、连接池、代码里的加锁写法、从库一致性四件事。
答案
上线前检查:
SELECT @@binlog_format;必须是ROW(或 MIXED)。RC 与 STATEMENT 不兼容,否则写语句直接报ERROR 1665,或者主从数据悄悄不一致。- 主库、所有从库、以及做备份/订阅 binlog 的实例(Canal、Flink CDC、DTS)配置要一致,避免中途某台还是 RR。
- 持久化方式:写进
[mysqld] transaction-isolation = READ-COMMITTED后重启,而不是只SET GLOBAL(重启即丢,且只影响新建连接);5.7 上变量名是tx-isolation。 - 代码扫描:搜出所有依赖「事务内多次读结果一致」的逻辑(对账、批量统计、先查再改),这些地方在 RC 下会读到别人新提交的值。
好处:间隙锁基本关闭 → 锁范围小、锁等待少、死锁概率显著下降;UPDATE 有半一致读,不匹配的行提前放锁;Read View 生命周期短,purge 更及时,长事务不容易把 undo 撑爆。整体并发吞吐更高。
要自己补上的:RC 不防幻读也不防不可重复读,凡是「先查后改」的关键路径必须显式加锁或加版本控制——SELECT ... FOR UPDATE(RC 下只锁命中行,不锁间隙)、唯一索引 + 重复键重试、或 WHERE ... AND version=? 的乐观锁,并用 affected rows 判断是否生效。
🔗 相关链接¶
- MySQL 8.0 · Transaction Isolation Levels — 官方对四级隔离的定义与 InnoDB 实现说明
- MySQL 8.0 · Consistent Nonlocking Reads — 快照读与 Read View 的官方描述
- MySQL 8.0 · Locks Set by Different SQL Statements — 各类语句加的 Record / Gap / Next-Key Lock
- MySQL 8.0 · Binary Log Format —
binlog_format与 RC 的兼容性约束 - PostgreSQL · Transaction Isolation — 对比 PG 默认 RC 及其 SSI 实现
- Go pkg · database/sql TxOptions —
Isolation与ReadOnly字段 - go-sql-driver/mysql —
BeginTx如何把隔离级别翻译成 SQL - GORM · Transactions —
db.Begin(&sql.TxOptions{...})用法 - 多级缓存与读写策略 — 缓存与 DB 之间的一致性问题,常和事务隔离一起被问