事务是一组有逻辑关系的 SQL 语句的集合,这些 SQL语句合起来完成某一项功能,并且这一组 SQL 语句执行时要么全部成功,要么全部失败,是一个整体。MySQL 提供一种机制保证我们达到这样的效果,这就是 MySQL 中的事务。
以大家很熟悉的12306购票系统为例,假设 “将用户的状态设置为已购票” 以及 “系统剩余票数减一” 分别代表一条 SQL 语句,那么这两条 SQL 语句组合起来就代表 “购票” 这个事务;这个事务中的 SQL 语句要么全部执行成功,代表购票成功,要么全部执行失败,代表购票失败,而不能出现某一部分执行成功,而另一部分执行失败的情况。
为什么要要有事物:
其实 MySQL 最开始的时候是没有事务这个概念的,事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,即不需要程序员去考虑各种各样的潜在错误和并发问题。因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
所以,我们也应该站在 MySQL 的上层,即用户的视角 (具体的业务逻辑) 来看待事务,而不能从程序员 (简单的几条 SQL 语句) 的角度来看待事物。
在一个数据库服务 mysqld 中,绝大多数情况下都不止一个事务在运行,甚至在某一个时间范围内,会有大量来自不同 mysql 客户端的请求被包装成事务,向 mysqld 发起事务处理请求。而每个事物都是一条或多条 SQL 语句,那么如果大家都访问同样的表数据,在不加保护的情况,就绝对会出现问题。甚至因为事务由多条 SQL 构成,可能还会发生事务执行到一半出错或者不想再执行的情况,这在某些事务场景下就会出现问题,比如上面的购票。
所以,一个完整的事务,绝对不是简单的 sql 集合,还需要满足如下四个属性:
这四个属性,可以简称为事务的 ACID 特性:原子性 (Atomicity) 、一致性 (Consistency)、隔离性 (Isolation) 和持久性 (Durability).
如何理解事务的 ACID 特性:
在 MySQL 数据库最流行的两种存储引擎 InnoDB
与MyISAM
中,只有InnoDB
支持事务,而MyISAM
是不支持事务的。我们可以通过show engines
指令来查看不同存储引擎是否支持事务 (transaction)。
事务的提交方式常见的有两种:
我们可以通过以下语句来查看事务的提交方式:
show global variables like 'autocommit';
show session variables like 'autocommit';
或者 show variables like 'autocommit';
其中 “ON” 代表自动提交开启,“OFF” 代表自动提交关闭。
我们也可以通过 set 来改变事务提交模式:
set [global/session] autocommit = 'OFF';
上面我们说过,MySQL 服务 (mysqld) 可能会同时被多个客户端进程 (线程) 访问,访问的方式以事务方式进行。同时,一个事务可能由多条 SQL 语句构成,而 SQL 语句的执行是需要时间的,这也就意味着任何一个事务都有执行前、执行中以及执行后的阶段。特别是对于长事务来说,执行中这个阶段会比较明显。
需要注意的是,事务有执行前、执行中、执行后三个阶段与事务的原子性并不冲突:
在数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特性 – 隔离性;同时,数据库也允许事务受一定不同程度的干扰,所以为隔离性设置了不同的隔离级别。
MySQL 事务一共有四种隔离级别:
-隔离级别 | -赃读 | 不可重复读 | 幻读 | 加锁读 |
---|---|---|---|---|
读未提交 RU | 存在 | 存在 | 存在 | 不加锁 |
读提交 RC | 不存在 | 存在 | 存在 | 不加锁 |
可重复度 RR | 不存在 | 不存在 | 不存在 (仅针对 MySQL) | 不加锁 |
串行化 Serializable | 不存在 | 不存在 | 不存在 | 加锁 |
MySQL 中隔离级别分为全局隔离级别与会话隔离级别,全局隔离级别是指以后每次登录 mysql 的默认隔离级别,会话隔离级别是指当前会话的隔离级别,它通常与全局隔离级别相同。
查看隔离级别 (tx 是 transaction 的缩写,代表事务,isolation 代表隔离):
select @@global.tx_isolation
;select @@session.tx_isloation
或者select @@tx_isolation
;设置隔离级别:
set global transaction isolation level 隔离级别
;set session transaction isolation level 隔离级别
或者set transaction isolation level 隔离级别
;需要注意的是,我们设置全局隔离级别并不会影响当前正在运行的会话的隔离级别,只会影响后续新起会话的隔离级别。
准备测试数据 (my.cnf 中配置了创建表默认使用 InnoDB 存储引擎):
create table account (
id int primary key,
name varchar(20) not null,
balance float(8,2) not null
);
insert into account values(1, '张三', 1234);
insert into account values(2, '李四', 2538);
将当前会话隔离级别设置为读未提交 [read uncommitted]:
现在,我们同时启动两个事务,来模拟多客户端多事务并发的场景:(启动事务:start transaction
或者 begin
,提交事务:commit
)
可以看到,RU 隔离模式下,在两个并发执行的事务中,只要一个数据对表中的数据做修改之后另一个事务马上就能看到,即使执行修改操作的事务并没有提交。这种一个事务在执行中读到另一个执行中事务的更新/删除/修改但是未 commit 的数据的现象叫做赃读。
需要注意的是,由于事务原子性的存在,只要我们将修改事务 commit 之后,数据的修改就是永久的;当然,我们也可以在事务未 commit 之前进行 rollback 回滚,这样修改操作就会被复原。
将当前会话隔离级别设置为读提交 [read committed]:
同时启动两个事务后观察发现:在事务 A 中无论我们对表数据进行增加还是删除,只要事务 A 还没有 commit,那么事务 B 的查询结果都是不变的;只有当事务 A commit 之后,事务 B 才能看到表数据的变化。这就是读提交 – 一个执行中的事务只能读取到其他已提交事务的修改数据。
同时,读提交可能会造成一个执行中的事务前后两次 select 相同的表查询到的结果不同,这种现象叫做不可重复读。
将当前会话隔离级别设置为可重复读 [repeatable read]:
同时启动两个事务我们可以发现:无论事务 A 是否 commit,事务 A 对表数据的修改 (insert/update/delete) 事务 B 都不可见;而只有当事务 B 也提交之后,再下一个事务才能看到事务 A 对表数据的修改。这样执行中的事务就完全看不到其他事务对表数据的修改,也就不会产生不可重复读问题了。
需要注意的是,我们在学习事务之前敲的一条条 SQL 指令,其实也会被封装成事务,只是由于系统的事务自动提交方式默认是打开的,即系统会自动将但 SQL 语句封装成事务,然后自动 commit,所以我们感知不到。如果我们将事务自动提交关掉,那么 SQL 语句就需要手动 commit 了。
同时,我们发现 MySQL 在 RR 模式下,其他事务无论是插入、更新还是删除数据,都不会影响当前执行中的事务。但其实一般的数据库在 RR 模式下,并无法屏蔽其它事务 insert 的数据;这是由于因为隔离性实现是对数据加锁完成的,而insert待插入的数据因为并不存在,所以一般加锁无法屏蔽这类问题。
这样就会造成虽然大部分内容是可重复读的,但是 insert 的数据仍然会在可重复读情况被读取出来,导致多次查找时,会多查找出来新的记录,就如同产生了幻觉,这种现象就叫做幻读。而 MySQL 在 RR 隔离级别下使用 Next-Key 间隙锁解决了幻读问题。
将当前会话隔离级别设置为串行化 [serializable]:
同时启动两个事务我们可以发现:由于串行化是对所有临界资源的写操作添加共享锁 (只能多事务共享读,不能写),所以如果事务 B 在读取数据,而事务 A 要插入数据,那么事务 A 必须等待事务 B commit 释放共享锁,然后事务 A 成功申请到数据的排他锁 (只能单事务写,不能读) 后才能修改数据。
由于串行化需要通过加锁来达到事务串行的进行 “写” 的目的,所以并发度非常低 (只有读并发,没有写并发),效率慢,所以实际开发中基本不使用。
最后,需要说明的是,我们上面的实验都是基于事务读写并发的场景进行的,这也是数据库面临最多的情况,另外,不同隔离级别下的赃读、不可重复读、幻读这些问题也只会发送在读与写的并发执行中发生;而关于事务的其他两个并发场景我们简单理解即可:
事务隔离级别越严格,安全性越高,但数据库的并发性能也就越低,所以往往需要在两者之间找一个平衡点,而 MySQL默认的隔离级别是可重复读,在此隔离级别下数据不会发生赃读、不可重复读以及幻读问题,且不用加锁,并发度较高,是一个比较不错的选择。
但是数据库不能只提供可重复度这一种隔离级别,因为数据库的应用场景是多样的,用户需要 MySQL 提供不同的隔离级别来供用户在不同场景下进行选择。
MySQL 四种隔离级别具体的特点如下:
-隔离级别 | -赃读 | 不可重复读 | 幻读 | 加锁读 |
---|---|---|---|---|
读未提交 RU | 存在 | 存在 | 存在 | 不加锁 |
读提交 RC | 不存在 | 存在 | 存在 | 不加锁 |
可重复度 RR | 不存在 | 不存在 | 不存在 (仅针对 MySQL) | 不加锁 |
串行化 Serializable | 不存在 | 不存在 | 不存在 | 加锁 |
MySQL 隔离级别以及各种锁 (包括间隙锁) 相关文章阅读推荐:
https://tech.meituan.com/2014/08/20/innodb-lock.html
https://www.cnblogs.com/aspirant/p/9177978.html
我们上面学习了事务的隔离性,知道了事务有不同的隔离级别,那么不同隔离级别到底是如何解决多事务并发过程数据的赃读、不可重复读以及幻读问题的呢?特别是 RC 与 RR 模式的区别到底是如何做到的?答案就是多版本并发控制 MVCC (Multi-Version Concurrency Control)。
多版本并发控制( MVCC )是一种用来解决 读-写冲突
的无锁并发控制。要理解 MVCC,我们需要先学习三个前提知识:
undo
日志。read view
类。在 MySQL 中,我们在创建表时,除了我们自己手动指定的列信息,实际上 MySQL 还会自动为表添加三个隐藏列:
其实除了上面这三个列字段,还有一个 flag 隐藏字段, 它用来标记一个记录是否被删除。这样,当需要删除表中的某个记录时,只需要将对应的 flag 标记为置为删除状态即可,而不需要真的在 Page 中进行线性移动来删除,从而提高效率;同时也有助于数据恢复。
我们以一个 student 表为例,假设我们创建的表结构如下 (没有手动指明主键):
create table student (
name varchar(20) not null,
age tinyint unsigned not null
);
insert into student values ('张三', 28);
那么,在我们用户看来,student 表的标结构如下:
-name | -age |
---|---|
张三 | 28 |
而实际上在 MySQL 数据库中 student 表的结构如下:
-name | age- | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
注:由于我们并不知道创建该记录的事务ID与隐藏主键,所以默认设为 null、1;同时,第一条记录也没有以前版本,我们设置回滚指针为 null。
在上一节学习索引的时候我们说过,MySQL 是以服务进程即 mysqld 的形式在内存中运行的,也就是说,我们之前所进行的所有操作,包括索引、事务 (即表的CURD操作),隔离性、日志等都是在内存中进行的。
同时,我们前面还提到,为了提高效率以及更好的管理 MySQL 中的各种数据,mysqld 会在启动时申请一块大的内存空间 buffer poll (默认大小为128M),以后新增 Page 就直接从 buffer poll 中申请空间,就类似于 C++ 中的空间配置器。所以,MySQL 中的各种操作其实是在 buffer poll 中完成并保存的。最后 MySQL 会在合适的时候,通过OS文件系统以16KB为单位将相关数据刷新到磁盘当中。
而 undo log 其实就是 buffer poll 中的一块内存空间,它专门用来保存快照日志数据,便于后续的回滚操作以及多版本并发控制。(其实 buffer poll 除了 undo log 之外,还有其他的类型的 log,但这里我们不深入探究)
为了理解 MVCC,我们可以来手动模拟一下 MVCC。
假设现在我们有一个事务10(随机编号,仅仅为了好区分),要对 student 表中的记录进行修改 (update):将 name (张三) 改成 name (李四);那么其执行过程如下:
undo log
中,此时 undo log
中就有了一行 ‘张三’ 副本数据。(类似于写时拷贝)undo log
中的 ‘张三’ 副本数据,表示我的上一个版本就是它;而 undo log
中的记录保持不变。示意图如下:(注意:实际上 undo log 中只会保存 ‘张三’ 这一行的信息,不会保存最上面的属性行,这里带上只是为了方便看)
现在又有一个事务11 (事务ID是自增长的),要对 student 表中的记录进行修改 (update):将 age(28) 改成 age(38);那么其执行过程如下:
undo log
中的 ‘张三’ 行。undo log
中,所以,undo log
中现在又多了一条 ‘李四’ 副本数据,对于新的副本数据,我们采用头插的方式插入 undo log
。undo log
中的 ‘李四’ 副本数据,表示我的上一个版本就是它。示意图如下:
这样,我们就得到了一个基于链表结构的记录的历史版本链。而所谓的回滚,其实就是用 undo log
中的某一历史版本数据来覆盖 Page 中的当前数据;所谓的隔离性,其实就是让不同的事务看到不同版本的数据。
上面的一个一个版本,我们可以称之为一个一个的快照。
关于快照的事务ID问题:
对于回滚操作来说,如果事务要对表数据进行修改,那么 MySQL 一般会在第一次修改前保存一份快照,后面每一个 setpoint 操作再形成一个快照;这样以后如果想在事务未 commit 时回滚,就可以通过版本链找到对应的快照进行回滚。而由于这些修改操作都是同一个事务进行的,所以这些快照中的事务ID都是相同的。 而对于版本链中不同事务ID的快照,则主要用于实现隔离性,即当前事务执行修改操作但未commit时,其他事务访问的是该行数据的哪一个历史版本。 我们上面举例使用的是不同事务ID的快照,这些快照不能用于回滚,只能用于事务隔离。
关于 undo log 空间不足的问题:
有的同学可能会有疑问,既然每条记录的每次修改操作都会形成对应的快照保存到 undo log 中,那么会不会出现 undo log 被打满的情况呢? 答案是并不会 – 对于 undo log 中的快照,如果只有一个事务在访问当前行,那么该事务 commit 之后 undo log 中该行的版本链就会被 free 掉;如果有其他事务也在访问该行 (比如RR隔离等级下其他事务在 select 该行的某个快照),那么该行的版本链会等待其他事务都访问完毕后,即其他事务 commit 后再 free。
注意事项:
我们上面是以修改 (update) 操作为主学习的 MVCC,那么如果是 delete 操作呢?其实是一样的,因为 MySQL 删数据不是真的删除,而是设置 flag 为删除状态,它也可以形成快照。 如果是 insert 操作呢?由于 insert 之前表中并没有对应的数据,那么 insert 也就没有历史版本;但是一般为了回滚操作,insert 的数据也会放入 undo log 中,当执行 insert 操作的事务 commit 之后,undo log 中对应的 insert 快照也就可以被 free 了。(因为没有其他事务在此前会访问表中并不存在的数据)
快照读 && 当前读
我们上面讨论了 update/insert/delete 操作的快照问题,那么 select 呢?首先可以明确的是,由于 select 并不会修改数据,所以并没有必要为 select 维护多版本。
其实 select 的问题不在形成多版本,而在读取时应该读取哪个版本?是应该读取最新的版本呢?还是应该读取历史的某一个版本?针对这个问题,select 被分为了当前读和快照读:
通过之前的实验我们可以看到,在多事务写写并发的情况下,由于都要修改数据,所以需要加锁进行当前读。而在多事务读写并发时,如果读事务需要读取最新数据 (当前读),那么此时就必须串行化。
而如果读事务读取的历史数据 (快照读) 的话,我们就可以不必加锁,让当前读与快照读并发执行,从而提高效率,这也是 MVCC 存在的意义之一。
而到底是当前读还是快照读是取决于事务的隔离性的 – 在 RU 隔离级别下,并发事务读取的总是最新的数据,所以是当前读;而在 RC 与 RR 隔离级别下,并发事务读取的是历史版本的数据,此时就是快照读。(Serializable 需要加锁,不考虑)
read view
那么如何保证并发的不同事务看到自己应该看到的历史版本呢?即如何实现隔离级别呢?答案是 read view
。
Read View
就是事务进行 快照读
操作的时候生产的 读视图
(Read View);在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,此快照记录并维护系统当前活跃事务的ID (当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
Read View
在 MySQL 源码中就是一个类,本质是用来进行可见性判断的。 即当某个事务执行快照读的时候,会对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
read view 类的部分成员如下:
class ReadView {
// 省略...
private:
/** 高水位,大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id;
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务id列表*/
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
其中最核心的字段有如下四个:
m_ids; // 一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; // 记录m_ids列表中事务ID最小的ID (没有写错)
low_limit_id; // ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1 (也没有写错)
creator_trx_id // 创建该ReadView的事务ID
现在我们在进行快照读的时候,即可以根据事务ID找到不同版本的快照,又可以通过 read view 对象取出上面四个核心变量;那么我们如何判断当前快照读应该读取哪个版本的快照呢?我们用一张图来说明:
上图一共被分为了三段,我们来一一解读:
up_limit_id
与快照中的事务ID进行对比,由于 up_limit_id
代表的是生成 read view 这一时刻与我并发执行的所有事务的最小ID,如果快照ID比此ID要小,那么说明修改此数据的事务在我生成 read view 时已经提交了,所以我应该看到这个数据,快照选择成功。low_limit_id
与快照中的事务ID进行对比,由于 low_limit_id
代表的是生成 read view 这一时刻系统尚未分配的下一个事务ID,如果快照ID比此ID要大或者相等,那么说明修改此数据的事务在我生成 read view 时还没有开始运行,只是由于它的执行时间较短 (可能为短事务),所以要比我先执行完毕,所以我不应该看到这个数据,选择失败,顺着版本链继续向后选择。up_limit_id
并且小于 low_limit_id
,那么说明此快照是由在我生成 read view 这一时刻与我并发执行的事务形成的;此时分为两种情况: m_ids
中,说明该事物在我生成 read view 时和我是并发的,都是活跃事务,没有 commit,所以不应该看到,继续向后选择。m_ids
中,说明在我生成 read view 这一时刻该事物已经 commit 了,此时不管是 RC 还是 RR 隔离级别下都应该看到,选择成功。MySQL 中关于上面快照选择逻辑的源码如下:
/**
* 可见性判断流程
* @param view 当前事务 readview 快照
* @param trx_id 数据行对应的事务 id
* @return
*/
bool read_view_sees_trx_id(
const read_view_t* view,
trx_id_t trx_id)
{
// 如果小于当前事务的最小 id
if (trx_id < view->up_limit_id) {
return(true);
// 如果大于等于当前事务快照的最大 id
} else if (trx_id >= view->low_limit_id) {
return(false);
} else {
// 如果在两者之间
ulint lower = 0;
ulint upper = view->n_trx_ids - 1;
ut_a(view->n_trx_ids > 0);
// 基于当前活跃的事务数组,通过二分法查找比较 trx_id 是否存在其中
do {
ulint mid = (lower + upper) >> 1;
trx_id_t mid_id = view->trx_ids[mid];
if (mid_id == trx_id) {
return(FALSE);
} else if (mid_id < trx_id) {
if (mid > 0) {
upper = mid - 1;
} else {
break;
}
} else {
lower = mid + 1;
}
} while (lower <= upper);
}
// 不在活跃事务中则当前数据行可见
return(true);
}
下面我们举一个例子来复盘一下快照读的流程:
假设当前有记录如下:
-name | age- | DB_TRX_ID(创建该记录的事务ID) | DB_ROW_ID(隐藏主键) | DB_ROLL_PTR(回滚指针) |
---|---|---|---|---|
张三 | 28 | null | 1 | null |
事务操作如下:
-事务1 [id=1] | -事务2 [id=2] | 事务3 [id=3] | 事务4 [id=4] |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
… | … | … | 修改且已提交 |
进行中 | 快照读 | 进行中 | |
… | … | … |
事务四执行的修改操作如下:修改 name (张三) 变成 name (李四);此时的版本链如下:
当事务2对某行数据执行了快照读,数据库会为该行数据生成一个 Read View 读视图,如下:
// 事务2的 read view
m_ids; // 1,3
up_limit_id; // 1
low_limit_id; // 4 + 1 = 5,ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id // 2
由于只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务,所以我们的事务2在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID
去跟 up_limit_id
, low_limit_id
和活跃事务ID列表 (trx_list) 进行比较,判断当前事务2能看到该记录的版本。如下:
// 事务4提交的记录对应的事务ID
DB_TRX_ID=4
// 比较步骤
DB_TRX_ID(4)< up_limit_id(1) ? 不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
由于快照ID大于等于事务2的 up_limit_id
,小于 low_limit_id
,并且不在事务2的 m_ids
中,事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。
我们可以通过一个简单的测试来引出 RR 与 RC 的本质区别。
设置会话模式 RR 隔离级别:
我们依旧以 user 表为例:
create table if not exists user(
id int primary key,
age tinyint unsigned not null,
name varchar(20) not null default ''
)ENGINE=InnoDB DEFAULT CHARSET=UTF8;
insert into user values (1, 15,'黄蓉');
测试一的事务操作如下:
-事务A操作 | -事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 启动事务 | 启动事务 | begin |
select * from user | 快照读 | 快照读 | select * from user |
update user set age=18 where id=1 | 更新 age=18 | - | - |
commit | 提交事务 | ||
select 快照读,没有读到age=18 | select * from user | ||
select lock in share mode当前读 , 读到age=18 | select * from user lock in share mode |
测试二的事务操作如下:
-事务A操作 | -事务A描述 | 事务B描述 | 事务B操作 |
---|---|---|---|
begin | 启动事务 | 启动事务 | begin |
select * from user | 快照读 | - | - |
update user set age=28 where id=1 | 更新 age=28 | - | - |
commit | 提交事务 | ||
select 快照读,age=28 | select * from user | ||
select lock in share mode 当前读 , 读到age=28 | select * from user lock in share mode |
对比测试一和测试二,我们可以发现测试一与测试二唯一的区别在于测试一的事务B在事务A未 commit 之前进行了一次快照读;而测试二的事务B是在事务A commit 之后才进行快照读的。但是读出来的结果却大相径庭。
所以结论就是事务快照读的结果取决于该事物首次出现快照读 (创建 read view 对象) 的地方,即某个事务中首次出现的快照读决定了该事务后续快照读结果的能力。delete 也是如此。
有了这个结论之后,RR 与 RC 的本质区别也就出来了:
多版本并发控制 (MVCC) 是一种用来解决 读-写冲突
的无锁并发控制。MySQL 为事务分配单向增长的事务ID,也为每个修改保存一个版本,并且让版本与事务ID关联。此后读操作 (快照读) 只读该事务开始前数据在 undo log 中的快照,写操作 (当前读) 只修改 Page 中的最新数据。
基于上面的机制,MVCC 可以为数据库解决以下问题:
注意:这里的读操作指的是快照读,写操作指的是当前读,当前读与当前读是不能同时进行的,这属于写写并发。