CategoryResourceRepost/极客时间专栏/SQL必知必会/第二章:SQL性能优化篇/30丨锁:悲观锁和乐观锁是什么?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

176 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="30丨锁悲观锁和乐观锁是什么" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/e9/916edcd61be50916e937c486a09007e9.mp3"></audio>
索引和锁是数据库中的两个核心知识点不论在工作中还是在面试中我们都会经常跟它们打交道。之前我们已经从不同维度对索引进行了了解比如B+树、Hash索引、页结构、缓冲池和索引原则等了解它们的工作原理可以加深我们对索引的理解。同时在基础篇的部分中我也讲解了事务的4大原则以及不同的隔离级别。这些隔离级别的实现都是通过锁来完成的你可以思考下为什么我们需要给数据加锁呢
实际上加锁是为了保证数据的一致性,这个思想在程序开发领域中同样很重要。在程序开发中也会存在多线程同步的问题。当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在进行访问,保证数据的完整性和一致性。
今天的内容主要包括以下几个方面:
1. 就分类而言,锁的划分有多种方式,这些划分方式都包括哪些?
1. 为什么共享锁会发生死锁?
1. 乐观锁和悲观锁的思想是什么?乐观锁有两种实现方式,这两种实现方式是什么?
1. 多个事务并发,发生死锁时该如何解决?怎样降低死锁发生的概率?
## 按照锁粒度进行划分
锁用来对数据进行锁定,我们可以从锁定对象的粒度大小来对锁进行划分,分别为行锁、页锁和表锁。
顾名思义,行锁就是按照行的粒度对数据进行锁定。锁定力度小,发生锁冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。
页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
表锁就是对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。
行锁、页锁和表锁是相对常见的三种锁除此以外我们还可以在区和数据库的粒度上锁定数据对应区锁和数据库锁。不同的数据库和存储引擎支持的锁粒度不同InnoDB和Oracle支持行锁和表锁。而MyISAM只支持表锁MySQL中的BDB存储引擎支持页锁和表锁。SQL Server可以同时支持行锁、页锁和表锁如下表所示
<img src="https://static001.geekbang.org/resource/image/ca/b2/ca5598e46ab7ec59c3d3721e337a1ab2.png" alt=""><br>
这里需要说明下每个层级的锁数量是有限制的因为锁会占用内存空间锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时就会进行锁升级。锁升级就是用更大粒度的锁替代多个更小粒度的锁比如InnoDB中行锁升级为表锁这样做的好处是占用的锁空间降低了但同时数据的并发度也下降了。
## 从数据库管理的角度对锁进行划分
除了按照锁粒度大小对锁进行划分外,我们还可以从数据库管理的角度对锁进行划分。共享锁和排它锁,是我们经常会接触到的两把锁。
共享锁也叫读锁或S锁共享锁锁定的资源可以被其他用户读取但不能修改。在进行`SELECT`的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。
比如我们想给product_comment在表上加共享锁可以使用下面这行命令
```
LOCK TABLE product_comment READ;
```
当对数据表加上共享锁的时候该数据表就变成了只读模式此时我们想要更新product_comment表中的数据比如下面这样
```
UPDATE product_comment SET product_id = 10002 WHERE user_id = 912178;
```
系统会做出如下提示:
```
ERROR 1099 (HY000): Table 'product_comment' was locked with a READ lock and can't be updated
```
也就是当共享锁没有释放时,不能对锁住的数据进行修改。
如果我们想要对表上的共享锁进行解锁,可以使用下面这行命令:
```
UNLOCK TABLE;
```
如果我们想要给某一行加上共享锁呢比如想对user_id=912178的数据行加上共享锁可以像下面这样
```
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 LOCK IN SHARE MODE
```
排它锁也叫独占锁、写锁或X锁。排它锁锁定的数据只允许进行锁定操作的事务使用其他事务无法对已锁定的数据进行查询或修改。
如果我们想给product_comment数据表添加排它锁可以使用下面这行命令
```
LOCK TABLE product_comment WRITE;
```
这时只有获得排它锁的事务可以对product_comment进行查询或修改其他事务如果想要在product_comment表上查询数据则需要等待。你可以自己开两个MySQL客户端来模拟下。
这时我们释放掉排它锁,使用这行命令即可。
```
UNLOCK TABLE;
```
同样的如果我们想要在某个数据行上添加排它锁比如针对user_id=912178的数据行则写成如下这样
```
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id = 912178 FOR UPDATE;
```
另外当我们对数据进行更新的时候,也就是`INSERT`、`DELETE`或者`UPDATE`的时候,数据库也会自动使用排它锁,防止其他事务对该数据行进行操作。
当我们想要获取某个数据表的排它锁的时候,需要先看下这张数据表有没有上了排它锁。如果这个数据表中的某个数据行被上了行锁,我们就无法获取排它锁。这时需要对数据表中的行逐一排查,检查是否有行锁,如果没有,才可以获取这张数据表的排它锁。这个过程是不是有些麻烦?这里就需要用到意向锁。
意向锁Intent Lock简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子你可以给整个房子设置一个标识告诉它里面有人即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权只需要看这个房子的标识即可不需要再对房子中的每个房间进行查找。这样是不是很方便
返回数据表的场景,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。
## 为什么共享锁会发生死锁的情况?
当我们使用共享锁的时候会出现死锁的风险下面我们用两个MySQL客户端来模拟一下事务查询。
首先客户端1开启事务然后采用读锁的方式对`user_id=912178`的数据行进行查询,这时事务没有提交的时候,这两行数据行上了读锁。
<img src="https://static001.geekbang.org/resource/image/94/48/94f4e7c282b6cbeae64f0bad5ac6cb48.png" alt=""><br>
然后我们用客户端2开启事务同样对`user_id=912178`获取读锁,理论上获取读锁后还可以对数据进行修改,比如执行下面这条语句:
```
UPDATE product_comment SET product_i = 10002 WHERE user_id = 912178;
```
当我们执行的时候客户端2会一直等待因为客户端1也获取了该数据的读锁不需要客户端2对该数据进行修改。这时客户端2会提示等待超时重新执行事务。
<img src="https://static001.geekbang.org/resource/image/10/16/10b9d284597012bcd237041d7c573a16.png" alt=""><br>
你能看到当有多个事务对同一数据获得读锁的时候,可能会出现死锁的情况。
## 从程序员的角度对进行划分
如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。
乐观锁Optimistic Locking认为对同一数据的并发操作不会总发生属于小概率事件不用每次都对数据上锁也就是不采用数据库自身的锁机制而是通过程序来实现。在程序上我们可以采用版本号机制或者时间戳机制实现。
### 乐观锁的版本号机制
在表中设计一个版本字段version第一次读的时候会获取version字段的取值。然后对数据进行更新或删除操作时会执行`UPDATE ... SET version=version+1 WHERE version=version`。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
这种方式类似我们熟悉的SVN、CVS版本管理系统当我们修改了代码进行提交时首先会检查当前版本号与服务器上的版本号是否一致如果一致就可以直接提交如果不一致就需要更新服务器上的最新代码然后再进行提交。
### 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
悲观锁Pessimistic Locking也是一种思想对数据被其他事务的修改持保守态度会通过数据库自身的锁机制来实现从而保证数据操作的排它性。
<img src="https://static001.geekbang.org/resource/image/0d/e3/0dd602515faab3b6b0cd0d42adaa87e3.png" alt=""><br>
从这两种锁的设计思想中,你能看出乐观锁和悲观锁的适用场景:
<li>
乐观锁适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
</li>
<li>
悲观锁适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读-写和写-写的冲突。
</li>
## 总结
今天我们讲解了数据库中锁的划分,你能看到从不同维度都可以对锁进行划分,需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。
既然有锁的存在,就有可能发生死锁的情况。死锁就是多个事务(如果是在程序层面就是多个进程)在执行过程中,因为竞争某个相同的资源而造成阻塞的现象。发生死锁,往往是因为在事务中,锁的获取是逐步进行的。
我在文章中举了一个例子在客户端1获取某数据行共享锁的同时另一个客户端2也获取了该数据行的共享锁这时任何一个客户端都没法对这个数据进行更新因为共享锁会阻止其他事务对数据的更新当某个客户端想要对锁定的数据进行更新的时候就出现了死锁的情况。当死锁发生的时候就需要一个事务进行回滚另一个事务获取锁完成事务然后将锁释放掉很像交通堵塞时候的解决方案。
<img src="https://static001.geekbang.org/resource/image/97/7f/9794e3a155edbf8d7b68a7ff8910fc7f.jpg" alt=""><br>
我们都不希望出现死锁的情况,可以采取一些方法避免死锁的发生:
1. 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;
1. 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;
1. 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率。
当然在数据库中也有一些情况是不会发生死锁的比如采用乐观锁的方式。另外在MySQL MyISAM存储引擎中也不会出现死锁这是因为MyISAM总是一次性获得全部的锁这样的话要么全部满足可以执行要么就需要全部等待。
最后你有没有想过使用MySQL InnoDB存储引擎时为什么对某行数据添加排它锁之前会在数据表上添加意向排他锁呢
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来进步。