1.事务的特性简介
1.1 原子性(Atomicity)
要么全做,要么全不做,一系列操作都是不可分割的,如果在执行操作的过程发生了错误,那么就把已经执行的操作恢复成没执行之前的样子。比如转账不能只有一方扣钱另一方不增加余额。
1.2 隔离性(Isolation)
任何其他状态操作不能影响本次状态操作转换,比如A
几乎同时向B
转2
次账,不同的事务读取出的卡上余额都是12
元,在第一个事务A-5
元后,第二个事务A-5
(那这里是12-5
还是7-5
呢?),所以MySQL
需要一些措施保证这些操作的隔离。
1.3 一致性(Consistency)
如果数据库的数据全部符合现实世界的约束,则这些数据就是一致性的,或者说符合一致性的。
比如余额不能小于0
,有一些业务id
不能为空。数据库本身能为我们解决一部分一致性需求,比如NOT NULL
来拒绝NULL
值的插入,但是更多的是需要靠写业务代码的程序员自己保证,比如在Spring Boot
里面,入参就可以@NotNull
或者@NotBlank
之类的来进行入参校验。
数据库检查一致性是一个耗费性能的工作,比如为表建立一个触发器,每当插入或更新记录的时候就会校验是否满足条件,如果涉及到某一些列的计算,就会严重影响插入或更新的速度。
尽量不要把校验参数的判断条件(一致性检查)写在MySQL
语句中,不仅影响插入更新的速度,而且数据库连接也是很耗时的。能在业务层面解决就在业务层面判断。
提示:建表时的
CHECK
子句对于一致性检查没什么用,在MySQL
中也不会去检查CHECK
子句中的约束是否成立。比如:
create table test (
id unsigned int not null auto_increment comment ‘主键id’,
name varchar(100) comment ‘姓名’,
balance int comment ‘余额’,
primary key (id),
check (balance >= 0)
);
1.4 持久性 (Durability)
数据库修改的数据都应该在磁盘中保留下来,无论发生什么事故,本次操作的影响都不应该丢失。比如转账成功后不可以又恢复到没转账之前的样子,那样钱就没了。
我们把这四种特性的首字母提出来加以排序就是一个英文单词:
ACID
(英文中“酸”的意思),方便记忆
2. 建表
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_hero_name (name)
) Engine=InnoDB CHARSET=utf8;
这里把
hero
表的主键命名为number
是为了与后面的事务id
进行区分,为了简单,就不写约束条件和注释了。
然后向这个表里插入一条数据:
INSERT INTO hero VALUES(1, '刘备', '蜀');
现在表里的数据就是这样的:
3. 事务隔离级别
MySQL
是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接后,就形成了一个会话(Session
)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分。服务器可以同时处理来自多个客户端的多个事务。
3.1 事务并发执行时遇到的一致性问题
在不同隔离级别中,对数据库的操作可能会出现几种现象。如下:
3.1.1 脏写(Dirty Write)(用于熟悉和理解ACID特性,实际中不可能存在脏写)
如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写。如下:
假设两个会话各开启了一个事务TA
和TB
,
- 原有
x=0, y=0
,TA
先修改了x=3
,TB
修改了x=1,y=1
,然后TB
提交,最后TA
回滚。
如果TA
回滚导致x=0
,那么对于TB
来说破坏了原子性,因为x
被回滚,y
还是正常修改。
如果TA
回滚导致TB
所有的修改都回滚,那么对于TB
来说破坏了持久性,明明TB
都提交了,怎么能让一个未提交的TA
将TB
的持久性破坏掉呢?
无论哪种隔离级别,都不允许脏写的存在,所以脏写也可以作为介绍事务特性的一个序言,了解即可。
3.1.2 脏读(Dirty Read)
如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:
Session A
和Session B
各开启了一个事务,Session B
中的事务先将number
列为1
的记录的name
列更新为’关羽’,然后Session A
中的事务再去查询这条number
为1
的记录,如果读到列name
的值为’关羽’,而Session B
中的事务稍后进行了回滚,那么Session A
中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。
这里例子中Session B
中的事务是rollback
,即使是commit
了,虽然最终数据库的状态是一致的,但是在Session A
中的事务读取number=1
这条记录的时候,这个事务却得到了不一致的状态。数据库不一致的状态是不应该暴露给用户的。
严格一点的解释:假设事务T1
、T2
并发执行,它们都要访问数据项X
,T1
先修改了X
的值,然后T2
又读取了未提交事务T1
修改后的X
值,之后T1
中止而T2
提交。这就意味着T2
读到了一个根本不存在的值,这也是脏读的严格解释。
3.1.3 不可重复读(Non-Repeatable Read)
如果一个事务修改了另一个未提交事务读取的数据,就意味着发生了不可重复读现象,或者叫模糊读(Fuzzy Read
)现象。
读的’刘备’,被修改为’关羽’ ,读的’关羽’ 又被修改为了’张飞’ 。
严格一点的解释:假设事务T1
、T2
并发执行,它们都要访问数据项X
,T1
先读取了X
的值,然后T2
又修改了未提交事务T1
读取的X
的值,之后T2
提交,然后T1
再次读取数据项X
的值时会得到与第一次读取时不同的值。
3.1.4 幻读(Phantom)
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下:
严格一点的解释:假设事务T1
、T2
并发执行,T1
先读取符合搜索条件P
的记录,然后T2
写入了符合搜索条件P
的记录。之后T1
再读取符合搜索条件P
的记录时,会发现两次读取的记录时不一样的。
如果Session B
中是删除了一些符合number > 0
的记录而不是插入新记录,那Session A
中之后再根据number > 0
的条件读取的记录变少了,这种现象算不算幻读呢?明确说明下,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。
我们这里只考虑
SQL
标准中提到的,不考虑其他论文的描述,对于MySQL
来说,幻读强调的是“一个事务在按照某个相同的搜索条件多次读取记录时,在后续读取到了之前没读到的记录”,可能是别的事务insert
操作引起的。那对于先前已经读到的记录,之后又读取不到这种情况算啥呢?我们把这种现象认为是结果集中的每一条记录分别发生了不可重复读的现象。
比如:第一次读到abc
三条记录,第二次读到abd
,既多了d
记录,又少了c
记录,这怎么分析?
对于记录c
来说,发生了不可重复读,对于记录d
来说,发生了幻读。一致性问题针对每条记录分析即可。
是否有可能发生一致性问题的判断依据是,在准备读取的那一刻,想查询的数据库某些列的值与实际查询出来的可能会有出入,则认为可能会发生一致性问题。
综上:脏读、不可重复读、幻读都可能会发生一致性问题。
既然会出现这些问题,那么SQL
也有一些标准来处理这些问题,接着看吧
3.2 SQL标准中的四种隔离级别
我们给可能导致一致性问题的严重性给这些现象排一下序:
脏读 > 不可重复读 > 幻读
舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越可能发生严重的问题。有一帮人(并不是设计MySQL
的大叔)制定了一个所谓的SQL
标准,在标准中设立了4
个隔离级别:
READ UNCOMMITTED
:未提交读。READ COMMITTED
:已提交读 (又简称为RC
) 。REPEATABLE READ
:可重复读 (又简称为RR
)。SERIALIZABLE
:可串行化。
SQL
标准中规定(是SQL
标准中规定,不是MySQL
中规定),针对不同的隔离级别,并发事务可以发生不同的现象,具体情况如下:
隔离级别 |
脏读 |
不可重复读 |
幻读 |
|
可能 |
可能 |
可能 |
|
不可能 |
可能 |
可能 |
|
不可能 |
不可能 |
可能 |
|
不可能 |
不可能 |
不可能 |
SQL92
标准中并没有指出脏写的现象,脏写这个现象对于一致性影响太严重了,无论哪种隔离级别都不允许脏写的发生,这里就不多提。
3.3 MySQL中支持的四种隔离级别
不同的数据库厂商对SQL
标准中规定的4
种隔离级别支持不一样,比方说Oracle
就只支持READ COMMITTED
(Oracle
的默认隔离级别)和SERIALIZABLE
隔离级别。这里所讨论的MySQL
虽然支持4
种隔离级别,但与SQL
标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL
在REPEATABLE READ
隔离级别下,是可以很大程度上禁止幻读问题的发生的(关于如何禁止之后会详细说明的)。
MySQL
的默认隔离级别为REPEATABLE READ
,我自己手上的项目在生产环境的隔离级别是READ COMMITTED
,而且相关的一些接口可能同时操作同一张表的某一个账号,并发性较高,我的操作是:每次进入事务之前都会用Redis
分布式锁去锁住这个账号再进入事务,操作同一个账号同一时间只能有一个成功,这样就不会出现多个事务并发去操作这个账号相关性的数据,也就不会有这条记录出现不可重复读和幻读的机会。
3.3.1 如何设置事务的隔离级别
我们可以通过下边的语句修改事务的隔离级别(实际开发中是不会让开发人员随意有这种操作的,可以在自己电脑尝试):
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
其中的level
可选值有4
个:
level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}
设置事务的隔离级别的语句中,在SET
关键字后可以放置GLOBAL
关键字、SESSION
关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:
- 使用
GLOBAL
关键字(在全局范围产生影响):
比如下面这样:
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则:
- 只对执行完该语句之后新产生的会话起作用。
- 当前已经存在的会话无效。
所谓新产生的会话,如果你是navicat
操作,得关闭连接之后再打开连接才算新的会话,如果仅仅是新建查询还算同一个会话,是看不到设置前后隔离级别的变化的。
- 使用
SESSION
关键字(在会话范围影响):
比方说这样:
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则:
- 对当前会话的所有后续的事务有效
- 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。
- 如果在事务之间执行,则对后续的事务有效。
- 上述两个关键字都不用(只对执行这个
SET
语句后的下一个事务产生影响):
比如下面这样:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
则:
- 只对当前会话中下一个即将开启的事务有效。
- 下一个事务执行完后,后续事务将恢复到之前的隔离级别。
- 该语句不能在已经开启的事务中间执行,否则会报错。
如果我们在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction-isolation
的值,比方说我们在启动服务器时指定了--transaction-isolatinotallow=SERIALIZABLE
,那么事务的默认隔离级别就从原来的REPEATABLE READ
变成了SERIALIZABLE
。
可以通过查看系统变量transaction_isolation
的值来确定当前会话默认的隔离级别:
SHOW VARIABLES LIKE 'transaction_isolation';
注意:
transaction_isolation
是在MySQL 5.7.20
的版本中引入来替换tx_isolation
的,如果你使用的是之前版本的MySQL
,请将上述用到系统变量transaction_isolation
的地方替换为tx_isolation
。
或者使用更简便的写法:
SELECT @@transaction_isolation;
我们之前使用SET TRANSACTION
语法来设置事务的隔离级别时,其实就是在间接设置系统变量transaction_isolation
的值,我们也可以直接修改系统变量transaction_isolation
来设置事务的隔离级别。系统变量一般系统变量只有GLOBAL
和SESSION
两个作用范围,而这个transaction_isolation
却有3个(GLOBAL
、SESSION
、仅作用于下一个事务),设置语法上有些特殊,更多详情可以参见文档:transaction_isolation。
这里总结下:
语法 |
作用范围 |
SET GLOBAL transaction_isolation = 某个隔离级别 |
全局 |
SET @@GLOBAL.var_name = 某个隔离级别 |
全局 |
SET SESSION var_name = 某个隔离级别 |
会话 |
SET @@SESSION.var_name = 某个隔离级别 |
会话 |
SET var_name = 某个隔离级别 |
全局 |
SET @@var_name = 某个隔离级别 |
下一个事务 |
4. MVCC原理
4.1 版本链
在前文底层揭秘MySQL行格式记录头信息说过,对于使用InnoDB
存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id
并不是必要的,我们创建的表中有主键或者有NOT NULL
限制的UNIQUE
键时都不会包含row_id
列):
trx_id
:每次一个事务对某条聚集索引记录进行改动时,都会把该事务的事务id
赋值给trx_id
隐藏列。roll_pointer
:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo
日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
比方说我们的表hero
现在只包含一条记录:
假设插入该记录的事务id
为80
,那么此刻该条记录的示意图如下所示:
假设之后两个事务id
分别为100
、200
的事务对这条记录进行UPDATE
操作,操作流程如下:
是否可以在两个事务中交叉更新同一条记录呢?不可以!这不就是一个事务修改了另一个未提交事务修改过的数据,沦为了脏写了么?
InnoDB
使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新某条记录前,就会给这条记录加锁,另一个事务再次更新该记录时,就需要等待第一个事务提交,把锁释放之后才可以继续更新。所以这里trx 200
在③④⑤
步的时候因为锁的原因是被阻塞的,关于锁,后续文章再介绍。
每次对记录进行改动,都会记录一条undo
日志,每条undo
日志也都有一个roll_pointer
属性(INSERT
操作对应的undo
日志没有该属性,因为该记录并没有更早的版本),可以将这些undo
日志都连起来,串成一个链表,所以现在的情况就像下图一样:
在每次更新该记录后,都会将旧值放到一条undo
日志中(就算是该记录的一个旧版本),随着更新次数的增多,所有的版本都会被roll_pointer
属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id
(这很重要),我们之后会利用这个记录的版本链来控制并发事务访问相同记录的行为,我们把这种机制称之为多版本并发控制(Multi-Version Concurrency Control
,MVCC
)
从上图可以看到,聚集索引记录和undo
日志中的roll_pointer
属性可以串连成一个记录的版本链。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC
)
在
UPDATE
操作产生的undo
日志中,只会记录一些索引列以及被更新的列的信息,并不会记录所有列的信息,上一张图展示的undo
日志中,之所以将一条记录的全部列的信息都画出来是为了方便理解(因为这样很直观的显示了该版本中各个列的值是什么)。比如对于trx_id
为80
的那条undo
日志来说,本身是没有记录country
列的信息的,那怎么知道该版本中country
列的值是多少呢?没有更新该列则说明该列和上一个版本中的值相同。如果上一个版本的undo
日志也没有记录该列的值,那么就和上上个版本中该列的值相同。如果各个版本的undo
日志都没有记录该列的值,说明该列从未被更新过,那么trx_id
为80
的那个版本的country
列的值就和数据页中的聚集索引记录的country
列的值相同。
4.2 ReadView
一条记录竟然更新了那么多版本?版本链中的哪个版本的记录是当前事务可见的?这在不同隔离级别中可见性是不相同的
- 对于使用
READ UNCOMMITTED
隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。(不生成ReadView
) - 对于使用
SERIALIZABLE
隔离级别的事务来说,设计InnoDB
的大叔规定使用加锁的方式来访问记录。(不生成ReadView
) - 对于使用
READ COMMITTED
和REPEATABLE READ
隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。(只有RC
、RR
这2
个隔离级别在读取数据的时候生成ReadView
)
一定要注意,没有事务就没有
ReadView
,ReadView
是事务产生的,而且是基于整个数据库的。
对此,设计InnoDB
的大叔提出了一个ReadView
(有的翻译为“一致性视图”)的概念
注意!在
MySQL
里有两个“视图”的概念:
一个是view
。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是create view ...
,而它的查询方法与表一样。 另一个是InnoDB
在实现MVCC
时用到的一致性读视图,即consistent read view
,用于支持RC
和RR
隔离级别的实现。ReadView
它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
这个ReadView
中主要包含4个比较重要的内容:
m_ids
:表示在生成ReadView
时当前系统中活跃的读写事务的事务id
列表。“活跃”指的就是,启动了但还没提交。min_trx_id
:表示在生成ReadView
时当前系统中活跃的读写事务中最小的事务id
,也就是m_ids
中的最小值。max_trx_id
:表示生成ReadView
时系统中应该分配给下一个事务的事务id
值。
注意
max_trx_id
并不是m_ids
中的最大值,事务id
是递增分配的。比方说现在有事务id
为1
,2
,3
这三个事务,之后事务id
为3
的事务提交了。那么一个新的读事务在生成ReadView
时,m_ids
就包括1
和2
,min_trx_id
的值就是1
,max_trx_id
的值就是4
。
creator_trx_id
:表示生成该ReadView
的事务的事务id
。
只有在对表中的记录做改动时(执行
INSERT
、DELETE
、UPDATE
这些语句时)才会为事务分配trx_id
,否则在一个只读事务中的事务id
的值trx_id
都默认为0
,未分配trx_id
前,creator_trx_id
的值为0
,分配trx_id
后,creator_trx_id
就变化成了对应的事务的trx_id
。
在MySQL
中,READ COMMITTED
和REPEATABLE READ
隔离级别的的一个非常大的区别就是它们生成ReadView
的时机不同。我们还是以表hero
为例来,假设现在表hero
中只有一条由事务id
为80
的事务插入的一条记录:
注意:当一个
ReadView
生成了,m_ids
、min_trx_id
、max_trx_id
、creator_trx_id
等变量的值都是固定的,比如此时有事务提交,m_ids
活跃事务列表的值也不会变。ReadView
就像快照一样,生成了就不再变,除非生成新的。
接下来看一下READ COMMITTED
和REPEATABLE READ
所谓的生成ReadView
的时机不同到底不同在哪里。
4.2.1 READ COMMITTED —— 一个事务中每次读取数据前都生成一个ReadView
比如,现在系统里有两个事务id
分别为100
、200
的事务在执行:
Transaction 100 |
Transaction 200 |
BEGIN; |
BEGIN; |
UPDATE hero SET name = ‘关羽’ WHERE number = 1; |
# 更新了一些别的表的记录 (这里更新为了分配事务 |
UPDATE hero SET name = ‘张飞’ WHERE number = 1; |
… |
… |
再次强调,事务执行过程中,只有在第一次真正修改记录时(比如使用
INSERT
、DELETE
、UPDATE
语句),才会被分配一个唯一的事务id
,这个事务id
是递增的。所以我们才在Transaction 200
中更新一些别的表的记录,目的是让它分配事务id
。
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED
隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录。从图中可以看出,最新版本的列
name
的内容是’张飞’,该版本的trx_id
值为100
,在m_ids
列表内,说明trx_id
为100
的事务还没提交,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是’关羽’,该版本的trx_id
值也为100
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是’刘备’,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,说明trx_id
为80
的事务已经提交了,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为’刘备’的记录。
之后,我们把事务id
为100
的事务提交一下,如下:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id
为200
的事务中更新一下表hero
中number
为1
的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED
隔离级别的事务中继续查找这个number
为1
的记录,如下
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交(第一次查询两个事务均未提交)
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交(第二次查询事务id为100的事务提交了)
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'
分析一下SELECT2
的执行过程
- 在执行
SELECT
语句时会又会单独生成一个ReadView
,该ReadView
的m_ids
列表的内容就是[200]
(事务id
为100
的那个事务已经提交了,所以再次生成ReadView
时就没有它了),min_trx_id
为200
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是’诸葛亮’,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是’赵云’,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是’张飞’,该版本的trx_id
值为100
,小于ReadView
中的min_trx_id
值200
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为’张飞’的记录。
以此类推,如果之后事务id
为200
的记录也提交了,再次在使用READ COMMITTED
隔离级别的事务中查询表hero
中number
值为1
的记录时,得到的结果就是’诸葛亮’了。总结一下就是:使用READ COMMITTED
隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
。
注意:
RC
下,在一个事务中,一条查询语句执行完,事务生成的ReadView
就没用了,下次查询得重新生成ReadView
。
4.2.2 REPEATABLE READ —— 一个事务中第一次读取数据时生成一个ReadView
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后这个事务执行期间,其他事务的更新对它不可见。
对于使用REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView
,之后的查询就不会重复生成了。来用和之前相同的例子再次分析。
比如,现在系统里有两个事务id
分别为100
、200
的事务在执行:
Transaction 100 |
Transaction 200 |
BEGIN; |
BEGIN; |
UPDATE hero SET name = ‘关羽’ WHERE number = 1; |
# 更新了一些别的表的记录 (这里更新为了分配事务 |
UPDATE hero SET name = ‘张飞’ WHERE number = 1; |
… |
… |
此刻,表hero
中number
为1
的记录得到的版本链表如下所示:
假设现在有一个使用REPEATABLE READ
隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这里和4.2.1
节的READ COMMITTED
隔离级别的SELECT1
分析过程一模一样,不赘述。查询出来是name
为’刘备’ 的记录。
我们把事务id
为100
的事务提交一下,如下:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id
为200
的事务中更新一下表hero
中number
为1
的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero
中number
为1
的记录的版本链就长这样:
一直到这里,例子分析和4.2.1
节的READ COMMITTED
隔离级别的分析过程一样。接下来,不一样的来了。
然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1
的记录,如下:
# 使用REPEATABLE READ隔离级别的事务300
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'
注意,这个SELECT2
的执行过程如下:
- 因为当前事务的隔离级别为
REPEATABLE READ
,而之前在执行SELECT1
时已经生成过ReadView
了,所以此时直接复用之前的ReadView
。 之前的ReadView
的m_ids
列表的内容就是[100, 200]
,min_trx_id
为100
,max_trx_id
为201
,creator_trx_id
为0
。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是’诸葛亮’,该版本的trx_id
值为200
,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是’赵云’,该版本的trx_id
值为200
,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是’张飞’,该版本的trx_id
值为100
,而m_ids
列表中是包含值为100
的事务id
的,所以该版本也不符合要求,同理下一个列name
的内容是’关羽’的版本也不符合要求。继续跳到下一个版本。 - 下一个版本的列
name
的内容是’刘备’,该版本的trx_id
值为80
,小于ReadView
中的min_trx_id
值100
,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为’刘备’的记录。
也就是说在REPEATABLE READ
隔离级别下,事务的两次查询得到的结果是一样的。记录的name
列值都是’刘备’,这就是为什么在RR下,不会出现不可重复读的理由。如果我们之后再把事务id
为200
的记录提交了,然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1
的记录,得到的结果还是’刘备’。
要想读到最新name
值为’诸葛亮’该如何操作呢?
前提: 把事务id
为100
、200
的事务提交。
- 此时把事务
id
为300
的事务提交,ReadView
就没用了,下次开启新的事务查询的时候会生成新的ReadView
,m_ids
列表中没有100
、200
,就可以查询到name
为’诸葛亮’了。 - 如果新的查询没有事务,那就没有
ReadView
这一说了,直接select
查询就可以查到name
为’诸葛亮’的记录,因为事务100
、200
已提交。
注意对比:
RR
下,一个事务提交时,它生成的ReadView
就没用了。RC
下,在一个事务中,一条查询语句执行完,事务生成的ReadView
就没用了,下次查询得重新生成ReadView
。
小提示:
在RR
下,如果使用START TRANSACTION WITH CONSISTENT SNAPSHOT
语句开启事务,会在执行该语句后立即生成一个ReadView
,而不是在执行第一条SELECT
语句时才生成。
使用START TRANSACTION WITH CONSISTENT SNAPSHOT
这个语句开始,创建一个持续整个事务的ReadView
。所以在RC
隔离级别下(每次读都创建ReadView
),这个用法就没意义了,等效于普通的start transaction
。
4.2.3 ReadView的可见性规则总结
在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
trx_id = creator_trx_id
时,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。trx_id < min_trx_id
时,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。trx_id ≥ max_trx_id
时,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。min_trx_id ≤ trx_id ≤ max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
上面说了,ReadView
是基于整库的。如果一个库有100G
,那么我启动一个事务,MySQL
就要拷贝100G
的数据出来吗,这样多慢啊,可是,我们平时的事务执行起来很快啊。
实际上,我们并不需要拷贝出这100G
的数据。InnoDB
利用版本链和活跃的事务id
列表,可以实现“秒级创建ReadView
”。
思考题:
RR
隔离级别下事务T1
和T2
并发执行,T1
先根据某个搜索条件读取到3
条记录,然后事务T2
插入一条符合相应搜索条件的记录并提交,然后事务T1
再根据相同搜索条件执行查询,结果如何?
分析:根据版本链和ReadView
分析,T1
第一次搜索3
条记录的时候生成了ReadView
,此时T1
、T2
都在m_ids
列表,都是活跃的,那么T2
中插入的版本记录T1
是不可见的,所以事务T1
第二次搜索仍然是3
条记录。此时在RR
下避免了幻读的产生。
由于
MySQL
的具体实现问题,RR
隔离级别下并不能完全避免幻读(只能很大程度避免),只有加锁才可以完全避免。
4.3 为什么不推荐使用长事务?
前面讲版本链的时候说过,每条记录在更新的时候都会同时记录一条回滚的 undo
日志 (也称为回滚段)。通过回滚操作,都可以得到前一个状态的值。
当前number
为1
的记录name
为是'诸葛亮'
,但是在查询这条记录的时候,不同时刻启动的事务会有不同的ReadView
。如图,要得到name
为'刘备'
的记录,就必须将当前值依次执行图中所有的回滚操作得到。
- 回滚段非常占用内存,那回滚段什么时候删除呢?
从上图可以看到回滚段里都是之前事务修改过的记录,事务提交后该记录的旧版本就不需要了,所以只有当开启回滚段以来的所有事务都提交的时候,回滚段就可以删除。
- 为什么不推荐使用长事务?
长事务意味着系统里面会存在很老的记录,事务不提交,记录的旧版本会一直存在。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
在MySQL 5.5
及以前的版本,回滚日志是跟数据字典一起放在ibdata
文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。有时候数据只有20GB
,而回滚段有200GB
的库。最终只好为了清理回滚段,重建整个库。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。
- 如何查询长事务?
在information_schema
库的innodb_trx
这个表中查询长事务,比如下面这个语句,用于查找持续时间超过60s
的事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
4.4 非聚集索引与MVCC
前面说过,只有聚集索引记录才有trx_id
和roll_pointer
隐藏列,如果某个查询语句是使用二级索引来执行查询时,该如何判断可见性呢?
begin;
select * from hero where name = '刘备';
这里判断条件是name
了,这是一个普通的非聚集索引,没有trx_id
和roll_pointer
该怎么根据版本链和ReadView
去判断可见性呢?
注:
trx_id
是记录存放该事务的事务id
的地方,没有这一列只能说明非聚集索引记录没存,并不代表执行事务时没有事务id
了。
过程如下:
步骤1:非聚集索引页面的Page Header
部分有一个名为PAGE_MAX_TRX_ID
的属性,每当对该页面中的记录执行增删改操作的时候,如下:
// 这里用伪代码说明更便捷
if(如果执行该事务的事务id > PAGE_MAX_TRX_ID) {
PAGE_MAX_TRX_ID = 如果执行该事务的事务id;
}
所以PAGE_MAX_TRX_ID
属性值代表修改该非聚集索引页面的最大的事务id
。
当SELECT
语句根据条件找到某个非聚集索引记录时,如下:
if (对应ReadView的min_trx_id > PAGE_MAX_TRX_ID) {
说明该页面中的所有记录都对该ReadView可见
} else {
执行步骤2
}
步骤2: 根据主键回表后,得到满足搜索条件的聚集索引记录后,根据版本链找到该ReadView
可见的第一个版本,然后判断该版本中相应的非聚集索引列的值是否与利用该非聚集索引查询时的值相同。本例子就是判断可见版本的name
是不是’刘备’。如果是,就把这条记录发送给客户端(如果where
子句中还有其他搜索条件的话还需要继续判断筛选后再返回),否则就跳过该记录。
4.5 MVCC小结
所谓的MVCC
(Multi-Version Concurrency Control
,多版本并发控制)指的就是在使用READ COMMITTD
、REPEATABLE READ
这两种隔离级别的事务执行普通的SELECT
操作时,访问记录的版本链的过程。这样可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD
、REPEATABLE READ
这两个隔离级别的一个很大不同,就是生成ReadView
的时机不同,READ COMMITTD
在一个事务中每一次进行普通SELECT
操作前都会生成一个ReadView
,而REPEATABLE READ
在一个事务中只在第一次进行普通SELECT
操作前生成一个ReadView
,之后的查询操作都重复使用这个ReadView
。
5. 全篇的反思与小结,你需要弄懂这几个问题
- 事务的概念是什么?
MySQL
的事务隔离级别读未提交, 读已提交, 可重复读, 串行读各是什么意思?- 读已提交, 可重复读是怎么通过视图构建实现的?
- 事务隔离是怎么通过
ReadView
(读视图)实现的? - 并发版本控制(
MVCC
)的概念是什么, 是怎么实现的? - 使用长事务的弊病? 为什么使用长事务可能拖垮整个库?
- 怎么查询各个表中的长事务?
- 如何避免长事务的出现?
如果还不清楚,可以返回再看一遍!