MySQL MVCC 与锁机制:分工、局限与协同

一、核心概念速览

在深入分析之前,先明确两套机制的基础组件:

机制 核心组件 作用层面
MVCC undo log 版本链 + ReadView 读操作的并发隔离
锁机制 行锁、间隙锁、Next-Key Lock 写操作及当前读的并发控制

二、MVCC 解决的问题

2.1 底层数据结构

InnoDB 每行记录隐含三个系统列:

+-------------+--------+-----------+---------------------+
|  row data   | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID      |
+-------------+--------+-----------+---------------------+
       |           |          |
       |      最后修改    指向 undo log
       |      该行的    版本链的指针
       |      事务ID
       |
       v
  undo log 版本链(历史版本)
  [v3: trx=100]  [v2: trx=80]  [v1: trx=50]  NULL

2.2 ReadView 的生成规则

ReadView 是事务在某一时刻生成的"可见性快照",包含:

ReadView {
    m_ids:          [活跃事务ID列表,即未提交的事务]
    min_trx_id:     m_ids 中最小值
    max_trx_id:     下一个将分配的事务ID(当前最大已分配+1)
    creator_trx_id: 创建该 ReadView 的事务ID
}

可见性判断逻辑(遍历版本链,找到第一个满足条件的版本):

对于版本链中某个版本的 trx_id:

 trx_id == creator_trx_id          可见(自己修改的)
 trx_id < min_trx_id               可见(已经提交的旧事务)
 trx_id >= max_trx_id              不可见(ReadView创建后才开始的新事务)
 min_trx_id <= trx_id < max_trx_id:
       trx_id  m_ids           不可见(还未提交)
       trx_id 不在 m_ids         可见(已提交)

2.3 RC 与 RR 的 ReadView 差异(关键!)

隔离级别 ReadView 生成时机 效果
RC(Read Committed) 每次 SELECT 都生成新的 ReadView 能读到其他事务已提交的最新数据 → 存在不可重复读
RR(Repeatable Read) 事务第一次 SELECT 时生成,后续复用 整个事务看到的是同一快照 → 解决不可重复读

举例(RR 级别):

-- 初始:账户余额 = 1000
-- 事务A(trx_id=200)         事务B(trx_id=201)

BEGIN;                          BEGIN;
SELECT balance FROM account;
-- 生成 ReadView,m_ids=[200,201]
-- 读到余额=1000
                                UPDATE account SET balance=2000;
                                COMMIT;  -- trx_id=201 提交

SELECT balance FROM account;
-- 复用同一个 ReadView
-- 版本链: [balance=2000, trx_id=201] → [balance=1000, trx_id=50]
-- trx_id=201 在最初的 m_ids 中 → 不可见
-- 回退到 trx_id=50 的版本 → 读到余额=1000(可重复读)

2.4 MVCC 解决的核心问题总结

  • 读写不阻塞:读操作不需要加锁,写操作不阻塞读
  • 一致性快照读(Consistent Read):事务内看到稳定的数据视图
  • 解决不可重复读(RR 级别):同一事务多次读结果一致
  • 大幅提升读并发性能:普通 SELECT 零锁开销

三、锁机制解决的问题

3.1 锁的分类体系

InnoDB 锁体系
├── 行级锁(Row-Level Lock)
│   ├── Record Lock(记录锁):锁住具体某行
│   ├── Gap Lock(间隙锁):锁住两条记录之间的间隙,防止插入
│   └── Next-Key Lock = Record Lock + Gap Lock(默认RR级别使用)
├── 表级锁
│   ├── IS / IX(意向锁,自动加)
│   └── AUTO-INC Lock(自增锁)
└── 按模式分
    ├── S Lock(共享锁 / 读锁):SELECT ... LOCK IN SHARE MODE
    └── X Lock(排他锁 / 写锁):SELECT ... FOR UPDATE / UPDATE / DELETE

3.2 锁解决写写冲突

场景:两个事务同时扣减库存

-- 商品库存=10
-- 事务A                              事务B
BEGIN;
UPDATE stock SET num=num-1
  WHERE id=1;
-- 获得 id=1 的 X 行锁
                                    BEGIN;
                                    UPDATE stock SET num=num-1
                                      WHERE id=1;
                                    -- 请求 id=1 的 X 锁
                                    -- 被阻塞,等待事务A释放

COMMIT; -- 释放X锁
                                    -- 获得X锁,继续执行
                                    -- 读到最新值=9,再减1=8
                                    COMMIT;
-- 最终结果:8(正确)

若没有锁,两个事务都读到 10,各自写入 9,产生丢失更新

3.3 间隙锁解决当前读的幻读

场景(RR 级别): 某事务用 SELECT FOR UPDATE 查询,另一事务插入新行

-- 表中 id: 1, 5, 10(B+树索引)
-- 事务A                               事务B
BEGIN;
SELECT * FROM t
  WHERE id BETWEEN 5 AND 10
  FOR UPDATE;
-- 加 Next-Key Lock: (1,5] (5,10]
-- 同时间隙锁锁住 (1,5) (5,10) 之间的间隙
                                     BEGIN;
                                     INSERT INTO t VALUES(7, ...);
                                     -- 间隙(5,10)被锁,阻塞

-- 再次查询仍然只有 id=5,10
-- 幻读被阻止

3.4 锁机制解决的核心问题总结

  • 写写冲突:串行化写操作,防止覆盖更新
  • 当前读的数据安全:保证读到最新已提交数据
  • 防止丢失更新(Lost Update)
  • 防止当前读场景的幻读(间隙锁)
  • SERIALIZABLE 级别强隔离:所有读写全部加锁

四、为何不能只用其中一种?

4.1 只用 MVCC 的致命缺陷

缺陷 ①:无法解决写写冲突

MVCC 只生成数据的历史版本供读取,对并发写毫无约束

时间线:
T1: 事务A 读取 balance=1000(通过MVCC快照)
T2: 事务B 读取 balance=1000(通过MVCC快照)
T3: 事务A 写入 balance=800(扣了200)
T4: 事务B 写入 balance=900(扣了100)← 覆盖了A的修改!

最终 balance=900,但实际应该是 700(丢失更新)

MVCC 没有任何机制阻止 T4 的发生,因为它只管理读可见性,不管理写冲突

缺陷 ②:无法支持安全的"当前读"

业务上经常需要"读最新值再修改"的原子操作:

-- 需要当前最新值,不能用历史快照!
SELECT * FROM orders WHERE id=1 FOR UPDATE;
-- 如果用MVCC快照读,可能读到过期数据,导致基于旧数据的错误决策

UPDATEDELETESELECT FOR UPDATE 都属于当前读(current read),必须读取最新已提交版本,MVCC 做不到这一点。

缺陷 ③:RR 级别下快照读仍有幻读隐患

-- 事务A(RR级别,纯MVCC)
BEGIN;
SELECT COUNT(*) FROM t WHERE age > 18;  -- 快照读,结果=5
-- 事务B INSERT 一条 age=20 的记录并提交
INSERT INTO t (age) VALUES(20);

-- 事务A 执行写操作
UPDATE t SET flag=1 WHERE age > 18;
-- UPDATE 是当前读!会看到事务B新插入的行,实际影响6行
-- 但之前SELECT说只有5行 → 前后矛盾,幻读!

4.2 只用锁的致命缺陷

缺陷 ①:读操作全部加锁,并发性能崩溃

若用锁实现 RR 级别(类似 SERIALIZABLE),普通 SELECT 必须加 S 锁:

事务A: SELECT ... (加S锁,持续到事务结束)
事务B: UPDATE ... (请求X锁) → ❌ 被S锁阻塞
事务C: SELECT ... (再加S锁) → 虽然不阻塞,但锁资源膨胀

高并发下:
- 大量读事务持有S锁
- 写事务全部排队等待
- 吞吐量急剧下降,接近串行化执行

实测对比(TPC-C 场景,仅示意):

纯锁方案(读加S锁):吞吐量基准 = 1x
MVCC + 锁方案:吞吐量 ≈ 3~5x(读不加锁,极大减少锁竞争)

缺陷 ②:死锁概率大幅上升

读操作也加锁后,锁的持有时间更长、范围更广,形成死锁的路径呈指数级增长。

缺陷 ③:没有历史版本,长事务阻塞问题严重

无 MVCC 的情况下,长读事务会长时间持有 S 锁,后续所有写操作都被阻塞,系统实际上退化为近似串行执行。


五、两者如何协同工作

5.1 核心协作原则

┌─────────────────────────────────────────────────────┐
│  普通 SELECT(快照读)  → MVCC(ReadView + undo log) │
│                                                     │
│  写操作 / 当前读        → 锁机制(行锁 + 间隙锁)    │
│  (UPDATE/DELETE/        (保证读最新 + 防并发冲突)   │
│   INSERT/SELECT FORUPDATE/LOCK IN
│   SHARE MODE)
└─────────────────────────────────────────────────────┘

5.2 RC 隔离级别下的协同

-- RC 级别行为

事务A(trx=100)                   事务B(trx=101BEGIN;
SELECT * FROM t WHERE id=1;
-- 快照读:生成ReadView{m_ids=[100,101]}
-- 读version链找可见版本

                                   BEGIN;
                                   UPDATE t SET v=99 WHERE id=1;
                                   -- 加行级X锁
                                   COMMIT; -- 释放X锁

SELECT * FROM t WHERE id=1;
-- 快照读:RC级别重新生成ReadView
-- 新ReadView{m_ids=[100]}(101已不在活跃列表)
-- trx=101的版本可见 → 读到v=99(不可重复读,RC允许)

UPDATE t SET v=v+1 WHERE id=1;
-- 当前读:读最新已提交值v=99,加X锁
-- 写入v=100

5.3 RR 隔离级别下的协同

-- RR 级别行为(MySQL InnoDB 默认)

事务A(trx=200)                   事务B(trx=201BEGIN;
SELECT * FROM t WHERE id>5;
-- 快照读:生成ReadView{m_ids=[200,201]}
-- 不加任何锁 读符合条件的历史版本

                                   INSERT INTO t VALUES(7,...);
                                   COMMIT;

SELECT * FROM t WHERE id>5;
-- 快照读:复用同一ReadView
-- trx=201 在m_ids中 → 新行不可见 ✓(快照读层面的幻读被解决)

SELECT * FROM t WHERE id>5 FOR UPDATE;
-- 当前读 不用MVCC
-- 加 Next-Key Lock,锁住相关范围
-- 读到trx=201插入的新行(最新已提交)→ 看到了(幻读)
-- 但Next-Key Lock阻止了后续新插入,保证后续读一致性

5.4 SERIALIZABLE 级别

所有 SELECT 自动升级为 SELECT ... LOCK IN SHARE MODE
MVCC 基本不工作,完全依赖锁,并发性最低但隔离性最强

5.5 各隔离级别协同总结

隔离级别 普通 SELECT 写操作/当前读 MVCC 作用 锁作用
READ UNCOMMITTED 直接读最新行(无 MVCC/无锁) X 锁 不用 仅写锁
READ COMMITTED MVCC 快照读(每次新 ReadView) X 锁 防读阻塞 防写冲突
REPEATABLE READ MVCC 快照读(复用 ReadView) X 锁+间隙锁 可重复读 防写冲突+防幻读
SERIALIZABLE S 锁(退化为锁控制) X 锁+间隙锁 基本不用 全面串行化

六、结论

┌────────────────────────────────────────────────────────────────┐
│                      核心结论                                   │
│                                                                │
│  MVCC  = 让读操作"穿越时间"看历史快照                              │
│          → 解决:读写不阻塞、不可重复读、提升读并发                   │
│          → 无法解决:写写冲突、当前读安全、写场景幻读                 │
│                                                                │
│  锁机制 = 让写操作"互斥等待"保证顺序                                │
│          → 解决:写写冲突、当前读安全、防幻读(间隙锁)               │
│          → 单独使用:读操作也需加锁,并发性能大幅下降                 │
│                                                                │
│  两者协同 = 快照读走MVCC(零锁开销) + 当前读走锁(强一致保证)           │
│          → InnoDB 在 RR 级别下的最优解                            │
│          → 兼顾高并发读性能与写安全的工程平衡                        │
└────────────────────────────────────────────────────────────────┘

MVCC 是乐观的"时间机器",解决读的隔离问题;锁是悲观的"互斥等待",解决写的安全问题。两者各司其职、相互补充,共同构成 InnoDB 并发控制的完整体系。

Comments

No Data
Total 0
  • 1