跳转至

MySQL 事务隔离级别

💡 一句话摘要

隔离级别就是数据库给并发事务「装多厚的隔板」:从薄到厚依次是 READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE,隔板越厚越安全但并发越差;MySQL(InnoDB) 默认 REPEATABLE READ,靠 MVCC 快照读 + 临键锁 挡住幻读,而 Oracle / PostgreSQL 默认 READ COMMITTED。本文把三大异常、四级矩阵、RR 幻读破功的边角场景、以及生产上 RC vs RR 怎么选,一次讲清。


🔑 核心概念

  1. 事务(Transaction) — 一组「要么全做、要么全不做」的 SQL。ACID 一句话:**原子性**靠 undo log(能回滚)、**持久性**靠 redo log + WAL(提交了就不丢)、**隔离性**靠锁 + MVCC(并发互不干扰)、**一致性**是前三者加上约束共同达成的最终结果。
  2. 三大并发异常 — 脏读(读到别人**未提交**的数据)、不可重复读(同一**行**两次读值不同,由 UPDATE 引起)、幻读(同一条件两次查**行数**不同,由 INSERT / DELETE 引起)。
  3. 四种隔离级别 — RU → RC → RR → SERIALIZABLE,隔离强度递增、并发度递减;MySQL InnoDB 默认 RR,Oracle / PostgreSQL / SQL Server 默认 RC,RU 基本没人用,SERIALIZABLE 太贵也很少用。
  4. MVCC(多版本并发控制) — 普通 SELECT 不加锁,靠行上的 DB_TRX_ID + DB_ROLL_PTR 串成 undo 版本链,再用 Read View 挑一个「对我可见」的旧版本读出来,于是读写不互相阻塞。
  5. 快照读 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 隐式加共享锁,并发最差

两个必须记住的点:

  1. 默认值不一样。MySQL 默认 RR,Oracle 和 PostgreSQL 默认 RC。所以「MySQL 会不会幻读」和「Oracle 会不会幻读」是两个完全不同的问题,面试时先问清楚是哪个数据库。
  2. 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 的三个零件:

  1. 每行两个隐藏列:DB_TRX_ID(最后修改这行的事务 ID)、DB_ROLL_PTR(指向 undo log 里这行的旧版本)。
  2. 版本链:多次修改产生的旧版本靠 DB_ROLL_PTR 串成一条链,最新的在链头。
  3. 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 下执行

SELECT * FROM orders WHERE id BETWEEN 1 AND 4 FOR UPDATE;

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、连接池、代码里的加锁写法、从库一致性四件事。

答案

上线前检查:

  1. SELECT @@binlog_format; 必须是 ROW(或 MIXED)。RC 与 STATEMENT 不兼容,否则写语句直接报 ERROR 1665,或者主从数据悄悄不一致。
  2. 主库、所有从库、以及做备份/订阅 binlog 的实例(Canal、Flink CDC、DTS)配置要一致,避免中途某台还是 RR。
  3. 持久化方式:写进 [mysqld] transaction-isolation = READ-COMMITTED 后重启,而不是只 SET GLOBAL(重启即丢,且只影响新建连接);5.7 上变量名是 tx-isolation。
  4. 代码扫描:搜出所有依赖「事务内多次读结果一致」的逻辑(对账、批量统计、先查再改),这些地方在 RC 下会读到别人新提交的值。

好处:间隙锁基本关闭 → 锁范围小、锁等待少、死锁概率显著下降;UPDATE 有半一致读,不匹配的行提前放锁;Read View 生命周期短,purge 更及时,长事务不容易把 undo 撑爆。整体并发吞吐更高。

要自己补上的:RC 不防幻读也不防不可重复读,凡是「先查后改」的关键路径必须显式加锁或加版本控制——SELECT ... FOR UPDATE(RC 下只锁命中行,不锁间隙)、唯一索引 + 重复键重试、或 WHERE ... AND version=? 的乐观锁,并用 affected rows 判断是否生效。


🔗 相关链接