MySQL锁问题,事务隔离级别

时间:2022-10-17 18:05:43

未完待续...

概述

这里专门指的是InnoDB存储引擎的锁问题和事务隔离级别。

=========================================================

锁问题现象

即并发问题,由于数据库的并发处理机制,带来的一些问题,如果是串行处理,则不会有这些问题。一般会有如脏读,不可重复读,幻读,丢失更新等锁问题。这些问题是否会发生,和当前的事务隔离级别有关系(后面会讨论)。现在,先让我们抛开事务隔离级别和引起锁问题的原因等因素,单纯的看下什么是锁问题?各种锁问题发生时的现象是咋样的?

锁问题和并发有关,因此,发生锁问题时,必定是多个(至少两个)事务因为访问的数据集产生了交集,而如果是只有一个事务,或者多个事务之间访问的数据集没有交集,那么就不会存在锁问题。脏读,不可重复读,幻读这三种锁问题一般是发生在一个事务读,另一个事务写(插入,更新,删除)的场景,而丢失更新一般发生在两事务都在更新的场景。

脏读(Dirty Read)
      一个事务读取到了另外一个事务中未提交的数据。脏读,从字面意思就能想到,首先要有脏的数据,且事务读取到了这部分脏的数据。什么是脏的数据呢,那种更新了(包括删除,插入),但未提交的数据就是脏数据。如果事务A对某个数据集(比如一条数据)做了更新,但是还没提交(还没有定下来最终结果,可以理解是临时结果),本来,这种未提交的数据不应该让其他事务比如事务B看到的,只有自己(事务A)才能看到,因为对于事务B来说,这修改后的数据是脏的,但是如果事务B能看到,那么意思就是事务B读取到了脏的数据,即发生了脏读。以下两张图分别测试脏读发生时,和没有发生脏读(如上面所说,相同的场景,因为隔离级别不同,锁问题会不一样)的情况,左边的事务类似刚才提到的事务A,右边的事务类似事务B(如果没有加begin,则默认是自动打开事务,并自动提交)。

MySQL锁问题,事务隔离级别

事务A先通过begin手动开启一个事务,且将id=8的这条记录的name从7修改为6,但是此时事务A并未提交,因此对于事务B来说,id=8的这条记录修改后的值(name=6)是脏的数据,事务B如果要读取本应该读取到的是修改前的值,即name=7,但事务B却读取到的值为6,即发生了脏读。

MySQL锁问题,事务隔离级别

   修改隔离级别后,进行相同的操作,没有发生脏读的情况。事务A将name从6修改为5,且并未提交,此时事务B读取到的仍然是修改前的值,即读取到的name值为6。

不可重复读
      不可重复读,从字面意思理解需要有两者对比才会有所谓重复的概念。不可重复读专指一个事务内,对同一个数据集读取的结果不一样,造成了两次或多次读取的结果是不可以重复的。这里需要注意两点:1,是同一个事务内的两次或者多次读取结果的对比,而不是不同事务读取结果的对比(比如在上面提到的在事务A中做两次或多次的读取,而不是一次在事务A中读取,另外一次在事务B中读取);2,两地或多次读取的数据集要相同(比如在上面提到的,两地或多次都读取id=8的记录,而不是一次地区id=8的记录,另外一次读取id=7的记录)。以下两张图分别测试不可重复读发生时,和没有发生(如上面所说,相同的场景,因为隔离级别不同,锁问题会不一样)的情况,左边的事务类似刚才提到的事务A,右边的事务类似事务B(如果没有加begin,则默认是自动打开事务,并自动提交)。

MySQL锁问题,事务隔离级别

事务A先通过begin手动开启一个事务,然后读取到id=8的记录的name值为5,然后等待一下,且不提交事务。此时再通过事务B将id=8的记录name值从5修改为4,并且提交(注意,这里需要提交),然后再切换到事务A,重新做一下和上次一样的查询,即,查询id=8(相同数据集)的记录,此时查询出的name变成了4,即,在事务A的两次相同的查询,结果不一样,发生了不可重复读问题。

MySQL锁问题,事务隔离级别

修改隔离级别后,进行相同的操作,没有发生不可重复读的问题。事务A的两次读取的name值都一样,没有受到其他事务(事务B)修改的影响。

丢失更新
      一个事务的更新操作被另外一个事务的更新操作覆盖,从而导致数据不一致。在数据库操作层面,并不会出现丢失更新的问题,因为丢失更新发生的条件是对相同数据集做更新操作,而两个事务的更新操作都需要获取X锁,因此其中一个一定会被阻塞,直到另外一个操作完成,实现更新操作的串行化,就不会存在丢失更新的问题。

丢失更新严格意义上并不算是锁问题(个人观点),而是应用代码bug在数据库上面的表现。典型的场景是两个应用并发的读取一个记录,然后将值保存在应用的内存中,然后都基于该值做计算,并更新到数据库,导致后面更新的把前面更新的覆盖了。之所以说这是代码bug,是因为,其中一个应用可能会基于一个错误的值进行计算,如,应用1读取一个值,然后基于该值计算,但是,在计算后的值最终更新到数据库前,另外一个应用已经将数据库的值做了更新,这样其实是应用1是基于一个错误的值的基础上做的计算。说的有点抽象,具体的例子到网上找个,然后结合这里的描述分析下(因为这个例子不好在数据库层面演示,所以例子就不写了,具体的场景也到网上找下)。

为了在应用层解决这个问题,从上面的分析可以看出,是在应用在读取数据,到最终将数据更新到过程中,数据被其他事务“污染”了,导致读取到内存中的数据,和数据库中的数据不一致。那么解决的办法也很明显,只要保证读取到内存的数据和数据库中的数据一致,也就是从读取开始,就将数据库中对应的行锁住,不让其他任何事务更新,直到计算后,将数据最终更新到数据库。

自然会想到,两个事务在读取时,都显示的使用X锁,即使用select ... for update; 这样,保证两个事务是完全串行话的。这里不能使用select ... lock in share mode; 会产生死锁,即使不会出现死锁,也会出现丢失更新,并不能解决问题,原因和上面一样。

丢失更新是比较容易忽视的bug,比较容易犯的错误,需要特别注意。

未完待续...

=========================================================

查看事务隔离级别

和其他参数一样,隔离级别也有范围,即,session,global,系统。查看的方式分别是:
      1,查看当前session的隔离级别:SELECT @@tx_isolation;
      2,查看global的隔离级别:SELECT @@global.tx_isolation;
      3,查看系统的隔离级别:查看Mysql配置文件(如my.cnf)中,transaction-isolation的赋值(类似transaction-isolation = READ COMMITTED)。如果,没有transaction-isolation的赋值,则系统的隔离级别设置为默认,一般为REPEATABLE READ。

这三者的区别和作用同其他参数一样,具体看下面的设置事务隔离级别的说明。

=========================================================
设置事务隔离级别

如果不做任何设置,那么所有的隔离级别(即session,global,系统对应的隔离级别)都是默认的(一般是REPEATABLE READ)。例如,在我的机器上的msyql配置,没有做隔离级别相关的配置:

MySQL锁问题,事务隔离级别

重启下mysql(消除其他global设置可能的影响,即,重启后,global设置重置为系统配置),然后新打开一个session,查看session和global的隔离级别:

MySQL锁问题,事务隔离级别

可以看到,session和global的隔离级别都是“REPEATABLE READ”,这也是当前系统的默认级别。

设置系统隔离级别

如上所述,设置系统的隔离级别需要修改配置文件,然后重启系统(修改后必须重启才生效),如下,将系统的隔离级别从默认的REPEATABLE READ,设置为READ COMMITTED:

MySQL锁问题,事务隔离级别

重启下,然后再重新打开一个session,查看session和global的隔离级别:

MySQL锁问题,事务隔离级别

从中可以看出,隔离级别由原来默认的REPEATABLE READ变为READ COMMITTED(和系统设置的一样)。

结论:在global和session未对隔离级别做任何设置的情况下,session和global的隔离级别都和系统隔离级别一样。

设置session隔离级别

设置session的隔离级别的命令为:SET SESSION TRANSACTION ISOLATION LEVEL 级别名;
     
我们在设置系统隔离级别中,将系统的隔离级别设置为READ COMMITTED,下面设置session的隔离级别(设置为REPEATABLE READ),并查看设置后的session隔离级别,和global的隔离级别:

MySQL锁问题,事务隔离级别

可以看出,设置后,session的隔离级别变成了REPEATABLE READ,但global的隔离级别仍然为READ COMMITTED(和系统的隔离级别一样)。从新打开一个session,看其他新的session的隔离级别:

MySQL锁问题,事务隔离级别

这是一个新打开的session(和上面的不是同一个session),从中可以看出,新session的隔离级别和系统的隔离级别一样都是READ COMMITTED,并未因为第一个session的设置,而变成和第一个session一样的隔离级别。

结论:session设置隔离级别,会改变当前sesssion的隔离级别,但不会改变global的隔离级别,也不会改变其他已存在或新打开session的隔离级别。

设置global隔离级别:
      设置global的隔离级别的命令为:SET GLOBAL TRANSACTION ISOLATION LEVEL 级别名;

将global的隔离级别设置为REPEATABLE READ,查看当前session的隔离级别,可以看到,仍然为READ COMMITTED,而global的隔离级别已经变成了REPEATABLE READ。

MySQL锁问题,事务隔离级别

再重新开个一session,查看该session的隔离级别,可以看到新的session的隔离级别和设置后的global的隔离级别一样。

MySQL锁问题,事务隔离级别

结论:global设置隔离级别,改变global的隔离级别和新打开session的隔离级别,但不改变当前sesssion的隔离级别和已经存在的session(即,在设置global的隔离级别前已经存在的session,这种测试没有做,实际和当前session的情况是一样的,因为当前session就是在global设置前就已经存在的)的隔离级别。

=========================================================

锁阻塞

因为锁的原因导致的阻塞,即,两个事务在并发执行时,每个事务都需要先获取某个锁,且这两个锁存在不兼容情况,这时,其中一个后获取锁的事务,因为需要获取的锁和另一个事务已经获取到的锁存在不兼容,便会进入锁阻塞等待,等待前一个事务释放了锁。

这里发生阻塞的必要条件:1,两个事务在访问时,都必须需要获取锁(什么操作,即sql语句需要获取锁,什么sql不需要获取锁,后面会分析),如果一个事务操作需要获取锁,另外一个事务不需要获取锁,或者两个事务都不需要获取锁,则不会产生阻塞;2,两把锁是不兼容的,两把锁是否兼容,又和锁的类型(排他锁,共享锁),以及锁定了哪部分数据集有关,这部分后面分析。

什么操作(sql)需要获取锁

     上文中提到,锁阻塞发生的必要条件之一是两个事务的操作(sql)必须都需要获取某把锁(先暂时不关心获取的锁对应的数据集,即获取哪把锁,因为是否阻塞和锁住的数据集也有关系),那么什么操作会需要获取锁呢,一般是:

1,更新操作(insert,update,delete等)会自动去获取所需要的锁;

2,select 语句后面加 lock in share mode(需要获取S锁)或者for update(需要获取X锁)

而如果是普通的select语句,是不需要获取任何锁的。

实例1:测试update等操作需要加锁

MySQL锁问题,事务隔离级别

左边事务(假设叫事务A)手动开启一个事务,并对id=8的记录做更新操作,且不提交事务,此时,事务A因为update操作,会自动获取X锁;接着右边事务(假设叫事务B)也对id=8的记录做更新操作,因为同样做的update操作,需要获取X锁,X锁和X锁不兼容,且操作的相同数据集,因此会导致事务B进入锁阻塞。

实例2:测试普通select操作不需要加任何锁

MySQL锁问题,事务隔离级别

左边事务(假设叫事务A)手动开启一个事务,并对id=8的记录做更新操作,且不提交事务,此时,事务A因为update操作,会自动获取X锁;接着右边事务(假设叫事务B)读取,

实例3:

MySQL锁问题,事务隔离级别

实例4:

MySQL锁问题,事务隔离级别

实例5:

MySQL锁问题,事务隔离级别