在深入分析之前,先明确两套机制的基础组件:
| 机制 | 核心组件 | 作用层面 |
|---|---|---|
| MVCC | undo log 版本链 + ReadView | 读操作的并发隔离 |
| 锁机制 | 行锁、间隙锁、Next-Key Lock | 写操作及当前读的并发控制 |
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
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 中 → 可见(已提交)
| 隔离级别 | 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(可重复读)
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
场景:两个事务同时扣减库存
-- 商品库存=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,产生丢失更新。
场景(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
-- 幻读被阻止
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快照读,可能读到过期数据,导致基于旧数据的错误决策
UPDATE、DELETE、SELECT FOR UPDATE 都属于当前读(current read),必须读取最新已提交版本,MVCC 做不到这一点。
-- 事务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行 → 前后矛盾,幻读!
若用锁实现 RR 级别(类似 SERIALIZABLE),普通 SELECT 必须加 S 锁:
事务A: SELECT ... (加S锁,持续到事务结束)
事务B: UPDATE ... (请求X锁) → ❌ 被S锁阻塞
事务C: SELECT ... (再加S锁) → 虽然不阻塞,但锁资源膨胀
高并发下:
- 大量读事务持有S锁
- 写事务全部排队等待
- 吞吐量急剧下降,接近串行化执行
实测对比(TPC-C 场景,仅示意):
纯锁方案(读加S锁):吞吐量基准 = 1x
MVCC + 锁方案:吞吐量 ≈ 3~5x(读不加锁,极大减少锁竞争)
读操作也加锁后,锁的持有时间更长、范围更广,形成死锁的路径呈指数级增长。
无 MVCC 的情况下,长读事务会长时间持有 S 锁,后续所有写操作都被阻塞,系统实际上退化为近似串行执行。
┌─────────────────────────────────────────────────────┐
│ 普通 SELECT(快照读) → MVCC(ReadView + undo log) │
│ │
│ 写操作 / 当前读 → 锁机制(行锁 + 间隙锁) │
│ (UPDATE/DELETE/ (保证读最新 + 防并发冲突) │
│ INSERT/SELECT FOR
│ UPDATE/LOCK IN
│ SHARE MODE)
└─────────────────────────────────────────────────────┘
-- RC 级别行为
事务A(trx=100) 事务B(trx=101)
BEGIN;
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
-- RR 级别行为(MySQL InnoDB 默认)
事务A(trx=200) 事务B(trx=201)
BEGIN;
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阻止了后续新插入,保证后续读一致性
所有 SELECT 自动升级为 SELECT ... LOCK IN SHARE MODE
MVCC 基本不工作,完全依赖锁,并发性最低但隔离性最强
| 隔离级别 | 普通 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