vlambda博客
学习文章列表

MySQL-深入浅出锁分类及实现原理

背景

数据库是一个多用户并发使用的共享资源。当多个并发读写数据时,在数据库中就会产生多个事务同时读写同一数据的情况。若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。这个时候就要引入「锁」的概念,来应对上面所说的并发情况。

简介

锁是在执行多线程或者协程时用于强行限制资源访问的同步机制,即用于在并发控制中保证对互斥要求的满足。

本文将按照下图对MySQL锁具体讲解。

MySQL-深入浅出锁分类及实现原理
简介

实现机制

悲观锁

总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加(悲观)锁。一旦加锁, 不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。

MySQL里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现

乐观锁(Optimistic Lock)

每次读写数据的时候都认为别人不会修改该数据,所以不会上锁,但是在提交更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁「适用于读多写少的应用场景,这样可以提高吞吐量」。

实现方式

「数据版本(Version)记录机制实现」

何谓数据版本?

即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 version 字段来实现。当读取数据时,将version字段的值一同读出, 数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对, 如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

MySQL的mvcc的实现就是此种方式,了解mvcc可以查看我的文章

「时间戳(timestamp)」

和数据版本实现方式类似,同样是在需要乐观锁控制的table中增加一个字段, 字段类型使用时间戳(timestamp), 和上面的version类似,在更新提交的时候检查当前数据库中数据的时间戳 和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。

锁粒度

相对于其他的数据库而言,MySQL的锁机制比较简单,最显著的特点就是不同的存储引擎支持不同的锁机制。根据不同的存储引擎,MySQL中锁的特性可以大致归纳如下

引擎 表锁 行锁 页锁
InnoDB 支持 支持
MyISAM 支持

BDB 支持
支持

行锁

开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

  1. 行锁的锁定颗粒度在MySQL中是最细的,应用于InnoDB存储引擎,只针对操作的当前行进行加锁。并发情况下,产生锁等待的概率较低,支持较大的并发数,但开销大,加锁慢,而且会出现死锁。

  2. 在InnoDB中使用行锁有一个前提条件:「检索数据时需要通过索引!因为InnoDB是通过给索引的索引项加锁来实现行锁的。」

  3. 在不通过索引条件查询的时候,InnoDB会使用表锁,这在并发较大时,可能导致大量的锁冲突。此外,行锁是针对索引加锁, 存在这种情况,虽然是访问的不同记录,但使用的是同一索引项,也可能会出现锁冲突。

提示:不一定使用了索引检索就一定会使用行锁,也有可能使用表锁。因为MySQL会比较不同执行计划的代价, 当全表扫描比索引效率更高时,InnoDB就使用表锁。因此需要结合SQL的执行计划去分析锁冲突。

  1. 行锁会产生死锁,因为在行锁中,锁是逐步获得的,主要分为两步:锁住主键索引,锁住非主键索引。如:当两个事务同时执行时,一个锁住了主键索引,在等待其他索引;另一个锁住了非主键索引,在等待主键索引。这样便会发生死锁。InnoDB一般都可以检测到这种死锁,并使一个事务释放锁回退,另一个获取锁完成事务。

表锁

表级锁为表级别的锁定,会锁定整张表,可以很好的避免死锁,是 MySQL 中最大颗粒度的锁定机制。

一个用户在对表进行写操作(插入、删除、更新等)时,需要先获得写锁,这会阻塞其它用户对该表的所有读写操作。没有写锁时,其它读取的用户才能获得读锁,读锁之间是不相互阻塞的。

表级锁最大的特点就是实现逻辑非常简单,带来的系统负面影响最小。所以获取锁和释放锁的速度很快。当然,锁定颗粒度大带来最大的负面影响就是出现锁定资源争用的概率会很高,致使并发度大打折扣。

不过在某些特定的场景中,表级锁也可以有良好的性能。例如,READ LOCAL 表级锁支持某些类型的并发写操作。另外,写锁也比读锁有更高的优先级,因此一个写锁请求可能会被插入到读锁队列的前面(写锁可以插入到锁队列中读锁的前面,反之读锁则不能插入到写锁的前面)。

使用表级锁的主要是 MyISAM,MEMORY,CSV 等一些非事务性存储引擎。

尽管存储引擎可以管理自己的锁,MySQL 本身还是会使用各种有效的表级锁来实现不同的目的。例如,服务器会为诸如 ALTER TABLE 之类的语句使用表级锁,而忽略存储引擎的锁机制。

页锁

页级锁是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中并不常见。

页级锁的颗粒度介于行级锁与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力同样也是介于上面二者之间。另外,页级锁和行级锁一样,会发生死锁。

页级锁主要应用于 BDB 存储引擎。

应用场景

从锁的角度来说,表级锁适合以查询为主,只有少量按索引条件更新数据的应用,如 Web 应用。而行级锁更适合于有大量按索引条件,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统。

「MySQL 这 3 种锁的特性可大致归纳如下」


表级锁 行级锁 页级锁
开销 介于表级锁和行级锁之间
加锁 介于表级锁和行级锁之间
死锁 不会出现死锁 会出现死锁 会出现死锁
锁粒度 介于表级锁和行级锁之间
并发度 一般

兼容性

概念

InnoDB实现了以下两种类型的行锁。

「共享锁(S)」:又称读锁 (read lock),是读取操作创建的锁。其他用户可以并发读取数据, 但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。当如果事务对读锁进行修改操作,很可能会造成死锁。

「排他锁(X)」:exclusive lock(也叫writer lock)又称写锁。若某个事物对某一行加上了排他锁,只能这个事务对其进行读写,在此事务结束之前, 其他事务不能对其进行加任何锁,其他进程可以读取,不能进行写操作,需等待其释放。「排它锁是悲观锁的一种实现」。

另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁

「意向共享锁(IS)」:事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。

「意向排他锁(IX)」:事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。

意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。

「InnoDB行锁模式兼容性列表」

请求锁模式
是否兼容
当前锁模式
X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IS 冲突 兼容 兼容 兼容

如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。

意向锁是InnoDB自动加的,不需用户干预。对于UPDATE、DELETE和INSERT写操作,InnoDB会自动给涉及数据集加排他锁(X);对于普通SELECT语句,InnoDB不会加任何锁;事务可以通过以下语句显示给记录集加共享锁或排他锁。

共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE。

排他锁(X):SELECT * FROM table_name WHERE ... FOR UPDATE。

用SELECT ... IN SHARE MODE获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在, 并确保没有人对这个记录进行UPDATE或者DELETE操作。但是如果当前事务也需要对该记录进行更新操作, 则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用SELECT... FOR UPDATE方式获得排他锁。

InnoDB在事务执行过程中,使用两阶段锁协议

随时都可以执行锁定,InnoDB会根据隔离级别在需要的时候自动加锁;

锁只有在执行commit或者rollback的时候才会释放,并且所有的锁都是在同一时刻被释放。

举个栗子

「共享锁」MySQL-深入浅出锁分类及实现原理

「排它锁」MySQL-深入浅出锁分类及实现原理

算法

「Record Lock」:单个行记录上的锁

锁总会锁住索引记录,锁住的是key。

如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB会使用隐式的主键进行锁定。

如果要锁的没有索引,则会进行全表记录加锁。

「Gap Lock」 :间隙锁,锁定一个范围,但不包含记录本身

锁定索引记录间隙,确保索引记录的间隙不变

间隙锁时针对事务隔离级别为可重复读或以上级别而配的

Gap Lock在InnoDB的唯一作用就是防止其他事务的插入操作,以此防止幻读

「Next-Key Lock」:临键锁,Gap Lock + Record Lock,锁定一个范围,并且包含记录本身

当查询的索引含有唯一属性时,InnoDB存储引擎会对Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。

当查询的索引为辅助索引时,默认使用Next-Key Locking技术进行加锁,锁定范围是前一个索引到后一个索引之间范围。

例如一个索引有10,11,13,20这四个值,那么该索引可能被Next-Key Locking的区间为:

  • (-∞,10】
  • (10,11】
  • (11,13】
  • (13,20】
  • (20,+∞)

在Next-Key Lock 算法下,InnoDB对于行的查询都是采用这种锁定的算法。可以有效的防止幻读

间隙锁在InnoDB的唯一作用就是防止其它事务的插入操作,以此来达到防止幻读的发生,所以间隙锁不分什么共享锁与排它锁。默认情况下,InnoDB工作在Repeatable Read隔离级别下,并且以Next-Key Lock的方式对数据行进行加锁,这样可以有效防止幻读的发生。

要禁止间隙锁的话,可以把隔离级别降为Read Committed,或者开启参数innodb_locks_unsafe_for_binlog。

对于快照读来说,幻读的解决是依赖mvcc解决。而对于当前读则依赖于gap-lock解决。 

「当前读」

读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。

「快照读」

  • 简单的select操作(不包括 select ... lock in share mode, select ... for update)。

  • Read Committed隔离级别:每次select都生成一个快照读。

  • Read Repeatable隔离级别:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读。

小结

本文主要讲解了锁的实现机制,MySQL中常见的锁类型、兼容方式和算法。

  • 表锁,行锁在不同的场景下有各自的优势,具体看业务选型。
  • 共享锁和互斥锁要合理应用,否则会导致死锁。
  • MySQL四种隔离级别,除了串行化是不能解决幻读的,固引入了临键锁,间隙锁解决此问题。

往期推荐


「感谢阅读,加个关注,欢迎点赞和转发!!!」