1. 什么是数据库事务?
在数据库中,事务是一个操作序列,要么全部执行成功,要么全部失败回滚。事务的典型特性可以用 ACID 表示:
- 原子性(Atomicity):事务是一个不可分割的最小工作单元,事务中的所有操作要么全部成功,要么全部失败。
- 一致性(Consistency):事务执行前后,数据库的数据状态保持一致。
- 隔离性(Isolation):多个事务同时执行时,一个事务的执行不会被其他事务干扰。
- 持久性(Durability):事务一旦提交,其结果将永久保存。
2. 什么是事务隔离级别?
事务隔离级别定义了多个事务之间的隔离程度。它是为了解决以下问题而设计的:
2.1 脏读(Dirty Read)
一个事务读取了另一个未提交事务修改的数据。如果后者回滚了,前者读到的就是脏数据。
例子:
- 事务A修改了某条记录的值,但未提交。
- 事务B读取了该修改后的值。
- 事务A回滚了,那事务B此时读到的数据就是错误的。
2.2 不可重复读(Non-Repeatable Read)
在同一事务中,前后两次读取同一数据,结果却不一致。
例子:
- 事务A读取了一条记录的值。
- 事务B修改了该记录,并提交了修改。
- 事务A再次读取该记录,发现值已发生变化。
2.3 幻读(Phantom Read)
一个事务读取了多行记录,另一个事务插入了新的记录,导致前者再次读取时发现“幻觉”般的新记录。
例子:
- 事务A查询某条件下的所有记录,发现有10条数据。
- 事务B插入了一条符合查询条件的新数据,并提交。
- 事务A再次查询发现有11条数据。
3. 事务隔离级别的种类
数据库提供了四种事务隔离级别,分别解决上面的问题。隔离级别越高,性能可能越低。
3.1 读未提交(Read Uncommitted)
- 描述: 最低的隔离级别,允许读取未提交的数据。
- 问题: 存在脏读、不可重复读和幻读问题。
- 实现: 读操作不加锁。
3.2. 读已提交(Read Committed)
- 描述: 只能读取已提交的数据,解决了脏读问题。
- 问题: 存在不可重复读和幻读问题。
- 实现:
- 写操作时加排他锁,防止其他事务读取未提交的数据。
- 读操作时无需加锁,直接读取最新已提交的数据。
3.3. 可重复读(Repeatable Read)
- 描述: 确保同一事务中多次读取的结果一致,解决了不可重复读问题。
- 问题: 存在幻读问题。
- 实现:
- 读操作时加共享锁,阻止其他事务写。
- 写操作时加排他锁,阻止其他事务读或写。
3.4. 串行化(Serializable)
- 描述: 最高的隔离级别,事务完全串行执行,解决了幻读问题。
- 问题: 并发性能极低。
- 实现:
- 对所有读写操作加锁或使用多版本并发控制(MVCC)。
4. 总结:问题与隔离级别的对应
问题 | Read Uncommitted | Read Committed | Repeatable Read | Serializable |
---|---|---|---|---|
脏读(Dirty Read) | ✅ 支持 | ❌ 不支持 | ❌ 不支持 | ❌ 不支持 |
不可重复读 | ✅ 支持 | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
幻读(Phantom Read) | ✅ 支持 | ✅ 支持 | ✅ 支持 | ❌ 不支持 |
5. 锁机制在事务隔离中的作用
数据库通过锁机制控制事务间的交互。常见的锁包括:
5.1 共享锁(S 锁)
允许多个事务同时读取数据,但禁止写操作。
- 应用于查询语句,确保数据一致。
5.2 排他锁(X 锁)
禁止其他事务读取或写入数据。
- 应用于更新操作,确保数据独占。
5.3 意向锁(Intent Lock)
用于标记事务即将对某数据加共享锁或排他锁,用以协调多个事务的锁需求。
5.4 多版本并发控制(MVCC)
不加锁,而是通过保存数据的多个版本实现隔离级别(如可重复读)。读取时获取快照版本,写入时检查冲突。
6. 性能与隔离级别的平衡
- 低隔离级别:并发性能高,但数据一致性可能受影响。
- 高隔离级别:数据一致性强,但并发性能低。
实际开发中,根据需求选择隔离级别:
- OLTP 系统:多选用
Read Committed
,性能与一致性平衡。 - 金融系统:可能选择
Serializable
或Repeatable Read
,保证高一致性。
7. 脏读的本质
脏读的本质在于:事务B可以读取事务A未提交的数据。这意味着,事务A即使只是临时修改了数据(尚未提交),这些修改也可能被事务B直接读取到。
在隔离级别为 未提交读(Read Uncommitted) 的情况下,数据库允许这种行为。也就是说,事务B不要求读取到“提交后的数据”,它直接读取当前的数据行(包括未提交的修改)。
7.1 为什么事务B会读取到事务A未提交的数据?
这是因为数据库默认会从内存中的缓冲区(Buffer Pool)读取数据,而不是直接从磁盘中读取。事务A在修改某条记录时,会将修改后的值写入缓冲区(未提交)。如果隔离级别较低(如 Read Uncommitted),事务B没有限制,直接读取了缓冲区中的最新状态,而不是事务A修改前的值。
以下是具体过程:
- 事务A启动
- 开始修改某条记录,但没有提交。
- 数据库将修改后的值写入内存缓冲区(脏页),并标记为未提交状态。
- 事务B启动
- 开启时,读取这条记录的数据。
- 如果隔离级别是 Read Uncommitted,则直接读取缓冲区中的数据,即事务A未提交的修改。
7.2 举个例子
假设表中某条记录初始值是 balance = 100
。
- 事务A 开始:
BEGIN; UPDATE account SET balance = 200 WHERE id = 1; -- 此时事务A未提交,表中数据是 balance = 100,但缓冲区中有未提交值 balance = 200。
- 事务B 开始:
BEGIN; SELECT balance FROM account WHERE id = 1; -- 如果隔离级别是 Read Uncommitted,事务B读到的是缓冲区中的 balance = 200。
- 事务A回滚:
ROLLBACK; -- 修改被撤销,balance 回到 100。
此时,事务B已经使用了错误的值 balance = 200
。
7.3 为什么隔离级别解决了这个问题?
1. Read Committed(解决脏读)
事务B只能读取已经提交的数据。如果事务A未提交,事务B会直接访问事务A提交前的快照版本或阻塞。
- 实现: 在 Read Committed 隔离级别下,数据库会在读取操作时忽略未提交的事务。
2. 可重复读 / Serializable
这些级别进一步确保事务读取到一致的视图,避免读取到事务A的未提交或中途修改的数据。
7.4 总结
为什么会读取未提交的数据?
因为数据库读取的是缓冲区中的最新状态,而不是磁盘中的旧值。为什么脏读是问题?
事务A未提交时,事务B读取到的数据可能在事务A回滚后失效,导致不一致。如何避免脏读?
设置事务隔离级别为 Read Committed 或更高,避免读取未提交的数据。
脏读问题的出现,是低隔离级别让步性能的一种结果。如果你的场景不需要这种高并发优化,避免使用 Read Uncommitted 即可!
8. 深入理解幻读
8.1 不可重复读和幻读的核心区别
不可重复读 不可重复读关注的是单条记录的内容在同一个事务中是否发生了变化。
- 场景:
- 事务A读取了一条记录。
- 事务B修改了这条记录,并提交。
- 事务A再次读取时,发现数据内容变了。
- 解决方法(Repeatable Read):
- 在事务A中第一次读取记录时,加上共享锁(S锁),防止事务B对该记录进行修改或删除。
幻读 幻读关注的是多条记录的新增或删除导致结果集发生变化。
- 场景:
- 事务A读取了符合某个条件的多条记录(比如
SELECT * FROM table WHERE age > 30
)。 - 事务B插入了一条符合该条件的新记录,并提交。
- 事务A再次读取时,发现结果集中多了一条记录(或少了一条记录)。
- 事务A读取了符合某个条件的多条记录(比如
- 关键点:
- 幻读不是单条记录内容的变化,而是结果集的变化(新增或删除记录)。
8.2 为什么 Repeatable Read 无法解决幻读
在 Repeatable Read 隔离级别下,共享锁(S锁)仅保证你读取的单条记录不会被修改或删除,但不会对“未存在的记录”加锁。这就导致幻读仍然可能发生。
举个例子
假设有如下 user
表:
ID | Age |
---|---|
1 | 25 |
2 | 35 |
- 事务A:
BEGIN; SELECT * FROM user WHERE age > 30; -- 查询结果为:ID=2
- 事务B:
BEGIN; INSERT INTO user (ID, Age) VALUES (3, 40); COMMIT;
- 事务A:
SELECT * FROM user WHERE age > 30; -- 查询结果为:ID=2, ID=3
在这个过程中,事务A的两次查询,结果集发生了变化(多了一条记录 ID=3)。虽然事务A在读取现有记录时加了共享锁,但由于事务B插入的新记录未被锁定,事务A仍然看到了幻读。
8.3 如何解决幻读(Serializable 的作用)
为了彻底避免幻读,Serializable 隔离级别采取的策略是:
- 全表范围锁定(范围锁,Range Lock):
- 对查询条件所涉及的范围加锁,不仅锁定已经存在的记录,还锁定“潜在可能存在的记录”。
- 比如事务A在查询
age > 30
时,数据库会锁定整个age > 30
的范围,包括尚未存在的记录。
- 串行化执行:
- 事务之间完全隔离,相当于按顺序执行,避免并发操作导致的任何不一致。
具体例子
在 Serializable 隔离级别下,上面的例子会是这样的:
- 事务A:
BEGIN; SELECT * FROM user WHERE age > 30; -- 数据库锁定范围:age > 30
- 事务B:
BEGIN; INSERT INTO user (ID, Age) VALUES (3, 40); -- 阻塞,直到事务A提交。
- 事务A 提交:
COMMIT;
- 事务B 插入成功:
COMMIT;
通过这种方式,事务A和事务B完全串行化,杜绝了幻读的可能性。
8.4 Repeatable Read 的 MVCC 机制与幻读
在 MySQL 的 InnoDB 存储引擎中,Repeatable Read 通过 MVCC(多版本并发控制)解决了大多数一致性问题,但它并不使用范围锁,因此只保证读取时的“点查询”一致性,而不能避免新增或删除数据导致的幻读。
8.5 总结
- 不可重复读 VS 幻读:
- 不可重复读:单条记录内容变了。
- 幻读:结果集新增或删除了记录。
- Repeatable Read 无法避免幻读的原因:
- 它只锁定已存在的记录,未锁定查询范围。
- Serializable 怎么解决幻读:
- 锁定查询范围(包括潜在记录)。
- 强制事务串行化。
- 实际应用:
- 幻读在某些场景下不是问题(如读多写少的系统)。
- 如果需要绝对一致性,使用 Serializable,但要注意性能成本。
9. 数据库锁和隔离级别
9.1 事务隔离级别与锁的关系
不同的事务隔离级别,本质上决定了事务之间的互斥程度(即锁的范围和时间),从而影响了并发性能和数据一致性:
- 低隔离级别(如 Read Uncommitted)
- 几乎不加锁:写操作的锁只针对事务自己,读操作完全不阻塞其他事务,即允许读取未提交的数据。
- 性能最高,但数据一致性最差:因为可能出现脏读、不可重复读、幻读。
- 中等隔离级别(如 Read Committed)
- 写操作加排他锁,但读操作会读取最新已提交的数据而不加锁。
- 性能较高:避免了脏读,但仍然可能发生不可重复读和幻读。
- 较高隔离级别(如 Repeatable Read)
- 读操作加共享锁,写操作加排他锁,同一事务中多次读取同一数据时能保证一致。
- 性能较低:避免了脏读和不可重复读,但可能有幻读。
- 最高隔离级别(Serializable)
- 读写操作都需要加锁,事务必须串行执行,完全避免了幻读。
- 性能最低,但数据一致性最高:因为几乎所有并发操作都会被锁阻塞。
9.2 锁的粒度与互斥性
事务隔离级别不仅决定是否加锁,还决定锁的粒度(针对行还是整表)以及锁的类型(共享锁或排他锁):
- 共享锁(S 锁):
- 允许多个事务同时读取数据,但阻止其他事务修改。
- 例如,可重复读隔离级别中用于防止不可重复读。
- 排他锁(X 锁):
- 阻止其他事务读取或修改数据,确保写操作的唯一性。
- 在所有隔离级别下的写操作都会使用排他锁。
- 意向锁:
- 用于标识事务意图(如打算加共享锁或排他锁),帮助协调更高级别锁的获取。
9.3 隔离级别与性能的权衡
你的总结非常准确,性能与一致性之间的权衡如下:
隔离级别 | 一致性 | 性能 | 问题 |
---|---|---|---|
Read Uncommitted | 最低(容易脏读) | 最高 | 脏读、不可重复读、幻读 |
Read Committed | 较低(避免脏读) | 较高 | 不可重复读、幻读 |
Repeatable Read | 较高(避免脏读和不可重复读) | 中等 | 幻读 |
Serializable | 最高(完全隔离) | 最低 | 几乎没有并发,所有操作都会互斥 |
9.4 实际应用中的折中
在实际的系统中,选择隔离级别时通常会在性能和一致性之间找到一个平衡点:
- 高并发系统(如电商、社交网络):
- 通常选择
Read Committed
,因为需要尽可能提升并发性能,允许一定程度上的不可重复读和幻读。
- 通常选择
- 金融系统(如银行转账):
- 通常选择
Serializable
或Repeatable Read
,因为一致性比性能更重要。
- 通常选择
- 默认选择:
- 大多数数据库(如 MySQL 的 InnoDB 存储引擎)默认隔离级别为 Repeatable Read,因为它在性能和一致性之间提供了合理的平衡。
9.5 结论的进一步延伸
性能越低,数据一致性越高;性能越高,数据一致性越低。
这句话可以扩展为:
- 隔离级别低: 更倾向于牺牲一致性来提高并发性能。
- 隔离级别高: 通过加锁(或类似技术,如多版本并发控制 MVCC)确保一致性,但会牺牲并发性能。
因此,隔离级别的设计核心在于:
如何通过控制互斥程度,在性能和一致性之间找到适合具体业务需求的平衡点。
文档信息
- 本文作者:Marshall