25、MySQL 教程 - MySQL 锁家族:解决并发事务带来问题的两种方式

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的 资源。为保证数据的一致性,需要对 并发操作进行控制 ,因此产生了 锁 。同时 锁机制 也为实现MySQL 的各个隔离级别提供了保证。 锁冲突 也是影响数据库 并发访问性能 的一个重要因素。所以锁对数据库而 言显得尤其重要,也更加复杂。

并发访问相同记录的情况大致分为3种:

  • 读-读情况:即并发事务相继读取相同的记录 。读取操作本身不会对记录有任何影响,并不会引起什么 问题,所以允许这种情况的发生。
  • 写-写 情况:即并发事务相继对相同的记录做出改动。
  • 读-写 或 写-读 :即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读 、不可重复读、幻读的问题。

1. 写-写情况

在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务 相继对一条记录做改动时,需要让它们排队执行 ,这个排队的过程其实是通过 来实现的。这个所谓的锁其实是一个内存中的结构 ,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进行关联的,如图所示:
 
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构 ,当没有的时候 就会在内存中生成一个 锁结构 与之关联。比如,事务 T1 要对这条记录做改动,就需要生成一个 锁结构 与之关联:
 
trx信息:表示这个锁结构与哪个事务关联的。

is_waiting:表示当前事务是否在等待。

在事务T1改动这条记录前,就生成了一个锁结构与该记录关联。因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false。我们把这个场景称为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

在事务T1提交之前,另一个事务T2也想对该记录进行改动,那么T2先去看看有没有锁结构与这条记录关联。在发现有一个锁结构与之关联后,T2也生成了一个锁结构与这条记录关联。不过锁结构的is_waiting属性值为true,表示需要等待。我们把这个场景称为获取锁失败或者加锁失败。
 
事务T1提交之后,就会把它生成的锁结构释放掉,然后检测一下还有没有与该记录关联的锁结构。结果发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让T2继续执行。此时事务T2就算获取到锁了。
 

  • 不加锁:意思就是不需要在内存中生成对应的 锁结构 ,可以直接执行操作。
  • 获取锁成功,或者加锁成功:意思就是在内存中生成了对应的锁结构 ,而且锁结构的 is_waiting 属性为 false ,也就是事务 可以继续执行操作。
  • 获取锁失败,或者加锁失败:意思就是在内存中生成了对应的 锁结构 ,不过锁结构的 is_waiting 属性为 true ,也就是事务 需要等待,不可以继续执行操作。

2. 读-写或写-读情况

在读-写或写-读情况下会出现脏读、不可重复读、幻读的现象。

  • 在 read uncommitted 隔离级别下,脏读、不可重复读、幻读都可能发生。
  • 在 read committed 隔离级别下,不可重复读、幻读可能发生,脏读不可能发生。
  • 在 repeatable read 隔离级别下,幻读可能发生,脏读和不可重复读不可能发生。
  • 在 serializable 隔离级别下,上述现象都不可能发生。

那么怎么解决脏读 、 不可重复读 、 幻读这些问题呢?

  • 读操作利用多版本并发控制,写操作进行加锁 。
  • 读、写操作都采用加锁的方式。

2.1 方式1:读操作采用MVCC-写操作采用加锁

所谓的mvcc,就是生成一个 ReadView,通过 ReadView 找到符合条件的记录版本。查询语句只能读到 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所在的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用 mvcc 时,读-写操作并不冲突。

注意:

普通的select 语句在 read committed 和 repeatable read 隔离级别下会使用到 mvcc 读取记录。

在read committed 隔离级别下,一个事务在执行过程中每次执行 select 操作时都会生成一 个ReadView,ReadView 的存在本身就保证了 事务不可以读取到未提交的事务所做的更改 ,也就是避免了脏读现象;

在repeatable read 隔离级别下,一个事务在执行过程中只有第一次执行 select 操作 才会生成一个 ReadView,之后的 select 操作都复用 这个 ReadView,这样也就避免了不可重复读和幻读的问题。

2.2 方式2:读操作采用加锁-写操作也采用加锁

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存储事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作钠盐排队执行。

脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会产生脏读的问题了。

不可重复读的产生是因为当前事务先读取一条记录,另一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也就不会发生不可重复读了。

幻读现象的产生是因为某个事务读取了符合某条搜索条件的记录,之后别的事务又插入了符合相同搜素条件的新记录,导致该事务再次读取相同搜索条件的记录时,可以读到别的事务插入的新记录,这些新插入的记录就称为幻影记录。

幻读的问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新纪录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取事务时幻影记录并不存在,所以读取的时候加锁就有点尴尬,因为你不知道给谁加锁。

总结:

采用MVCC 方式的话, 读-写 操作彼此并不冲突, 性能更高 。 采用 加锁 方式的话, 读-写操作彼此需要排队执行 ,影响性能。 一般情况下我们当然愿意采用 MVCC 来解决 读-写 操作并发执行的问题,但是业务在某些特殊情况 下,要求必须采用 加锁 的方式执行。

3. 一致性读

事务利用MVCC进行的读取操作称为一致性读,或者一致性无锁读。所有普通的 select 在 read committed、repeatable read 隔离级别下都算是一致性读,比如:

select * from t;
select * from t1 inner join t2 on t1.col1=t2.clo2;

一致性读并不会对表中的任何记录进行加锁操作,其他事务可以自由的对表中的记录进行改动。