MySQL事务的并发问题:脏读、不可重复读、幻读到底怎么解决?

摘要:从面试官的连环追问出发,深度剖析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
T1SELECT balance=1000
T2SELECT balance=1000
T3UPDATE balance=900
T4UPDATE 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有窗口


希望这篇文章能帮你搞定事务并发问题!把这些原理讲清楚,面试官一定满意!💪