四种隔离级别是如何逐步加强事务隔离的,以及在底层如何使用锁机制和多版本控制(MVCC)来实现
隔离级别越高,事务之间的隔离性就越强,但并发性能通常会下降。为了更好地理解隔离级别的差异,下面通过一个简单的场景来说明四种隔离级别是如何逐步加强事务隔离的,以及在底层如何使用锁机制和多版本控制(MVCC)来实现。
场景描述
假设有一个银行系统,用户A和用户B都在账户表 accounts
中有一条记录,账户余额字段为 balance
。用户A的账户当前余额为100元。
- 事务1:用户A从账户中取出50元(
UPDATE accounts SET balance = balance - 50 WHERE user_id = 1
)。 - 事务2:系统读取用户A的账户余额,进行财务报表统计(
SELECT balance FROM accounts WHERE user_id = 1
)。
1. 读未提交(Read Uncommitted)
- 事务1开始:用户A取钱,账户余额变为50元,但事务1还未提交。
- 事务2开始:系统读取用户A的余额,由于读未提交隔离级别允许读取未提交的数据,事务2读到的是事务1尚未提交的50元。
- 问题:如果事务1最终回滚,用户A的余额应恢复为100元,但事务2已经在报表中记录了错误的余额(50元),这就是“脏读”。
底层实现:
- 锁机制:基本不使用锁,因此读可以直接读取到其他事务未提交的写操作。
- 代码实现:读取时不加锁,类似于下面的伪代码。
-- 事务1: START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE user_id = 1; -- 事务2: SELECT balance FROM accounts WHERE user_id = 1; -- 读取到50元,虽然事务1未提交
2. 读已提交(Read Committed)
- 事务1开始:用户A取钱,账户余额变为50元,但事务1还未提交。
- 事务2开始:系统读取用户A的余额,读已提交隔离级别不允许读取未提交的数据,因此事务2读取到的是事务1之前的余额(100元)。
- 事务1提交:用户A的余额最终更新为50元。
- 问题:虽然避免了脏读,但如果事务2再次查询余额,可能会看到50元,这就是“不可重复读”,即同一事务内多次读取同一数据可能得到不同结果。
底层实现:
- 锁机制:读操作会读取已经提交的数据,写操作会加排他锁(X锁),避免其他事务读取未提交的数据。
- 代码实现:通过锁定写操作确保读操作只能看到已提交的数据。
-- 事务1: START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE user_id = 1; -- 加排他锁 -- 事务2: SELECT balance FROM accounts WHERE user_id = 1; -- 读取到100元,等待事务1提交 COMMIT; -- 事务1提交,之后查询才能看到50元
3. 可重复读(Repeatable Read)
- 事务1开始:用户A取钱,账户余额变为50元,但事务1还未提交。
- 事务2开始:系统读取用户A的余额,可重复读确保事务2在整个事务期间看到的数据是一致的,事务2读取到100元。
- 事务1提交:用户A的余额更新为50元,但事务2再次读取时依然看到100元,保持一致性。
- 问题:虽然解决了不可重复读,但可能出现“幻读”,即如果事务1在插入新数据时,事务2可能看到不同的数据集。
底层实现:
-
MVCC:通过多版本控制,每次事务开始时创建一致性视图,保证读操作始终看到事务开始时的数据版本。
-
Next-Key Lock:为了防止幻读,MySQL会对读取的记录和记录间的间隙加锁,阻止其他事务插入新记录。
-
代码实现:通过一致性视图保证同一事务内多次读取数据结果相同。
-- 事务1: START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE user_id = 1; -- 事务2: SELECT balance FROM accounts WHERE user_id = 1; -- 读取到100元 SELECT balance FROM accounts WHERE user_id = 1; -- 读取到100元,即使事务1提交 COMMIT; -- 事务1提交后,事务2仍然看到的是事务开始时的数据
4. 串行化(Serializable)
- 事务1开始:用户A取钱,账户余额变为50元。
- 事务2开始:系统想要读取用户A的余额,但串行化隔离级别要求所有事务串行执行,因此事务2必须等待事务1提交后才能读取数据。
- 事务1提交:事务2读取到最新的余额(50元)。
- 问题:串行化的性能非常低,因为所有并发事务都必须一个接一个地执行,虽然解决了所有并发问题,但严重影响系统性能。
底层实现:
- 锁机制:事务之间完全隔离,通过锁定整个数据表或行,确保事务顺序执行。
- 代码实现:所有事务串行执行,避免任何并发问题。
-- 事务1: START TRANSACTION; UPDATE accounts SET balance = balance - 50 WHERE user_id = 1; -- 锁定数据 -- 事务2: SELECT balance FROM accounts WHERE user_id = 1; -- 必须等待事务1提交后才能执行 COMMIT; -- 事务1提交后,事务2才开始执行
锁机制和MVCC的底层实现
1. 锁机制实现(伪代码)
在数据库引擎(如InnoDB)中,锁机制是通过管理锁表或内存结构来实现的。当事务试图对某个数据进行操作时,数据库会根据隔离级别判断是加共享锁还是排他锁。
// 事务1请求修改某行数据
if (requesting WRITE lock) {
if (no other transactions holding lock) {
// 加排他锁
grant EXCLUSIVE lock;
} else {
// 等待其他事务释放锁
wait for lock release;
}
}
// 事务2请求读取同一行数据
if (requesting READ lock) {
if (no exclusive lock) {
// 加共享锁
grant SHARED lock;
} else {
// 等待排他锁释放
wait for lock release;
}
}
2. MVCC的实现(伪代码)
MVCC 通过为每个事务创建数据的多个版本来实现。在InnoDB中,每行数据都有两个隐含的列:创建版本号和删除版本号,它们标识了事务对数据的修改时间。
-- 在读操作时,判断版本号
SELECT * FROM accounts
WHERE user_id = 1
AND create_version <= current_transaction_version
AND (delete_version IS NULL OR delete_version > current_transaction_version);
每次事务读写时,都会根据事务的版本号判断能看到哪些数据版本。
总结
- 隔离级别越高,事务之间的相互隔离就越彻底,数据的一致性越强,但并发性能也会下降。
- 锁机制通过加锁和排它控制,确保事务之间的隔离性;MVCC通过多版本控制,使得读取和写入操作可以同时进行,提高并发性能。
- 隔离级别的选择取决于业务场景对性能和一致性的需求。在高并发系统中,通常选择较低的隔离级别以提高性能,而在数据一致性要求较高的场景,则可能选择更高的隔离级别。