目录
数据库的演示数据在这一篇里面,这里就不再重复添加:MySQL 的 4 种事务隔离级别【读未提交、读已提交、可重复读、串行化】的最佳实践演示)详解
深入理解分布式事务③ ---->分布式事务基础(MySQL 中锁的分类 -> MVCC 机制 、悲观锁、乐观锁、读锁(共享锁)、写锁(排他锁)、表锁、行锁、页面锁、间隙锁、临键锁、死锁)详解
1、MySQL 中锁的分类
从本质上讲,锁是一种协调多个进程或多个线程对某一资源的访问的机制,MySQL 使用 锁和 MVCC 机制实现了事务隔离级别。
MySQL 中的锁可以从以下几个方面进行分类,如图:
1、从性能上看,MySQL 中的锁可以分为 悲观锁 和 乐观锁,这里的乐观锁是通过版本对比来实现的。
2、从对数据库的操作类型上看,MySQL 中的锁可以分为 读锁 和 写锁,这里的 读锁 和 写锁 都是悲观锁。
3、从操作数据的粒度上看,MySQL 中的锁可以分为 表锁、行锁 和 页面锁。
4、从更细粒度上看,MySQL 中的锁可以分为 间隙锁 和 临键锁。
1-1:悲观锁 和 乐观锁
悲观锁:
顾名思义,悲观锁对于数据库中数据的读写持悲观态度,即在整个数据处理的过程中,它会将相应的数据锁定。
在数据库中,悲观锁的实现需要依赖数据库提供的锁机制,以保证对数据库加锁后,其他应用系统无法修改数据库中的数据。
在悲观锁机制下,读取数据库中的数据时需要加锁,此时不能对这些数据进行修改操作。
修改数据库中的数据时也需要加锁,此时不能对这些数据进行读取操作。
乐观锁:
悲观锁会极大的降低数据库的性能,特别是对长事务而言,性能的损耗往往是无法承受的。乐观锁则在一定程度上解决了这个问题。
顾名思义,乐观锁对于数据库中数据的读写持乐观态度,即在整个数据处理的过程中,大多数情况下它是通过数据版本记录机制实现的。
实现乐观锁的一种常用做法是为数据增加一个版本标识,如果是通过数据库实现,往往会在数据表中增加一个类似 version 的版本号字段。
在查询数据表中的数据时,会将版本号字段的值一起读取出来,当更新数据时,会令版本号字段的值 +1 。
将提交数据的版本与数据表对应记录的版本进行对比,如果提交的数据版本号大于数据表中当前要修改的数据的版本号,则对数据进行修改操作。
否则,不修改数据表中的数据
比如数据库有一条name = 小白的数据,该数据此时的版本号字段的值是 5;
如果是查询的话,在查 where name=小白 时,就也会把 版本号字段值 5 给查出来;
如果是更新数据的话,在更新数据时,会顺便把获取到的版本号 5 进行 +1 操作,再一起提交数据,也就是提交的数据里面,版本号字段值已经是 6 了,然后提交之前,把刚刚修改后的记录的版本号字段值(6)和没修改之前这行数据的版本号字段值(5)进行对比,如果 6 > 5 ,则可以成功修改数据,如果没有 大于 5 ,则不修改。
1-2:读锁 和 写锁
读锁(共享锁):
读锁又称为【共享锁】或 【 S 锁】(Shared Lock),针对同一份数据,可以加多个读锁而不互相影响。
就是平常数据表里面的数据,比如 name = 小白 这个数据,是可以被多个线程一起读取,只是每个线程读取的时候,都会给该数据加上一个共享锁。然后一个数据是可以被加上多个共享锁的。
Shared :分享的、共有的
写锁(排他锁):
写锁又称为【排他锁】或【 X 锁 】(Exclusive Lock),如果当前写锁未释放,它会阻塞其他的写锁和读锁。
需要注意的是,对同一份数据,如果加了读锁,则可以继续为其加读锁,且多个读锁之间互不影响,但此时不能为数据增加写锁。一旦加了写锁,则不能再增加写锁和读锁。因为读锁具有共享性,而写锁具有排他性。
Exclusive :独家的、专属、独占的
演示:一个数据在被一个线程修改时,会被加上写锁,其他线程无法对该数据进行查询和修改,
也就是一个数据被加上写锁(修改操作),则不能再增加写锁(修改操作)和读锁(查询操作)
# 登录 mysql 服务器
e:
cd E:\install\mysql8\mysql-8.0.21-winx64\bin
mysql -u root -p
密码:123456
# 使用这个test数据库
use test;
# 将当前终端的事务隔离级别设置为 repeatable read,也就是【可重复读】
set session transaction isolation level repeatable read;
# 开启事务
start transaction;
# 修改操作
update account set balance = balance + 100 where id = 1;
# 查询操作
select * from account where id = 1;
1-3:表锁、行锁 和 页面锁
表锁
表锁也称为【表级锁】,就是在整个数据表上对数据进行加锁和释放锁。
典型特点是:开销小,加锁速度快,一般不会出现死锁,锁定的粒度比较大,释放锁冲突的概率最高,并发度最低。
锁定的粒度解释:
也就是锁定数据的范围大小,粒度越大,锁定数据的范围越大。
释放锁冲突解释:
就是释放锁时,会出现的冲突,比如:
1、一个资源的锁被释放,那么在竞争这个资源的其他线程,可能会因为资源竞争而发生冲突,导致数据不一致或者操作失败。
2、也会出现数据一致性问题:比如释放锁可能会改变数据库的状态,如果其他事务依赖于被释放锁所保护的数据进行操作,但在释放锁之前和之后的数据状态不一致,可能会导致数据一致性问题
3、也会出现死锁。
这些都是释放锁会出现的一些冲突。
并发度最低解释:
并发就是指在同一时间段内,系统能同时处理多个请求或事务。
高并发就是指在同一时间段内,系统能同时处理大量的请求或事务。
因为一张表都被锁住了,其他线程要访问这张表的数据,都会被阻塞,导致在一个时间段内,能处理的请求或事务会非常低,也就是并发度低。
表共享锁和表独占写锁
在MySQL中,有两种表级锁模式:一种是表共享锁(Table Shard Lock);另一种是表独占写锁(Table Write Lock)。
当一个线程获取到一个表的【读锁】后,其他线程仍然可以对表进行读操作,但是不能对表进行写操作。
当一个线程获取到一个表的【写锁】后,只有持有锁的线程可以对表进行更新操作,其他线程对数据表的读写操作都会被阻塞,直到写锁被释放为止。
手动增加表锁
可以在 MySQL 的命令行通过如下命令手动增加表锁。
lock table 表名称 read(write) , 表名称2 read(write)
例如:为 account 数据表增加【表级读锁】和【表级写锁】,如下所示
先登录 MySQL 服务器
没增加锁之前的样子,如下所示:
# 用于显示当前数据库中所有打开的表的列表。打开的表是指正在被使用或被锁定的表,可以是读取或写入状态
show open tables;
“In_use” 列的值为 “1” 表示表正在被使用,而值为 “0” 表示表当前没有被使用
然后增加锁:
再次查看,account 表的 In_use 使用状态为 1
也可以用这个命令查看:
# 显示指定表的详细信息,包括表的引擎、行数、平均行长度等
show table status like 'account';
删除表锁
删除 account 数据表中手动添加的表锁,如下所示:
然后再重新查询一次:In_use 已经为 0 了。
#使用这个 test 数据库
use test;
# 查询会话级别的事务隔离级别
select @@session.transaction_isolation
# 查询全局级别的事务隔离级别
select @@global.transaction_isolation;
# 显示当前数据库中所有打开的表的列表。打开的表是指正在被使用或被锁定的表,可以是读取或写入状态
show open tables;
# 为 account 表 增加 【表级读锁】
lock table account read;
# 为 account 表 增加 【表级写锁】
lock table account write;
# 删除 account 表中手动添加的表锁
unlock tables;
行锁
行锁也称为行级锁,就是在数据行上对数据进行加锁和释放锁。
典型的特点就是开销比较大,加锁速度慢,可能会出现死锁,锁定的粒度最小,发生锁冲突的概率最小,并发度最高。
在 InnoDB 中存储引擎中,有两种类型的行锁,一种是共享锁,另一种是排他锁。
共享锁允许一个事务读取一行数据,但不允许一个事务对加了共享锁的当前行增加排他锁。
排他锁只允许当前事务对数据行进行增删改查操作,不允许其他事务对增加了排他锁的数据行增加共享锁和排他锁。
使用行锁时,需要注意一下几点:
1、行锁主要加在索引上,如果对非索引的字段设置条件进行更新,行锁可能会变成表锁。
2、InnoDB 的行锁是针对索引加锁,不是针对记录加锁,并且加锁的索引不能失效,否则行锁可能会变成表锁。
3、锁定某一行时,可以使用 lock in share mode 命令来指定共享锁,使用 for update 命令来指定排他锁。
添加行级锁中的排他锁
# SELECT ... FOR UPDATE 语句,执行加排他锁的操作
# 查询 id = 1 的这行数据,并为该行数据添加排他锁
select * from account where id = 1 for update;
演示这句:排他锁只允许当前事务对数据行进行增删改查操作,不允许其他事务对增加了排他锁的数据行增加共享锁和排他锁。
添加行级锁中的共享锁
# SELECT ... LOCK IN SHARE MODE,执行加共享锁的操作。
# 查询 id = 1 的这行数据,并为该行数据添加共享锁
select * from account where id = 1 lock in share mode;
演示添加锁
演示这句:共享锁允许一个事务读取一行数据,但不允许一个事务对加了共享锁的当前行增加排他锁。
演示锁升级
演示【锁升级】,就是同一个事务给一行数据添加了共享锁之后,这个事务仍然可以再给这行数据添加排他锁。
这个数据被添加了排他锁之后,竟然还能添加共享锁。
演示发现死锁
步骤如下:
页面锁
页面锁也称为页级锁,就是在页面级别对数据进行加锁和释放锁。
对数据的加锁开销介于表锁和行锁之间,可能会出现死锁,锁定的粒度大小介于表锁和行锁之间,并发度一般。
在数据库领域,"页面锁"通常指的是针对数据库表中的页面(page)级别的锁,而不是应用程序中的页面。
总结:表锁、行锁和页面锁的特点
1-4:间隙锁 和 临键锁
间隙锁
在 MySQL 中使用范围查询时,如果请求共享锁或排他锁,InnoDB 会给符合查询条件的已有数据的索引项加锁。如果键值在条件范围内,而这个范围内并不存在记录,则认为此时出现了 “间隙(也就是GAP)”。InnoDB 存储引擎会对这个 “间隙” 加锁,而这种加锁机制就是 间隙锁(GAP Lock) 。
简单来说,间隙锁就是对两个值之间的间隙加锁。MySQL 的默认事务隔离级别是可重复读的,在可重复读的事务隔离级别下会存在幻读的问题,而间隙锁在某种程度下可以解决幻读的问题。
例如,account 数据表中存在如下数据:
此时,account 数据表中的间隙包括 id 为 (3,15]、(15,20]、(20,正无穷] 的三个区间。
如果执行如下命令,将符合条件的用户的账户余额增加 100 元。
update account set balance = balance + 100 where id > 5 and id < 16;
则其他事务无法在 (3,20] 这个区间内插入或者修改任何数据。
这里需要注意的是,间隙锁只有在可重复读事务隔离级别下才会生效。
经过演示:我发现其实是 (3, 20) 这个区间不能执行修改和插入操作,而不是书本所说的 (3 , 20],我觉得是 20 这边应该是开区间。
临键锁
临键锁(Next-Key Lock) 是行锁和间隙锁的组合,例如上面例子中的区间 (3,20] 就可以称为 临键锁。
1-5:死锁
死锁的产生
虽然锁在一定程度上能够解决并发的问题,但稍有不慎,就可能造成死锁。发生死锁的必要条件有四个,分别为【互斥条件、不可剥夺条件、请求与保持条件、循环等待条件】,如下图所示:
1、互斥条件:
在一段时间内,计算机中的某个资源只能被一个进程占用。此时,如果其他进程请求该资源,则只能等待。
2、不可剥夺条件:
某个进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得资源的进程主动释放。
3、请求与保持条件:
进程已经获得了至少一个资源,又要请求其他资源,但请求的资源已经被其他进程占有,此时请求的进程就会被阻塞,并且不会释放自己已获得的资源。
4、循环等待条件:
系统中的进程之间相互等待,同时各自占用的资源又会被下一个进程所请求。
例如:有进程A 、进程B 和 进程C 三个进程,进程A 请求的资源被 进程B 占用,进程B 请求的资源被 进程C 占用,进程C 请求的资源被 进程A 占用,于是形成了循环等待条件。
如图:
需要注意的是,只有四个必要条件都满足,才会发生死锁。
死锁的预防
处理死锁有四种方法,分别为 预防死锁、避免死锁、检测死锁 和 解除死锁。
1、预防死锁:
处理死锁最直接的方法就是破坏造成死锁的4个必要条件中的一个或多个,以防止死锁的产生。
2、避免死锁:
在系统资源的分配过程中,使用某种策略或者方法防止系统进入不安全状态,从而避免死锁的发生。
3、检测死锁:
这种方法允许系统在运行过程中发生死锁,但是能够检测死锁的发生,并采取适当的措施清除死锁。
4、解除死锁:
当检测出死锁后,采用适当的策略和方法将进程从死锁状态解脱出来。
在实际工作中,通常采用有序资源分配法和银行家算法这两种方式来避免死锁。
MySQL 中的死锁问题演示
在 MySQL 5.5.5 及以上版本中,MySQL 的默认存储引擎是 InnoDB 。该存储引擎使用的是【行级锁】,在某种情况下会产生死锁问题,所以 InnoDB 存储引擎采用了一种叫做【等待图】(wait-for graph)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务。
graph:图、图表
接下来,演示一个MySQL中的死锁案例:
第一步:
打开 终端A ,等了 MySQL ,将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id = 1 的数据添加排他锁,如下所示:
# 打开命令行窗口,cd 到 MySQL 安装目录的bin目录下进行登录
e:
cd E:\install\mysql8\mysql-8.0.21-winx64\bin
mysql -u root -p
密码:123456
# 使用这个test数据库
use test;
# 将当前终端的事务隔离级别设置为 repeatable read,也就是【可重复读】
set session transaction isolation level repeatable read;
# 开启事务--命令1
start transaction;
# 开启事务--命令2
begin;
# 查询该行数据时,给该行数据添加一个【排他锁】
select * from account where id = 1 for update;
第二步:
打开终端B,登录 MySQL ,将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id = 2 的数据添加排他锁,如下所示:
此时的命令和终端A 的都一样,这里不再重复把命令贴出来。
第三步:
在终端A 为 account 数据表中 id = 2 的数据添加排他锁,如下所示:
此时,线程会一直卡住,因为在等待 终端B 中 id = 2 的数据释放 排他锁。
第四步:死锁出现
在 终端B 中为 account 数据表中 id = 1 的数据添加排他锁,如下所示:
完整命令流程图
查看死锁的日志信息
此时发生了死锁。通过如下命令可以查看死锁的日志信息
show engine innodb status\G
在 MySQL 的命令行界面中,使用 \G 替代分号 ; 作为语句的结束标志,可以让查询结果以更为清晰的垂直格式输出,而不是通常的水平表格格式。
通过命令行查看【 LATEST DETECTED DEADLOCK 】选项相关的信息,可以发现死锁的相关信息,或者通过配置 【innodb_print_all_deadlocks】(MySQL 5.6.2 版本开始提供)参数为 ON,将死锁相关信息打印到 MySQL 错误日志中。
在MySQL中,通常通过以下几种方式来避免死锁:
1、尽量让数据表中的数据检索都通过索引来完成,避免无效索引导致行锁升级为表锁。
2、合理设计索引,尽量缩小锁的范围。
3、尽量减少查询条件的范围,尽量避免间隙锁或缩小间隙锁的范围。
4、尽量控制事务的大小,减少一次事务锁定的资源数量,缩短锁定资源的时间。
5、如果一条 SQL 语句涉及事务加锁操作,则尽量将其放在整个事务的最后执行。
6、尽可能使用低级别的事务隔离机制。