事务基础知识

1. 数据库事务概述

1.1 基本概念

事务:一组逻辑操作单元,使数据从一种状态变换到另一种状态。

事务处理的原则:保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态。

1.2 事务的ACID特性

  • 原子性(atomicity):

原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。

  • 一致性(consistency):

一致性是指事务执行前后,数据从一个合法性状态变换到另外一个合法性状态。这种状态是语义上的而不是语法上的,跟具体的业务有关。

  • 隔离型(isolation):

事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

  • 持久性(durability):

持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响。

持久性是通过事务日志来保证的。日志包括了重做日志回滚日志。当我们通过事务对数据进行修改的时候,首先会将数据库的变化信息记录到重做日志中,然后再对数据库中对应的行进行修改。这样做的好处是,即使数据库系统崩溃,数据库重启后也能找到没有更新到数据库系统中的重做日志,重新执行,从而使事务具有持久性。

1.3 事务的状态

  • 活动的(active)

事务对应的数据库操作正在执行过程中时,我们就说该事务处在活动的状态。

  • 部分提交的(partially committed)

当事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处在部分提交的状态。

  • 失败的(failed)

当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,我们就说该事务处在失败的状态。

  • 中止的(aborted)

如果事务执行了一部分而变为失败的状态,那么就需要把已经修改的事务中的操作还原到事务执行前的状态。换句话说,就是要撤销失败事务对当前数据库造成的影响。我们把这个撤销的过程称之为回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,我们就说该事务处在了中止的状态。

  • 提交的(committed)

当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。

image-20220403110448951

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
+ 数据库事务
+ 引擎支持
+ 只有innodb支持事务
+ 事务
+ 一组逻辑操作单元,使数据从一种状态变成另一种状态
+ 一个工作单元
+ 要么全部做要么不做
+ 事务的ACID特性
+ 原子性
+ 一致性
+ 一个合法性状态变成另一个合法性状态
+ 隔离性
+ 并发
+ 隔离级别
+ 持久性
+ 事务的状态
+ 两种最终状态
+ 显式事务
+ 开启事务
+ 修饰符
+ 保存点
+ 隐式事务
+ 默认开启
+ 每个DML操作独立的事务
+ 关闭自动提交
+ 隐式提交前面的事务的情况
+ DDL
+ 修改表等
+ 事务控制等

2. 如何使用事务

2.1 显式事务

步骤1: START TRANSACTION或者BEGIN,作用是显式开启一个事务。

1
2
3
mysql> BEGIN; 
#或者
mysql> START TRANSACTION;

START TRANSACTION语句相较于BEGIN特别之处在于,后边能跟随几个修饰符

READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的数据库操作只能读取数据,而不能修改数据。

READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。

WITH CONSISTENT SNAPSHOT:启动一致性读。

步骤2:一系列事务中的操作(主要是DML,不含DDL)

步骤3:提交事务 或 中止事务(即回滚事务)

1
2
# 提交事务。当提交事务后,对数据库的修改是永久性的。
mysql> COMMIT;
1
2
3
4
5
# 回滚事务。即撤销正在进行的所有没有提交的修改 
mysql> ROLLBACK;

# 将事务回滚到某个保存点。
mysql> ROLLBACK TO [SAVEPOINT]

其中关于SAVEPOINT相关操作有:

1
2
# 在事务中创建保存点,方便后续针对保存点进行回滚。一个事物中可以存在多个保存点。
SAVEPOINT 保存点名称;
1
2
# 删除某个保存点
RELEASE SAVEPOINT 保存点名称;

2.2 隐式事务

  • 显式的的使用START TRANSACTION或者BEGIN语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。
  • 把系统变量autocommit的值设置为OFF

2.3 隐式提交数据的情况

  • 数据定义语言(Data definition language,缩写为:DDL)

  • 隐式使用或修改mysql数据库中的表

  • 事务控制或关于锁定的语句

    • 当我们在一个事务还没提交或者回滚时就又使用START TRANSACTION或者BEGIN语句开启了另一个事务时,会隐式的提交上一个事务。
    • 当前的autocommit系统变量的值为OFF,我们手动把它调为ON时,也会隐式的提交前边语句所属的事务。
    • 使用LOCK TABLESUNLOCK TABLES等关于锁定的语句也会隐式的提交前边语句所属的事务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+ 手动开启事务
+ 默认DML自动提交数据
+ 不手动开启事务
+ completion_type
+ 0
+ 默认情况
+ 1
+ 提交事务后自动开启新事务
+ 链式事务
+ 2
+ 提交后自动与服务器断开连接
+ 事务的分类
+ 扁平事务
+ 带有保存点的扁平事务
+ 链事务
+ 嵌套事务
+ 分布式事务
+ myisam不支持事务
+ savepoint保存点

3. 事务隔离级别

3.1 数据并发问题

1. 脏写(Dirty Write

对于两个事务 Session A、Session B,如果事务Session A修改了另一个未提交事务Session B修改过的数据,那就意味着发生了脏写

image-20220403112416944

2. 脏读(Dirty Read

对于两个事务 Session A、Session B,Session A读取了已经被 Session B更新但还没有被提交的字段。之后若 Session B回滚,Session A读取的内容就是临时且无效的。

image-20220403112435995

3. 不可重复读(Non-Repeatable Read

对于两个事务Session A、Session B,Session A读取了一个字段,然后 Session B更新了该字段。 之后Session A再次读取同一个字段,值就不同了。那就意味着发生了不可重复读。

image-20220403112458183

4. 幻读(Phantom

对于两个事务Session A、Session B, Session A 从一个表中读取了一个字段, 然后 Session B 在该表中插入了一些新的行。 之后, 如果 Session A再次读取同一个表, 就会多出几行。那就意味着发生了幻读。

image-20220403112514712

注意1:

有的同学会有疑问,那如果Session B中剔除了一些符合studentno > 0的记录而不是插入新记录,那么Session A之后再根据studentno > 0的条件读取的记录变少了,这种现象算不算幻读呢?这种现象不属于幻读,幻读强调的是一个事物按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录

注意2:

那对于先前已经读到的记录,之后又读取不到这种情况,算啥呢?这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到之前读取没有获取到的记录。

3.2 SQL中的四种隔离级别

SQL标准中设立了4个隔离级别

  • READ UNCOMMITTED:读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。
  • READ COMMITTED:读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。
  • REPEATABLE READ:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别
  • SERIALIZABLE:可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。

image-20220403112740425

3.3 如何设置事务的隔离级别

1
2
3
4
5
6
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 隔离级别; 
#其中,隔离级别格式:
> READ UNCOMMITTED
> READ COMMITTED
> REPEATABLE READ
> SERIALIZABLE

或者:

1
2
3
4
5
6
SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '隔离级别' 
#其中,隔离级别格式:
> READ-UNCOMMITTED
> READ-COMMITTED
> REPEATABLE-READ
> SERIALIZABLE
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
+ 事务的隔离级别
+ 数据并发问题
+ 脏写
+ 已经提交的数据被回滚掉了
+ 脏读
+ 读到为提交的数据
+ 回滚后,数据不存在
+ 不可重复读
+ 读取字段后被修改
+ 再次读取,数据不一样
+ 幻读
+ 读取后多出来几行数据
+ 删除记录不算
+ 相当于不可重复读
+ SQL中的四种隔离级别
它本质作用是保证在开启事务后,对数据库所有表数据的查询,查询到的都是相同的版本,就是开启事务那一刻的版本(在mysql中为第一次查询那一刻的版本),而不管它是查询的一个表,还是不同的表,所以可重复读事务级别解决的并不是表面上的不可重复读现象。
+ 默认隔离级别
+ 可重复读
+ 设置隔离级别
+ global
+ session
+ 配置文件
+ 读未提交
+ 脏读
+ 读到未提交的数据
+ 一定要避免的
+ 临时数据应该避免读到
+ 读已提交
+ 避免脏读
+ 不可重复读
+ 当前事务没结束,读到变化的数据
+ 其他事务修改了数据
+ 可重复读
+ 避免不可重复读
+ 幻读
+ 当前事务还没结束,插入数据失败
+ 其他事务已经插入了新数据
+ 串行化解决
+ 添加行锁,只能一个事务操作某行数据
+ 其实可重复读也可以避免

MySQL事务日志

事务有4种特性:原子性、一致性、隔离性和持久性。那么事务的四种特性到底是基于什么机制实现呢?

  • 事务的隔离性由锁机制实现。
  • 而事务的原子性、一致性和持久性由事务的 redo 日志和undo 日志来保证。
    • REDO LOG 称为重做日志,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性。
    • UNDO LOG 称为回滚日志,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。

1. redo日志

1.1 为什么需要REDO日志

一方面,缓冲池可以帮助我们消除CPU和磁盘之间的鸿沟,checkpoint机制可以保证数据的最终落盘,然而由于checkpoint并不是每次变更的时候就触发的,而是master线程隔一段时间去处理的。所以最坏的情况就是事务提交后,刚写完缓冲池,数据库宕机了,那么这段数据就是丢失的,无法恢复。

另一方面,事务包含持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。

那么如何保证这个持久性呢?一个简单的做法:在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题

另一个解决的思路:我们只是想让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。比如,某个事务将系统表空间中第10号页面中偏移量为100处的那个字节的值1改成2。我们只需要记录一下:将第0号表空间的10号页面的偏移量为100处的值更新为 2 。

1.2 REDO日志的好处、特点

1. 好处

  • redo日志降低了刷盘频率
  • redo日志占用的空间非常小

2. 特点

  • redo日志是顺序写入磁盘的
  • 事务执行过程中,redo log不断记录

1.3 redo的组成

Redo log可以简单分为以下两个部分:

  • 重做日志的缓冲 (redo log buffer) ,保存在内存中,是易失的。

参数设置:innodb_log_buffer_size:

redo log buffer 大小,默认16M,最大值是4096M,最小值为1M。

  • 重做日志文件 (redo log file),保存在硬盘中,是持久的。

1.4 redo的整体流程

image-20220403114709581

第1步:先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝

第2步:生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值

第3步:当事务commit时,将redo log buffer中的内容刷新到 redo log file,对 redo log file采用追加写的方式

第4步:定期将内存中修改的数据刷新到磁盘中

Write-Ahead Log(预先日志持久化):在持久化一个数据页之前,先将内存中相应的日志页持久化。

1.5 redo log的刷盘策略

redo log buffer刷盘到redo log file的过程并不是真正的刷到磁盘中去,只是刷入到文件系统缓存(page cache)中去(这是现代操作系统为了提高文件写入效率做的一个优化),真正的写入会交给系统自己来决定(比如page cache足够大了)。那么对于InnoDB来说就存在一个问题,如果交给系统来同步,同样如果系统宕机,那么数据也丢失了(虽然整个系统宕机的概率还是比较小的)。

针对这种情况,InnoDB给出innodb_flush_log_at_trx_commit参数,该参数控制 commit提交事务时,如何将 redo log buffer 中的日志刷新到 redo log file 中。它支持三种策略:

  • 设置为0:表示每次事务提交时不进行刷盘操作。(系统默认master thread每隔1s进行一次重做日志的同步)
  • 设置为1:表示每次事务提交时都将进行同步,刷盘操作(默认值
  • 设置为2:表示每次事务提交时都只把 redo log buffer 内容写入 page cache,不进行同步。由os自己决定什么时候同步到磁盘文件。

1.6 不同刷盘策略演示

1. 流程图

image-20220403115232833

image-20220403115249492

image-20220403115300809

1.7 写入redo log buffer过程

1. 补充概念:Mini-Transaction

一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志

image-20220404091224993

2. redo 日志写入log buffer

不同的事务可能是并发执行的,所以事务T1事务T2之间的mtr可能是交替执行的。

image-20220404091511602

1.8 redo log file

1. 相关参数设置

  • innodb_log_group_home_dir:指定 redo log 文件组所在的路径,默认值为./,表示在数据库的数据目录下。MySQL的默认数据目录(var/lib/mysql)下默认有两个名为ib_logfile0ib_logfile1的文件,log buffer中的日志默认情况下就是刷新到这两个磁盘文件中。此redo日志文件位置还可以修改。

  • innodb_log_files_in_group:指明redo log file的个数,命名方式如:ib_logfile0,ib_logfile1… ib_logfilen。默认2个,最大100个。

  • innodb_flush_log_at_trx_commit:控制 redo log 刷新到磁盘的策略,默认为1

  • innodb_log_file_size:单个 redo log 文件设置大小,默认值为 48M 。最大值为512G,注意最大值指的是整个 redo log 系列文件之和,即(innodb_log_files_in_group * innodb_log_file_size )不能大于最大值512G。

2. 日志文件组

image-20220404092038421

3. checkpoint

image-20220404092106617

如果 write pos 追上 checkpoint ,表示日志文件组满了,这时候不能再写入新的 redo log记录,MySQL 得停下来,清空一些记录,把 checkpoint 推进一下。

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
80
81
82
83
84
85
86
87
88
89
+ 隔离性
+ 锁机制保证
+ 原子性、一致性、持久性
+ redo日志保证持久性
+ 提供再写入操作
+ undo日志保证原子性、一致性
+ 回滚
+ redo日志,提交
+ 物理级别的操作
+ undo日志,中止
+ 逻辑操作日志
+ 存储引擎层生成的日志
+ redo日志
+ 页数据缓存到内存
+ 更新页数据
+ 刷入磁盘
+ 为什么需要redo日志?
+ 刷盘时宕机
+ 希望内存修改的东西先保存到文件中
+ redo日志
+ 先写日志,再写磁盘
+ 好处
+ 降低刷盘频率
+ 占用空间小
+ 特点
+ 顺序写入磁盘,比随机io快
+ 事务执行中,redo log不断记录
+ redo的组成
+ 重做日志的缓冲
+ 内存中,redo日志的缓冲区
+ 重做日志文件
+ 保存在磁盘中
+ redo的整体流程
+ 磁盘读取数据页,修改数据页
+ 生成一条重做日志写入redo缓存区
+ 事务提交时,缓冲区内容刷新到redo日志文件中,追加写的方式
+ 已经保证了持久化
+ 定期将内存数据刷盘
+ redo log的刷盘策略
+ 刷入到文件系统缓存
+ 操作系统的优化
+ 日志刷盘参数
+ 1默认
+ 事务提交时日志刷盘
+ 0
+ 事务提交不刷盘
+ 后台线程,每隔1秒写入日志文件,然后刷盘
+ 事务未完成可能就刷盘了
+ 2
+ 事务提交时写入page cache,之后交给操作系统
+ 1
+ 事务提交,日志刷盘
+ 2
+ 事务提交,刷入文件系统缓存
+ 后面交给操作系统
+ 有操作系统宕机的风险
+ 0
+ 交给后台线程定时刷盘
+ 写入redo log buffer过程
+ mini-transaction
+ 对底层页面的一次原子访问过程
+ 包含一组redo日志
+ 一个事务包含多个语句
+ 一个语句包含多个mtr
+ redo日志写入log buffer
+ 一个mtr的redo日志不可分割
+ redo log block
+ 日志头
+ 日志体
+ 日志尾
+ redo log file
+ 相关参数
+ 指定文件路径
+ 指明文件个数
+ 默认两个文件
+ 刷盘策略
+ 文件大小
+ 日志文件组
+ 每个大小一样
+ 从头往后写,写完返回到头部继续覆盖
+ checkpoint
+ 当前记录位置
+ 当前要擦除的位置
+ 刷盘后记录未刷盘记录尾部
+ 如果满了
+ 不能写入
+ innodb策略
+ 预先日志持久化策略
+ 先写日志,再写入磁盘

2. Undo日志

redo log是事务持久性的保证,undo log是事务原子性的保证。在事务中更新数据前置操作其实是要先写入一个 undo log 。

2.1 如何理解Undo日志

事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但有时候事务执行到一半会出现一些情况,比如:

  • 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误操作系统错误,甚至是突然断电导致的错误。
  • 情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前事务的执行。

以上情况出现,我们需要把数据改回原先的样子,这个过程称之为回滚,这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。

2.2 Undo日志的作用

  • 作用1:回滚数据
  • 作用2:MVCC(详情看第16章)

2.3 undo的存储结构

1. 回滚段与undo页

InnoDB对undo log的管理采用段的方式,也就是回滚段(rollback segment)。每个回滚段记录了1024undo log segment,而在每个undo log segment段中进行undo页的申请。

2. 回滚段与事务

  1. 每个事务只会使用一个回滚段,一个回滚段在同一时刻可能会服务于多个事务。

  2. 当一个事务开始的时候,会制定一个回滚段,在事务进行的过程中,当数据被修改时,原始的数据会被复制到回滚段。

  3. 在回滚段中,事务会不断填充盘区,直到事务结束或所有的空间被用完。如果当前的盘区不够用,事务会在段中请求扩展下一个盘区,如果所有已分配的盘区都被用完,事务会覆盖最初的盘区或者在回滚段允许的情况下扩展新的盘区来使用。

  4. 回滚段存在于undo表空间中,在数据库中可以存在多个undo表空间,但同一时刻只能使用一个undo表空间。

  5. 当事务提交时,InnoDB存储引擎会做以下两件事情:

    • 将undo log放入列表中,以供之后的purge操作
    • 判断undo log所在的页是否可以重用,若可以分配给下个事务使用

3. 回滚段中的数据分类

  1. 未提交的回滚数据(uncommitted undo information)

  2. 已经提交但未过期的回滚数据(committed undo information)

  3. 事务已经提交并过期的数据(expired undo information)

2.4 undo的类型

在InnoDB存储引擎中,undo log分为:

  • insert undo log
  • update undo log

2.5 undo log的生命周期

1. 简要生成过程

只有Buffer Pool的流程:

image-20220404093706650

有了Redo Log和Undo Log之后:

image-20220404093832512

2. 详细生成过程

image-20220404093950136

当我们执行INSERT时:

1
2
begin; 
INSERT INTO user (name) VALUES ("tom");

image-20220404094033441

当我们执行UPDATE时:

image-20220404094105525

1
UPDATE user SET id=2 WHERE id=1;

image-20220404094142337

3. undo log是如何回滚的

以上面的例子来说,假设执行rollback,那么对应的流程应该是这样:

  1. 通过undo no=3的日志把id=2的数据删除

  2. 通过undo no=2的日志把id=1的数据的deletemark还原成0

  3. 通过undo no=1的日志把id=1的数据的name还原成Tom

  4. 通过undo no=0的日志把id=1的数据删除

4. undo log的删除

  • 针对于insert undo log

因为insert操作的记录,只对事务本身可见,对其他事务不可见。故该undo log可以在事务提交后直接删除,不需要进行purge操作。

  • 针对于update undo log

该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待purge线程进行最后的删除。

2.6 小结

image-20220404094436830

undo log是逻辑日志,对事务回滚时,只是将数据库逻辑地恢复到原来的样子。

redo log是物理日志,记录的是数据页的物理变化,undo log不是redo log的逆过程。

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
80
81
+ undo日志
+ 更新数据的前置操作,写入undo log
+ 保证情况
+ 执行错误、断电保证回滚
+ rollback保证回滚
+ 记录情况
+ 插入记录
+ 记录插入的主键,将来可以删除
+ 删除记录
+ 记录内容
+ 修改记录
+ 记录内容
+ undo log会产生redo log
+ undo log也需要持久化
+ undo日志的作用
+ 回滚数据
+ undo日志是逻辑日志
+ 物理变化改不了
+ 回滚后,数据结构和数据页可能不同
+ MVCC
+ 多版本并发控制
+ undo存储结构
+ 回滚段
+ 包含1024个undo log segment
+ 参数
+ 文件位置
+ 回滚段个数
+ 回滚段文件数量
+ undo页的重用
+ 放到链表中
+ 使用空间小于3/4,进行重用
+ undo log是离散的
+ 回滚段与事务
+ 回滚段中的数据分类
+ 未提交
+ 已经提交但未过期
+ 事务已提交并过期
+ 注意
+ 事务提交不能立马删除undo
+ 其他事务可能需要行记录之前的版本
+ undo的类型
+ insert
+ 只对本事务可见,其他事务不可见
+ 事务提交后直接删除
+ update
+ 需要提供MVCC机制
+ 事务提交后放入undo log链表,等待purge线程进行删除
+ undo log的生命周期
+ 示例
+ 只要buffer poll的流程
+ 有了redo log和undo log的流程
+ undo log
+ redo log
+ bin log
+ 主从复制
+ 详细生成过程
+ 行格式 - (真实数据表中的)三个隐藏的列
+ 行id
+ 事务id
+ 目前是哪个事务正在操作
+ 回滚指针
+ insert时
+ 指向insert类型的undo log
+ 记录插入的主键信息
+ update时
+ 指向update类型的undo log
+ 记录之前的列信息
+ 新生成的undo log指向旧的undo log
+ 单向链表
+ 对于更新主键的操作
+ 原数据标记删除
+ 插入新数据,产生新的undo log
+ undo log如何回滚
+ undo log的删除
+ insert
+ 直接删除
+ update
+ 提供MVCC机制
+ 放到undo log链表
+ purge线程
+ 小结

1. 概述

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对并发操作进行控制,因此产生了。同时锁机制也为实现MySQL的各个隔离级别提供了保证。 锁冲突 也是影响数据库并发访问性能的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。

2. MySQL并发事务访问相同记录

2.1 读-读情况

读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。

2.2 写-写情况

写-写情况,即并发事务相继对相同的记录做出改动。

在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过来实现的。

2.3 读-写或写-读情况

读-写写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读不可重复读幻读的问题。

2.4 并发问题的解决方案

怎么解决脏读不可重复读幻读这些问题呢?其实有两种可选的解决方案:

  • 方案一:读操作利用多版本并发控制(MVCC,下章讲解),写操作进行加锁

所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。

普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作 才会生成一个ReadView,之后的SELECT操作都复用这ReadView,这样也就避免了不可重复读和幻读的问题。
  • 方案二:读、写操作都采用加锁的方式。

  • 小结对比发现:

    • 采用MVCC方式的话,读-写操作彼此并不冲突,性能更高
    • 采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。

一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。

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
+ 锁
+ 保证事务的隔离性
+ 并发事务访问相同记录
+ 读读
+ 写写
+ 脏写问题
+ 必须排队执行
+ 锁结构
+ trx信息
+ 哪个事务生成的
+ is_waiting
+ 当前事务是否在等待
+ 其他事务等待获取锁
+ 小结
+ 读写、写读
+ 脏读、不可重复读、幻读问题
+ 读到其他事务未提交数据
+ 读到其他事务提交数据
+ 读到其他事务提交插入的数据,行变多
+ 并发问题解决方案
+ 方案一
+ 读采用MVCC,写加锁
+ MVCC
+ 生成一个ReadView
+ 已提交事务所做的更改
+ read committed隔离级别下
+ 一个事务执行中的每次SELECT都生成一个ReadView
+ 保证事务可以读到已提交事务的数据
+ 避免了脏读的问题
+ repeatable read
+ 一个事务执行中只有第一次SELECT会生成一个ReadView
+ 之后的SELECT都复用第一个ReadView
+ 保证了不可重复读和幻读的问题
+ 保证读到的数据都是事务开始的时候
+ 方案二
+ 读写都加锁
+ 每次读到的都是最新的数据
+ 幻读问题,不能只加锁当前记录
+ 小结

3. 锁的不同角度分类

image-20220405093322184

3.1 从数据操作的类型划分:读锁、写锁

  • 读锁:也称为共享锁、英文用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。
  • 写锁:也称为排他锁、英文用X表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。

1. 锁定读

在采用加锁方式解决脏读不可重复读幻读这些问题时,读取一条记录时需要获取该记录的S锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:

  • 对读取的记录加S锁
1
2
3
SELECT ... LOCK IN SHARE MODE;
# 或
SELECT ... FOR SHARE; #(8.0新增语法)
  • 对读取的记录加X锁
1
SELECT ... FOR UPDATE;

MySQL8.0新特性:

在5.7及之前的版本,SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。在8.0版本中,SELECT … FOR UPDATE, SELECT … FOR SHARE 添加NOWAITSKIP LOCKED语法,跳过锁等待,或者跳过锁定。

  • NOWAIT:如果查询的行已经加锁,会立即报错返回
  • SKIP LOCKED:如果查询的行已经加锁,只返回结果中不包含被锁定的行

2. 写操作

  • DELETE:对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。
  • UPDATE:在对一条记录做UPDATE操作时分为三种情况:
    • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原纪录的位置进行修改操作。
    • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。新插入的记录由INSERT操作提供的隐式锁进行保护。
    • 情况3:修改该记录的键值,则相当于在原纪录上做DELECT操作之后再来一次INSERT操作。
  • INSERT:一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。

3.2 从数据操作的粒度划分:表级锁、页级锁、行锁

1. 表锁(Table Lock)

该锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎,并且表锁是开销最少的策略。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁的问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣

① 表级别的S锁、X锁

在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。在对某个表执行一些诸如ALTER TABLEDROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名:Metadata Locks,简称MDL)结构来实现的。

一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁X锁。只会在一些特殊情况下,比方说崩溃恢复过程中用到。比如,在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t 的S锁或者X锁可以这么写:

  • LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁

  • LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁

总结:MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。

② 意向锁 (intention lock)

InnoDB 支持多粒度锁(multiple granularity locking),它允许行级锁表级锁共存,而意向锁就是其中的一种表锁

1、意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。

2、意向锁是一种不与行级锁冲突的表级锁,这一点非常重要。

3、表明“某个事务正在某些行持有了锁或该事务准备去持有锁”

意向锁分为两种:

  • 意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
1
2
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。 
SELECT column FROM table ... LOCK IN SHARE MODE;
  • 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
1
2
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 
SELECT column FROM table ... FOR UPDATE;

即:意向锁是由存储引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在数据表的对应意向锁

1. 意向锁要解决的问题

现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级级别的空间示意里面是否已经上过锁。

在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排它锁即可。

  • 如果事务想要获取数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁
  • 如果事务想要获取数据表中某些记录的排它锁,就需要在数据表上添加意向排他锁

这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。

③ 自增锁(AUTO-INC锁)

1. “Simple inserts” (简单插入)

可以预先确定要插入的行数(当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()REPLACE语句。

2. “Bulk inserts” (批量插入)

事先不知道要插入的行数(和所需自动递增值的数量)的语句。比如INSERT ... SELECTREPLACE ... SELECTLOAD DATA语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。

3. “Mixed-mode inserts” (混合模式插入)

这些是“Simple inserts”语句但是指定部分新行的自动递增值。例如INSERT INTO teacher (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');只是指定了部分id的值。另一种类型的“混合模式插入”是INSERT ... ON DUPLICATE KEY UPDATE

对于上面数据插入的案例,MySQL采用了自增锁的方式来实现,AUTO-INT锁是当向使用含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个AUTO-INT锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INT锁释放掉。一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。也正因为此,其并发性显然并不高,当我们向一个有AUTO_INCREMENT关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所以innodb通过innodb_autoinc_lock_mode的不同取值来提供不同的锁定机制,来显著提高SQL语句的可伸缩性和性能。

innodb_autoinc_lock_mode有三种取值,分别对应与不同锁定模式:

(1)innodb_autoinc_lock_mode = 0(“传统”锁定模式)

在此锁定模式下,所有类型的insert语句都会获得一个特殊的表级AUTO-INC锁,用于插入具有AUTO_INCREMENT列的表。这种模式其实就如我们上面的例子,即每当执行insert的时候,都会得到一个表级锁(AUTO-INC锁),使得语句中生成的auto_increment为顺序,且在binlog中重放的时候,可以保证master与slave中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert的时候,对于AUTO-INC锁的争夺会限制并发能力。

(2)innodb_autoinc_lock_mode = 1(“连续”锁定模式)

在 MySQL 8.0 之前,连续锁定模式是默认的。

在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT … SELECT,REPLACE … SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。

对于“Simple inserts”(要插入的行数事先已知),则通过在mutex(轻量锁)的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC锁,则“Simple inserts”等待AUTO-INC锁,如同它是一个“bulk inserts”。

(3)innodb_autoinc_lock_mode = 2(“交错”锁定模式)

从 MySQL 8.0 开始,交错锁模式是默认设置。

在这种锁定模式下,所有类INSERT语句都不会使用表级AUTO-INC锁,并且可以同时执行多个语句。这是最快和最可拓展的锁定模式,但是当使用基于语句的复制或恢复方案时,从二进制日志重播SQL语句时,这是不安全的。

在此锁定模式下,自动递增值保证在所有并发执行的所有类型的insert语句中是唯一单调递增的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。

④ 元数据锁(MDL锁)

MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加MDL写锁。

2. InnoDB中的行锁

行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

优点:锁定力度小,发生锁冲突概率低,可以实现的并发度高

缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况

InnoDB与MyISAM的最大不同有两点:一是支持事务;二是采用了行级锁。

① 记录锁(Record Locks)

记录锁也就是仅仅把一条记录锁上,官方的类型名称为:LOCK_REC_NOT_GAP

记录锁是有S锁和X锁之分的,称之为S型记录锁X型记录锁

  • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;
  • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。

② 间隙锁(Gap Locks)

MySQLREPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁

gap锁的提出仅仅是为了防止插入幻影记录而提出的。虽然有共享gap锁独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。

③ 临键锁(Next-Key Locks)

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁,innodb默认的锁就是Next-Key locks。

1
2
begin; 
select * from student where id <=8 and id > 3 for update;

④ 插入意向锁(Insert Intention Locks)

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。

插入意向锁是在插入一条记录行前,由INSERT 操作产生的一种间隙锁

事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。

3. 页锁

页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

每个层级的锁数量是有限制的,因为锁会占用内存空间,锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

3.3 从对待锁的态度划分:乐观锁、悲观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想

1. 悲观锁(Pessimistic Locking)

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

注意:select … for update 语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。

2. 乐观锁(Optimistic Locking)

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。在Java中java.util.concurrent.atomic包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。

1. 乐观锁的版本号机制

在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

2. 乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

3. 两种锁的适用场景

从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:

  1. 乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

  2. 悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写写 - 写的冲突。

3.4 按加锁的方式划分:显式锁、隐式锁

1. 隐式锁

  • 情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。

  • 情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。

即:一个事务对新插入的记录可以不显示的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种延迟加锁的机制,从而来减少加锁的数量。

2. 显式锁

通过特定的语句进行加锁,我们一般称之为显示加锁。

3.5 其它锁之:全局锁

全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是:做全库逻辑备份

全局锁的命令:

1
Flush tables with read lock

3.6 其它锁之:死锁

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。

1. 如何处理死锁

方式1:等待,直到超时(innodb_lock_wait_timeout=50s)

即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。

方式2:使用死锁检测进行死锁处理

发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
+ MySQL的锁
+ 数据操作的类型划分:读写锁
+ 读锁
+ 之间不阻塞
+ 写锁
+ 阻塞其他读锁和写锁
+ 只要有排它锁,都是阻塞
+ 锁定读
+ FOR SHARE
+ FOR UPDATE
+ 事务结束才会释放锁
+ 8.0新特性
+ 阻塞情况参数
+ 写操作
+ delete
+ 删除的记录加锁,修改删除标记
+ update
+ 不修改键值
+ 更新的列存储空间不变化
+ 获取X锁,修改记录
+ 更新的列存储空间变化
+ 获取X锁,删除记录
+ 插入新记录,隐式锁
+ 修改键值
+ 先删除后插入
+ insert
+ 插入新记录,隐式锁保护不被其他事务访问
+ 从数据操作的粒度划分:表级锁、页级锁、行锁
+ 表锁
+ 特点
+ 开销最小,粒度最大
+ 避免死锁
+ 并发低
+ 表级别的S锁、X锁
+ 一般情况innodb不使用表级别的S、X锁
+ 加表锁情况
+ 加读锁
+ 不可写,不可操作其他表,他人不可写
+ 加写锁
+ 不可操作其他表,他人不可读写
+ 总结
+ 意向锁(表级别)
+ 允许行级锁和表级锁共存
+ 协调了行锁与表锁的关系
+ 要解决的问题
+ 加上排它锁后,会给更大一级的结构加上意向锁
+ 便于告诉内部已经有人上锁,无法在更大结构上锁了
+ 避免内部锁的查找
+ 加锁标记
+ 存储引擎自己维护的,用户无法操作意向锁
+ 小粒度锁阻塞大粒度锁
+ 小粒度锁自动在大粒度结构加锁标记(意向锁)
+ 总结
+ 意向锁之间兼容(实际是不同行锁)
+ 自增锁
+ 加入数据模式
+ 简单插入
+ 批量插入
+ 基于现有的表
+ 不知道插入的行数
+ 混合模式
+ 每条自增语句对表锁进行竞争
+ 保证插入的递增
+ 三种锁的模式
+ 0 - 传统模式
+ 表级的自增锁
+ 1 - 连续锁定模式
+ 对于简单插入,申请对应行数,然后释放锁
+ 2 - 交错锁定模式 - 默认
+ 不使用表级的自增锁,保证主键唯一且单调递增
+ 简单插入,可以保证连续
+ 批量、混合模式,可能存在间隙
+ 元数据锁
+ 对表增删改查操作,加MDL读锁
+ 对表结构变更,加MDL写锁
+ 解决了DML和DDL操作一致性问题
+ 系统自动维护
+ 阻塞后排队执行
+ 若写锁被阻塞,则后面加的读锁也阻塞
+ 行锁
+ 记录锁
+ 记录的S,X操作
+ 针对一条记录
+ 间隙锁
+ 限制前后区间的插入
+ 可能导致死锁问题
+ 临键锁
+ 记录锁和间隙锁的合体
+ 锁住记录同时,锁住间隙防止插入
+ innodb默认的锁
+ 插入意向锁
+ 事务插入等待着也需要生成一个锁结构
+ 也是一种gap锁
+ 页锁
+ 特点
+ 开销、粒度介于行锁和表锁之间
+ 也会出现死锁的问题
+ 作为了解
+ 若某个层级的锁数量超过阈值限制
+ 锁升级
+ 从对待锁的态度划分:乐观锁、悲观锁(锁的设计思想)
+ 悲观锁
+ 使用各种独占锁,进行阻塞
+ 示例
+ 超卖问题
+ 使用悲观锁必须确定使用了索引
+ 否则会把整个表锁住
+ 乐观锁
+ 版本号机制实现
+ 每次修改更新版本
+ 版本相同说明读取正确
+ 或者时间戳机制
+ CAS机制实现
+ 读写分离问题
+ 强制读取master表数据
+ 适用场景
+ 乐观锁
+ 读多
+ 利用程序实现,不存在死锁问题
+ 版本号、时间戳
+ 悲观锁
+ 写多
+ 具有排他性,可在数据库层面阻止冲突
+ 分类
+ 共享锁、排他锁
+ 行锁、表锁
+ 按加锁的方式划分:显式锁、隐式锁
+ 隐式锁
+ 事务insert操作,限制其他事务的访问,加上隐式锁
+ 对于聚簇索引记录
+ 如果其他线程访问当前记录,帮助当前线程创建一个锁结构
+ 自己进入等待状态(也创建一个锁结构)
+ 对于二级索引记录
+ 页结构有一个事务id
+ 通过二级索引找到主键索引,重复情景一的情况
+ 延时加锁的机制
+ 只要锁等待时,隐式锁变成显式锁
+ 显式锁
+ 通过特点语句加锁,可以查看的
+ 全局锁
+ 对整个数据库实例加锁
+ 整个数据库只读
+ 死锁
+ 死锁的必要条件
+ 如何解决死锁
+ 超时
+ 回滚undo最小的事务
+ 避免死锁
+ 合理设计索引
+ SQL执行顺序
+ 避免大事务

4. 锁的内存结构

image-20220405151409557

结构解析:

1. 锁所在的事务信息

不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个 锁结构 ,这里就记录这个事务的信息。

锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。

2. 索引信息

对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。

3. 表锁/行锁信息

表锁结构行锁结构在这个位置的内容是不同的:

  • 表锁:记载着是对哪个表加的锁,还有其他的一些信息。
  • 行锁:记载了三个重要的信息:
    • Space ID :记录所在表空间。
    • Page Number :记录所在页号。
    • n_bits :对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits 属性代表使用了多少比特位。

n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后也不至于重新分配锁结构

4. type_mode

这是一个32位的数,被分成了lock_modelock_typerec_lock_type三个部分,如图所示:

image-20220405151829547

  • 锁的模式(lock_mode),占用低4位,可选的值如下:
    • LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁
    • LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁
    • LOCK_S(十进制的2):表示共享锁,也就是S锁
    • LOCK_X(十进制的3):表示独占锁,也就是X锁
    • LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁

在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和 LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

  • 锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:
    • LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
    • LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
  • 行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在 lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
    • LOCK_ORDINARY(十进制的0):表示next-key锁
    • LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁
    • LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁
    • LOCK_INSERT_INTENTION(十进制的2048):也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
  • is_waiting属性呢?基于内存空间的节省,所以把 is_waiting 属性放到了 type_mode 这个32位的数字中:
    • LOCK_WAIT(十进制的256) :当第9个比特位置为1时,表示is_waitingtrue,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waitingfalse,也就是当前事务获取锁成功。

5. 其他信息

为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

6. 一堆比特位

如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits属性表示的。InnoDB数据页中的每条记录在记录头信息中都包含一个 heap_no 属性,伪记录Infimumheap_no值为0Supremumheap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no,即一个比特位映射到页内的一条记录。

5. 锁监控

1
mysql> show status like 'innodb_row_lock%';
  • Innodb_row_lock_current_waits:当前正在等待锁定的数量;
  • Innodb_row_lock_time:从系统启动到现在锁定总时间长度;(等待总时长)
  • Innodb_row_lock_time_avg:每次等待所花平均时间;(等待平均时长)
  • Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
  • Innodb_row_lock_waits:系统启动后到现在总共等待的次数;(等待总次数)

其他监控方法:

MySQL把事务和锁的信息记录在了information_schema库中,涉及到的三张表分别是INNODB_TRXINNODB_LOCKSINNODB_LOCK_WAITS

MySQL5.7及之前,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。

MySQL8.0删除了information_schema.INNODB_LOCKS,添加了performance_schema.data_locks,可以通过performance_schema.data_locks查看事务的锁情况,和MySQL5.7及之前不同,performance_schema.data_locks不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

同时,information_schema.INNODB_LOCK_WAITS也被performance_schema.data_lock_waits所代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+ 锁的内存结构
+ 锁所在的事务信息
+ 指针,指向内存中的事务的更多信息
+ 索引信息
+ 指针
+ 表锁/行锁信息
+ 行锁
+ 表空间
+ 页号
+ 比特位个数
+ type_mode
+ 锁的模式
+ 意向锁、读写锁等
+ 锁的类型
+ 表级锁、行级锁
+ 行锁的具体类型
+ 是否等待
+ 其他信息
+ 一堆比特位
+ 锁的监控
+ 加锁案例

多版本并发控制

1. 什么是MVCC

MVCC (Multiversion Concurrency Control),多版本并发控制。顾名思义,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制。这项技术使得在InnoDB的事务隔离级别下执行一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。

2. 快照读与当前读

MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁非阻塞并发读,而这个读指的就是快照读, 而非当前读。当前读实际上是一种加锁的操作,是悲观锁的实现。而MVCC本质是采用乐观锁思想的一种方式。

2.1 快照读

快照读又叫一致性读,读取的是快照数据。不加锁的简单的 SELECT 都属于快照读,即不加锁的非阻塞读。

之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于MVCC,它在很多情况下,避免了加锁操作,降低了开销。

既然是基于多版本,那么快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

2.2 当前读

当前读读取的是记录的最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。

3. 复习

3.1 再谈隔离级别

我们知道事务有 4 个隔离级别,可能存在三种并发问题:

image-20220405153617536

另图:

image-20220405153632021

3.2 隐藏字段、Undo Log版本链

回顾一下undo日志的版本链,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列。

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id 隐藏列。
  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo日志 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
1
2
3
4
5
6
7
8
9
10
11
+ MVCC
+ 使用MVCC进行读操作
+ 读写不冲突
+ 通过数据行的多个版本管理来实现并发控制
+ 实现依赖于:隐藏字段、undo log、read view
+ 保证了一致性读
+ 解决了读写冲突,不加锁非阻塞并发读
+ 快照读和当前读
+ 快照读
+ 当前读
+ 获取记录最新版本,加锁

4. MVCC实现原理之ReadView

MVCC 的实现依赖于:隐藏字段、Undo Log、Read View

4.1 什么是ReadView

ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃”指的就是,启动了但还没提交)。

4.2 设计思路

使用READ UNCOMMITTED隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。

使用SERIALIZABLE隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。

使用READ COMMITTEDREPEATABLE READ隔离级别的事务,都必须保证读到已经提交了的事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。

这个ReadView中主要包含4个比较重要的内容,分别如下:

  1. creator_trx_id,创建这个 Read View 的事务 ID。

说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

  1. trx_ids,表示在生成ReadView时当前系统中活跃的读写事务的事务id列表

  2. up_limit_id,活跃的事务中最小的事务 ID。

  3. low_limit_id,表示生成ReadView时系统中应该分配给下一个事务的id值。low_limit_id 是系统最大的事务id值,这里要注意是系统中的事务id,需要区别于正在活跃的事务ID。

注意:low_limit_id并不是trx_ids中的最大值,事务id是递增分配的。比如,现在有id为1, 2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是4。

4.3 ReadView的规则

有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。

  • 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的up_limit_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的low_limit_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的up_limit_idlow_limit_id之间,那就需要判断一下trx_id属性值是不是在 trx_ids 列表中。
    • 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
    • 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

4.4 MVCC整体操作流程

了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过MVCC找到它:

  1. 首先获取事务自己的版本号,也就是事务 ID;

  2. 获取 ReadView;

  3. 查询得到的数据,然后与 ReadView 中的事务版本号进行比较;

  4. 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;

  5. 最后返回符合规则的数据。

在隔离级别为读已提交(Read Committed)时,一个事务中的每一次 SELECT 查询都会重新获取一次Read View。

如表所示:

image-20220405154948505

注意,此时同样的查询语句都会重新获取一次 Read View,这时如果 Read View 不同,就可能产生不可重复读或者幻读的情况。

当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次 SELECT 的时候会获取一次 Read View,而后面所有的 SELECT 都会复用这个 Read View,如下表所示:

image-20220405155041964

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
+ 隔离级别
+ MySQL中
+ MVCC解决了幻读、不可重复读
+ 隐藏字段
+ 行格式中
+ 事务id
+ 回滚指针
+ 指向最近的undo日志,单链表
+ 事务提交后可能被系统回收
+ undo log版本链
+ MVCC实现原理之ReadView
+ 创建read view的事务id
+ 当前系统活跃的事务id列表
+ 未提交事务
+ 活跃的事务中最小的事务id
+ 生成readview时系统下一个事务id
+ read view的规则
+ 被访问版本的事务id与readview的
+ 事务id相同
+ 当前事务修改的记录,可以访问
+ 小于最小活跃的值
+ 说明事务已提交,可以访问
+ 大于等于最大活跃的值
+ 生成该版本的事务在当前事务生成readview后开启
+ 不能被访问,找历史版本
+ 在活跃事务id区间
+ 在列表中有值
+ 创建readview时生产该版本的事务还是活跃的
+ 不能被访问
+ 不在列表中
+ 已经提交,可以访问
+ MVCC流程:查询操作
+ 获取当前事务id
+ 针对事务生成readview
+ 查询得到的数据,和readview中的事务版本号比较
+ 如果不可被访问,则从undo log中获取历史快照
+ 依次往后找版本链中的数据
+ 返回符合规则的数据
+ 生成readview:隔离级别
+ 读已提交
+ 每次SELECT生成
+ 可重复读
+ 生成一次
+ 保证查询相同

5. 举例说明

5.1 READ COMMITTED隔离级别下

READ COMMITTED :每次读取数据前都生成一个ReadView

5.2 REPEATABLE READ隔离级别下

使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView ,之后的查询就不会重复生成了。

5.3 如何解决幻读

假设现在表 student 中只有一条数据,数据内容中,主键 id=1,隐藏的 trx_id=10,它的 undo log 如下图所示。

image-20220405155640520

假设现在有事务 A 和事务 B 并发执行,事务 A的事务 id 为20事务 B的事务 id 为30

步骤1:事务 A 开始第一次查询数据,查询的 SQL 语句如下。

1
select * from student where id >= 1;

在开始查询之前,MySQL 会为事务 A 产生一个 ReadView,此时 ReadView 的内容如下:trx_ids= [20,30]up_limit_id=20low_limit_id=31creator_trx_id=20

由于此时表 student 中只有一条数据,且符合 where id>=1 条件,因此会查询出来。然后根据 ReadView机制,发现该行数据的trx_id=10,小于事务 A 的 ReadView 里 up_limit_id,这表示这条数据是事务 A 开启之前,其他事务就已经提交了的数据,因此事务 A 可以读取到。

结论:事务 A 的第一次查询,能读取到一条数据,id=1。

步骤2:接着事务 B(trx_id=30),往表 student 中新插入两条数据,并提交事务。

1
2
insert into student(id,name) values(2,'李四'); 
insert into student(id,name) values(3,'王五');

此时表student 中就有三条数据了,对应的 undo 如下图所示:

image-20220405155909223

步骤3:接着事务 A 开启第二次查询,根据可重复读隔离级别的规则,此时事务 A 并不会再重新生成ReadView。此时表 student 中的 3 条数据都满足 where id>=1 的条件,因此会先查出来。然后根据ReadView 机制,判断每条数据是不是都可以被事务 A 看到。

1)首先 id=1 的这条数据,前面已经说过了,可以被事务 A 看到。

2)然后是 id=2 的数据,它的 trx_id=30,此时事务 A 发现,这个值处于 up_limit_id 和 low_limit_id 之间,因此还需要再判断 30 是否处于 trx_ids 数组内。由于事务 A 的 trx_ids=[20,30],因此在数组内,这表示 id=2 的这条数据是与事务 A 在同一时刻启动的其他事务提交的,所以这条数据不能让事务 A 看到。

3)同理,id=3 的这条数据,trx_id 也为 30,因此也不能被事务 A 看见。

image-20220405155941753

结论:最终事务 A 的第二次查询,只能查询出 id=1 的这条数据。这和事务 A 的第一次查询的结果是一样的,因此没有出现幻读现象,所以说在 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
+ 示例
+ 读已提交隔离级别
+ SELECT执行过程
+ 可重复读隔离级别
+ SELECT执行
+ 始终用一个readview,活跃事务列表
+ 原理
+ 根据事务生成readview,获取当前活跃线程列表
+ 若行记录事务id
+ 比较
+ 为当前事务id,直接返回
+ 小于列表最小值
+ 说明活跃线程未修改,行记录事务以提交,直接返回
+ 大于列表最大值
+ 说明行记录是当前事务之后的事务修改的,肯定不是之前的记录
+ 版本链next
+ 在活跃线程列表区间
+ 在列表中
+ 说明当前活跃事务修改的记录,且未提交
+ 版本链next
+ 不在列表中
+ 说明已经提交事务的,直接返回
+ 总结
+ 若在事务列表中且不为当前线程,往前找
+ 若不在事务列表中,且小于当前事务id,往前找
+ 返回当前行
+ MVCC解决幻读问题
+ 幻读?
+ 读到新插入的数据
+ readview根据活跃事务id判断为新插入的数据
+ 舍弃记录,查询不到
+ 总结
+ 隔离级别
+ 只有读已提交和可重复读
+ 每次读到已提交事务数据
+ 每次读到事务开始时已提交事务的数据
+ 读为提交、串行化可直接读数据
+ deletemark伏笔
+ 考虑回滚等等
+ 为MVCC服务
+ 解决问题
+ 读写阻塞
+ 降低死锁概率
+ MVCC:乐观锁
+ 读不加锁
+ 解决快照读问题
+ 两个隐藏列 + undolog版本链 + readview

6. 总结

这里介绍了MVCCREAD COMMITTDREPEATABLE READ这两种隔离级别的事务在执行快照读操作时访问记录的版本链的过程。这样使不同事务的读-写写-读操作并发执行,从而提升系统性能。

核心点在于 ReadView 的原理,READ COMMITTDREPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同:

  • READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView
  • REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。