最近在学习数据库事务相关的知识,事务最重要的就是隔离级别,不同的隔离级别对于我们查询出来的结果是不一样的,也对应着不同的业务场景,而innodb的隔离级别是依靠mvcc来实现的,好不容易理解了mvcc,但是在学到锁的时候,大脑又宕机了,mvcc的功能都这么强了,还要这么多复杂的锁干嘛呢?
带着问题去思考后,我的一点薄见:锁和mvcc的结合,可以更好的帮助事务隔离级别去解决并发问题下的一系列问题,且听我慢慢解释
首先我们从一些基础概念开始看看
事务的隔离级别
众所周知,数据库面对并发,会出现脏读、不可重复读、幻读
为了有效保证并发读取数据的正确性,提出了事务隔离级别
- 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;
- 读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
- 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
- 串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;
不可重复读和幻读的区别
有些时候我们会分不清 不可重复读和幻读的概念,其实用锁的概念去理解,当我们把现有数据锁住,我们可以保证我们读几次,都是一样的数据,除非锁释放了,但是这时候新插入一条数据呢,我们是没法锁住的,这时候查询就多一条数据。所以就很好区分不可重复读和幻读,不可重复读是我们在一次事务中读到的数据数值不一致,而幻读则是读到了上一次查询根本不存在的行,当然实际innodb用的不是悲观锁,而是乐观锁MVCC
MVCC——多版本控制
快照readview和版本号
MVCC是利用readview来实现的,readview的重要组成如下:
trx_id是每个事务在开启的时候根据时间先后顺序来创建的,也就是说每个事务都有自己的版本号trx_id,那么版本号怎么来使用的呢?
其实不光是每个事务有自己的版本号,每条行记录也有自己的trx_id和roll_pointer,他们属于是隐藏列,每个事务在修改一行数据时,并不是直接在内存上写,而是利用写时复制的方式,新写一条记录,链接在后面,比如我们下面这条记录,版本号2的事务进行一条操作. Update set name = 123 and number =1 where id =1
就会多一条记录
那这是修改,查询的时候应该查询哪条记录呢
不管是RC还是RR,都遵循着一个准则:
-
如果记录的 trx_id 值小于 Read View 中的
min_trx_id
值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。 -
如果记录的 trx_id 值大于等于 Read View 中的
max_trx_id
值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。 -
如果记录的 trx_id 值在 Read View 的
min_trx_id
和max_trx_id
之间,需要判断 trx_id 是否在 m_ids 列表中:- 如果记录的 trx_id 在
m_ids
列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见 - 如果记录的 trx_id 不在
m_ids
列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见
- 如果记录的 trx_id 在
RR可重复读的原因
RC和RR的区别在哪里呢,我们可以通过两张图来看看,如图,我们假设原本的版本号为99,现在开启了三个事务,分别是A,B,C,版本号按照顺序依次是100,101,102,
其中在RR隔离级别下,如果没有start transcation with consistent snatshop
,意味着一条语句就是一个事务,那么如下图
那么按照刚才的读版本号的规则,对于事务A两次select,它都应该去读版本号小于100的,对于第一次select,只有99的版本号,那就去读99版本号数据,对于第二次查询,虽然这时候已经有了其他的版本号,但是大于100,所以不可见
它会按照roll_pointer一直找到99的记录,所以我们看到RR级别下,借助MVCC的快照读,确实是可以解决不可重复读的问题
这里我们要提一下的是,现实里99这条记录并不会物理存在,当每行的trx_id小于所有活跃的事务id时就会被清除,否则会占用大量的空间,那怎么读99号记录呢,其实是利用了undolog的功能,把这条记录回滚出来的
RC隔离级别下的不可重复读原因
同样的我们来分析一下RC为什么不能可重复读,RC不同于RR,它不是一次事务只生成一个快照,而是每次执行命令都获取一个新的快照,因此,显示的开启事务start transcation with consistent snatshop
并不起作用,如图
可能会有疑惑,怎么版本号变成了[99,100,101],事务C得版本号不是102吗,正如我们前面所说start transcation with consistent snatshop
并不起作用,所以此时谁先执行命令谁先被分配靠前的ID,所以事务A第一次查询获取99的数据,第二次获取版本号101的数据
至此,我们也看到了MVCC的强大功能,值的一提的是,mysql的快照生成不像redis生成rdb快照那么耗费时间,而是秒级创建快照,就是利用的版本控制功能,利用版本号就实现了快照功能
那么聊了这么多,锁呢?
我们可以看到,前面的一系列动作,好像只是为了解决读读、读写情况下的一致读问题,但是如果遇到了写写问题怎么办呢?
锁的分类
锁总体来分可以分成全局锁、表锁、行锁
全局锁
全局锁,通常用Flush tables with read lock
(FTWRL)命令,使得整个库处于只读状态,一般只用来做全库逻辑备份,但这样锁全局会对性能有很大的影响,所以在做备份的时候,可以考虑使用参数–single-transaction,通过开启事务,拿到一致性视图,同步视图
表级锁
表级锁常见的是表锁和元数据锁mdl
表锁:lock tables ...read/write,表锁最大的问题是影响范围太大了
mdl元数据锁:在访问一个表结构时自动加上的,无需显式使用
同时对于mdl锁,读读共享,但是读写互斥
所以mdl锁使用的时候要注意使用顺序,如图
我们可以看到,当第一个读事务还没提交的,这时候表里一直加了mdl读锁,所以后面一旦有写操作,从写操作后面的所有操作都会被阻塞
所以,当我们需要去修改表结构的时候,要注意是否有长事务,如果有长事务就不要去执行修改操作,实在要加,就先kill掉长事务
如果是热点数据,即使kill了也会很快又再次查询怎么办,那也可以在修改命令后添加超时时间,这样短时间没修改就会自动放弃执行命令
行锁
首先介绍行锁前,我们看看什么是两阶段锁
两阶段锁就是,将锁分为封锁阶段和解锁阶段,在事务提交后才会解锁
行锁就是两阶段锁,同样的,表级锁也是两阶段锁
而因为二阶段锁的特性,我们在执行事务时,尽可能把并发度最大,影响范围最广的安排在事务的最后面执行,从而提高并发度
锁和MVCC的配合
那聊到这边,锁到底是怎么配合MVCC的呢?
我们前面也说到,MVCC只是解决了读写一致性的问题,那如果写写呢?比如看这张图,事务B的更新是怎么一个过程?
在判断之前,我们再加一点补充:当前读
当前读是相对于快照读来说的,对于一些对数据有时效性的请求,快照读可能不太适合,于是就有了当前读,读取最新的版本号数据
而对于我们所有的写请求,为了保证写的数据是最新的,都会在写之前自动当前读取最新的快照,同样不光是写,对于上了锁的读请求也会开启当前读
所以对于事务B来说,他在修改前,先发起了当前读,但这时候事务C在写,并且未提交事务,也就是未释放锁,因此发生了冲突,所以事务B的执行要一直等到事务C提交才可以
Next-key lock
同样的,我们刚刚说到当前读会获取最新的快照,那对于select .... For update,那么在一个事务中查询两次,且两次查询期间有新的快照产生,不就发生了幻读了吗?
mysql对于这种现象使用了next-key lock来保证,next-key lock是记录锁和间隙锁的组合,
比如下图,对于page6,其实数据被分成了「10,13),13,(13,17」,当我们对13这个数据修改的时候,mysql就会自动给我们对13加上记录锁,并对周围「10,13),(13,17」上间隙锁,这时候如果有一个插入id=16的命令,就会被阻塞。通过这样的方式,很大程度上解决了幻读问题
当然这样的做法虽然好,但是要谨慎使用,我们知道,如果我们修改一条语句的时候,没有走索引,就会走全表扫描,mysql就得一条一条找,因为不确定哪条是自己要的,所以就得找一条锁住一条,就会给全表加间隙锁,这样就等于把全表都锁住了,非常影响并发,因此要十分的注意
特别补充一点:next-key还有其他的重要用处:比如binlog主从同步可以保证一致性,但我们这边就不展开了
至此,行锁和我们的MVCC就这样结合起来了,他们一起保证了各个事务级别的隔离能力,
当然了除了行级锁,表级锁也有很大的作用,比如两个同时要修改表结构的写操作发生,就可以利用表级锁做保证,当然表级锁一般我们用mdl,不用我们自己操心。
最后,其实各个级别的事务隔离能力并没有绝对的好与坏,还是要结合具体的场景,因地制宜,将每个事务隔离级别都应用在最合适的位置