MySQL 中的锁
按照 MySQL 官方的说法,InnoDB 中锁可以分为:
可见,InnoDB 中锁非常多,总的来说,可以如下分类:
这些锁都是做什么的?具体含义是什么?我们现在来一一学习。
8.1. 解决并发事务问题
我们已经知道事务并发执行时可能带来的各种问题,最大的一个难点是:一
方面要最大程度地利用数据库的并发访问,另外一方面还要确保每个用户能以一
致的方式读取和修改数据,尤其是一个事务进行读取操作,另一个同时进行改动
操作的情况下。
8.1.1. 复习并发事务问题
一个事务进行读取操作,另一个进行改动操作,我们前边说过,这种情况下
可能发生脏读、不可重复读、幻读的问题。
SQL 标准规定不同隔离级别下可能发生的问题不一样:
在 READ UNCOMMITTED 隔离级别下,脏读、不可重复读、幻读都可能发生。
在 READ COMMITTED 隔离级别下,不可重复读、幻读可能发生,脏读不可以发生。
在 REPEATABLE READ 隔离级别下,幻读可能发生,脏读和不可重复读不可以发生。
在 SERIALIZABLE 隔离级别下,上述问题都不可以发生。
不过各个数据库厂商对 SQL 标准的支持都可能不一样,与 SQL 标准不同的一
点就是,MySQL 在 REPEATABLE READ 隔离级别实际上就基本解决了幻读问题。
怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:
8.1.2. 方案一:读操作 MVCC,写操作进行加锁
所谓的 MVCC 我们在前一章有过详细的描述,就是通过生成一个 ReadView,
然后通过 ReadView 找到符合条件的记录版本(历史版本是由 undo 日志构建的),
其实就像是在生成 ReadView 的那个时刻做了一个快照,查询语句只能读到在生
成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或
者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的
记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用
MVCC 时,读-写操作并不冲突。
我们说过普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离
级别下会使用到 MVCC 读取记录。在 READ COMMITTED 隔离级别下,一个事务
在执行过程中每次执行 SELECT 操作时都会生成一个 ReadView,ReadView 的存在
本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现
象;REPEATABLE READ 隔离级别下,一个事务在执行过程中只有第一次执行
SELECT 操作才会生成一个 ReadView,之后的 SELECT 操作都复用这个 ReadView,
这样也就避免了不可重复读和很大程度上避免了幻读的问题。
8.1.3. 一致性读(Consistent Reads)/快照读
事务利用 MVCC 进行的读取操作称之为一致性读,或者一致性无锁读,也称
之为快照读,但是往往读取的是历史版本数据。所有普通的 SELECT 语句(plain
SELECT)在 READ COMMITTED、REPEATABLE READ 隔离级别下都算是一致性读。
上面的这句话中,普通的 SELECT 语句是指不加锁的 select 语句在非串行化事务隔离级别下。
一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。
很明显,采用 MVCC 方式的话,读-写操作彼此并不冲突,性能更高,采用
加锁方式的话,读-写操作彼此需要排队执行,影响性能。一般情况下我们当然
愿意采用 MVCC 来解决读-写操作并发执行的问题,但是业务在某些情况下,要
求必须采用加锁的方式执行。
8.1.4. 方案二:读、写操作都采用加锁的方式
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取
记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然
后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,
就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以
访问账户的余额。这样在读取记录的时候也就需要对其进行加锁操作,这样也就
意味着读操作和写操作也像写-写操作那样排队执行。
我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,
如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。
不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。
我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有不太容易了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点麻烦 —— 因为并不知道给谁加锁。InnoDB 中是如何解决的,我们后面会讲到。
8.2. 锁定读(Locking Reads)/LBCC
也称当前读, 读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。
哪些是当前读呢?select lock in share mode (共享锁)、select for update (排他
锁)、update (排他锁)、insert (排他锁)、delete (排他锁)、串行化事务隔离级别都是当前读。
当前读这种实现方式,也可以称之为 LBCC(基于锁的并发控制,Lock-Based
Concurrency Control),怎么做到?
8.2.1.1. 共享锁和独占锁
在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使
写-写、读-写或写-读情况中的操作相互阻塞,MySQL 中的锁有好几类:
-
共享锁,英文名:Shared Locks,简称 S 锁。在事务要读取一条记录时,需要先获取该记录的 S 锁。
-
独占锁,也常称排他锁,英文名:Exclusive Locks,简称 X 锁。在事务要改动一条记录时,需要先获取该记录的 X 锁。
-
假如事务 E1 首先获取了一条记录的 S 锁之后,事务 E2 接着也要访问这条记录:
-
如果事务 E2 想要再获取一个记录的 S 锁,那么事务 E2 也会获得该锁,也就意味着事务 E1 和 E2 在该记录上同时持有 S 锁。
-
如果事务 E2 想要再获取一个记录的 X 锁,那么此操作会被阻塞,直到事务E1 提交之后将 S 锁释放掉。
-
如果事务 E1 首先获取了一条记录的 X 锁之后,那么不管事务 E2 接着想获取该记录的 S 锁还是 X 锁都会被阻塞,直到事务 E1 提交。
-
所以我们说 S 锁和 S 锁是兼容的,S 锁和 X 锁是不兼容的,X 锁和 X 锁也是不兼容的,画个表表示一下就是这样:
X 不兼容 X 不兼容 S
S 不兼容 X 兼容 S
8.2.1.2. 锁定读的 SELECT 语句
MySQ 有两种比较特殊的 SELECT 语句格式:
对读取的记录加 S 锁:
SELECT … LOCK IN SHARE MODE;
也就是在普通的 SELECT 语句后边加 LOCK IN SHARE MODE,如果当前事务执
行了该语句,那么它会为读取到的记录加 S 锁,这样允许别的事务继续获取这些
记录的 S 锁(比方说别的事务也使用 SELECT … LOCK IN SHARE MODE 语句来读取
这些记录),但是不能获取这些记录的 X 锁(比方说使用 SELECT … FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。
如果别的事务想要获取这些记录的 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 S 锁释放掉。
对读取的记录加 X 锁:
SELECT … FOR UPDATE;
也就是在普通的 SELECT 语句后边加 FOR UPDATE,如果当前事务执行了该语
句,那么它会为读取到的记录加 X 锁,这样既不允许别的事务获取这些记录的 S
锁(比方说别的事务使用 SELECT … LOCK IN SHARE MODE 语句来读取这些记录),
也不允许获取这些记录的 X 锁(比如说使用 SELECT … FOR UPDATE 语句来读取这
些记录,或者直接修改这些记录)。
如果别的事务想要获取这些记录的 S 锁或者 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 X 锁释放掉。
8.2.1.3. 写操作的锁
平常所用到的写操作无非是 DELETE、UPDATE、INSERT 这三种:
DELETE:
对一条记录做 DELETE 操作的过程其实是先在 B+树中定位到这条记录的位置,
然后获取一下这条记录的 X 锁,然后再执行 delete mark 操作。我们也可以把这
个定位待删除记录在 B+树中位置的过程看成是一个获取 X 锁的锁定读。
INSERT:
一般情况下,新插入一条记录的操作并不加锁,InnoDB 通过一种称之为隐
式锁来保护这条新插入的记录在本事务提交前不被别的事务访问。当然,在一些
特殊情况下 INSERT 操作也是会获取锁的,具体情况我们后边再说。
UPDATE:
在对一条记录做 UPDATE 操作时分为三种情况:
如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发
生变化,则先在 B+树中定位到这条记录的位置,然后再获取一下记录的 X 锁,
最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在
B+树中位置的过程看成是一个获取 X 锁的锁定读。
如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在 B+树中定位到这条记录的位置,然后获取一下记录的 X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在 B+树中位置的过程看成是一个获取 X 锁的锁定读,新插入的记录由 INSERT 操作提供的隐式锁进行保护。
如果修改了该记录的键值,则相当于在原记录上做 DELETE 操作之后再来一次 INSERT 操作,加锁操作就需要按照 DELETE 和 INSERT 的规则进行了。
8.3. 锁的粒度
我们前边提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S 锁)和独占锁(X 锁)
8.3.1.1. 表锁与行锁的比较
锁定粒度:表锁 > 行锁
加锁效率:表锁 > 行锁
冲突概率:表锁 > 行锁
并发性能:表锁 < 行锁
8.3.1.2. 给表加 S 锁
如果一个事务给表加了 S 锁,那么:
别的事务可以继续获得该表的 S 锁
别的事务可以继续获得该表中的某些记录的 S 锁
别的事务不可以继续获得该表的 X 锁
别的事务不可以继续获得该表中的某些记录的 X 锁
8.3.1.3. 给表加 X 锁
如果一个事务给表加了 X 锁(意味着该事务要独占这个表),那么:
别的事务不可以继续获得该表的 S 锁
别的事务不可以继续获得该表中的某些记录的 S 锁
别的事务不可以继续获得该表的 X 锁
别的事务不可以继续获得该表中的某些记录的 X 锁。
为了更好的理解这个表级别的 S 锁和 X 锁和后面的意向锁,我们举一个现实
生活中的例子。我们用曾经很火爆的互联网风口项目共享 Office 来说明加锁:
共享 Office 有栋大楼,楼自然有很多层。办公室都是共享的,客户可以随便选办公室办公。每层楼可以容纳客户同时办公,每当一个客户进去办公,就相当于在每层的入口处挂了一把 S 锁,如果很多客户进去办公,相当于每层的入口处挂了很多把 S 锁(类似行级别的 S 锁)。
有的时候楼层会进行检修,比方说换地板,换天花板,检查水电啥的,这些维修项目并不能同时开展。如果楼层针对某个项目进行检修,就不允许客户来办公,也不允许其他维修项目进行,此时相当于楼层门口会挂一把 X 锁(类似行级别的 X 锁)。
上边提到的这两种锁都是针对楼层而言的,不过有时候我们会有一些特殊的需求:
A、有投资人要来考察 Office 的环境。
投资人和公司并不想影响客户进去办公,但是此时不能有楼层进行检修,所以可以在大楼门口放置一把 S 锁(类似表级别的 S 锁)。此时:来办公的客户们看到大楼门口有 S 锁,可以继续进入大楼办公。
修理工看到大楼门口有 S 锁,则先在大楼门口等着,啥时候投资人走了,把大楼的 S 锁撤掉再进入大楼维修。
B、公司要和房东谈条件。
此时不允许大楼中有正在办公的楼层,也不允许对楼层进行维修。所以可以在大楼门口放置一把 X 锁(类似表级别的 X 锁)。此时:来办公的客户们看到大楼门口有 X 锁,则需要在大楼门口等着,啥时候条件谈好,把大楼的 X 锁撤掉再进入大楼办公。
修理工看到大楼门口有 X 锁,则先在大楼门口等着,啥时候考试结束,把大楼的 X 锁撤掉再进入大楼维修。