第25章 工作面试老大难-锁
解决并发事务带来问题的两种基本方式#
上一章介绍了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况大致可以划分为3种:
读-读情况:即并发事务相继读取相同的记录。
读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
写-写情况:即并发事务相继对相同的记录做出改动。
我们前面说过,在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比方说事务T1要对这条记录做改动,就需要生成一个锁结构与之关联:

其实在锁结构里有很多信息,不过为了简化理解,我们现在只把两个比较重要的属性拿了出来: - trx信息:代表这个锁结构是哪个事务生成的。 - is_waiting:代表当前事务是否在等待。
如图所示,当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁,画个图表示就是这样:

在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果图就是这样:

我们总结一下后续内容中可能用到的几种说法,以免大家混淆:
+
不加锁
意思就是不需要在内存中生成对应的锁结构,可以直接执行操作。
+
获取锁成功,或者加锁成功
意思就是在内存中生成了对应的锁结构,而且锁结构的is_waiting属性为false,也就是事务可以继续执行操作。
+
获取锁失败,或者加锁失败,或者没有获取到锁
意思就是在内存中生成了对应的锁结构,不过锁结构的is_waiting属性为true,也就是事务需要等待,不可以继续执行操作。
小贴士:这里只是对锁结构做了一个非常简单的描述,我们后边会详细介绍介绍锁结构的,稍安勿躁。
读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。
我们前面说过,这种情况下可能发生脏读、不可重复读、幻读的问题。
小贴士:幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的,这一点要注意一下。
SQL标准规定不同隔离级别下可能发生的问题不一样: - 在READ UNCOMMITTED隔离级别下,脏读、不可重复读、幻读都可能发生。 - 在READ COMMITTED隔离级别下,不可重复读、幻读可能发生,脏读不可以发生。 - 在REPEATABLE READ隔离级别下,幻读可能发生,脏读和不可重复读不可以发生。 - 在SERIALIZABLE隔离级别下,上述问题都不可以发生。
不过各个数据库厂商对SQL标准的支持都可能不一样,与SQL标准不同的一点就是,MySQL在REPEATABLE READ隔离级别实际上就已经解决了幻读问题。
怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:
+
方案一:读操作利用多版本并发控制(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,这样也就避免了不可重复读和幻读的问题。
+
方案二:读、写操作都采用加锁的方式。