感谢阅读!❤️
如果这篇文章对你有帮助,欢迎 **点赞** 👍 和 **关注** ⭐,获取更多实用技巧和干货内容!你的支持是我持续创作的动力!
**关注我,不错过每一篇精彩内容!**
目录
- 一、Transaction事务
-
- [📊 事务的四大特性](#📊 事务的四大特性)
- [⏱️ 事务的生命周期](#⏱️ 事务的生命周期)
- [💥 事务的并发的三个现象/问题](#💥 事务的并发的三个现象/问题)
- [🛡️ 事务的隔离级别](#🛡️ 事务的隔离级别)
- 🔧查看和设置事务隔离级别
- [🧪 各个事务隔离级别测试](#🧪 各个事务隔离级别测试)
- [👻 可重复读隔离级别下的幻读问题](#👻 可重复读隔离级别下的幻读问题)
- 二、MVCC机制的核心原理
-
- [❓ 什么是MVCC](#❓ 什么是MVCC)
- [💡 MVCC作用](#💡 MVCC作用)
- [🧠 MVCC原理](#🧠 MVCC原理)
- [⚖️ MVCC在RC和RR下的不同表现](#⚖️ MVCC在RC和RR下的不同表现)
- [👁️ 演示如何根据ReadView确定是哪个版本内容](#👁️ 演示如何根据ReadView确定是哪个版本内容)
一、Transaction事务
- 事务是一个最小的工作单元,在数据库中,事务表示一件完整的事。
- 一个业务的完成可能需要多条
DML语句共同配合才能完成。例如:转账业务:需要执行两条DML语句,先更新张三账户的余额,再更新李四账户的余额,为了确保转账业务不出现问题,就必须保证这俩天DML语句要么同时成功,要么同时失败,怎么样保证同时成功或者同时失败呢?就需要使用事务机制。 - 也就是说用了事务机制之后,在同一个事务当中,多条
DML语句会同时成功或者同时失败,不会出现一部分成功,一部分失败的现象。 - 事务只针对
DML语句有效:因为只有这三个语句是改变表中数据的。(insert、update、delete)
📊 事务的四大特性
-
原子性(
Atomicity):事务中所有的操作要么全部成功完成,要么全部失败执行。例如:A像B转账1000元,必须同时完成"扣A 1000元 和 加 B 1000元",不能只做其中一步
-
一致性(
Consistency):事务执行前后,数据库中的数据应该保持一致的。例如:A和B的账户余额加起来一共20000元,不管它们之间进行多少次转账操作,总量20000元始终是不会变的,这就是事务的一致性。
-
隔离性(
Isolation):多个并发事务之间互不干扰,即使多个事务同时执行,每个事务都感觉像是独占数据库。例如:当多个用户并发访问数据库时,比如操作同一张表,数据库为每个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。事务就像每个教室一样,教室与教室之间有面墙,俩个教室中所发生的事情互不干扰。
-
持久性(
Durability):一旦事务被提交,那么对数据库的修改就是永久性的,即使数据库系统崩溃了也不会丢失提交事务的操作。通常通过写入日志(如Redo Logo)来实现的。
⏱️ 事务的生命周期
一个典型事务的生命周期包括以下阶段:
- 开始事务(
Begin/Start transaction) - 执行一系列数据库操作(
DML:Insert/Update/Delete) - 提交事务或回滚事务(
Commit:永久性保存修改;Rollback:撤销所有的修改)
只要执行了
Commit或者Rollback,事务都会结束。Mysql默认的事务机制是:自动提交事务,即只要执行一条DML语句则提交一次。
sql
-- 测试数据:之后都会使用该测试数据
DROP TABLE IF EXISTS CUSTOMER;
CREATE TABLE CUSTOMER(
ID INT(10) PRIMARY KEY AUTO_INCREMENT,
NAME VARCHAR(255) NOT NULL
);
INSERT INTO CUSTOMER(NAME) VALUES
('Tom'),
('Jack'),
('Lily'),
('Nina'),
('Joao');
-- 插入一条数据
INSERT INTO CUSTOMER(NAME) VALUES('Fabio');
-- 回滚数据,是无效的,因为Mysql默认自动提交事务
ROLLBACK;
💥 事务的并发的三个现象/问题
当多个事务并发执行时,可能会出现以下现象/问题
| 问题 | 描述 | 示例 |
|---|---|---|
脏读(Dirty Read) |
一个事务读取到了另一个未提交事务的修改数据 | T1 读取 x = 100,T2 修改 x 为 200,但未提交事务,T1 再次读取 x 的值,x 变为 200 了 |
不可重复读 (Non-Repeatable Read) |
同一个事务多次读取同一数据时,结果不一致 | T1 读取 x = 100,T2 修改 x 为 200,并提交事务,T1 再次读取 x 的值,x 变为 200 了 |
幻读(Phantom Read) |
同一个查询条件多次执行,返回的结果集的记录数不一样 | T1 查询年龄 < 30 岁的用户有 5 人,T2 插入一个 25 岁的用户,并提交事务,T1 再次查询有 6 个用户 |
🛡️ 事务的隔离级别
设置不同的事务隔离级别,可以解决上述的并发问题
- 隔离级别由低到高:读未提交< 读已提交 < 可重复读 < 串行化
- 不同的隔离级别会存在不同的现象,现象按照严重性从高到低:脏读 > 不可重复读 > 幻读
- 出现脏读,必然有不可重复读和幻读现象;出现不可重复读,必然有幻读现象。
Mysql的默认的事务隔离级别:可重复读Oracle的默认事务隔离级别:读已提交PostgreSQL的默认事务隔离级别:读已提交
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
读未提交(READ UNCOMMITTED) |
✅ 存在 | ✅ 存在 | ✅ 存在 |
读已提交(RADM COMMITTED) |
❌ 不存在 | ✅ 存在 | ✅ 存在 |
可重复读(REPEATABLE READ) |
❌ 不存在 | ❌ 不存在 | ✅ 存在 |
串行化(SERIALIZABLE) |
❌ 不存在 | ❌ 不存在 | ❌ 不存在 |
🔧查看和设置事务隔离级别
查看事务隔离级别
- 查看当前会话的事务隔离级别
sql
SELECT @@TRANSACTION_ISOLATION;
- 查看全局的事务隔离级别
sql
SELECT @@GLOBAL.TRANSACTION_ISOLATION;
设置事务隔离级别
- 设置当前会话的事务隔离级别
sql
SET TRANSACTION ISOLATION LEVEL <事务级别名称>
-- 示例
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
- 设置全局的事务隔离级别
sql
SET GLOBAL TRANSACTION ISOLATION LEVEL <事务级别名称>
-- 示例
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
🧪 各个事务隔离级别测试
读未提交(READ UNCOMMITTED)
sql
-- 验证是否会出现脏读现象
-- 设置全局事务隔离级别:READ UNCOMMITTED
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 重新开启俩个会话窗口,检查事务隔离级别是否修改成功,一个代表事务A,一个代表事务B,俩个事务使用同一个数据库中的同一张表
SELECT @@TRANSACTION_ISOLATION;
-- 事务A:开启事务
START TRANSACTION;
-- 事务B:开启事务
START TRANSACTION;
-- 事务A:查询CUSTOMER表中数据
SELECT * FROM CUSTOMER;
-- 事务B:插入一条新记录,不提交
INSERT INTO CUSTOMER(NAME) VALUES('Filip');
-- 事务A:再次查询CUSTOMER表中数据,发现读取到未提交事务B的新增的数据(出现了脏读)
SELECT * FROM CUSTOMER;
读已提交(READ COMMITTED)
sql
-- 验证是否会出现脏读现象
-- 设置全局事务隔离级别:READ COMMITTED
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 重新开启俩个会话窗口,查询事务隔离级别
SELECT @@TRANSACTION_ISOLATION;
-- 事务A:开启事务
START TRANSACTION;
-- 事务B:开启事务
START TRANSACTION;
-- 事务A:查询CUSTOMER表中数据
SELECT * FROM CUSTOMER;
-- 事务B:插入一条新数据,不提交
INSERT INTO CUSTOMER(NAME) VALUES('张三');
-- 事务A:再次查询CUSTOMER表中数据,发现与之前结果一样
SELECT * FROM CUSTOMER;
-- 事务B:提交事务
COMMIT;
-- 事务A:再次查询CUSTOMER表中数据,查询到了事务B提交的数据(没出现脏读)
SELECT * FROM CUSTOMER;
-- 验证是否出现不可重复读现象
-- 事务A:查询ID=2的记录信息
SELECT * FROM CUSTOMER WHERE ID = 2;
-- 事务B:更新ID=2的记录信息,并提交事务
UPDATE CUSTOMER SET NAME = 'Jack111' WHERE ID = 2;
COMMIT;
-- 事务A:再次查询ID=2的记录信息,发现NAME被修改为了Jack111(出现了不可重复读现象)
SELECT * FROM CUSTOMER WHERE ID = 2;
可重复读(REPEATABLE READ)
sql
-- 验证是否会出现不可重复读现象
-- 设置全局事务隔离级别:REPEATABLE READ
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 重新开启俩个会话窗口,查询事务隔离级别
SELECT @@TRANSACTION_ISOLATION;
-- 事务A:开启事务
START TRANSACTION;
-- 事务B:开启事务
START TRANSACTION;
-- 事务A:查询ID=3的记录信息
SELECT * FROM CUSTOMER WHERE ID = 3;
-- 事务B:更新ID=3的记录信息,并提交事务
UPDATE CUSTOMER SET NAME = 'Lily111' WHERE ID = 3;
COMMIT;
-- 事务A:再次查询ID=3的记录信息,和原先查询结果一样(没有出现不可重复读现象)
SELECT * FROM CUSTOMER WHERE ID = 3;
-- 验证是否会出现幻读
-- 事务A: 提交事务
COMMIT;
-- 事务A:开启事务
START TRANSACTION;
-- 事务B:开启事务
START TRANSACTION;
-- 事务A:查询ID在2~5之间的数据(4条记录)
SELECT * FROM CUSTOMER WHERE ID BETWEEN 2 AND 5;
-- 事务B: 删除ID=3的记录并提交事务
DELETE FROM CUSTOMER WHERE ID = 3;
COMMIT;
-- 事务A:查询ID在2~5之间的数据(使用的是"快照读",仍然是4条记录,没有出现幻读现象)
SELECT * FROM CUSTOMER WHERE ID BETWEEN 2 AND 5;
-- 事务A:查询ID在2~5之间的数据(使用"当前读",记录是3条记录,出现了幻读现象)
SELECT * FROM CUSTOMER WHERE ID BETWEEN 2 AND 5 FOR UPDATE;
快照读 和当前读,请查看本文中"可重复读隔离级别下的幻读问题"的内容。
串行化(SERIALIZABLE)
sql
-- 如果不存在的话,就插入ID=3的数据
INSERT INTO CUSTOMER VALUES(3,'Lily');
-- 设置全局事务隔离级别:SERIALIZABLE
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 重新开启俩个会话窗口,查询事务隔离级别
SELECT @@TRANSACTION_ISOLATION;
-- 事务A:开启事务
START TRANSACTION;
-- 事务B:开启事务
START TRANSACTION;
-- 事务A:查询ID在2~5之间的数据(4条记录)
SELECT * FROM CUSTOMER WHERE ID BETWEEN 2 AND 5;
-- 事务B: 删除ID=3的记录,此时事务B会阻塞等待事务A完成,这条语句无法执行完毕
DELETE FROM CUSTOMER WHERE ID = 3;
-- 事务A:结束事务A,事务B立刻执行完刚刚阻塞的语句;
COMMIT;
-- 事务B:提交事务
COMMIT;
-- 查询ID在2~5之间的数据(就剩3条记录)
SELECT * FROM CUSTOMER WHERE ID BETWEEN 2 AND 5;
👻 可重复读隔离级别下的幻读问题
MySQL默认的隔离级别是可重复读,在很大程度上避免了幻读问题,但并不能完全解决。在下列情况下会出现幻读问题:
- 同一个事务中,先使用了"快照读",再使用当前读
- 同一个事务中,先使用了"快照读",再使用了"
DML语句",再使用了"快照读"总结:
- 执行
DML语句之前,会进行一次"当前读";- 同一个事务中, 使用了俩种不同方式读,可能导致了幻读现象。
- 都使用快照读或者都使用当前读,可以避免幻读现象。但不能保证多次读取数据之间不执行
DML语句。
-
快照读(
MVCC方式):普通的
Select语句就是快照读 。快照读 读取的是某个一致性快照 中的数据,在整个事务的处理过程中,执行相同的一个Select语句,每次都是读取快照(快照指的是固定的某个时刻的数据,就像现实世界中的拍照语言,把某个时刻的内容保留下来)。快照读 是通过MVCC方式解决幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即便中途有其他事务插入一条数据,也是查询不出来这条数据的,所以就很好的避免了幻读问题。快照读是如何解决幻读问题
底层由
MVCC(多版本并发控制)实现,实现方式是在开始事务后,在执行第一个查询语句后,会创建一个Read View,后续查询语句利用这个Read View,通过这个Read View就可以在Undo Log版本链中找到事务开始时的数据,所以事务过程中每次查询的数据都是一样的,即使中途有其他事务插入新纪录,时查询不出来这条数据的,所以很好的避免了幻读问题。 -
当前读(锁的方式):
Select ... for update等语句就是当前读 。当前读 总是读取最新Committed数据。当前读 是通过next-key lock(记录锁和间隙锁)方式解决了幻读。因为当执行select ... for update语句时,会加上next-key lock,如果有其他事务在next-key lock锁范围内插入或删除一条记录,那么这个插入就会被阻塞,无法成功插入,所以很好的避免了幻读问题。执行DML语句之前,MySQL都会进行一次"当前读"。下面列举的这些语法的是当前读:
sqlSELECT ... LOCK IN SHARE MODE SELECT ... FOR UPDATE INSERT ... DELETE ... UPDATE ...
当前读是如何解决幻读问题
select ... for update原理是:对查询范围内的数据进行加锁,不允许其他事务对这个范围内的数据进行增删改。即select语句 范围内的数据是不允许并发的,只能排队执行,从而避免幻读问题。
select ... for update加的锁叫做:next-key Lock。也称为记录锁 + 间隙锁。记录锁用来保证在锁定的数据范围内不允许 Delete和Update操作;间隙锁用来保证锁定的数据范围内不允许Insert操作。
假如有这样的学生数据表:
| ID | NAME |
|---|---|
| 1 | 学生1 |
| 2 | 学生2 |
| 4 | 学生4 |
SQL语句是这样写的:
sql
SELECT * FROM STUDENT WHERE ID BETWEEN 2 AND 4 FOR UPDATE;
那么ID在[2, 4]区间的所有记录行都被锁定,不能删除或修改ID = 2和ID = 4的数据是通过记录锁来完成的,不能插入ID = 3的数据是通过间隙锁来完成的。
二、MVCC机制的核心原理
❓ 什么是MVCC
MVCC全称Multi-Version Concurrency Control,即多版本并发控制。
多版本:指MySQL维护着行数据的多个版本
并发控制:在多个事务同时操作某一行记录时,MySQL控制返回多个版本的行记录中的某个版本。
这里的多版本指的是数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在。

💡 MVCC作用
MVCC的目的主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁。
🧠 MVCC原理
三个核心点:
- 隐藏字段
Undo Log版本链ReadView
隐藏字段和
Undo Log版本链决定了返回的数据
ReadView+ 版本链访问规则/可见性算法 决定了返回哪个版本的数据
隐藏字段
对与使用InnoDB存储引擎的数据库表,它的聚簇索引记录中都包含俩个隐藏列
trx_id:当一个事务对某条聚簇索引记录进行改动时,就会把改事务的事务id记录在trx_id隐藏列中roll_pointer:回滚指针,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到Undo Log中,然后回滚指针指向每一个旧版本记录,于是就可以通过它找到修改前的记录。
InnoDB中每个事务都有一个唯一的事务id,叫transaction id,它是在事务开始时向InnoDB的事务系统中申请的,是按申请顺序严格递增的。

Undo Log版本链
如上图所示,针对id = 1001这条数据,都会将旧数据放到一条Undo Log中,记作改记录的一个旧版本,随着更新次数的增多,所有版本都会被roll_pointer指针连成一个链表,把这个链表成为版本链 。版本链 由页记录 + Undo Log组成。根据这个版本链就能找到这条数据的历史版本。
根据这个例子理解版本链是如何生成的:
| 事务ID | 操作类型 | 操作说明 |
|---|---|---|
(T1) 1 |
Insert |
插入初始记录:Id=1001,Name="张三",Age=21 |
(T2) 2 |
Update |
更新Id=1001的记录:Name="李四",Age=22 |
(T3) 3 |
Update |
更新Id=1001的记录:Name="李四111",Age=23 |
sql
-- 事务1:插入初始记录
INSERT INTO STUDENT(ID,NAME,AGE) VALUES(1001,"张三",21);
-- 事务2:开启事务,并更新ID = 1001的数据,提交事务
START TRANSACTION;
-- 事务3:开启事务,并更新ID = 1001的数据,不提交事务
START TRANSACTION;
-- 事务2:更新ID = 1001的数据并提交事务
UPDATE STUDENT SET NAME="李四",AGE=22 WHERE ID = 1001;
COMMIT;
-- 事务3:更新ID = 1001的数据并提交事务
UPDATE STUDENT SET NAME="李四111",AGE=23 WHERE ID = 1001;
COMMIT;
版本链示意图
plaintext
+--------------------------------------------------------------------------------------+
| 聚簇索引 - 当前记录行 (最新版本,存储在磁盘/内存页中) |
| +----------------+----------------+----------------+----------------+--------------+ |
| | 主键ID | trx_id | roll_pointer | name | age | |
| | 1001 | T3 | 指向Undo Log3 | 李四111 | 23 | |
| +----------------+----------------+----------------+----------------+--------------+ |
+--------------------------------------------------------------------------------------+
↓ (roll_pointer 指向)
+--------------------------------------------------------------------------------------+
| Undo Log3 (UPDATE_UNDO - T3事务更新前的版本) |
| +----------------+----------------+----------------+----------------+--------------+ |
| | trx_id | roll_pointer | name | age | 其他字段 | |
| | T2 | 指向Undo Log2 | 李四 | 22 | ... | |
| +----------------+----------------+----------------+----------------+--------------+ |
+--------------------------------------------------------------------------------------+
↓ (roll_pointer 指向)
+--------------------------------------------------------------------------------------+
| Undo Log2 (UPDATE_UNDO - T2事务更新前的版本) |
| +----------------+----------------+----------------+----------------+--------------+ |
| | trx_id | roll_pointer | name | age | 其他字段 | |
| | T1 | 指向Undo Log1 | 张三 | 21 | ... | |
| +----------------+----------------+----------------+----------------+--------------+ |
+--------------------------------------------------------------------------------------+
↓ (roll_pointer 指向)
+--------------------------------------------------------------------------------------+
| Undo Log1 (INSERT_UNDO - T1事务插入的初始版本,仅用于事务回滚,不参与MVCC读) |
| +----------------+----------------+----------------+----------------+--------------+ |
| | trx_id | roll_pointer | name | age | 其他字段 | |
| | T1 | NULL | 张三 | 21 | ... | |
| +----------------+----------------+----------------+----------------+--------------+ |
+--------------------------------------------------------------------------------------+

ReadView
利用undo Log已经保留下了各个版本的数据,现在关键的问题是需要读取哪个版本的数据。这时候就需要用ReadView。
ReadView 就是事务在使用 MVCC 机制进行快照读操作时产生的一致性视图。
ReadView包含 4 个关键属性,这些属性是判断版本可见性的核心依据:
| 字段名称 | 英文标识 | 核心含义 |
|---|---|---|
| 当前活跃事务 ID 集合 | m_ids |
生成 ReadView 时,当前数据库中所有未提交的活跃事务 ID的集合(不包含当前事务自身) |
| 最小活跃事务 ID | min_trx_id |
m_ids 集合中的最小值(当前最早的活跃事务 ID) |
| 最大事务 ID + 1 | max_trx_id |
生成 ReadView 时,数据库中即将分配的下一个事务 ID(即当前已存在的最大事务 ID + 1) |
| 当前事务 ID | creator_trx_id |
生成该 ReadView 的当前事务自身的 ID |

ReadView版本链数据访问规则
trx_id是当前事务ID
| 规则序号 | 判断条件 | 结论(是否可访问版本) | 补充说明 |
|---|---|---|---|
| 1 | trx_id = creator_trx_id |
可以访问该版本 | 成立,说明数据是当前这个事务更改的。(trx_id:代表当前记录版本的事务 ID;creator_trx_id:代表生成 ReadView 的当前事务 ID) |
| 2 | trx_id < min_trx_id |
可以访问该版本 | 成立,说明数据已经提交了。(min_trx_id:ReadView 中记录的最小活跃事务 ID) |
| 3 | trx_id > max_trx_id |
不可以访问该版本 | 成立,说明该事务是在 ReadView 生成后才开启。(max_trx_id:ReadView 中记录的最大事务 ID+1) |
| 4 | min_trx_id <= trx_id <= max_trx_id |
若 trx_id 不在 m_ids 中,可访问该版本 |
成立,说明数据已经提交。(m_ids:ReadView 生成时,数据库中所有未提交的活跃 |
⚖️ MVCC在RC和RR下的不同表现
Read Committed:每次快照读生成新的ReadViewRepeatable Read:只有第一次快照读生成ReadView,后面复用。
同事务内,Read Committed 隔离级别下,可能俩次快照读返回的是不同版本的记录;而Repeatable Read 隔离基本下则俩次快照读返回的是相同的版本记录。
这解释了为什么Read Committed 隔离级别下有不可重复读问题,而Repeatable Read 隔离基本是可重复读
👁️ 演示如何根据ReadView确定是哪个版本内容
用上述例子做演示并且是在可重复读隔离级别下做演示:
sql
-- 事务1 (trx_id=1):插入初始记录
INSERT INTO STUDENT(ID,NAME,AGE) VALUES(1001,"张三",21);
-- 事务2(trx_id=2):开启事务,并更新ID = 1001的数据,提交事务
START TRANSACTION;
-- 事务3(trx_id=3):开启事务,并更新ID = 1001的数据,不提交事务
START TRANSACTION;
-- 事务4(trx_id=4):开启事务,并第一次查询ID=1001的数据(此时生成了ReadView)
START TRANSACTION;
SELECT * FROM STUDENT WHERE ID = 1001;
-- 事务2(trx_id=2):更新ID = 1001的数据并提交事务
UPDATE STUDENT SET NAME="李四",AGE=22 WHERE ID = 1001;
COMMIT;
-- 事务3(trx_id=3):更新ID = 1001的数据并提交事务
UPDATE STUDENT SET NAME="李四111",AGE=23 WHERE ID = 1001;
COMMIT;
现在已经生成了版本链 ,和ReadView。
ReadView信息如下:
creator_trx_id |
m_ids |
min_trx_id |
max_trx_id |
|---|---|---|---|
| 4 | [2, 3] | 2 | 5 |
当开启事务4时,查询ID=1001的数据应该显示哪个呢?
sql
-- 事务4:再次查询ID=1004
SELECT * FROM STUDENT WHERE ID = 1001;
根据这个版本链示意图,一步一步分析:

- 第一步:
事务 4再次执行查询时,从版本链的页记录开始,页记录的trx_id = 3,因为trx_id < creator_trx_id并且trx_id是在m_ids中,所以页记录不可见。 - 第二步:顺着
roll_pointer进入到Undo Log中寻找到第一条记录(即row_id = 3),trx_id = 2因为trx_id < creator_trx_id并且trx_id是在m_ids中,所以当前版本不可见。 - 第三步:顺着
roll_pointer进入到Undo Log中寻找到第下一条记录(即row_id = 2),trx_id = 1因为trx_id < creator_trx_id并且trx_id < min_trx_id,所以当前版本可见,返回该版本数据。
所以查询到的结果还是:NAME = "张三", AGE = 21