前言

MySQL是现在非常主流的开源数据库软件,在大多数公司的OLAP(on-line transaction processing)系统中作为RDS(Relation Database Server)。

对MySQL熟悉的肯定知道MySQL在保证并发条件下数据的准确性,增加了事务、锁。而且还引入MVCC提高MySQL对于多并发的读写操作效率。之前文章介绍了MySQL的事务的四种隔离级别(读未提交,读已提交,可重复读,串行化)。但是没有具体分析MySQL在可重复读的情况下是如何读和写。下面我们具体分析一下MySQL如何保证隔离级别和隔离级别如何实现,主要介绍锁和MVCC模式。

锁这个概念大家都应该是熟悉的,一般解决并发和资源共享问题都会用锁来解决。将共享资源用锁锁住,其他的四事务就不能使用,只能当前事务使用。(使用锁也会造成死锁问题,这个是我们后面要讨论的)现在我们先把锁和事务关系搞清楚,什么时候会用什么样的锁,锁是怎么加上的。锁是存在存储引擎层面。

事务并发情况

一般锁在事务并发情况下出现,对于MySQL数据只有两种操作读和写,那么按照排列组合,多事务(两个事务)并发就会有四种情况,读-读、读-写、写-读、写-写。

读-读模式,一般不会有太大问题,因为没有数据修改,不会出现,脏读、幻读、不可重复读问题。

写-写模式,一般也不会出现问题,因为在在数据库存都有唯一键。这种根据唯一键更新时候,一般都是顺序进行。不过也有可能出现脏写(后面的内容被前面的覆盖),为了保证这个不出现藏写。MySQL引入写锁的概念,与之对应的还有读锁。读锁和写锁是逻辑的概念,不代表MySQL在实现时候就会有写锁和读锁。

写-读模式,读-写模式这种并发情况下会出现各种各样的问题。比如脏读、重复读、幻读,这种是根据数据库设置的事务隔离级别去确定的。

隔离级别 脏读(Dirty Read) 不可重复读(Non-Repeatable Read 幻读(Phantom Read)
读未提交(READ UNCOMMITTED) 可能 可能 可能
读已提交(READ COMMITTED) 不可能 可能 可能
可重复读(REPEATABLE READ) 不可能 不可能 可能
串行化(SERIALIZABLE) 不可能 不可能 不可能

隔离级别(MySQL/InnoDB)

隔离级别:指事务之间隔离程度,隔离程度越高并发性能越低。事务有隔离性,隔离性我的理解就是事务之间相互影响程度。(隔离,从某种程度讲和新冠病毒的隔离类似,只不过那个隔离个体是人,数据库里的隔离个体是事务,隔离可以减少相互影响)大多数数据库不会使用串行化的模式(每个事务进行严格的隔离,读和写都进行锁操作),在性能与数据准确性做抉择,创造了事务的隔离级别,保证在数据准确的情况下,提升系统的性能。MySQL种事务的提交通过begin和commit这两个关键字去实现。如下代码

1
2
3
4
5
6
begin
select * from student where name = 'xxxx';
update student set age = xx where name = 'xxxx';
insert into student_score (id,name,score) values(x,'xxx',xx);
delete from student_temp_score where name = 'xxxx';
commit;

读未提交(RU):一个事务还没提交时,它做的变更就能被别的事务看到。(在A没有提交时候,事务B就可以读取到A事务更新或者插入的数据。A可能在提交过程报错,但是B还是可以读取A事务写入的脏数据,因为每次都读最新的数据,没有快照和历史数据)能看出来A事务内部出错或者再次更新,会对B事务读取数据有影响。

读已提交(RC):一个事务提交之后,它做的变更才会被其他事务看到。(在A事务提交后,B事务才能获取A事务更改数据内容。这种防止了读取脏数据,在A事务提交前B事务读的都是快照里面的数据,准确点说是ReadView里的数据。但是当B事务是一个长事务,当A事务开始前读取数据,A事务提交后B事务再次读取数据即重复读出现问题,这个时候在次读取数据时候会导致数据不一致。因为在RC级别的ReadView是每次select的时候生成的)

可重复读(RR):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。(在B长事务中,A事务在读已提交,B事务在重复读取A事务处理数据时候会出现重复读不一致问题。为了解决这种问题RR级别的ReadView是在第一次select生成,后续都只使用这个ReadView)

但在使用行级锁还会有另外一个问题,即我们可重复读针对的是update和delete操作,但对于A事务的insert操作没有很好的处理。当B事务在执行时候,A事务不与B事务有相同的行级锁重复,但是会在B事务操作的数据后面插入数据,同时在B事务中又存在计数返回,那么就会导致B事务之前获取的计数和A事务执行后的计数不一样。导致幻读。幻读针对是insert,在传统理论会推荐使用串行化的隔离级别,但是在InnoDB使用了间隙锁来解决insert数据问题,在加行锁的同时会在数据上增加间隙锁,防止其他事务插入引起幻读。

串行化(SERIALIZABLE):写会加写锁,读会加读锁。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。(悲观锁形式,获取锁才能进行修改,这种性能很差,几乎没有数据库使用这种隔离级别)

上面关于ReadView的内容是在MVCC里的内容,这里先铺垫一下,让后面更好理解MVCC的作用。

二阶加锁(2PL)

传统的RDBMS加锁原则是2PL(两阶加锁)Two-Phase Locking。将锁操作分成两个阶段,加锁阶段和解锁阶段,而且加锁和解锁不相交。

加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。

解锁阶段:当事务提交后,事务进行解锁阶段,在该阶段只能解锁不能加锁。

事务 加锁/解锁处理 阶段
begin;
insert into 加insert对应的X锁 加锁
update … set 加update对应的X锁 加锁
delete from 加delete对应的X锁 加锁
commit; 事务提交时,同时释放insert、update、delete对应的锁 解锁

这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化(串行化很重要,尤其是在数据恢复和备份的时候)

读锁

读锁:共享锁,S锁(Shared)针对同一份数据,多个读操作可以同时进行而不会互相影响。

写锁

写锁:排它锁,X锁(eXclusive)当前写操作没有完成前,它会阻断其他写锁和读锁。

读锁和写锁都是属于悲观锁,读锁和写锁是按照数据库操作行为。

表锁

表锁:即针对MySQL表级的锁,作用域是数据库的表,当锁定整个表。表锁也读锁和写锁。

表锁共享锁:不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求。

表锁独占锁:会阻塞其他用户对同一表的读和写操作。

行锁

行锁:对行数据进行锁定,作用域是单个或多条数据。一般都是根据索引去处理的。一般来讲是锁主键索引,如果主键索引不能实现,会使用二级索引进行锁定。(InnoDB对于update、insert、delete使用的是排他行级锁即X锁,而在select时候不会使用锁)

MyISAM存储引擎使用的是独占表锁,当查询时候会锁住整个表,不允许修改。InnoDB大多数况是行锁,当更新走不到索引,全表扫码时候行锁会退化成表锁。

间隙锁(GAP锁)

间隙锁:在符合范围的数据中间加锁,GAP锁的主要作用是防止幻读。这个在隔离级别时候我们也说到了。

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是是所谓的间隙锁(GAP锁)。

Next-key锁:Next-key锁就是行锁+间隙锁的合并。

意向锁

意向锁:事务在请求行锁中的共享锁和排他锁前,会先自动获得意向锁。意向锁是InnoDB主动增加的,不需要用户介入。意向锁分两种意向共享锁,意向排他锁,在获取共享锁和排他锁之前提前获取意向锁。

多版本并发控制 MVCC

说多版本并发控制前,还是先说悲观锁和乐观锁的问题,从某个角度讲MVCC就是一种乐观锁的实现形式,即引入版本对对部分数据加锁,提高性能。

悲观锁

悲观锁:顾名思义,就是对外部事务操作比较悲观,每次都会为数据上加锁。事务获取到锁才可以继续操作,获取不到锁的持续等待。锁保证数据访问的排他性。在悲观锁的情况下,为了保证事务隔离性,就需要一致性锁定读,即读取时加锁,其他事务操作无法修改。删除修改时也要加锁,其他事务无法读取数据。(这种串行化的隔离级别时常见的)

乐观锁

乐观锁:相对悲观锁,乐观锁采取更加宽松的锁机制。大多数情况下不加锁,通过版本控制去管理。悲观锁大多数是靠数据库的锁机制实现,但随之而来就是对性能的开销很大,因为读取和写入都进行了上锁操作,这种开销对大事务而言时间会无法接受。

上面说到乐观锁通过版本记录机制去实现。在数据上增加version的标识,在读取数据时候,将版本号一起带出来。更新时候将版本号+1。在提交时候,将提交版本号与数据库存储的版本做对比,如果数据版本号大于当前数据库存储的版本号说明是新操作,可以更新数据。否则认为操作的是过期数据。

MVCC(多版本并发控制)本质上也是一种乐观锁,是数据库应对并发和性能问题的一种解决实现方案。不同的存储引擎和数据库都有具体的实现方式。本片文章主要使用的是InnoDB的存储引擎去分析。只有RC和RR级别才有MVCC。

快照读

快照读:读取的是记录的可见版本 (有可能是历史版本),不用加锁。一般用于select语句

当前读

当前读:当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。一般用于update 、delete、insert、select…for update \select ….lock in share mode.

update语句为什么是当前读

update语句在执行的时候会先去查询存储引擎,然后对数据加上行锁,等更新完成,提交事务才会解除锁,所以update是当前读,不是快照读,如果快照独会发生数据不存在的问题。delete语句与update语句类似。update的本质也是删除,在插入。

insert语句为什么是当前读

insert语句本身在插入的时候会进行Unique Key冲突校验,所以也会查询一下最新的数据,看是否重复。所以insert也是当前读

快照读和当前读都是针对MVCC模式。一般在RR和RC级别都会存在快照读。只不过RC和RR读出的数据策略是不一样的。后续讨论MVCC也主要是讨论怎么去查询,关于加锁和死锁的讨论后面单独发一篇文章去写。

undo Log

undo log是逻辑日志,将数据库的逻辑恢复到原来的样子,所有insert语句都会记录一条反向的日志操作delete语句,update语句会对应反向的update语句,delete语句会对应一条insert语句。undo log的作用主要有两个一个是提供原子性的支持(即回滚操作)和MVCC。

innodb存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log

数据版本链

MySQL是聚簇索引存储数据的,在聚簇索引中存在一个属性roll_pointer。这个roll_pointer会指向undo log中的数据,即反向逻辑的操作。当更新或插入一条数据操作,就会把新数据放到聚簇索引,把回滚逻辑放到undo log。这样多操作几次就形成了一条由roll_pointer链接的链表(这个是一个单向链表),这个链表就是数据版本链。存在很多版本,不过版本链也不会无限边长,当有些数据永远不会被回滚时候就会被清空掉(有点像垃圾回收根据可达性算法去清除垃圾)这种操作叫purge。

聚簇索引上还存在一个字段是trx_id,这个trx_id是事务Id,对应每次对数据操作的事务,当事务发生异常要回滚的时候就是通过trx_id找到这个版本之前的数据,通过undo log进行数据的恢复,保证数据库的原子性。

ReadView

上面说了那么多关于数据库的存储和查询内容,现在来说一下MVCC的核心也就ReadView。ReadView从名字上来看是一个视图,在RC和RR级别利用(consistent read view)一致读视图方式支持的。

所谓一致性视图就是在某一时刻给事务系统(trx_sys)打一个快照(snapshot),把当时事务系统状态(包括活跃读写事务数组)记下来,之后的所有读操作根据其事务ID(即trx_id)与快照(snapshot)中的事务系统的状态作比较,以此判断read view对于事务的可见性。

ReadView的组成

ReadView是主要由m_ids,min_trx_id,max_trx_id,creator_trx_id这四部分组成。

m_ids:活跃事务列表的Id,表示在生成ReadView时候当前系统活跃的事务id列表。

min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。如果被访问版本的trx_id属性值小于ReadView中的 min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。

creator_trx_id:生成ReadView的事务Id。如果被访问版本的trx_id属性值与ReadView中的 creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。 当trx_id大于max_trx_id表示ReadView之后生成的,所以该版本不可以被当前事务访问。

如果被访问版本的事务Id(trx_id)属性值在ReadView的min_trx_id 和max_trx_id之间,那就需要判断⼀下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明 创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

ReadView的使用过程

快照查询进入到存储引擎后,会获取当前查询的事务Id,就会根据当前ReadView的min_trx_id做对比(trx_id是自增的Id后生成事务Id大于前面的十事务Id),如果小于min_trx_id说明事务已经提交,ReadView的版本可以被查看。

如果trx_id等于creator_trx_id说明同一个事务。ReadView的版本可以被查看。

如果trx_id大于min_trx_id小于max_trx_id,同时trx_id存在m_ids中,说明该事务为活跃事务。ReadView的版本不可以被查看。需要到后续的undo log日志中去查找版本信息

如果trx_id大于max_trx_id表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务查看,需要到后续的undo log日志中查找版本信息。

RR和RC级别的ReadView的区别就在于,RC级别是每次查询(select)都会生成一个ReadView然后根据活跃列表和最小事务Id去判断。每次select生成的ReadView生成的最小事务Id都是不一样的,拿着事务Id去版本链里面去查找会找到不一样的数据。而RR级别的ReadView是在第一次查询后就一直复用ReadView,所以ReadView是不变的,当根据事务Id去查询版本链的时候会保证查询出来的结果一致(无论上面的事务有没有提交)。所以保证了可重复读。

在MVCC里面RC和RR的区别也就是ReadView的生成时间点不一样,导致一个是可重复读,一个保证不会脏读。

purge(清空)操作

MySQL在删除数据的时候并不是马上删除数据,而是为数据打上删除标志,然后把数据存在undo log上(事务已提交)。这个数据尽管是删除了,但是可能历史数据被其他事务共享或者重用,所以在查询数据库的时候依旧可以返回数据。(已删除的数据,RR级别)purge操作是对那些无法被共享和重用的数据进行删除。purge操作为了提高数据库的效率使用其他线程异步操作。

总结

MySQL是一个复杂的系统,这里只是简单介绍MVCC和事务隔离级别具体内容,之前介绍redo log和WAL机制,现在介绍锁与快照读的机制。从本质来讲MySQL都是在保证数据存储的正确性的前提下,提高自己的吞吐量和并发。从某个角度来讲我们在处理一些关于内存数据时候也可以借鉴这种快照模式,类似于DDD在实践Repository中链路check的时候使用快照的方法或者乐观锁的方法实践。

参考

《MySQL 是怎样运行的:从根儿上理解 MySQL》