怎么解决脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:
-
方案一:读操作利用多版本并发控制(
MVCC
),写操作进行加锁
。MVCC
通过生成一个ReadView
,然后通过ReadView
找到符合条件的记录版本(历史版本是由undo日志
构建的),其实就像是在生成ReadView
的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView
之前已提交事务所做的更改,在生成ReadView
之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC
时,读-写
操作并不冲突。 -
小贴士: 我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。
方案二:读、写操作都采用加锁
的方式。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行加锁
操作,这样也就意味着读
操作和写
操作也像写-写
操作那样排队执行。
很明显,采用MVCC
方式的话,读-写
操作彼此并不冲突,性能更高,采用加锁
方式的话,读-写
操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC
来解决读-写
操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁
的方式执行,那也是没有办法的事。
一致性读(Consistent Reads)
事务利用MVCC
进行的读取操作称之为一致性读
,或者一致性无锁读
,有的地方也称之为快照读
。所有普通的SELECT
语句(plain SELECT
)在READ COMMITTED
、REPEATABLE READ
隔离级别下都算是一致性读。
锁定读(Locking Reads)
共享锁和排他锁
-
共享锁
,英文名:Shared Locks
,简称S锁
。在事务要读取一条记录时,需要先获取该记录的S锁
。 -
排他锁
,英文名:Exclusive Locks
,简称X锁
。在事务要改动一条记录时,需要先获取该记录的X锁
。
读操作
对读取的记录加S锁
: 允许别的事务继续获取这些记录的S锁
(可以让别的事务来读这些记录),但是不能获取这些记录的X锁别的事务不能来修改这些记录)
。如果别的事务想要获取这些记录的X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁
释放掉。
对读取的记录加X锁
:为读取到的记录加X锁
,这样既不允许别的事务获取这些记录的S锁
,也不允许获取这些记录的X锁
。如果别的事务想要获取这些记录的S锁
或者X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁
释放掉。
写操作
-
DELETE
:对一条记录做
DELETE
操作的过程其实是先在B+
树中定位到这条记录的位置,然后获取一下这条记录的X锁
,然后再执行delete mark
操作(再由purge线程决定何时删除)。我们也可以把这个定位待删除记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
。 -
INSERT
:一般情况下,新插入一条记录的操作并不加锁,设计
InnoDB
的大叔通过一种称之为隐式锁
的东东来保护这条新插入的记录在本事务提交前不被别的事务访问
-
UPDATE
:在对一条记录做
UPDATE
操作时分为三种情况:-
如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在
B+
树中定位到这条记录的位置,然后再获取一下记录的X锁
,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
。 -
如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在
B+
树中定位到这条记录的位置,然后获取一下记录的X锁
,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
,新插入的记录由INSERT
操作提供的隐式锁
进行保护。 -
如果修改了该记录的键值,则相当于在原记录上做
DELETE
操作之后再来一次INSERT
操作,加锁操作就需要按照DELETE
和INSERT
的规则进行了。
-
一个事务也可以在表
级别进行加锁,自然就被称之为表级锁
或者表锁
,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。
-
意向共享锁,简称
IS锁
。当事务准备在某条记录上加S锁
时,需要先在表级别加一个IS锁
。 -
意向独占锁,简称
IX锁
。当事务准备在某条记录上加X锁
时,需要先在表级别加一个IX锁
。
对于MyISAM
、MEMORY
、MERGE
这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。
InnoDB
存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制
行锁类型
Record Locks
:
仅仅把一条记录锁上
Gap Locks
:
MySQL
在REPEATABLE READ
隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC
方案解决,也可以采用加锁
方案解决。但是在使用加锁
方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上Record Locks
。不过这难不倒设计InnoDB
的大叔,他们提出了一种称之为Gap Locks
的锁,官方的类型名称为:LOCK_GAP
,我们也可以简称为gap锁
。比方说我们把number
值为8
的那条记录加一个gap锁
的示意图如下:
如图中为number
值为8
的记录加了gap锁
,意味着不允许别的事务在number
值为8
的记录前边的间隙
插入新记录,其实就是number
列的值(3, 8)
这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number
值为4
的新记录,它定位到该条新记录的下一条记录的number
值为8,而这条记录上又有一个gap锁
,所以就会阻塞插入操作,直到拥有这个gap锁
的事务提交了之后,number
列的值在区间(3, 8)
中的新记录才可以被插入。
这个gap锁
的提出仅仅是为了防止插入幻影记录而提出的,如果你对一条记录加了gap锁
,并不会限制其他事务对这条记录加正经记录锁
或者继续加gap锁
,再强调一遍,gap锁
的作用仅仅是为了防止插入幻影记录的而已。
给一条记录加了gap锁
只是不允许其他事务往这条记录前边的间隙插入新记录。
Next-Key Locks
既想锁住某条记录,又想阻止其他事务在该记录前边的间隙
插入新记录
Insert Intention Locks
:
即插入意向锁,定义:事务在等待的时候也需要在内存中生成一个锁结构
,表明有事务想在某个间隙
中插入新记录,但是现在在等待。
隐式锁
一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id
这个牛逼的东东的存在,相当于加了一个隐式锁
。别的事务在对这条记录加S锁
或者X锁
时,由于隐式锁
的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
InnoDB锁的内存结构
锁结构