摘要:从面试官的连环追问出发,深度剖析MySQL事务并发的三大经典问题。通过真实的SQL并发演示、MVCC实现原理、以及可重复读隔离级别下的幻读解决方案,揭秘快照读与当前读的本质区别、间隙锁如何防止幻读、以及为什么一条UPDATE不是原子性的。配合时序图展示并发执行流程,给出不同场景下的事务隔离级别选择建议。
💥 面试现场
哈吉米的三面,技术总监亲自面。
技术总监:"聊聊MySQL事务吧。事务的特性是什么?如何实现的?"
哈吉米:"ACID,原子性、一致性、隔离性、持久性……"
技术总监:"嗯,那MySQL可能出现哪些并发问题?"
哈吉米:"脏读、不可重复读、幻读。"
技术总监:"举个例子,哪些场景不适合脏读?"
哈吉米:"呃……转账?"
技术总监:"MySQL是怎么解决这些并发问题的?"
哈吉米:"MVCC……"
技术总监:"可重复读隔离级别下,A事务提交的数据,在B事务看来会怎样呢?"
哈吉米:"……"(懵了)
技术总监:"那一条UPDATE是原子性的吗?为什么?"
哈吉米:"……"(完全不会)
晚上,哈吉米又找南北绿豆和阿西噶阿西复盘。
哈吉米:"我被问懵了,这些问题我都没想过!"
南北绿豆:"来,今晚给你讲透彻!"
🤔 问题1:哪些场景不适合脏读?举个例子
面试标准答案:
所有场景都不适合脏读!因为脏读会读到未提交的数据,可能导致严重的数据不一致。
反例1:转账业务
-- 场景:A给B转账100元
-- 事务A(转账)
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE user_id = 'A'; -- A余额减100
UPDATE account SET balance = balance + 100 WHERE user_id = 'B'; -- B余额加100
-- 还没提交...
-- 事务B(查询A的余额)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 脏读
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 'A';
-- 结果:900(读到了事务A未提交的数据)
-- 如果这时候事务A回滚了
ROLLBACK; -- 事务A回滚,A的余额恢复成1000
-- 问题:事务B读到的900是"脏数据",实际上A的余额是1000!
危害:
- 用户看到余额是900,但实际是1000
- 基于900做决策(如余额不足提示),但实际余额够
反例2:库存扣减
-- 事务A(扣减库存)
START TRANSACTION;
UPDATE stock SET num = num - 10 WHERE product_id = 1001; -- 库存减10
-- 还没提交,可能要回滚(支付失败)
-- 事务B(查询库存)
SELECT num FROM stock WHERE product_id = 1001;
-- 读到了扣减后的库存(脏读)
-- 事务A回滚
ROLLBACK;
-- 问题:事务B读到的库存是错的,可能显示"库存不足",但实际库存够
反例3:订单状态
-- 事务A(更新订单状态)
START TRANSACTION;
UPDATE `order` SET status = 1 WHERE order_id = 100001; -- 改成"已支付"
-- 还没提交,可能要回滚(银行扣款失败)
-- 事务B(查询订单状态)
SELECT status FROM `order` WHERE order_id = 100001;
-- 读到status=1(脏读)
-- 事务A回滚
ROLLBACK;
-- 问题:事务B读到"已支付",但实际订单还是"待支付",可能发货了
南北绿豆:"看到了吗?脏读在任何业务场景都很危险,所以READ UNCOMMITTED基本不用!"
🤔 问题2:MySQL是怎么解决并发问题的?
面试标准答案:
通过隔离级别 + MVCC + 锁机制。
解决方案对比
| 并发问题 | 解决方式 | 实现机制 |
|---|---|---|
| 脏读 | READ COMMITTED及以上 | MVCC(不读未提交的版本) |
| 不可重复读 | REPEATABLE READ及以上 | MVCC(Read View只生成一次) |
| 幻读 | REPEATABLE READ + 间隙锁 | Next-Key Lock |
脏读的解决:MVCC
-- READ COMMITTED隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 事务A
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 'A';
-- 未提交
-- 事务B
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 'A';
-- 结果:1000(读不到事务A未提交的900)
-- 原理:MVCC的Read View判断trx_id=A还在活跃列表中,不可见
不可重复读的解决:RR + MVCC
-- REPEATABLE READ隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务B
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 'A'; -- 结果:1000
-- 生成Read View(只生成一次)
-- 事务A
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 'A';
COMMIT; -- 提交了
-- 事务B再次查询
SELECT balance FROM account WHERE user_id = 'A'; -- 结果:仍然是1000
-- 原理:RR隔离级别只生成一次Read View,看不到事务A的修改
幻读的解决:间隙锁
-- 事务A
START TRANSACTION;
SELECT * FROM account WHERE id > 5 AND id < 15 FOR UPDATE; -- 当前读,加锁
-- 事务B
INSERT INTO account (id, balance) VALUES (10, 500);
-- 阻塞(间隙锁防止插入)
-- 原理:Next-Key Lock锁住了(5, 15)的间隙,防止插入
时序图:
sequenceDiagram
participant TxnA as 事务A
participant GapLock as 间隙锁(5,15)
participant TxnB as 事务B
TxnA->>GapLock: SELECT ... FOR UPDATE<br/>加间隙锁(5,15) ✅
TxnB->>GapLock: INSERT id=10<br/>请求插入...
Note over TxnB: 阻塞(间隙被锁)
TxnA->>TxnA: COMMIT(释放锁)
GapLock->>TxnB: 获取锁 ✅
TxnB->>TxnB: INSERT成功
🤔 问题3:可重复读隔离级别下,A事务提交的数据,在B事务看来会怎样?
面试标准答案:
分情况讨论:快照读 vs 当前读。
情况1:快照读(普通SELECT) → 看不到
-- 事务B
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 'A'; -- 结果:1000
-- 生成Read View
-- 事务A
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 'A';
COMMIT; -- 提交了
-- 事务B再次查询(快照读)
SELECT balance FROM account WHERE user_id = 'A';
-- 结果:仍然是1000(看不到事务A的修改)
-- 原因:RR隔离级别,Read View只生成一次,看到的是快照版本
情况2:当前读(FOR UPDATE) → 能看到
-- 事务B
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 'A' FOR UPDATE; -- 当前读
-- 结果:1000
-- 事务A
START TRANSACTION;
UPDATE account SET balance = 900 WHERE user_id = 'A';
COMMIT; -- 提交了
-- 事务B再次查询(当前读)
SELECT balance FROM account WHERE user_id = 'A' FOR UPDATE;
-- 结果:900(能看到事务A的修改)
-- 原因:当前读读取的是最新数据,不走MVCC
对比总结
| 读取方式 | 能否看到其他事务已提交的修改 | 实现机制 |
|---|---|---|
| 快照读(SELECT) | ❌ 看不到 | MVCC(Read View固定) |
| 当前读(FOR UPDATE) | ✅ 能看到 | 加锁,读最新数据 |
南北绿豆:"这就是为什么有时候你觉得数据'不对',因为你看到的是快照版本!"
🤔 问题4:MySQL设置了可重复读隔离级别后,怎么保证不发生幻读?
面试标准答案:
通过Next-Key Lock(间隙锁 + 行锁)防止幻读。
什么是幻读?
-- 事务A
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE balance > 500; -- 结果:3行
-- 事务B
START TRANSACTION;
INSERT INTO account (id, balance) VALUES (100, 1000);
COMMIT;
-- 事务A再次查询
SELECT COUNT(*) FROM account WHERE balance > 500; -- 结果:4行(幻读!)
快照读不会幻读
-- 事务A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE balance > 500; -- 结果:3行(快照读)
-- 事务B
INSERT INTO account (id, balance) VALUES (100, 1000);
COMMIT;
-- 事务A再次查询(快照读)
SELECT COUNT(*) FROM account WHERE balance > 500;
-- 结果:仍然是3行(不会幻读)
-- 原因:MVCC的Read View固定,看不到事务B插入的数据
当前读用间隙锁防止幻读
-- 事务A
START TRANSACTION;
SELECT * FROM account WHERE balance > 500 FOR UPDATE; -- 当前读,加间隙锁
-- 结果:3行
-- 事务B
INSERT INTO account (id, balance) VALUES (100, 1000);
-- 阻塞(间隙锁防止插入)
-- 事务A再次查询
SELECT * FROM account WHERE balance > 500 FOR UPDATE;
-- 结果:仍然是3行(不会幻读)
-- 原因:间隙锁防止了事务B插入数据
总结:
| 读取方式 | 是否会幻读 | 实现机制 |
|---|---|---|
| 快照读 | ❌ 不会 | MVCC(Read View固定) |
| 当前读 | ❌ 不会 | 间隙锁(防止插入) |
🤔 问题5:串行化隔离级别是通过什么实现的?
面试标准答案:
通过加锁,退化成串行执行。
串行化的加锁规则
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 事务A
START TRANSACTION;
SELECT * FROM account WHERE id = 1; -- 普通SELECT也会加锁
-- 实际执行:
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE; -- 自动加共享锁
-- 事务B
UPDATE account SET balance = 900 WHERE id = 1;
-- 阻塞(等待事务A释放锁)
加锁机制
| 操作 | 加锁类型 |
|---|---|
| SELECT | 共享锁(S锁) |
| INSERT | 排他锁(X锁) |
| UPDATE | 排他锁(X锁) |
| DELETE | 排他锁(X锁) |
时序图:
sequenceDiagram
participant TxnA as 事务A
participant Lock as 锁
participant TxnB as 事务B
Note over TxnA,TxnB: SERIALIZABLE隔离级别
TxnA->>Lock: SELECT ... <br/>获取共享锁S ✅
TxnB->>Lock: UPDATE ...<br/>请求排他锁X
Note over TxnB: 阻塞(S锁和X锁冲突)
TxnA->>TxnA: COMMIT(释放S锁)
Lock->>TxnB: 获取X锁 ✅
TxnB->>TxnB: 执行UPDATE
Note over TxnA,TxnB: 退化成串行执行
性能影响:
| 隔离级别 | 并发性能 | 适用场景 |
|---|---|---|
| READ UNCOMMITTED | ⭐⭐⭐⭐⭐ | ❌ 不推荐 |
| READ COMMITTED | ⭐⭐⭐⭐ | 统计报表 |
| REPEATABLE READ | ⭐⭐⭐ | ✅ 推荐(默认) |
| SERIALIZABLE | ⭐ | 极少使用(性能太差) |
南北绿豆:"SERIALIZABLE通过加锁保证绝对串行,但性能极差,基本不用。"
🤔 问题6:介绍MVCC实现原理
面试标准答案(结合第4篇文章):
MVCC的三大组件:
1. 隐藏字段
每行数据的隐藏字段:
- DB_TRX_ID:最后修改这行的事务ID
- DB_ROLL_PTR:回滚指针,指向undo log中的历史版本
- DB_ROW_ID:隐藏主键(如果表没有主键)
2. undo log版本链
版本链示例:
最新版本(balance=800, trx_id=200)
↓ (roll_ptr)
历史版本1(balance=900, trx_id=100)
↓ (roll_ptr)
历史版本2(balance=1000, trx_id=80)
↓
NULL
3. Read View(读视图)
// Read View结构
{
creator_trx_id: 250, // 当前事务ID
m_ids: [100, 200, 250], // 活跃事务列表
min_trx_id: 100, // 最小活跃事务ID
max_trx_id: 251 // 下一个事务ID
}
可见性判断:
读取某个版本(trx_id=X):
1. 如果 X == creator_trx_id → 可见(自己改的)
2. 如果 X < min_trx_id → 可见(早就提交了)
3. 如果 X >= max_trx_id → 不可见(还没创建)
4. 如果 X in m_ids → 不可见(未提交)
5. 否则 → 可见(已提交)
阿西噶阿西:"MVCC通过版本链和Read View,让不同事务看到不同版本的数据,避免加锁!"
🤔 问题7:一条UPDATE是原子性的吗?为什么?
面试标准答案:
是原子性的,但并发时可能出现问题。
UPDATE的执行流程
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
单个UPDATE的原子性:
graph LR
A[开始事务] --> B[加行锁]
B --> C[读取balance=1000]
C --> D[计算:1000-100=900]
D --> E[写入balance=900]
E --> F[写undo log]
F --> G[写redo log]
G --> H[提交事务]
H --> I[释放锁]
Note[单个UPDATE是原子的<br/>要么全做,要么全不做]
style Note fill:#90EE90
单个UPDATE是原子的:
- ✅ 读取、计算、写入是一个整体
- ✅ 如果失败,整个UPDATE回滚
- ✅ 不会出现"读了balance但没写入"的情况
但并发时有问题(查询和更新不是原子)
-- 转账业务(两条SQL)
START TRANSACTION;
-- SQL1:查询余额
SELECT balance FROM account WHERE user_id = 'A'; -- balance = 1000
-- SQL2:扣减余额
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
COMMIT;
并发问题:
| 时间 | 事务A | 事务B |
|---|---|---|
| T1 | SELECT balance=1000 | |
| T2 | SELECT balance=1000 | |
| T3 | UPDATE balance=900 | |
| T4 | UPDATE balance=900 ❌(覆盖了A的修改) |
问题:SELECT和UPDATE之间不是原子的,有时间窗口。
解决方案
方案1:用一条SQL完成(推荐)
-- ✅ 一条SQL,原子性
UPDATE account
SET balance = balance - 100
WHERE user_id = 'A' AND balance >= 100; -- 带余额检查
方案2:用当前读(FOR UPDATE)
START TRANSACTION;
-- 当前读,加锁
SELECT balance FROM account WHERE user_id = 'A' FOR UPDATE;
-- 判断余额
IF balance >= 100 THEN
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
END IF;
COMMIT;
南北绿豆:"所以单条UPDATE是原子的,但多条SQL组合不是原子的!"
🤔 问题8:事务的隔离级别有哪些?
面试标准答案(总结):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 | 并发性能 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 会 | ❌ 会 | ❌ 会 | 不加锁 | ⭐⭐⭐⭐⭐ |
| READ COMMITTED | ✅ 解决 | ❌ 会 | ❌ 会 | MVCC(每次生成Read View) | ⭐⭐⭐⭐ |
| REPEATABLE READ | ✅ 解决 | ✅ 解决 | ✅ 解决 | MVCC + 间隙锁 | ⭐⭐⭐ |
| SERIALIZABLE | ✅ 解决 | ✅ 解决 | ✅ 解决 | 加锁(串行) | ⭐ |
MySQL默认:REPEATABLE READ
推荐:
- 大部分场景:REPEATABLE READ
- 统计报表:READ COMMITTED
- 极少使用:SERIALIZABLE
🎉 结束语
晚上11点,哈吉米把面试题都复习了一遍。
哈吉米:"原来脏读这么危险,所以READ UNCOMMITTED基本不用!"
南北绿豆:"对,而且RR隔离级别下,快照读看不到其他事务的修改,当前读能看到。"
阿西噶阿西:"记住:快照读走MVCC,当前读加锁读最新数据。"
哈吉米:"还有单条UPDATE是原子的,但查询+更新组合不是原子的!"
南北绿豆:"对,这是很多人的误区!明天面试加油!"
记忆口诀:
脏读危险都不用,不可重复RC有
幻读RR间隙锁,串行化加锁慢
MVCC快照读,当前读加锁新
单条UPDATE原子,组合SQL有窗口
希望这篇文章能帮你搞定事务并发问题!把这些原理讲清楚,面试官一定满意!💪