[TOC]
前言
本文主要介绍Mysql的事务特性以及mysql的MVCC机制,这块也是笔者在之前面试中的高频问题,特整理一番。主要参考了《掘金-Mysql是怎样运行的》《MySQL技术内幕:InnoDB存储引擎》
1. 事务简介
事务是一个系列的数据操作步骤在逻辑上的执行单元,一般我们都在数据库方面说的很多。事务的作用就是保证数据的一致性和正确性。在多个应用线程并发访问数据库时,事务可以在这些应用程序之间提供一个隔离的方法防止互相干扰。事务有 4 个特性 ACID(原子性、隔离性、一致性、持久性)。其中,原子性、隔离性、和持久性都是为了实现一致性,也就是说一致性是事务最终的目的。
1.1 事务的ACID特性
- 原子性(Atomicity): 一次事务的操作要么全都成功,要么全都失败,不会存在只成功一部分的情况。比如我们转账:A向B转账100元,要么就转账成功,要么就转账失败,不会出现只转过去了50元的情况。InnoDB通过undo log日志实现了原子性。
- 隔离性(Isolation): A向B账户转账2次,每次转账100元,B的账户会增加200元,防止下面的情况产生
- 2.1 B 收到A转的100元,此时B的账户余额 x = x + 100(x为原有的余额),但是没有执行提交
- 2.2 B 收到A转的100元,此时B账户余额 x = x + 100 ,提交
- 2.3 步骤1中的提交
这样交替执行就会导致B最终只拿到了100元,而A实际转了200元。所以对于现实的数据库而言,不仅仅是原子性有很大的要求,而且要保证其它的操作不会影响到本次操作,解决这个问题就得看隔离性。隔离性的实现就是对应着4种的隔离级别,使用不同的隔离级别可以达到应用想要的状态。
- 一致性(Consistency): 一致性好理解,就是A向B转账100元,只会出现2种一致性的结果:转账成功,A减少了100,B增加了100.转账失败,A和B的账户余额都没有变化。可以看到,转账前后,金额的总数是不会被改变的。其实一致性也是事务的主要作用,一致性是通过原子性、隔离性、和持久性实现的。
- 持久性(Durability): 还是以A向B转账100元来举例子,假设到账成功了,那么B的账户这100元就会被记录下来,并且不会丢失。绝对不会出现转账完成之后因为数据库或者系统宕机导致B增加的100元消失了。InnoDB通过redo log实现了持久性。
1.2 事务的并发
对于mysql服务器来说,同一时间可能有很多来自客户端的会话,一个会话里可能会有很多来自客户端的请求,而请求又可能会是事务的一部分,也就是说mysql服务器可能会同时处理很多的事务,可能会出现事务间并发。并发的事务可能会带来一些问题:
- 脏读:一个事务中读取到了另外一个事务还没提交的记录,如下,事务A读取到了事务B还没提交的记录,但是事务B后来回滚了,导致这条记录实际上是一个不正确的脏记录。
时间序号 | 事务A | 事务B |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | update student set age = 18 where id = 1000; | |
4 | SELECT * FROM student where id = 1000 | |
5 | COMMIT; | |
6 | ROLLBACK; |
- 不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果出现了不一致的情况。如下表:事务A中第一次读取到的age = 10。然后事务B执行了修改age提交了。事务A内再次读取age的时候发现值和刚刚不一样了,这个现象就是不可重复读。
时间序号 | 事务A | 事务B |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | SELECT FROM student where id = 1000;(age = 10) | |
4 | update student set age = 18 where id = 1000; | |
5 | COMMIT; | |
6 | SELECT FROM student where id = 1000;(age = 18) |
- 幻读:假设执行一条查询年龄大于18岁的学生的SQL,事务A中第一次查询到了10条记录,然后事务B插入了几条大于18岁的学生记录,此时事务A还在执行,事务A又执行了一次刚刚的查询SQL,发现查询到了大于10条的记录,就像是发生了幻觉一样。(PS:幻读强调的是记录新增和删除,不可重复读强调的是记录的修改)
时间序号 | 事务A | 事务B |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | SELECT COUNT(*) FROM student where age > 18;(10) |
|
4 | insert student(id,age) values(1,20) | |
5 | commit; | |
6 | SELECT COUNT(*) FROM student where age > 18;(11) |
1.3 事务隔离级别
上面简单介绍了一下事务并发可能会带来的问题,为了区分这些问题的严重程度及实际业务上的可接受程度,很早的数据库设计者定义了4个隔离级别。
- 读未提交:这种级别下允许脏读, 隔离级别最低,在事务 A 中修改了某行数据但是事务 A 并没有提交,事务 B 过来的时候也可以读取到这一行修改后的数据。比如事务 A 需要把 1 自增一直到 10之后再提交事务,那么事务 B 读取到的 2-9 都是未提交的。
- 读已提交:事务 A 还是将 1 自增一直到 10,事务 B 只能读取到 10,假设事务 C 将 10 自增到 20,事务 B 也只能看到 20.而不会看见事务操作中间的那部分的内容。
- 可重复读:在事务的A 自增 1 到 10 的过程中,事务 B 读取到的这行数据在事务 A 执行的过程中(此时事务 A 没有提交),读取到的值应该一直是 1
(Mysql默认的隔离级别)
- 串行化:是最严格的数据级别,所有的事务串行执行,没有并发的可能,也是最严格的隔离级别(在这种隔离级别下,就连select语句也会锁表)
画了个图:
image.png
2. MVCC多版本并发控制
学习了前面关于事务的基础之后,我们了解到事务的ACID特性、事务在并发的时候还会带来一些问题以及Mysql事务的4种隔离级别机制。这些并发问题在不同的隔离级别中都得到了良好的解决,使用者可以根据自己的业务去选择合适的隔离级别。那么隔离级别是如何实现的呢?
这里先直接抛出一句话:Mysql通过锁机制和MVCC实现了隔离级别。
读未提交和序列化在上面有了简单的介绍,在互联网实践中使用的场景比较少,所以我们主要分析下读已提交和可重复读。
- 读已提交:其它的数据库如Oracle、SqlServer等数据库等以这个为默认的,事务中可以去读其它事务提交的数据。(解决了脏读问题,但是不可重复读、幻读问题没有解决)
- 可重复读(Mysql 默认):同一个事务中,执行相同的SQL查询某一行记录,无论何时只要事务还没结束,那么它看到的记录的样子和第一次读取时看到的是一样的,InnoDB 用过 MVCC 机制解决了这个问题。(解决了脏读和不可重复读问题,但是幻读问题没有解决)
2.1 MVCC的版本链
对于InnoDB引擎而言,存储数据的时候行记录中可能会包含3个隐藏字段:
trx_id: 每当有事务要操作某行记录时,就会把事务的事务id赋值给行记录中的 trx_id
roll_pointer:事务对记录进行修改前,把旧版本写到undo log日志中,这个字段就是一个指向了undo log日志的指针,undo log可能有多条,会串成一个版本链
(非必存在)row_id:当有主键了或者有唯一索引了就不会建立隐式主键了
我们往student表中插入一条记录,如下:
mysql> insert into student values(1,18,'isole');
Query OK, 1 row affected (0.02 sec)
mysql> select * from student;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 18 | isole |
+------+------+-------+
1 row in set (0.01 sec)
此时假设我们插入这个事件的事务id为300,则此时这条记录如图
image.png在这之后,假设有事务A(trx_id = 400)和事务B(tex_id = 500)要对这行记录进行更新操作
# 事务A:
UPDATE student SET age = 20 WHERE id = 1;
# 事务B:
UPDATE student SET age = 24 WHERE id = 1;
时间序号 | 事务A trx_id=400 | 事务B trx_id = 500 |
---|---|---|
1 | BEGIN; | |
2 | BEGIN; | |
3 | UPDATE student SET age = 20 WHERE id = 1; | |
4 | COMMIT; | |
5 | UPDATE student SET age = 24 WHERE id = 1; | |
6 | COMMIT; |
每次对记录进行改动都会产生Undo log日志,每条undo log日志也会有roll_pointer属性(除了insert操作之外,就他自己没有之前的),这样的话就形成了一条由 roll_pointer
组成的版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id,如图:上框是真实数据页面中的记录,下框是Undo log中的记录,所谓多版本,其实就是真实页指向了一个链式结构的undo log记录。
2.2 ReadView
介绍了 MVCC 多版本的含义,接下来就到了如何利用MVCC多版本去实现不同的隔离级别了。
先简单介绍一下串行化这个隔离级别,它的设计是MySQL的InnoDB设计者执行S2PL并发控制协议, 一阶段申请,一阶段释放,读写都要加锁,是使用锁机制实现的,并没有使用到MVCC,所以暂时不介绍。
对于读未提交的隔离级别来说,直接读取记录的最新版本就好,不需要额外的判断,不管最新的记录是否被提交了。
而对于读已提交和可重复读2种隔离级别,都保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了这条记录但是尚未提交,是不能直接读取最新版本的记录的,其实问题的核心就是:我们需要根据不同的隔离级别去判断我们的MVCC版本链中哪些版本中修改的是对当前事务可见的。为此,才有了 ReadView。
关于 ReadView 不必把它想的太复杂,它就是用来记录一些必要关于事务的信息表,主要是以下4部分信息:
- m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
- min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值
- creator_trx_id:表示生成该ReadView的事务的事务id
到这里,我们知道一个ReadView会有哪些信息,下面的就是怎么使用这个ReadView的问题了,在事务访问某条记录时,会产生这个ReadView,只需要按照下边的步骤判断记录的某个版本是否可见
- 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,自己修改的,自己肯定是可见的
- 如果被访问版本的trx_id < ReadView中的min_trx_id值,表明该版本已经被提交过了(因为生成该版本的事务已经不是活跃的事务并且该记录还存在)
- 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,代表生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
在MySQL中,可重复读和读已提交的隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。
2.3 ReadView 实现可重复读
在可重复读的级别下,会在事务第一次查询的时候去生成一个ReadView,之后如果在这个事务中再次去查询的时候使用的还会是这个ReadView,不会再去重复生成新的ReadView,所以相同的事务中看到的记录是一样的,以下举例说明分析,
举个例子:数据库中有记录如下:
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 18 | isole |
+------+------+-------+
1 row in set (0.01 sec)
有事务A、事务B在执行,它们的事务id分别为 400、500
在事务A中,执行了如下的SQL
# 事务A: 连续更新了3次id=1的记录
UPDATE student SET age = 21 WHERE id = 1;
UPDATE student SET age = 22 WHERE id = 1;
UPDATE student SET age = 23 WHERE id = 1;
# 执行其它操作去了,但是事务A还未提交
此时对于id=1的记录就有了如下的版本链
image.png
在事务B中,去查询这个id=1的记录
# 事务B,经过一些事务的操作之后,需要查询id=1的记录
select * from student where id = 1;
# 此时的查询结果为 age = 18 的记录
事务B在执行查询语句的时候会生成ReadView,该ReadView的值如下
- m_ids:[400,500]
- min_trx_id: 400
- max_trx_id: 501
- creator_trx_id: 500(如果是一个其它的只读事务的话,这个一般默认是0,事务只有写操作才会分配事务id)
然后走了如下的流程,先是判断了最新的版本记录,age=23的事务id,发现在活跃的事务id列表m_ids中,于是使用roll_pointer指向下一个记录age=22的,同理依次往下判断,直到找到age=18的记录,发现该版本的事务id比ReadView中最小的事务id还小,那就意味着它是在事务B开始之前已经被提交的记录,可以读取,于是就读取到了该记录。
image.png
还没完,接着往下分析,假设在事务A中提交了该事务,事务B中按照上述的查询查到了 age = 18的记录。在事务B中接着执行了如下SQL
# 事务B也更新了id=1的记录
mysql> update student set age = 19 where id = 1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1 Changed: 1 Warnings: 0
# 事务B接着去查询id=1的记录,结果如下:
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 19 | isole|
+------+------+-------+
1 row in set (0.00 sec)
这个时候是不是不符合可重复读的条件了呢,其实并不是,在执行
select * from student where id = 1;
的时候,ReadView的结构依然是:
- m_ids:[400,500]
- min_trx_id: 400
- max_trx_id: 501
-
creator_trx_id: 500
然后此时的版本链如下,在读取最新的记录开始的时候发现事务id为当前事务id,自己修改的,自己肯定是可见的。
image.png
接下来再继续分析,假设事务B提交了,我们在事务A中执行查询的时候,事务A的ReadView如下
- m_ids:[400] (这里是假设事务A之前并没有对这条记录进行查询,并没有生成过ReadView的情况,如果在事务B提交之前,事务A查询了id=1的记录,那么这里应该是[400,500])
- min_trx_id:400
- max_trx_id:401
-
creator_trx_id:400
事务B在执行查询的时候,它经过的版本链如下,判断最新的版本的事务id不在活跃列表,且大于400,所以该记录是在本事务开启后并且提交前已经提交了,所以不可访问,然后找到了age=23的记录,发现是自己事务内修改的,所以可以访问到age=23的记录。所以我自始至终看到的都会是遵守了ReadView的规则看到的记录,即使事务B已经提交过了我也只是专注我需要专注的数据。
image.png
2.4 可重复读小结
可重复读的优点
1.一个事务,只有再开始的时候才会产生read-view,有且只有一个,所以这块消耗相对比读已提交的话会比较小一些
2.解决了幻读的问题, 实现了真正意义上的隔离级别
可重复读的缺点:
可重复读是通过Gap-lock实现,经常会锁定一个范围,那么导致死锁和所等待的概率非常大,所以在我们公司已经把默认的RR隔离级别修改成了RC+Row模式,这个在锁章节中详细学习。
2.5 ReadView 实现读已提交
上面可重复读的特点就是无论何时,在同一个事务中去执行查询sql得到id=1的记录,ReadView都是同一个,所以得到的记录也会是一样的或者本事务内修改的还没提交的。而和读已提交的隔离级别的区别就在于,后者是每次查询都生成新的ReadView。话不多说,上面的例子中其实只有2个事务在操作同一行记录或者说读取同一行记录。本次我们引入一个只读的事务C,这个事务C对id=1这行记录是只读的(引入事务C的目的其实就是加深对于 creator_trx_id的理解,只读事务的这个值默认都是0的),那就拿读已提交再举个栗子吧🌰
首先设置隔离级别为读已提交(会话级别的范围),表的数据如下
# 当前隔离级别为RR
mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
# 修改为RC
mysql> set session transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT @@tx_isolation;
+----------------+
| @@tx_isolation |
+----------------+
| READ-COMMITTED |
+----------------+
1 row in set, 1 warning (0.00 sec)
# 还是student的那张表,此时记录如下:
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 19 | isole |
+------+------+-------+
1 row in set (0.00 sec)
我们有事务A(事务id为700),事务B(事务id为800),事务C为只读事务(只读事务ReadView的creator_trx_id=0),按照如下的顺序操作
时间序号 | 事务A(trx_id=700) | 事务B(trx_id=800) | 事务C(只读事务) |
---|---|---|---|
1 | BEGIN; | (查询A):SELECT * FROM student where id = 1; | |
2 | BEGIN; | ||
3 | UPDATE student SET age = 20 WHERE id = 1; | ||
4 | COMMIT; | ||
5 | (查询B):SELECT * FROM student where id = 1; | ||
5 | UPDATE student SET age = 24 WHERE id = 1; | (查询C):SELECT * FROM student where id = 1; | |
6 | COMMIT; | ||
7 | (查询D):SELECT * FROM student where id = 1; |
查询结果如下:
# 查询A
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 19 | isole |
+------+------+-------+
1 row in set (0.00 sec)
# 查询B
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 20 | isole |
+------+------+-------+
1 row in set (0.00 sec)
# 查询C
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 20 | isole |
+------+------+-------+
1 row in set (0.00 sec)
# 查询D
mysql> select * from student where id = 1;
+------+------+-------+
| id | age | name |
+------+------+-------+
| 1 | 24 | isole |
+------+------+-------+
1 row in set (0.00 sec)
针对查询ABCD分别进行ReadView的分析:
查询A:
查询A的时候ReadView信息如下:
- m_ids:[700]
- min_trx_id:700
- max_trx_id:701
-
creator_trx_id:0
此时的查询A的版本访问过程及版本链记录如下,直接访问到最新的已经提交过的版本即可(因为版本号600小于ReadView的min_trx_id 700,所以这个版本在我这个ReadView生成之前就提交过了的)
image.png
查询B:
查询A的时候ReadView信息如下:
- m_ids:[800]
- min_trx_id:800
- max_trx_id:801
- creator_trx_id:800
此时的查询B的版本访问过程及版本链记录如下,判断最新的记录事务id=700,小于当前ReadView中的min_trx_id,所以可以访问。
image.png
查询C:
查询C的时候ReadView信息如下:
- m_ids:[800]
- min_trx_id:800
- max_trx_id:801
- creator_trx_id:0
此时的查询C的版本访问过程及版本链记录如下,最新记录的事务id=800,在活跃的事务列表中,说明还没有提交,于是沿着版本链进行访问下一个事务id=700的版本,因为700<min_trx_id,所以这个事务是最新提交的事务,所以可以访问,于是查询C得到 age = 20的记录,如下:
image.png
查询D:大家自己细细品一下。
2.6 读已提交小结
读已提交的优点: 由于降低了隔离级别,实现起来简单,锁的开销会小一些,基本上不会有Gap锁,死锁的几率会降低,并不是完全没有死锁,在锁的章节中会继续学习。
读已提交的缺点
1.会有幻读发生
2.事务内的每条select,都会产生新的read-view,造成资源浪费
网友评论