3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
为了提高数据库并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但管理锁是很耗资源(涉及获取、检查、释放锁等动作)。因此数据库系统需要在高并发响应和系统性能两方面进行平衡,这样就产生了“锁粒度(Lock granularity)”的概念。
对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。
1. 表锁(Table Lock)
该锁会锁定整张表,它是MysQL中最基本的锁策略,并不依赖于存储引擎(不管你是MysQL的什么存储引擎对于表锁的策略都是一样的),并且表锁是开销最小的策略(因为粒度比较大)。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。
① 表级别的S锁、X锁
在对某个表执行
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
)结构来实现的(总之就是宁可用元数据锁也不用表级别的S
锁、X锁)。
innodb只会在一些特殊情况下,比方说
崩溃恢复
过程中用到。在系统变量
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
锁
大家了解一下就可以了。
总结 :
MyISAM 在执行查询语句(select)前, 会给涉及的所有表加读锁, 在执行增删改操作前, 会给涉及的表加写锁,
InnoDB
存储引擎是不会为这个表添加表级锁的
读锁
和
写锁
的(因为InnoDB实现了行锁)
② 意向锁 (intention lock)
InnoDB
支持
多粒度锁(
multiple granularity locking
)
,它允许
行级锁
与
表级锁
共存,而
意向
锁
就是其中的一种
表锁
。
1. 意向锁的存在是为了协调行锁和表锁的关系, 支持多粒度(表锁与行锁)的锁并存
2. 意向锁是一种不与行级锁冲突的表级锁, 这点非常重要
3. 表明某个事务正在某些行持有了锁或该事务准备去持有锁
意向锁分为两种:
①
意向共享锁
(
intention shared lock, IS
):事务有意向对表中的某些行加
共享锁
(
S
锁)
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
②意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
SELECT column FROM table ... FOR UPDATE;
即:意向锁是由存储引擎 自己维护的 ,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前, InooDB 会先获取该数据行 所在数据表的对应意向锁 。
1. 意向锁要解决的问题
现在有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁,如果没有意向锁存在,那么T2就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的
表级别意向锁的阻塞
。T2在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。
在数据表的场景中,
如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了
,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。
①
如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加
意向共享锁
。
②
如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加
意向排他锁
。
这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。
举例:
因为共享锁与排他锁互斥,所以事务B在试图对teacher表加共享锁的时候,必须保证两个条件。
(1)当前没有其他事务持有teacher表的排他锁
(2)当前没有其他事务持有teacher表中任意一行的排他锁。
为了检测是否满足第二个条件,事务B必须在确保teacher表不存在任何排他锁的前提下,去检测表中的每一行是否存在排他锁。
很明显这是一个效率很差的做法,但是有了意向锁之后,情况就不一样了。
意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。
即
意向锁之间是互相兼容的(虽然是表级的,但描述的是行级上锁情况),虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥。
注意这里的排他/共享锁指的都是表锁,
意向锁不会与行级的共享/排他锁互斥。回到刚才teacher 表的例子。
意向锁的并发性
意向锁不会与行级的共享 / 排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排 他锁时的并发性。(不然我们直接用普通的表锁就行了)
我们扩展一下上面
teacher
表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可 能存在多种不同锁,但是这里我们只着重表现意向锁)。
从上面的案例可以得到如下结论:
1. InnoDB
支持
多粒度锁
,特定场景下,行级锁可以与表级锁共存。
2.
意向锁之间互不排斥,但除了
IS
与
S
兼容外,
意向锁会与 共享锁
/
排他锁 互斥
。
3. IX
,
IS
是表级锁,不会和行级的
X
,
S
锁发生冲突。只会和表级的
X
,
S
发生冲突。
4.
意向锁在保证并发性的前提下,实现了
行锁和表锁共存
且
满足事务隔离性
的要求。
③ 元数据锁(MDL锁)
MySQL5.5
引入了
meta data lock
,简称
MDL
锁,属于表锁范畴。
MDL
的作用是,保证读写的正确性。比 如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个
表结构做变更
,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,
当对一个表做增删改查操作的时候,加
MDL
读锁;当要对表做结构变更操作的时候,加
MDL
写
锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。
不需要显式使用
,在访问一个表的时候会被自动加上。
2. InnoDB中的行锁
行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。需要的注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。
优点:锁定力度小,发生锁冲突概率低,可以实现的并发度高。
缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。
InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。
先建立student表
① 记录锁(Record Locks)
记录锁也就是仅仅把一条记录锁上,官方的类型名称为:
LOCK_REC_NOT_GAP
。比如我们把
id
值为
8
的那条记录加一个记录锁的示意图如图所示。仅仅是锁住了id
值为8的记录,对周围的数据没有影响。
举例如下:
记录锁是有
S
锁和
X
锁之分的,称之为
S
型记录锁
和
X
型记录锁
。
- 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可 以继续获取X型记录锁;
- 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁。
② 间隙锁(Gap Locks)
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
锁的提出仅仅是为了防止插入幻影记录而提出的
。
间隙锁的引入,可能会导致同样的语句锁住更大的范围, 这其实是影响了并发度的, 下面的例子会产生死锁
- session 1 执行select ... for update 语句, 由于id = 5 这一行并不存在, 因此会加上间隙锁(3,8)
- session 2 执行select ... for update 语句, 同样加上间隙锁(3,8), 间隙锁之间不会冲突, 因此这个语句可以执行成功
- session 2 试图插入一行, 被session 1 的间隙锁挡住,进入等待
- session 1 视图插入一行, 被session 2 的间隙锁挡住, 两个session进入死锁
③ 临键锁(Next-Key Locks)
记录锁 + 间隙锁
④ 插入意向锁(Insert Intention Locks)
我们说一个事务在
插入
一条记录时需要判断一下插入位置是不是被别的事务加了
gap
锁
(
next
-
key
锁
也包含
gap
锁
),如果有的话,插入操作需要等待,直到拥有
gap
锁
的那个事务提交。但是
InnoDB
规
定事务在等待的时候也需要在内存中生成一个锁结构
,表明有事务想在某个
间隙
中
插入
新记录,但是 现在在等待。InnoDB
就把这种类型的锁命名为
Insert Intention Locks
,官方的类型名称为:
LOCK_INSERT_INTENTION
,我们称为
插入意向锁
。插入意向锁是一种
Gap
锁
,不是意向锁,在
insert操作时产生。
插入意向锁是在插入一条记录行前,由INSERT操作产生的一种间隙锁。该锁用以表示插入意向,
当多个事务在同一区间(gap)插入位置不同的多条数据时,事务之间不需要互相等待。假设存在两条值分别为4和7的记录,两个不同的事务分别试图插入值为5和6的两条记录,每个事务在获取插入行上独占的(排他)锁前,都会获取(4,7)之间的间隙锁,但是因为数据行之间并不冲突,所以两个事务之间并不会产生冲突(阻塞等待)。
总结来说,插入意向锁的特性可以分成两部分:
- 插入意向锁是一种特殊的间隙锁—―间隙锁可以锁定开区间内的部分记录。
- 插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待。
注意,虽然插入意向锁中含有意向锁三个字,但是它并不属于意向锁而属于间隙锁,因为意向锁是表锁而插入意向锁是行锁。
比如,把id值为8的那条记录加一个插入意向锁的示意图如下:比如, 现在T1为id值为8 的记录加了一个gap锁, 然后T2 和 T3 分别想向student表中插入id值分别为4,5的两条记录, 所以现在为id值为8的记录加的锁的示意图就如下所示:
从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到id值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
3.3 从对待锁的态度划分:乐观锁、悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待
数据并发的思维方式
。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的
设计思想
。
1. 悲观锁(Pessimistic Locking)
悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会
阻塞
直到它拿到锁(
共享资源每次只给一个线程使用,其它线程阻塞,
用完后再把资源转让给其它线程
)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java
中
synchronized
和
ReentrantLock
等独占锁就是悲观锁思想的实现。
2. 乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是
不采用数据库自身的锁机制,而是通过
程序来实现
。在程序上,我们可以采用
版本号机制
或者
CAS
机制
实现。
乐观锁适用于多读的应用类型,
这样可以提高吞吐量
。在
Java
中
java.util.concurrent.atomic
包下的原子变量类就是使用了乐观锁
的一种实现方式:
CAS
实现的。
1. 乐观锁的版本号机制
在表中设计一个
版本字段
version
,第一次读的时候,会获取
version
字段的取值。然后对数据进行更新或删除操作时,会执行
UPDATE ... SET version=version+1 WHERE version=version
。此时如果已经有事务对这条数据进行了更改,修改就不会成功。
2. 乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。
你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。
3. 两种锁的适用场景
从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:
- 乐观锁 适合 读操作多 的场景,相对来说写的操作比较少。它的优点在于 程序实现 , 不存在死锁 问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
- 悲观锁 适合 写操作多 的场景,因为写的操作具有 排它性 。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止 读 - 写 和 写 - 写 的冲突。