数据库事务隔离
事务的 ACID 特性
| 特性 | 说明 | 实现机制 |
|---|---|---|
| Atomicity(原子性) | 事务是不可分割的工作单位,要么全部成功,要么全部失败 | undo log(回滚日志) |
| Consistency(一致性) | 事务前后数据库的完整性约束没有被破坏 | 由应用层和数据库共同保证 |
| Isolation(隔离性) | 多个事务并发执行时,一个事务的执行不应影响其他事务 | 锁机制 + MVCC |
| Durability(持久性) | 事务完成后,对数据库的修改是永久性的 | redo log(重做日志) |
并发事务的问题
1. 脏读(Dirty Read)
一个事务读到了另一个事务未提交的数据。
时间线 事务A 事务B
1 BEGIN
2 UPDATE user SET age=20
WHERE id=1 (age: 18→20)
3 SELECT age FROM user WHERE id=1
→ 读到 age=20(未提交的数据)
4 ROLLBACK (age 回滚为18)
5 事务B使用的 age=20 是脏数据!2. 不可重复读(Non-Repeatable Read)
同一个事务内,两次读取同一数据得到不同结果(针对修改)。
时间线 事务A 事务B
1 BEGIN
2 SELECT age FROM user
WHERE id=1 → age=18
3 BEGIN
UPDATE user SET age=20 WHERE id=1
COMMIT
4 SELECT age FROM user
WHERE id=1 → age=20
(同一事务内两次读取结果不同!)3. 幻读(Phantom Read)
同一个事务内,两次查询返回的记录数不同(针对插入/删除)。
时间线 事务A 事务B
1 BEGIN
2 SELECT * FROM user
WHERE age > 18 → 10条
3 BEGIN
INSERT INTO user(age) VALUES(20)
COMMIT
4 SELECT * FROM user
WHERE age > 18 → 11条
(多了一条"幻影"记录)问题对比
| 问题 | 产生原因 | 影响范围 |
|---|---|---|
| 脏读 | 读到未提交数据 | 数据不一致,可能回滚 |
| 不可重复读 | 读到已提交的修改 | 同一数据值变化 |
| 幻读 | 读到已提交的插入/删除 | 结果集数量变化 |
四种隔离级别
MySQL InnoDB 默认隔离级别:REPEATABLE READ(可重复读)
隔离级别与问题关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED(读未提交) | ✅ 可能 | ✅ 可能 | ✅ 可能 | 无特殊处理 |
| READ COMMITTED(读已提交) | ❌ 不可能 | ✅ 可能 | ✅ 可能 | MVCC + 行锁 |
| REPEATABLE READ(可重复读) | ❌ 不可能 | ❌ 不可能 | ✅ 可能* | MVCC + 间隙锁 |
| SERIALIZABLE(串行化) | ❌ 不可能 | ❌ 不可能 | ❌ 不可能 | 表锁 |
*MySQL InnoDB 的 RR 级别通过间隙锁(Gap Lock)防止幻读
各级别详解
READ UNCOMMITTED(读未提交)
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;- 最低级别,几乎不加锁
- 性能最好,但数据一致性最差
- 实际应用极少使用
READ COMMITTED(读已提交)- Oracle 默认
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;- 只能读到已提交的数据
- 解决脏读问题
- 每次查询生成新的 Read View
REPEATABLE READ(可重复读)- MySQL 默认
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;- 同一事务内多次读取结果一致
- 解决脏读和不可重复读
- 事务开始时生成 Read View,整个事务期间复用
SERIALIZABLE(串行化)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;- 最高隔离级别
- 强制事务串行执行
- 性能最差,并发能力最低
- 通过对读取的每一行数据加锁实现
MVCC(多版本并发控制)
核心思想
MVCC 通过保存数据的历史版本,使读操作可以读取某个历史版本的数据,从而避免加锁。
InnoDB MVCC 实现
隐式字段
每行数据包含两个隐藏列:
| 字段 | 说明 |
|---|---|
DB_TRX_ID | 最后插入或更新该行的事务 ID(6字节) |
DB_ROLL_PTR | 回滚指针,指向 undo log(7字节) |
DB_ROW_ID | 行 ID(6字节,如果表没有主键则自动创建) |
undo log(回滚日志)
当前数据: id=1, age=20, DB_TRX_ID=100, DB_ROLL_PTR=0x7F8...
undo log 链:
┌─────────────────────────────────────┐
│ undo log (trx_id=100): age=20 │ ← 当前版本
├─────────────────────────────────────┤
│ undo log (trx_id=80): age=18 │ ← 历史版本1
├─────────────────────────────────────┤
│ undo log (trx_id=50): age=16 │ ← 历史版本2
└─────────────────────────────────────┘Read View(读视图)
Read View 包含四个关键字段:
| 字段 | 说明 |
|---|---|
m_ids | 创建 Read View 时活跃的事务 ID 列表 |
min_trx_id | 活跃事务中最小的事务 ID |
max_trx_id | 下一个将要分配的事务 ID |
creator_trx_id | 创建该 Read View 的事务 ID |
可见性判断规则
对于某行数据的 DB_TRX_ID:
- 等于
creator_trx_id:自己修改的,可见 - 小于
min_trx_id:事务已提交,可见 - 大于等于
max_trx_id:事务在 Read View 创建后才开启,不可见 - 在
m_ids中:事务未提交,不可见 - 不在
m_ids中:事务已提交,可见
RC 和 RR 的区别
| 隔离级别 | Read View 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每次 SELECT 生成新的 | 能读到最新已提交数据 |
| REPEATABLE READ | 事务第一次 SELECT 时生成 | 整个事务看到相同快照 |
面试高频问题
Q1: MySQL 如何解决幻读?
回答要点:
-
快照读(普通 SELECT):通过 MVCC 解决
- RR 级别下,事务开始时生成 Read View
- 后续 SELECT 只能看到 Read View 创建前已提交的数据
- 新插入的数据对当前事务不可见
-
当前读(SELECT ... FOR UPDATE、UPDATE、DELETE):通过间隙锁解决
- Next-Key Lock = Record Lock(行锁)+ Gap Lock(间隙锁)
- 锁住记录及其前后间隙,防止其他事务插入
-- 当前读示例
SELECT * FROM user WHERE id > 5 FOR UPDATE;
-- 锁定 id > 5 的所有记录,以及 (5, +∞) 间隙
-- 其他事务无法在此范围插入新记录Q2: RC 和 RR 本质区别是什么?
回答要点:
RC 每次查询生成新 Read View,RR 只在事务开始时生成一次。
-- 场景:事务A先查询,事务B修改并提交,事务A再查询
-- RC 隔离级别
事务A: SELECT age FROM user WHERE id=1; -- age=18
事务B: UPDATE user SET age=20 WHERE id=1; COMMIT;
事务A: SELECT age FROM user WHERE id=1; -- age=20(生成新Read View)
-- RR 隔离级别
事务A: SELECT age FROM user WHERE id=1; -- age=18(生成Read View)
事务B: UPDATE user SET age=20 WHERE id=1; COMMIT;
事务A: SELECT age FROM user WHERE id=1; -- age=18(复用Read View)Q3: 为什么 MySQL 默认使用 RR 而不是 RC?
回答要点:
-
数据一致性更强:避免不可重复读问题
-
binlog 日志格式:MySQL binlog 有三种格式
- STATEMENT:SQL 语句(RR 首选)
- ROW:行数据变化
- MIXED:混合模式
RR + STATEMENT 格式可以保证主从复制一致性
-
历史原因:MySQL 5.1 之前,RC + STATEMENT 可能导致主从不一致
Q4: 什么是当前读和快照读?
| 类型 | 说明 | 示例 |
|---|---|---|
| 快照读 | 读取历史版本数据,不加锁 | SELECT * FROM table |
| 当前读 | 读取最新版本数据,加锁 | SELECT ... FOR UPDATE |
SELECT ... LOCK IN SHARE MODE | ||
INSERT / UPDATE / DELETE |
Q5: MVCC 能解决所有并发问题吗?
回答要点:
不能。MVCC 只解决读-写冲突,写-写冲突仍需要锁。
场景:两个事务同时更新同一行
事务A: UPDATE user SET age=age+1 WHERE id=1; -- 获取行锁
事务B: UPDATE user SET age=age+1 WHERE id=1; -- 等待行锁
事务A: COMMIT; -- 释放行锁
事务B: 获取行锁,执行更新 -- 不会读到旧版本,而是等待总结
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | MVCC Read View | 锁机制 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | √ | √ | √ | 不使用 | 无 |
| READ COMMITTED | × | √ | √ | 每次 SELECT 生成 | 行锁 |
| REPEATABLE READ | × | × | √* | 事务开始时生成 | 行锁 + 间隙锁 |
| SERIALIZABLE | × | × | × | 不使用 | 表锁 |
MySQL InnoDB 的 RR 级别通过间隙锁防止幻读
选择建议:
- 大多数场景:使用默认的 RR 级别
- 需要读取最新数据:使用 RC 级别
- 对一致性要求极高:使用 SERIALIZABLE(性能下降明显)