事务问题:并发场景下的四大异常
想象一个场景:
你和你的女朋友同时查看同一张银行卡余额,都是 10000 块。
然后你刷卡消费了 5000 块,她也刷卡消费了 5000 块。
理论上,应该只剩 0 块。但如果数据库没有正确的隔离机制,可能出现各种奇怪的结果...
这就是事务的并发问题。
并发事务的四大异常
脏读(Dirty Read)
定义:事务 A 读取了事务 B 未提交的数据。
时间线:
T1: BEGIN;
T1: UPDATE accounts SET balance = 5000 WHERE user_id = 1; -- 把余额改成 5000
T2: BEGIN;
T2: SELECT balance FROM accounts WHERE user_id = 1; -- 脏读!读到了 T1 未提交的数据:5000
T1: ROLLBACK; -- T1 回滚了,余额恢复成 10000
T2: UPDATE accounts SET balance = balance - 5000; -- T2 基于错误的数据做操作
T2: COMMIT;后果:T2 基于错误的余额做决策,导致数据不一致。
隔离级别:读未提交(Read Uncommitted)会产生脏读。
不可重复读(Non-Repeatable Read)
定义:同一个事务中,两次读取同一行数据,结果不一样(因为其他事务修改并提交了)。
时间线:
T1: BEGIN;
T1: SELECT balance FROM accounts WHERE user_id = 1; -- 第一次读:10000
T2: BEGIN;
T2: UPDATE accounts SET balance = 8000 WHERE user_id = 1; -- T2 修改了余额
T2: COMMIT; -- T2 提交了
T1: SELECT balance FROM accounts WHERE user_id = 1; -- 第二次读:8000(不一样了!)
T1: COMMIT;特点:其他事务修改了数据并提交,导致当前事务两次读取结果不同。
隔离级别:读已提交(Read Committed)会产生不可重复读。
幻读(Phantom Read)
定义:同一个事务中,两次执行相同的查询,返回的记录数不一样(因为其他事务插入或删除了记录)。
时间线:
T1: BEGIN;
T1: SELECT * FROM orders WHERE user_id = 1; -- 第一次查:2 条订单
T2: BEGIN;
T2: INSERT INTO orders (user_id, amount) VALUES (1, 100); -- T2 插入了一条新订单
T2: COMMIT; -- T2 提交了
T1: SELECT * FROM orders WHERE user_id = 1; -- 第二次查:3 条订单(多了!)
T1: COMMIT;特点:其他事务插入或删除了记录,导致当前事务两次查询结果集不同。好像遇到了「幻影」。
隔离级别:可重复读(Read Committed)及以上可能产生幻读。
脏写(Dirty Write)
定义:事务 A 修改了一行数据,事务 B 也修改了同一行数据,但事务 B 先提交了。事务 A 后来回滚时,覆盖了事务 B 的修改。
时间线:
T1: BEGIN;
T2: BEGIN;
T1: UPDATE accounts SET balance = 5000 WHERE user_id = 1; -- T1 余额改 5000
T2: UPDATE accounts SET balance = 6000 WHERE user_id = 1; -- T2 余额改 6000(T2 在 T1 之前提交)
T2: COMMIT;
T1: ROLLBACK; -- T1 回滚,恢复原值 10000,覆盖了 T2 的 6000后果:T2 的提交被 T1 的回滚覆盖了,T2 的修改丢失了。
隔离级别:几乎所有隔离级别都会产生脏写,这是最严重的并发问题。
四种异常对比
| 异常 | 原因 | 读取了什么 | 隔离级别 |
|---|---|---|---|
| 脏读 | 读取了未提交的数据 | 其他事务未提交的修改 | Read Uncommitted |
| 不可重复读 | 其他事务修改并提交了数据 | 其他事务提交的修改 | Read Committed |
| 幻读 | 其他事务插入了新记录 | 其他事务插入的记录 | Repeatable Read |
| 脏写 | 两个事务同时修改,后提交的覆盖了先提交的 | - | 几乎所有 |
图解:脏读 vs 不可重复读 vs 幻读
┌─────────────────────────────────────────────────────────────────┐
│ 并发事务异常 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 脏读(Dirty Read): │
│ T1: UPDATE x = 1 (未提交) │
│ T2: SELECT x FROM ... -- 读到了 T1 未提交的值 │
│ T1: ROLLBACK │
│ │
│ 不可重复读(Non-Repeatable Read): │
│ T1: SELECT x = 1 (第一次读) │
│ T2: UPDATE x = 2 (并提交) │
│ T1: SELECT x = 2 (第二次读,和第一次不一样) │
│ │
│ 幻读(Phantom Read): │
│ T1: SELECT COUNT(*) = 10 (第一次查,10 条记录) │
│ T2: INSERT INTO ... (并提交) │
│ T1: SELECT COUNT(*) = 11 (第二次查,多了 1 条) │
│ │
└─────────────────────────────────────────────────────────────────┘如何解决这些异常?
不同的隔离级别可以防止不同的异常:
| 隔离级别 | 防止脏读 | 防止不可重复读 | 防止幻读 |
|---|---|---|---|
| Read Uncommitted | ❌ | ❌ | ❌ |
| Read Committed | ✅ | ❌ | ❌ |
| Repeatable Read | ✅ | ✅ | ✅(InnoDB) |
| Serializable | ✅ | ✅ | ✅ |
面试场景
面试官: 什么是脏读?
你: 脏读是指一个事务读取了另一个事务未提交的数据。比如事务 A 修改了一条记录但还没提交,事务 B 读取了这条记录,然后事务 A 回滚了——事务 B 读到的就是脏数据。
面试官: 幻读和不可重复读有什么区别?
你: 不可重复读是同一个事务中,两次读取同一行数据结果不同(因为被其他事务修改并提交了)。幻读是两次执行相同查询,返回的记录数不同(因为其他事务插入或删除了记录)。
面试官: MySQL 的 InnoDB 是怎么解决幻读的?
你: InnoDB 的默认隔离级别是可重复读(RR),通过 MVCC(多版本并发控制)解决了大部分幻读问题。对于当前读(如 SELECT ... FOR UPDATE),InnoDB 还会使用间隙锁来防止幻读。
一句话总结
并发事务的四大异常:脏读是读到了别人未提交的数据,不可重复读是别人修改并提交后导致数据变了,幻读是别人插入或删除了记录导致记录数变了,脏写是回滚覆盖了别人的提交。
