MySQL事务
MySQL事务
事务是由MySQL的引擎来实现的,因为MyISAM不支持事务,所以MySQL基本上引擎都是用的InnoDB。
事务的特性及保证手段
- 原子性:一个事务的所有操作,要么全部成功完成,要么全部不执行
- 一致性:事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态
- 隔离性:多个事务同时使用相同数据时互不干扰
- 持久性:事务对于数据的修改是永久保存的
InnoDB保证四个特性的手段
- 原子性:undo log
- 一致性:持久性+原子性+隔离性
- 隔离性:MVCC或者锁
- 持久性:redo log
并发事务引起的问题
同时处理多个事务时,就可能发生脏读、不可重复读、幻读的问题。
这三个问题的严重性排序为:脏读 > 不可重复读 > 幻读。
脏读
如果一个事务读到了另一个未提交事务修改过的数据,就发生了脏读现象。
例如,A和B两个事务同时在系统中执行,事务A先开始从数据库中读取余额数据,然后执行更新操作,若此时A尚未提交,且事务B也从数据库中读取该余额数据,那么B得到的数据是A刚刚更新的新数据。
脏读问题就会这样产生:A更新完后由于一些故障原因,导致回滚,这个数据又变回了原来的旧值,那么B得到的就是「脏的数据」。
不可重复读
在一个事务内多次读取同一个数据,如果前后两次读到的数据不一致,就发生了不可重复读的现象。
例如,A和B两个事务同时在系统中执行,A先开始从数据库中读取余额数据,然后继续执行代码逻辑,这个过程中B更新了这条数据并且提交了,那么当A再次读取该数据时就会产生前后不一致的情况。
幻读
在一个事务内多次查询某个符合查询条件的记录数量,如果前后两次查询到的记录条数不一致,则发生了幻读现象。
例如,A和B两个事务同时在系统执行,A先开始从数据库查询余额大于100的记录,发现共有5条,然后B也按相同的搜索条件也查询出了5条记录。接着A插入了一条余额超过100的记录,此时B按之前的搜索条件又执行了一次查询,发现符合的记录数变成了6,如同幻觉。
事务的隔离级别
四个隔离级别按照隔离水平从高至低分别为:
- 串行化,不会出现任何并发问题
- 可重复读,只可能出现幻读问题
- 读已提交:可能出现幻读、不可重复读现象
- 读未提交:可能出现幻读、不可重复读、脏读现象
四种隔离级别具体是如何实现的?
- 对于「读未提交」级别,顾名思义,可以读到未提交事务的数据,所以直接读取最新数据,没有任何约束,自然也就会产生并发问题
- 对于「串行化」级别,任何事务在执行过程中都会加读写锁,避免并行访问,但效率最低
- 对于「读已提交」和「可重复读」级别,都是通过Read View来实现的,区别只在于创建Read View的时机不同。Read View相当于是一个数据快照。「读提交」是在「每个语句执行前」都会重新生成一个Read View,而「可重复读」是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个Read View。
PS:MySQL有两种开启事务的命令:
- begin/start transaction
- start transaction with consistent snapshot
第一种执行并不意味着事务即刻启动,而是等执行了增删查改的语句才算真正启动事务;第二种则是即刻启动事务
Read View在MVCC中的工作原理
在这一部分中,主要关注的是两个方面:Read View中四个字段的作用、聚簇索引记录中两个跟事务有关的隐藏列。
Read View的四个字段
Read View创建时包含四个字段:
- creator_trx_id:创建该Read View的事务的ID
- m_ids:创建该Read View时,数据库里活跃事务的ID列表,「活跃」指的是事务启动但未提交
- min_trx_id:创建该Read View时,数据库里活跃事务的ID列表中最小的ID,即m_ids中的最小值
- max_trx_id:创建该Read View时,数据库中应该给下一个事务提供的ID,即m_ids中最大值+1
聚簇索引记录的两个隐藏列
假设在表中插入一条余额记录时,包含隐藏列的完整记录应该为:
- trx_id:当一个事务对某条聚簇索引记录进行改动时,就会把该事务的ID记录在trx_id列中
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到undo log中;这个隐藏列是一个指针,指向每一个旧版本的记录,可以通过这个隐藏列找到修改前的记录
MVCC工作原理
根据前文所讲的两个重要部分,在创建Read View时,可以将记录中的trx_id划分为这三种情况:
当一个事务去访问某个记录时,除了自己的更新记录总是可见,还有以下几种情况:
- 如果记录的trx_id小于Read View的min_trx_id,表明这个版本的记录是在创建该Read View之前已提交的事务生成的,所以该版本的记录对当前事务是可见的
- 如果记录的trx_id大于等于Read View的max_trx_id,表明这个版本的记录是在创建该Read View后才启动的事务生成的,所以该版本的记录对当前事务是不可见的
- 如果记录的trx_id在min_trx_id和max_trx_id之间,则需要进一步判断trx_id是否在m_ids列表内:
- 若在,则表明生成这个版本的记录的事务依旧是活跃的(尚未提交),所以该版本的记录对当前事务不可见
- 若不在,则表明生成这个版本的记录的事务已经提交,所以该版本的记录对当前事务可见
以上这种通过「版本链」来控制并发事务访问同一个记录的方式就是MVCC(多版本并发控制)。
可重复读的工作原理
RR隔离级别是事务启动时生成一个Read View,然后在整个事务执行期间都用这个Read View。
实例说明
假设,事务A和事务B相继启动,ID分别为51和52,且处于RR隔离级别;那么这两个事务创建的Read View如下:
记录的字段就用前文关于余额的例子:
接下来,A和B按顺序执行了如下操作:
- B读取了Mitsui的余额记录,得知此时的余额是100
- A将Mitsui的余额记录修改成200,但还没有提交
- B读取Mitsui的余额记录,得知余额仍然是100
- A提交事务
- B读取Mitsui的余额记录,得知余额仍然是100
在上述这个过程中,就体现了RR隔离级别的特性。
首先,B第一次读取Mitsui的余额记录时,会先看这条记录的trx_id,发现trx_id是50,比B创建的Read View中的min_trx_id(51)小,这就表明修改这条记录的事务是在B启动之前就已经提交了,因此B可见这个版本的记录。
然后,A通过update将这条记录修改了,但还没有提交,MySQL会记录相应的undo log,并用链表的方式串起来,形成版本链,如图:
可以发现,新纪录的trx_id变为了A的事务ID。
接着,B第二次去读该记录,发现此时记录的trx_id是51,在B创建的Read View的min_trx_id和max_trx_id之间,那么就需要判断这个trx_id是否在m_ids范围内;trx_id确实是在m_ids范围内的,那就说明这个记录是被还未提交的活跃事务修改的,B就不会去读这个版本的记录,而是沿着undo log的链条向下找旧版本的记录,直到找到trx_id小于Read View中min_trx_id的第一条记录。因此B能读到的就是trx_id为50的记录。
最后,当A提交后,在RR隔离级别下,B再次读取记录时,还是基于事务启动时创建的Read View来读记录。因此,B再读记录时,都是余额100的记录。
以上过程解释了为什么RR隔离级别下在事务期间读到的记录都是事务启动前的记录。
读提交的工作原理
RC级别是在每次读取数据时,都会生成一个新的Read View。
那么,就有可能出现前后两次读的数据不一致的情况,因为两次读取期间可能会有另一个事务修改了数据并作了提交。
实例说明
假设依旧是事务A和B相继启动,事务ID分别为51和52,并按顺序执行了如下操作:
- B读取数据(创建Read View),Mitsui的余额为100
- A修改数据,但未提交,Mitsui余额改为200
- B读取数据(创建Read View),Mitsui的余额为100
- A提交事务
- B读取数据(创建Read View),Mitsui的余额为200
那么,为什么B第二次读取数据时,读不到A修改后的200而是原来的100呢?
B在找到这条记录时,先看这条记录的trx_id是51,在B的Read View的min_trx_id和max_trx_id之间,然后判断trx_id是否在m_ids范围内;发现是在这个范围内的,说明这条记录是被尚未提交的活跃事务修改的,B就不会读这个版本的数据,而是沿着版本链向下寻找旧版本的记录,直到找到trx_id小于min_trx_id的第一条记录,因此B读到的是trx_id=50的记录,也就是余额为100的记录。
与RR级别不同,为什么在A提交后,B读到的就是新的200记录呢?
因为在RC级别下,当A提交后,B再次读数据时会创建新的Read View,那么此时B创建的Read View就变为:
B找到余额这条记录时,发现记录的trx_id是51,比min_trx_id小,说明修改这条记录的事务在B创建这个Read View之前就已经提交了,因此这个版本的记录对B是可见的。B读到的就是新的200记录了。
MySQL解决幻读方法
根据前文,可知MySQL通过四个隔离级别来解决相应的并发事务问题。但由于「串行化」在实际环境中的效率问题,一般默认采用的隔离级别是「可重复读」。
MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免了幻读现象(并不是完全解决了),具体的保护方法有两种:
快照读如何避免幻读
针对快照读(普通select语句),通过MVCC方式解决了幻读问题。因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就避免了幻读问题。
当前读如何避免幻读
除了普通查询是快照读之外,update、insert、delete等都是当前读,执行前都会查询最新版本的数据,然后再做相应操作。
针对当前读(select … for update语句),通过next-key lock(记录锁+间隙锁)方式解决了幻读问题。因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就避免了幻读问题。
例如,事务A执行了这条读命令:
1 | begin; |
那么就对表中的记录加上了id范围为$(2,\ +\infin)$的next-key lock(记录锁+间隙锁的组合);
如果事务B想要执行如下的插入语句:
1 | begin; |
其会判断到插入的位置被A加了next-key lock,因此B会生成一个插入意向锁,同时进入阻塞等待状态,直到A提交事务。
幻读并不会完全解决
在RR级别下,仍有两种场景可能会发生幻读现象。
第一种幻读场景
假设现在有一张表:
事务A查询id=5的记录,显然是查不出来的:
1 | mysql> begin; |
然后事务B插入一条id=5的记录,并且作了提交:
1 | mysql> begin; |
此时,A更新id=5这条记录(虽然A之前没查到,但忽略掉逻辑就这么去做了),然后再次查询id=5的记录,A就能看到B插入的这条记录,幻读现象就这样发生了。
1 | mysql> update student set name = 'phantom' where id = 5; |
第二种幻读场景
- T1时刻:事务A先执行「快照读语句」:
select * from test where id > 100
得到了 3 条记录。 - T2 时刻:事务B插入一个id= 200的记录并提交;
- T3 时刻:事务A再执行「当前读语句」:
select * from test where id > 100 for update
就会得到 4 条记录,此时也发生了幻读现象。