Dawn's Blogs

分享技术 记录成长

0%

MySQL高级 (10) 锁

MySQL中利用来保证事务的隔离性,对并发操作进行控制。同时,锁冲突也是影响数据库并发访问性能的重要因素。

并发问题的解决

脏写

对于两个事务都进行写数据(写-写情况)的操作,可能会产生脏写问题,这是任何一种隔离级别都不允许这种问题的发生的。

所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行(通过锁实现)。

脏读 不可重复读 幻读

对于一个事务进行读取操作一个事务进行写数据的操作(读-写情况),可能会产生脏读、不可重复读和幻读的问题。对于这些问题,有两种解决方案。

方案一:读操作利用多版本并发控制MVCC,写操作进行加锁

所谓MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本。查询语句只能到生成ReadView之前已提交事务所做的更改。而写操作肯定针对的是最新的版本信息记录的历史版本和改动记录的最新版本并不冲突,也就是采用MVCC时,读-写并不冲突

普通的SELECT语句在READ COMMITTEDREPEATABLE READ隔离级别下会使用到MVCC读取记录:

  • READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象。
  • REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读幻读的问题。

方案二:读、写操作都采用加锁方式

如果一些场景下不允许读取记录的旧版本,而是每次都需要读取最新的记录版本。此时取记录时也需要加锁读操作和写操作一样,也需要排队执行

小结

  • 采用MVCC方式,读-写操作并不冲突,性能更高
  • 采用读操作加锁的方式,读写都需要排队执行,影响性能。但是可以保证读操作每次都能读到最新的记录版本。

锁的分类

MySQL中锁的分类如下:

MySQL锁家族

从数据操作的类型划分:读锁、写锁

读锁/共享锁

读锁,也称为共享锁,用S表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。

1
2
3
4
5
SELECT ...
LOCK IN SHARE MODE; # 加S锁
# 或者
SELECT ...
FOR SHARE # 8.0新语法

写锁/排他锁

写锁,也称为排他锁,用X表示。当前写操作没有完成前,它会阻塞其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

有时查询数据时也需要加上X锁,防止其他事务修改当前读取的记录:

1
2
SELECT ...
FOR UPDATE; # 加X锁

MySQL 8.0新特性

在8.0版本中,可以为SELECT ... FOR UPDATESELECT ... FOR SHARE添加NOWAITSKIP LOCKED语法,跳过锁等待或者跳过锁定:

  • NOWAIT:查询的行已经加锁则立即报错返回
  • SKIP LOCKED:立即返回,返回的结果不包括被锁定的行

对于InnoDB来说,读锁和写锁既可以加在表上,也可以加在行上。一般情况下,InnoDB不会使用表级别的S锁和X锁,而是使用行级别的S锁和X锁(并发程度更高)。

从数据操作的粒度划分:表级锁、页级锁、行锁

为了尽可能的提高数据库的并发程度,锁粒度越小越好,但是锁粒度越小会消耗更多的系统性能来管理锁。所以需要平衡并发性能系统性能两方面的关系。

表级锁

表级别的S锁和X锁

1
2
3
4
LOCK TABLES table_name READ;		# InnoDB对表table_name加上S锁
LOCK TABLES table_name WRITE; # InnoDB对表table_name加上X锁

UNLOCK TABLES; # 释放表级S/X锁

一般情况下,不会使用InnoDB存储引擎提供的表级别的S锁和X锁,而是使用行级别的S锁和X锁。

表级别读/写锁

意向锁

InnoDB 支持多粒度锁,它允许行级锁与表级锁共存,而意向锁就是其中的一种表级锁。

比如,现在有两个事务T1和T2。其中T2视图在该表级上应用共享锁或者排他锁,如果没有意向锁的存在,那么T2就必须检查各个行是否存在锁;如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。T2在锁定该表之前不必检查各个页或者行锁,而只需要检查表上的意向锁。

如果我们给某一行数据加上排他锁,数据库会自动给更大一级的空间加上意向锁,告诉其他人这个数据页或者数据表已经有人上过排他锁了。当其他人需要获取整个表的排他锁时,只需要了解是这个表上的意向锁即可。

意向锁有两种:

  • 意向共享锁(IS):表示事务意图在表中的某行上设置共享锁
  • 意向排他锁(IX):表示事务意图在表中的某行上设置排他锁

意向锁是存储引擎自己维护的,在为数据行加共享/排他锁之前,InnoDB会获取该数据行所在数据表对应的意向锁

自增锁

自增锁保证具有AUTO_INCREMENT的字段在插入数据时是递增且唯一的。

元数据锁(MDL锁)

元数据(meta data lock,MDL)锁属于表级锁的范畴。MDL锁用来保证读写的正确性。

设想这样一个场景,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更(比如增加一个列),这样查询的数据跟表结构对不上,这样是不行的。

当对一个表做增删改查操作的时候,加上MDL读锁;当要对表做结构变更操作的时候,加MDL写锁

MDL读锁之间是不互斥的,因为可以有多个线程对同一张表进行增删改查操作;但是MDL读写锁之间、MDL写锁之间是互斥的,用来保证表结构变更的安全性。MDL锁解决了DML和DDL操作之间的一致性问题,不需要显式使用会自动加上。

行级锁

行锁用于锁住某一行(记录),行级锁只在存储引擎层实现。

优点:锁的粒度小,发生锁冲突的概率低,并发程度高

缺点:所得开销比较大,容易出现死锁的情况

记录锁

记录锁也就是仅仅把一条记录锁上,对其他记录没有影响,官方的类型名称为:LOCK_REC_NOT_GAP

记录锁有S锁和X锁之分,称为S型记录锁X型记录锁(也就是行级别的S锁和X锁):

  • 当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁
  • 当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁

间隙锁

MySQLREPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁(间隙锁)方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录上锁。为此,InnoDB提出了一种称之为Gap Locks(间隙锁)的锁,官方的类型名称为LOCK_GAP

间隙锁加在不存在的空间上(用于封锁记录间的间隔),可以是两个索引记录之间,也可能是第一个索引记录之前或最后一个索引之后的空间。

  • 对于在索引字段查询某一条记录的加锁语句,如果该记录不存在,会产生间隙锁。如果记录存在,唯一索引则不会产生间隙锁;其他索引或没有被索引会产生间隙锁(锁定前面的间隙,防止插入同样符合条件的记录)
  • 对于在索引字段上范围查询的语句,会在范围内加上间隙锁。
  • 若在非索引字段上了查询,若记录不存在会发生锁表。

间隙锁的提出仅仅是在REPEATABLE READ级别下,为了防止插入幻影记录而提出的

临键锁

有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks(临键锁)的锁,官方类型名称为LOCK_ORDINARY

临键锁是存储引擎InnoDB事务级别在REPEATABLE READ下使用的数据库锁。

next-key锁 = 记录锁 + 间隙锁,它既能锁住该条记录,又能阻止别的事务将新记录插入到前面的间隙中。

插入意向锁

我们说一个事务在 插入 一条记录时需要判断一下插入位置是不是被别的事务加了gap锁next-key锁也包含 gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为Insert Intention Locks(插入意向锁),官方的类型名称为:LOCK_INSERT_INTENTION

插入意向锁是一种gap锁,不是意向锁。在插入一条记录行前,由INSERT操作产生的一种间隙锁。在可重复读这个隔离级别下生效。

插入意向锁是一种在 INSERT 操作之前设置的一种间隙锁,插入意向锁表示了一种插入意图,即当多个不同的事务,同时往同一个索引的同一个间隙中插入数据的时候,它们互相之间无需等待,即不会阻塞。假设有值为 4 和 7 的索引记录,现在有两个事务,分别尝试插入值为 5 和 6 的记录,在获得插入行的排他锁之前,每个事务使用插入意向锁锁定 4 和 7 之间的间隙,但是这两个事务不会相互阻塞,因为行是不冲突的。

  1. 插入意向锁之间不互斥
  2. 插入意向锁和排他锁之间互斥

页级锁

页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。


注意:

每个层级的锁数量是有限制的,因为锁会占用内存空间锁空间大小也是有限制的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级,即用更大粒度的锁来代替小粒度的锁,比如InnoDB中行锁升级为表锁。这样锁占用的空间降低了,同时数据的并发程度也降低了。

从对待锁的态度划分:乐观锁、悲观锁

乐观锁和悲观锁并不是锁,而是锁的设计思想

悲伤锁

悲观锁总是假设最坏的情况,即每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁

乐观锁假设拿数据时别人对数据的并发修改不是经常发生的,所以不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现乐观锁适用于多读的场景,这样可以提高吞吐量

可以采用版本号机制和时间戳机制:

  • 版本号机制:在表中设计一个版本号字段version,第一次读的时候会获取version值。在删除或者更新操作时,会执行UPDATE ... SET version=version+1 WHERE version=第一次读取的version值。若已经有事务对这条数据做出了修改,那么此时的修改不会成功。
  • 时间戳机制:时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

两种锁的适用场景

  • 乐观锁:适用于多读的场景,优点在于不通过数据库的锁机制实现而是,程序实现,不存在死锁问题,并发程度更高。
  • 悲观锁:适用于多写的场景,因为写的操作具有排他性

按加锁的方式划分:隐式锁和显式锁

隐式锁

除去插入意向锁的场景之外,一般情况下INSERT操作是不加锁的。但是,如果一个事务插入了一条数据,然后另一个事务:

  • 若想要获取这条新插入记录的S锁,可能产生脏读问题;
  • 若想要获取这条新插入记录的X锁,可能产生脏写问题。

所以就可能需要用到事务IDtrx_id,从而产生隐式锁。

  • 对于聚簇索引来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务ID。那么如果在当前事务中新插入一条聚簇索引记录后 ,如果其他事务此时想对该记录添加S锁或者X锁时,会首先查看该记录的trx_id隐藏列所代表的事务是否为活动的(未提交/回滚)。如果是活动的事务,那么会帮助当前事务创建一个X锁,然后自己进入等待状态。
  • 对于二级索引而言,本身没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务ID,如果PAGE_MAX_TRX_ID小于当前最小的活跃事务ID,那么说明对该页面做修改的事务都已经提交了;否则需要进行回表,帮助当前事务创建一个X锁,然后自己进入等待状态。

隐式锁是一种延迟加锁的机制,从而减少锁的数量。

显式锁

与显式锁相反,需要通过特定的语句显式的加锁,如S锁和X锁:

1
2
3
SELECT ... LOCK IN SHARE MODE;

SELECT ... FOR UPDATE;

其他锁:全局锁

全局锁

全局锁就是对整个数据库加锁,当需要整个数据库处于只读状态时,可以加上全局锁:

1
FLUSH TABLES WITH READ LOCK;