深入剖析MYSQL MVCC多版本并发控制+ReadView视图快照规避幻读问题
一、剖析MVCC底层如何实现
1. MVCC 的目标
在高并发场景下,MVCC 实现:
能力 | 描述 |
---|---|
读写并发 | 读不会阻塞写,写也不会阻塞读(大多数 SELECT 是非阻塞的) |
一致性视图 | 每个事务看到的数据是其开始时的一致状态 |
高性能 | 避免频繁加锁,减少锁竞争开销 |
2. InnoDB 如何实现 MVCC
InnoDB 使用以下两部分实现 MVCC:
-
Undo Log(撤销日志):用于保存数据变更前的旧版本
-
ReadView(读视图):用于判断当前事务“能不能看到”某一行的某个版本
1. Undo Log:保存老版本数据
每当事务修改一条记录,InnoDB 会把修改前的值记录在 Undo Log 中:
UPDATE user SET age = 30 WHERE id = 1;
-- Undo Log: 保存 age = 20 的旧值
Undo Log 是行级的、链式结构,连接着该记录的所有历史版本,形成“版本链”。
每条数据记录会在内部隐藏字段中存储:
字段 | 作用 |
---|---|
DB_TRX_ID | 最后修改该记录的事务 ID(说白了,当前版本属于哪个事务) |
DB_ROLL_PTR | 回指 undo log 的指针,可沿指针“找老版本” |
DB_ROW_ID (隐藏主键) | 若无主键则生成的自增 ID |
示例:记录的多版本链
记录原始状态:age = 20
UPDATE age = 30 => Undo1: age = 20, trx_id=100UPDATE age = 40 => Undo2: age = 30, trx_id=101Undo1: age = 20, trx_id=100
记录链如下:
最新版本
┌──────────────┐
│ age = 40 │ trx_id = 102
│ roll_ptr → Undo2
└──────────────┘↓
Undo2: age=30, trx_id=101↓
Undo1: age=20, trx_id=100
2. ReadView:事务的可见性判断标准
当一个事务开始读取数据,InnoDB 会创建一个 ReadView(快照),它决定“哪些版本是我能看到的”。
ReadView 包含内容:
字段 | 含义 |
---|---|
m_ids | 当前活跃事务列表(未提交事务的 trx_id) |
min_trx_id | 活跃事务中最小的事务 ID |
creator_trx_id | 当前事务的 ID |
up_limit_id | 下限:min(m_ids) |
low_limit_id | 上限:最大事务 ID(+1) |
3. 可见性判断逻辑:
当事务 A 在 ReadView 中读取某条记录,InnoDB 按以下条件判断该记录版本是否可见:
设 X
是记录的 DB_TRX_ID
,表示这条记录是哪个事务写的:
条件 | 是否可见 | 含义 |
---|---|---|
X == 当前事务 ID | ✅ 可见 | 当前事务自己写的数据 |
X < min_trx_id | ✅ 可见 | 提交时间早于当前快照 |
X ∈ 活跃事务列表 m_ids | ❌ 不可见 | 还未提交的事务,不能看到 |
X > low_limit_id | ❌ 不可见 | 新事务,还没提交,不能看到 |
💡 若不满足可见性,InnoDB 会沿着
roll_ptr
追溯旧版本,直到找到符合 ReadView 的版本为止。
3. 完整示例(含快照机制)
-- 假设当前数据:
id = 1, age = 20, trx_id = 100-- 事务A:
START TRANSACTION; -- trx_id = 101
SELECT age FROM user WHERE id = 1; -- ReadView 创建-- 事务B:
START TRANSACTION; -- trx_id = 102
UPDATE user SET age = 30 WHERE id = 1;
COMMIT;-- 事务A再次读取:
SELECT age FROM user WHERE id = 1;
✅ 事务A两次读取都看到 age = 20,因为 ReadView 不包含事务102提交后的版本。
4. MVCC 的限制
限制项 | 描述 |
---|---|
只适用于 SELECT | DELETE , UPDATE , INSERT 仍然加锁 |
仅限 RC/RR 隔离级别 | READ UNCOMMITTED 不使用 MVCC,SERIALIZABLE 强制加锁 |
范围加锁仍需要间隙锁 | 并不能防止幻读,仍需 SELECT ... FOR UPDATE 配合 |
Undo 不是永久保存 | 被 purge 线程清理后,不可回溯旧版本 |
5. MVCC 工作流程图
事务A 事务B│ │┌────────────▼────────────────────▼────────────┐│ InnoDB 引擎层 ││ ││ +-------------------------------+ ││ | 当前记录(最新版本) | ││ | DB_TRX_ID = 102 | ← 写入 ││ | DB_ROLL_PTR → Undo2 | ││ +-------------------------------+ ││ ↓ ││ Undo2(trx_id=101) ← A 可见 ││ ↓ ││ Undo1(trx_id=100) │└──────────────────────────────────────────────┘A 使用 ReadView(trx_id=101),只看见 trx_id ≤ 100 的版本
6. 如何查看事务信息 / ReadView
-- 查看当前事务
SELECT * FROM information_schema.innodb_trx;-- 查看 undo 使用情况(8.0+)
SELECT * FROM performance_schema.data_locks;
7. 总结
组件 | 作用 |
---|---|
Undo Log | 存储旧版本,用于回滚与版本回溯 |
ReadView | 事务视图,决定“你能看到谁的版本” |
DB_TRX_ID | 表示记录是哪个事务写入的 |
DB_ROLL_PTR | 指向 undo,构成多版本链 |
可见性判断 | 是否在 ReadView 中“可见” = 是否为可读历史版本 |
8. 建议实践
场景 | 建议做法 |
---|---|
高并发下的数据一致性需求 | 开启 REPEATABLE READ ,使用 MVCC |
需要防止幻读 | 搭配 SELECT ... FOR UPDATE 使用 |
查询长时间读旧数据 | 合理配置 purge 线程参数,保留 undo |
手动事务控制 | 显式 START TRANSACTION 控制 ReadView 生命周期 |
二、深入剖析InnoDB可见性算法和 ReadView 构建机制
1. 可见性算法:解决什么问题
在多事务并发执行中:
-
同一条记录可能被多个事务读写;
-
每个事务只能看到符合自己视图的数据版本(避免读未提交/幻读等);
-
InnoDB 使用 MVCC + 可见性算法 决定你是否能“看见”一条记录的某个版本。
这个过程的核心就是:判断某个版本(记录的 trx_id)是否对当前事务可见?
2. 可见性算法核心逻辑(重点)
每条记录的当前版本都带有一个隐藏字段:
-
DB_TRX_ID
:最后修改该记录的事务 ID(也就是该版本由哪个事务创建) -
DB_ROLL_PTR
:指向 undo log 的指针,用于回溯历史版本
每个事务读取数据时,会使用当前事务生成的 ReadView 来判断版本可见性。
可见性判断规则(InnoDB):
设:
-
TRX_ID
:这条记录的版本是由哪个事务创建的; -
trx_id
:当前事务的 ID; -
ReadView.m_ids
:当前 ReadView 中的“活跃事务列表”(未提交); -
up_limit_id
= 最小活跃事务 ID; -
low_limit_id
= 当前系统中尚未分配的最小可用事务 ID;
可见性判断逻辑如下:
条件 | 是否可见 | 含义 |
---|---|---|
TRX_ID == trx_id | ✅ 是 | 当前事务自己创建的版本(最新) |
TRX_ID < up_limit_id | ✅ 是 | 创建者事务早于当前快照创建,且已提交 |
TRX_ID ∈ ReadView.m_ids | ❌ 否 | 创建者事务未提交,不可见 |
TRX_ID ≥ low_limit_id | ❌ 否 | 创建者事务尚未开始(并发之后的事务),不可见 |
3. ReadView 是什么?如何构建?
ReadView 是一个结构体,它描述了某个时刻所有活跃事务的快照,由 InnoDB 在以下场景中构建:
✅ 构建时机:
-
第一次执行
SELECT
(非锁定读取)时自动创建; -
若显式开启事务:
START TRANSACTION
+ 第一次查询时构建; -
若自动提交模式:每条
SELECT
都独立构建一个新的 ReadView。
✅ ReadView 包含的内容:
字段 | 含义 |
---|---|
creator_trx_id | 当前事务 ID |
up_limit_id | 活跃事务中最小的事务 ID |
low_limit_id | 当前系统中尚未分配的最小事务 ID(即最大已分配+1) |
m_ids[] | 当前所有活跃事务的 ID 列表(未提交的) |
这个结构决定了“哪些事务写的版本对我可见”。
✅ 构建流程:
-
当前事务在
trx_sys
中注册(分配trx_id
); -
遍历
trx_sys
全部事务列表,获取所有已开启但未提交事务,生成m_ids[]
; -
计算出
up_limit_id = min(m_ids)
,low_limit_id = 最大已分配事务ID + 1
; -
将
ReadView
绑定到当前事务的上下文; -
后续查询使用此
ReadView
判断版本可见性。
4. 可见性判断完整流程图解
+---------------------+| 当前事务 trx_id |+---------------------+|第一次执行 SELECT↓+---------------------+| 构建 ReadView ||---------------------|| m_ids: [101, 102] | ← 当前活跃事务ID| creator_id: 103 || up_limit_id: 101 || low_limit_id: 105 |+---------------------+↓遍历记录版本链(每条记录)↓对于记录 R,版本由 trx_id = X 创建:判断是否可见:┌────────────┬──────────────────────┐│ 条件 │ 是否满足 │├────────────┼──────────────────────┤│ X == 103 │ 当前事务写的版本 │ ✅│ X < 101 │ 老事务,已提交 │ ✅│ X ∈ m_ids[] │ 活跃未提交事务写的版本│ ❌│ X ≥ 105 │ 新事务,未开始 │ ❌└────────────┴──────────────────────┘
5. 可见性算法的代码层实现简析(源码级别)
InnoDB 实现版本可见性的核心代码在文件 row0sel.cc
中,核心函数为:
row_search_mvcc()
内部调用了:
row_sel_build_read_view_if_needed()
trx_is_active()
row_vers_build_for_consistent_read()
row_vers_old_has_index_entry()
可见性判断核心函数是:
row_vers_is_version_consistent()
它判断给定记录版本 trx_id
是否落在当前事务的 ReadView
可见范围内。
6. 实操观察:如何看到 ReadView / 事务状态
查询当前活跃事务(生成 ReadView 基础):
SELECT * FROM information_schema.innodb_trx;
输出字段包括:
-
trx_id
-
trx_started
-
trx_state
-
trx_query
查看当前版本使用的 undo 和锁状态:
SELECT * FROM performance_schema.data_locks;
7. ReadView 与隔离级别的关系
隔离级别 | ReadView 特性 | MVCC 是否启用 |
---|---|---|
READ UNCOMMITTED | 不创建 ReadView | ❌ |
READ COMMITTED | 每次查询生成新的 ReadView | ✅ |
REPEATABLE READ | 第一次查询创建,整个事务复用 | ✅ |
SERIALIZABLE | 强制加锁,不依赖 MVCC | ❌(或部分) |
8. 总结
组件 | 描述 |
---|---|
DB_TRX_ID | 记录的创建者事务 ID |
DB_ROLL_PTR | 指向旧版本 undo 链 |
ReadView | 当前事务能看到哪些事务写的版本 |
判断逻辑 | 本事务写的、已提交、比我老 → ✅可见 |
作用场景 | 非锁定读取(如 SELECT)使用可见性判断,不加锁 |
三、事务交叉并发操作导致事务视图冲突的场景
1. 环境准备
表结构:
CREATE TABLE user (id INT PRIMARY KEY,name VARCHAR(20),age INT
) ENGINE=InnoDB;
初始数据:
INSERT INTO user (id, name, age) VALUES (1, 'Alice', 20);
设置隔离级别为:REPEATABLE READ
(InnoDB 默认)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2. 事务交叉并发场景演练
场景 1:事务A看不到事务B提交的数据(MVCC 快照生效)
🧪 目标:验证事务A使用的是旧快照(ReadView)
-- 会话A:
START TRANSACTION;
SELECT age FROM user WHERE id = 1; -- 读取 age = 20(创建 ReadView)-- 会话B:
START TRANSACTION;
UPDATE user SET age = 30 WHERE id = 1;
COMMIT;-- 会话A(再次读取):
SELECT age FROM user WHERE id = 1; -- age = 20(依旧使用 ReadView)
COMMIT;
✅ 解释:
-
A 的
ReadView
在START TRANSACTION
+SELECT
时创建; -
B 的更新提交后虽然已经生效;
-
但 A 一直用的是旧快照,因此只能看到原值
20
; -
MVCC 生效,读一致性保证。
场景 2:事务A更新数据后立即读取到新值(自己可见)
🧪 目标:验证事务内部写入对自己始终可见
-- 会话A:
START TRANSACTION;
UPDATE user SET age = 40 WHERE id = 1; -- 修改成功
SELECT age FROM user WHERE id = 1; -- age = 40 ✅
COMMIT;
✅ 解释:
-
InnoDB 可见性规则中,
当前事务自己创建的版本
一定可见; -
不管 ReadView 怎么设置,DB_TRX_ID == 当前事务ID → 总是可见;
-
所以事务中写自己、读自己,肯定能看到最新的值。
场景 3:事务B无法看到A未提交的数据(读已提交验证)
🧪 目标:读未提交/读已提交隔离级别对可见性的影响
设置:
-- 会话B设置为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
执行流程:
-- 会话A
START TRANSACTION;
UPDATE user SET age = 50 WHERE id = 1;-- 会话B
START TRANSACTION;
SELECT age FROM user WHERE id = 1; -- 看到的是 40,A未提交
COMMIT;-- 会话A 提交
COMMIT;
✅ 解释:
-
A 修改后的新版本未提交,对其他事务不可见;
-
B 作为
READ COMMITTED
,只能看到已提交版本; -
所以仍然读到提交前的
age = 40
; -
实现了避免脏读的目标。
场景 4:事务A插入新记录,事务B看不到(可见性 + 幻读验证)
🧪 目标:验证 ReadView 快照不能看见并发 insert
-- 会话A:
START TRANSACTION;
SELECT * FROM user WHERE age > 10; -- 看到 id=1 一条记录-- 会话B:
START TRANSACTION;
INSERT INTO user (id, name, age) VALUES (2, 'Bob', 25);
COMMIT;-- 会话A(再次读取):
SELECT * FROM user WHERE age > 10; -- 仍然只有 id=1 ✅
COMMIT;
✅ 解释:
-
会话 A 的
ReadView
不包括 B 的事务; -
虽然 B 成功插入数据,但 A 的快照中根本“没这个事务”;
-
所以 A 看不到插入的新行,即使它“符合条件”;
-
这就是 MVCC 避免幻读的前提(非锁定读)。
场景 5:强制加锁,阻止幻读插入
🧪 目标:使用 SELECT ... FOR UPDATE
强制锁住间隙,阻止其他事务插入
-- 会话A:
START TRANSACTION;
SELECT * FROM user WHERE age > 10 FOR UPDATE; -- 加 next-key lock(间隙锁)-- 会话B:
START TRANSACTION;
INSERT INTO user (id, name, age) VALUES (3, 'Carl', 22); -- 🚫 阻塞 ❗
✅ 解释:
-
会话 A 加了 Next-Key Lock(行锁 + 间隙锁),锁住
age > 10
范围; -
B 插入的 age=22 落在该间隙中,被锁;
-
真正避免了幻读(防止新行插入);
-
这是防幻读最彻底的手段,比 MVCC 本身更强。
3. 事务视图冲突本质总结
冲突情况 | 是否可见 | 原因 |
---|---|---|
当前事务自己创建的记录 | ✅ 可见 | DB_TRX_ID == trx_id |
已提交事务创建的版本,在视图之前提交 | ✅ 可见 | MVCC 快照可见 |
并发事务创建的新版本 | ❌ 不可见 | 在 ReadView 中不可见 |
已提交的新事务写入,但 ReadView 早于它 | ❌ 不可见 | 快照不可见 |
幻读插入记录 | ❌(MVCC) / 🚫(加锁) | MVCC 屏蔽 / Next-Key Lock 阻止 |
4. 实操建议与最佳实践
场景 | 建议 |
---|---|
跨多次查询要保证数据一致 | 显式 START TRANSACTION ,复用 ReadView |
并发写入读操作多 | 用 MVCC + REPEATABLE READ |
精确防幻读 | 使用 SELECT ... FOR UPDATE 锁定间隙 |
高并发插入/更新 | 监控锁冲突,避免大范围查询加锁 |
实时读取最新数据 | 使用 READ COMMITTED 隔离级别 |
四、深入剖析 MySQL InnoDB 如何从底层解决幻读
我们需要从两个核心机制入手:
✅ MVCC(多版本并发控制) – 避免“读到别人新插入的数据”
✅ 间隙锁(Gap Lock) + Next-Key Lock – 阻止别人“插入我看不见的新数据”
1. 什么是幻读(Phantom Read)
幻读:在同一个事务中,两次相同条件的查询,返回的结果集不一样,而这个差异来自于其他事务新增的“符合条件”的行。
📍 示例:
-- 第一次查询
SELECT * FROM orders WHERE price > 100;-- 另一个事务插入一条新记录(price=200)-- 第二次查询,结果多了一行
这种“新冒出来的记录”,就叫“幻影行” —— 这就是幻读。
2. InnoDB 的两种防幻方式
手段 | 能否防幻 | 原理 |
---|---|---|
✅ MVCC + ReadView | 是(对普通非锁定查询) | 多版本快照,不读新事务创建的版本 |
✅ 间隙锁机制 | 是(对锁定读取) | 锁住查询范围,阻止插入/修改操作 |
3. MVCC 是如何防幻读的(“读时”控制)
在 REPEATABLE READ
下,InnoDB 对非加锁查询使用 MVCC(多版本)读取:
START TRANSACTION;
SELECT * FROM user WHERE age > 20; -- 第一次查询,生成 ReadView
-- 别的事务插入一行 age = 25
SELECT * FROM user WHERE age > 20; -- 第二次查询,不会看到新行 ✅
COMMIT;
✅ 原理:
-
第一次查询时创建
ReadView
-
后续所有查询都只读“老版本链”中对 ReadView 可见的版本
-
别人插入的是新版本,不在快照中,不可见
-
所以不会出现“幻影行”
⚠️ 局限:
MVCC 只是“读视图”避开幻读,并没有真正阻止幻读产生
👉 如果你是要做更新或删除操作,MVCC 是不够的!
4. Next-Key Lock 是如何防幻读的(“写时”控制)
✅ 什么是 Next-Key Lock?
InnoDB 为了防止幻读,在范围查询上加的锁叫做:
Next-Key Lock
= 行锁(Record Lock)+ 间隙锁(Gap Lock)
它会把查询范围内的“已有行 + 可能插入的新位置”都锁住!
📌 示例
-- 原表中 age 有 10, 20, 30 三条记录-- 查询:
SELECT * FROM user WHERE age > 15 FOR UPDATE;-- 加锁范围:
(15,20]、(20,30]、(30, +∞)
👉 所有可能插入符合 age > 15
条件的点,都被锁住了。
✅ 幻读控制流程图
范围:age > 15
┌─────┬────────┬────────┬────────────┐
│ 10 │ 20 │ 30 │ ∞ │
└─────┴────────┴────────┴────────────┘↑ ↑ ↑ ↑└─ (15,20] (20,30] (30,+∞) ← 都被加锁其他事务无法在这些“间隙”中插入新记录 ✅
🧪 示例验证
-- 会话A
START TRANSACTION;
SELECT * FROM user WHERE age > 15 FOR UPDATE;-- 会话B(阻塞 ❌)
INSERT INTO user (id, name, age) VALUES (4, 'Bob', 25); -- 被间隙锁阻止
🔒 会话 A 持有 (15, +∞)
的间隙锁,B 插入的 age = 25
正好落在间隙中,阻塞等待。
5. InnoDB 底层如何实现间隙锁(Gap Lock)
InnoDB 使用 B+ Tree 存储记录,查询过程中会:
-
扫描范围:定位满足
WHERE
条件的记录位置 -
对这些记录本身加行锁(Record Lock)
-
对这些记录之间的空隙,加间隙锁(Gap Lock)
🔍 B+ Tree 节点示意:
┌────────────┐
│ key = 10 │
├────────────┤
│ key = 20 │
├────────────┤
│ key = 30 │
└────────────┘
对于 SELECT ... WHERE key > 15 FOR UPDATE
:
-
加锁 key=20、30(Record Lock)
-
同时加锁 (15,20)、(20,30)、(30,+∞) 的间隙(Gap Lock)
6. 源码层简要机制
InnoDB 核心函数:
row_search_for_mysql()└─ row_sel_build_range_access_path()└─ sel_set_rec_lock() → 加锁逻辑
锁的结构体:
struct lock_t {ulint type_mode; // record/gap/next-keyrec_t* record;dict_index_t* index;
};
间隙锁和 next-key lock 会设置特定位标识,并与索引项绑定。
7. 实际对比测试(验证幻读与否)
❌ 幻读发生(未加锁)
-- A:
START TRANSACTION;
SELECT * FROM user WHERE age > 20;-- B:
INSERT INTO user (id, name, age) VALUES (10, 'New', 25);
COMMIT;-- A:
SELECT * FROM user WHERE age > 20; -- 多了一条 ✅,发生幻读
✅ 幻读避免(加锁)
-- A:
START TRANSACTION;
SELECT * FROM user WHERE age > 20 FOR UPDATE;-- B:
INSERT INTO user (id, name, age) VALUES (10, 'New', 25); -- 阻塞 ❌
8. 总结对比:幻读解决机制
机制 | 防止方式 | 场景 |
---|---|---|
MVCC + ReadView | 不读新插入版本 | 普通非锁定查询(如 SELECT) |
Next-Key Lock | 直接阻止插入 | 范围查询+更新/删除等操作 |
间隙锁 | 锁定插入位置 | 范围内插入被阻塞 |
行锁 | 仅锁定已有记录 | 无法防止幻读 |
9. 最佳实践
应用场景 | 推荐写法 |
---|---|
严格防止幻读(如金融场景) | 使用 SELECT ... FOR UPDATE |
普通查询,关注一致性 | 使用 REPEATABLE READ 即可 |
插入并发冲突 | 配合唯一约束 + 锁 |
范围更新/删除 | 明确加锁防幻插入 |
五、典型的幻读场景分析
1. 幻读测试准备
-- 创建测试表
CREATE TABLE user (id INT PRIMARY KEY,name VARCHAR(20),age INT
) ENGINE=InnoDB;-- 插入初始数据
INSERT INTO user VALUES (1, 'Alice', 20);
INSERT INTO user VALUES (2, 'Bob', 25);
设置隔离级别为默认的 REPEATABLE READ
(InnoDB 默认使用 MVCC + Next-Key Lock)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2. 测试1:普通 SELECT 查询是否会发生幻读?(MVCC 生效)
🎯 目标:
-
测试非加锁读是否看到新插入的符合条件的记录?
-
用于演示 MVCC 防止幻读
🧵 会话A:
START TRANSACTION;SELECT * FROM user WHERE age > 22;
-- 返回:id=2, Bob, 25(仅 1 条)-- 此时生成 ReadView 快照
🧵 会话B:
START TRANSACTION;INSERT INTO user VALUES (3, 'Carl', 30); -- 满足 age > 22
COMMIT;
🧵 会话A(再次查询):
SELECT * FROM user WHERE age > 22;
-- 结果仍然是:id=2, Bob, 25 ✅
-- 并未出现 Carl 的记录
✅ 结果分析:
-
幻读 没有发生
-
虽然会话B插入了一条符合条件的记录,但事务A使用的是快照 ReadView
-
新插入的版本不在当前事务快照内,不可见
3. 测试2:使用 FOR UPDATE
显式加锁是否能阻止插入?(防幻插入)
🎯 目标:
-
测试
SELECT ... FOR UPDATE
是否会锁住插入间隙 -
演示 间隙锁 + Next-Key Lock 阻止插入
🧵 会话A:
START TRANSACTION;SELECT * FROM user WHERE age > 22 FOR UPDATE;
-- 会锁住已有记录(age=25),以及它之后的间隙(25,+∞)
🧵 会话B:
START TRANSACTION;INSERT INTO user VALUES (4, 'David', 28); -- 落入间隙范围-- ⚠️ 阻塞!等待锁释放
🧵 会话A:
COMMIT;
🧵 会话B:
-- 插入现在才完成
✅ 结果分析:
-
会话A加了 Next-Key Lock(间隙锁 + 行锁)
-
插入操作被“锁定范围”阻塞
-
成功防止了幻读插入(不仅不可读,还不可写)
4. 测试3:用 READ COMMITTED
+ 非加锁 SELECT 会发生幻读
🎯 目标:
-
测试低隔离级别
READ COMMITTED
会不会看到新插入的行 -
验证在非加锁读下幻读是可能的
🧵 会话A:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;SELECT * FROM user WHERE age > 22;
-- 返回 id=2 (Bob, 25)
🧵 会话B:
START TRANSACTION;
INSERT INTO user VALUES (5, 'Eva', 26); -- age > 22
COMMIT;
🧵 会话A:
SELECT * FROM user WHERE age > 22;
-- ❗ 返回两条记录:Bob + Eva
-- 发生幻读了
✅ 结果分析:
-
因为
READ COMMITTED
隔离级别每次查询都重新构建 ReadView -
所以新插入的记录对后续查询可见
-
幻读确实发生了
5. 总结对比
测试编号 | 场景 | 是否幻读 | 原因分析 |
---|---|---|---|
1 | REPEATABLE READ + 非锁定查询 | ❌ 没有 | MVCC + 快照一致性,插入对快照不可见 |
2 | FOR UPDATE 锁定查询范围 | ❌ 没有 | Next-Key Lock 锁住插入间隙,插入阻塞 |
3 | READ COMMITTED + 普通查询 | ✅ 有 | 每次新 ReadView,插入的新数据对后续查询可见 |
6. 实战建议
需求场景 | 推荐手段 |
---|---|
保证两次查询结果一致 | 使用 REPEATABLE READ 事务 |
严格禁止“条件插入”导致幻读 | 使用 SELECT ... FOR UPDATE |
查询实时数据 | 使用 READ COMMITTED |
高并发插入更新场景 | 控制锁粒度、加唯一索引防止锁冲突 |
六、剖析INFORMATION_SCHEMA
的 3 张核心锁监控表
要深入了解 InnoDB 锁的状态与机制,就必须掌握如何实时查看、分析事务加锁情况、阻塞状态、锁等待链等信息。
MySQL 提供了 INFORMATION_SCHEMA
的 3 张核心锁监控表 —— INNODB_LOCKS
, INNODB_LOCK_WAITS
, INNODB_TRX
,可以结合它们分析锁争用与事务阻塞。
1. 如何启用锁监控视图
从 MySQL 5.5 起,InnoDB 引入了锁监控表。
查看当前事务锁信息:
SELECT * FROM information_schema.innodb_locks;
SELECT * FROM information_schema.innodb_lock_waits;
SELECT * FROM information_schema.innodb_trx;
⚠️ 注意:
-
这些表只会有活动事务产生数据(即:有加锁且未提交的事务)
-
若你看到为空,说明当前无持锁事务
2. 3 张视图的作用详解
视图名称 | 作用 |
---|---|
INNODB_TRX | 展示当前所有活跃事务(事务ID、状态、运行时间、锁等待等) |
INNODB_LOCKS | 展示所有持有或等待的锁(锁类型、锁对象、模式) |
INNODB_LOCK_WAITS | 展示锁等待关系(谁等待谁) |
3. 实战演练:两个事务竞争导致阻塞
我们创建以下环境用于演示:
CREATE TABLE t_lock_demo (id INT PRIMARY KEY,val VARCHAR(20)
) ENGINE=InnoDB;INSERT INTO t_lock_demo VALUES (1, 'a'), (2, 'b');
🧵 会话A:
START TRANSACTION;
SELECT * FROM t_lock_demo WHERE id = 1 FOR UPDATE;
-- A事务锁住了 id = 1 这一行
🧵 会话B:
START TRANSACTION;
UPDATE t_lock_demo SET val = 'z' WHERE id = 1;
-- ⚠️ 阻塞!因为等待 A 释放锁
🔍 这时我们执行:
SELECT * FROM information_schema.innodb_trx\G
SELECT * FROM information_schema.innodb_locks\G
SELECT * FROM information_schema.innodb_lock_waits\G
4. 字段详解与分析技巧
✅ INNODB_TRX
核心字段
字段名 | 含义 |
---|---|
trx_id | 当前事务 ID(MySQL 内部 ID) |
trx_state | 事务状态,如 RUNNING , LOCK WAIT 等 |
trx_started | 事务开始时间 |
trx_mysql_thread_id | MySQL 线程 ID(可用来 KILL ) |
trx_query | 当前事务执行的 SQL 语句 |
trx_wait_started | 若等待中,等待开始时间 |
trx_rows_locked | 已加锁行数 |
🧠 Tips:
-
有事务为
LOCK WAIT
状态,说明它正在等待其他事务释放锁。
✅ INNODB_LOCKS
核心字段
字段名 | 含义 |
---|---|
lock_id | 锁的唯一标识 ID |
lock_mode | 锁模式(如 X , S , IX , IS 等) |
lock_type | RECORD 、TABLE (行锁/表锁) |
lock_table | 锁在哪张表 |
lock_index | 锁在哪个索引上(通常为主键或二级索引) |
lock_data | 被锁住的主键值(记录锁)或索引范围(间隙锁) |
🧠 Tips:
-
多个事务锁定相同
lock_data
,说明竞争发生在这行。
✅ INNODB_LOCK_WAITS
核心字段
字段名 | 含义 |
---|---|
requesting_trx_id | 正在等待锁的事务 ID |
blocking_trx_id | 持有锁的事务 ID |
requested_lock_id | 等待中的锁 ID |
blocking_lock_id | 阻塞它的锁 ID |
🧠 Tips:
-
这是分析死锁/阻塞链的关键视图!
5. 组合查询分析事务阻塞链(推荐 SQL)
1️⃣ 查询所有活跃事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx;
2️⃣ 查询锁等待关系(谁阻塞谁):
SELECT r.trx_id AS waiting_trx,r.trx_mysql_thread_id AS waiting_thread,r.trx_query AS waiting_query,b.trx_id AS blocking_trx,b.trx_mysql_thread_id AS blocking_thread,b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id;
6. 实战场景分析(输出样例)
-- innodb_trx:trx_id: 1234
trx_state: LOCK WAIT
trx_query: UPDATE t_lock_demo SET val='z' WHERE id = 1;trx_id: 1233
trx_state: RUNNING
trx_query: SELECT * FROM t_lock_demo WHERE id=1 FOR UPDATE;-- innodb_lock_waits:requesting_trx_id: 1234
blocking_trx_id: 1233-- innodb_locks:lock_id: 1233:45:3
lock_mode: X
lock_type: RECORD
lock_table: test/t_lock_demo
lock_data: 1
🧠 分析:
-
事务1233 已锁住了
id = 1
,持有X
锁 -
事务1234 想更新同一行,被阻塞
-
阻塞链清晰、可追踪
7. 运维实战建议
目标 | 工具/建议 |
---|---|
观察事务是否阻塞 | 查看 innodb_trx |
分析锁等待链 | 联表 innodb_trx + lock_waits |
分析哪个键被锁住 | innodb_locks.lock_data |
手动终止阻塞事务 | KILL <thread_id> |
定期排查锁争用 | 使用 performance_schema.events_waits_current |
8. 总结
工具 | 用途 |
---|---|
INNODB_TRX | 显示当前事务状态 |
INNODB_LOCKS | 显示每个事务持有/等待的锁 |
INNODB_LOCK_WAITS | 显示锁等待依赖关系(谁等谁) |
联表分析(推荐) | 快速定位死锁/阻塞事务链 |
performance_schema | MySQL 8.0 更强锁监控 |