概述

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

MySQL并发事务访问相同记录

读-读情况

读-读 情况,即并发事务相继 读取相同的记录 。读取操作本身不会对记录有任何影响,并不会引起什么 问题,所以允许这种情况的发生。

写-写情况

写-写 情况,即并发事务相继对相同的记录做出改动。

在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务 相继对一条记录做改动时,需要让它们 排队执行 ,这个排队的过程其实是通过 锁 来实现的

这个所谓 的锁其实是一个 内存中的结构 ,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进 行关联的,如图所示:

image-20220128220813861

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

image-20220128220859591

  • trx信息:代表这个锁结构是哪个事务生成的。
  • is_waiting:代表当前事务是否在等待。

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

在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,图示:

image-20220128222734246

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

image-20220128222830297

小结:

  • 不加锁

    意思就是不需要在内存中生成对应的 锁结构 ,可以直接执行操作。

  • 获取锁成功,或者加锁成功

    意思就是在内存中生成了对应的 锁结构 ,而且锁结构的 is_waiting 属性为 false ,也就是事务 可以继续执行操作。

  • 获取锁失败,或者加锁失败,或者没有获取到锁

    意思就是在内存中生成了对应的 锁结构 ,不过锁结构的 is_waiting 属性为 true ,也就是事务 需要等待,不可以继续执行操作。

读-写或写-读情况

读-写 或 写-读 ,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、 不可重 复读 、 幻读 的问题。

各个数据库厂商对 SQL标准 的支持都可能不一样。比如MySQL在 REPEATABLE READ 隔离级别上就已经 解决了 幻读 问题。

并发问题的解决方案

方案一:读操作利用多版本并发控制,写操作进行 加锁 。

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

方案二:读、写操作都采用 加锁 的方式。

如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,这样读取记录的时候就需要对其进行加锁操作,这样也就意味这读操作和写操作也像写-写操作那样排队执行。

通过加锁可以避免脏读和不可重复读,但对于幻读就有一些麻烦了,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬,不知道给谁加锁。

小结:

  • 采用 MVCC 方式的话, 读-写 操作彼此并不冲突, 性能更高 。

  • 采用 加锁 方式的话, 读-写 操作彼此需要 排队执行 ,影响性能。

锁的不同角度分类

锁的分类图

image-20220128223907604

从数据操作的类型划分:读锁、写锁

  • 读锁 :也称为 共享锁 、英文用 S 表示。针对同一份数据,多个事务的读操作可以同时进行而不会 互相影响,相互不阻塞的。

  • 写锁 :也称为 排他锁 、英文用 X 表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样 就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上

总结:

(这里的兼容是指对同一张表或记录的锁的兼容性情况)

image-20220128225739564

1、锁定读

  • 对读取的记录加S锁

    SELECT LOCK IN SHARE MODE;
    #或
    SELECT FOR SHARE;#(8.0新增语法)
    

    在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加s锁,这样允许别的事务继续获取这些记录的s锁,但是不能获取这些记录的x锁。

    如果别的事务想要获取这些记录的x锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。

  • 对读取的记录加x锁

    SELECT ... FOR UPDATE;
    

    在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加x锁,这样既不允许别的事务获取这些记录的s锁,也不允许获取这些记录的x锁

    如果别的事务想要获取这些记录的s锁或者x锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的x锁释放掉。

MySQL8.0的新特性:

在5.7及之前的版本,SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到i超时。在8.0版本中,SELECT … FOR UPDATESELECT…POR SHARE添加NOWAITSKIP LOCKED语法,跳过锁等待,或者跳过锁定。

2、写操作

  • DELETE

    对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的x锁,再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取x锁的锁定读

  • UPDATE:在对一记录做UPDATE操作时分为三种情况:

  • 情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。

    则先在B+树中定位到这条记录的位置,然后再获取一下记录的x锁,最后在原记录的位置进行修改操作。我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取x锁的锁定读

  • 情况2:未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。

    则先在B+树中定位到这条记录的位置,然后获取一下记录的x锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取x锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护。

  • 情况3:修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。

  • INSERT

    一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。

从数据操作的粒度划分:表级锁、页级锁、行锁

1、表锁

① 表级别的S锁、X锁

  • LOCK TABLES t READ :InnoDB存储引擎会对表 t 加表级别的 S锁 。

  • LOCK TABLES t WRITE :InnoDB存储引擎会对表 t 加表级别的 X锁 。

不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,InnoDB 牛逼的地方在于实现了颗粒度更细的行级锁

② 意向锁 (intention lock)

意向锁是由存储引擎 自己维护的 ,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前, InooDB 会先获取该数据行 所在数据表的对应意向锁 。

  • 意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)

    -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
    SELECT column FROM table ... LOCK IN SHARE MODE;
    
  • 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)

    -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
    SELECT column FROM table ... FOR UPDATE;
    

意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables … read)和独占表锁(lock tables … write)发生冲突。

意向锁的作用

如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。

那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。

所以,意向锁的目的是为了快速判断表里是否有记录被加锁

③ 自增锁(AUTO-INC锁)

在为某个字段声明 AUTO_INCREMENT 属性时,之后可以在插入数据时,可以不指定该字段的值,数据库会自动给该字段赋值递增的值,这主要是通过 AUTO-INC 锁实现的。