你真的理解可重复读吗?从幻读到 MVCC,从实操搞懂
你真的理解可重复读吗?从幻读到 MVCC,从实操搞懂
在数据库领域,可重复读(Repeatable Read)是一个常见的隔离级别,但其具体含义和实现机制往往容易被误解。本文通过一个银行取款场景,详细解释了可重复读的实现机制和常见误解,帮助读者深入理解这一概念。
疑问
在学习数据库隔离级别时,看到的一篇文章里:
可重复读(Repeatable Read/RR)
又称为幻读,一个事务读可以读取到其他事务提交的数据,但是在RR隔离级别下,当前读取此条数据只可读取一次,在当前事务中,不论读取多少次,数据仍然是第一次读取的值,不会因为在第一次读取之后,其他事务再修改提交此数据而产生改变。因此称为幻读,因为读出来的数据并不一定就是最新的数据。(快照读)
举个例子:某已婚男欲购买一套新房准备迎接新生命的到来,购房款 330万 ,付款的时候查询自己账号余额 360万(第一次查询),在这个时候,该已婚男子的爱人无法从该已婚男账户转出余额,接下来就可以正常付款了。
这个例子不知大家怎么想,给我感觉怪怪的。我通过自身尝试不仅推翻了这个例子,而且还了解到了关于MVCC版本链的深层原理。
实战准备
我们模拟一下上述场景,去银行取钱的场景,建表进行实际测试。
表很简单,一个id和一个account字段。一会我们主要是对account字段进行操作。为了便于发现一些深层次的问题,account我就不设为unsigned了,方便大家查看全流程。
CREATE TABLE `bank` (
`id` int NOT NULL AUTO_INCREMENT,
`account` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
插入一条数据,也就是我们的测试数据。我们先让金额开始都是500
INSERT INTO `bank` VALUES (1, 500);
先看一下数据库的隔离级别,对当前会话我们使用 REPEATABLE READ
show variables like 'transaction_isolation';
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
可以看到显示的是可重复读级别。
准备一下接下来的两个事务SQL
事务A
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
SELECT account FROM bank WHERE id = 1;
SELECT account FROM bank WHERE id = 1 for UPDATE;
COMMIT
事务B
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
COMMIT
实战
先执行事务A的查询
-- 事务A
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
由MVCC机制,REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView。此时这个select是快照读。
然后我们开启事务B并进行更新,但不提交
-- 事务B
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
事务A先查一下,此时还是原数据(因为快照读)。执行更新
-- 事务A
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
执行更新后事务A会被锁住,等待事务B提交(这里能知道,可重复读中,先执行update的会阻塞后执行update的,跟场景举例不符)
-- 事务B
COMMIT
事务B提交后,可以查询数据库看到account已经从500变为200
此时事务A已经解锁,我们再从事务A查询一下
-- 事务A
SELECT account FROM bank WHERE id = 1;
事务A查到了-100!
用当前读查询:
SELECT account FROM bank WHERE id = 1 for UPDATE;
结果仍然是-100!
不对啊?select不是快照读吗,select后加for update才是当前读。按理说快照读应该只会读到500-300=200,为什么能直接读到了-100!
另外请注意,事务A还没提交,所以数据库查询出当时数据仍然是200.
我们最终提交事务A
-- 事务A
COMMIT
数据库数据变为-100。
我了解到:只有以下显示声明当前读的语句才会读取最新数据并加锁:
UPDATE
/
INSERT
/
DELETE
(写操作隐含当前读)
SELECT ... FOR UPDATE
(共享锁)
SELECT ... LOCK IN SHARE MODE
(排他锁)
当时我有了猜想:会不会是update更新了数据覆盖到了快照上?还是说跟阻塞住有关?我们往下看
那如果让事务B火速提交,让A都无法阻塞住呢?
-- 事务A
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
-- 事务B
START TRANSACTION;
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
COMMIT
-- 事务A
SELECT account FROM bank WHERE id = 1;
UPDATE bank SET account = account - 300 WHERE id = 1;
SELECT account FROM bank WHERE id = 1;
COMMIT
事务B提交完后,事务A的第一个select中,查询结果仍然是500,符合快照读
但是update执行完后(此时因事务B已经提交,不会阻塞),再进行查询,account变为-100!
说明跟update有关系,可能是update改变了快照。
我查了一些MVCC的资料:
MVCC 的核心规则
- 每个事务的修改会生成新版本,记录在undo log中,并通过版本链串联历史版本。
- 事务可见性规则:
- 自己的修改:事务内的所有操作(包括快照读)始终可见自己的修改。
- 其他事务的修改:
- 快照读(普通
SELECT
):根据隔离级别决定是否可见(如可重复读不可见其他事务已提交的修改)。 - 当前读(
UPDATE
/
FOR UPDATE
等):可见其他事务已提交的最新修改。
MVCC 的工作流程
- 事务开始:InnoDB 为每个事务创建一个 Read View。
- 数据读取:
- 快照读:读取数据的可见版本,不加锁。
- 当前读:读取最新的数据版本,并加锁。
- 数据修改:事务创建数据的新版本,并将其添加到版本链中。
- 事务提交:提交后,数据版本对其他事务可见。
- 垃圾回收:系统定期清理不再需要的旧版本。
从这我们能知道,update是当前读,此时已经将数据更新到了最新版本链
步骤模拟
步骤 1:事务 A 启动并快照读
START TRANSACTION;
SELECT account FROM bank WHERE id = 1; -- 快照读,读取版本 V1(500)
- 生成事务 A 的一致性视图(包含所有已提交事务的版本,不包含未提交事务)。
- 版本链:当前最新版本为
V1:500
(无其他事务修改)。
步骤 2:事务 B 提交修改
UPDATE bank SET account = 200 WHERE id = 1; -- 生成新版本 V2:200(提交后变为已提交版本)
- 版本链:
V2:200
(已提交) ←
V1:500
(旧版本)。
步骤 3:事务 A 执行 UPDATE(当前读)
UPDATE bank SET account = account - 300 WHERE id = 1;
- 当前读:读取最新已提交版本
V2:200
。 - 生成新版本
V3:-100
(事务 A 未提交,版本状态为“正在写入”)。 - 版本链:
V3:-100
(未提交) ←
V2:200
(已提交) ←
V1:500
。
步骤 4:事务 A 执行普通 SELECT(快照读)
SELECT account FROM bank WHERE id = 1;
- 可见性判断:
- 优先查找自己的修改:事务 A 发现版本链中存在自己未提交的版本
V3:-100
。 - 直接读取
V3:-100
(无需遍历版本链),因为事务内的修改对自身可见。
总结
快照读在事务内会看到自己的修改,因为:
- MVCC 的可见性规则允许事务访问自己未提交的版本。
- 快照读的一致性视图仅过滤其他事务的未提交版本,不过滤自己的。
版本链比对规则:
- 如果 row 的 trx_id 落在绿色部分( trx_id可见的);
- 如果 row 的 trx_id 落在红色部分( trx_id>max_id ),表示这个版本是由将来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的);
- 如果 row 的 trx_id 落在黄色部分(min_id <=trx_id<= max_id),那就包括两种情况
- a. 若 row 的 trx_id 在视图数组中,表示这个版本是由还没提交的事务生成的,不可见(若 row 的 trx_id 就是当前自己的事务是可见的);
- b. 若 row 的 trx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见。
那开头的这个例子就应该描述成:某已婚男欲购买一套新房准备迎接新生命的到来,购房款 330万 ,付款的时候查询自己账号余额 360万(第一次查询),在这个时候,该已婚男子的爱人从该已婚男账户转出余额50万并提交,已婚男想买房则提示余额不足!
没想到我们习以为常出现在面试题上的可重复读能牵扯出这么多疑问。这也告诉我们要多动手尝试,书上写的网上写的不一定是对的,实践是检验真理的唯一标准!!!