三大并发问题概述

问题定义影响范围解决隔离级别
脏读读到未提交的数据单行数据READ COMMITTED
不可重复读同一查询两次结果不同(UPDATE)单行数据REPEATABLE READ
幻读范围查询两次结果不同(INSERT)多行数据SERIALIZABLE

1. 脏读(Dirty Read)

定义

读取到其他事务未提交的数据,如果该事务回滚,就会读到"脏"数据。

场景演示

-- 设置为最低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

-- 时间线:事务A和事务B并发执行
┌─────────────────┬─────────────────────────────────┐
 事务A              事务B                            
├─────────────────┼─────────────────────────────────┤
 START TRANSACTION                                 
                  START TRANSACTION                
 SELECT balance                                   
 FROM account                                     
 WHERE id=1;                                      
 -- 读到1000      │                                 │
                  UPDATE account                   
                  SET balance=500                  
                  WHERE id=1;                      
                  -- 未提交                         │
 SELECT balance                                   
 FROM account                                     
 WHERE id=1;                                      
 -- 读到500(脏读)│                                 │
                  ROLLBACK;                        
                  -- 余额回滚到1000                  │
 -- 但事务A已经基于 │                                 │
 -- 500做决策,错误!│                                 │
 COMMIT;                                          
└─────────────────┴─────────────────────────────────┘

真实案例

-- 场景:电商库存扣减
-- 事务A:查询库存并下单
START TRANSACTION;
SELECT stock FROM product WHERE id = 1001; -- 读到库存50

-- 事务B:库存更正(发现统计错误)
START TRANSACTION;
UPDATE product SET stock = 10 WHERE id = 1001; -- 实际只有10件
-- 未提交

-- 事务A继续
-- 基于库存50的判断,允许用户下单40件
INSERT INTO orders (product_id, quantity) VALUES (1001, 40);
COMMIT;

-- 事务B回滚
ROLLBACK; -- 库存恢复到50

-- 结果:用户下单40件,但实际库存只有10件,超卖!

解决方案

提高隔离级别到READ COMMITTED或更高

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 事务A
START TRANSACTION;
SELECT stock FROM product WHERE id = 1001; -- 只能读到已提交的数据
-- 如果事务B未提交,读到的是修改前的值(50)
-- 如果事务B已提交,读到的是修改后的值(10)
COMMIT;

2. 不可重复读(Non-Repeatable Read)

定义

同一事务内,两次读取同一行数据,结果不同(其他事务修改并提交了数据)。

场景演示

-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 时间线
┌─────────────────┬─────────────────────────────────┐
 事务A              事务B                            
├─────────────────┼─────────────────────────────────┤
 START TRANSACTION                                 
 SELECT balance                                   
 FROM account                                     
 WHERE id=1;                                      
 -- 第一次读:1000 │                                 │
                  START TRANSACTION                
                  UPDATE account                   
                  SET balance=500                  
                  WHERE id=1;                      
                  COMMIT; -- 提交                   │
 SELECT balance                                   
 FROM account                                     
 WHERE id=1;                                      
 -- 第二次读:500  │                                 │
 -- 不可重复读!   │                                 │
 COMMIT;                                          
└─────────────────┴─────────────────────────────────┘

真实案例

-- 场景:统计报表生成
-- 事务A:生成财务报表(需要多次查询同一数据)
START TRANSACTION;

-- 第一步:查询账户余额
SELECT SUM(balance) FROM account; -- 总额:100万

-- 第二步:计算其他指标(耗时操作)
SELECT AVG(balance), MAX(balance) FROM account;

-- 事务B:财务调整(并发执行)
START TRANSACTION;
UPDATE account SET balance = balance * 1.05; -- 所有账户增加5%利息
COMMIT;

-- 事务A:第三步,再次查询余额用于核对
SELECT SUM(balance) FROM account; -- 总额:105万(不可重复读!)
-- 报表数据不一致,第一步和第三步的总额不同

COMMIT;

解决方案

提高隔离级别到REPEATABLE READ

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 事务A
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 第一次读:1000

-- 即使事务B修改并提交,事务A仍读到快照版本
SELECT balance FROM account WHERE id = 1; -- 第二次读:仍然1000
COMMIT;

原理MVCC(多版本并发控制)

  • 每行数据有多个版本
  • 事务读取的是事务开始时的快照版本

3. 幻读(Phantom Read)

定义

同一事务内,两次范围查询,返回的记录数不同(其他事务插入或删除了数据)。

与不可重复读的区别

问题影响范围操作类型示例
不可重复读单行数据UPDATE查询id=1的余额,两次结果不同
幻读多行数据INSERT/DELETE查询balance>500的记录,两次结果行数不同

场景演示

-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 时间线
┌──────────────────┬─────────────────────────────────┐
 事务A               事务B                            
├──────────────────┼─────────────────────────────────┤
 START TRANSACTION                                  
 SELECT * FROM                                     
 account                                           
 WHERE balance>500                                 
 -- 第一次:3行     │                                 │
                   START TRANSACTION                
                   INSERT INTO account              
                   VALUES (4,'D',600);              
                   COMMIT;                          
 SELECT * FROM                                     
 account                                           
 WHERE balance>500                                 
 -- 快照读:仍然3行  │                                 │
                                                   
 UPDATE account                                    
 SET balance=                                      
 balance+10                                        
 WHERE balance>500                                 
 -- 当前读:影响4行!│                                 │
 -- 幻读!         │                                 │
 COMMIT;                                           
└──────────────────┴─────────────────────────────────┘

快照读 vs 当前读

快照读(Snapshot Read)

  • 普通SELECT语句
  • 读取MVCC快照版本,不加锁
  • 不会出现幻读
SELECT * FROM account WHERE balance > 500;
-- 读取事务开始时的快照,新插入的行不可见

当前读(Current Read)

  • SELECT FOR UPDATE、SELECT LOCK IN SHARE MODE
  • UPDATE、DELETE、INSERT
  • 读取最新版本,加锁
  • 可能出现幻读
-- 当前读(会读到新插入的行)
SELECT * FROM account WHERE balance > 500 FOR UPDATE;
UPDATE account SET balance = balance + 10 WHERE balance > 500;

真实案例

-- 场景:防止重复下单
-- 业务逻辑:同一用户同一商品只能有1个未支付订单

-- 事务A:用户A下单商品1001
START TRANSACTION;

-- 第一步:检查是否有未支付订单(快照读)
SELECT COUNT(*) FROM orders
WHERE user_id = 'A' AND product_id = 1001 AND status = 'UNPAID';
-- 返回0,判断可以下单

-- 事务B:用户A在另一个设备上也下单(并发)
START TRANSACTION;
SELECT COUNT(*) FROM orders
WHERE user_id = 'A' AND product_id = 1001 AND status = 'UNPAID';
-- 也返回0,判断可以下单
INSERT INTO orders (user_id, product_id, status) VALUES ('A', 1001, 'UNPAID');
COMMIT; -- 提交

-- 事务A继续
-- 第二步:插入订单(当前读,会看到事务B插入的记录)
INSERT INTO orders (user_id, product_id, status) VALUES ('A', 1001, 'UNPAID');
COMMIT;

-- 结果:用户A有2个未支付订单,违反业务规则(幻读导致)

解决方案

方案1:使用当前读 + 行锁

START TRANSACTION;

-- 使用FOR UPDATE加锁(当前读)
SELECT COUNT(*) FROM orders
WHERE user_id = 'A' AND product_id = 1001 AND status = 'UNPAID'
FOR UPDATE;
-- 如果返回0,可以插入

INSERT INTO orders (user_id, product_id, status) VALUES ('A', 1001, 'UNPAID');
COMMIT;

方案2:提高隔离级别到SERIALIZABLE

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- SELECT自动加锁,阻塞其他事务的INSERT
START TRANSACTION;
SELECT COUNT(*) FROM orders
WHERE user_id = 'A' AND product_id = 1001 AND status = 'UNPAID';
-- 其他事务的INSERT会被阻塞
COMMIT;

方案3:应用层唯一约束

-- 创建唯一索引(推荐)
CREATE UNIQUE INDEX uk_user_product_unpaid
ON orders(user_id, product_id, status)
WHERE status = 'UNPAID';

-- 插入时自动检测冲突
INSERT INTO orders (user_id, product_id, status) VALUES ('A', 1001, 'UNPAID');
-- 如果已有未支付订单,抛出唯一约束冲突错误

MySQL的特殊处理

Next-Key Lock机制

MySQL在REPEATABLE READ级别使用Next-Key Lock,部分解决幻读

-- 事务A
START TRANSACTION;
SELECT * FROM account WHERE id BETWEEN 1 AND 10 FOR UPDATE;
-- 加Next-Key Lock:锁住id=1~10的行,以及(10, +∞)的间隙

-- 事务B(并发执行)
INSERT INTO account (id, name, balance) VALUES (5, 'E', 500);
-- 阻塞!无法插入id=5的行

INSERT INTO account (id, name, balance) VALUES (15, 'F', 600);
-- 阻塞!无法插入id=15的行(间隙锁)

Next-Key Lock = Record Lock(行锁) + Gap Lock(间隙锁)


问题对比总结

特性脏读不可重复读幻读
读到的数据未提交的数据已提交的修改已提交的新增/删除
影响范围单行单行多行(范围查询)
操作类型UPDATE/INSERTUPDATEINSERT/DELETE
最低解决级别READ COMMITTEDREPEATABLE READSERIALIZABLE
MySQL特殊处理-MVCC快照读Next-Key Lock

实战建议

1. 默认使用REPEATABLE READ

-- 查看当前隔离级别
SELECT @@transaction_isolation; -- 默认REPEATABLE-READ

2. 关键业务使用当前读

-- 防止幻读:使用FOR UPDATE
SELECT * FROM orders WHERE user_id = 'A' FOR UPDATE;
INSERT INTO orders VALUES (...);

3. 应用层加唯一约束

-- 数据库层面防止重复
CREATE UNIQUE INDEX uk_user_product ON orders(user_id, product_id);

4. 避免长事务

  • 减少锁冲突概率
  • 降低幻读风险

常见面试题

Q1: 为什么READ COMMITTED不能防止不可重复读?

  • 因为每次SELECT都读取最新的已提交数据,不使用快照

Q2: MySQL的REPEATABLE READ能完全防止幻读吗?

  • 快照读:能防止(MVCC)
  • 当前读:能防止(Next-Key Lock)
  • 但先快照读后当前读,可能出现幻读

Q3: 如何选择使用快照读还是当前读?

  • 只读操作:快照读(性能好)
  • 需要修改数据:当前读(FOR UPDATE)
  • 防止幻读:当前读 + Next-Key Lock

小结

脏读:读到未提交数据,READ COMMITTED解决 ✅ 不可重复读:同一查询两次结果不同,REPEATABLE READ解决 ✅ 幻读:范围查询两次行数不同,SERIALIZABLE或Next-Key Lock解决

推荐:默认REPEATABLE READ + 关键业务使用FOR UPDATE。


📚 相关阅读

  • 下一篇:《MVCC多版本并发控制:原理与实现》
  • 推荐:《InnoDB行锁:Record Lock、Gap Lock、Next-Key Lock》