mysql事务原理与实践
导读
Mysql事务是后端开发中经常用到的知识点,很多同学都会比较浅的了解事务的特性和基本原理就进行开发,但并没有深入的进行多各种隔离方式的实践,往往会在多线程处理的时候就会掉进坑里。今天我们就一起深入实践,还原每一个场景。
目录
什么是事务?
事务的特性
多个事务并发会出现什么问题?
事务的几种隔离级别
事务隔离级别的实现原理
什么是事务?
事务是数据库一个有限的操作序列,这些操作要么全部执行,要么全部不执行。
举个例子:
银行的用户A账户余额有100块,这时候用户A要转账100块给用户B,银行已经把A的余额扣除了,但突然银行系统出错了,并没有把100块转到用户B的账户上。这种情况这100块就凭空消失了,当然是大家都不能接受的,所以银行就会一个回滚机制,把100块退回到用户A的账户上。这就是事务,A的100块要不成功转账给B,要不就退回给A,并不会凭空消失。
事务的特性
从上面的例子其实就能反应出事务的4个特点:
原子性
原子性指的就是事务中一系列的操作要不全部执行,要不全部不执行。
在刚刚例子中,如果用户A同时转账50给用户B和用户C,要不都转账成功,用户B、C各收到50块。要不银行把2个50块都退回给用户A,用户A账户余额还是100块。
一致性
一致性指的是在事务开始前和事务操作之后数据是不被破坏的。
在刚刚例子中,不管怎么转账,对于用户A、B、C来说他们手上的总金额都是100,不会凭空消失。
隔离性
隔离性指的是多个事务并发访问的时候,他们之间是相互隔离的,一个事务不会被其他事务干扰。
例如用户B在查看余额的时候,看到的是0块。这时候同时用户A给B转账了100块,B看到的依然是0块,要等到B完成查看后A的100块成功转账后再次查看才能看到B账户里面多了100块。(这里是用了串行化的隔离级别,后面会对各种隔离级别进行介绍,不同的隔离级别会出现不同的效果)
持久性
持久性指的是事务完成提交后,所有对数据库的操作都会被永久保存在数据库中。
多个事务并发会出现什么问题?
刚刚不是说事务有隔离性吗?事务之间互不干扰,那为什么多个事务并发的时候还会出现问题呢?
嗯,在一开始学习的时候,我也有这个疑问。其实可以理解为事务最终要实现的是这4大特性,而为了实现这4大特性的我们需要如何设计?我们需要抱着这个思路进行思考。
在单线程中一个事务我们只要考虑原子性和一致性就可以了,要不全部执行,要不都不执行,且数据前后是一致的。
但我们实践中基本都是多线程并发的,那在隔离性的实现上就会出现以下问题:
脏读
例如:
用户A准备查询自己账户的余额(100块)
同时用户B给用户A转账50块(未提交)
最终用户A查询到自己的余额是150块
实际用户A的账户只有100块,用户A事务查询到时候已经被用户B的事务影响到了
不可重复读
例如:
用户A查询自己账户的余额(100块)
同时用户B给用户A转账50块(已提交)
用户A再查询自己账户的时候余额变成了150块
这次实际用户A的余额的确是150块了,但在用户A的事务里面两次的查询金额应该是一致的才对,现在就是被用户B的事务影响到了。
幻读
例如:
用户A查询自己有多少条收入记录,查询到了有2条
同时用户B给用户A转账50块(已提交)
用户A再查询自己有多少条收入记录时,查询到了有3条记录
用户A在事务中第一次查询了一个结果集,但在用户B的事务中插入了记录导致用户A第二次查询的时候结果集不一样了。
有没有发现不可重复读和幻读很像?都是在一个事务内查询了2次,这里很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
事务的几种隔离级别
那上面提到在并发时候有可能出现的脏读、不可重复读、幻读3个问题,InnoDB到底是怎样解决的呢?下面我们一起到mysql设置不同的事务隔离级别进行实践,看看是如何解决的。
下面是事务的4个隔离级别:
- 读未提交(read-uncommitted)
- 读已提交(read-committed)
- 可重复读(repeatable-read)
- 串行化(serializable)
在我们实践之前,先说一下mysql如何设置隔离级别 和 事务提交设置
查询当前隔离级别:select @@tx_isolation;
mysql默认的事务隔离级别是可重复读(repeatable-read)
接下来在说一下如何设置成不同的隔离级别
设置成其他隔离级别:set session transaction isolation level “隔离级别”
例如下图,我们把隔离级别设置成读未提交(read-uncommited)
然后是查看一下我们当前的事务提交设置,默认是1,即自动提交事务
select @@autocommit;
由于我们的实践是需要我们手动提交的,所以我们先设置取消自动提交事务
set autocommit = 0;
好了,我们知道怎样查询和设置后,下面就是我们的实操流程,我们同时打开2个mysql实例开启事务,看看这几种隔离级别到底能不能解决脏读、不可重复读、幻读的问题
读未提交(read-uncommitted)
1、先设置成读未提交级别
set session transaction isolation level read uncommitted;
2、我们先查询看看其中一条纪录,可以看到当前的纪录信息是:小明,22岁
3、接下来我们开启事务A,先什么都不做
4、然后我们打开另外一个mysql实例,开启事务B,把这条记录的age修改成25,但不提交
5、这时候我们回到事务A的实例,查询一下小明的记录,会发现记录的age已经被修改成了25
由此看出,上面的例子已经出现了脏读问题,事务B都还没有commit,事务A就已经读到了事务B更新的数据。可以说读未提交级别是事务隔离级别里面最低级的,我觉得根据就没有解决到我们提到的4大问题。
读已提交(read-committed)
1、先设置成读未提交级别
set session transaction isolation level read committed;
2、我们开启事务A,查询以下小明的信息,当前是小明,22岁
3、然后我们打开另外一个mysql实例,开启事务B,把这条记录的age修改成25,不提交
4、这时候我们回到事务A的实例,查询一下小明的记录,依然还是22岁,证明没有出现脏读的问题了
5、然后我们在事务B进行提交
再回到事务A查询记录,这时候发现变成25岁了,同一个事务内2次查询的内容不一样了
由此看出,事务B的数据需要提交后,事务A查询的记录才会更新,证明读已提交级别已经解决了脏读问题,但还是会出现不可重复读的问题,即同一个事务内,查询同一条件的时候数据内容发生了变化。
可重复读
1、先设置成可重复读级别
set session transaction isolation level repeatable read;
2、我们开启事务A,查询以下小明的信息,当前是小明,22岁
3、然后我们打开另外一个mysql实例,开启事务B,把这条记录的age修改成25,不提交
4、这时候我们回到事务A的实例,查询一下小明的记录,依然还是22岁,证明没有出现脏读的问题了
5、然后我们在事务B进行提交
再回到事务A查询记录,依然还是22岁,证明不可重复读问题也没有出现了
那我们再看看有没有幻读问题:
6、在事务B插入一条新记录并提交
7、回到事务A进行查询结果集,可以看到得到了1条记录,没有出现幻读问题
由此看出,可重复读级别能解决的是脏读、不可重复读、幻读的问题。
串行化
从上面的例子大家应该可以猜到,串行化就是最强的隔离级别了。是的,他可以解决脏读、不可重复读、幻读3大问题。下面我们直接实操验证一下是不是能解决幻读问题:
1、先设置成串行化级别
set session transaction isolation level serializable;
2、我们开启事务A,查询以下小明的信息,当前是小明,22岁
3、然后我们打开另外一个mysql实例,开启事务B,插入一条新记录,这时候会发现实例一直在等待
由此看出,当隔离级别是串行化的时候,事务B的写操作在等待事务A的读操作,可以说串行化的隔离级别是最安全的,读写都不允许并发。虽然说是最安全的,但由于阻塞了其他实例执行,性能也会变差。
小结
实践之后我们简单的小结一下这4种事务隔离级别分别能解决什么问题:
事务隔离级别 |
脏读 |
不可重复读 |
幻读 |
---|---|---|---|
读未提交 | 不能解决 | 不能解决 | 不能解决 |
读已提交 | 能解决 | 不能解决 | 不能解决 |
可重复读 | 能解决 | 能解决 | 能解决 |
串行化 | 能解决 | 能解决 | 能解决 |
事务隔离级别的实现原理
掌握事务的4大隔离级别之后,当然还要继续深入了解他们分别是怎样实现的?是怎样做到不同事务之间不同程度的互不干扰的。大家应该能猜到,就是锁。除了锁以外,还有另外一种机制:MVCC(多版本并发控制)。
先说使用锁机制的2种隔离级别:
读未提交(read-uncommitted)
采取的是读不加锁原理
事务读不加锁,不阻塞其他事务读和写
事务写加锁(排他锁),阻塞其他事务写,但不阻塞其他事务读
串行化(serializable)
采取的是读加锁、写加锁原理
事务读的时候加锁(共享锁),不阻塞其他事务读,但阻塞其他事务写
事务写的时候加(排他锁),阻塞其他时候的读和写
然后再说一下使用MVCC的另外2种隔离级别,首先介绍一下什么是MVCC:
MVCC(多版本并发控制)
可以将MVCC看成行级别锁的一种妥协,它在许多情况下避免了使用锁,同时可以提供更小的开销。不用加任何锁, 对各个时间点生成一致性数据快照 (Snapshot), MVCC 的实现是通过保存数据在某个时间点的快照来实现的,并用这个快照来提供一定级别事务隔离。同一条记录在系统中可以存在多个版本。
MVCC有2个重要概念,分别是隐藏字段和读视图
在MySQL Innodb中,会默认为我们的表后面添加三个隐藏字段
DB_ROW_ID:行ID,MySQL的B+树索引特性要求每个表必须要有一个主键。如果没有设置的话,会自动寻找第一个不包含NULL的唯一索引列作为主键。如果还是找不到,就会在这个DB_ROW_ID上自动生成一个唯一值,以此来当作主键(该列和MVCC的关系不大)
DB_TRX_ID:事务ID,记录的是当前事务在做INSERT或UPDATE语句操作时的事务ID(DELETE语句被当做是UPDATE语句的特殊情况)
DB_ROLL_PTR:回滚指针,通过它可以将不同的版本串联起来,形成版本链。相当于链表的next指针。
Read View(读视图)
m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
creator_trx_id:表示生成该ReadView的快照读操作产生的事务id。
Read View就是事务进行快照读(select * from)操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成事务系统当前的一个快照,记录并维护系统当前活跃事务(未提交事务)的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。
Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据
ReadView中主要包含4个比较重要的内容:
好了,理解完MVCC的主要概念后,我们回到隔离级别原理这块,下面我们再看看事务是如何使用MVCC来达到读已提交和可重复读级别的:
读已提交(read-committed)
1、事务B把age修改成25(未提交),事务C把ag修改成28(未提交),这时候的undo log版本链是这样的:
id |
name |
age |
trx_id(隐藏字段) |
roll_ptr(隐藏字段) |
---|---|---|---|---|
1 | xiaoming | 28 | 10003 | 指针,指向上一个版本,事务ID为10002 |
1 | xiaoming | 25 | 10002 | 指针,指向上一个版本,事务ID为10001 |
1 | xiaoming | 22 | 10001 | 历史已存在的版本 |
2、这时候事务A进行查询id=1的操作,会先生成一个Read View,记录当前未提交的事务信息
m_ids |
min_trx_id |
max_trx_id |
creator_trx_id |
---|---|---|---|
[10002,10003,10004] | 10002 | 10004 | 10004 |
然后在undo log版本链找找到最新的记录事务ID10003,但事务10003在Read View里面,证明事务还没提交,不可以展示,那么久依次往上个版本事务10002、10001判断,这时候发现事务10001比min_trx_id还要小,证明10001是已提交的最新版本并展示,这时候展示的数据不会出现脏读问题。
3、当事务C提交后(事务B还没提交),这时候undo log版本链不会改变
4、再一次事务A进行查询,会重新生成Read View,这时候,Read View已经变化了
m_ids |
min_trx_id |
max_trx_id |
creator_trx_id |
---|---|---|---|
[10002,10005] | 10002 | 10005 | 10005 |
然后在undo log版本链找找到最新的记录事务ID10003,而10003在min_trx_id和max_trx_id之间,这时候还需要判断10003是否在Read View中,发现10003已经不存在Read Viwe中,证明事务已提交了,可以展示,但这个时候age查询到的是28,复现了不可重复读的问题。
可重复读(repeatable-read)
理解好了读已提交的原理的话,可重复读会比较容易理解,我们还是用回刚刚的例子:1、事务B把age修改成25(未提交),事务C把ag修改成28(未提交),这时候的undo log版本链是这样的:
id |
name |
age |
trx_id(隐藏字段) |
roll_ptr(隐藏字段) |
---|---|---|---|---|
1 | xiaoming | 28 | 10003 | 指针,指向上一个版本,事务ID为10002 |
1 | xiaoming | 25 | 10002 | 指针,指向上一个版本,事务ID为10001 |
1 | xiaoming | 22 | 10001 | 历史已存在的版本 |
2、这时候事务A进行查询id=1的操作,会先生成一个Read View,记录当前未提交的事务信息
m_ids |
min_trx_id |
max_trx_id |
creator_trx_id |
---|---|---|---|
[10002,10003,10004] | 10002 | 10004 | 10004 |
跟读已提交一样,这里查询结果是10001,age=22。
3、当事务C提交后(事务B还没提交),这时候undo log版本链不会改变
4、再一次事务A进行查询,当我们的级别设置成可重复读时,不会再重新生成Read View,而是用回我们第一次生成的Read View
m_ids |
min_trx_id |
max_trx_id |
creator_trx_id |
---|---|---|---|
[10002,10003,10004] | 10002 | 10004 | 10004 |
所以这样查询得到的结果依然是10001,age=22。解决了不可重复读的问题。
总结
以上就是我对Mysql事务隔离级别的理解与分享,大家可以根据以下思路进行重温和深入理解
为什么要用事务?(为了隔离不同数据库实例之间的影响)
有什么隔离级别?(4大隔离级别,解决3大问题)
原理是什么?(锁和MVCC)
希望能带给大家一些启发。