MySQL之锁
锁是计算机在执行多线程或线程时用于并发访问同一共享资源时的同步机制,MySQL中的锁是在服务器层或者存储引擎层实现的,保证了数据访问的一致性与有效性
MySQL锁可以按模式分类为:乐观锁与悲观锁。
按粒度分可以分为全局锁、表级锁、页级锁、行级锁按属性可以分为:共享锁(读锁/S锁)、排它锁(写锁/X锁)
按状态分为:意向共享锁、意向排它锁
按算法分为:间隙锁、临键锁、记录锁。
全局锁
全局锁就是对整个数据库实例加锁
应用场景
一般用与数据备份 全库导出等全库操作
实现方式
Flush tables with read lock
当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句
风险:
如果在主库上备份,那么在备份期间都不能执行更新,业务基本上就能停止。
如果在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟
解决办法:
mysqldump使用参数–single-transaction,启动一个事务,确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的
表锁
当前操作的整张表加锁,最常使用的 MyISAM 与 InnoDB 都支持表级锁定。
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)
应用场景
读操作密集,写操作少,全表更新删除
实现方式
lock tables … read/write
例如lock tables t1 read, t2 write; 命令,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能在unlock tables之前访问其他表
元数据锁:
MDL 不需要显式使用,在访问一个表的时候会被自动加上,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁
MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的
风险:
那如果数据库有一个长事务(所谓的长事务,就是开启了事务,但是一直还没提交),那在对表结构做变更操作的时候,可能会发生意想不到的事情,比如下面这个顺序的场景:
- 首先,线程 A 先启用了事务(但是一直不提交),然后执行一条 select 语句,此时就先对该表加上 MDL 读锁;
- 然后,线程 B 也执行了同样的 select 语句,此时并不会阻塞,因为「读读」并不冲突;
- 接着,线程 C 修改了表字段,此时由于线程 A 的事务并没有提交,也就是 MDL 读锁还在占用着,这时线程 C 就无法申请到 MDL 写锁,就会被阻塞
- 那么在线程 C 阻塞后,后续有对该表的 select 语句,就都会被阻塞,如果此时有大量该表的 select 语句的请求到来,就会有大量的线程被阻塞住,这时数据库的线程很快就会爆满了。
为什么线程 C 因为申请不到 MDL 写锁,而导致后续的申请读锁的查询操作也会被阻塞?
这是因为申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。
解决方案:
所以为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以考虑 kill 掉这个长事务,然后再做表结构的变更。
意向锁
- 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
- 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;
也就是,当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。
如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。
那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。
所以,意向锁的目的是为了快速判断表里是否有记录被加锁
AUTO-INC锁
表里的主键通常都会设置成自增的,这是通过对主键字段声明
AUTO_INCREMENT
属性实现的。之后可以在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 AUTO-INC 锁实现的。
AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放。在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被
AUTO_INCREMENT
修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被
AUTO_INCREMENT
修饰的字段的值是连续递增的。但是, AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。
因此, 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增。
那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被
AUTO_INCREMENT
修饰的字段的值是连续递增的。但是, AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。
因此, 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增。
行锁
InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。
前面也提到,普通的 select 语句是不会对记录加锁的,因为它属于快照读。如果要在查询时对记录加行锁,可以使用下面这两个方式,这种查询会加锁的语句称为锁定读。
//对读取的记录加共享锁
select ... lock in share mode;
//对读取的记录加独占锁
select ... for update;
上面这两条语句必须在一个事务中,因为当事务提交了,锁就会被释放,所以在使用这两条语句的时候,要加上 begin、start transaction 或者 set autocommit = 0
行级锁的类型主要有三类:
- Record Lock,记录锁,也就是仅仅把一条记录锁上;
针对行数据加锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的:
- 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容);
- 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。
- Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身;
Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。
间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的。
- Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。
假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改 id = 5 这条记录。所以,next-key lock 即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。
next-key lock 是包含间隙锁+记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。
比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞。
虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的。
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。
如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态。
举个例子,假设事务 A 已经对表加了一个范围 id 为(3,5)间隙锁。
当事务 A 还没提交的时候,事务 B 向该表插入一条 id = 4 的新记录,这时会判断到插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态(PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁,属于行级别锁。
如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。
插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。
页级锁
页级锁是 MySQL 中比较独特的一种锁定级别,在其他数据库管理软件中并不常见。
页级锁的颗粒度介于行级锁与表级锁之间,所以获取锁定所需要的资源开销,以及所能提供的并发处理能力同样也是介于上面二者之间。另外,页级锁和行级锁一样,会发生死锁。
页级锁主要应用于 BDB 存储引擎。
乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。
应用场景
适用于读多写少,因为如果出现大量的写操作,写冲突的可能性就会增大,业务层需要不断重试,会大大降低系统性能。
实现方式
一般使用数据版本(Version)记录机制实现,在数据库表中增加一个数字类型的“version”字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。
悲观锁
悲观锁,正如其名,具有强烈的独占和排他特性,每次去拿数据的时候都认为别人会修改,对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。
应用场景
适用于并发量不大、写入操作比较频繁、数据一致性比较高的场景。
实现方式
在MySQL中使用悲观锁,必须关闭MySQL的自动提交,set autocommit=0。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。
死锁
两个事务都持有对方需要的锁并等待对方释放,且双方都不会释放自己的锁
产生死锁的必要条件
- 两个或者两个以上的事务
- 两个事务都持有锁并申请新的锁
- 锁资源同时只能被同一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁而互相等待
如何处理死锁
- 等待直至超时
当两个事务中有一个事务设置时间超过等待时间的阈值就将其回滚,另外事务进行;
innodb中innodb_lock_wait_timeout设置超时时间
但这个超时时间对于在线服务是不可接受的,过大会导致在等待时间其他线程无法访问资源进入长时间等待状态;过短容易误伤短时间锁的等待
- 进行死锁检测
死锁检测原理是构建一个以事务为顶点,锁为边的有向图,判断图中是否存在环,存在即有死锁
一旦检测到死锁,innodb会选择回滚undo量最小的事务,让其他事务继续进行
如何避免死锁
- 合理设置索引,使业务SQL尽量定位更少的行减少锁的竞争
- 调整业务逻辑SQL执行顺序,避免update/delete长时间持有锁的SQL在事务前面
- 避免大事务,尽量大事务拆解成多个小事务进行,小事务减少资源锁定的时间,发生锁冲突的情况更少
- 降低隔离级别。如果业务允许,将隔离级别从RR降低到RC,可以减少因为gap锁造成的死锁
锁的内存结构
锁所在的事务信息
不论是表锁还是行锁都是在事务执行的过程中生成的,所以我们需要记录生成锁的事务信息,通过指针可以在内存中快速找到事务的信息
索引信息
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的,也是一个指针
表锁/行锁信息
表锁记录对加锁表信息的记录
行锁记录:
space_id 记录所在表空间
page_num 页号
n_bits 对于行锁来说,一条记录对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区别哪一条记录加了锁
type_mode
lock_mode
lock_type
rec_lock_type
其他信息
为了更好的管理系统运行中生成的各种锁结构设计的各种哈希表和链表