什么是悲观锁 乐观锁 共享锁 排它锁 记录锁 间隙锁 临键锁

前言

要保证最大程度地利用数据库的并发访问,以及还要确保每个用于能以一致的方式读取和修改数据,因此出现了锁机制。数据库使用锁是为了支持对共享资源进行并发访问,提供数据的完整性和一致性。各种数据库以及MySQL数据库的不同引擎实现锁的方法也不相同,如MySQL的MySIAM引擎实现了表锁,SQL Server2005及其之前版本实现了页锁,MySQL的InnoDB引擎和Oracle数据库非常类似,实现了行锁、表锁、一致性的非锁定读。行级锁没有相关额外的开销,并可以同时得到并发性和一致性。

锁(lock)的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或者rollback后进行释放(不同隔离级别下释放的时间可能不同)。

1 悲观锁和乐观锁

1.1 乐观锁

乐观锁是一种基于“乐观”的概念,就是每次去读数据的时候都认为别的事物不会修改该数据,所以不会上锁,但是在更新的时候会判断一下在此期间别的事物有没有更新这个数据。

可以通过为记录添加版本号或时间戳字段来实现乐观锁,一旦发现出现冲突了,修改失败就要通过事务进行回滚操作。可以用session.Lock()锁定对象来实现悲观锁(本质上就是执行了SELECT * FROM t FOR UPDATE语句),避免冲突的发生。

相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。 悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。而乐观锁机制在一定程度上解决了这个问题。

乐观锁,大多是基于数据版本( Version )记录机制实现。

乐观锁的实现方式

版本号(version)

就是给数据增加一个版本标识,在数据库上就是表中增加一个version字段,每次更新把这个字段加1,读取数据的时候把version读出来,更新的时候比较version,如果还是开始读取的version就可以更新了,如果最开始读取的version比当前version小,说明有其他事务更新了该数据,并增加了版本号,这时候得到一个无法更新的通知,用户自行根据这个通知来决定怎么处理,比如重新开始一遍。这里的关键是判断version和更新两个动作需要作为一个原子单元执行,否则在你判断可以更新以后正式更新之前有别的事务修改了version,这个时候你再去更新就可能会覆盖前一个事务做的更新,造成第二类丢失更新,所以你可以使用update … where … and version="old version"这样的语句,根据返回结果是0还是非0来得到通知,如果是0说明更新没有成功,因为version被改了,如果返回非0说明更新成功。

时间戳(timestamp)

和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间。

待更新字段

和版本号方式相似,只是不增加额外字段,直接使用有效数据字段做版本控制信息,因为有时候我们可能无法改变旧系统的数据库表结构。假设有个待更新字段叫count,先去读取这个count,更新的时候去比较数据库中count的值是不是我期望的值(即开始读的值),如果是就把我修改的count的值更新到该字段,否则更新失败。java的基本类型的原子类型对象如AtomicInteger就是这种思想。

所有字段

和待更新字段类似,只是使用所有字段做版本控制信息,只有所有字段都没变化才会执行更新。

乐观锁几种方式的区别

新系统设计可以使用version方式和timestamp方式,需要增加字段,应用范围是整条数据,不论那个字段修改都会更新version,也就是说两个事务更新同一条记录的两个不相关字段也是互斥的,不能同步进行。旧系统不能修改数据库表结构的时候使用数据字段作为版本控制信息,不需要新增字段,待更新字段方式只要其他事务修改的字段和当前事务修改的字段没有重叠就可以同步进行,并发性更高。

乐观锁的优势和劣势

优势:乐观锁机制避免了长事务中的数据库加锁解锁开销,大大提升了大并发量下的系统整体性能表现
劣势:冲突非常的多

1.2 悲观锁

认为数据随时会修改,所以整个数据处理中需要将数据加锁,这样别的事务拿这个数据就会block(阻塞),直到它拿到锁。悲观锁一般都是依靠关系数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。事实上关系数据库中的行锁、表锁、读锁、写锁都是悲观锁,在读之前先上锁。

悲观锁的实现方式

完全依赖于数据库锁的机制实现的

悲观锁的优势和劣势

劣势:对于并发访问性支持不好,因为会导致很多未获取到锁的操作阻塞
优势 : 能避免冲突的发生,因为使用阻塞的方式

乐观锁可以认为是一种在最后提交的时候检测冲突的手段,而悲观锁则是一种避免冲突的手段。

乐观锁VS悲观锁

乐观锁和悲观锁没有绝对的谁好谁坏,而是根据业务的不同各有优势,在高并发的情况下,如果事务之间的资源竞争冲突较小,使用乐观锁效率更好;反之,如果事务之间的资源竞争冲突较大,则使用悲观锁较好,能避免冲突的发生,因为使用阻塞的方式。

2 锁的类型—共享锁、排它锁

InnoDB存储引擎实现了如下两种标准的行级锁

  • 共享锁
  • 排它锁
2.1 共享锁(Share locks)

S锁,也称读锁,事务A对对象T加S锁,其他事务也只能对T加S锁,多个用户可以同时读,但是不能允许有事务修改, 任何事务都不允许获得数据上的排它锁,直到除自身的其他事务释放掉共享锁 。

性质

  1. 多个事务可封锁同一个共享页;
  2. 任何事务都不能修改该页;
  3. 通常是该页被读取完毕,S锁立即被释放。

举例

在SQL Server中,默认情况下,数据被读取后,立即释放共享锁。
例如,执行查询语句 SELECT * FROM my_table 时,首先锁定第一页,读取之后,释放对第一页的锁定,然后锁定第二页。这样,就允许在读操作过程中,修改未被锁定的第一页。
例如,语句 SELECT * FROM my_table HOLDLOCK 就要求在整个查询过程中,保持对表的锁定,直到查询完成才释放锁定。

显示产生共享锁的SQL语句如下:

select ... from table where ... lock in share mode;
2.2 排它锁(Exclusive locks)

X锁,也称写锁,事务A对对象加X锁以后,只有事务A可以读写该对象,其他事务不能对该对象加任何锁,直到事务A释放X锁。

性质

  1. 仅允许一个事务封锁此页;
  2. 其他任何事务必须等到X锁被释放才能对该页进行访问加锁;
  3. X锁一直到事务结束才能被释放。

显示产生排它锁的SQL语句如下:

select ... from ad_plan for update;
2.3 意向锁

innoDB存储引擎支持多粒度锁定,这种锁定允许事务在行级别上的锁和表级别上的锁同时存在。为了支持多粒度锁定方式,InnoDB存储引擎支持一种额外的的锁方式,称之为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意向锁以为这事务希望在更细粒度上进行加锁。
共享锁和排它锁时行级别上的锁,意向锁是表级别上的锁,设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。InnoDB支持两种意向锁:

意向共享锁(IS Lock):事务想要获得一张表中某几行的共享锁。
意向排他锁(IX Lock):事务想要获得一张表中某几行的排他锁。

下图为InnoDB存储引擎中锁的兼容性(两种锁可以共存称为兼容,反之为不兼容):
在这里插入图片描述

3 一致性非锁定读和一致性锁定读

之前在 InnoDB 中四种事务隔离级别是如何实现的? 这篇文章中详细讲解了一致性非锁定读的概念,即select操作使用一致性非锁定读,读取的是快照数据,通过多版本控制(MVCC)实现。而有些情况为了保证数据逻辑的一致性,即使对于select的只读操作,也要加锁,加锁的select是当前读,即读取当前数据库最新的数据,包括可重复读模式下,A事务中读取B事务未提交的修改数据,以下方式都是锁定读:

select ... from table where ... lock in share mode;
select ... from ad_plan for update;

上述两个SQL,无论使用哪一种,必须放在事务中,当事务提交的时候,锁就释放了。

select * from ad_plan for update;主要是用来防止死锁的。因为使用共享锁时,修改数据的操作分为两步,首先获得一个共享锁,读取数据,然后将共享锁升级为排它锁,然后再执行修改操作。这样如果同时有两个或多个事务同时对一个对象申请了共享锁,在修改数据的时候,这些事务都要将共享锁升级为排它锁。这些事务都不会释放共享锁而是一直等待对方释放,这样就造成了死锁。如果一个数据在修改前的select语句中直接申请排它锁,其他事务再也无法获取改行的共享锁或者排它锁,就可以避免死锁。

4 外键和锁

在InnoDB存储引擎中,对于一个外键列,如果没有显示的给该类加索引,InnoDB会自动对其加一个索引,因为这样可以避免表锁。
对于外键值的插入或更新,首先需要查询父表中的记录,即select父表。但是对于父表的select操作,不是使用一致性非锁定读,因为这是读取快照数据,会导致数据不一致,因此这时使用的是select … from table where … lock in share mode方式,即对父表加S锁,如果这时父表已经加了X锁,则子表上的操作会被阻塞,如果对父表加了S锁之后,父表上有update之类的需要获取X锁的操作也会被阻塞,这样就实现了数据的一致性。

5 行锁的三种算法

  • 行锁锁(Record Lock)
  • 间隙所(Gap Lock)
  • 临键锁(Next-Key Lock)【解决幻读的关键】

间隙锁和临键锁只有在可重复读隔离级别下才会生效

行锁

索引分为主键索引和非主键索引两种,如果一条sql 语句操作了主键索引,Mysql 就会锁定主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,如果会继续查主键索引,则再锁定相应的主键索引。

行锁:锁定具体某一行记录的索引

间隙锁(Gap Lock)

锁定一个范围,但不包含记录本身,多个事务可以对同一范围的索引项加锁,但其他任何一个事务不能在锁范围内插入\修改数据。

临键锁(Next-Key Lock)

该锁是行锁+间隙锁,锁定一个范围,并锁定记录本身。每个next-key Lock是左开右闭区间,在可重复读隔离级别下才会发生(该锁是防止幻读的关键),如表t有id为1,5,10的三条记录,执行select * from t for update的时候把整个表记录锁住,产生4个临键锁:(负无穷大,0], (0, 1], (1, 5], (5, 10], (10, +supremum],InnoDB 给每个索引加了一个不存在的最大值supremum,这才符合“左开右闭区间”。

6加锁规则

在InnoDB引擎的默认隔离级别可重复读下,使用的是Next-Key Lock算法,加锁规则如下(mysql版本:mysql7.2,隔离级别:可重复读,引擎:InnoDB)。
两个规则:

  • 加锁的默认算法是Next-Key Lock
  • 查询过程中访问到的对象才会加锁

两个优化:

  • 索引上的等值查询,给唯一索引加锁的时候,临键锁会退化为行锁
  • 索引上的等值查询,向右遍历是且第一次找到一个不满足等值条件的时候,临键锁会退化为间隙所

概念:
锁是加在索引上的(案例2会解释其含义)

举例:

CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
案例1

会话A:
begin;
update t set d=d+1 where id=7;
会话B:
insert into t values(8,8,8); 【阻塞】
会话C:
update t set d=d+1 where id=10; 【成功】
分析

  1. 根据可重复读隔离级别下的Next-Key Lock算法,会锁住(5, 10]
  2. 由于会话A是索引等值查询,根据优化2,id=10不符合等值条件,临键锁退化为间隙锁(5,10)

因此最终锁住的范围是(5,10)

案例2

会话A:
begin;
select id from t where c=5 lock in share mode;
会话B:
update t set d=d+1 where id=5;
会话C:
insert into t values(7,7,7); 【阻塞】

分析
  1. 根据可重复读隔离级别下的Next-Key Lock算法,会锁住(0,5]
  2. 由于c只是普通索引,因此扫描到c=5这一行不会立即停止,而是继续向右扫描,遇到c=10时停止,因此锁住(5,10]
  3. 但是c=10不符合等值条件,因此临键锁(5,10]退化为间隙所(5,10)
  4. 根据规则2,select id from t where c=5 lock in share mode;使用了覆盖索引,因此不会走主键索引,主键索引也就不会被上锁

最终加锁为辅助索引上的临键锁(0,5]和间隙所(5,10)。
注意:会话A中使用的是lock in share mode,如果用的是for update,则主键索引也会被锁上。
通过这个例子,也刚好解释锁的一个概念,锁是加在索引上的

案例3

会话A:
begin;
select * from t where id>=10 and id<11 for update;
会话B:
insert into t values(8,8,8);【成功】
insert into t values(13,13,13);【阻塞】
会话C:
update t set d=d+1 where id=15; 【阻塞】
分析:

  1. 建立临键锁(5,10]
  2. 最后一个10符合等值条件id=10,因此临键锁退化为行锁,只在id=10这一行加锁
  3. 继续沿着主键索引向后扫描,建立临键锁(10, 15]
  4. id<11不是等值查询,因此临键锁(10, 15]也不会退化成间隙所

最终加锁为id=10上的行锁和临键锁(10, 15]

案例4

会话A:
begin;
select * from t where c>=10 and c<11 for update;
会话B:
insert into t values(8,8,8);【阻塞】
会话C:
update t set d=d+1 where id=15; 【阻塞】
分析:

  1. 建立临键锁(5,10]
  2. 沿着赋值索引继续向后扫描,建立临键锁(10,15]

最终加锁为临键锁(5,10]和临键锁(10,15]

案例5

还有一个加锁的概念需要理解的是:虽然有next-key lock 最开右闭区间的加锁算法,但是实际加锁的过程是分成两步骤的:先加间隙所,成功后,再给右边的记录加行锁,合起来才是临键锁的加锁步骤。
下面通过一个案例讲解,依旧上之前案例的表t
会话A:
begin;
select id from t where c=10 lock in share mode;
会话B:
update t set d=d+1 where id=10;【阻塞】
会话A:
insert into t values(8,8,8);
会话B:
会话A的插入操作会导致死锁,然后InnoDB引擎会让会话B回滚,让会话A执行成功

分析:

  1. 会话A会导致辅助索引c上加临键锁(5,10]和间隙锁(10,15)
  2. 会话B修改id=10的记录,根据加临键锁的两个步骤以及临键锁不是互斥锁的特性,先在(5,10)上加间隙锁成功,再在c=10的辅助索引上加行锁,被阻塞等待会话A释放锁
  3. 会话A插入c=8的记录,被会话B的间隙锁阻塞等待会话B释放锁

因此造成死锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值