Session 25 锁

上一章介绍了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况大致可以划分为3种:

(1)读-读情况:即并发事务相继读取相同的记录。

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

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

  在这种情况下会发生脏写的问题,在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过来实现的。

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。(对一条记录加锁的本质就是在内存中创建一个锁结构与之关联

image-20230909203045599

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

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

image-20230909203208282

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

(3)读-写写-读情况

也就是一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读不可重复读幻读的问题。有两种可选的解决方案:

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

​ 查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录(写操作要事务排队),读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。(两个并发的事务可以一个在写同时一个在读,读的内容不受写的影响)

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

​ 在读取记录的时候也需要对记录进行加锁操作,这样也就意味着操作和操作也像写-写操作那样排队执行。

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

一致性读 (Consistent Reads)

事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTEDREPEATABLE READ隔离级别下都算是一致性读一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。

锁定读 (Locking Reads)

共享锁和独占锁

  • 共享锁,英文名:Shared Locks,简称==S锁==。事务要读取一条记录时,需要先获取该记录的S锁
  • 独占锁,也称排他锁,英文名:Exclusive Locks,简称==X锁==。事务要改动一条记录时,需要先获取该记录的X锁

事务T1首先获取了一条记录的S锁之后,事务T2也可以获取该记录的S锁,但不能获取X锁

对读取的记录加S锁

1
SELECT ... LOCK IN SHARE MODE;

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

SELECT ... LOCK IN SHARE MODE语句只在事务中有效。如果不处于任何事务中,执行该语句将不会获得共享锁(S锁)。在非事务环境下执行SELECT ... LOCK IN SHARE MODE语句会被视为普通的SELECT语句,不会对数据进行锁定。

对读取的记录加X锁

1
SELECT ... FOR UPDATE;

写操作

平常所用到的写操作无非是DELETEUPDATEINSERT这三种。其中DELETEUPDATE都需要获取X锁。而一般情况下(也有特殊情况,后面介绍),新插入一条记录的操作并不加锁,InnoDB通过一种称之为隐式锁的东西来保护这条新插入的记录在本事务提交前不被别的事务访问。

多粒度锁

我们前面提到的都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁S锁)和独占锁X锁

如果一个事务给表加了S锁,那么别的事务可以继续获得该表的S锁,不可以继续获得该表的X锁。其余规则不赘述。

在给表上S锁时,要先查看有没有记录被上了X锁。我们当然不会为此就去遍历全表。InnoDB提出了意向锁(英文名:Intention Locks`)——

  • 意向共享锁,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁
  • 意向独占锁,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁

总结一下:IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。(同一张表的两条记录加读锁或写锁互不相干)

其他存储引擎中的锁

对于MyISAMMEMORYMERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。

比方说在Session 1中对一个表执行SELECT操作,就相当于为这个表加了一个表级别的S锁,如果在SELECT操作未完成时,Session 2中对这个表执行UPDATE操作,相当于要获取表的X锁,此操作会被阻塞,直到Session 1中的SELECT操作完成,释放掉表级别的S锁后,Session 2中对这个表执行UPDATE操作才能继续获取X锁

因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。

InnoDB存储引擎中的锁

(表级锁详见原文)

行级锁

我们来看看都有哪些常用的行锁类型

(1)Record Locks(记录锁)

我们前面提到的记录锁就是这种类型,也就是仅仅把一条记录锁上,我(注:原文作者)决定给这种类型的锁起一个比较不正经的名字:正经记录锁(请允许我皮一下,我实在不知道该叫什么名好)。官方的类型名称为:LOCK_REC_NOT_GAP

image-20230909213305913

(2)Gap Locks(间隙锁)

就是事务在第一次执行读取操作时,那些幻影记录尚不存在(还未插入),我们无法给这些幻影记录加上正经记录锁

InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们也可以简称为gap锁。比方说我们把number值为8的那条记录加一个gap锁的示意图如下:

image-20230909213438008

如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前面的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。

这个gap锁的提出仅仅是为了防止插入幻影记录而提出的。对一条记录加了gap锁,并不会限制其他事务对这条记录加正经记录锁或者继续加gap锁

为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁

(3)Next-Key Locks(临键锁)

next-key锁的本质就是一个正经记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前面的间隙

image-20230909214413199

(4)Insert Intention Locks(插入意向锁)

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁next-key锁也包含gap锁,后边就不强调了),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是设计InnoDB的大佬规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。

这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION

image-20230909214644742

(5)隐式锁

(非mvcc场景)对于两个同时进行的事务,如果一个事务新插入了一条记录,由于一般情况下INSERT操作是不加锁的(插入意向锁是例外),那么难道这条记录就可以被另一事务随便加S锁(脏读)或X锁(脏写)了吗?

对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true

总结:一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。

InnoDB锁的内存结构

(详见原文)