学习了之后,你会对MySQL如何进行数据的访问控制有更深刻的了解;

本文中有大量截图,如果出现404的图片,请评论告知,谢谢;

1.什么是事务?

1.1 场景举例

1.1.1 转账

当A需要给B转账50元的时候,后台应该进行如何处理?

1
2
3
4
5
6
-- 先判断用户还有多少余额(这里的user_id=A只是一个示例,知道什么意思就行)
select money from user where user_id=A;
-- 如果余额充足,那就给A扣50
update user set money = money - 50 where user_id=A;
-- 然后给B加50
update user set money = money + 50 where user_id=B;

这一个操作,需要3个SQL语句才能完成,但很明显,我们即便在当前的服务器端对这3条语句的操作进行加锁,也没有办法避免如下的情况

  • 服务器负载过高,进程被kill了
  • 服务器断电了
  • MySQL服务器抽风挂掉了
  • 等等不可抗力

这时候,如果在执行第一条查询语句后就挂掉了,那还不会有啥问题;但如果是在执行完毕第二条扣钱的语句之后挂掉了,那问题就大了!

  • A给B转钱,A的钱扣了,B的钱没有加,这怎么可以?

最终还需要程序员通过MySQL的日志一个个核对到底是谁出现了这样的情况,给人家把钱加回去,那太过麻烦;在数据量大的时候,这种工作更是不应该由人工手动完成的!

所以,MySQL就需要提供一种方法,让我们能够实现类似原子性的操作。在执行这3条语句的时候,只能出现两种情况

  • 成功执行,A扣了钱,B也加了钱,转账成功;
  • 执行失败,但是A和B的钱都没有变化,并告知用户转账失败;

这样才是靠谱的结果!

1.1.2 卖票

另外一个场景就是卖票,其也包含了多条SQL

1
2
3
select * from ticket where tid=?; -- 查询某某车次高铁的票还有没有余量
-- 有余量,进行售卖流程;这里可能要操作另外一张表来添加买票者的用户信息
update ticket set num=num-1 where tid=?; -- 售卖流程结束,扣去这张票

如果有两个客户端都同时来执行这两条SQL语句,那么就会出现问题;

  • 只剩一张票
  • 客户端A进行查询,发现有票,开始执行卖票流程
  • 客户端B进行查询,也发现有票,开始执行卖票流程
  • 此时A执行完毕,将票的数量-1,卖完票了;
  • 随后B执行完毕,也将票的数量-1,此时就会出现票数是负数的情况!
  • 更难受的是,同一张票被错误地卖给了两个用户!

这时候在客户端里面加锁是不顶用的,两个客户端都不是同一个进程,锁无法共享;需要MySQL解决这个问题,就还得提供更加严格的访问控制,在A没有完成整个卖票逻辑,票数没有扣掉的情况下,B不可以来查询票数

1.2 事务的介绍 ACID

事务(transaction)就是一组DML语句的集合,这些语句在逻辑上是一个整体;执行这组语句,必须全部成功,亦或者是全部失败。

在数据库中,并发控制的基本单位事务

在执行这组SQL的时候,MySQL需要将其视作一个原子性的操作;这个操作不能被其他事务打断,出现问题的时候还需要回滚到开始执行之前的初始状态;事务还能使不同客户端看到的数据是不同的,不同事务之间的操作不会相互影响;只有事务结束后,双方才能看到对方的操作(根据隔离级别不同,这点也会有区别);

正如上面的两个场景所提到的操作,同一个时刻对于MySQL的一个数据库来说,其可能有多个客户端进行不同业务的操作,如果都在访问同一个表,在不加访问控制的情况下,一定会出现访问临界资源的数据二义性问题。

事务的出现,是客户端在实际应用场景下的需要,

所以,一个完整的事务,除了是多条DML语句的集合,还需要满足下面4个特性

  • 原子性:一个事务中的所有操作,要么全部完成,要么全部失败;如果事务在执行过程中发现错误,那就需要回滚(Rollback)到事务开始之前的状态,就好比这个事务并没有执行过一般
  • 一致性:事务开始之前和结束后,数据库的完整性没有被破坏。进行的操作复合我们预设的要求;这包含数据的准确度、串联性以及后续数据库可以自发完成预定的工作;
  • 隔离性:数据库允许多个并发事务同时对数据库中的数据进行CURD操作,隔离性保证这些事务之间的操作并不会由于并发的交叉运行而出现不一致的情况。事务隔离性分为不同级别,具体的区别会在后文详解
    • 读未提交(Read Uncommitted)
    • 读已提交(Read Committed)
    • 可重复读(Repeatble Read)
    • 串行化(Serializable)
  • 持久性:事务处理完毕后,对事务的修改是永久的,即便系统宕机了也不会丢失(除非硬盘坏了)

上面的四个属性,简称为ACID;

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

其中,一致性是由原子性、隔离性、持久性来保证的,只要满足了这三个性质,自然能实现一致性。简单记忆,AID来保证C;另外还有个知识点:数据库的完整性是指数据的正确性和一致性

1.3 事务的存储引擎支持

在MySQL中,只有Innodb支持事务,MyISAM是不支持事务的;

我们可以用如下语句来查询MySQL的引擎和支持的特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
MariaDB [hello_mysql]> show engines \G
*************************** 1. row ***************************
Engine: MEMORY
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 2. row ***************************
Engine: MRG_MyISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: CSV
Support: YES
Comment: Stores tables as CSV files
Transactions: NO
XA: NO
Savepoints: NO
*************************** 4. row ***************************
Engine: BLACKHOLE
Support: YES
Comment: /dev/null storage engine (anything you write to it disappears)
Transactions: NO
XA: NO
Savepoints: NO
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: Non-transactional engine with good performance and small data footprint
Transactions: NO
XA: NO
Savepoints: NO
*************************** 6. row ***************************
Engine: ARCHIVE
Support: YES
Comment: gzip-compresses tables for a low storage footprint
Transactions: NO
XA: NO
Savepoints: NO
*************************** 7. row ***************************
Engine: FEDERATED
Support: YES
Comment: Allows to access tables on other MariaDB servers, supports transactions and more
Transactions: YES
XA: NO
Savepoints: YES
*************************** 8. row ***************************
Engine: PERFORMANCE_SCHEMA
Support: YES
Comment: Performance Schema
Transactions: NO
XA: NO
Savepoints: NO
*************************** 9. row ***************************
Engine: SEQUENCE
Support: YES
Comment: Generated tables filled with sequential values
Transactions: YES
XA: NO
Savepoints: YES
*************************** 10. row ***************************
Engine: InnoDB
Support: DEFAULT -- 默认引擎
Comment: Supports transactions, row-level locking, foreign keys and encryption for tables -- 描述
Transactions: YES -- 支持事务
XA: YES
Savepoints: YES -- 支持事务中的保存点
*************************** 11. row ***************************
Engine: Aria
Support: YES
Comment: Crash-safe tables with MyISAM heritage
Transactions: NO
XA: NO
Savepoints: NO
11 rows in set (0.000 sec)

1.4 保存点

所谓保存点,就是在事务执行过程中,给当前的数据设置一个savepoint,这样在出现问题的时候,可以回退到当前的数据中;就好比之后的操作没有进行一样;

游戏中的保存点其实也是这样的功能,让玩家回退到打某个BOSS之前,包括当时你收集的材料,都会回退到打这个BOSS之前的状态;

你可以理解为,是给当前的数据拍了张照片,出现问题的时候,用这个照片里面的东西复写掉新的东西。这便是保存点的作用;

1.5 基础命令操作

先提一下事务的开启和结束方式,以及如何设置保存点和回滚吧

1
2
3
4
5
6
7
8
9
10
-- 开启,这两种方式是一样的
begin;
start transaction;
-- 设置保存点(名字可以自由更改,但不能有重复)
savepoint save1; -- 创建一个保存点save1
-- 事务回滚
rollback to save1; -- 回滚到保存点save1
rollback; -- 回滚到事务开始
-- 结束
commit; -- 提交事务

2.使用

了解了事务为什么存在,下面就是来使用一下事务了;

2.1 提交方式

事务的提交方式有两种方式

  • 自动提交
  • 手动提交

默认情况下,MySQL的自动提交是处于开启状态的

1
2
3
4
5
6
7
MariaDB [hello_mysql]> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+
1 row in set (0.001 sec)

我们可以用下面的语句来设置是否开启自动提交(只对当前会话生效)

1
2
set autocommit = 0; -- 关闭自动提交
set autocommit = 1; -- 开启

这两个提交方式有啥区别呢?是不是开了自动提交就什么都不用管了?后文我们再通过实测来说明;

2.2 场景演示

最开始测试的时候,先将事务隔离级别设置为读未提交;请注意,不要在有生产环境的MySQL中进行此项操作;

1
2
3
4
5
6
7
8
9
10
-- 设置全局隔离级别为读未提交
set global transaction isolation level READ UNCOMMITTED;
-- 重启mysql终端后查看隔离级别
select @@tx_isolation;
+------------------+
| @@tx_isolation |
+------------------+
| READ-UNCOMMITTED | -- 成功设置读未提交的隔离级级别
+------------------+
1 row in set (0.000 sec)

2.2.1 数据回滚演示

设置完毕后,进入测试数据库,创建如下测试表

1
2
3
4
5
create table test_ruc(
id int unsigned primary key,
user_id int unsigned not null,
name varchar(30) not null
);

先给表里面插入几个基本的数据:

1
insert into test_ruc values (1,10,'李华'),(2,20,'王五');

插入后,我们在两侧的终端中都可以看到已有的数据

image-20230910163833837

随后在两个终端中都开启一个事务,左侧终端插入一个数据,右侧终端查看

1
insert into test_ruc values (3,30,'左侧终端插入');

可以看到,两侧的终端都可以看到这份数据

image-20230910164315360

此时将左侧终端强制退出(使用CTRL+\),右侧再次查询,会发现新插入的数据没有了;这里便是事务的原子性的体现,左侧的终端所执行的事务没有commit就因为异常退出了,MySQL自动将数据回滚到了这个事务执行之前,即没有插入这份新数据;

image-20230910164414488

左侧重新链接MySQL,开启一个事务;在右侧插入一个数据,随后创建保存点save1;插入第二个数据,创建保存点save2

在左侧查询,我们可以看到右侧插入的两条新数据;

image-20230910165426916

在右侧回滚到保存点save1,会发现插入的数据2没有了;这与保存点的预期操作相符合;

image-20230910165719357

将右侧终端强制退出,再次查询数据,可以看到第一次插入的数据也没有了,数据又回到了右侧终端事务开始之前的样子。

image-20230910171826318

这里能得出一个结论:如果终端异常退出,MySQL会将数据回滚到事务开始时;你可以理解为,我们使用begin;的时候就已经创建了一个隐藏的保存点,MySQL在异常的时候只会将数据回退到这个事务开始之时的隐藏保存点,无论中途用户有没有设置其他保存点!

即:在异常退出的时候,MySQL会自动帮我们执行rollback命令回退到事务开头!

1
rollback; -- 回退到事务开头

这样就能避免最开始提到的,转账系统中给A扣了钱但是没有给B加钱的问题!

2.2.2 数据持久化演示

接着我们再来看看,如果执行了commit之后又会是什么情况;

右侧提交了事务后退出,左侧依旧能查询倒已经提交的数据。代表数据已经被持久化写入到了磁盘(当然这个时候也不一定立刻刷盘了,但是这已经能代表MySQL将我们的数据加入到了持久化策略中,并不需要我们管啥时候刷盘的问题!)

image-20230910213252764

2.2.3 自动提交

前面提到了,MySQL的自动提交默认是开启的;那么这个自动提交有什么作用呢?是不是开启了自动提交,就不需要手动创建事务了?

并不是!在之前的测试中,autocommit是开启的;我们已经证明了,手动begin创建的事务,在没有手动commit的时候不会被写入磁盘(不进行持久化);这代表我们手动创建的事务,不会受到autocommit是否开关的影响;(开着的时候都没有自动提交,关闭的时候肯定更不会自动提交了)

  • 那么这个autocommit会影响谁呢?

开启两个终端,左侧将自动提交关闭,随后插入一个数据;插入后会发现右侧看不到左侧新插入的数据!

备注:set autocommit = 0;语句只会影响当前终端,不会影响另外的终端。

image-20230911081342217

此时手动进行commit,右侧就看得到数据了!

image-20230911081509985

而如果我们不手动执行commit就把左侧终端CTRL+\退出,那么新插入的这一条数据会丢失!

2.2.4 单条SQL和事务的关系

欸?我们明明没有begin开启事务,为什么可以执行commit呢?

这是因为,在MySQL中,每一个不主动使用事务的单条SQL语句,都会视作一个事务进行操作!既我们之前没学习事务时,执行的所有单条SQL也是一个个各自独立的事务!

知道了这一点,你就能理解自动提交的作用了:在执行单条SQL的时候,是否自动提交该SQL语句的事务。

autocommit重新开启,再进行测试;会发现左侧插入的数据右侧可以立马看到,无需手动commit。这便是自动提交的作用的体现!

image-20230911082134863

2.3 结论

经过上面的这些简单测试,我们可以看到事务的原子性和持久性,做一个总结

  • 输入了begin/start transaction手动创建的事务,必须通过commit手动挡提交,才会持久化;这一点和autocommit无关!
  • 事务可以设置保存点和手动回滚,出现异常的时候,MySQL会自动回退到事务开头;
  • 对于InnoDB存储引擎而言,所有的单条SQL语句都会被视作一个事务,自动提交;
  • select语句会有一些差别,因为MySQL有MVCC访问控制(后文讲解)

操作事务的一些注意事项

  • 只有InnoDB才支持事务,MyISAM不支持;
  • 一个事务被提交了之后,不可以执行rollback;
  • 创建了保存点后,可以选择回退到哪一个保存点,亦或者是回退到事务开头;

3.事务隔离级别

3.1 基本说明

事务的隔离级别和事务的隔离性息息相关

  • MySQL的单个数据库可能被多个进程/线程同时进行访问和事务操作;
  • 一个事务是由多个SQL语句组成的,任何一个事务,都会存在执行前/中/后的阶段。所谓原子性,就是MySQL要保证各个客户端要么只能看到事务执行前或者执行后的数据;执行中的事务,对于其他用户来看是原子的,出现异常会被自动回滚;
  • 但事务毕竟还是存在执行中的概念,其他客户端是有可能看到某个事物执行中的状态的,这就需要隔离性来介入了;
  • 隔离性:保证事务在执行过程中尽量不受干扰;
  • 隔离级别:允许事务受不同程度的干扰;

开头就提到了,MySQL的事务有4种隔离级别

  • 读未提交(Read Uncommitted):在这个隔离级别中,所有事务都可以看到其他事物还没有提交的内容(你可以理解为压根没有隔离性,之前的测试使用的就是这个隔离级别)这种情况下就容易引起并发和读取数据的不一致问题,还有脏读问题(对方没有commit的数据就叫做脏数据,读取脏数据是脏读),生产环境中是打死不能用这个隔离级别的!
  • 读已提交(Read Committed):在这个隔离级别中,事务只可以看到其他事务已经提交了的内容。这已经满足了隔离的基本定义:只能看到其他事务开始前或者开始后的内容;但是这种隔离模式会产生不可重复读的问题(相同select会出现不同结果);
  • 可重复读(Repeatable Read):为了解决不可重复读的问题,这个隔离级别保证了事务在运行过程中多次读取相同数据时会得到相同结果;但是这个隔离级别在某些数据库中可能会出现幻读(一个事务在执行过程中读取到了另一个事务已提交的插入数据)问题;MySQL默认采用此隔离级别;
  • 串行化(Serializable):所谓串行化,就是MySQL给数据库的所有操作都上锁,必须要排队运行。一个事务在运行时,另外一个事务得等它运行完毕了才能进来运行,这样能最大程度保证隔离性;但是会降低效率;

隔离级别是由MySQL进行各种类型的加锁来实现的,比如表锁、行锁、读写锁、间隙锁(GAP)、NEXT-KEY锁(GAP+行锁)等等;

这里还需要验证另外一件事,MySQL如果开启了串行化,客户端c/cpp操作还需要加锁吗?mysqlquery的操作是会阻塞还是直接错误退出?

在MySQL中,事务的隔离级别可以分别设置全局的隔离级别和当前客户端的隔离级别;如果没有主动设置客户端的隔离级别,则会继承全局的隔离级别。顾名思义,会话隔离级别只会影响当前的会话,不会影响另外的客户端;

另外,全局的隔离级别会在数据库服务重启后,被重置为配置文件中默认的隔离级别(一般是RR)

1
2
3
4
5
6
-- 设置 会话/全局 隔离级别
set [session | global] transaction isolation level [read uncommitted | read committed | repeatable read | serializable ]
-- 查看当前会话的隔离级别
select @@session.tx_isolation;
-- 查看全局的隔离级别
select @@tx_isolation;

总结一下几个不正常的读问题:

  • 脏读:一个事务读到了其他事务还没有提交的修改;
  • 幻读:一个事务读到了其他事务已经提交了的新增数据;

3.2 读未提交

这个隔离级别在第二大点里面就已经测试过了,左侧终端插入的数据,即便没有commit,在右侧终端中也能看得到,这里就不二次演示了

image-20230910164315360

清除已有的数据,来测试一下会话隔离级别的作用;默认情况下,全局和会话的隔离级别都是RR;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MariaDB [hello_mysql]> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set (0.000 sec)

MariaDB [hello_mysql]> select @@session.tx_isolation;
+------------------------+
| @@session.tx_isolation |
+------------------------+
| REPEATABLE-READ |
+------------------------+
1 row in set (0.000 sec)

将左侧的终端中的会话隔离级别设置为读未提交

1
set session transaction isolation level read uncommitted;

插入数据之前,执行一次select全列查询,两侧都是空;

在左侧插入数据后,右侧查看会发现依旧是empty set没有结果;只有左侧commit之后,右侧才能看到数据;这是因为在之前测试的时候,我们将自动提交关闭了;右侧看不到我们还没有提交的事务;

image-20230911100829407

右侧手动开启事务后,即便没有进行commit,但是左侧因为设置了读未提交,所以可以直接看到右侧还没有提交的事务中的修改。这便是这个隔离级别的特性;

image-20230911101100927

3.3 读已提交

接下来再将全局隔离级别设置为读已提交,重新开启两个终端进行测试;

1
set global transaction isolation level read committed;

设置完毕,重启终端后,可以看到两侧的隔离级别都是读已提交,自动提交都是开启的;

image-20230911102633596

两侧都开启一个事务,左侧插入数据,右侧查看,发现没有内容;

image-20230911103402950

左侧commit之后,右侧才可以看到这个数据;

image-20230911103432740

这符合我们对读已提交这个隔离级别的字面理解,但是这并不代表这个隔离级别没有问题!

3.3.1 出问题的场景

来看看下面这个场景:

  • 公司需要根据员工工资,分发不同级别的奖金:
    • 2000-4000 级别一
    • 4001-8000 级别二
    • 8001-15000 级别三
  • 甲开启了一个客户端,开始用MySQL筛选不同工资区间的员工,记录下来;
  • 乙开启了一个客户端,想要给员工丁加工资
    • 丁原本的工资是3000
    • 乙给丁加了2000工资,丁的工资变成了5000,跳到了工资级别二;
    • 乙提交修改(commit)
  • 假设甲已经完成了对2000-4000级别工资的员工筛选,此时乙过来给丁加了工资,丁跳到了工资级别二;
  • 因为隔离级别是读已提交,甲在筛选工资级别二的人员时,会把刚刚被修改过的丁也给纳入进去;
  • 这时候,对于甲而言,丁就同时出现在了工资级别一和二的人员中,这明显是不合理的

这个场景被称为不可重复读,即在一个事务中,同一个查询语句,可能会因为其他事务的提交而产生不同的结果;换做SQL语句来描述,在读已提交的隔离级别中,同一个select语句只能执行一次,第二次执行的时候,可能会获取到和第一次不同的结果,所以这个问题才被称作不可重复读(不可以重复执行select)问题;


来实际演示一下,两侧都开启一个新的事务,左侧终端更新表中的用户id,只要它不提交,右侧就看不到修改。

image-20230911105147039

但是提交了之后,右侧就能看到这个用户id的改变,在进行数据筛选的时候,这种情形就会出现问题!

image-20230911105214340

3.4 可重复读

所以,为了避免上面提到的这个问题,MySQL还提供了一个可重复读的隔离级别,且默认采用的就是这个隔离级别;

1
set global transaction isolation level  repeatable read;

可重复读就解决了上面提到的问题,即便其他事务提交了修改,当前事务也看不到这份修改,只有当前事务结束后,才能看到最新的数据;

看下图,两侧都开启一个事务,不管左侧是否有提交事务,右侧都看不到左侧新插入的数据;

image-20230911150201158

只有右侧也提交了自己的事务,才能看到左侧插入的新数据;

image-20230911150323005

这就保证了,右侧这个事务在运行的始终,它执行的相同select语句的结果永远都是一样的,不会出现不可重复读的问题!

而如果在RR级别中,同时访问相同数据,会出现加锁的情况,下图中,右侧插入了主键为6的数据,此时左侧如果也尝试插入一个主键为6的数据,会进入阻塞状态;

image-20230911151027879

如果很久都不继续操作,就会因为等待超时,跳出等待

1
2
MariaDB [hello_mysql]> insert into test_ruc values (6,60,'天数');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

但当前事务中插入的数据依旧是存在的,当前事务也没有结束

image-20230911151335335

再次尝试插入这个主键为6的数据,依旧会陷入阻塞态,只有右侧的事务被提交了之后,左侧才会出现主键冲突的提示,禁止插入;

image-20230911151444595

左侧commit了之后,右侧才能看到左侧新插入的主键为5的数据;

image-20230911151710391

3.5 串行化

这个就比较简单了,一个事务没结束,另外一个就阻塞等待;

串行化是最高的隔离级别,但其并发性能很低,实际上用的也不多;

1
set global transaction isolation level  serializable;

两侧终端都开始事务,查询的时候是不会加锁的,但是左侧执行update的时候,阻塞了(可以通过左侧update的执行时间观察到阻塞现象,截图看的不是很明显);右侧也执行一次update,左侧的update就立马成功了,右侧显示报错检测到死锁,建议尝试重启事务;

image-20230911110259760

再来试试,两侧都开启事务,左侧尝试插入一个数据,会阻塞;右侧commit之后,左侧的插入就立马成功了!

image-20230911110525989

这便是串行化的表现:两个事务如果都是在执行select的读取操作,则不会被阻塞,但如果有一个事务要执行增删改,那么就必须等待其他事务都结束了,这个操作才能被执行;

3.5.1 串行化的适用场景

回到 1.1.2 卖票 的场景,在这个场景下,如果你需要保证客户端A的卖票逻辑完全执行完毕了,客户端B才能过来查询票数,那就需要使用串行化;

但是,串行化中,我们是可以执行查询操作的!那么在A没有结束的情况下,B不还是会查询到有剩余票数?

实际上,我们的卖票操作并不是直连数据库实现的,而是有一个服务端进程来提供API,供客户端来查询剩余票数,剩下的卖票操作其实都是服务端来实现的(让客户端直连MySQL来执行SQL是非常不可靠的,很有可能出现SQL注入攻击)

所以,这个问题得在服务端进程中加锁解决!MySQL本身即便使用串行化也是没有办法解决这个问题的;

但是,别忘了,MySQL还有另外一个东西——约束;你可以设置一个触发器,更新剩余票数的时候,拒绝将票数设置为负数;这样客户端B即便进入了卖票业务,最终将剩余票数的数据设置为负数的时候,也会被MySQL阻止插入,这个卖票的事务B就相当于错误退出了,所有操作都会被回滚!而隔离级别就是避免A和B同时修改一个数据而导致的错误;

二者相辅相成,就能解决这个问题;当然,在服务端API处理逻辑中加锁是更好的解决办法,既可以保证数据一致性,又能隔离客户端和MySQL服务;

后文讲到的当前读也能在某种程度上解决这个问题,反正解决的办法多多!

3.5.2 串行化和CPP

如果将 MySQL 的隔离级别(isolation level)设置为最高级别的串行化(SERIALIZABLE),那么并发的多线程操作可能会遇到以下情况之一:

  1. 阻塞:当一个线程正在使用某个数据时,其他线程需要等待该线程释放锁才能继续进行操作。这会导致其他线程在获得所需锁之前被阻塞。
  2. 错误退出:如果多个线程同时操作相同的数据,并且它们无法同时持有访问该数据的锁,就会引发死锁。在这种情况下,某些线程会被系统终止,从而导致错误退出。

所以,并不是说你将MySQL的隔离级别设置好了,那么客户端就啥事不用干了;最好的操作依旧是在客户端就加锁进行一定的访问控制,因为MySQL Query函数的错误退出+退出情况识别,相比于客户端进行访问控制更难以操作;

3.6 总结

  • 隔离级别越高,安全性越高,数据库的并发性能就越低;我们需要根据业务逻辑,选用合适的隔离级别;
  • 不可重复读的问题在于其他客户端的修改和删除,同样的select语句会查出不同的结果,是不合理的;
  • 幻读的重点在于新增,其他客户端新增的记录可能被我们当前的客户端查询到;但是在MySQL中,这个问题是不存在的,因为InnoDB通过NEXT-KEY锁解决了幻读问题;
  • 事务也分为长短事务的概念,事务之间互相影响,主要指的是事务在并行阶段(都没有commit的时候)产生的影响;
  • 一般情况下,我们使用MySQL默认的RR的事务级别就可以了;

下面给出一个不同事务级别之间的区别的表格,Y代表会出现这个问题,N代表不会出现这个问题

隔离级别脏读不可重复读幻读加锁读
读未提交YYY不加锁
读已提交NYY不加锁
可重复读NNN不加锁
串行化NNN加锁

3.7 一致性

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

4.隔离性的理解

说完了隔离级别,接下来再深入理解一下隔离性

4.1 MVCC

数据库并发的场景一共有下面三种

  • 读-读:不存在任何问题,不需要访问控制
  • 读-写:有线程安全问题,可能会遇到隔离性中的脏读、幻读、不可重复读问题;
  • 写-写:有线程安全问题,还有可能出现更新丢失问题;

我们主要关注的是读写并发的情况,这也是数据库最常遇到的处理场景;

再MySQL中,采用了MVCC(多版本并发控制)来解决读写冲突,这是一种无锁的并发控制机制;

在了解MVCC之前,我们需要了解几个前置知识:

  • 三个隐藏列字段
  • UNDO日志
  • READ VIEW

4.1.1 隐藏列字段

在MySQL中,每一个表都存在三个隐藏的列字段

  • DB_TRX_ID:6 byte,最近修改(修改/插入)事务ID,记录创建这条记录/最后一次修改该记录的事务ID;
  • DB_ROLL_PTR: 7 byte,回滚指针,指向这条记录的上一个版本(简单理解成,指向历史版本就 行,这些数据一般在 undo log 中)
  • DB_ROW_ID: 6 byte,隐含的自增ID(隐藏主键),如果数据表没有主键, InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引
  • 补充:实际还有一个删除flag隐藏字段。记录被更新或删除时并不代表真的删除,而是删除flag变了;这在很多系统的删除操作中都是一个常见做法。

假设有如下表结构

1
2
3
4
5
6
create table if not exists student(
name varchar(11) not null,
age int not null
);
-- 插入一条记录
insert into student (name, age) values ('张三', 19);

那么这个表中的实际数据是这样的

nameageDB_TRX_ID(创建这个记录的事务ID)DB_ROW_ID(隐藏主键)DB_ROLL_PTR(回滚指针)
张三19NULL1NULL

我们并不知道创建这个记录的事务ID,所以设置为NULL;因为是第一条记录,所以隐藏主键是1;同样因为是第一条记录,所以没有回滚的地方,回滚指针也是NULL(换句话说,如果回滚指针为NULL,代表这个就是这个表中的第一条记录了)。

4.1.2 undo日志

MySQL是以服务进程的方式在内存中运行的,我们对数据的CURD操作,都需要通过MySQL将其刷入到硬盘上进行持久化,MySQL为这些操作会提供一个专门的buffer pool内存缓冲区;而undo log/redo log也是内存中的一块区域,对于MySQL而言

  • 日志需要立刻刷盘(有一个专门的线程来管理日志)
  • 数据根据运行策略,选择合适的时间刷盘(因为数据量一般比较大,如果实时刷盘会造成较大IO负载,影响进程运行)

先来说说redo log,这里包含了MySQL中的所有尚未落盘的CURD操作;如果MySQL还没有写入数据的时候就挂了,那么下次启动的时候就会从redo log里面恢复数据,来确保数据的一致性和完整性;

在MySQL存储路径/var/lib/mysql中的ib_logfile0/ib_logfile1就是redo log

1
2
-rw-rw----. 1 mysql mysql  50331648 Sep 11 03:16 ib_logfile0
-rw-rw----. 1 mysql mysql 50331648 Sep 9 21:03 ib_logfile1

undo log主要用于以下功能

  1. 回滚事务
  2. MVCC(多版本控制/隔离性和隔离级别)

假设我们来了一个update操作,将刚刚插入的张三的年龄改成30岁,对于MySQL而言就会在undo log里面做如下处理;

  • 该update操作对应的事务DB_TRX_ID是10;
  • 将旧数据放入undo log中,并将当前数据的DB_ROLL_PTR指向旧数据的地址;
  • 如果需要回滚,那么就通过DB_ROLL_PTR找到旧数据,复写回去;

image-20230911172501941

如果再来一次修改,将张三的名字改成李四,也是依照这个逻辑在undo log中新增旧数据,并链接DB_ROLL_PTR指针;

  • 事务11修改这条数据,先申请锁;
  • 将现有数据复制到undo log中(头插),并将DB_ROLL_PTR指针指向旧数据的地址;
  • 修改记录中的name,并且将DB_TRX_ID改成当前事务的ID 11;
  • 事务11提交,释放锁;

image-20230911173024425

这样,我们就有了一个类似于链表的历史版本链;每次回退的时候,都可以找到历史数据,覆盖当前的数据。这些版本我们称之为快照

而插入和删除都可以写入undo log,但是只有更新和删除能形成历史版本链;

  • 删除是将删除flag置为真,旧数据依旧可以写入到undo log中,如果需要回滚,将这行记录恢复;
  • 插入并没有历史版本,但也需要将其放入undo log中,事务回滚的时候需要将新插入的数据删除;
  • 事务提交了之后,已有的历史记录可以被删除;

增删改都说了,那么select呢?

一般而言,查询不会产生数据写入操作,也不需要设置历史版本链;

但是在之前关于RR级别隔离性的验证中,我们看到了select无法看到另外一个事务已经提交了的数据,这说明select有时候读取的是历史版本的数据,而并非当前最新的数据!

读取历史数据我们称之为快照读,在RR级别中默认采用的就是快照读;读取最新数据叫做当前读(增删改都是当前读);还有一种读取是为了更新而查询;

1
2
3
4
5
select ... -- 在RR级别下,默认是快照读
select ... for update; -- 为了更新而查询
-- MySQL会将当前行上排他锁,上锁了之后,其他客户端只能查询该行,无法修改
select ... lock in share mode; -- 查询最新数据(当前读)
-- 是串行化的,只有其他事务完成了,才能查询到最新结果;

除了手动指定select的查询方式以外,隔离级别会影响select的默认行为,比如在串行化隔离级别的场景下,默认执行的就是当前读

在RR模式下进行测试,会发现当前读会阻塞当前的客户端(右侧客户端阻塞);

image-20230911200703608

只有左侧的事务在提交了之后,右侧的查询才会返回最新的结果;

image-20230911201033979

这是因为当前读,包括增删改操作,是需要加锁的;如果要在RR模式下实现当前读,那么整个系统就需要串行化执行;

而快照读不需要加锁,因为它可以读取历史版本,而历史版本是不会被其他线程修改的,也就不需要维护访问控制,提高了并发效率;

那么,MySQL是如何实现快照读的呢?

4.1.3 Read View

Read View就是MySQL为快照读生成的一个读视图;在事务执行快照读的时候(即执行select的时候)将当前数据拍一张照,这样在这个事务中,后续的所有select都只会看到这个照片里面的结果,看不到其他事务最新的修改(不管其他事务是否commit)。

快照读的现象我们已经在本文 3.4 可重复读 中演示过了,这里就不再二次演示了;

Read View在MySQL的源码中其实就是一个类,该类中包含一些可读性判断的信息,内部有条件,来标明当前事务能够看到那个版本的数据,即有可能是当前最新的数据,也有可能是undo log中的某个历史版本;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
// 省略...
};

单独说明一下这里的4个重要的变量;其中需要注意,在这里面up是低水位,low是高水位,这是由他们保存的数据的性质决定的,请不要和这两个单词的本意混淆!

1
2
3
4
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),那么我们手中就有快照读中最大最小的事务ID,以及当前活跃中的事务ID;那么在读取版本链的时候,就进行如下匹配,设当前开启的这个事务为A:

  • 版本链中的事务ID不存在于m_ids中,代表事务A开启的时候,这个事务已经提交了,所以我们可以读取这个事务操作的结果;
  • 版本链中的事务ID存在于m_ids中,代表事务A开启的时候,这个事务是活跃状态,那么我就不应该读到这个事务的操作结果;
  • 版本链的中的事务ID大于low_limit_id,代表事务A开启的时候,这个事务还不存在,所以也不应该看到这个在A之后来的事务的操作结果;(A还没操作完毕,这个新事务就来了)
  • 版本链中的事物ID小于up_limit_id,代表事物A开启的时候,这个事物都不知道是是它的父亲辈还是爷爷辈了,长辈给你留下的东西肯定得好好收着;说人话:这个是A来之前的之前就已经结束了的事物,肯定是可以读取其结果的;

在MySQL中我们可以看到如下源码,链接也贴出来了,github上可以查看源码;这个函数就是用来判断当前事务应该能看到那些版本链的,具体的判断逻辑参考中文注释(英文注释是官方留的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 这个函数是ReadView类的成员函数 ReadView::changes_visible 
// 源码链接 https://github.com/mysql/mysql-server/blob/ea1efa9822d81044b726aab20c857d5e1b7e046a/storage/innobase/include/read0types.h#L162

// [[nodiscard]] 代表这个函数的返回值不能被忽略,一定需要使用该返回值;否则编译器会爆警告;即告知程序员必须关注这个函数的返回值!
// 函数的返回值是当前事务能否看到某一个事务id值的版本链,入参是目标事务id

/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
[[nodiscard]] bool changes_visible(trx_id_t id,
const table_name_t &name) const {
ut_ad(id > 0);

// 小于最小的事务id或者等于当前事务id,则代表这个事务我们可以看到
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}

check_trx_id_sanity(id, name);
// 如果这个id比当前事务中的高水位还大,说明这个id是后来者,不应该看到
if (id >= m_low_limit_id) {
return (false);
}
// 如果当前事务创建时,没有活跃事务
// 且 m_up_limit_id <= id < m_low_limit_id
// 那么这个事务ID就可以被看到,返回真
else if (m_ids.empty()) {
return (true);
}

const ids_t::value_type *p = m_ids.data();
// 如果上面都没有匹配到,那就检查这个id是否在活跃列表m_ids中
// std::binary_search的返回值:是否在位图中,true代表在,false代表不在
// 如果在,那么这个事务是活跃状态,不能被看到
// 如果不在,那么这个事务是已经提交了,可以被看到
return (!std::binary_search(p, p + m_ids.size(), id));
}

如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即可以看到。这便是我们select当前读的时候,会自动产生的ReadView结构体的作用;

根据这份源码,我们也能知道m_ids位图中,并不需要存放当前事务ID,因为会有额外的判断来处理当前事务ID!

4.2 快照读实操

4.2.1 快照是什么时候生成的?

假设当前的事务流程如下,省略号代表这个事务没有进行操作,事务2能看到事务1的操作结果吗?

事务1事务2
事务开始事务开始
插入数据1并提交事务……
……第一次查询
重开一个新事务,插入数据2并提交……
……第二次查询

来实操一下,开两个MySQL终端,隔离级别设置为RR;图中黄字标出了每一个SQL的执行顺序;我们会发现,当左侧终端提交了第一个插入的数据后,右侧才查询,是可以看到这条新插入的数据的

而左侧插入的第二条数据右侧就看不到了,很明显,此时访问的就是快照中的旧数据,而不是新数据了;

image-20230912094327001

这就告诉我们:快照读的快照是在事务中第一次执行select语句的时候生成的!并不是事务开始的时候生成的!

这也非常合理,如果你的当前事务压根没有进行select语句,那我也就没有必要生成快照了。毕竟快照的底层是ReadView对象,也是会占用内存空间的!而增删改操作本身就是当前读,无需生成快照!

4.2.2 实例解释ReadView的成员变量

用实际例子来解释一下ReadView里面的4个成员变量到底应该存放什么值;

假设有下面这个数据

nameageDB_TRX_ID(创建这个记录的事务ID)DB_ROW_ID(隐藏主键)DB_ROLL_PTR(回滚指针)
张三19NULL1NULL

一共有4个事务同时运行,认为事务序号就是事务ID;

事务2事务3事务4事务5
事务开始事务开始事务开始事务开始
………………修改张三的年龄并提交
运行快照读运行……
……………………

在这种情况下,事务3对某行数据进行了快照读,MySQL就会在这时候生成一个ReadView,内部变量赋值如下

1
2
3
4
m_ids; // 2,4 (当前事务5已经提交了,所以除了自己就只有2和4活跃中)
up_limit_id; // 2 (m_ids中最小事务ID)
low_limit_id; // 5 + 1 = 6 (当前已经出现的最大事务ID+1)
creator_trx_id // 3 (当前事务ID)

当事务3进行快照读的时候,就会拿事务5的这份数据进行比较,最终得到的结果是,事务3的快照是可以看到事务5对数据进行的修改的!

而且对于全局来说,事务5提交的这份数据也是当前最新的数据!

1
2
3
4
5
6
7
// 比较流程(依照上方MySQL的源码)
5 < up_limit_id || 5 == 3; // 不小于最小的,且也不等于当前的事务ID 3
5 > low_limit_id; // 不大于最大的
// 判断5是否在m_ids里面
m_ids.contains(5); // 不在
// 结论:return true;
// 也就是事务3可以看到事务5的修改

4.3 当前读实操

快照读的场景基本搞明白了,再来试试当前读(这里小提一嘴,写-写并发可以理解为所有操作都是当前读,需要串行化)

1
select ... lock in share mode;

下面是两个不同的操作流程,最终会得到不同的结果;操作之前,先执行如下两个sql

1
2
delete from test_ruc;
insert into test_ruc values (1,20,'张三');

在第一次测试中,我们在左侧终端更新数据之前就执行了一次快照读,此时MySQL会生成快照,即便在左侧终端的事务commit了之后,右侧也无法看到最新的user_id=18的数据;

但此时使用当前读,就可以读取到最新的数据!见图中右下角的红色第六次当前读操作,读到了最新修改的user_id=18的数据。

image-20230912103634717

而在第二次测试中,我们让左侧终端完成所有操作并提交事务后,右侧终端再去查询。会发现快照读和当前读都能查询到最新的修改,因为此时对于右侧终端而言并没有其他活跃的事务,当前读和快照读就没有区别了。

image-20230912103902180

由此可见

  • 快照读的快照是第一次select查询的时候生成的;
  • 当前读能绕过快照,实时查看到最新数据!

4.4 RR和RC的区别

先复习一下,RC是读已提交(Read Committed),RR是可重复读(Repeatble Read)。

  • 经过上面的测试我们可以知道,正是Read View生成时机的不同,从而造成RC/RR级别下快照读的结果的不同;
  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来;

此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个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才会有不可重复读问题。

4.5 封锁和封锁协议

在《数据库系统原理》学习中会涉及到这个概念。

4.5.1 X锁和S锁

首先认识一下两种封锁类型,分别是X锁和S锁:

  • X锁(写锁):上了X锁后其他事务不能对对象上其他锁,只有上锁的事务才能操作读写;
  • S锁(读锁):上了S锁后,其他事务也能上S锁和读取,但是不能修改。上了S锁后不能上X锁;

其实S锁和X锁就是一个基本的读写锁的概念,比较好理解。

下图是一个封锁类型的相容矩阵。

image.png

4.5.2 三级封锁协议

然后是分为三级的封锁协议:

  • 一级:事务T操作数据之前上X锁,事务结束才能释放
  • 二级:事务T操作数据之前上X锁(一级)其他事务读取该数据之前上S锁,读取完毕后释放S;
  • 三级:事务T操作数据之前上X锁(一级)其他事务读取该数据之前上S锁,直到事务结束释放;这样能避免丢失修改、读脏数据和不可重复读,也是最常用的维护一致性的手段;

如果所有事务都遵循第三级封锁协议,由于其隔离级别高,这些事务无论如何交叉并行,最终都是可串行化的调度。

4.5.3 两段锁协议 2PL

因为第三级封锁协议太过严苛,对并发性有负面影响,三级封锁协议其实是两段锁协议的特例(更严苛的两段锁协议)

两段锁协议要求:事务在对任何数据进行读写前,需要获得对该数据的封锁;而事务在释放任何一个封锁后,不可以在获得任何其他封锁。

事务遵顼两段锁协议是可串行化的充分条件,但遵循两段锁协议是可能发生死锁的

The end

关于MySQL事务的内容到这里基本就OVER了,内容多多,也需要多多理解和复习!

1
2
3
4
可以深入阅读如下文章
https://blog.csdn.net/SnailMann/article/details/94724197
https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html
https://blog.csdn.net/chenghan_yang/article/details/97630626