MYSQL 事务原理及使用

MYSQL 事务原理及使用

  • [MySQL 事务与隔离性笔记](#MySQL 事务与隔离性笔记)
    • 一、事务基础
      • [1. 为什么需要事务](#1. 为什么需要事务)
      • [2. 什么是事务](#2. 什么是事务)
      • [3. 为什么会出现事务](#3. 为什么会出现事务)
      • [4. 事务的版本支持](#4. 事务的版本支持)
      • [5. 事务提交方式](#5. 事务提交方式)
      • [6. 正常演示 - 证明事务的开始与回滚](#6. 正常演示 - 证明事务的开始与回滚)
      • [7. 非正常演示1 - 证明未 commit 客户端崩溃 MySQL 自动回滚](#7. 非正常演示1 - 证明未 commit 客户端崩溃 MySQL 自动回滚)
      • [8. 单条 SQL 与事务的关系](#8. 单条 SQL 与事务的关系)
      • [9. 结论](#9. 结论)
      • [10. 总结](#10. 总结)
    • 二、事务的隔离性
      • [1. 隔离性通俗理解](#1. 隔离性通俗理解)
      • [2. 如何理解隔离性](#2. 如何理解隔离性)
      • [3. 四种隔离级别](#3. 四种隔离级别)
      • [4. 查看与设置隔离性](#4. 查看与设置隔离性)
      • [5. 读未提交(Read Uncommitted)实验](#5. 读未提交(Read Uncommitted)实验)
      • [6. 读提交(Read Committed)实验](#6. 读提交(Read Committed)实验)
      • [7. 可重复读(Repeatable Read)实验](#7. 可重复读(Repeatable Read)实验)
      • [8. 串行化(Serializable)实验](#8. 串行化(Serializable)实验)
      • [9. 隔离级别总结](#9. 隔离级别总结)
      • [10. 一致性(Consistency)](#10. 一致性(Consistency))
    • 三、隔离性的具体实现(MVCC)
      • [1. 如何理解隔离性2](#1. 如何理解隔离性2)
      • [2. 读-写与 MVCC](#2. 读-写与 MVCC)
      • [3. 共识](#3. 共识)
      • [4. 理解 MVCC 需要知道三个前提知识](#4. 理解 MVCC 需要知道三个前提知识)
      • [5. 三个记录隐藏列字段](#5. 三个记录隐藏列字段)
      • [6. undo 日志](#6. undo 日志)
      • [7. 模拟 MVCC](#7. 模拟 MVCC)
      • [8. 一些思考](#8. 一些思考)
      • [9. Read View](#9. Read View)
      • [10. 整体流程示例](#10. 整体流程示例)
      • [11. RR 与 RC 的本质区别](#11. RR 与 RC 的本质区别)
        • [当前读和快照读在 RR 级别下的区别](#当前读和快照读在 RR 级别下的区别)
        • [RR 与 RC 的本质区别(正式)](#RR 与 RC 的本质区别(正式))

MySQL 事务与隔离性笔记


一、事务基础

1. 为什么需要事务

因为 MySQL 存储数据注定了未来肯定不只一个进程来向 MySQL 要数据,这就决定了 mysqld 服务会有高并发的场景,而事务的引出就是为了解决这种问题的。

场景举例: 假设有一个买票系统,现在还有多少张票这个数据是存放在数据库里面的一张表里面的,现在只有一张票了。现在有一个进程来访问买票,那么先判断是否有票也就是一个 if 判断,但是在这个时候可能是时间片切换或者访问硬件导致这个进程被挂起了,导致没有进行对票的减操作。就在同一时刻又有一个进程来买票,进行判断发现还有票(因为上一个进程还没有来得及减),这个进程就进了 if 条件让票减少了。当之前那个进程被重新唤起的时候也会对票进行减操作,导致一张票被卖了两次。

所以我们要解决这个问题,CURD 需要满足什么属性?

  1. 买票的过程得是原子的吧
  2. 买票互相应该不能影响吧
  3. 买完票应该要永久有效吧
  4. 买前和买后都要是确定的状态吧

2. 什么是事务

事务就是一组 DML 语句组成,这些语句在逻辑上存在相关性,这一组 DML 语句要么全部成功,要么全部失败,是一个整体。MySQL 提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。

事务就是要做的或所做的事情,主要用于处理操作量大、复杂度高的数据。假设一种场景:你毕业了,学校的教务系统后台 MySQL 中不再需要你的数据,要删除你的所有信息(一般不会:)),那么要删除你的基本信息(姓名、电话、籍贯等)的同时,也删除和你有关的其他信息,比如你的各科成绩、你在校表现、甚至你在论坛发过的文章等。这样就需要多条 MySQL 语句构成,所有这些操作合起来就构成了一个事务。

就是站在 MySQL 的上层去看待 SQL 语句,站在用户的角度,我要完成应用层的某一个业务需要使用多个 SQL 语句,这个多个 SQL 叫做事务。

正如上面所说,一个 MySQL 数据库可不止你一个事务在运行,同一时刻甚至有大量的请求被包装成事务,在向 MySQL 服务器发起事务处理请求。而每条事务至少一条 SQL,最多很多 SQL,这样如果大家都访问同样的表数据,在不加保护的情况就绝对会出现问题。甚至因为事务由多条 SQL 构成,也会存在执行到一半出错或者不想再执行的情况,那么已经执行的怎么办呢?所以一个完整的事务绝对不是简单的 SQL 集合,还需要满足如下四个属性:

  • 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • 一致性(Consistency):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。就是我们从一种状态变成另一种状态,它的结果是可预期的,可预期就是和我们预先的结果一样。数据库中并没有单独为一致性做操作,而是使用其他三种属性的满足来实现一致性。
  • 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read Uncommitted)、读提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
  • 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

总而言之,事务就是为了完成某种业务封装的一串 SQL 语句组成的对象。

3. 为什么会出现事务

事务被 MySQL 编写者设计出来,本质是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。可以想一下当我们使用事务时,要么提交要么回滚,我们不会去考虑网络异常了、服务器宕机了、同时更改一个数据怎么办对吧?因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。

备注:后面把 MySQL 中的一行信息称为一行记录。

4. 事务的版本支持

在 MySQL 中只有使用了 InnoDB 数据库引擎的数据库或表才支持事务,MyISAM 不支持。

查看数据库引擎:

sql 复制代码
mysql> show engines;
mysql> show engines \G
复制代码
*************************** 1. row ***************************
Engine: InnoDB    -- 引擎名称
Support: DEFAULT   -- 默认引擎
Comment: Supports transactions, row-level locking, and foreign keys -- 描述
Transactions: YES  -- 支持事务
XA: YES
Savepoints: YES    -- 支持事务保存点

*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO   -- MyISAM不支持事务
XA: NO
Savepoints: NO

5. 事务提交方式

事务的提交方式常见的有两种:自动提交、手动提交。

查看事务提交方式:

sql 复制代码
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.41 sec)

用 SET 来改变 MySQL 的自动提交模式:

sql 复制代码
mysql> SET AUTOCOMMIT=0;    -- 禁止自动提交
Query OK, 0 rows affected (0.00 sec)

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+
1 row in set (0.00 sec)

mysql> SET AUTOCOMMIT=1;    -- 开启自动提交
Query OK, 0 rows affected (0.00 sec)

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.01 sec)

为了便于演示,将 MySQL 的默认隔离级别设置成读未提交。具体操作后面专门会讲,现在以使用为主。

sql 复制代码
mysql> set global transaction isolation level READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)
mysql> quit
Bye
-- 需要重启终端,进行查看
mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set, 1 warning (0.00 sec)

因为我们想要人为地创造这种高并发场景并且有现象,如果隔离级别太高就看不到现象了。

创建测试表:

sql 复制代码
create table if not exists account(
    id int primary key,
    name varchar(50) not null default '',
    blance decimal(10,2) not null default 0.0
) ENGINE=InnoDB DEFAULT CHARSET=UTF8;

6. 正常演示 - 证明事务的开始与回滚

sql 复制代码
mysql> show variables like 'autocommit';  -- 查看事务是否自动提交。故意设置成自动提交,看看该选项是否影响begin

mysql> start transaction;  -- 开始一个事务,begin也可以,推荐begin
mysql> begin;

注意:当我们执行这个语句后,往后的所有 SQL 语句都是同一个事务的。

现在同时使用两个连接同一个 MySQL 得到两个 MySQL 进程,并且使用同一个数据库,以下简称两个 MySQL 为 m1 和 m2,让两个 MySQL 同时开启事务。

m1 执行以下操作:

sql 复制代码
mysql> savepoint save1;                          -- 创建一个保存点save1
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);   -- 插入一条记录
Query OK, 1 row affected (0.05 sec)
mysql> savepoint save2;                          -- 创建一个保存点save2
Query OK, 0 rows affected (0.01 sec)
mysql> insert into account values (2, '李四', 10000); -- 再插入一条记录
Query OK, 1 row affected (0.00 sec)

m2 执行:

sql 复制代码
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

发现两条都插入了(因为隔离级别是读未提交)。

现在不想插入第二条了,那么可以在 m1 中使用:

sql 复制代码
mysql> rollback to save2;     -- 回滚到保存点save2
Query OK, 0 rows affected (0.03 sec)

再在 m2 中执行:

sql 复制代码
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)

发现第二条没有了。所以这个 savepoint 就类似于存档点的操作。

那么是不是必须加了 savepoint 才支持回滚呢?然后直接插入三条数据并且不打 savepoint,然后直接执行:

sql 复制代码
mysql> rollback;  -- 直接rollback,回滚到最开始
mysql> select * from account;
Empty set (0.00 sec)

发现什么数据都没有了。所以我们在写 SQL 的时候,在关键的地方可以使用 savepoint 来回滚,也可以使用 rollback 直接回到开始事务的时候的状态。

当前事务完成后可以使用 commit; 来结束当前事务。所以在事务结束后再使用 rollback 就不行了,包括 rollback to 也不行了,也就是说进行提交后数据就持久化保存到磁盘中了。

所以回滚操作只能在事务期间进行回滚,这是正常情况。

7. 非正常演示1 - 证明未 commit 客户端崩溃 MySQL 自动回滚

(隔离级别设置为读未提交)

终端 A:

sql 复制代码
mysql> select * from account;
Empty set (0.00 sec)  -- 当前表内无数据

mysql> show variables like 'autocommit';  -- 依旧自动提交
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)

mysql> begin;                             -- 开启事务
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);  -- 插入记录
Query OK, 1 row affected (0.00 sec)
mysql> select * from account;             -- 数据已经存在,但没有commit

此时同时查看终端 m2:

复制代码
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
sql 复制代码
mysql> Aborted  -- 对m1执行 ctrl + \ 异常终止MySQL

m1 崩溃前 m2 看到:

复制代码
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)

m1 崩溃后 m2 查看:

sql 复制代码
mysql> select * from account;  -- 数据自动回滚了
Empty set (0.00 sec)

但是如果在执行 ctrl + \ 异常终止 MySQL 之前 commit 提交了事务,即使崩溃也不会回滚,因为只要提交了就会把数据写到磁盘中了,也就没法回滚了。也就是说没有 commit 之前崩溃会回滚,这样保证了原子性。但是如果没有提交发生异常了,即使有 savepoint 也会回滚到事务开始前的样子。

终端 A(先 commit 再崩溃):

sql 复制代码
mysql> show variables like 'autocommit';  -- 依旧自动提交
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)

mysql> select * from account;   -- 当前表内无数据
Empty set (0.00 sec)
mysql> begin;                   -- 开启事务
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (1, '张三', 100);  -- 插入记录
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.04 sec)
mysql> Aborted                 -- 异常终止

终端 m2:

sql 复制代码
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)  -- 数据存在了,所以commit的作用是将数据持久化到MySQL中

这个时候我们使用 show variable like 'autocommit' 发现自动提交是打开的,但是在我们没有使用 commit 进行手动提交的时候发生崩溃会导致回滚。能回滚代表这个没有自动提交,我们可以知道:我们使用 begin; 或者 start transaction; 手动开始事务,commit 手动提交和自动提交是两码事。

为了验证 begin 操作会自动更改提交方式、不会受 MySQL 是否自动提交影响,把 set autocommit=0; 关闭自动提交然后进行一样的操作,发现现象还是一样的,就说明了我们手动开始了事务就要使用 commit 手动提交,和是否自动提交没有什么关系。

8. 单条 SQL 与事务的关系

实验一(关闭自动提交):

终端 A:

sql 复制代码
mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)

mysql> set autocommit=0;      -- 关闭自动提交
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values (2, '李四', 10000);  -- 插入记录
Query OK, 1 row affected (0.00 sec)
mysql> select * from account;

终端 B 看到:

复制代码
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)
sql 复制代码
mysql> ^DBye  -- ctrl + \ or ctrl + d,终止终端

终端 B:

sql 复制代码
mysql> select * from account;  -- 终端A崩溃前
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> select * from account;  -- 终端A崩溃后
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)

实验二(开启自动提交):

终端 A:

sql 复制代码
mysql> show variables like 'autocommit';  -- 开启默认提交
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)

mysql> select * from account;
+----+--------+--------+
| id | name   | blance |
+----+--------+--------+
|  1 | 张三   | 100.00 |
+----+--------+--------+
1 row in set (0.00 sec)

mysql> insert into account values (2, '李四', 10000);
Query OK, 1 row affected (0.01 sec)
mysql> select * from account;  -- 数据已经插入
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> Aborted  -- 异常终止

终端 B:

sql 复制代码
mysql> select * from account;  -- 终端A崩溃前
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> select * from account;  -- 终端A崩溃后,并不影响,已经持久化,autocommit起作用
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

我们发现当把自动提交关闭后,执行的单个 SQL 在 MySQL 崩溃后会撤回这个操作;但是如果把自动提交打开,我们执行的操作就不会撤回。所以这个自动提交会影响单个 SQL 操作。我们的单个 SQL 操作看似没有使用事务,但是实际上单个 SQL 会被包装成事务,所以导致了如果我们关闭自动提交,那么在系统崩溃的时候 MySQL 会认为你没有提交这个事务导致回滚;但是如果我们打开自动提交,那么我们执行的单个 SQL 就会自动提交导致不会回滚了。但是如果我们在关闭自动提交后再手动 commit,那么崩溃后也不会回滚。

注意:当我们在 InnoDB 引擎下默认 autocommit 开启时,每条 SQL 会被包装为独立事务,多条 SQL 依次执行中途 MySQL 崩溃,已执行完成并自动提交的数据保留,正在执行的 SQL 变更回滚;当执行 SET autocommit=0 关闭自动提交后,在手动 commit 或 rollback 之前执行的多条 DML 语句都归属于同一个事务,若此时发生数据库崩溃,依靠事务原子性,这批未提交的所有修改会整体全部撤回,不会出现部分保存的情况;而通过 START TRANSACTIONBEGIN 手动开启事务执行多条 SQL 且未提交时崩溃,行为和 autocommit 关闭的场景一致,同一事务内所有变更统一回滚。

9. 结论

  • 只要输入 begin 或者 start transaction,事务便必须要通过 commit 提交才会持久化,与是否设置 set autocommit 无关。
  • 事务可以手动回滚,同时当操作异常时 MySQL 会自动回滚。
  • 对于 InnoDB,每一条 SQL 语句都默认封装成事务自动提交。(select 有特殊情况,因为 MySQL 有 MVCC)
  • 如果没有设置保存点,也可以回滚,只能回滚到事务的开始,直接使用 rollback(前提是事务还没有提交)。
  • 从上面的例子能看到事务本身的原子性(回滚)、持久性(commit)。
  • 那么隔离性?一致性?

10. 总结

事务是数据库保证数据一致性与完整性的核心机制,其本质是将一组操作捆绑为一个原子单元------要么全部成功提交,要么全部失败回滚,绝不出现部分生效的情况。通过 BEGIN 或 START TRANSACTION 可以显式开启一个事务,此后执行的所有 DML 语句(增删改)都属于同一个事务,直到执行 COMMIT 将修改持久化到磁盘,或执行 ROLLBACK 撤销自事务开始以来的全部变更。

在事务执行过程中,可以使用 SAVEPOINT 创建存档点,并通过 ROLLBACK TO SAVEPOINT 回退到指定的存档位置,实现事务内部的部分回滚,而无需放弃整个事务。需要特别注意的是,一旦执行了 COMMIT,所有修改便永久生效,此后无论是 ROLLBACK 还是 ROLLBACK TO SAVEPOINT 都无法再撤销已提交的数据。

MySQL 中还有一个重要的概念是 autocommit 自动提交模式,它决定了 DML 语句如何被包装成事务以及何时提交:

  • 当 autocommit = ON 时(MySQL 默认设置),每一条 SQL 语句都会被包装成一个独立的隐式事务,执行成功后立即自动提交并持久化到磁盘,无需手动干预;这意味着在默认情况下即使没有显式使用 BEGIN 开启事务,单条插入、更新或删除操作也处于事务的保护之下------执行成功则持久保存,执行失败则自动回滚。
  • 当 autocommit = OFF 时,关闭了自动提交功能,从设置生效的那一刻起,后续执行的所有 DML 语句都将归属于同一个隐式事务,直到手动执行 COMMIT 才会将这批修改统一持久化到磁盘,或执行 ROLLBACK 统一撤销所有修改;在这种模式下,如果执行了多条插入或更新操作后还未提交,此时发生客户端崩溃或 MySQL 服务器异常终止,那么所有这些未提交的修改会在崩溃恢复后全部自动回滚,不会出现部分保存的情况,这充分体现了事务的原子性。

二、事务的隔离性

1. 隔离性通俗理解

事务隔离性可以这样通俗理解:当两个事务同时操作同一张表,一个负责查找,一个负责插入,数据库底层会按照它们实际到达的时间顺序依次处理,但每个事务从启动的那一刻起就拥有了属于自己的独立数据快照,在整个事务执行期间它始终读取这份快照中的数据,彼此之间互不干扰。

举个例子:如果查找事务先到达并启动,那么它会立即拍下当前表的一份快照,这份快照里记录的是插入事务开始之前的数据状态。随后插入事务开始执行,向表中添加新数据并最终提交,数据虽然已经持久化到磁盘,但查找事务由于仍在运行中,它并不会去读取磁盘上最新的数据,而是继续读取自己启动时的那份旧快照,所以它看到的依然是插入之前的表状态,即使它执行了很长时间甚至插入事务早已完成,这个结果也不会改变。

反过来,如果插入事务先到达但还没有提交,查找事务这时才启动,那么查找事务拍下的快照中依然不包含那些尚未提交的数据,所以看到的仍然是插入前的表;只有当插入事务先完成了提交,查找事务在插入提交之后才启动,它的快照里才会包含新插入的数据,此时查找才能看到插入后的结果。

简单来说:查找那一刻表中已提交的数据是什么样子,查找事务看到的就是什么样子;未提交的数据无论插入事务执行了多久,对其他事务始终不可见。

2. 如何理解隔离性

MySQL 服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行。一个事务可能由多条 SQL 构成,也就意味着任何一个事务都有执行前、执行中、执行后的阶段。而所谓的原子性,其实就是让用户层要么看到执行前要么看到执行后,执行中出现问题可以随时回滚。所以单个事务对用户表现出来的特性就是原子性。

但毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个 SQL 的时候,就还是有可能会出现互相影响的情况。比如多个事务同时访问同一张表,甚至同一行数据。

就如同你妈妈给你说:你要么别学,要学就学到最好。至于你怎么学、中间有什么困难,你妈妈不关心。那么你的学习对你妈妈来讲就是原子的。那么你学习过程中很容易受别人干扰,此时就需要将你的学习隔离开,保证你的学习环境是健康的。

数据库中为了保证事务执行过程中尽量不受干扰,就有了一个重要特征:隔离性 。数据库中允许事务受不同程度的干扰,就有了一种重要特征:隔离级别

3. 四种隔离级别

隔离级别 说明 问题
读未提交(Read Uncommitted) 所有事务都可以看到其他事务没有提交的执行结果。相当于没有任何隔离性 脏读、幻读、不可重复读
读提交(Read Committed) 大多数数据库的默认隔离级别(不是 MySQL 默认的)。一个事务只能看到其他已经提交的事务所做的改变 不可重复读
可重复读(Repeatable Read) MySQL 默认的隔离级别。确保同一个事务在执行中多次读取操作数据时会看到同样的数据行 幻读(MySQL 通过 Next-Key 锁解决了)
串行化(Serializable) 事务的最高隔离级别,通过强制事务排序使之不可能相互冲突,从而解决幻读。在每个读的数据行上面加上共享锁 可能导致超时和锁竞争,效率极低
  • 读未提交:两个事务并发执行,这边有什么操作另外一边马上就能看到,即使没有 commit。实际生产中不可能使用这种隔离级别。
  • 读提交:两个事务只有一边提交了事务另外一边才能看到。
  • 可重复读:一边提交事务了但是另一个事务看不到,只有当另一个事务也提交了事务才能看到。
  • 串行化:一个一个事务地执行。这种隔离级别太极端,实际生产基本不使用。

我们注意到所有的级别都是和读有关的。其实会在高并发出现情况的只有读写的情况:读读两个都不做修改自然怎么来都行;写写就不能并行只能串行;只有读写这种情况最常见又需要并行。

4. 查看与设置隔离性

查看:

sql 复制代码
mysql> SELECT @@global.tx_isolation;   -- 查看全局隔离级别
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+
1 row in set, 1 warning (0.00 sec)

mysql> SELECT @@session.tx_isolation;  -- 查看会话(当前)隔离级别
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ        |
+------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> SELECT @@tx_isolation;          -- 默认同上,都是查看当前会话的隔离级别
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

设置:

sql 复制代码
-- 设置当前会话 or 全局隔离级别语法
SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL
{READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}

设置当前会话隔离性,另起一个会话看不到,只影响当前会话:

sql 复制代码
mysql> set session transaction isolation level serializable;  -- 串行化
mysql> SELECT @@global.tx_isolation;   -- 全局隔离性还是RR
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| REPEATABLE-READ       |
+-----------------------+
mysql> SELECT @@session.tx_isolation;  -- 会话隔离性成为串行化
+------------------------+
| @@session.tx_isolation |
+------------------------+
| SERIALIZABLE           |
+------------------------+
mysql> SELECT @@tx_isolation;          -- 同上
+----------------+
| @@tx_isolation |
+----------------+
| SERIALIZABLE   |
+----------------+

设置全局隔离性,另起一个会话会被影响:

sql 复制代码
mysql> set global transaction isolation level READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT @@global.tx_isolation;
+-----------------------+
| @@global.tx_isolation |
+-----------------------+
| READ-UNCOMMITTED      |
+-----------------------+
mysql> SELECT @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| READ-UNCOMMITTED       |
+------------------------+

注意:如果没有现象,关闭 MySQL 客户端重新连接。

MySQL 的隔离级别分为全局和会话两个层次,两者互不影响:修改全局只对新连接生效,不影响已有会话;修改会话只影响当前会话,不影响全局设置。在实际使用中遵循"就近原则"------如果当前会话显式设置了隔离级别,则优先使用会话级别;否则使用全局级别作为默认值。

通过 SET GLOBAL 修改全局隔离级别后立即生效,退出重新登录创建的新连接会使用修改后的值,因此观察到修改后重登录依然生效是正确的,因为这个值保存在服务器的运行内存中,不会因客户端断开而丢失;只有当 MySQL 服务重启时,内存中的值会被清空,重新加载配置文件中的设置,此时修改才会失效。如果需要重启后依然保留,可以通过修改配置文件(如 my.cnf 或 my.ini)永久生效,或在 MySQL 8.0 以上使用 SET PERSIST 持久化设置。

5. 读未提交(Read Uncommitted)实验

几乎没有加锁,虽然效率高但是问题太多,严重不建议采用。

终端 A:

sql 复制代码
mysql> set global transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)
-- 重启客户端
mysql> select @@tx_isolation;
+------------------+
| @@tx_isolation   |
+------------------+
| READ-UNCOMMITTED |
+------------------+
1 row in set, 1 warning (0.00 sec)

mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   100.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> begin;  -- 开启事务
Query OK, 0 rows affected (0.00 sec)
mysql> update account set blance=123.0 where id=1;  -- 更新指定行
Query OK, 1 row affected (0.05 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- 没有commit哦!!!

终端 B:

sql 复制代码
mysql> begin;
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |  -- 读到终端A更新但是未commit的数据[insert,delete同样]
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

一个事务在执行中读到另一个执行中事务的更新(或其他操作)但是未 commit 的数据,这种现象叫做脏读(dirty read)

6. 读提交(Read Committed)实验

终端 A:

sql 复制代码
mysql> set global transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)
-- 重启客户端
mysql> select * from account;  -- 查看当前数据
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> begin;  -- 手动开启事务,同步的开始终端B事务
Query OK, 0 rows affected (0.00 sec)
mysql> update account set blance=321.0 where id=1;  -- 更新张三数据
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- 切换终端到终端B,查看数据
mysql> commit;  -- commit提交!
Query OK, 0 rows affected (0.01 sec)
-- 切换终端到终端B,再次查看数据

终端 B:

sql 复制代码
mysql> begin;  -- 手动开启事务,和终端A一前一后
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 终端A commit之前,查看不到
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   123.00 |  -- 老的值
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

-- 终端A commit之后,看到了!
-- but,此时还在当前事务中,并未commit,那么就造成了同一个事务内同样的读取在不同的时间段
-- (依旧还在事务操作中!)读取到了不同的值,这种现象叫做不可重复读(non-repeatable read)!
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |  -- 新的值
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

7. 可重复读(Repeatable Read)实验

终端 A:

sql 复制代码
mysql> set global transaction isolation level repeatable read;  -- 设置全局隔离级别RR
Query OK, 0 rows affected (0.01 sec)
-- 关闭终端重启
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |  -- 隔离级别RR
+-----------------+
1 row in set, 1 warning (0.00 sec)

mysql> select * from account;  -- 查看当前数据
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> begin;  -- 开启事务,同步的终端B也开始事务
Query OK, 0 rows affected (0.00 sec)
mysql> update account set blance=4321.0 where id=1;  -- 更新数据
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
-- 切换到终端B,查看另一个事务是否能看到
mysql> commit;  -- 提交事务
-- 切换终端到终端B,查看数据

终端 B:

sql 复制代码
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 终端A中事务commit之前,查看当前表中数据,数据未更新
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> select * from account;  -- 终端A中事务commit之后,查看当前表中数据,数据未更新
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)
-- 可以看到,在终端B中事务无论什么时候进行查找,看到的结果都是一致的,这叫做可重复读!

mysql> commit;  -- 结束事务
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 再次查看,看到最新的更新数据
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

如果将上面终端 A 中的 update 操作改成 insert 操作,会有什么问题?

终端 A:

sql 复制代码
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |   321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> begin;  -- 开启事务,终端B同步开启
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account (id,name,blance) values(3, '王五', 5432.0);
Query OK, 1 row affected (0.00 sec)
-- 切换到终端B,查看另一个事务是否能看到
mysql> commit;  -- 提交事务
Query OK, 0 rows affected (0.00 sec)
-- 切换终端到终端B,查看数据
mysql> select * from account;
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
|  3 | 王五   |  5432.00 |
+----+--------+----------+
3 rows in set (0.00 sec)

终端 B:

sql 复制代码
mysql> begin;  -- 开启事务
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 终端A commit前查看
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> select * from account;  -- 终端A commit后查看
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

mysql> select * from account;  -- 多次查看
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
+----+--------+----------+
2 rows in set (0.00 sec)

发现终端 A 在对应事务中 insert 的数据,在终端 B 的事务周期中也没有什么影响,也符合可重复的特点。

但是一般的数据库在可重复读情况的时候,无法屏蔽其他事务 insert 的数据(为什么?因为隔离性实现是对数据加锁完成的,而 insert 待插入的数据因为并不存在,那么一般加锁无法屏蔽这类问题),会造成虽然大部分内容是可重复读的,但是 insert 的数据在可重复读情况被读取出来,导致多次查找时会多查找出来新的记录,就如同产生了幻觉。这种现象叫做幻读(phantom read)

很明显,MySQL 在 RR 级别的时候是解决了幻读问题的(解决的方式是用 Next-Key 锁(GAP+行锁)解决的,这块比较难,有兴趣同学了解一下)。

sql 复制代码
mysql> commit;  -- 结束事务
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 看到更新
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
|  3 | 王五   |  5432.00 |
+----+--------+----------+
3 rows in set (0.00 sec)

8. 串行化(Serializable)实验

对所有操作全部加锁进行串行化,不会有问题,但是只要串行化效率很低,几乎完全不会被采用。注意是对事务的串行化。

终端 A:

sql 复制代码
mysql> set global transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)
mysql> select @@tx_isolation;
+----------------+
| @@tx_isolation |
+----------------+
| SERIALIZABLE   |
+----------------+
1 row in set, 1 warning (0.00 sec)

mysql> begin;  -- 开启事务,终端B同步开启
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 两个读取不会串行化,共享锁
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
|  3 | 王五   |  5432.00 |
+----+--------+----------+
3 rows in set (0.00 sec)

mysql> update account set blance=1.00 where id=1;  -- 终端A中有更新或者其他操作,会阻塞,直到终端B事务提交
Query OK, 1 row affected (18.19 sec)
Rows matched: 1 Changed: 1 Warnings: 0

终端 B:

sql 复制代码
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;  -- 两个读取不会串行化
+----+--------+----------+
| id | name   | blance   |
+----+--------+----------+
|  1 | 张三   |  4321.00 |
|  2 | 李四   | 10000.00 |
|  3 | 王五   |  5432.00 |
+----+--------+----------+
3 rows in set (0.00 sec)
mysql> commit;  -- 提交之后,终端A中的update才会提交
Query OK, 0 rows affected (0.00 sec)

9. 隔离级别总结

  • 隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。
  • 不可重复读的重点是修改和删除:同样的条件,你读取过的数据再次读取出来发现值不一样了。
  • 幻读的重点在于新增:同样的条件,第 1 次和第 2 次读出来的记录数不一样。
  • MySQL 默认的隔离级别是可重复读,一般情况下不要修改。
  • 上面的例子可以看出事务也有长短事务这样的概念。事务间互相影响指的是事务在并行执行的时候,即都没有 commit 的时候影响会比较大。

10. 一致性(Consistency)

事务执行的结果必须使数据库从一个一致性状态变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。如果系统运行发生中断,某个事务尚未完成而被迫中断,而该未完成的事务对数据库所做的修改已被写入数据库,此时数据库就处于一种不正确(不一致)的状态。因此一致性是通过原子性来保证的。

其实一致性和用户的业务逻辑强相关,一般 MySQL 提供技术支持,但是一致性还是要用户业务逻辑做支撑,也就是一致性是由用户决定的。而技术上通过 AID(原子性、隔离性、持久性)保证 C(一致性)。


三、隔离性的具体实现(MVCC)

1. 如何理解隔离性2

数据库并发的场景有三种:

  • 读-读:不存在任何问题,也不需要并发控制
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读
  • 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失、第二类更新丢失(后面补充)

我们主要学习读写的场景。

2. 读-写与 MVCC

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制。为事务分配单向增长的事务 ID,也就是说事务 id 和事务是一对一的关系,并且一般来说 id 越小就证明该事务越先到来,所以根据 id 的大小可以判断事务的先后顺序。为每个修改保存一个版本,版本与事务 ID 关联,读操作只读该事务开始前的数据库的快照。

所以 MVCC 可以为数据库解决以下问题:在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能;同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。

3. 共识

  1. 每一个事务都要有自己的事务 id,并且事务 id 的大小决定了事务的先后顺序。
  2. mysqld 可能会面临同时处理多个事务的情况,处理事务是需要时间的。虽然对上我们看到事务是原子的,但是实际事务的处理是需要时间的,所以事务有自己的生命周期。所以 mysqld 肯定要对多个事务进行管理,注定了会对事务进行先描述再组织。所以事务在我看来就是在 mysqld 中的一种结构体对象或者类对象,对事务的管理就是对结构体的管理,然后用一种数据结构进行管理这个事务的结构体,所以我们对事务的操作就是对特定的数据结构的增删查改操作。

4. 理解 MVCC 需要知道三个前提知识

  1. 3 个记录隐藏字段
  2. undo 日志
  3. Read View

5. 三个记录隐藏列字段

  • DB_TRX_ID:6 byte,最近修改(修改/插入)事务 ID,记录创建这条记录/最后一次修改该记录的事务 ID。即使是单 SQL 也是会被包装成事务进行处理。
  • DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本(简单理解成指向历史版本就行,这些数据一般在 undo log 中)。实际上,如果你对 MySQL 中表中的某一行记录进行修改,MySQL 在特定的隔离级别下不一定会直接去修改原表数据,可能会把你要改的记录在改之前先保存一份然后改新的,这样即使我改了数据我仍然知道以前的数据,所以新改一定要找到没有改之前的记录。
  • DB_ROW_ID:6 byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引。

补充:实际还有一个删除 flag 隐藏字段,即记录被更新或删除并不代表真的删除,而是删除 flag 变了。

所以一个表真正的样子是:

sql 复制代码
mysql> create table if not exists student(
    name varchar(11) not null,
    age int not null
);
mysql> insert into student (name, age) values ('张三', 28);
Query OK, 1 row affected (0.05 sec)
mysql> select * from student;
+--------+-----+
| name   | age |
+--------+-----+
| 张三   |  28 |
+--------+-----+
1 row in set (0.00 sec)

这个是我们之前学习到的。但是上面描述的真正的意思是:

name age DB_TRX_ID(创建该记录的事务ID) DB_ROW_ID(隐式主键) DB_ROLL_PTR(回滚指针)
张三 28 null 1 null

我们目前并不知道创建该记录的事务 ID、隐式主键,就默认设置成 null、1。第一条记录也没有其他版本,我们设置回滚指针为 null。


6. undo 日志

这里不想细讲,但是有一件事情得说清楚:MySQL 将来是以服务进程的方式在内存中运行。我们之前所讲的所有机制------索引、事务、隔离性、日志等,都是在内存中完成的,即在 MySQL 内部的相关缓冲区中保存相关数据,完成各种判断操作,然后在合适的时候将相关数据刷新到磁盘当中的。所以我们这里理解 undo log,简单理解成就是 MySQL 中的一段内存缓冲区,用来保存日志数据的就行。


7. 模拟 MVCC

现在有一个事务 10(仅仅为了好区分),对 student 表中记录进行修改(update):将 name(张三)改成 name(李四)。

事务 10 因为要修改,所以要先给该记录加行锁。修改前,先将该行记录拷贝到 undo log 中,所以 undo log 中就有了一行副本数据(原理就是写时拷贝)。

所以现在 MySQL 中有两行同样的记录。现在修改原始记录中的 name 改成 '李四',并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 10 的 ID(默认从 10 开始,之后递增)。而原始记录的回滚指针 DB_ROLL_PTR 列里面写入 undo log 中副本数据的地址,从而指向副本记录,即表示我的上一个版本就是它。

事务 10 提交,释放锁。

备注:此时最新的记录是 '李四' 那条记录。

图片内容描述:标题为"事务10执行完毕后"。上方是最新数据行:name=李四,age=28,DB_TRX_ID=10,DB_ROW_ID=1,DB_ROLL_PTR=0x11223344(示意)。下方 undo log 区域中有一行历史数据:name=张三,age=28,DB_TRX_ID=null,DB_ROW_ID=1,DB_ROLL_PTR=null。最新数据行的回滚指针通过箭头指向 undo log 中的张三记录,表示版本链只有两个节点:最新的李四 -> 原始的张三。

现在又有一个事务 11,对 student 表中记录进行修改(update):将 age(28)改成 age(38)。

事务 11 因为也要修改,所以要先给该记录加行锁(该记录是哪条?一定不能是历史的数据,所以只能改最新的)。修改前,先将该行记录拷贝到 undo log 中,所以 undo log 中就又有了一行副本数据。此时新的副本采用头插方式插入 undo log。

现在修改原始记录中的 age 改成 38,并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 11 的 ID。而原始记录的回滚指针 DB_ROLL_PTR 列里面写入 undo log 中副本数据的地址,从而指向副本记录,即表示我的上一个版本就是它。

图片内容描述:标题为"事务11执行完毕后",标注"最新数据"。上方最新数据行:name=李四,age=38,DB_TRX_ID=11,DB_ROW_ID=1,DB_ROLL_PTR=0x11223366(示意)。下方 undo log 区域中有两行历史数据,形成链表:第一行(头插的新副本)name=李四,age=28,DB_TRX_ID=10,DB_ROW_ID=1,DB_ROLL_PTR=0x11223344(示意);第二行 name=张三,age=28,DB_TRX_ID=null,DB_ROW_ID=1,DB_ROLL_PTR=null。最新数据的回滚指针指向 undo log 中的李四(28),李四(28)的回滚指针指向张三(28),形成完整的三节点版本链:李四(38,trx=11) -> 李四(28,trx=10) -> 张三(28,null)。

这样我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据覆盖当前数据。但是实际上并不是直接进行覆盖,而是 MySQL 会在日志中记录和你当前操作相反的操作,例如你 insert 那么日志中就记录 delete,你 update 新数据日志里面就 update 老数据,这样在进行回滚的时候就把这些操作执行一遍就实现了回滚了。

上面的一个一个版本,我们可以称之为一个一个的快照。

注意 undo log 是在内存中的,所以就代表了这个只能临时保存。undo log 里面记录的是事务在执行期间的操作,但是如果 commit 了那么 undo log 里面的记录也就会 free。但是注意这个 undo log 进行 free 的时候就要保证没有人正在使用这些历史数据,类似于引用计数。


8. 一些思考

上面是以更新(update)主讲的,如果是 delete 呢?一样的,别忘了删数据不是清空,而是设置 flag 为删除即可,所以也可以保存历史记录,可以形成版本。

如果是 insert 呢?因为 insert 是插入,也就是之前没有数据,那么 insert 也就没有历史版本。但是一般为了回滚操作,insert 的数据也是要被放入 undo log 中,我们之前说过日志里面会保存和 insert 相反的操作也就是 delete,回滚时执行 delete 即可。如果当前事务 commit 了,那么这个 undo log 的历史 insert 记录就可以被清空了。

总结一下,也就是我们可以理解成 update 和 delete 可以形成版本链,insert 暂时不考虑。那么 select 呢?

首先 select 不会对数据做任何修改,所以为 select 维护多版本没有意义。不过此时有个问题:select 读取是读取最新的版本呢?还是读取历史版本?

  • 当前读 :读取最新的记录就是当前读。增删改都叫做当前读,select 也有可能当前读,比如 select lock in share mode(共享锁)、select for update
  • 快照读:读取历史版本(一般而言)就叫做快照读(这个后面重点讨论)。

历史经验告诉我们,无论是在 RC 还是在 RR 的隔离等级下读写都是可以并发的。根据隔离性的不同,我们发现你即使修改提交了我都有可能看不到,那么就决定了我们读的肯定不是同一数据。所以为什么读写可以并发?因为写一定是写的是最新版本,而读读的是历史版本,所以我们不会出现访问同一位置,也就不用再加锁了。不需要加锁也就是不会出现相互耗着的情况了,所以就实现了并发的操作。

所以隔离性的本质上在数据上做隔离,做法上是在版本上做隔离。所以我们可以在不同的隔离级下有不同的现象,隔离性的不同决定了我们能看到哪个版本。

我们可以看到,在多个事务同时删改查的时候都是当前读,是要加锁的。那同时有 select 过来,如果也要读取最新版(当前读),那么也就需要加锁,这就是串行化。但如果是快照读,读取历史版本的话是不受加锁限制的,也就是可以并行执行!换言之提高了效率,即 MVCC 的意义所在。

那么是什么决定了 select 是当前读还是快照读呢?隔离级别!

那为什么要有隔离级别呢?事务都是原子的,所以无论如何事务总有先有后。但是经过上面的操作我们发现,事务从 begin -> CURD -> commit 是有一个阶段的,也就是事务有执行前、执行中、执行后的阶段。但不管怎么启动多个事务总是有先有后的。那么多个事务在执行中 CURD 操作是会交织在一起的,那么为了保证事务的"有先有后",是不是应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。

先来的事务应不应该看到后来的事务所做的修改呢?显然在不同的隔离性下就会出现不一样的操作。那么如何保证不同的事务看到不同的内容呢?也就是如何实现隔离级别?


9. Read View

Read View 就是事务进行快照读操作的时候产生的读视图(Read View)。在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID(当每个事务开启时都会被分配一个 ID,这个 ID 是递增的,所以最新的事务 ID 值越大)。

Read View 在 MySQL 源码中就是一个类,本质是用来进行可见性判断的。即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。

下面是 ReadView 结构,但为了减少负担简化一下:

cpp 复制代码
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 的,即当前记录的 DB_TRX_ID。

那么我们现在手里面有的东西就有:当前快照读的 ReadView 和版本链中的某一个记录的 DB_TRX_ID。所以现在的问题就是当前快照读应不应该读到当前版本记录。一张图解决所有问题!

图片内容描述:这是一张综合示意图,分为上下两部分。

上半部分是事务11执行完毕后的版本链:最新数据行(李四,38,DB_TRX_ID=11,DB_ROW_ID=1,DB_ROLL_PTR=0x11223366)通过回滚指针指向 undo log 中的李四(28,DB_TRX_ID=10),再指向张三(28,null),形成三节点版本链。

下半部分是 Read View 可见性判断的时间轴示意图,从左到右按时间分为三个区域:

  1. "已经提交的事务"区域(up_limit_id 左侧):标注 creator_trx_id == DB_TRX_ID 或 DB_TRX_ID < up_limit_id,说明是历史上已经提交的,应该看到。
  2. "正在操作的事务"区域(up_limit_id 到 low_limit_id-1 之间):标注有多个事务就有多个事务ID,所有活跃事务ID都在m_ids中;下方有思维误区说明:快照到的事务ID不一定是连续的,比如有11,12,13,14,15号事务,在快照前12,14提交了,那么m_ids就是11,13,15;如果DB_TRX_ID不在m_ids列表中说明已经提交可以看到,如果在说明该事务和我们的事务一样都是活跃事务没有commit不应该看到。
  3. "快照后新来的事务"区域(low_limit_id 右侧):标注 DB_TRX_ID >= low_limit_id,说明是快照之后才提交事务,不应该看到。
    底部标注:任何时刻都有多个事务在不断到来与提交;事务ID不断在递增,所以事务ID大小就能决定先后顺序。
    图中用虚线箭头从版本链中的DB_TRX_ID指向时间轴,表示拿着版本链中的事务ID去和Read View中的各个字段做比较。

所以归根结底,我们现在要做的操作就是拿着 ReadView 里面的事务 id 和历史版本链中每一条比较 id,来确认该条记录我该不该看。

可见性判断规则:

  1. 只要当前事务的 creator_trx_id 等于历史链的 DB_TRX_ID,就证明了该记录是我修改的,所以我应该看到,从这个以前的版本我都可以看到。
  2. 当 up_limit_id 比历史链中最新也就是最大的 DB_TRX_ID 还大的时候,就证明 DB_TRX_ID 对应的事务应该早就结束了,不然我不应该看到,并且比我最小的还小说明已经提交了,所以我应该看到。
  3. 当创建 ReadView 的时候就会创建 low_limit_id,这个值是 ReadView 生成时刻系统尚未分配的下一个事务 ID,也就是目前已出现过的事务 ID 的最大值 + 1。ReadView 是一个对象,值在进行初始化之后我们认为它就不会变了。所以当历史链中记录中 DB_TRX_ID 大于等于 low_limit_id,就证明修改这些数据的事务是在我事务后创建的,显然我不应该看到。
  4. 当我们在事务中时会有很多事务和我并发执行,所以大家同时在执行的时候我也不应该看到。所以有一个列表 m_ids,用来维护 Read View 生成时刻系统正活跃的事务 ID。这里快照到的事务 id 一定是连续的吗?因为事务的执行是要时间的,先来的可能晚走,晚来的也可能早走,所以快照 id 可能不连续。所以当我们拿着 DB_TRX_ID 在 m_ids 中查找的时候,发现不在列表中就说明在我进行快照的时候该事务已经提交了,所以我应该看到;如果在,说明我和你都是活跃事务并没有提交,所以我们不应该看到。

最后注意:ReadView 是事务可见性的一个类,但是不代表事务创建就会创建这个 ReadView,而是当事务首次进行快照读的时候创建。


10. 整体流程示例

假设当前有条记录:

name age DB_TRX_ID DB_ROW_ID DB_ROLL_PTR
张三 28 null 1 null

事务操作:

事务1 id=1 事务2 id=2 事务3 id=3 事务4 id=4
事务开始 事务开始 事务开始 事务开始
... ... ... 修改且已提交
进行中 快照读 进行中
... ... ...

事务 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

事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id、low_limit_id 和活跃事务 ID 列表(trx_list)进行比较,判断当前事务 2 能看到该记录的版本。

复制代码
// 事务2的 Read View
m_ids;          // 1,3
up_limit_id;    // 1
low_limit_id;   // 4 + 1 = 5
creator_trx_id  // 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不在当前的活跃事务中

// 结论
故事务4的更改应该看到。所以事务2能读到的最新数据记录是事务4所提交的版本,
而事务4提交的版本也是全局角度上最新的版本。

11. RR 与 RC 的本质区别

当前读和快照读在 RR 级别下的区别

下面的代码经过测试是完全没有问题的。select * from user lock in share mode 以加共享锁方式进行读取,对应的就是当前读,此处只作为测试使用。因此不带 lock in share mode 就是快照读。

测试表:

sql 复制代码
-- 设置RR模式下测试
mysql> set global transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
-- 重启终端
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)

-- 依旧用之前的表
create table if not exists account(
    id int primary key,
    name varchar(50) not null default '',
    blance decimal(10,2) not null default 0.0
) ENGINE=InnoDB DEFAULT CHARSET=UTF8;

-- 插入一条记录,用来测试
mysql> insert into user (id, age, name) values (1, 15, '黄蓉');
Query OK, 1 row affected (0.00 sec)

测试用例1 - 表1:

事务A操作 事务A描述 事务B描述 事务B操作
begin A开启事务 B开启事务 begin
select * from user A快照读(无影响)查询 B快照读查询 select * from user
update user set age=18 where id=1; A更新age=18 B什么也不做 B什么也不做
commit A提交事务 B什么也不做 B什么也不做
B select 快照读,没有读到 age=18 select * from user(因为隔离级别是RR,所以在事务B没有提交前看不到A)
B select lock in share mode 当前读,读到 age=18 select * from user lock in share mode

测试用例2 - 表2:

事务A操作 事务A描述 事务B描述 事务B操作
begin A开启事务 B开启事务 begin
select * from user A快照读,查到age=18 B什么也不做 B什么也不做
update user set age=28 where id=1; A更新age=28 B什么也不做 B什么也不做
commit A提交事务 B什么也不做 B什么也不做
B select 快照读 age=28 select * from user
B select lock in share mode 当前读 age=28 select * from user lock in share mode

用例 1 与用例 2 唯一区别仅仅是:表 1 的事务 B 在事务 A 修改 age 前快照读过一次 age 数据,而表 2 的事务 B 在事务 A 修改 age 前没有进行过快照读。

结论:

用例 1 事务 B 在事务 A 之前就进行了快照读,就创建了快照,所以自然读不到事务 A 的操作。但是用例 2 是在事务 A 提交后才快照读,所以能看到。

事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读决定该事务后续快照读结果的能力,delete 同样如此。所以 Read View 形成时机的不同会影响事务的可见性。


RR 与 RC 的本质区别(正式)

正是 Read View 生成时机的不同,从而造成 RC、RR 级别下快照读的结果的不同。

  • 在 RR 级别下,某个事务对某条记录的第一次快照读会创建一个快照及 Read View,将当前系统活跃的其他事务记录起来。此后在调用快照读的时候,还是使用的是同一个 Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 Read View,所以对之后的修改不可见。所以 RR 的可见性在第一次快照读时创建 Read View 的时候就不会变了。
  • 即 RR 级别下,快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于 Read View 创建的事务所做的修改均是可见。
  • 在 RC 级别下,事务中每次快照读都会重新生成一个快照和 Read View,这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。

总之:在 RC 隔离级别下,是每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建 Read View,之后的快照读获取的都是同一个 Read View。

正是 RC 每次快照读都会形成 Read View,所以 RC 才会有不可重复读问题。

相关推荐
wxwx_bscxy3221 小时前
NodeJS 高校学业预警系统10551
mysql·node.js·vue·高校学业预警
一笑的小酒馆3 小时前
AndroidKMP之瀑布流实现
android
RainyJiang3 小时前
AI时代下,Android的边界正在消失
android·openai·ai编程
Android-Flutter4 小时前
android 内存抖动详解
android
方白羽5 小时前
为什么 Android 非要用 Intent 传值?
android·ios·harmonyos
寺中人6 小时前
MySQL 8.0 Windows 完整安装教程:环境配置、密码重置与常见报错排查
数据库·windows·mysql·环境搭建·mysql 安装
这个DBA有点耶7 小时前
MySQL 8.0执行计划分析利器:EXPLAIN ANALYZE到底比EXPLAIN强在哪?
数据库·mysql·代码规范
方白羽8 小时前
Android17 重写 MessageQueue,解决 Handler 隐性卡顿
android·app
蜡台8 小时前
Kotlin 零基础完整版实战教程|从语法入门到Android工程实战
android·开发语言·kotlin