先说结论,MVCC不能完全解决幻读。只能解决快照读下的幻读,当前读的幻读依然需要借助next-key锁来解决幻读。
目录
什么是幻读?
使用InnoDB作为引擎的MySQL有四种事务隔离级别,分别是:
- Read Uncommitted:读未提交
- Read Committed:度提交
- Repeatable Read:可重复读
- Serialization:串行化
使用这些隔离级别可以解决数据读取时的脏读,不可重复读,幻读问题。其中脏读是指A事务读到了其他事物未提交的修改。不可重复读指的是同一个事务在相同查询条件下读取到的数据不一致。幻读则是指同一个事务在相同查询条件下前后读取到的数据量不一致(其实也是读取到的数据不一致)。不可重复读和幻读的最大区别在于,不可重复读针对的是update/delete修改了查询的数据,但是幻读针对的是insert,插入了新的数据,之前查询的数据没有变,但是会多出来数据。
RC级别的实现?
由于Read Uncommitted什么措施也不做,因此本事务可以读到其他事务没有提交的数据。但是RC级别下,可以避免这个情况。确保只有其他事务提交之后才能读取到。这其中的原理使用的是Read View和undo log版本链实现的。其中版本链中会保存几个重要的变量,包括
- TRX_ID(事务ID),ROLL_BACK_POINTER(回滚指正,指向上一次修改的指针)
- m_ids(目前活跃的事务的id集合)
- min_trx_id(本事务生成read view时m_ids中最小的,其中活跃代表其他事务执行了操作但是没有提交事务)
- max_trx_id(表示系统中最大的事务ID+1,也就是系统如果开启下一个事务会生成的id)。
RC级别下每次查询数据都会生成一个read view,这里和RR不一样,RR级别下只会在事务开启的一瞬间生成一个read view,后来的每次查询都是使用这个read view,一个read view可以保证整个事务器件数据的可重复读取。假设现在有A,B,C两个事务,C的事务trx_id=50,B对数据进行了修改,但是没有提交,此时A对这一条数据进行读取,此时read view中min_trx_id=50,max_trx_id=71,creator_trx_id=60,此时进行读取发现50<70<71,并且70在m_ids列表中,因此属于未提交的事务,进而数据不可读取。如果B进行提交,此时read view不变,但是读取的时候发现70不在m_ids里面,此时还是可以读取。如果这条数据的trx_id>max_trx_id,那么一定不可读取,说明在此事务之后有新的事务对数据进行了修改,一定不可修改,无论是在RC还是RR级别。
img_13.png
RR级别下如果发现某条数据的ID介于min_trx_id,max_trx_id之间,那么数据一定不可读,只有trx_id<min_trx_id严格满足才能对数据进行读取。
MySQL的锁和事务隔离级别?
MySQL的锁按照粒度可以分为全局锁,表锁,页锁,行锁,gap锁。其中全局锁只允许对数据库进行读取操作,不允许进行修改,多用于数据库备份的过程,此时外界无法对数据进行修改,属于数据库层面的锁机制。表锁适用于读多写少的场景,锁的粒度大发生变化冲突的概率高。行锁的开销大,加锁的速度慢,且innoDB的行锁是基于索引实现的,要想锁住数据之前必须命中索引,但是优点是并发量大。gap锁属于行锁的其中一种实现算法,是对索引的间隙的数据进行上锁,常用于RR级别。如果按照锁的加锁机制还可以区分为乐观锁(开发人员自己实现),悲观锁(数据库层面自己实现)。同时还可以区分为共享锁和排它锁,其中某个事务对某条数据使用共享锁之后,其他的事务可以继续上共享锁,但是不能添加排它锁。如果获得排它锁之后,其他的事务不能再加任何的锁。
所有情况下,普通的select * from table;
不会上锁,试想一下,如果连最基本的查询都上锁,那么数据库的并发能力会急剧下降。只有在诸如update;insert;delete
对数据进行更新的时候innoDB引擎才会上锁,或者是使用select * from table in shared mode;
或者select * from table for update
自己进行显示上锁,前者是使用共享锁,后者是使用排它锁。也就是数据库事务中,任何隔离级别下,普通读取(select)都不会上任何锁,对于数据的写入都会使用排它锁,不同是在读取的时候使用MVCC和锁的方式不一样。
MVCC(Multiversion Concurrency Control)-多版本并发控制
正如上面所提到的,加上表级共享锁可以解决幻读问题,但是并发能力会下降。因此MVCC的诞生就是为了解决在不损失数据库读取并发能力的条件下,又能够解决幻读问题。当然这里使用MVCC解决的是快照读而不是当前读。
当前读
当前读就是字面意思,读取的数据是数据的最新版本,会对当前最新的数据加上共享锁,以下查询都属于当前读:
- select * from table where id=1 lock in share mode; # 手动加共享锁
- select * from table where id=1 for update # 加排他锁
- update table column_name1=name1,column_name2=name2 where id=1; # 更新语句,自动加排他锁
- insert into table (column_name1,column_name2) VALUES (VALUE1, VALUE2); # 插入语句,,自动加排他锁
- delete from table where id=1; # 插入语句,自动加排他锁
快照读
由于数据存在多个版本,因此快照读顾名思义就是读取到的数据是历史数据,默认的select(不加for update以及lock in share mode)就是读取快照读。
MVCC的实现
MVCC的实现是基于版本链,undo日志,Read View实现的。
- 版本链:数据库中的每行数据,除了我们肉眼看见的数据,还有几个隐藏字段,分别是db_trx_id、db_roll_pointer、db_row_id(row_id不一定每个表都有,表中有主键或者非NULL的唯一键时都不会包含row_id列)。
- trx_id:记录了最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID。
- roll_pointer:回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
每次数据有所更新,都会在undo log当中写入一条记录,同时这条记录当中也有roll_pointer字段来连接之前的记录。每条记录都有自己的事务ID,由同一事务的多条log连接的链表就是版本链,同时版本链的最前端是最近更新的记录。
- undo log:可以在回滚的时候直接回复数据,可以保证原子性和一致性。同时在使用快照读读取历史数据的时候快速的读取相应版本号的数据。undo log可以分为两种,第一种是insert undo log,在插入数据时生成,回滚之后可以日志内容直接清除。另一种是update undo log,对数据更新的时候产生,只有数据回滚或者快照读的时候不涉及到该日志的时候才能进行清除。
- Read View:在该事务执行的快照读的那一刻(是快照读的前一瞬间,不是事务开始的时候),会生成数据库系统当前的一个快照(可以理解为一个数据结构)。 记录并维护系统当前活跃事务的ID(没有commit,当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以越新的事务,ID值越大),是系统中当前不应该被本事务看到的其他事务id列表。 之后本事务的所有读操作根据事务ID(即trx_id)与快照中记录下来的中的trx_sys的状态作比较,以此判断read view对于事务的可见性。Read View中的主要记录的属性有:
- trx_ids: 当前系统活跃(未提交)事务版本号集合。
- low_limit_id: 创建当前read view 时“当前系统最大事务版本号+1”。
- up_limit_id: 创建当前read view 时“系统正处于活跃事务最小版本号”。
- creator_trx_id: 创建当前read view的事务版本号;
- trx_id < up_limit_id || trx_id = creator_trx_id,当前数据可以被读取。也就是说明本快照产生时候(数据读取的时候)是在其他所有活跃事务开始之前的,那么自然读取到的数据是没有被修改的,也就是可见的。例如开启快照的时候,本事务的id为40,其他事务的ID有50,60,70,那么说明,50,60,70事务对数据的修改是在之后发生的。此外
trx_id == creator_trx_id
表示如果查询到的数据属于自己修改的那么也是可见的。 - trx_id >= low_limit_id,那么数据不可被读。
- 如果介于两个之间,那么依据隔离界别确定:
- Read Committed级别下如果要读取的数据还存在于活跃事务列表中,表示该数据行快照对应的事务还未提交,则该快照不可使用。否则表示已经提交,可以使用。
- Repeatable Read级别下无论如何都不可读。
Read View在RR和RC级别下生成
- RC隔离级别下,是每个快照读都会生成并获取最新的Read View;
- 而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View,之后的查询就不会重复生成了,所以一个事务的查询结果每次都是一样的。
总结
总结一下就是,MVCC解决了幻读的问题吗?严谨来说并没有解决,MVCC利用版本链,undo log,Read View可以在快照读模式下解决幻读问题,并且不用加锁解决读写冲突问题,极大的增加了数据库的并发量。另一方面,在当前读的模式下,仅仅依靠MVCC不能解决幻读问题,必须依赖next-key锁(行锁+gap锁)来解决,这是因为当前读必须获取最新数据。而行锁是把符合条件的数据上锁,放置update,delete操作,间隙所则是把符合条件的附近区间锁住,解决insert插入,即可解决欢度问题。