一句话结论
MySQL InnoDB 通过 MVCC(多版本并发控制) 实现非锁定的一致性读——每个事务看到的是快照数据,读写互不阻塞。隔离级别决定了快照的"新鲜程度"。
核心原理
四大隔离级别
* InnoDB 的 REPEATABLE READ 通过临键锁基本解决了幻读。
MVCC 核心:Read View + Undo Log
每行数据隐含两个字段:
DB_TRX_ID: 最近修改这行的事务 ID
DB_ROLL_PTR: 指向 Undo Log 中旧版本的指针
Read View: 事务开始时创建的快照
- m_ids: 当前活跃事务列表
- min_trx_id: 最小活跃事务 ID
- max_trx_id: 下一个将分配的事务 ID
- creator_trx_id: 创建此 Read View 的事务 ID
可见性规则: 一行数据的 DB_TRX_ID 如果:
< min_trx_id→ 可见(在快照之前已提交)> max_trx_id→ 不可见(在快照之后才开始)在
m_ids中 → 不可见(活跃事务未提交)等于
creator_trx_id→ 可见(自己修改的)
REPEATABLE READ vs READ COMMITTED
项目中的应用
在 分布式电商交易系统 中,秒杀库存扣减用 SELECT ... FOR UPDATE 获取最新数据(当前读),而非快照读:
START TRANSACTION;
-- 当前读:加排他锁,读取最新已提交数据
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- stock = 10(最新值,不是快照)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
高频面试问题
Q: 当前读和快照读有什么区别?
30 秒回答: 快照读(普通 SELECT)读的是 MVCC 快照,不加锁。当前读(SELECT ... FOR UPDATE / UPDATE / DELETE)读最新已提交版本并加锁,保证读到的是最新的。
Q: InnoDB 怎么解决幻读?
30 秒回答: 快照读通过 MVCC 解决(同一事务内快照不变,看不到其他事务插入的新行)。当前读通过临键锁解决(锁定行 + 行之间的间隙,防止插入)。
最小实验
-- 会话 A: REPEATABLE READ
START TRANSACTION;
SELECT * FROM users WHERE id = 1; -- name='张三'
-- 会话 B: 修改并提交
UPDATE users SET name = '李四' WHERE id = 1;
COMMIT;
-- 会话 A: 再次查询
SELECT * FROM users WHERE id = 1; -- 仍然是 '张三'(快照读)
SELECT * FROM users WHERE id = 1 FOR UPDATE; -- '李四'(当前读)
速记
MVCC = Read View + Undo Log 链。RR 事务开始创快照,RC 每次查询刷新快照。快照读不加锁,当前读加锁并读最新。幻读:快照读靠 MVCC,当前读靠临键锁。
深入原理
一、Read View 创建时机(重要纠正)
常见误区: "REPEATABLE READ 下 Read View 在 BEGIN/START TRANSACTION 时创建。"
正确结论:RR 下 Read View 在第一次快照读时创建,不是 BEGIN 时。
-- 会话 A
START TRANSACTION; -- T1: 只标记事务开始,未创建 Read View
-- ... 其他事务在此期间提交 ...
SELECT * FROM users WHERE id = 1; -- T2: 第一次快照读,创建 Read View
-- 此时 Read View 记录 T2 时刻的活跃事务列表
-- 如果 T2 之前会话 B 提交了修改,会话 A 能读到吗?能!
-- 因为 Read View 在 T2 才创建,T2 之前已提交的修改对 Read View 是可见的。
-- 第二次同样是快照读,复用同一个 Read View(RR 关键特性)
SELECT * FROM users WHERE id = 1; -- T3: 复用 T2 的 Read View,结果不变
时间线(REPEATABLE READ 模式):
─────────────────────────────────────────────────────────
事务A: BEGIN 第一次SELECT 第二次SELECT 第三次SELECT
| | | |
| 不创建View | 创建View #1 | 复用View #1 | 复用View #1
| | (m_ids: [B,C]) | |
事务B: UPDATE COMMIT |
事务C: UPDATE ────|─── COMMIT
事务D: | UPDATE ────── COMMIT
|
此时 Read View 记录:
m_ids = [A, C] (B 已提交不在列表)
D 此时还不存在
结果:B 的修改可见(创建时已提交)
C 的修改不可见(创建时活跃)
D 的修改不可见(创建时还不存在)
READ COMMITTED 的区别: RC 每次快照读都创建新的 Read View,所以每次都能看到最新已提交的数据。
二、Undo Log 版本链遍历过程
当一行数据被多次修改,Undo Log 形成版本链:
当前行(聚簇索引叶子页):
┌──────────────────────────────────────────────┐
│ id=1 │ name='王五' │ DB_TRX_ID=300 │ DB_ROLL_PTR │──┐
└──────────────────────────────────────────────┘ │
▼
Undo Log 版本链:
┌─────────────────────────────────────────────────────┐
│ [Undo Log Record #2] │
│ 旧值: name='李四' │
│ DB_TRX_ID = 200 │
│ DB_ROLL_PTR ──────────────────────────┐ │
└────────────────────────────────────────┼────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ [Undo Log Record #1] │
│ 旧值: name='张三'(最初值) │
│ DB_TRX_ID = 100 │
│ DB_ROLL_PTR = NULL(链尾) │
└─────────────────────────────────────────────────────┘
可见性判断流程(快照读):
SELECT * FROM users WHERE id = 1;
→ 存储引擎读取聚簇索引中当前行
→ 检查 DB_TRX_ID = 300 对当前 Read View 是否可见
1. DB_TRX_ID = 300 < min_trx_id? → 否
2. DB_TRX_ID = 300 > max_trx_id? → 否
3. DB_TRX_ID = 300 在 m_ids 中? → 是(事务 300 活跃)→ 不可见!
→ 沿着 DB_ROLL_PTR 读取 Undo Log Record #2
→ 检查 DB_TRX_ID = 200 是否可见
1. DB_TRX_ID = 200 < min_trx_id? → 是 → 可见!
→ 返回 name='李四'
关键细节:
Undo Log 中的版本也包含
DB_TRX_ID,用于可见性判断如果遍历到链尾仍无可见版本 → 该行对当前事务不可见(如同不存在)
RR 事务中,一旦确定了可见版本,后续同一行的快照读直接返回该版本(因为 Read View 不变)
三、Purge 线程——清理旧版本
Undo Log 中的历史版本不可能永久保留。Purge 线程负责清理不再需要的旧版本。
Purge 工作流程:
1. 事务提交时,Undo Log 标记为"待清理"(而非立即删除)
2. Purge 线程定期扫描 Undo Log
3. 对每条 Undo Log Record,检查其 DB_TRX_ID:
- 如果当前系统中**没有任何活跃 Read View 需要这个版本** → 物理删除
- 如果仍有某个长事务的 Read View 的 min_trx_id 小于此 DB_TRX_ID → 保留
4. 清理后回收 Undo 表空间
为什么不是事务提交时立即删除?
→ 因为其他事务的快照读可能还需要这些旧版本(如 RR 的 Read View 在第一次SELECT时创建,后续SELECT仍需看到当时的数据)
→ 长事务会导致 Undo Log 膨胀(历史版本无法清理)
长事务的危害之一:Undo Log 堆积
-- 危险的长事务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1; -- 创建 Read View,min_trx_id = 1000
-- ... 执行了 1 小时的业务逻辑 ...
-- ... 期间 orders 表被修改了 100 万次 ...
-- ... 所有 Undo Log 版本都因为 min_trx_id=1000 而无法被 Purge 清理 ...
COMMIT; -- 此时 Purge 才能清理这些旧版本
四、当前读 vs 快照读完整对比
触发当前读的所有操作:
-- 1. 显示锁定读
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- 排他锁(X 锁)
SELECT * FROM products WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁(S 锁)MySQL 8.0+ 用 FOR SHARE
-- 2. DML 的"读取"阶段(读取待修改的行时触发当前读)
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 内部先当前读,再加 X 锁,再修改
DELETE FROM orders WHERE id = 100; -- 同上
INSERT INTO orders (...) VALUES (...); -- 检查唯一约束时触发当前读
-- 3. 外键检查
-- 插入子表行时,对父表引用行加 S 锁(当前读)
五、临键锁加锁规则(深入)
核心规则: 加锁的基本单位是 Next-Key Lock(临键锁),即 行锁 + 该行前面的间隙锁。
但原则上有两条退化规则:
唯一索引等值查询命中 → 退化为 Record Lock(只锁行,不加 Gap Lock)
等值查询未命中 → 退化为 Gap Lock(只锁间隙,不锁行)
加锁规则总结表:
| 索引类型 | 查询类型 | 是否命中 | 实际加的锁 | 示例 |
|---------|---------|---------|-----------|------|
| 主键/唯一索引 | 等值 | 命中 | Record Lock | WHERE id=10 |
| 主键/唯一索引 | 等值 | 未命中 | Gap Lock | WHERE id=7 (表中无7) |
| 普通索引 | 等值 | 命中 | Next-Key Lock + 额外的 Gap Lock | WHERE user_id=5 |
| 普通索引 | 等值 | 未命中 | Gap Lock | WHERE user_id=7 |
| 任意索引 | 范围 | — | Next-Key Lock 多段 | WHERE id>10 AND id<20 |
为什么唯一索引等值命中退化为行锁?
因为唯一索引保证不会插入重复值——既然值 10 已经存在了,绝不可能有另一个事务插入 id=10,所以不需间隙锁防插入。
为什么普通索引要加额外 Gap Lock?
因为普通索引的同值行之间可以插入新的同值行——如 user_id=5 有多行(不同主键),另一个事务可能插入新的 user_id=5 行,所以需要对 user_id=5 前后的间隙都加锁。
六、死锁检测:Wait-For Graph
InnoDB 通过等待图(Wait-For Graph)检测死锁:
死锁场景:
事务A: 持有 id=10 的 X 锁,等待 id=20 的 X 锁
事务B: 持有 id=20 的 X 锁,等待 id=10 的 X 锁
Wait-For Graph:
事务A ──等待──→ 事务B
↑ │
└──等待─────────┘
检测到环 → 死锁!
InnoDB 选择回滚"undo 量最小"的事务(通常是后开始的那个)
死锁检测算法: 每次事务等待锁时,InnoDB 启动深度优先搜索(DFS)遍历 Wait-For Graph,检测是否存在环。检测开销 O(N+E),其中 N 是事务数,E 是等待边数。
参数控制:
innodb_deadlock_detect = ON(默认),MySQL 8.0+ 支持关闭(极高并发时检测开销大)innodb_lock_wait_timeout = 50(秒),关闭死锁检测后靠超时释放
七、死锁案例代码 + 排查 + 解决
案例:秒杀库存扣减 + 订单插入导致死锁
-- 表结构
CREATE TABLE products (
id INT PRIMARY KEY,
stock INT NOT NULL
);
INSERT INTO products VALUES (1, 100), (2, 50);
-- 会话 A(扣减商品 1 和 2 的库存)
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 锁住 id=1
-- 此时 A 持有 id=1 的 X 锁
-- 会话 B(扣减商品 2 和 1 的库存)
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 锁住 id=2
-- 此时 B 持有 id=2 的 X 锁
-- 会话 A 继续
UPDATE products SET stock = stock - 1 WHERE id = 2; -- 等待 B 释放 id=2 → 阻塞
-- 会话 B 继续
UPDATE products SET stock = stock - 1 WHERE id = 1; -- 等待 A 释放 id=1 → 死锁!
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
排查命令:
-- 查看最近死锁详情(包含涉及的事务、SQL、锁信息)
SHOW ENGINE INNODB STATUS\G
-- 滚动到 LATEST DETECTED DEADLOCK 段落
-- 查看当前活跃事务
SELECT * FROM information_schema.INNODB_TRX\G
-- MySQL 8.0+ 查看锁等待关系
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query 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;
解决方案:
-- 方案 1:固定加锁顺序(最重要)
-- 规定所有事务必须先锁 id=1 再锁 id=2
-- ✅ 就不会出现 A 等 B、B 等 A 的情况
-- 方案 2:一次锁定所有需要的行
SELECT * FROM products WHERE id IN (1, 2) FOR UPDATE; -- 此时按主键顺序加锁
-- 方案 3:应用层重试
try {
// 执行事务
} catch (DeadlockLoserDataAccessException e) {
// 休眠随机毫秒后重试
Thread.sleep(random(10, 100));
retry();
}
在 分布式电商交易系统 中的死锁预防:
// 电商项目三 - 订单创建中的死锁预防
public void createOrder(Long userId, Long productId, Integer quantity) {
// 策略 1:先锁商品再锁库存——所有订单统一顺序
Product product = productMapper.selectForUpdate(productId); // 1. 先锁商品
Stock stock = stockMapper.selectForUpdate(product.getStockId()); // 2. 再锁库存
// 策略 2:事务尽量短——不在事务中做 RPC 调用、发消息、写日志
if (stock.getQuantity() >= quantity) {
stockMapper.decreaseStock(stock.getId(), quantity);
orderMapper.insert(new Order(userId, productId, quantity));
}
// 事务在此提交——不在事务中做其他耗时操作
}
八、四种隔离级别的实现方式
隔离级别强度 vs 并发性能:
READ UNCOMMITTED READ COMMITTED REPEATABLE READ SERIALIZABLE
弱 ←──────────────────────────────────────────────────────────────→ 强
高并发 低并发
[几乎不用] [Oracle/PostgreSQL默认] [MySQL InnoDB默认] [极端需求]
MySQL InnoDB 为什么默认 RR 而不是 RC?
历史原因:MySQL 5.0 之前 binlog 只有 STATEMENT 格式,RC + STATEMENT 会导致主从数据不一致(binlog 回放时的执行顺序与主库不同,导致结果不同)。RR 下同样的 SQL 看到同样的快照,主从一致。此问题在 ROW 格式 binlog 中不存在,但 RR 已沿用为默认。
九、项目三秒杀场景隔离级别选择
场景: 项目三-分布式电商交易系统 秒杀,高并发库存扣减。
推荐方案: 使用 READ COMMITTED + 乐观锁(或 Redis 扣库存)。
为什么不推荐 REPEATABLE READ?
RR 的临键锁在秒杀场景的副作用:
UPDATE products SET stock = stock - 1 WHERE id = 1;
→ 主键唯一索引等值命中 → 退化为 Record Lock → 只锁 id=1 这一行
BUT!如果秒杀还有其他查询如:
SELECT * FROM products WHERE stock > 0 FOR UPDATE;
→ 范围查询 → 加 Next-Key Lock 多段 → 大量间隙被锁 → 并发度骤降
RC 在秒杀中的优势:
间隙锁更少: RC 下只有行锁(Record Lock),无 Gap Lock,并发度更高
库存扣减用当前读:
SELECT ... FOR UPDATE在 RC 下同样读到最新已提交数据配合乐观锁:
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1—— 无 SELECT FOR UPDATE 的阻塞,靠 affected_rows 判断是否成功
// 项目三秒杀库存扣减 - RC + 乐观锁方案
@Transactional(rollbackFor = Exception.class)
public boolean decreaseStock(Long productId) {
// 直接 UPDATE + WHERE 条件,不加 SELECT FOR UPDATE
int affected = productMapper.decreaseStock(productId);
// UPDATE products SET stock = stock - 1
// WHERE id = #{productId} AND stock >= 1
return affected > 0; // affected=0 表示库存不足
}
但需要注意: RC 下 binlog 必须用 ROW 格式(不能用 STATEMENT),否则主从不一致。MySQL 5.7+ 默认就是 ROW 格式。
深度面试追问
Q1:RR 隔离级别下,一个事务内两次相同的 SELECT 返回不同结果,可能吗?
30 秒回答: 快照读不会不同(同一个 Read View)。但如果中间发生了当前读(如 SELECT ... FOR UPDATE),当前读看到的是最新数据,和快照读结果不同。
深入回答: RR 下快照读复用同一个 Read View,所以同一事务内多次普通 SELECT 结果一定相同。但如果事务内混合快照读和当前读,就会看到"不一致":
START TRANSACTION;
SELECT stock FROM products WHERE id = 1; -- 快照读 → stock=10
-- 另一个事务在此修改 stock 为 9 并提交
SELECT stock FROM products WHERE id = 1; -- 快照读 → 仍是 10(复用 Read View)
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 当前读 → stock=9(最新值)
这是正确的 MVCC 行为,不是 bug。业务中需要时用 FOR UPDATE 取得最新值。
追问 1: 为什么有人误以为 RR 下 UPDATE t SET c=c+1 会基于快照值更新?
UPDATE 内部先做当前读获取最新行——不会基于快照值。如果当前值是 10,更新后是 11,不会因为快照是 8 就更新成 9。
追问 2: RR 是否能完全解决幻读?
快照读层面:完全解决(Read View 不变)。当前读层面:临键锁基本解决,但仍有边缘情况——如事务 A
SELECT * WHERE id>10 FOR UPDATE锁了范围,事务 B 插入 id=11 被阻塞。看似解决了,但如果事务 A 只做快照读,事务 B 插入成功,然后事务 A 执行UPDATE t SET c=1 WHERE id>10(当前读),会发现多了一行——这是幻读的仅存场景。所以严格说 RR 解决了快照读的幻读,当前读的幻读绝大部分也解决了。
Q2:MySQL 的 MVCC 在 RR 和 RC 下具体差异是什么?
30 秒回答: 核心差异在 Read View 的创建时机。RR 在第一次快照读时创建 Read View,整个事务复用。RC 每次快照读都创建新的 Read View。
深入回答:
实现原理相同(都是 DB_TRX_ID + DB_ROLL_PTR + Undo Log),差异只在 Read View 的管理策略。
追问 1: 为什么 RC 更容易出现幻读?
RC 每次读创建新 Read View → 每次都能看到别的事务新插入的行。加上 RC 的当前读只加行锁不加间隙锁 → 其他事务可以插入新行 → 下次当前读会看到更多行。
追问 2: Oracle 默认 RC,PostgreSQL 默认 RC,为什么它们不需要 RR?
Oracle 的 MVCC 实现不同(UNDO 在回滚段,快照基于 SCN),PostgreSQL 的 MVCC 也不同(旧版本存在数据页内部)。它们的 RC 配合各自的多版本机制能满足大多数场景。但在 MySQL 中,如果应用依赖 binlog STATEMENT 格式,必须用 RR。
Q3:一个长事务(运行 1 小时)会有什么影响?
30 秒回答: 1) Undo Log 堆积(Purge 线程无法清理旧版本,可能导致 Undo 表空间无限膨胀);2) 锁不释放(持有的行锁/临键锁阻塞其他事务);3) 从库延迟(binlog 在事务提交时才写入,导致从库长时间收不到该事务的更新)。
深入回答:
长事务的连锁影响:
事务 A: BEGIN → SELECT (创建 Read View, min_trx_id = 100) → ... 1 小时后 COMMIT
1. Undo Log 膨胀:
这 1 小时内,其他事务修改的所有行的旧版本(DB_TRX_ID >= 100)
都因为事务 A 的 Read View 可能还需要 → Purge 线程无法清理
→ Undo Log 文件不断增长,可能耗尽磁盘
2. 锁持有时间:
如果事务 A 中执行了 UPDATE/DELETE/SELECT FOR UPDATE
→ 行锁/临键锁持有 1 小时 → 大量其他事务排队等待 → 连接池耗尽
3. 从库延迟:
MySQL binlog 在事务 COMMIT 时才写入
→ 1 小时内从库收不到任何该连接相关的 binlog 事件
→ 从库延迟增大(虽然其他事务正常同步)
4. 从库一致性:
如果从库并行复制,长事务提交瞬间可能产生大量的 binlog 事件
→ 从库回放压力大
追问 1: 如何监控和预防长事务?
监控:
SELECT * FROM information_schema.INNODB_TRX WHERE trx_started < NOW() - INTERVAL 60 SECOND;。预防:设置max_execution_time(MySQL 5.7+)限制单条 SQL 执行时间;设置innodb_lock_wait_timeout限制锁等待;应用层事务粒度最小化原则。
追问 2: 如果已经出现长事务,除了 KILL 还有什么办法?
无法"优雅"解决——只有 KILL 连接或等待它自己完成。KILL 后事务回滚可能也很耗时(Undo 量大)。这就是为什么要预防长事务:事务内不做 RPC、不发消息、不写文件、不做复杂计算。
Q4:既然 MVCC 这么好,为什么还需要锁?
30 秒回答: MVCC 解决读-写冲突(读写互不阻塞),但不能解决写-写冲突。当两个事务同时修改同一行时,必须用锁来串行化。
深入回答:
MVCC 能解决的:
事务A 读 X ──→ 事务B 同时写 X ──→ A 读到旧版本,不被阻塞 ✅
MVCC 不能解决的:
事务A 写 X ──→ 事务B 同时写 X ──→ 必须等 A 释放锁 ❌(写写冲突)
为什么写写冲突不能靠 MVCC?
→ 因为最终数据页上只能有 1 个版本
→ LOST UPDATE 问题:如果没有锁,A 和 B 同时读 stock=10,
A 改成 9,B 改成 8 —— 应该变成 7,但变成 8(后写入覆盖前写入)
所以总结:读-读无冲突;读-写用 MVCC 解决;写-写必须用锁解决。
追问 1: 那 UPDATE products SET stock = stock - 1 WHERE id = 1 是怎么避免写写冲突的?
UPDATE 内部流程:1) 对 id=1 加 X 锁(当前读);2) 读取最新 stock 值(假设 10);3) 计算 new_stock = 9;4) 写入。如果另一个 UPDATE 同时到达,它会在步骤 1 被阻塞,等待第一个事务提交释放锁。这样第二个 UPDATE 读到的是 9,改成 8——结果正确。
追问 2: 乐观锁(CAS)是不是不需要数据库锁?
乐观锁本质是应用层的"冲突检测"而非"冲突避免"。
UPDATE t SET v=v+1 WHERE id=1 AND v=10如果 affected_rows=0 说明冲突了,应用重试。数据库内部仍然要对这行加锁(UPDATE 的当前读阶段),但锁持有时间极短(毫秒级)。所以乐观锁并不消除锁,只是让锁持有时间极短。