MYSQL之事务

MYSQL之事务

引言

数据库注定是会被多个客户端访问的, 也就是可能被上层的多个线程并发访问, 同时对数据库发出多个CURD请求, 于是像MYSQL这样的关系型数据库会提供事务的管理 (同样的, NoSQL中KV型数据库, 比如 Redis, 也支持事务).

比如下面这个场景, 如果不对数据库进行事务管理, 就会产生不符合现实逻辑的行为:

再比如一个同行转账的例子, 我想给一个同是工商银行的银行卡转100, 但是我的账号上余额-100之后, 突然 网络, 数据库等出现异常, 导致产生一个中间态, 我的余额被扣除了, 但是对方账户没有+100元. 我们并不是不允许这样的异常产生, 而是异常产生后, 我们要能对未完成的"事务"进行回滚 , 将系统还原成最初的状态, 就好像什么都没有发生一样.

事务

什么是事务?

事务就是一组DML 语句组成, 这些语句存在逻辑相关性 , 这一组DML语句要么全部成功, 要么全部失败 , 是一个整体. 我们看待事务, 要站在 MYSQL 的上层去从业务的角度去看待SQL语句, 单拎出来一个事物的一条SQL语句是无意义的. 比如转账这个事务, 由两条UPDATE语句构成, 从程序员的角度去看, 把两条SQL语句分开就是两条SQL语句, 而从数据库的使用者而言, 将两条SQL结合起来, 才叫一个事务.

MySQL提供一种机制, 保证我们达到上面这样的效果.

此外, 事务还规定不同的客户端看到的数据是不相同的, 这里在事务的隔离级别体现.

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

所以严格意义上讲, 事务并不是一个程序员的术语, 但它又确实是 MYSQL 为了表述SQL的逻辑相关性和保证数据一致性而为数据库的使用者将一系列SQL语句封装后并满足特定属性的结果.

事务的属性?

一个完整的事务, 绝对不是简单的 sql 集合, 还需要满足如下四个属性

  • 原子性: 一个事务(transaction)中的所有操作, 要么全部完成 , 要么全部不完成 , 不会结束在中间某个环节 . 事务在执行过程中发生错误 , 会被回滚 (Rollback) 到事务开始前的状态, 就像这个事务从来没有执行过一样

  • 持久性: 事务处理结束后, 对数据的修改就是永久 的, 即便系统故障也不会丢失

  • 隔离性(最大的问题, 不好理解): 数据库允许多个并发事务 同时对其数据进行读写和修改 的能力, 隔离性可以防止多个事务并发执行时由于交叉执行 而导致数据的不一致. 事务隔离分为不同级别, 包括读未提交( Readuncommitted )、读提交( read committed )、可重复读( repeatable read )和串行化( Serializable )

  • 一致性: 在事务开始之前 和事务结束以后 , 数据库的完整性 没有被破坏. 这表示写入的资料必须完全符合所有的预设规则, 这包含资料的精确度, 串联性以及后续数据库可以自发性地完成预定的工作. 比如 A 账户有 200, B 账户有 100, A 向 B 转账 50 , 我一定可以预期到: 如果转账成功, A账户有150, B账户有100; 转账失败, A账户仍为200, B账户仍为100.

MYSQ在技术层面没有对一致性制定策略, 而是从技术层面对事务的原子性, 持久性 和隔离性 实现, 然后通过 用户+DBMS 的配合, 最终去保证业务层面的一致性的.

(原子性持久性隔离性是"因", 一致性是"果")

上面四个属性,可以简称为 ACID:

  • 原子性(Atomicity,或称不可分割性)
  • 一致性(Consistency)
  • 隔离性(Isolation,又称独立性)
  • 持久性(Durability)

因此事务是为了更好的描述业务需求, 对SQL语句集合做的封装. 而同时为了保证在 DB 并发访问的条件下更好更快更安全的去运行, DBMS 要用 ACID 去保证事务正确运行.

既然 MYSQL 在运行期间要存在大量的事务, 就要把大量的事务所管理起来, 先描述再组织, 于是事务在 MYSQL 内部看来就是一批被打包的事务对象, 然后放入事务执行队列去执行, 所以抽象的"事务"落脚在应用中就是一个个具体的"对象".

为什么要有事务?

事务不是伴随着的 DBS 一开始就有的, 它是在C/C++, Java等编程过程中被设计出来的简化的编程模型, 于是程序员就不用考虑各种各样的潜在错误, 并发问题, 原子性问题, 更不用关心持久化问题, 程序员只需要给出SQL, 由DBMS封装成事务去管理ACID问题, 从而使程序员更方便的去使用 SQL.

可以想一下: 当我们使用事务时, 要么提交 , 要么回滚 , 我们不会去考虑网络异常了, 服务器宕机了, 同时更改一个数据怎么办, 因此事务本质上是为了应用层服务的.而不是伴随着数据库系统天生就有的.

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

事务的准备工作

事务的版本支持

在 MySQL 中并不是所有的数据库引擎都支持事务, 只有使用了 Innodb 数据库引擎的 数据库或表 才支持事务, MyISAM 不支持.

show engines 可以查看MYSQL支持引擎的信息, 只挑选常见的两种InnoDB和MyISAM

sql 复制代码
show engines \G;
***************************[ 3. 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
XA           | NO
Savepoints   | NO

从中可以发现, InnoDB 由于我们曾在配置文件配置过, 因此是默认的存储引擎, 它支持transactions, 也就是事务; 而MyISAM不支持事务.

事务提交方式

事务的提交方式常见的有两种:

  • 自动提交
  • 手动提交

查看 autocommit 属性, ON 为自动提交, OFF 为手动提交.

sql 复制代码
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

用 SET 来改变 MySQL 的自动提交模式, 0为关闭, 1为打开:

sql 复制代码
set autocommit=0;

show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF   |
+---------------+-------+

事务常见操作方式

首先要清楚, mysql 其实只是一个客户端, 而 mysqld 是服务端, 可以有多台不同的机器 去访问指定主机下的 mysqld (ip+3306):

netstat 可以查看是否开启了 3306 mysqld 服务:

bash 复制代码
netstat -nltp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:3306            0.0.0.0:*               LISTEN      173276/mysqld       
tcp6       0      0 :::33060                :::*                    LISTEN      173276/mysqld      

现在在Windows上启动一个mysql客户端, 尝试连接mysql的server端:

在 server 端本地 ss / netstat 查看到了该链接, 所以可知: mysql本质是一个客户端进程

为了便于演示, 将mysql的默认隔离级别设置成最低的读未提交, 如果隔离级别太高, 演示不出效果:

sql 复制代码
set global transaction isolation level READ UNCOMMITTED;

如果未重启mysql客户端, 修改不生效:

重启客户端, 然后 SELECT @@transaction_isolation;, 就可以查看到修改后的事务隔离级别了:

sql 复制代码
SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| READ-UNCOMMITTED        |
+-------------------------+

然后创建一个表:

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;

事务的基本操作

首先将事务设置为自动提交:

sql 复制代码
set autocommit=1;
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+

首先介绍事务几种操作方式:

  1. 开始一个事务用 start transaction/ begin 都可以:
  2. 创建一个保存点s1: savepoint s1;
  3. 回滚到保存点s2: rollback to s2;
  4. 直接回滚到最开始: rollback
  5. 提交事务: commit;

情景一, 正常提交:

  1. 先在 client A开始事务, 插入了三条数据; 然后再在clientB查询:
sql 复制代码
//clientA:
start transaction

savepoint s1;
insert into account values(1, 'zs', 10.1);

savepoint s2;
insert into account values(2, 'ls', 10.2);

savepoint s3;
insert into account values(3, 'ww', 10.3);

//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
+----+------+--------+
  1. 只要事务还没有commit, 就是可以回滚的, 所以在 clientA 依次回滚到 s3, s2, s1; 然后在 clientB 查询:
sql 复制代码
//clientA
rollback to s3
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
+----+------+--------+

//clientA
rollback to s2
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
+----+------+--------+

//clientA
rollback to s1
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+
  1. 然后再重新在 clientA 插入三条数据, 直接 rollback, 回到了最初的无数据的状态:
sql 复制代码
//clientA
insert into account values(1, 'zs', 10.1);
insert into account values(2, 'ls', 10.2);
insert into account values(3, 'ww', 10.3);
rollback
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+
  1. 再重新在clientA 将三条数据插入, 然后 commit, 结束事务. 再尝试rollback, 回滚无效了, 因为 commit 后数据已经持久化保存在DB中了, 所以我们所说的回滚操作, 是在事务执行期间进行回滚, 事务提交后, 无法回滚.
sql 复制代码
//clientA
insert into account values(1, 'zs', 10.1);
insert into account values(2, 'ls', 10.2);
insert into account values(3, 'ww', 10.3);
commit
rollback

//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
+----+------+--------+

情景二, 事务提前发生异常(因为回滚一般是为了应对异常情况, 命令行上手动rollback比较少, 上面只是为了演示):

2.1 未commit, 客户端崩溃, MySQL自动会回滚(隔离级别设置为读未提交)

clientA 依然是开启 autocommit:

sql 复制代码
//clientA
begin
insert into account values(4, 'zs', 10.4);

//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
+----+------+--------+

此时将clientA强制退出, 发现数据被自动回滚:

sql 复制代码
//clientB
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
+----+------+--------+

2.2 证明commit了, 客户端崩溃, MySQL数据不会在受影响, commit 后数据已经持久化:

sql 复制代码
//clientA
begin;
insert into account values(4, 'zs', 10.4);
commit;
-- clientA异常退出

//clientB
 select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
+----+------+--------+

情景三: 事务的手动提交, 即(begin/start transaction, commit) 操作的显式事务, 不会受 MySQL 是否设置为 autocommit 的影响.

因为autocommit = 1 表示:每条语句执行后自动提交, 这是在你不使用 BEGIN/START TRANSACTION 时去生效的, 即对每一个单条的 DML 生效.

这里想说明的是, autocommit 的作用 和 begin/strat transaction 显式事务是两套逻辑, 在显式事务中, autocommit 不起作用, 因为它作用的场景根本不在显式事务里, 而是对情景四的单条SQL有作用.

实验就是把autocommit设为 0 然后重复上面的操作即可, 发现现象和之前一样.

结论: begin手动开启的事务, 最后就需要手动 commit 才能提交.

情景四: autocommit 和 单条SQL语句的关系

注意: 下面的操作和 begin, end 无关, 并不是手动去开启事务.

  1. clientA 先开启 autocommit:
sql 复制代码
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
delete from account where id=5
clientA崩溃

//clientB
--delete前
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
| 5  | tq   | 10.50  |
+----+------+--------+
--delete并且clientA崩溃后, 发现数据被持久化删除了, 说明这单条SQL语句被clientA自动提交了
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
+----+------+--------+
  1. clientA 关闭 autocommit:
sql 复制代码
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF    |
+---------------+-------+
delete from account where id=5
clientA崩溃

//clientB
--clientA的delete前
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
| 5  | tq   | 10.50  |
+----+------+--------+
--clientA的delete后并且cleintA还没有崩溃, 也没有提交:
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
+----+------+--------+
--clientA的delete后并且clientA崩溃后, 发现数据被回滚了, 说明这单条SQL语句没有被clientA自动提交
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
| 1  | zs   | 10.10  |
| 2  | ls   | 10.20  |
| 3  | ww   | 10.30  |
| 4  | zs   | 10.40  |
| 5  | tq   | 10.50  |
+----+------+--------+

由于autocommit默认是打开的, 所以之前我们执行单条SQL语句后, 每一条SQL都被自动提交了, 因此autocommit=1时, 每一条SQL都是一个事务, 所以平时我们察觉不到事务的存在.

  1. 理所当然的, 如果我们在delete语句执行后, 手动 commit 提交, 即使clientA, 数据依然被持久化保存. 不再演示
sql 复制代码
//clientA
show variables like 'autocommit'
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | OFF    |
+---------------+-------+
delete from account where id=5
commit
clientA崩溃

结论:

  • 只要输入 begin 或者 start transaction, 事务便必须 要通过commit提交, 才会持久化, 与是否 set autocommit 无关.
  • 在没有 commit 的情况下, 事务可以手动回滚到设置好的 savepoint , 同时, 当操作异常 , MySQL会自动回滚
  • 对于 InnoDB 每一条 SQL 语言默认 封装成事务, 自动提交(select有特殊情况, 因为MySQL 有 MVCC)
  • 数据是否持久化保存, 看的是 是否commit, 无论 手动commit 还是 自动commit.

事务操作注意事项:

  • 如果没有设置保存点, 也可以回滚, 只能回滚到事务的开始. 直接使用 rollback(前提是事务还没有提交)
  • 如果一个事务被提交了(commit),则不可以回退(rollback)
  • 可以选择回退到哪个保存点
  • InnoDB 支持事务, MyISAM 不支持事务
  • 开始事务可以使 start transaction 或者 begin

从之前的情景, 我们能看到事务本身的原子性 (回滚), 持久性(commit), 那么隔离性和一致性? 下面介绍隔离性:

☆ 事务的隔离级别

如何理解隔离性

首先需要明确 MySQL服务可能是会同时被多个客户端进程(线程)访问的, 访问的方式以事务方式 进行, 而一个事务可能由多条SQL构成, 所以在我们看来, 任何一个事务一定是有执行前, 执行中, 执行后 的阶段. 而所谓的原子性, 其实想让执行中的过程不被别人影响, 就算影响了, 也能回滚到执行前的状态, 从而在用户层只体现出执行前和执行后两态. 所以单个事务, 对用户表现出来的特性, 就是原子性.

但多个事务各自执行多个SQL的时候, 还是有可能会出现互相影响的情况. 比如:多个事务同时访问同一张表, 甚至同一行数据. 所以MySQL的一系列的技术手段, 比如隔离级别, 加锁等, 都是为了去保证这个"执行中"的状态尽量不被影响.

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

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

结论:

  • 在事务场景中, 隔离是必要的.
  • 在事务运行中, "不会"出现互相干扰, 体现隔离性
  • 根据影响程度不用, 设置隔离级别.

隔离级别

  • 读未提交【Read Uncommitted】 : 在该隔离级别, 所有的事务都可以看到其他事务没有提交的执行结果, 其实就是相当于没有任何隔离性, 之前的实验就是采取的这种隔离级别(实际生产中不可能使用这种隔离级别的), 这会产生很多并发问题, 如脏读,幻读, 不可重复读等.
  • 读提交【Read Committed】 : 该隔离级别是大多数数据库的默认的隔离级别 (不是 MySQL 默认的).它满足了隔离的简单定义: 一个事务只能看到其他的已经提交的事务所做的改变. 这种隔离级别会引起不可重复读, 即一个事务执行时, 如果多次 select, 可能得到不同的结果. (一个事务只有提交了之后才能被其它事务看到, 观察者事务在执行中和执行后都能看到)
  • 可重复读【Repeatable Read】 : 这是 MySQL 默认的隔离级别 , 它确保同一个事务 , 在执行中, 多次读取 操作数据时, 会看到同样的数据行, 但是会有幻读问题. (两个事务在执行的过程中都看不到对方的修改, 如果自己的事务在执行中, 还没有提交, 即使对方提交了事务, 我也看不到对方的修改)
  • 串行化【Serializable】: 这是事务的最高隔离级别, 它通过强制事务排序, 使之不可能相互冲突, 从而解决了幻读的问题. 它在每个读的数据行上面加上共享锁, 但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)

我们可以发现, 上面的隔离级别都包含了" "操作, 是应对读写并发的场景 , 通俗讲就是有一个人在进行CURD操作, 另一个人想要读取数据. 因为写写并发场景下为了保证数据一致性是一定要串行化的, 但是读写并发不需要严格串行, 而数据库大部分的操作都是"读", 所以为了一定程度上提高读写并发的并发度, 才有了隔离级别.

查看与设置隔离性

全局隔离级别会话隔离级别的区别是:

  1. 会话隔离级别在会话开始时会用全局隔离级别去初始化, 修改全局隔离级别相当于修改所有会话隔离级别的默认初始值.
  2. 修改当前会话隔离级别不会影响其它会话隔离级别.
sql 复制代码
select @@global.transaction_isolation --查看全局隔级别
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| READ-UNCOMMITTED               |
+--------------------------------+

select @@session.transaction_isolation --查看会话(当前)全局隔级别
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| READ-UNCOMMITTED                |
+---------------------------------+

select @@transaction_isolation --默认也是查看会话(当前)全局隔级别
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| READ-UNCOMMITTED        |
+-------------------------+

使用 set session/global transaction isolation 隔离级别 可以修改会话/全局隔离级别:

sql 复制代码
// 设置会话隔离级别
set session transaction isolation level serializable

//查看全局隔离级别, 没有被改变
select @@global.transaction_isolation
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| READ-UNCOMMITTED               |
+--------------------------------+

//查看会话隔离级别, 被改为串行化
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| SERIALIZABLE                    |
+---------------------------------+

读未提交【Read Uncommitted】

读未提交其实就是: 我能读到其它事务未提交的数据.

sql 复制代码
//事务开始前, 表是空的
select * from account
+----+------+--------+
| id | name | blance |
+----+------+--------+
+----+------+--------+

//事务A, 插入了一条数据, 还没有commit
begin
insert into account values(1, "张三", 1000)

//事务B, 在事务A提交前, 就已经看到了数据, 由于事务A还在执行中, 所以操作并不是原子性的
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
+----+------+---------+

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

"脏" 指数据处于一个"暂时、不稳定、不可信"的中间状态,

脏读是一个很不合理的现象. 比如假如事务A后续对事务B读到的数据作UPDATE, 甚至回滚, 那么事务B读到的数据就是"假的".

读提交【Read Committed】

读提交其实就是: 我能读到其它事务提交后的数据(读不到未提交的数据)

sql 复制代码
//读提交
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| READ-COMMITTED                  |
+---------------------------------+
//1.事务开始前, 表有一条数据
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
+----+------+---------+

//2.1 事务A, 插入了一条数据, 还没有commit
begin
insert into account values(2, "李四", 2000)

//2.2 事务B, 在事务A提交前查看表, 看不到事务A未提交的数据
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
+----+------+---------+

//3.1 事务A 可以看到修改后的数据
 select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+
commit --事务A提交
//3.2 事务B可以看到事务A提交后的数据
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+

这就造成了, 同一个事务内, 同样的读取,在不同的时间段 (依旧还在事务操作中!), 即事务A commit 的前后, 读取到了不同的值, 这种现象叫做不可重复读 (non reapeatable read).

具体表现在上个例子中就是:

sql 复制代码
//事务B 在运行期间, 同一条查询语句查到了不同的结果.
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
+----+------+---------+
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+

那么不可重复读是问题吗?

一个事务提交后的数据应不应该被其它事务看到呢? 应该被看到. 但是它不应该在其它事务运行期间 被看到!

比如, 在上面的例子中, 事务A提交后的数据, 可以被事务A commit之后, 新启动的其它事务看到, 而对于事务B来说, 此时事务B还在执行中, 它不应该看到事务A commit后的数据, 这样就会导致一次事务中读相同的数据不一致.

举一个例子:

可重复读【Repeatable Read】

可重复读就是: 在一个事务执行中, 读取同一数据, 得到的结果是相同的. (不是不让你读, 而是事务执行期间不能读到)

sql 复制代码
//当前事务隔离级别
select @@session.transaction_isolation
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| REPEATABLE-READ                 |
+---------------------------------+

//表中的数据
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+

//事务A 
begin
insert into account values(3, "王五", 3000)
update account set blance=4000.0 where id=1;

//事务B, 此时事务A还没有提交, 肯定看不到事务A修改的数据
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+

// 事务A, 可以查看到被修改的数据
 select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 4000.00 |
| 2  | 李四 | 2000.00 |
| 3  | 王五 | 3000.00 |
+----+------+---------+
commit --事务A提交

//事务B此时还未提交, 事务A commit后的数据也查看不到
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 1000.00 |
| 2  | 李四 | 2000.00 |
+----+------+---------+

// 事务B提交后, 新启动一个事务就能查看到事务A commit的数据了(autocommit下单条SQL就是一个事务)
commit
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 4000.00 |
| 2  | 李四 | 2000.00 |
| 3  | 王五 | 3000.00 |
+----+------+---------+

多次查看, 发现终端A在对应事务中insert的数据, 在终端B的事务周期中, 也没有什么影响, 也符合可重复的特点. 但是, 一般的数据库在可重复读情况的时候, 无法屏蔽其他事务 insert 的数据 (为什么?因为隔离性实现是对数据加锁完成的, 而 insert 待插入的数据因为并不存在, 那么一般加锁无法屏蔽这类问题) , 会造成虽然大部分内容是可重复读的, 但是insert的数据在可重复读情况被读取出来, 导致多次查找时, 会多查找出来新的记录, 就如同产生了幻觉. 这种现象,叫做幻读(phantom read). 很明显, MySQL在RR级别的时候,是解决了幻读问题的(解决的方式是用Next-Key锁(GAP+行锁)解决的

串行化【Serializable】

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

sql 复制代码
//最初的表数据
 select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 4000.00 |
| 2  | 李四 | 2000.00 |
| 3  | 王五 | 3000.00 |
+----+------+---------+

//事务A
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 4000.00 |
| 2  | 李四 | 2000.00 |
| 3  | 王五 | 3000.00 |
+----+------+---------+
3 rows in set
Time: 0.007s

//事务B开启, 但是读sql并没有被阻塞
begin
select * from account
+----+------+---------+
| id | name | blance  |
+----+------+---------+
| 1  | 张三 | 4000.00 |
| 2  | 李四 | 2000.00 |
| 3  | 王五 | 3000.00 |
+----+------+---------+

//事务A, 删除SQL被阻塞了, 需要等待锁
delete from account where id=3
//You're about to run a destructive command.
//Do you want to proceed? (y/n): y
//Your call!
//(1205, 'Lock wait timeout exceeded; try restarting transaction')
//可以看到, 上面的事务A因为长时间等待锁超时了, 事务被强制结束

//重启一个事务A
begin
delete from account where id=3
You're about to run a destructive command.
Do you want to proceed? (y/n): y
Your call!
--此时事务A还在被阻塞状态

//事务B等待2s左右, commit
commit

//事务A才显示事务结束, 用时2s多, 可以看到事务A的写操作一直在等待锁
Query OK, 1 row affected
Time: 2.096s

总结:

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

一致性(Consistency)

  • 事务执行的结果, 必须使数据库从一个一致性状态, 变到另一个一致性状态. 当数据库只包含事务成功提交的结果时, 数据库处于一致性状态. 如果系统运行发生中断, 某个事务尚未完成而被迫中断, 而未完成的事务对数据库所做的修改已被写入数据库, 此时数据库就处于一种不正确(不一致)的状态. 因此一致性是通过原子性来保证的.
  • 其实一致性和用户的业务逻辑强相关, MySQL只是提供技术支持, 但是一致性还是要用户业务逻辑做支撑, 也就是, 一致性, 是由用户决定的. 技术上是AID去保证C

简单来说就是, MYSQL技术上有原子性(A), 持久性(I), 和 隔离性(D), 我保证: 只要用户把事务提交给我, 如果事务commit成功, 我能保证数据能从一个确定的状态转为下一个确定的状态; 如果事务commit失败, 数据会恢复原来的状态. 从而去保证 一致性©, 也就是说, 我保证用户事务提交后的状态是可预测的!

但是防君子不防小人, MYSQL提供了这样的技术支持, 但是用户如果在业务层面使坏, 假如A要给B转账, 我光给A扣钱, 而不给B加钱, 这种情况造成的数据不一致是用户造成的, 而不是MYSQL造成的.

所以一致性是由数据库和用户共同维护的!

隔离性实现原理

首先需要明确数据库并发的场景有三种:

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

读-写并发

"多版本并发控制 (MVCC)" 技术 是一种用来解决 读-写冲突 的无锁并发控制

读操作只读该事务开始前的数据库的快照. 所以 MVCC 可以为数据库解决以下问题:

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

在理解MVCC之前, 我们要明确事务的组织逻辑:

  1. MYSQL会为事务分配单向增长的事务ID , 为每个修改保存一个版本, 版本与事务ID一一对应, 且ID越小, 事务来的越早, 从而决定事务的先后顺序
  2. mysqld 可能面临处理多个事务的情况, 事务也有自己的生命周期, 所以事务也需要被组织管理起来, 每一个事务都可以被抽象为一个类, 在底层都对应一个结构体.

还要明确四个问题:

1. MySQL的每个表都有 3 个隐藏列
  • DB_TRX_ID :6 byte, 最近修改 ( 修改/插入 ) 事务ID, 记录创建这条记录/最后一次修改该记录的事务ID
  • DB_ROLL_PTR : 7 byte, 回滚指针, 指向这条记录的上一个版本(简单理解成, 指向历史版本就行, 这些数据一般在 undo log 中)
  • DB_ROW_ID : 6 byte, 隐含的自增ID (隐藏主键), 如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引.
  • 补充:在记录头(Record Header)实际还有一个"删除标记 delete-flag"隐藏, 当执行 DELETE 或 UPDATE(产生新旧版本)时,数据行不会立即从物理页中删除, 而是:
    ✔ 在记录头设置 delete-mark flag = 1
    ✔ InnoDB 通过 undo log 生成前版本
    ✔ 当前版本对其他事务不可见(由 DB_TRX_ID 控制)
    换句话说: DELETE 在 InnoDB 不是磁盘级删除, 而是内存级删除. 用于 MVCC 回溯, 等所有需要旧版本的事务都结束后, Purge 线程才会物理删除它.
    如果没有这个flag:
    1. MVCC 需要旧版本, 如果立即删除, 别的事务无法读取旧版本
    2. 删除行需要在所有索引树上删除, 成本高, 在 purge 批处理时做更高效
2. undo 日志

我们需要清楚, MySQL 将来是以服务进程的方式, 在内存中运行. 所以之前提到的MySQL的所有机制: 索引,事务,隔离性,日志等, 都是在内存中完成的, 即在 MySQL 内部的相关缓冲区中, 保存相关数据, 完成各种判断操作. 然后在合适的时候, 将相关数据刷新到磁盘当中的.

所以, 我们这里需要把 undo log 简单理解成, 它就是 MySQL 中的一段内存缓冲区, 用来保存日志数据的.

3. 模拟 MVCC

先以 UPDATE 为例介绍"版本":

现在有一条记录如下, 有一个事务10, 对student表中记录进行修改(update):将name(张三)改成name(李四), 那么它的 undo log 是如何生成的呢?

  1. 事务10,因为要修改, 所以要先给该记录加行锁.

  2. 修改前, 现将改行记录拷贝到undo log中, 所以, undo log中就有了一行副本数据。(原理就是写时拷贝)
    所以现在 MySQL 中有两行同样的记录

  3. 修改回滚指针指向旧数据, 原始记录的回滚指针 DB_ROLL_PTR 列, 里面写入undo log中副本数据的地址, 从而指向副本记录, 既表示我的上一个版本就是它.

  4. 进行数据的更新.修改原始记录中的name, 改成 '李四'. 并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务10 的ID, 我们默认从 10 开始, 之后递增

  5. 事务10提交, 释放锁

此时, 最新的记录是'李四'那条记录

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

  1. 事务11,因为也要写操作, 所以要先给该记录加行锁。
  2. 修改前,现将改行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的数据我们采用头插方式, 插入undo log
  3. 现在修改原始记录中的age,改成 38. 并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID, 而原始记录的回滚指针 DB_ROLL_PTR 列, 里面写入undo log中副本数据的地址, 从而指向副本记录, 既表示我的上一个版本就是它.
  4. 事务11提交, 释放锁

这样, 我们就有了一个基于链表记录 的历史版本链.

所谓的回滚, 就是去遍历这个日志链表(当前记录是头指针), 然后找到对应的结点, 用历史数据, 覆盖当前数据

undo log 缓冲区会被打满吗?

如果没有事务在使用 undo log, 那么undo log的内容就会被 mysqld 自动清理掉,

那么如果是DELETE呢?

和UPDATE是一样的, 因为删数据不是清空, 而是设置 delete-flag 为删除即可, 所以DELETE 也可以形成自己的版本链:

  1. 先拷贝当前数据到undo log 缓冲区, 变成旧数据
  2. 新数据的回滚指针指向旧数据
  3. 新数据的 flag 设置为删除.

那么如果是INSERT呢?

因为 insert 是插入, 也就是之前没有数据, 那么 insert 也就没有历史版本. 但是一般为了回滚操作, insert 的数据也是要被放入undo log中, insert的相反操作就是delete, 所以在undo log里记录对应的delete语句即可.

补充问题: 如果当前事务commit了, undo log 是否会被删除呢

a. 除非事务是RU隔离级别, 否则 insert 的 undo log 的可以被清空了, 因为insert没有历史版本, 不会有其它事务在使用...

b. UPDATE 和 DELETE 就不一定, 因为它们的历史版本可能会被其它事务使用.

那么如果是SELECT呢?

首先, select不会对数据做任何修改, 所以, 为select维护多版本, 没有意义.

不过, 此时有个问题: 我们之前的 update, delete 修改的一定是最新的数据(所以才要加锁), 历史版本的数据没有资格修改, 那么 select读取, 是读取最新的版本呢?还是读取历史版本?

是要根据隔离级别区分的, 比如在RR级别下, 一个事务都提交了, 另一个事务却读不到新数据, 所以两个事务一定读的是不同的数据!

读分为两种, 当前读和快照读, 一个最大的区别就是是否需要加锁

  • 当前读: 读取最新的记录 , 就是当前读.
    • 增删改, 都叫做当前读
    • select 也有可能当前读, 比如:select in share mode(共享锁), select for update (这个好理解,我们后面不讨论).
  • 快照读: 读取历史版本(一般而言), 就叫做快照读. (这个我们后面重点讨论), 只有RC和RR使用快照读, 且一般情况RC和RR都使用快照读, 除非是显式使用上面当前读的两种方式.
隔离级别 普通 SELECT SELECT ... FOR UPDATE / UPDATE / DELETE select in share mode
RU 当前读+ 不加锁(允许脏读) 当前读 + 加X锁 当前读 + 加S锁
RC 快照读, 不加锁 (每次select都生成一次read view) 当前读 + 加X锁 当前读 + 加S锁
RR 快照读, 不加锁 (只在事务开始时生成一次 Read View) 当前读 + 加X锁 当前读 + 加S锁
SERIALIZABLE 当前读 + 强制加S锁 当前读 + 加X锁 当前读 + 加S锁

所以为什么读写可以并发呢?

因为我们"写"写的是最新的记录, 操作修改是最新的数据, 而"读"一般是快照读, 读的都是历史版本, 所以它们操作的根本不是同一个位置的数据, 所以不需要加锁, 可以进行读写并发.

所以隔离性是在版本上做隔离, 隔离级别具体决定了应该看到哪些版本.

4. Read View

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

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

注意: 事务和ReadView 的关系就像进程PCB和进程地址空间mm_struct的关系, ReadView表示事务对版本资源的可见性, mm_struct表示进程对内存资源的可见性.

cpp 复制代码
class ReadView {
	// 省略...
	private:
	trx_id_t m_low_limit_id //高水位,大于等于这个ID的事务均不可见
	trx_id_t m_up_limit_id; //低水位:小于这个ID的事务均可见
	trx_id_t m_creator_trx_id; //创建该 Read View 的事务ID
	ids_t m_ids; //创建视图时, mysql中的活跃事务id列表
	
	/*配合purge(purge是mysql中的一个负责刷新数据到磁盘, undo log的删除等问题的线程), 
	标识该视图不需要小于m_low_limit_no的UNDO LOG,
	如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
	trx_id_t m_low_limit_no;
	
	bool m_closed; //标记视图是否被关闭
	// 省略...
};

我们最关心的是下面四个字段:

cpp 复制代码
m_ids; //一张列表,用来维护Read View生成时刻, 系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)

/*ReadView生成时刻系统尚未分配的下一个事务ID, 
也就是目前已出现过的事务ID的最大值+1(也没有写错).
需要强调的是, 这里的"最大值"指的并不是m_ids里事务ID的最大值, 而是整个系统中所有事务的最大值
*/
low_limit_id; 

creator_trx_id //创建该ReadView的事务ID

接下来就介绍这四个字段是如何控制事务的可见范围的:

如图所示, 当前16号事务有一个快照读操作, 想要读取李四这一行的内容:

  1. 先看最左边, 分为两种情况, 而且这两种情况下的数据都应该被看到,
    • creator_trx_id == DB_TRX_ID 表示当前事务要读的数据就是自己修改的, 自己肯定可以读到
    • 如果要读的数据的事务ID(DB_TRX_ID) < low_limit_id, 表示这个事务比当前活跃的最早事务还要早, 说明它已经提交了, 也应该被读到
  2. 再看最右边, 一共一种情况, 不应该被看到. 要读的数据(DB_TRX_ID) >= low_limit_id, 说明是照了快照之后才出现的新事物, 不应该被读到
  3. 最后看中间, 也分为两种情况:
    • 如果要读的数据的事务ID(DB_TRX_ID) in 活跃事务(m_ids), 说明其它事务还没有提交, 为了避免脏读, 不应该看到它
    • 如果要读的数据的事务ID(DB_TRX_ID) not in 活跃事务(m_ids), 说明事务在产生快照时已经提交了, 可以看到它

      可以从代码上再理解一下, 要从前到后遍历版本链对每一个结点用changes_visible去判断是否"应该被看到", id是被判断的事务ID, 其它逻辑和上面文字版一样:

注意: readview 是事务可见性的一个类, 不是事务创建出来就会伴生一个readview, 而是这个事务首次进行快照读的时候, mysql 形成 read view!

举个例子:

假设当前有条记录

事务操作:

cpp 复制代码
//事务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,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
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提交的版本也是全局角度上最新的版本

RR 与 RC的本质区别

至此我们已经了解了Read View的工作原理, 明白了为什么可以通过MVCC来支持读写并发, 但是我们还不清楚 RR 和 RC 的区别, RR是为了解决RC不可重复读问题的, 其中一个关键的节点就是"事务是否提交".

现在是RR模式下, 有两个事务在同时运行, 事务B在事务A commit之前, 查询了一次, 产生了一个Read View, 此时事务A的ID应该在事务B ReadView的m_ids, 所以在后续事务A更新, commit, 之后, 事务B拿着这个快照去查的时候, 都还"假想"着事务A还在运行中呢, 所以没有读到那一条修改age=18的版本记录.

最后用当前读去验证一下, age确实被修改为18, 只是快照读没有读到而已, 这很合理:

再来看这个场景, 与上面唯一的区别就是, 事务B在事务A commit之前, 并没有select产生快照读, 而是在事务A commit 之后生成的Read View, 所以此时事务A的那条记录是可以被读到的:

结论:

  • 事务中快照读 的结果是非常依赖该事务首次出现快照读的地方, 即某个事务中首次出现快照读, 决定该事务后续快照读结果的能力.
  • delete也是如此

RR 与 RC的本质区别

所以我们就可以理解RR和RC为什么不一样了: 正是Read View生成时机的不同, 从而造成RC,RR级别下快照读的结果的不同.

  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照 及Read View, 将当前系统活跃的其他事务记录起来. 重点!! 此后在调用快照读的时候,还是使用的是同一个Read View, 所以只要当前事务(上例事务B)在其他事务(上例事务A) commit 之前使用过快照读, 由于快照读使用的都是同一个Read View, 所以事务B通过这个 Read View 把自己"蒙骗"了, 它只活在当时"拍照"的瞬间, 表现为之后是否有旧事务commit, 是否有新事务到来, 它都不关心, 它只活在当时. (即RR级别下,快照读生成Read View时, Read View会记录此时所有其他活动事务的快照, 这些事务的修改对于当前事务都是不可见的. 而早于 Read View 创建的事务所做的修改均是可见.)
  • 而在RC级别下的事务中, 重点!! 每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因. 也正是RC每次快照读, 都会形成Read View, 所以, RC才会有不可重复读问题.

所以RR才解决了不可重复的问题, RR目的就是要保证我的每一次读读到的内容都是一致的, 所以不能每次读都生成一份新的Read View, 否则每次读的视角都刷新一次, 自然会就有可能读到不同的数据.

相关推荐
q***R3081 小时前
MySQL并发
数据库·mysql
星辰_mya2 小时前
浅谈redis中的hash
数据库·redis·哈希算法
正在走向自律3 小时前
金仓KingbaseES助力央企数字化转型
数据库·国产数据库·kingbasees·电科金仓·央企数字化
YFLICKERH3 小时前
【数据包】Sql Server 数据库TDS协议抓包
数据库·协议
云边有个稻草人3 小时前
【MySQL】第二节—库的操作 | 详解
数据库·mysql·库的操作
张较瘦_3 小时前
数据库 | MySQL表管理与增删改查:从入门到实践
数据库·mysql
q***42053 小时前
Redis如何设置密码
数据库·redis·缓存
字节数据平台3 小时前
火山引擎多模态数据湖,破解智能驾驶数据处理瓶颈
数据库
e***U8203 小时前
SQL在数据迁移中的脚本编写
数据库·sql