MySQL高级篇——锁的概述与案例应用
Posted 张起灵-小哥
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了MySQL高级篇——锁的概述与案例应用相关的知识,希望对你有一定的参考价值。
文章目录:
1.锁的概述
在数据库中,除传统的计算资源(如 CPU 、 RAM 、 I/O 等)的争用以外,数据也是一种供许多用户共享的资源。为保证数据的一致性,需要对 并发操作进行控制 ,因此产生了 锁 。同时 锁机制 也为实现 mysql的各个隔离级别提供了保证。 锁冲突 也是影响数据库 并发访问性能 的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。
2.MySQL并发事务访问相同记录
2.1 读-读情况
读 - 读 情况,即并发事务相继 读取相同的记录 。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。
2.2 写-写情况
写 - 写 情况,即并发事务相继对相同的记录做出改动。 在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们 排队执行 ,这个排队的过程其实是通过 锁 来实现的。这个所谓的锁其实是一个 内存中的结构 ,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进行关联的。 当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构 ,当没有的时候就会在内存中生成一个 锁结构 与之关联。比如,事务 T1 要对这条记录做改动,就需要生成一个 锁结构 与之关联:
小结几种说法: 不加锁 :意思就是不需要在内存中生成对应的 锁结构 ,可以直接执行操作。 获取锁成功,或者加锁成功:意思就是在内存中生成了对应的 锁结构 ,而且锁结构的 is_waiting 属性为 false ,也就是事务可以继续执行操作。 获取锁失败,或者加锁失败,或者没有获取到锁: 意思就是在内存中生成了对应的 锁结构 ,不过锁结构的 is_waiting 属性为 true ,也就是事务 需要等待,不可以继续执行操作。
2.3 读-写或写-读情况
读 - 写 或 写 - 读 ,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。 各个数据库厂商对 SQL 标准 的支持都可能不一样。比如 MySQL 在 REPEATABLE READ 隔离级别上就已经解决了 幻读 问题。
2.4 并发问题的解决方案
怎么解决 脏读 、 不可重复读 、 幻读 这些问题呢?其实有两种可选的解决方案: 方案一:读操作利用多版本并发控制( MVCC ,下章讲解),写操作进行 加锁 。 普通的SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用到 MVCC 读取记录。 在 READ COMMITTED 隔离级别下,一个事务在执行过程中每次执行 SELECT 操作时都会生成一个ReadView , ReadView 的存在本身就保证了 事务不可以读取到未提交的事务所做的更改 ,也就是避免了脏读现象; 在 REPEATABLE READ 隔离级别下,一个事务在执行过程中只有 第一次执行 SELECT 操作 才会生成一个ReadView ,之后的 SELECT 操作都 复用 这个 ReadView ,这样也就避免了不可重复读和幻读的问题。 方案二:读、写操作都采用 加锁 的方式。 小结对比发现: 采用 MVCC 方式的话, 读 - 写 操作彼此并不冲突, 性能更高 。 采用 加锁 方式的话, 读 - 写 操作彼此需要 排队执行 ,影响性能。 一般情况下我们当然愿意采用 MVCC 来解决 读 - 写 操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用 加锁 的方式执行。下面就讲解下 MySQL 中不同类别的锁。
3.锁的不同角度分类
3.1 从数据操作的类型划分:读锁、写锁
读锁 :也称为 共享锁 、英文用 S 表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。 写锁 :也称为 排他锁 、英文用 X 表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。 需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。
下面进行简单的案例测试:👇👇👇 (开启两个会话),先在第一个会话中对account表加读锁,再在第二个会话中对account表加读锁,可以看到都成功了,也即此时读锁是可以多个事务同时加、共享的。
此时,我们再尝试对account表加写锁,可以看到mysql命令行卡在了这里。也即要想获取写锁,那么这部分数据不能被其他事务占有读锁和写锁。
我们将上面的两个事务提交,下面再在两个会话中分别开两个事务,首先在第一个会话中对account表加写锁。
此时在另一个会话中尝试再对account表加读锁,可以看到命令行卡在了这里,我们回到第一个事务中commit提交(也即释放占有的account表写锁),而后在第二个会话中可以看到对account表加的读锁成功了。
3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
3.2.1 表锁——读锁、写锁
在对某个表执行 SELECT 、 INSERT 、 DELETE 、 UPDATE 语句时, InnoDB 存储引擎是不会为这个表添加表级别的 S 锁 或者 X 锁 的。在对某个表执行一些诸如 ALTER TABLE 、 DROP TABLE 这类的 DDL 语句时,其他事务对这个表并发执行诸如SELECT 、 INSERT 、 DELETE 、 UPDATE 的语句会发生阻塞。同理,某个事务中对某个表执行SELECT 、 INSERT 、 DELETE 、 UPDATE 语句时,在其他会话中对这个表执行 DDL 语句也会发生阻塞。这个过程其实是通过在 server 层 使用一种称之为 元数据锁 (英文名: Metadata Locks ,简称 MDL )结构来实现的。 一般情况下,不会使用 InnoDB 存储引擎提供的表级别的 S 锁 和 X 锁 。只会在一些特殊情况下,比方说 崩溃恢复 过程中用到。比如,在系统变量 autocommit=0 , innodb_table_locks = 1 时, 手动 获取 InnoDB存储引擎提供的表 t 的 S 锁 或者 X 锁 可以这么写: LOCK TABLES t READ : InnoDB 存储引擎会对表 t 加表级别的 S 锁 。 LOCK TABLES t WRITE : InnoDB 存储引擎会对表 t 加表级别的 X 锁 。 不过尽量避免在使用 InnoDB 存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB 的厉害之处还是实现了更细粒度的 行锁 ,关于 InnoDB表级别的 S 锁 和 X 锁 大家了解一下就可以了。MySQL的表级锁有两种模式:(以 MyISAM 表进行操作的演示) 表共享读锁(Table Read Lock)。 表独占写锁( Table Write Lock )。
下面的案例来演示表锁中的写锁和读锁,也即上面这张图。
首先创建mylock表,添加一条数据,用作后续测试。(注意这张表采用 myisam 存储引擎)
在第一个会话中对mylock表加锁,加锁之后,可以通过 show open tables 命令查看加锁情况:👇👇👇
下面在第一个会话中对mylock表加读锁,加锁之后,自己可以读、不能写、不能操作其他表,其他会话可以读、不能写。(当第一个会话释放锁之后,第二个会话在此之前对该表的写操作才可以顺利执行)
见如下运行图:👇👇👇
下面在第一个会话中对mylock表加写锁,加锁之后,自己可以读、可以写、不能操作其他表,其他会话不可以读、不能写。(当第一个会话释放锁之后,第二个会话在此之前对该表的读写操作才可以顺利执行)
见如下运行图:👇👇👇
3.2.2 表锁——意向锁
InnoDB 支持 多粒度锁( multiple granularity locking ) ,它允许 行级锁 与 表级锁 共存,而 意向 锁 就是其中的一种 表锁 。 意向锁分为两种:
意向锁的并发性:意向锁不会与行级的共享 / 排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了) 。我们扩展一下上面 teacher表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可能存在多种不同锁,但是这里我们只着重表现意向锁)。
从上面的案例可以得到如下结论: 1. InnoDB 支持 多粒度锁 ,特定场景下,行级锁可以与表级锁共存。 2. 意向锁之间互不排斥,但除了 IS 与 S 兼容外, 意向锁会与 共享锁 / 排他锁 互斥 。 3. IX, IS 是表级锁,不会和行级的 X , S 锁发生冲突。只会和表级的 X , S 发生冲突。 4. 意向锁在保证并发性的前提下,实现了 行锁和表锁共存 且 满足事务隔离性 的要求。
下面进行案例测试,首先创建teacher表,表中插入5条数据。
在第一个会话中对teacher表中id位5这行记录加X锁,此时也会对teacher表加IX锁。即对表也上了锁。
然后在第二个会话中尝试对teacher表加读锁,就失败了。。。
此时必须等待第一个会话将之前的X锁释放,第二个会话对锁的操作才可以成功。
3.2.3 行锁——记录锁
记录锁也就是仅仅把一条记录锁上,官方的类型名称为: LOCK_REC_NOT_GAP 。比如我们把 id 值为 8 的那条记录加一个记录锁的示意图如图所示。仅仅是锁住了id 值为 8 的记录,对周围的数据没有影响。
记录锁是有 S 锁和 X 锁之分的,称之为 S 型记录锁 和 X 型记录锁 。 当一个事务获取了一条记录的 S 型记录锁后,其他事务也可以继续获取该记录的 S 型记录锁,但不可以继续获取X 型记录锁; 当一个事务获取了一条记录的 X 型记录锁后,其他事务既不可以继续获取该记录的 S 型记录锁,也不可以继续获取X 型记录锁。
下面进行案例测试,首先创建student表,表中插入数据。
在第一个会话中,对id为1这条记录加记录锁。
此时在第二个会话中,针对非1的记录进行读写操作是可以的,但是对id为1这条记录是不可以再加读、写锁的。
仍然是需要等到第一个会话释放了id为1记录的记录锁(或者commit提交事务),第二个会话才可以进行操作id为1这条记录。
3.2.4 行锁——间隙锁
MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用 加锁 方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些 幻影记录 加上 记录锁 。 InnoDB 提出了一种称之为 Gap Locks 的锁,官方的类型名称为: LOCK_GAP ,我们可以简称为 gap 锁 。比如,把 id 值为 8 的那条记录加一个gap 锁的示意图如下。
图中 id 值为 8 的记录加了 gap 锁,意味着 不允许别的事务在 id 值为 8 的记录前边的间隙插入新记录 ,其实就是 id列的值 (3, 8) 这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条 id 值为 4 的新记录,它定位到该条新记录的下一条记录的id 值为 8 ,而这条记录上又有一个 gap 锁,所以就会阻塞插入操作,直到拥有这个gap 锁的事务提交了之后, id 列的值在区间 (3, 8) 中的新记录才可以被插入。 gap 锁的提出仅仅是为了防止插入幻影记录而提出的 。
因为在student表中的几条记录:id是1、3、8、15、20这样,那么我们在第一个会话中对id为5这条记录加间隙锁。
此时在第二个会话中仍然可以对id为5这条记录再加间隙锁。
但是我们到第三个会话中尝试在(3,8)这个区间中插入一条id为6的记录时,命令行就卡在这里了。。。这就体现了间隙锁的作用。
因为在student表中的几条记录:id是1、3、8、15、20这样,那么我们在第一个会话中对id为25这条记录加间隙锁。
此时超过了20的记录id,此时从下面的运行结果中可以看到,间隙锁作用的范围就变成了 (20,+∞)
3.2.5 行锁——临键锁
有时候我们既想 锁住某条记录 ,又想 阻止 其他事务在该记录前边的 间隙插入新记录 ,所以 InnoDB 就提出了一种称之为 Next - Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY ,我们也可以简称为 next- key 锁 。 Next-Key Locks 是在存储引擎 innodb 、事务级别在 可重复读 的情况下使用的数据库锁, innodb默认的锁就是 Next-Key locks 。 (可以简单的理解:临键锁 = 记录锁 + 间隙锁)
下面的测试中,我们在第一个会话中对student表中 id 区间为 (8,15] 添加临键锁。
到了第二个会话中,可以看到对id为15、12这些记录加锁均失败了。。。
3.2.6 行锁——插入意向锁
我们说一个事务在 插入 一条记录时需要判断一下插入位置是不是被别的事务加了 gap 锁 ( next - key 锁 也包含 gap 锁 ),如果有的话,插入操作需要等待,直到拥有 gap 锁 的那个事务提交。但是 InnoDB 规 定事务在等待的时候也需要在内存中生成一个锁结构 ,表明有事务想在某个 间隙 中 插入 新记录,但是现在在等待。InnoDB 就把这种类型的锁命名为 Insert Intention Locks ,官方的类型名称为: LOCK_INSERT_INTENTION ,我们称为 插入意向锁 。插入意向锁是一种 Gap 锁 ,不是意向锁,在 insert操作时产生。 插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁 。事实上 插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
3.2.7 页锁
页锁就是在 页的粒度 上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。 页锁的开销 介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。 每个层级的锁数量是有限制的,因为锁会占用内存空间, 锁空间的大小是有限的 。当某个层级的锁数量超过了这个层级的阈值时,就会进行 锁升级 。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。
3.3 从对待锁的态度划分:乐观锁、悲观锁
3.3.1 悲观锁
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。 悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁( 共享资源每次只给一个线程使用,其它线程阻塞, 用完后再把资源转让给其它线程 )。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
3.3.2 乐观锁
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是 不采用数据库自身的锁机制,而是通过 程序来实现 。在程序上,我们可以采用 版本号机制 或者 CAS 机制 实现。 乐观锁适用于多读的应用类型, 这样可以提高吞吐量 。在 Java 中 java.util.concurrent.atomic 包下的原子变量类就是使用了乐观锁的一种实现方式:CAS 实现的。 1. 乐观锁的版本号机制 : 在表中设计一个 版本字段 version ,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行 UPDATE ... SET version=version+1 WHERE version=version 。此时如果已经有事务对这条数据进行了更改,修改就不会成功。 2. 乐观锁的时间戳机制 : 时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
3.3.3 对比
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待 数据并发的思维方式 。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的 设计思想 。 从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景: 1. 乐观锁 适合 读操作多 的场景,相对来说写的操作比较少。它的优点在于 程序实现 , 不存在死锁 问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。 2. 悲观锁 适合 写操作多 的场景,因为写的操作具有 排它性 。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止 读 - 写 和 写 - 写 的冲突。
3.4 全局锁
全局锁就是对 整个数据库实例 加锁。当你需要让整个库处于 只读状态 的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用 场景 是:做 全库逻辑备份 。 全局锁的命令:
Flush tables with read lock
3.5 死锁
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。死锁示例:
这时候,事务 1 在等待事务 2 释放 id=2 的行锁,而事务 2 在等待事务 1 释放 id=1 的行锁。 事务 1 和事务 2 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有 两种策略 : 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务(将持有最少行级排他锁的事务进行回滚),让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on ,表示开启这个逻辑。 MySQL 进阶 锁 -- MySQL锁概述MySQL锁的分类:全局锁(数据备份)表级锁(表共享读锁表独占写锁元数据锁意向锁)行级锁(行锁间隙锁临键锁)