{T}

MVCC 与事务隔离级别

数据库事务讨论到后面,绕不开两个高频点:

  • 事务隔离级别到底在控制什么
  • MVCC 为什么能让很多读操作不加锁也保持一致视图

很多人会背"脏读、不可重复读、幻读",但一到线上排障就说不清:为什么这条查询没加锁也能读,为什么更新又会互相等待。核心就在 MVCC 和隔离级别。

一、事务隔离级别解决什么问题

事务隔离级别的目标,是在并发读写时平衡三件事:

  • 一致性: 确保数据在并发操作下仍然保持正确
  • 并发性能: 提高系统的吞吐量和响应速度
  • 锁冲突成本: 减少因加锁带来的性能开销

隔离越强,数据越稳定,但并发代价通常也越高。

1.1 常见四种隔离级别

隔离级别能否脏读能否不可重复读能否幻读并发性能
READ UNCOMMITTED可能可能可能最高
READ COMMITTED不会可能可能
REPEATABLE READ不会不会在MySQL下不会
SERIALIZABLE不会不会不会最低

在 MySQL InnoDB 中,默认隔离级别通常是 REPEATABLE READ

需要注意,表格是理论层总结,真实行为还要结合数据库实现细节来看,尤其是在 MySQL 下,MVCC 和间隙锁会让很多人对幻读的理解产生偏差。

1.2 隔离级别的选择建议

READ COMMITTED (读已提交)

  • 适用场景:对数据一致性要求不高,追求高并发的场景
  • 优点:锁冲突少,并发性能好
  • 缺点:可能出现不可重复读和幻读
  • 示例场景:论坛帖子浏览、商品列表展示

REPEATABLE READ (可重复读)

  • 适用场景:需要数据一致性保证的场景(MySQL默认)
  • 优点:防止脏读和不可重复读,性能平衡
  • 缺点:可能需要间隙锁来防止幻读
  • 示例场景:订单处理、账户余额操作

SERIALIZABLE (可串行化)

  • 适用场景:对数据一致性要求极高的金融场景
  • 优点:完全避免并发问题
  • 缺点:并发性能最低
  • 示例场景:银行转账、账务处理
sql
-- 查看当前会话隔离级别
SELECT @@transaction_isolation;

-- 设置会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 设置全局隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

二、三类经典并发问题详解

2.1 脏读 (Dirty Read)

定义:一个事务读到了另一个事务尚未提交的数据。如果对方事务回滚,那么当前事务读到的其实是无效数据。

示例场景:

sql
-- 事务A:转账操作
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 扣款
-- 此时事务A还未提交

-- 事务B:查询余额(READ UNCOMMITTED级别)
SELECT balance FROM accounts WHERE user_id = 1; 
-- 读到了900(未提交的值)

-- 事务A:回滚
ROLLBACK; -- 实际余额还是1000

-- 事务B基于错误的余额做业务决策,导致问题

危害:

  • 业务逻辑基于错误数据做出决策
  • 数据不一致性传播
  • 难以追踪和排查

解决方案:使用 READ COMMITTED 及以上隔离级别。

2.2 不可重复读 (Non-repeatable Read)

定义:同一事务中,对同一行数据前后两次读取结果不同,通常是因为中间有其他事务提交了更新。

示例场景:

sql
-- 事务A:查询余额两次
BEGIN;
SELECT balance FROM accounts WHERE user_id = 1; -- 第一次读到1000

-- 事务B:更新并提交
BEGIN;
UPDATE accounts SET balance = 900 WHERE user_id = 1;
COMMIT;

-- 事务A:再次查询同一行
SELECT balance FROM accounts WHERE user_id = 1; -- 第二次读到900
COMMIT;

危害:

  • 同一事务中数据不一致
  • 业务逻辑判断失效
  • 报表数据不准确

解决方案:使用 REPEATABLE READ 及以上隔离级别,通过MVCC保证一致性读。

2.3 幻读 (Phantom Read)

定义:同一事务里,前后两次按条件查询时,结果集的记录数量发生变化。它强调的是"范围内新增或减少了记录",而不是单行值变化。

示例场景:

sql
-- 事务A:统计订单
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 第一次:10条

-- 事务B:插入新订单并提交
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 100);
COMMIT;

-- 事务A:再次统计
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 第二次:11条
COMMIT;

危害:

  • 批量操作遗漏记录
  • 统计数据不准确
  • 业务逻辑判断错误

解决方案:

  • SERIALIZABLE 隔离级别
  • MySQL的 REPEATABLE READ 通过间隙锁和MVCC解决幻读

2.4 三类问题对比总结

问题类型关注点出现的隔离级别解决方案
脏读读到未提交数据READ UNCOMMITTED使用READ COMMITTED及以上
不可重复读同一行数据前后不一致READ COMMITTED使用REPEATABLE READ及以上
幻读结果集记录数量变化READ COMMITTED、REPEATABLE READ(理论上)使用SERIALIZABLE或MySQL的RR

三、MVCC核心原理详解

3.1 MVCC 是什么

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制机制,核心思想是:

  • 一行数据在不同时间点上可能有多个版本
  • 事务读取时,不一定总是看"当前最新值"
  • 它会根据自己的可见性规则,决定能看到哪个版本

核心价值:

  • 读写不冲突:普通读不必和写完全互斥
  • 提高并发性能:减少大量无谓加锁
  • 保证一致性:让一致性读和并发性能同时兼顾

适用场景:

  • 大量读操作的系统(如电商、社交)
  • 需要高并发性能的场景
  • 需要保证读一致性的业务

3.2 InnoDB中MVCC的核心组件

InnoDB 里,MVCC 主要依赖三个核心组件:

  1. 隐藏字段:每行记录的版本信息
  2. Undo Log:多版本数据链
  3. Read View:事务可见性判断
code
┌─────────────────────────────────────────┐
│          MVCC 核心组件关系              │
├─────────────────────────────────────────┤
│                                         │
│  ┌──────────┐      ┌──────────┐       │
│  │ 聚簇索引  │─────▶│ 隐藏字段  │       │
│  │  (数据行) │      │ - DB_TRX_ID│       │
│  └──────────┘      │ - DB_ROLL_PT│      │
│       │            │ - DB_ROW_ID│       │
│       │            └──────────┘       │
│       ▼                   │            │
│  ┌──────────┐             │            │
│  │ Undo Log │◀────────────┘            │
│  │  (版本链) │                          │
│  └──────────┘                          │
│       │                                │
│       ▼                                │
│  ┌──────────┐                          │
│  │Read View │                          │
│  │(可见性判断)│                         │
│  └──────────┘                          │
│                                         │
└─────────────────────────────────────────┘

四、隐藏字段详解

4.1 三个隐藏字段

InnoDB为每行数据自动添加三个隐藏字段:

字段名大小说明
DB_TRX_ID6字节最近修改该行数据的事务ID
DB_ROLL_PTR7字节回滚指针,指向该行的上一个版本(在Undo Log中)
DB_ROW_ID6字节隐藏主键(如果表没有显式主键)

4.2 隐藏字段示例

sql
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT
);

INSERT INTO users VALUES (1, '张三', 25);

数据行实际存储:

code
┌────┬──────┬─────┬───────────┬──────────────┬────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR  │ DB_ROW_ID  │
├────┼──────┼─────┼───────────┼──────────────┼────────────┤
│ 1  │ 张三 │ 25  │  1001     │   NULL       │  (无需)    │
└────┴──────┴─────┴───────────┴──────────────┴────────────┘

4.3 字段作用详解

DB_TRX_ID (事务ID):

  • 记录最后修改该行的事务
  • 每次INSERT/UPDATE/DELETE都会更新
  • 用于Read View判断可见性

DB_ROLL_PTR (回滚指针):

  • 指向Undo Log中的上一个版本
  • 形成版本链
  • 用于回滚和MVCC读取旧版本

DB_ROW_ID (隐藏主键):

  • 表没有主键时自动生成
  • 用于索引组织
  • 有主键时不需要此字段

五、Undo Log与版本链

5.1 Undo Log 概述

Undo Log(回滚日志)是InnoDB实现MVCC和事务回滚的核心机制。

主要作用:

  • 事务回滚:提供回滚需要的数据
  • MVCC:提供多版本数据
  • 崩溃恢复:帮助恢复未提交的事务

存储位置:

  • 共享表空间(ibdata1)
  • 独立Undo表空间(MySQL 8.0+)

5.2 版本链的形成

每次更新数据时,InnoDB会:

  1. 将旧数据写入Undo Log
  2. 更新聚簇索引中的数据
  3. 更新DB_ROLL_PTR指向Undo Log中的旧版本

示例:更新操作形成版本链

sql
-- 初始插入(事务ID=1001)
INSERT INTO users VALUES (1, '张三', 25);

-- 第一次更新(事务ID=1002)
UPDATE users SET age = 26 WHERE id = 1;

-- 第二次更新(事务ID=1003)
UPDATE users SET name = '李四', age = 27 WHERE id = 1;

版本链结构:

code
当前数据行(最新版本)
┌────┬──────┬─────┬───────────┬──────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR  │
│ 1  │ 李四 │ 27  │  1003     │      ↓       │
└────┴──────┴─────┴───────────┴──────┼───────┘
                                     │
        ┌────────────────────────────┘
        ▼
Undo Log (版本1)
┌────┬──────┬─────┬───────────┬──────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR  │
│ 1  │ 张三 │ 26  │  1002     │      ↓       │
└────┴──────┴─────┴───────────┴──────┼───────┘
                                     │
        ┌────────────────────────────┘
        ▼
Undo Log (版本0)
┌────┬──────┬─────┬───────────┬──────────────┐
│ id │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR  │
│ 1  │ 张三 │ 25  │  1001     │    NULL      │
└────┴──────┴─────┴───────────┴──────────────┘

5.3 Undo Log的类型

Insert Undo Log:

  • 由INSERT语句产生
  • 事务提交后可以立即删除
  • 只用于事务回滚

Update Undo Log:

  • 由UPDATE/DELETE语句产生
  • 需要保留到没有活跃事务需要该版本
  • 用于MVCC和事务回滚

5.4 Undo Log清理机制

purge线程负责清理不再需要的Undo Log:

  1. 判断条件:没有活跃事务需要该版本
  2. 清理时机:异步后台清理
  3. 影响因素:
    • 长事务会阻止清理
    • undo log retention time配置
    • 系统负载情况

长事务的危害:

sql
-- 长事务开始
BEGIN;
SELECT * FROM users WHERE id = 1; -- 事务ID=2000

-- 其他事务持续更新(产生大量Undo Log)
-- UPDATE、INSERT、DELETE操作...

-- 长事务持续运行(不提交也不回滚)
-- 所有Undo Log都无法清理,导致:
-- 1. Undo Log空间膨胀
-- 2. 版本链过长,查询性能下降
-- 3. 系统整体性能下降

六、Read View详解

6.1 Read View 结构

Read View是MVCC的核心,用于判断某个版本对当前事务是否可见。

Read View 包含的关键字段:

字段名说明
m_ids生成Read View时,当前活跃的事务ID列表(未提交)
min_trx_idm_ids中最小的事务ID
max_trx_id系统应分配的下一个事务ID(不是m_ids最大值)
creator_trx_id创建该Read View的事务ID

6.2 可见性判断规则

对于某行数据的某个版本,判断其对当前事务是否可见:

规则1:自己修改的,一定可见

java
if (DB_TRX_ID == creator_trx_id) {
    // 自己修改的,可见
    return VISIBLE;
}

规则2:已提交且在Read View生成前提交的,可见

java
if (DB_TRX_ID < min_trx_id) {
    // 在Read View生成前已提交,可见
    return VISIBLE;
}

规则3:在Read View生成后才开始的事务,可见

java
if (DB_TRX_ID >= max_trx_id) {
    // 在Read View生成后才开始,可见
    return VISIBLE;
}

规则4:活跃事务列表中,不可见

java
if (m_ids.contains(DB_TRX_ID)) {
    // 还未提交,不可见
    return NOT_VISIBLE;
}

规则5:其他情况,可见

java
// 在Read View生成时已提交
return VISIBLE;

6.3 Read View生成时机

不同隔离级别,Read View生成时机不同:

READ COMMITTED:

  • 每次SELECT都生成新的Read View
  • 可以看到其他事务已提交的修改

REPEATABLE READ:

  • 第一次SELECT生成Read View
  • 整个事务期间使用同一个Read View
  • 保证可重复读

对比示例:

sql
-- 事务A (RC级别)
BEGIN; -- 事务ID=100
SELECT * FROM users WHERE id = 1; -- 生成Read View-1

-- 事务B
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 事务ID=101
COMMIT;

-- 事务A再次查询
SELECT * FROM users WHERE id = 1; -- 生成Read View-2(能看到age=30)
COMMIT;
sql
-- 事务A (RR级别)
BEGIN; -- 事务ID=100
SELECT * FROM users WHERE id = 1; -- 生成Read View-1

-- 事务B
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 事务ID=101
COMMIT;

-- 事务A再次查询
SELECT * FROM users WHERE id = 1; -- 使用Read View-1(看不到age=30)
COMMIT;

6.4 Read View工作流程

code
开始查询
    │
    ▼
生成/复用Read View
    │
    ▼
读取数据行(DB_TRX_ID)
    │
    ▼
判断可见性
    │
    ├─▶ DB_TRX_ID == creator_trx_id? ──▶ YES ──▶ 可见
    │            │
    │            NO
    │            ▼
    ├─▶ DB_TRX_ID < min_trx_id? ──────▶ YES ──▶ 可见
    │            │
    │            NO
    │            ▼
    ├─▶ DB_TRX_ID >= max_trx_id? ─────▶ YES ──▶ 可见
    │            │
    │            NO
    │            ▼
    ├─▶ DB_TRX_ID in m_ids? ──────────▶ YES ──▶ 不可见
    │            │
    │            NO
    │            ▼
    └─▶ 可见(已提交) ──────────────────▶ 返回该版本
    
    如果不可见,通过DB_ROLL_PTR找上一版本,重复判断

七、Redo Log详解

7.1 Redo Log 概述

Redo Log(重做日志)是InnoDB保证事务持久性的核心机制。

主要作用:

  • 崩溃恢复:数据库崩溃后恢复已提交的事务
  • 性能优化:将随机写转换为顺序写
  • WAL机制:Write-Ahead Logging,先写日志再写数据

存储位置:

  • ib_logfile0ib_logfile1(默认)
  • 循环写入,固定大小

7.2 Redo Log 工作原理

code
┌─────────────────────────────────────────────┐
│          Redo Log 工作流程                  │
├─────────────────────────────────────────────┤
│                                             │
│  1. 事务提交                                │
│     │                                       │
│     ▼                                       │
│  2. 写入Redo Log Buffer(内存)              │
│     │                                       │
│     ▼                                       │
│  3. 刷写到Redo Log File(磁盘)              │
│     │                                       │
│     ▼                                       │
│  4. 返回事务提交成功                        │
│     │                                       │
│     ▼                                       │
│  5. 后台异步刷写脏页到数据文件              │
│                                             │
└─────────────────────────────────────────────┘

7.3 Redo Log 刷盘策略

innodb_flush_log_at_trx_commit参数控制刷盘策略:

策略性能安全性
0每秒刷盘最高最低(可能丢失1秒数据)
1每次提交刷盘最低最高(不丢数据)
2每次提交写OS缓存,每秒刷盘中(OS崩溃可能丢数据)

推荐配置:

sql
-- 生产环境,数据安全优先
SET GLOBAL innodb_flush_log_at_trx_commit = 1;

-- 性能优先,可容忍少量数据丢失
SET GLOBAL innodb_flush_log_at_trx_commit = 2;

7.4 Redo Log 与 Undo Log 对比

特性Redo LogUndo Log
作用保证持久性保证原子性、MVCC
内容物理日志(页修改)逻辑日志(反向操作)
写入时机事务提交时数据修改时
清理时机Checkpoint后无活跃事务需要时
存储方式循环写入线性增长
崩溃恢复重做已提交事务回滚未提交事务

八、MVCC完整工作流程

8.1 INSERT操作流程

sql
BEGIN; -- 事务ID=1000
INSERT INTO users (id, name, age) VALUES (1, '张三', 25);
COMMIT;

MVCC处理流程:

  1. 分配事务ID=1000
  2. 写入Undo Log(INSERT类型)
  3. 写入数据行:
    • id=1, name='张三', age=25
    • DB_TRX_ID=1000
    • DB_ROLL_PTR=NULL(INSERT操作的Undo Log)
  4. 写入Redo Log
  5. 提交事务

8.2 UPDATE操作流程

sql
-- 初始数据:事务ID=1000, name='张三', age=25
BEGIN; -- 事务ID=1001
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;

MVCC处理流程:

  1. 分配事务ID=1001
  2. 读取当前数据行:
    • DB_TRX_ID=1000
    • name='张三', age=25
  3. 将当前数据写入Undo Log:
    • name='张三', age=25, DB_TRX_ID=1000
  4. 更新数据行:
    • name='张三', age=26
    • DB_TRX_ID=1001
    • DB_ROLL_PTR → Undo Log
  5. 写入Redo Log
  6. 提交事务

8.3 SELECT操作流程

sql
-- 数据状态:
-- 最新版本:事务ID=1001, age=26
-- Undo Log:事务ID=1000, age=25

-- 场景1:事务ID=1002查询(RC级别)
BEGIN;
SELECT * FROM users WHERE id = 1; -- 生成Read View
-- Read View: m_ids=[1002], min=1002, max=1003
-- DB_TRX_ID=1001 < min=1002,可见
-- 返回:age=26
COMMIT;

-- 场景2:事务ID=1000查询(RR级别,Read View在1001提交前生成)
BEGIN; -- 事务ID=1000
SELECT * FROM users WHERE id = 1; -- 生成Read View
-- Read View: m_ids=[1000], min=1000, max=1001
-- DB_TRX_ID=1001 >= max=1001,但在Read View生成后
-- 判断不可见,通过DB_ROLL_PTR找Undo Log
-- Undo Log: DB_TRX_ID=1000 == creator_trx_id,可见
-- 返回:age=25
COMMIT;

8.4 DELETE操作流程

sql
BEGIN; -- 事务ID=1002
DELETE FROM users WHERE id = 1;
COMMIT;

MVCC处理流程:

  1. 分配事务ID=1002
  2. 读取当前数据行
  3. 将当前数据写入Undo Log
  4. 更新数据行:
    • 标记为已删除(不立即物理删除)
    • DB_TRX_ID=1002
    • DB_ROLL_PTR → Undo Log
  5. 写入Redo Log
  6. 提交事务
  7. Purge线程异步清理

九、一致性读与当前读

9.1 一致性读(Snapshot Read)

定义:基于MVCC读取快照数据,不需要加锁。

特点:

  • 读取历史版本,不阻塞写操作
  • 基于Read View判断可见性
  • 提高并发性能

示例:

sql
-- 普通SELECT都是一致性读
SELECT * FROM orders WHERE id = 1001;
SELECT * FROM orders WHERE status = 'pending';

9.2 当前读(Current Read)

定义:读取最新版本数据,需要加锁。

特点:

  • 读取最新已提交数据
  • 需要加锁,可能阻塞
  • 用于UPDATE、DELETE等修改操作

示例:

sql
-- 共享锁(S锁)
SELECT * FROM orders WHERE id = 1001 LOCK IN SHARE MODE;
SELECT * FROM orders WHERE id = 1001 FOR SHARE; -- MySQL 8.0+

-- 排他锁(X锁)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;

-- UPDATE和DELETE也是当前读
UPDATE orders SET status = 'completed' WHERE id = 1001;
DELETE FROM orders WHERE id = 1001;

9.3 为什么这个区别非常重要

很多排障误判都来自把两类读混为一谈:

案例1:误判并发问题

sql
-- 事务A
SELECT * FROM orders WHERE id = 1001; -- 一致性读,快速返回

-- 事务B
SELECT * FROM orders WHERE id = 1001 FOR UPDATE; -- 当前读,等待锁

如果只看事务A能快速返回,误以为没有并发问题,实际上事务B可能在等待锁。

案例2:更新丢失问题

sql
-- 事务A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 一致性读,读到1000
-- 业务处理...
UPDATE accounts SET balance = 900 WHERE id = 1; -- 当前读,更新
COMMIT;

-- 事务B(同时进行)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 读到1000
UPDATE accounts SET balance = 800 WHERE id = 1; -- 覆盖事务A的修改
COMMIT;

解决方案:使用SELECT ... FOR UPDATE进行当前读加锁。

sql
-- 正确做法
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 当前读+锁
-- 业务处理...
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

9.4 一致性读与当前读对比

特性一致性读当前读
加锁不加锁加共享锁或排他锁
读取版本快照版本最新已提交版本
并发性能高(不阻塞写)低(可能阻塞)
适用场景查询操作修改操作、需要最新数据
实现机制MVCC锁机制

十、事务隔离级别与MVCC关系

10.1 READ COMMITTED + MVCC

特点:

  • 每次SELECT生成新的Read View
  • 可以看到其他事务已提交的修改
  • 不可重复读问题存在

示例:

sql
-- 事务A (RC级别)
BEGIN; -- 事务ID=1000
SELECT * FROM users WHERE id = 1; 
-- Read View-1: m_ids=[1000], min=1000, max=1001
-- 假设读到: age=25

-- 事务B
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 事务ID=1001
COMMIT;

-- 事务A再次查询
SELECT * FROM users WHERE id = 1;
-- Read View-2: m_ids=[1000], min=1000, max=1002
-- DB_TRX_ID=1001 不在m_ids,且已提交,可见
-- 读到: age=30(不可重复读)
COMMIT;

10.2 REPEATABLE READ + MVCC

特点:

  • 第一次SELECT生成Read View,整个事务期间复用
  • 看不到其他事务的提交
  • 保证可重复读

示例:

sql
-- 事务A (RR级别)
BEGIN; -- 事务ID=1000
SELECT * FROM users WHERE id = 1;
-- Read View: m_ids=[1000], min=1000, max=1001
-- 假设读到: age=25

-- 事务B
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 事务ID=1001
COMMIT;

-- 事务A再次查询
SELECT * FROM users WHERE id = 1;
-- 使用Read View: m_ids=[1000], min=1000, max=1001
-- DB_TRX_ID=1001 >= max=1001,不在活跃列表
-- 但通过Undo Log找旧版本: age=25
-- 保证可重复读
COMMIT;

10.3 SERIALIZABLE + MVCC

特点:

  • 所有SELECT自动转换为SELECT ... LOCK IN SHARE MODE
  • 强制加共享锁
  • 完全串行化执行

示例:

sql
-- 事务A (SERIALIZABLE级别)
BEGIN;
SELECT * FROM users WHERE id = 1;
-- 实际执行: SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE
-- 加共享锁,阻塞其他事务的UPDATE

-- 事务B
BEGIN;
UPDATE users SET age = 30 WHERE id = 1; -- 等待事务A释放锁
COMMIT;

10.4 不同隔离级别MVCC行为对比

隔离级别Read View生成是否解决脏读是否解决不可重复读是否解决幻读
READ COMMITTED每次SELECT××
REPEATABLE READ第一次SELECT√(MySQL)
SERIALIZABLE不使用MVCC

十一、MVCC实战分析案例

11.1 案例1:订单状态更新并发问题

场景:多个线程同时处理同一订单的状态更新。

问题现象:

sql
-- 初始状态:订单1001, status='pending', version=1

-- 线程A
BEGIN;
SELECT status FROM orders WHERE id = 1001; -- 一致性读,pending
UPDATE orders SET status = 'processing' WHERE id = 1001; -- 当前读+更新
COMMIT;

-- 线程B(并发)
BEGIN;
SELECT status FROM orders WHERE id = 1001; -- 一致性读,pending
UPDATE orders SET status = 'cancelled' WHERE id = 1001; -- 覆盖线程A的修改
COMMIT;

问题分析:

  • 一致性读不阻塞,两个线程都读到 pending
  • 两个UPDATE都成功,但后提交的覆盖前面的
  • 订单状态不一致

解决方案:

sql
-- 方案1:使用当前读+排他锁
BEGIN;
SELECT status FROM orders WHERE id = 1001 FOR UPDATE; -- 当前读+锁
UPDATE orders SET status = 'processing' WHERE id = 1001;
COMMIT;

-- 方案2:乐观锁(版本号)
BEGIN;
SELECT status, version FROM orders WHERE id = 1001;
UPDATE orders SET status = 'processing', version = version + 1 
WHERE id = 1001 AND version = 1; -- 乐观锁检查
-- affected_rows = 0 则表示版本冲突,重试
COMMIT;

-- 方案3:状态机检查
UPDATE orders SET status = 'processing' 
WHERE id = 1001 AND status = 'pending'; -- 利用WHERE条件检查
-- affected_rows = 0 则表示状态已变更,不符合预期

11.2 案例2:库存扣减超卖问题

场景:秒杀场景,多个用户同时购买同一商品。

问题现象:

sql
-- 商品1001, stock=10

-- 用户A
BEGIN;
SELECT stock FROM products WHERE id = 1001; -- 一致性读,stock=10
UPDATE products SET stock = stock - 1 WHERE id = 1001; -- 当前读
COMMIT;

-- 用户B(并发)
BEGIN;
SELECT stock FROM products WHERE id = 1001; -- 一致性读,stock=10
UPDATE products SET stock = stock - 1 WHERE id = 1001;
COMMIT;

-- 实际卖出2件,但库存扣减前都看到stock=10,可能超卖

解决方案:

sql
-- 方案1:使用SELECT FOR UPDATE
BEGIN;
SELECT stock FROM products WHERE id = 1001 FOR UPDATE; -- 当前读+锁
-- stock=10,判断>0
UPDATE products SET stock = stock - 1 WHERE id = 1001;
COMMIT;

-- 方案2:乐观锁+版本号
BEGIN;
SELECT stock, version FROM products WHERE id = 1001; -- 一致性读
-- stock=10, version=5
UPDATE products SET stock = stock - 1, version = 6 
WHERE id = 1001 AND version = 5;
-- affected_rows = 1则成功,=0则重试
COMMIT;

-- 方案3:利用UPDATE原子性
UPDATE products SET stock = stock - 1 
WHERE id = 1001 AND stock > 0;
-- affected_rows = 1则成功,=0则库存不足

11.3 案例3:长事务导致Undo Log膨胀

场景:数据导出任务,长时间运行的事务。

问题现象:

sql
-- 事务A(长事务)
BEGIN; -- 事务ID=1000
SELECT * FROM orders WHERE create_time > '2024-01-01';
-- 导出数据,运行2小时...

-- 事务B,C,D...持续更新orders表
-- UPDATE orders SET status = 'completed' WHERE ...
-- 产生大量Undo Log

-- 问题:
-- 1. Undo Log无法清理,空间持续增长
-- 2. 其他查询需要遍历长版本链,性能下降
-- 3. 系统整体变慢

解决方案:

sql
-- 方案1:将长事务拆分
-- 每10000条记录提交一次
BEGIN;
SELECT * FROM orders WHERE create_time > '2024-01-01' LIMIT 10000 OFFSET 0;
-- 处理数据...
COMMIT;

BEGIN;
SELECT * FROM orders WHERE create_time > '2024-01-01' LIMIT 10000 OFFSET 10000;
-- 处理数据...
COMMIT;

-- 方案2:使用只读事务+短事务
SET SESSION TRANSACTION READ ONLY;
BEGIN;
SELECT * FROM orders WHERE create_time > '2024-01-01';
COMMIT;

-- 方案3:使用历史表或数据仓库
-- 将需要导出的数据复制到历史表
CREATE TABLE orders_export AS 
SELECT * FROM orders WHERE create_time > '2024-01-01';
-- 然后从orders_export导出

11.4 案例4:幻读排查与分析

场景:统计订单数量,发现数量不一致。

问题现象:

sql
-- 事务A (RR级别)
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 10条

-- 事务B
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 100);
COMMIT;

-- 事务A再次统计
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 10条(MySQL的RR防止了幻读)
-- 但如果使用当前读:
SELECT COUNT(*) FROM orders WHERE user_id = 1 FOR UPDATE; -- 11条
COMMIT;

分析:

  • MySQL的RR隔离级别通过MVCC+间隙锁防止幻读
  • 一致性读使用快照,看不到新插入的记录
  • 当前读读取最新版本,能看到新记录

解决方案:

sql
-- 如果需要看到最新数据,使用当前读
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1 FOR UPDATE; -- 当前读+锁
-- 业务处理...
COMMIT;

-- 或者使用RC隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 能看到已提交的新记录
COMMIT;

十二、常见问题与解决方案

12.1 问题1:为什么SELECT不阻塞UPDATE?

原因:

  • SELECT是一致性读,基于MVCC读取快照
  • 不需要加锁,不会阻塞写操作

解决方案:

  • 如果需要保证读后的写一致性,使用 SELECT ... FOR UPDATE
sql
-- 场景:读取余额后扣款
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 加锁
-- balance=1000
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

12.2 问题2:为什么UPDATE会等待?

原因:

  • UPDATE是当前读,需要获取排他锁
  • 如果其他事务持有锁,就需要等待

排查方法:

sql
-- 查看锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看锁信息
SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 'accounts';

-- 查看活跃事务
SELECT * FROM information_schema.INNODB_TRX;

解决方案:

  • 优化事务逻辑,减少锁持有时间
  • 设置合理的锁等待超时: SET innodb_lock_wait_timeout = 10;

12.3 问题3:为什么RR隔离级别下还是看到数据变化?

原因:

  • 当前读读取最新版本
  • DDL语句会更新表元数据

示例:

sql
-- 事务A (RR级别)
BEGIN;
SELECT * FROM users WHERE id = 1; -- 一致性读,age=25

-- 事务B
UPDATE users SET age = 30 WHERE id = 1;
COMMIT;

-- 事务A使用当前读
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 当前读,age=30
COMMIT;

解决方案:

  • 理解一致性读和当前读的区别
  • 根据业务需求选择合适的读取方式

12.4 问题4:如何监控MVCC性能?

监控指标:

sql
-- 查看Undo Log信息
SHOW ENGINE INNODB STATUS\G

-- 查看事务信息
SELECT * FROM information_schema.INNODB_TRX;

-- 查看Undo表空间
SELECT * FROM information_schema.INNODB_TABLESPACES 
WHERE NAME LIKE '%undo%';

-- 查看长事务
SELECT trx_id, trx_state, trx_started, 
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration
FROM information_schema.INNODB_TRX
HAVING duration > 60; -- 运行超过60秒的事务

优化建议:

  • 定期监控长事务
  • 设置合理的undo log表空间大小
  • 使用 innodb_max_undo_log_size 控制undo log大小

12.5 问题5:如何避免长事务?

最佳实践:

sql
-- 1. 设置事务超时
SET SESSION innodb_lock_wait_timeout = 10;
SET SESSION MAX_EXECUTION_TIME = 60000; -- 60秒超时

-- 2. 应用层控制事务范围
-- 避免在事务中进行耗时操作
BEGIN;
-- 只做数据库操作
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 不要在事务中调用外部API、发送邮件等

-- 3. 使用只读事务
SET SESSION TRANSACTION READ ONLY;
BEGIN;
SELECT * FROM orders WHERE create_time > '2024-01-01';
COMMIT;

-- 4. 监控和告警
-- 定期查询长事务并告警

十三、性能优化建议

13.1 MVCC性能优化

1. 控制事务长度

sql
-- 不推荐:事务过长
BEGIN;
SELECT * FROM orders WHERE id = 1;
-- 业务处理(耗时操作)
UPDATE orders SET status = 'completed' WHERE id = 1;
COMMIT;

-- 推荐:事务尽可能短
-- 先在事务外处理业务逻辑
-- 仅在事务中做数据库操作
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
UPDATE orders SET status = 'completed' WHERE id = 1;
COMMIT;

2. 合理使用索引

sql
-- 无索引:需要扫描全表,遍历所有版本链
UPDATE orders SET status = 'completed' WHERE order_no = 'ORD001';

-- 有索引:快速定位,减少版本链遍历
CREATE INDEX idx_order_no ON orders(order_no);
UPDATE orders SET status = 'completed' WHERE order_no = 'ORD001';

3. 避免热点数据争用

sql
-- 热点数据:所有用户更新同一行
UPDATE config SET value = value + 1 WHERE key = 'counter';

-- 优化:分片计数器
UPDATE counter_shard_1 SET value = value + 1 WHERE id = 1;
UPDATE counter_shard_2 SET value = value + 1 WHERE id = 2;
-- 最后聚合统计

13.2 Undo Log优化

1. 配置独立的Undo表空间

sql
-- MySQL 8.0+
-- 查看Undo表空间
SHOW VARIABLES LIKE 'innodb_undo_tablespaces';

-- 创建独立的Undo表空间
CREATE UNDO TABLESPACE undo_01 ADD DATAFILE 'undo_01.ibu';

2. 控制Undo Log大小

sql
-- 设置最大Undo Log大小
SET GLOBAL innodb_max_undo_log_size = 1073741824; -- 1GB

-- 启用Undo Log自动截断
SET GLOBAL innodb_undo_log_truncate = ON;

13.3 Read View优化

1. 选择合适的隔离级别

sql
-- 读多写少:使用RR级别
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 写多读少:考虑RC级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

2. 避免跨表事务

sql
-- 不推荐:跨多个表的长事务
BEGIN;
UPDATE orders SET status = 'completed' WHERE id = 1;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
UPDATE payments SET status = 'success' WHERE order_id = 1;
COMMIT;

-- 推荐:拆分为多个短事务
BEGIN;
UPDATE orders SET status = 'completed' WHERE id = 1;
COMMIT;

BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
COMMIT;

-- 使用分布式事务保证一致性(如需要)

十四、面试要点

14.1 基础类问题

Q1: MVCC解决的核心问题是什么?

A: MVCC解决的核心问题是在保证一致性读的同时减少读写冲突。通过多版本机制,读操作可以读取历史版本而不阻塞写操作,大大提高了并发性能。

Q2: 事务隔离级别有哪些?分别解决什么问题?

A:

  • READ UNCOMMITTED:不解决任何问题
  • READ COMMITTED:解决脏读
  • REPEATABLE READ:解决脏读和不可重复读
  • SERIALIZABLE:解决脏读、不可重复读和幻读

Q3: 一致性读和当前读有什么区别?

A:

  • 一致性读:基于MVCC读取快照版本,不加锁,不阻塞写操作
  • 当前读:读取最新版本并加锁,可能阻塞,用于UPDATE/DELETE/FOR UPDATE等操作

14.2 进阶类问题

Q4: Read View的工作原理是什么?

A: Read View包含四个关键字段:

  • m_ids:活跃事务ID列表
  • min_trx_id:最小活跃事务ID
  • max_trx_id:下一个应分配的事务ID
  • creator_trx_id:创建Read View的事务ID

通过这些字段判断某版本对当前事务是否可见:

  1. 如果是自己修改的,可见
  2. 如果在Read View生成前已提交,可见
  3. 如果在Read View生成后才开始,可见
  4. 如果还在活跃事务列表中,不可见

Q5: Undo Log和Redo Log有什么区别?

A:

  • Undo Log:

    • 保证事务原子性和MVCC
    • 记录反向操作(逻辑日志)
    • 用于回滚和多版本读取
    • 在事务提交后可能还需要保留(MVCC)
  • Redo Log:

    • 保证事务持久性
    • 记录页修改(物理日志)
    • 用于崩溃恢复
    • 循环写入,固定大小

Q6: 为什么RR隔离级别能防止幻读?

A: MySQL的RR隔离级别通过以下机制防止幻读:

  1. MVCC:一致性读使用快照,看不到新插入的记录
  2. 间隙锁:当前读时对间隙加锁,防止其他事务插入新记录

但需要注意:一致性读和当前读的幻读行为不同。

14.3 实战类问题

Q7: 长事务为什么会影响MVCC性能?

A: 长事务会导致:

  1. Undo Log无法清理,空间持续增长
  2. 版本链过长,查询需要遍历多个版本,性能下降
  3. 其他事务的Read View需要判断长事务,增加开销
  4. 系统整体性能下降

Q8: 如何解决更新丢失问题?

A:

  1. 悲观锁:使用 SELECT ... FOR UPDATE 加锁
  2. 乐观锁:使用版本号或时间戳
  3. 原子更新:利用UPDATE的WHERE条件检查
sql
-- 方案1:悲观锁
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE id = 1;

-- 方案2:乐观锁
UPDATE products SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = 5;

-- 方案3:原子更新
UPDATE products SET stock = stock - 1 
WHERE id = 1 AND stock > 0;

Q9: 如何排查数据库并发问题?

A:

  1. 查看锁等待: information_schema.INNODB_LOCK_WAITS
  2. 查看活跃事务: information_schema.INNODB_TRX
  3. 查看锁信息: performance_schema.data_locks
  4. 分析慢查询日志
  5. 使用 SHOW ENGINE INNODB STATUS 查看详细信息

Q10: MVCC在RR和RC隔离级别下的区别是什么?

A:

  • Read View生成时机:

    • RC:每次SELECT生成新的Read View
    • RR:第一次SELECT生成,整个事务期间复用
  • 可见性:

    • RC:可以看到其他事务已提交的修改
    • RR:只能看到事务开始时已提交的修改
  • 一致性:

    • RC:可能出现不可重复读
    • RR:保证可重复读

十五、总结

15.1 MVCC核心要点

  1. 核心思想:多版本并发控制,读写不冲突
  2. 三大组件:隐藏字段、Undo Log、Read View
  3. 适用场景:读多写少,需要高并发性能

15.2 事务隔离级别选择

  • READ COMMITTED:追求性能,可容忍不可重复读
  • REPEATABLE READ:平衡性能和一致性(MySQL默认)
  • SERIALIZABLE:强一致性要求,性能最低

15.3 最佳实践

  1. 避免长事务:事务尽可能短,减少锁持有时间
  2. 合理使用索引:减少版本链遍历
  3. 理解一致性读和当前读:根据场景选择合适的读取方式
  4. 监控MVCC性能:定期查看长事务、Undo Log等信息
  5. 选择合适的隔离级别:根据业务需求平衡性能和一致性

15.4 排查思路

遇到事务问题时,优先查看:

  1. 当前隔离级别
  2. 查询类型(一致性读 or 当前读)
  3. 是否存在长事务
  4. 锁等待和活跃事务情况
  5. Undo Log和版本链状态

参考资料

  • MySQL官方文档:InnoDB Multi-Versioning
  • 《高性能MySQL》
  • 《MySQL技术内幕:InnoDB存储引擎》
  • MySQL 8.0 Reference Manual

版本差异(MySQL 5.7 → 8.0/8.4)

特性旧版(本文编写时,MySQL 5.7)当前(MySQL 8.0/8.4 LTS)
默认字符集utf8(需显式配置 utf8mb4)utf8mb4(MySQL 8.0 起默认)
索引普通 B+Tree降序索引、隐藏索引、函数索引(8.0+)
SQL 能力常规查询递归 CTE、窗口函数(8.0+)
版本策略5.78.0(主流)/ 8.4 LTS / 9.x(创新版)
Java 驱动mysql-connector-java 5.x/8.0mysql-connector-j 8.x/9.x

本文基于 MySQL 5.7 编写,核心概念(索引、事务、锁、MVCC、InnoDB)在 8.0/8.4 中依然适用;8.0 的默认字符集、隐藏索引与 SQL 增强(CTE/窗口函数)是升级后的主要差异。