详解MySQL的事务和MVCC原理
1. 什么是事务?事务带来什么问题?如何解决?
2. MVCC是什么?它的原理是什么?用它解决了什么问题?
事务是什么?
事务是我们学习MySQL时,永远绕不开的话题。我们知道,当一个系统多线程运行时,并发带来的问题永远是最主要考虑解决的。因此,而MySQL用来解决并发问题的关键词即为事务。
事务主要将围绕以下几个维度展开,如图:
什么是事务?
事务的基本概念要先了解,我个人的理解是:要么有始有终,要么其实都没有
有一场景,用户A需要向用户B转账1000块钱,我们将步骤细分为四步,如下:
- 从数据库读取用户A的余额
- 用户A的余额扣1000元(如果余额>1000),将扣完的余额更新到数据库
- 从数据库读取用户B的余额
- 将用户B的余额加1000元,将增加后的余额更新到数据库
试想?如果在第二步,用户A的余额扣完后,服务器断电了,这1000元不是直接不翼而飞了?这换做谁都无法接受吧!
因此,便有了事务这一概念。MySQL通过事务来解决这一问题。在原先转账操作的基础上,在转账前后分别开启事务和提交事务。
如果开启事务后,中途发生意外(报错了,或是服务器断电),直接将 “这件事” 退回到事务开启前的状态。如果顺利执行完,就将事务提交,完毕!
总结来说,就是在一开始做事的时候,有个标记在初始位置,如果发现情况不对,赶紧跑回去。 如果一切顺利,就不用管了。
事务四大特性总结
事务肯定是有特点的嘛,分别是原子性,持久性,隔离性,一致性。下面我们分别展开说说!
原子性
如何理解原子性? 我个人的理解是:原子是世界上最小的单位,肯定是无法再做切割的。整个事务可以看成是一个原子,无法将事务再拆分开。因此一次事务内的所有操作,肯定是要么全部成功,要么直接挫骨扬灰(全部失败)。
事务的原子性由 undo log回滚日志来保证(后续会展开说)
持久性
如何理解持久性?上文提到的转账操作,如果事务提交了,还可以修改,或者是回滚,那问题可就麻烦了。那需要事务来做什么?因此,事务的持久性代表了:提交的事务是不可被修改的状态,已经彻底保存了下来。
事务的持久性由 redo log回滚日志来保证(后续会展开说)
隔离性
如何理解隔离性?如果同时有多个事务在运行,事务之间可以互相看到对方的事务,并修改对方事务内的信息,那事务就会彻底乱套啦。因此事务的隔离性代表:未提交的事务之间相互不可见,不可修改对方事务内的信息,保证数据一致性。
事务的持久性由 MVCC机制和上锁来保证(持久性是锁篇的重点)
一致性
如何理解一致性?事务的一致性表示,事务的操作前后的数据需要保持完整性约束。(例如,上述例子提到的1000块转账钱,只是从A账户跑到了B账户,并没有凭空消失,这就是数据的完整性)由原子性+持久性+一致性共同守护。
事务的并发问题
如果整个世界只允许单线程的细胞生物存在就好了,哈哈哈!上到各门编程语言,下到操作系统,并发的问题总是会被提到,MySQL的事务也不例外。肯定是允许多事务并发的,也会带来一些并发问题,总结如下:
脏读
如何理解脏读?假设有两个正在运行的事务,事务A能读取到事务B的未提交的数据,表明发生了脏读。这个问题就很严重啦!我画了个图举例,如下:
如果发生了脏读,那就表明在t5时刻查到的余额V1为2000,如果这时候是有两个人在做交易:
- B在t5时刻给A看完余额记录
- 随后在t6时刻,再把事务不是提交,而是直接回滚
- 在t7时刻,查询到的余额V2为1000。这样就涉及到诈骗了!
脏读是绝对不允许发生的!
不可重复读
如何理解不可重复读?不可重复读是在解决了脏读的基础上,存在的其它问题。具体为:一个正在活跃的事务,在不同时刻查询到的数据不一致。,还是同样的图:
如果发生了不可重复读,即:
- 在t4时刻,事务B将余额修改为2000,没有提交
- 在t5时刻,事务A查到的余额V1为1000,没有出现脏读现象
- 在t6时刻,事务B把事务提交了
- 在t7时刻,事务A再次查询余额,此时读到其他提交的事务的值,查到的余额V2为2000,出现了不可重复读问题
幻读
如何理解幻读?在处理了脏读和不可重复读后,幻读的具体为:一个正在活跃的事务,在不同时刻查询到的数据的数量不一致。幻读更多关注的是数量,多次查询某个条件的数量,出现数量不一致的情况,如图:
如果发生了幻读,即:
- 在t2时刻,查询到符合条件的用户数量为5
- 在t4时刻,新启动的事务B将插入一条新用户,并随后提交
- 在t6时刻,事务A再次执行相同的sql,此时查询到符合条件的用户数量为6,和一开始的数量对不上,出现了幻读问题
总结下事务的并发问题:
事务的隔离级别
既然让事务并发操作,会带来这么多问题,那MySQL就不可能不处理这些问题,让这些问题破坏我们的数据。针对这不同的问题,MySQL同样有多种隔离级别来应对。分别是:读未提交,读提交,重复读,串行化
从安全性考虑
从会产生的问题考虑
不同隔离级别下读到的数据
隔离级别的原理
我们在写sql时,执行单条insert, update, delete语句时,MySQL会默认开启事务(在sql的前后默认加上begin(开启事务), commmit(提交事务))。
而执行关键的查询select sql时,有两种写法:
- 普通的select ... 语句(快照读),会通过MVCC的方式来解决事务的并发问题
- select ... for update(当前读),会通过直接给数据加锁的方式来解决事务的并发问题
而根据不同的隔离级别,MVCC和上锁的粒度也有所不同,我会在后续展开详细介绍
MVCC是如何解决事务并发问题的?
在上章节我们提到,普通的select语句是通过MVCC的方式来解决事务的并发问题,MVCC(多版本并发控制) 是我们这次介绍的重点,具体从以下三部分展开:
什么是MVCC?
在介绍MVCC之前,我们需要了解几个基础的概念。
一. 普通的MySQL记录,还有几个我们看不到的隐藏字段:
-
trx_id: 最近的操作这条数据的事务ID;(对一条记录做增删改时,会在这条记录上留下“我是谁”的印记,即事务ID)
-
roll_pointer: 回滚指针,指向该条记录的上一个版本:(一条数据修改后,旧记录并不是被直接抹去,而是有新纪录的指针会指向它)
-
row_id: 虚拟字段,若表中没有主键,起到代替主键的作用
举个例子,数据的变更图如下。
在t1时刻,由id为50的事务创建了id = 1的记录,此刻他的trx_id为50,由于是新记录,roll_pointer为NULL。
在t2时刻,由id为51的事务修改了id = 1的记录,将age从20改成18。等于这条记录的版本被更新了。当前最新版本记录的trx_id为51,而roll_pointer则会指向当前记录的上一个版本。
二. Read View(视图),MVCCd的重要组成,可以理解为数据的快照,相机的快门,每生成一个Read View时(按一下相机快门),总会有一些记录(图片)被保存下来。以下是Read View的关键内容:
- create_trx_id:创建这个read view的事务ID
- m_ids: 创建这个read view时,当前还活跃的事务ID集合(事务是允许并发的嘛!)
- min_trx_id: 当前还活跃的事务最小ID,即m_ids集合中的最小值
- max_trx_id: 下一个即将创建的事务的ID。相当于是一种预分配,提前告诉这个read view会有哪个事务会来,但是现在还没来!
通过上述的基本概念,我们就可以系统性的学习MVCC是如何工作的,read view, rtx_id, roll_pointer...
如何用MVCC来判定当前的数据是否可见(MVCC工作原理)?
如何理解数据是否可见这句话? 其实就是在查询数据时,查到了本不应该让他看到的数据,才导致的并发问题。比如说:脏读:查到了其他事务还没提交的数据;不可重复读和幻读:自己的数据还没提交,查到了其他事务提交的数据
MVCC是如何利用Read View来判断数据是否可见的?
当前我开启了一个事务,其中有查询语句,并且手里有一个read view,判别步骤如下:
判别流程图可以总结为如下:
可以看下伪代码:
row_data = select ...;
while (row_data != null){
if (row_data.trx_id == read_view.create_trx_id) {
break;
}
if (row_data.trx_id < read_view.min_trx_id) {
break;
}
if (read_view.m_ids contains row_data.trx_id) {
row_data = row_data.poll_pointer;
continue;
}
if (row_data.trx_id >= read_view.max_trx_id) {
row_data = row_data.poll_pointer;
continue;
}
}
return row_data;
不同隔离级别下的MVCC是如何工作的?
读未提交不谈(这玩意没有任何防御措施),重点是读提交和重复读两种隔离机制,MVCC开启Read View的时机不同,因此安全性也不同
对于重复读隔离级别,整个事务的运行阶段,只会创建一个Read View,事务内执行的select语句共用一个read view。(从一而终)
对于读提交隔离级别,整个事务的运行阶段,只要执行一次select语句,就会马上创建一个Read View,然后用这个新的Read View做可见性判断。(始乱终弃)
那在重复读的隔离级别下,创建唯一的Read View的时机?
读提交下的MVCC
举个例子,下面是两个事务在不同时刻对某条相同记录执行的CRUD操作,
一. 在T5时刻,事务B对余额要做一次查询操作,此时会创建一个Read View,如下:
根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的活跃事务id集合为[50, 51],50的事务对于当前的read_view是不可见的。因此,通过回滚指针找到上一条版本记录,即余额为1000,而余额为1000的记录则是由id < 50的事务创建的,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V1=1000。
二. 在T7时刻,事务B再次对余额做一次查询操作,由于是读提交隔离级别,因此会创建新的Read View,如下:
根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的最小活跃事务ID为51,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V2=2000。一个事务内前后时刻读取的数据不一致,出现了不可重复读的并发问题。
重复读下的MVCC
还是相同的例子,隔离级别升级为重复读
一. 在T5时刻,事务B对余额要做一次查询操作,此时会创建一个Read View,如下:
根据判断规则,余额的最新记录为2000,这是由id为50的记录创建的,该read_view的活跃事务id集合为[50, 51],50的事务对于当前的read_view是不可见的。因此,通过回滚指针找到上一条版本记录,即余额为1000,而余额为1000的记录则是由id < 50的事务创建的,小于min_trx_id。因此,该版本记录对于这个read_view是可见的,余额V1=1000。
二. 在T7时刻,事务B再次对余额做一次查询操作,由于是重复读隔离级别,因此不会创建新的Read View,复用T5时刻创建的Read View。因此,时刻T7的判断和T5相同,余额V2仍然为1000。不可重复读的问题解决。从理论上来说,一个事务内,对任何查询都沿用同一个read_view,在这个期间无法看到其他事务插入的数据,也能避免幻读问题(除个例)。
MVCC真能完全解决幻读嘛?
毫无疑问,在重复读隔离级别下,对于普通的幻读,MVCC是可以避开的,如下图:
在t3时刻再次执行t2时刻的select语句,由于t3时刻使用的read view的和t1时刻的一样。因此,在t2时刻,执行的insert语句对于该read_view是不可见的, 因此select语句的结果仍然可以和t1时刻的保持一致。
但如果是本事务去对其他事务insert的数据做修改呢?
在讲解之前,需要科普一下快照读和当前读的概念
- 快照读:读的是数据的版本,(读取到的不一定是最新的版本,因为有可能不可见)。普通的select ... 用的就是快照读
- 当前读:读的是一定是数据的最新版本。执行insert, update, delete用的都是当前读(需要知道操作数据的最新值,才能操作); 还有特殊的 select ... for update语句。
特例一:两次select间穿插update
存在一张user表,该表里面没有数据,存在字段id, age, mal5。
一. 在T1时刻,A开启事务,执行select语句,由于表没有数据,因此查询结果为NULL
二. 在T2时刻,B事务插入了一条id=10的数据,并马上提交事务
三. 在T3时刻,事务A执行一条update语句,需要对id = 10的数据做更新,此时采用的是当前读,由于数据库有一条已经提交了的id = 10的数据,因此能读到,并修改。此时,id=10这条记录的最新版本的trx_id为事务A
四.在T4时刻,事务A执行同样的select语句,此时对于id=10这条记录来说,最新版本的记录的trx_id为事务A,正好满足条件:read_view.create_trx_id == trx_id。结果为可见,尴尬的幻读问题出现了。
特例二:两次select; 一次select, 一次select...for update
还是同样的表,执行顺序变为如下:
一. 在T1时刻,A开启事务,执行select语句,由于表没有数据,因此查询结果为NULL
二. 在T2时刻,B事务插入了一条id=10的数据,并马上提交事务
三. 在T3时刻,事务A执行的是select ... for update,不同于普通的select,为当前读,一定会读取数据最新的版本,事务B提交的记录对其是可见的,尴尬的幻读问题又出现了!
思考题,为何我的数据修改了不生效呢?
看下图,开启事务后,执行第一次select语句的时候,我们可以看到表里的数据,所有的id值均等于age值,随后执行了一条update语句,再次执行同样的select语句,为什么数据没有改变? 什么样的场景能复现该操作?
其实问题的关键在于:执行了update语句,受到影响的行数居然为0。数据必然是提前被其他事务修改了。答案如下:
一. 在T1时刻,A开启事务,执行select语句,正常返回数据,可以看到所有的id均等于age
二. 在T2时刻,B事务将表里的age全部+1,并马上提交事务
三. 在T3时刻,事务A执行的是update语句,将id == age的数据中的age都修改为0。此刻会进行当前读,读取最新数据。由于最新数据的age全被+1,已经不满足id == age这个条件,因此受影响的行数为0。
四. 在T4时刻,事务A再次执行的是普通的select语句,为快照读。这时候会从数据的最新版本开始查找,事务A此时用的read_view是在T1时刻创建的,(此时事务B操作的sql均不可见,trx_id >= read_view.max_trx_id,对于当时的事务A来说,事务B还没创建,所以肯定是读不到的)。因此会通过行的roll_pointer查找到上个版本的数据,因此查找的数据仍和T1时刻相同!!
总结下MVCC的工作原理
对于事务产生的脏读,不可重复读,幻读问题。对于MVCC来说,活跃的事务数据均是不可见的,因此脏读问题自然解决。
MVCC的读提交隔离级别,生成的是语句级别的快照Read View(每次select都会创建),不可重复读,幻读问题仍然存在。
MVCC的重复读隔离级别,生成的是事务级别的快照Read View(一个事务只会有一个),从开始到结束都会保持一致。即使有别的事务插入和修改数据,对于普通的快照读都是不可见的,因此可解决不可重复读和幻读问题
未完待续...........还会讲MySQL的锁,上锁规则,死锁分析...重量级的还没来