锁是Mysql在服务器层和存储引擎层的的并发控制,本文将从不同维度对MySQL锁相关的知识点做一个总结。
一、锁的粒度
从粒度来分,分为表锁、页面锁和行锁。
表锁一般由Mysql Server实现,不同的存储引擎支持不同的锁机制
MyISAM和MEMORY存储引擎采用的是表级锁;
BDB存储引擎采用的是页面锁;
InnoDB存储引擎既支持行级锁又支持表级锁,默认情况采用行级锁。
不同粒度的锁对比
表级锁:
开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
这些存储引擎通过总是一次性同时获取所有需要的锁以及总是按相同的顺序获取表锁来避免死锁。
表级锁更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用,如Web 应用
行级锁:
开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
最大程度的支持并发,同时也带来了最大的锁开销。
在 InnoDB 中,除单个 SQL 组成的事务外,锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。
行级锁只在存储引擎层实现,而Mysql服务器层没有实现。
行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统.
二、锁的模式
锁的模式有:读锁,写锁,读意向锁,写意向锁,自增锁。
1. 读锁(或共享锁、S锁)与写锁(或排他锁、X锁)
读锁,又称共享锁(Share locks,简称 S 锁),加了读锁的记录,所有的事务都可以读取,但是不能修改,并且可同时有多个事务对记录加读锁。
写锁,又称排他锁(Exclusive locks,简称 X 锁),或独占锁,对记录加了排他锁之后,只有拥有该锁的事务可以读取和修改,其他事务都不可以读取和修改,并且同一时间只能有一个事务加写锁。
2. 读写意向锁
意向锁是一种表级锁,可以分为读意向锁(IS)和写意向锁(IX)。当事务要在记录上加上读锁或者写锁的时候,需要现在表上加上意向锁。这样判断表中是否有记录加锁就很简单了,只要看表上是不是有意向锁就行了。
意向锁之间是不会产生冲突的,也不和自增表锁冲突,它只会阻塞表级读锁或表级写锁,另外,意向锁也不会和行锁冲突,行锁只会和行锁冲突。
3. 自增锁
自增锁(一般简写成AI锁)是一种表级锁,当表中有自增列时出现。当插入表中有自增列时,数据库需要自动生成自增值,它会先为该表加 AI锁,阻塞其他事务的插入操作,这样保证生成的自增值肯定是唯一的。
AI锁有一下两个特点:
- AI锁互不兼容,也就是说同一张表同时只允许有一个自增锁;
- 自增值一旦分配了就会 +1,如果事务回滚,自增值也不会减回去,所以自增值可能会出现中断的情况。
显然,AI锁会导致并发插入的效率降低,为了提高插入的并发性,MySQL 从 5.1.22 版本开始,引入了一种可选的轻量级锁(mutex)机制来代替AI锁,可以通过参数 innodbautoinclockmode 来灵活控制分配自增值时的并发策略。
4. 不同模式锁的兼容矩阵
下面是各个表锁之间的兼容矩阵。
总结起来有下面几点:
- 意向锁之间互不冲突;
- S 锁只和 S/IS 锁兼容,和其他锁都冲突;
- X 锁和其他所有锁都冲突;
- AI 锁只和意向锁兼容;
三、锁的使用场景
从使用场景上来细分,行锁还可以分为:
Record Lock 记录锁
Gap Lock 间隙锁
Next-Key Lock Record Lock + Gap Lock,锁定一个范围,并且锁定记录本身, MySql 防止幻读,就是使用此锁实现
II Gap Lock 插入意向锁
1.记录锁
记录锁是最简单的行锁,并没有什么好说的。当 SQL 语句无法使用索引时,会进行全表扫描,这个时候 MySQL 会给整张表的所有数据行加记录锁,再由 MySQL Server 层进行过滤。但是,在 MySQL Server 层进行过滤的时候,如果发现不满足 WHERE 条件,会释放对应记录的锁。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。
所以更新操作必须要根据索引进行操作,没有索引时,不仅会消耗大量的锁资源,增加数据库的开销,还会极大的降低了数据库的并发性能。
2.间隙锁
举个例子,现在有一条SQL,更新表中id=49的这条记录,如果这条记录不存在,这个 SQL 语句还会加锁吗?答案是可能有,这取决于数据库的隔离级别。这种情况下,在 RC 隔离级别不会加任何锁,在 RR 隔离级别会在 id = 49 前后两个索引之间加上间隙锁。
间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。这个间隙可以跨一个索引记录,多个索引记录,甚至是空的。使用间隙锁可以防止其他事务在这个范围内插入或修改记录,保证两次读取这个范围内的记录不会变,从而不会出现幻读现象。
值得注意的是,间隙锁和间隙锁之间是互不冲突的,间隙锁唯一的作用就是为了防止其他事务的插入,所以加间隙 S 锁和加间隙 X 锁没有任何区别。
3.Next-Key锁
Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。假设一个索引包含 15、18、20 ,30,49,50 这几个值,可能的 Next-key 锁如下:
(-∞, 15],(15, 18],(18, 20],(20, 30],(30, 49],(49, 50],(50, +∞)
通常我们都用这种左开右闭区间来表示 Next-key 锁,其中,圆括号表示不包含该记录,方括号表示包含该记录。前面四个都是 Next-key 锁,最后一个为间隙锁。和间隙锁一样,在 RC 隔离级别下没有 Next-key 锁,只有 RR 隔离级别才有。还是之前的例子,如果 id 不是主键,而是二级索引,且不是唯一索引,那么这个 SQL 在 RR 隔离级别下就会加如下的 Next-key 锁 (30, 49](49, 50)
此时如果插入一条 id = 31 的记录将会阻塞住。之所以要把 id = 49 前后的间隙都锁住,仍然是为了解决幻读问题,因为 id 是非唯一索引,所以 id = 49 可能会有多条记录,为了防止再插入一条 id = 49 的记录。
4.插入意向锁
插入意向锁是一种特殊的间隙锁(简写成 II GAP)表示插入的意向,只有在 INSERT 的时候才会有这个锁。注意,这个锁虽然也叫意向锁,但是和上面介绍的表级意向锁是两个完全不同的概念,不要搞混了。
插入意向锁和插入意向锁之间互不冲突,所以可以在同一个间隙中有多个事务同时插入不同索引的记录。譬如在上面的例子中,id=30 和id=49之间如果有两个事务要同时分别插入id=32和id=33是没问题的,虽然两个事务都会在id=30和id=50之间加上插入意向锁,但是不会冲突。
插入意向锁只会和间隙锁或 Next-key 锁冲突,正如上面所说,间隙锁唯一的作用就是防止其他事务插入记录造成幻读,正是由于在执行 INSERT 语句时需要加插入意向锁,而插入意向锁和间隙锁冲突,从而阻止了插入操作的执行。
5.不同类型锁的兼容矩阵
不同类型锁的兼容下如下图所示。
其中,第一行表示已有的锁,第一列表示要加的锁。插入意向锁较为特殊,所以我们先对插入意向锁做个总结,如下:
- 插入意向锁不影响其他事务加其他任何锁。也就是说,一个事务已经获取了插入意向锁,对其他事务是没有任何影响的;
- 插入意向锁与间隙锁和 Next-key 锁冲突。也就是说,一个事务想要获取插入意向锁,如果有其他事务已经加了间隙锁或 Next-key 锁,则会阻塞。
其他类型的锁的规则较为简单:
- 间隙锁不和其他锁(不包括插入意向锁)冲突;
- 记录锁和记录锁冲突,Next-key 锁和 Next-key 锁冲突,记录锁和 Next-key 锁冲突;
四、常见加锁场景分析
这一章节将从原理走向实战,分析常见 SQL 语句的加锁场景。
数据库的隔离等级,SQL 语句和当前数据库数据会共同影响该条 SQL 执行时数据库生成的锁模式,锁类型和锁数量。
1.隔离等级对加锁的影响
MySQL的隔离等级对加锁有影响,所以在分析具体加锁场景时,首先要确定当前的隔离等级。
回顾一下数据库四个隔离等级:
读未提交(Read Uncommitted 后续简称 RU):可以读到未提交的读,基本上不会使用该隔离等级。
读已提交(Read Committed 后续简称 RC):存在不可重复读问题,对当前读获取的数据加记录锁。
可重复读(Repeatable Read 后续简称 RR):存在幻读问题,需要对当前读获取的数据加记录锁,同时对涉及的范围加间隙锁,防止新的数据插入导致幻读。
序列化(Serializable):从 MVCC 并发控制退化到基于锁的并发控制,不存在快照读,都是当前读,并发效率急剧下降,不建议使用。
这里说明一下,RC 总是读取记录的最新版本,而 RR 是读取该记录事务开始时的那个版本,虽然这两种读取的版本不同,但是都是快照数据,并不会被写操作阻塞,所以这种读操作称为 快照读(Snapshot Read)
MySQL 还提供了另一种读取方式叫当前读(Current Read),它读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁,根据语句和加锁的不同,又分成三种情况:
SELECT ... LOCK IN SHARE MODE:加共享(S)锁
SELECT ... FOR UPDATE:加排他(X)锁
INSERT / UPDATE / DELETE:加排他(X)锁
当前读在 RR 和 RC 两种隔离级别下的实现也是不一样的:RC 只加记录锁,RR 除了加记录锁,还会加间隙锁,用于解决幻读问题。
2.不同SQL语句对加锁的影响:
不同的 SQL 语句当然会加不同的锁,总结起来主要分为五种情况:
- SELECT ... 语句正常情况下为快照读,不加锁;
- SELECT ... LOCK IN SHARE MODE 语句为当前读,加 S 锁;
- SELECT ... FOR UPDATE 语句为当前读,加 X 锁;
- 常见的 DML 语句(如 INSERT、DELETE、UPDATE)为当前读,加 X 锁;
- 常见的 DDL 语句(如 ALTER、CREATE 等)加表级锁,且这些语句为隐式提交,不能回滚。
其中,当前读的 SQL 语句的 where 从句的不同也会影响加锁,包括是否使用索引,索引是否是唯一索引等等。
3.当前数据对加锁的影响
SQL 语句执行时数据库中的数据也会对加锁产生影响。
比如一条最简单的根据主键进行更新的 SQL 语句,如果主键存在,则只需要对其加记录锁,如果不存在,则需要在加间隙锁。
五、乐观锁、悲观锁
乐观锁和悲观锁是抽象性锁,并不真实存在。
1.乐观锁(Optimistic Lock)
假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。 乐观锁不能解决脏读的问题。
乐观锁, 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。
2.悲观锁(Pessimistic Lock)
假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
悲观锁,顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
举个例子,某商品,用户购买后库存数应-1,如果多个用户同时购买,此时读到的库存数均为n,后面的执行均是update table set 库存数=n-1,这显然是不对的。
使用悲观锁:
|--程序A在查询库存数时使用排他锁(select * from table where id=10 for update)
|--然后进行后续的操作,包括更新库存数,最后提交事务。
|--程序B在查询库存数时,如果A还未释放排他锁,它将等待。
|--程序C同B…
使用乐观锁(靠表设计和代码来实现):
|--一般是在该商品表添加version版本字段或者timestamp时间戳字段
|--程序A查询后,执行更新变成了:update table set num=num-1 where id=10 and version=23
这样,保证了修改的数据是和它查询出来的数据是一致的,而其他执行程序未进行修改。当然,如果更新失败,表示在更新操作之前,有其他执行程序已经更新了该库存数,那么就可以尝试重试来保证更新成功。为了尽可能避免更新失败,可以合理调整重试次数(阿里巴巴开发手册规定重试次数不低于三次)。
总结:对于以上,可以看得出来乐观锁和悲观锁的区别。
1.悲观锁使用了排他锁,当程序独占锁时,其他程序就连查询都是不允许的,导致吞吐较低。如果在查询较多的情况下,可使用乐观锁。
2.乐观锁更新有可能会失败,甚至是更新几次都失败,这是有风险的。所以如果写入较频繁,对吞吐要求不高,可使用悲观锁。
也就是一句话:读用乐观锁,写用悲观锁。