事务的隔离性是由锁来实现的。
1. 概述
锁是计算机协调多个进程或线程并发访问某一资源的机制。在程序开发中会存在很多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(如订单,金额等),我们就需要保证这个数据在任何时刻最多只有一个线程访问,保证数据的完整性和一致性。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域同样重要。
在数据库中,除传统的计算资源(如CPU,RAM,I/O等)的争用外,数据也是一种供用户共享的资源。为了保证数据的一致性,需要对并发操作进行控制,因此产生了锁。同时锁机制也为实现MySQL的各个隔离级别分别提供了保证。锁冲突也是影响数据库的并发访问性能的一个重要因素。所以锁对数据库而言显得尤为重要,也会更加复杂。
2. MySQL并发事务访问相同记录
并发事务访问相同记录的情况大致分为三种:读-读,写-写,读-写/写-读。
2.1 读-读情况
读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况发生。
2.2 写-写情况
该种情况,即并发事务相继对相同的记录做出改动。
在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种情况的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本身是没有锁的,也就是说一开始是没有锁结构和记录进行关联的。如图:
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有时就会在内存中生成一个锁结构与之关联。比如,事务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,也就是事务需要等待,不可以继续执行操作。
2.3 读-写或写-读情况
读-写或写-读情况,即一个事务进行读取操作,另一个进行改动操作。这种情况可能发生脏读,不可重复读,幻读的问题。
各个数据库厂商对sql标准的支持力度不一样,比如MySQL在REPEATABLE READ隔离级别上已经解决了幻读的问题。
2.4 并发问题的解决方案
怎么解决脏读,不可重复读,幻读这些问题呢?其实有两种可选的解决方案。
方案一:读操作利用多版本并发控制MVCC,写操作进行加锁。、
所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能读到在生成ReadView之前已经提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用了MVCC,读-写操作并不冲突。
普通的select语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。
- 在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行select操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象。
- 在REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行select操作才会生成一个ReadView,之后的select操作都复用这个ReadView,这样就避免了不可重复读和幻读的问题。
方案2:读-写操作都采用加锁的方式。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写入数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也意味着读操作和写操作也像写写操作那样排队执行。
- 脏读的产生是因为当前事务读取了另一个未提交的事务写的一条记录。如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读的问题产生。
- 不可重复读的产生是因为当前事务先读取一条记录,另一个事务对该记录做了改动后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生可重复读。
- 幻读的问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围的事务插入了一些新记录,当前事务再次读取该范围的记录的时候发现了新插入的记录。采用加锁的方式解决幻读问题有一定的麻烦,因为当前事务在第一次读取记录时的幻影记录并不存在,所以读取的时候加锁有点尴尬。
一般情况下,我们当然愿意采用MVCC来解决读-写操作并发执行问题,但业务在某些特殊情况下,要求必须采用加锁的方式执行。