Database · #mysql#transaction#mvcc#isolation

MySQL事务隔离级别与MVCC实现原理

2024.11.13 7 min 3.0k
// 目录 · contents

引言

事务是数据库区别于文件系统的核心特性之一。在高并发场景下,如何在保证数据一致性的同时提供良好的并发性能,是数据库设计的核心挑战。MySQL InnoDB引擎通过MVCC(多版本并发控制)机制,在不加锁的情况下实现了事务的隔离,极大地提升了并发性能。

ACID 特性

事务的四个基本特性(ACID)是理解事务隔离的基础:

graph LR
    A[ACID] --> B[Atomicity<br/>原子性]
    A --> C[Consistency<br/>一致性]
    A --> D[Isolation<br/>隔离性]
    A --> E[Durability<br/>持久性]
    B --> B1[undo log]
    C --> C1[其他三者共同保证]
    D --> D1[MVCC + 锁机制]
    E --> E1[redo log]
特性 实现机制 说明
原子性(Atomicity) undo log 事务中的操作要么全部成功,要么全部回滚
一致性(Consistency) 约束 + AID 事务前后数据库的完整性约束不被破坏
隔离性(Isolation) MVCC + 锁 并发事务之间互不干扰
持久性(Durability) redo log 事务提交后数据不会丢失

事务隔离级别

SQL标准定义了四种隔离级别,它们在并发性能数据一致性之间做出不同的权衡:

四种隔离级别与并发问题

graph TD
    subgraph 隔离级别从低到高
        RU[READ UNCOMMITTED<br/>读未提交]
        RC[READ COMMITTED<br/>读已提交]
        RR[REPEATABLE READ<br/>可重复读]
        SE[SERIALIZABLE<br/>串行化]
    end

    subgraph 并发问题
        D1[脏读]
        D2[不可重复读]
        D3[幻读]
    end

    RU -.->|存在| D1
    RU -.->|存在| D2
    RU -.->|存在| D3
    RC -.->|存在| D2
    RC -.->|存在| D3
    RR -.->|部分存在| D3
隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不可能 可能 可能
REPEATABLE READ(InnoDB默认) 不可能 不可能 部分解决
SERIALIZABLE 不可能 不可能 不可能

并发问题演示

1
2
3
4
5
-- 查看当前隔离级别
SELECT @@transaction_isolation;

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

脏读示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 事务A(READ UNCOMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 结果: 1000

-- 事务B
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
-- 注意:事务B还没有提交

-- 事务A
SELECT balance FROM accounts WHERE id = 1; -- 结果: 500(脏读!读到了未提交的数据)

-- 事务B
ROLLBACK; -- 事务B回滚了
-- 但事务A已经读到了不存在的数据

不可重复读示例:

1
2
3
4
5
6
7
8
9
10
11
12
-- 事务A(READ COMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 结果: 1000

-- 事务B
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT; -- 事务B提交了

-- 事务A
SELECT balance FROM accounts WHERE id = 1; -- 结果: 500(不可重复读!同一事务内两次读取结果不同)
COMMIT;

幻读示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 事务A(REPEATABLE READ)
BEGIN;
SELECT * FROM accounts WHERE balance > 800; -- 结果: 2行

-- 事务B
BEGIN;
INSERT INTO accounts (id, balance) VALUES (10, 900);
COMMIT;

-- 事务A
SELECT * FROM accounts WHERE balance > 800; -- MVCC快照读: 仍然2行
UPDATE accounts SET balance = balance + 1 WHERE balance > 800; -- 当前读: 影响了3行(幻读!)
SELECT * FROM accounts WHERE balance > 800; -- 结果: 3行
COMMIT;

MVCC 实现原理

MVCC(Multi-Version Concurrency Control)的核心思想是:为每行数据维护多个版本,读操作读取的是某个时间点的快照,而不是当前最新数据,从而实现读写不冲突。

隐藏列

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

1
2
3
4
5
6
7
+--------+--------------+---------------+-------+------+-----+
| 列名 | 大小 | 说明 | id | name | age |
+--------+--------------+---------------+-------+------+-----+
| DB_TRX_ID | 6 bytes | 最后修改该行的事务ID | ... | ... | ... |
| DB_ROLL_PTR| 7 bytes | 指向undo log的指针 | ... | ... | ... |
| DB_ROW_ID | 6 bytes | 隐藏主键(无主键时) | ... | ... | ... |
+--------+--------------+---------------+-------+------+-----+

Undo Log 版本链

每次对数据行的修改都会生成一条undo log记录,多条undo log通过DB_ROLL_PTR串联形成版本链。

graph LR
    subgraph 当前数据
        A["id=1, name='Dave', age=28<br/>DB_TRX_ID=300<br/>DB_ROLL_PTR → "]
    end
    subgraph Undo Log
        B["id=1, name='Carol', age=25<br/>DB_TRX_ID=200<br/>DB_ROLL_PTR → "]
        C["id=1, name='Bob', age=22<br/>DB_TRX_ID=100<br/>DB_ROLL_PTR → "]
        D["id=1, name='Alice', age=20<br/>DB_TRX_ID=50<br/>DB_ROLL_PTR = NULL"]
    end
    A --> B
    B --> C
    C --> D

版本链的形成过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 事务50: 插入数据
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 20);
-- 当前行: DB_TRX_ID=50

-- 事务100: 更新数据
UPDATE users SET name = 'Bob', age = 22 WHERE id = 1;
-- 1. 将旧版本写入undo log
-- 2. 更新当前行,DB_TRX_ID=100,DB_ROLL_PTR 指向旧版本

-- 事务200: 再次更新
UPDATE users SET name = 'Carol', age = 25 WHERE id = 1;
-- DB_TRX_ID=200,形成版本链

-- 事务300: 再次更新
UPDATE users SET name = 'Dave', age = 28 WHERE id = 1;
-- DB_TRX_ID=300,版本链继续增长

ReadView 机制

ReadView是MVCC实现快照读的关键。当事务执行快照读(普通SELECT)时,会生成一个ReadView,用来判断数据版本的可见性。

ReadView包含以下关键字段:

1
2
3
4
5
6
ReadView {
m_ids: 当前系统中所有活跃(未提交)事务的ID列表
min_trx_id: m_ids中的最小值
max_trx_id: 系统应该分配给下一个事务的ID值(当前最大事务ID + 1)
creator_trx_id: 创建该ReadView的事务ID
}

可见性判断算法

flowchart TD
    A[获取数据行的 DB_TRX_ID] --> B{trx_id == creator_trx_id?}
    B -->|是| C[可见 - 自己修改的数据]
    B -->|否| D{trx_id < min_trx_id?}
    D -->|是| E[可见 - 在ReadView创建前已提交]
    D -->|否| F{trx_id >= max_trx_id?}
    F -->|是| G[不可见 - 在ReadView创建后开始的事务]
    F -->|否| H{trx_id 在 m_ids 中?}
    H -->|是| I[不可见 - 该事务还未提交]
    H -->|否| J[可见 - 该事务已经提交]
    I --> K[沿版本链找上一个版本<br/>重复判断]
    G --> K

RC 与 RR 的 ReadView 差异

两种隔离级别的核心区别在于生成ReadView的时机

  • READ COMMITTED:每次执行SELECT都会重新生成一个ReadView
  • REPEATABLE READ:只在事务中第一次执行SELECT时生成ReadView,后续复用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- 假设当前存在数据: id=1, name='Alice', DB_TRX_ID=50

-- 事务A (trx_id=100, RR级别)
BEGIN;
SELECT * FROM users WHERE id = 1;
-- 生成 ReadView: m_ids=[100,200], min_trx_id=100, max_trx_id=201
-- 读取 DB_TRX_ID=50 < min_trx_id=100, 可见
-- 结果: name='Alice'

-- 事务B (trx_id=200) 此时执行并提交
-- BEGIN;
-- UPDATE users SET name = 'Bob' WHERE id = 1;
-- COMMIT;
-- 数据行 DB_TRX_ID 变为 200

-- 事务A 再次查询
SELECT * FROM users WHERE id = 1;
-- RR级别:复用之前的 ReadView
-- DB_TRX_ID=200, 在 m_ids=[100,200] 中,不可见
-- 沿版本链找到 DB_TRX_ID=50 的版本,可见
-- 结果: name='Alice'(实现了可重复读)

-- 如果是RC级别:
-- 重新生成 ReadView: m_ids=[100], min_trx_id=100, max_trx_id=201
-- DB_TRX_ID=200, 不在 m_ids 中且 < max_trx_id, 可见
-- 结果: name='Bob'(读到了最新提交的数据)

间隙锁(Gap Lock)与幻读解决

在RR隔离级别下,InnoDB通过间隙锁和临键锁(Next-Key Lock)在当前读场景下解决幻读问题。

锁的类型

graph TD
    A[InnoDB行级锁] --> B[Record Lock<br/>记录锁]
    A --> C[Gap Lock<br/>间隙锁]
    A --> D[Next-Key Lock<br/>临键锁]
    B --> B1[锁定索引记录本身]
    C --> C1[锁定索引记录之间的间隙]
    D --> D1[Record Lock + Gap Lock<br/>锁定记录及其前面的间隙]

Next-Key Lock 示例

假设表中有记录 id: 1, 5, 10, 15, 20,索引间隙为:

1
(-∞, 1] (1, 5] (5, 10] (10, 15] (15, 20] (20, +∞)
1
2
3
4
5
6
7
8
9
10
11
-- 事务A
BEGIN;
SELECT * FROM users WHERE id BETWEEN 5 AND 15 FOR UPDATE;
-- 加锁范围: (1, 5] (5, 10] (10, 15] (15, 20]
-- 即锁定了 id 在 (1, 20] 范围内的所有记录和间隙

-- 事务B
BEGIN;
INSERT INTO users (id, name) VALUES (8, 'Test'); -- 阻塞!8在间隙(5,10]中
INSERT INTO users (id, name) VALUES (12, 'Test'); -- 阻塞!12在间隙(10,15]中
INSERT INTO users (id, name) VALUES (25, 'Test'); -- 成功!25不在锁定范围内

等值查询的加锁规则

1
2
3
4
5
6
7
8
9
10
11
12
-- 唯一索引等值查询,记录存在
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 只加 Record Lock,不加 Gap Lock(因为唯一索引保证不会有重复)

-- 唯一索引等值查询,记录不存在
SELECT * FROM users WHERE id = 8 FOR UPDATE;
-- 加 Gap Lock: (5, 10)

-- 非唯一索引等值查询
-- 假设 idx_age 上有值: 20, 25, 25, 30
SELECT * FROM users WHERE age = 25 FOR UPDATE;
-- 加 Next-Key Lock: (20, 25] 和 Gap Lock: (25, 30)

快照读与当前读

理解MVCC,必须区分两种读取方式:

1
2
3
4
5
6
7
8
9
10
-- 快照读(Snapshot Read): 读取的是数据的快照版本,通过MVCC实现
SELECT * FROM users WHERE id = 1;

-- 当前读(Current Read): 读取的是数据的最新版本,会加锁
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- 排他锁
SELECT * FROM users WHERE id = 1 FOR SHARE; -- 共享锁(MySQL 8.0+)
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁(旧语法)
INSERT INTO users ...; -- 隐式当前读
UPDATE users SET ...; -- 隐式当前读
DELETE FROM users ...; -- 隐式当前读
graph TD
    A[SELECT语句] --> B{是否加锁?}
    B -->|否| C[快照读<br/>MVCC ReadView]
    B -->|是| D[当前读<br/>加锁读取最新版本]
    D --> E{FOR UPDATE?}
    E -->|是| F[排他锁 X Lock]
    E -->|否| G{FOR SHARE?}
    G -->|是| H[共享锁 S Lock]
    I[INSERT/UPDATE/DELETE] --> D

事务相关的实践建议

死锁检测与处理

1
2
3
4
5
6
7
8
9
10
11
-- 查看当前正在运行的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看锁等待情况
SELECT * FROM performance_schema.data_lock_waits;

-- 查看当前持有的锁
SELECT * FROM performance_schema.data_locks;

-- 死锁日志
SHOW ENGINE INNODB STATUS;

减少锁冲突的最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 1. 事务尽量短小,减少持锁时间
BEGIN;
-- 避免在事务中执行耗时操作(如RPC调用、文件IO)
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 2. 按固定顺序访问资源,避免死锁
-- 转账场景: 总是先锁ID小的账户
-- Good: 先锁 id=1, 再锁 id=2
-- Bad: 事务A先锁 id=1, 事务B先锁 id=2(可能死锁)

-- 3. 使用合理的索引,减少锁的范围
-- 没有索引时,UPDATE会锁全表
UPDATE orders SET status = 'shipped' WHERE user_id = 100;
-- 如果 user_id 没有索引 -> 全表扫描 -> 锁所有行
-- 如果 user_id 有索引 -> 只锁符合条件的行

-- 4. 设置合理的锁等待超时
SET innodb_lock_wait_timeout = 10; -- 默认50秒

长事务的危害

1
2
3
4
5
6
7
8
9
-- 查找运行时间超过60秒的事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

长事务的危害: 1. Undo log膨胀:长事务会阻止undo log的回收,导致存储空间持续增长 2. 锁占用时间长:增加锁冲突和死锁的概率 3. MVCC版本链过长:影响查询性能,需要遍历更多版本

总结

MySQL InnoDB的事务隔离机制是一个精巧的工程设计:

  • MVCC通过undo log版本链和ReadView机制,实现了快照读的无锁并发,极大提升了读写并发性能
  • RC和RR的核心区别在于ReadView的生成时机——RC每次SELECT生成,RR只在第一次SELECT生成
  • 间隙锁和临键锁在当前读场景下解决幻读问题,但也增加了死锁的风险
  • 实际使用中,需要合理设计事务,避免长事务,按固定顺序访问资源以减少死锁
  • InnoDB默认的RR隔离级别在大多数场景下提供了良好的一致性与并发性平衡,但在某些高并发场景下,RC级别可能更合适(减少间隙锁冲突)
作者 · authorzt
发布 · date2024-11-13
篇幅 · length3.0k 字 · 7 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论