聊聊MySQL中的锁

原文地址:http://www.linzichen.cn/article/1571531799593484288

在程序开发中,当多个线程并发操作共享数据时,我们需要保证在任何时刻最多只能有一个线程在操作,以保证数据的 完整性一致性。比如在 JAVA 中,单体应用有 synchornizedReentrantLock锁,分布式应用有 分布式锁。同样在数据库中,用户数据作为一种共享资源,同样也提供对应的锁机制。

一、多事务访问的几种情况

并发事务访问相同记录的情况大致可以分为三种:

读读写写读写或写读

1.1 读 - 读

即并发事务相继读取相同的记录。读取操作不会对记录有任何影响,也不会引起什么问题,所以允许这种情况发生。

1.2 写 - 写

即并发事务相继对相同的记录做出改动。

在这种情况下会发生脏写的问题,所以在多个事务对一条记录做改动时,需要让它们 排队执行。排队过程就是通过来实现的。它们的流程为:

1、当事务 T1 想对记录改动时,会先判断内存中是否存在当前记录的锁。如果不存在,则 T1会在内存中生成一个锁结构,与当前记录关联。
锁结构.png

锁结构中有很多信息,其中有两个重要的属性:

  • trx信息:当前锁结构是哪个事务生成的。
  • is_waiting:当前事务是否是等待状态。

2、在 T1事务提交之前,另一个事务T2也要对当前记录做改动,但是此时内存中,还存在 T1事务的锁与当前记录关联,所以 T2也会生成一条与当前记录关联的锁结构,只不过 T2锁结构的 is_waiting属性值是 true,代表当前事务需要等待。

3、在事务 T1提交之后,就会把它生成的锁结构释放掉,然后看看内存中有没有别的事务锁在等待状态,发现 T2在等待之后,会把 T2对应的锁结构的 is_waiting属性改为 false,然后把该事务的线程唤醒,让它继续执行,此时事务 T2就算获取到锁了,可以继续执行。

脏写:一个事务修改了另一个事务未提交的数据。
比如事务T1进行了 insert但还未提交:
insert into t1 (id, name) values (1, '张三');
但事务 T2T1未提交的数据进行了修改:
update t1 set name = '李四' where id = 1;
如果T1进行了回滚,那此数据实际是不存在的。这种现象就是脏写,数据库中任何隔离级别都不允许此情况发生。

1.3 读 - 写 或 写 - 读

即一个事务进行读操作,另一个进行写操作。这种情况下可能发生 脏读不可重复读幻读的问题。

脏读:事务T1读了另一个事务T2还未提交的数据。比如 T2进行了insert但还未提交,此时 T1事务select到了此数据,这种现象就是脏读。
不可重复读:事务T1查询了id=1的记录的name字段是张三,此时事务T2对此记录的name改为了 李四,并且提交了。然后 T1再次查询时发现 name字段变成了 李四。前后读取的数据不一致,这种现象就是不可重复读。
幻读:事务 T1查询了表中id>1的记录有3条,此时事务 T2进行了 insert操作(id > 1)并且提交,然后T1再次查询时记录变成了 4条。前后读取的记录数不一致。这种现象就是幻读。

二、并发事务问题解决方案

为了解决 脏读不可重复读幻读 这些现象的发生,其实有两种可选的解决方法。

MVCC

读操作利用 MVCC,写操作加锁

所谓MVCC,就是通过 undo日志的版本链和 ReadView快照构成的多版本并发控制,它实际读取的是版本链中最新提交的数据,而非其他活动事务正在操作的数据,这样就可以避免因为活动中事务的写,造成的一系列的问题。MVCC不是本文的重点内容,所以这里不做过多介绍了。

加锁

脏读是因为事务 T1读到了事务T2写入的未提交的数据。那我们只要在T2写的时候加锁,让T1读的时候等待,等T2提交后才允许读,就可以避免脏读现象。

不可重复读是因为事务 T1先读了一条记录,随后事务T2修改了当前记录并提交,等T1再读时发现跟第一次读的不一致了。那我们只要在T1读的时候加锁,让T2写的时候等待,等T1读完之后T2再去进行写操作,就可以避免不可重复读。

幻读是因为事务T1查询了一个范围内的记录,随后事务T2对当前范围内插入了新的记录并提交,导致T1再查询范围时发现记录数变了。那我们可以在T1查询的范围上给加锁,此时T2在此范围内插入数据时让其等待,就可以避免幻读。

三、锁从不同角度的分类

mysql中的锁,不但保证了数据的一致性,而且还为实现各个隔离级别提供了保证。尤其对于一些敏感数据上我们必须采用加锁的方式,所以锁对数据库而言显得尤其重要,也更加复杂。本文从mysq锁的不同角度,详细聊一聊mysql中的锁。

锁家族.jpg

四、操作角度:读写锁

对于数据库中并发事务的读-读不会引起什么问题。对于 写-写读-写写-读这些情况可能会引起一些问题,需要通过 MVCC加锁方式解决。在使用加锁方式时,由于既要允许读-读情况不受影响,又要使 写-写读-写写-读情况中的操作互相阻塞,所以mysql实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为 共享锁(Shared Lock)排他锁(Excluslve Lock),也叫 读锁(read lock)写锁(write lock)

4.1 共享锁

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

4.2 排他锁

也成为写锁,英文用 X表示。此事务没有完成前,它会阻断其他的排他锁和共享锁。这样能确保在给定的时间里,有且只有一个事务能访问同一资源。

4.3 读锁案例

  • 对读的记录加S锁

一般情况下,我们需要对操作加S锁,以避免不可重复读幻读这些问题。

select …… lock in share moe;
# 或
select …… for share; #(8.0语法)

在普通的select语句后面加 lock in share mode,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,此时允许其他事务继续获取这些记录的S锁,但是不能获取这些记录的X锁。想获取X锁的事务将会阻塞,知道当前事务的S锁释放掉后才可以。

读-读
读读.png

读-写
读写.png

4.4 写锁案例

  • 对读取记录加X锁

在有些场景下,比如涉及到金额存取款问题等,我们需要对读操作加X锁。即当前事务在读取的时候,不需要其他的事务进行读写操作。

select …… for update;

在普通的select语句后加for update,如果当前事务执行了该语句,那么事务会为读取的记录加X锁。此时其他事物,无论是对这些记录加S锁 还是 X锁,都会进入阻塞状态。

写-读
写读.png

写-写
写写.png

4.5 小结:

当一个事务加了S锁,其他事务S锁不影响,X锁阻塞。

当一个事务加了X锁,其他事务S锁阻塞,X锁阻塞。

S锁X锁
S锁不阻塞阻塞
X锁阻塞阻塞

在5.7及之前版本,select …… for update,如果获取不到锁,会一直等待,直到innodb_lock_wait_timeout超时。
在8.0版本中,select …… for update,select …… for share 添加 NOWAITSKIP LOCKED语法,可以跳过锁等待,或者跳过锁定。

如果查询的行已经加锁:

  • NOWAIT:会立即返回报错。
  • SKIP LOCKED:会立即返回结果,结果中不包含被锁定的行。

五、 数据操作粒度角度:表锁、页锁、行锁

为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)。因此数据库系统需要在高并发响应系统性能两方面进行平衡,就产生了 锁粒度的概念。

对一条记录加锁影响的就是该记录而已,我们就说这个锁的粒度比较细;一个事务也可以在表级别进行加锁,我们称之为 表锁,对一个表加锁会影响这个表的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表锁、页锁和行锁。

5.1 表锁

5.1.1 表级别的读写锁

在对表执行select、insert、delete、update 这些DML语句时,InnoDB不会为该表添加 S锁X锁的,但是其他事务对该表进行 alter table、drop table 等这类 DDL语句时会发生阻塞。同理,当事务对表进行 DDL时,其他事务的 DML也会进行堵塞。这个过程是通过在server层使用一种称之为元数据锁(Metadata Locks,简称 MDL)结构来实现的。

表锁.png

手动获取表 的 S锁或者X锁

  • lock tables t read:对表 tS锁
  • lock tables t write:对表 tX锁
  • lock tables t1 read, t2 write;:对表t1S锁t2加写锁。

表S锁.png

查看正在加锁的表:

  • show open tables where In_use > 0;
    in_use.png

释放当前会话的读写锁:

  • unlock tables:只能释放当前会话的锁,其他会话的锁释放不了。

unlock.png

小结

关于表锁的读写类型,可以用下面表格来概述:

锁类型自己可读自己可写自己可操作其他表他人可读他人可写
读锁否,等
写锁否,等否,等

一般情况下不会对InnoDB存储引擎使用表级别的 S锁X锁,它们并不会提供什么额外的保护,只会降低并发能力而已。InnoDB的厉害之处是实现了更细粒度的 行锁

5.1.2 表级别的意向锁

概念

意向锁是由存储引擎自己维护的一种表级锁,我们自己无法手动操作意向锁。当我们给表中的某些记录添加了 共享锁时,存储引擎会自动给所在的表添加 意向共享锁;当我们给表中的某些记录添加了排他锁时,存储引擎会自动给所在的表添加意向排他锁

  • 意向共享锁(intention shared lock,IS):由事务添加行的共享锁时,存储引擎自动给表添加的。
  • 意向排他锁(intention exclusive lock,IX):由事务添加行的排他锁时,存储引擎自动给表添加的。

为什么需要意向锁

此时可能会有疑问,我们既然给表中的记录填加行级的共享/排他锁,为什么还需要给表添加意向锁?其实意向锁的存在,是为了协调行锁和表锁的关系。举个例子:

假如事务T1想给a表添加表的排他锁(X锁),因为排他锁互斥的关系,T1需要在加锁前,判断该表中的所有记录行,是否存在 S锁X锁,如果数据量很大,每一行都去做判断则非常的耗时。所以我们在给表中记录添加S/X锁时,会对应的给表也添加上IS/IX锁。此时事务T1再想给表a添加X锁时,只需要先判断该表是否存在 IS/IX锁即可,就不需要再去遍历每一行判断了,可以提升系统效率。

意向锁之间的关系

行级锁的某一条记录,同一时间内可以存在多个S锁,但只能存在一个X锁,且S锁X锁互相排斥。而在意向锁中,由于多个事务可以给记录同时添加S锁,所以一个表也会存在多个IS锁;由于多个事务可以给表中不同的记录分别添加X锁,所以一个表也会存在多个IX锁。

所以我们得出结论,意向锁之间互不排斥,可以共存。意向锁与行锁之间不排斥,可以共存。

表锁与意向锁之间:表的S锁IS锁可以共存,其余的表锁与意向锁互斥。

小结

  1. InnoDB支持多粒度锁,行级锁可以与表级锁共存。
  2. 意向锁之间互相兼容,意向锁与行级锁之间互相兼容。
  3. 意向共享锁与表共享锁兼容,其余的意向锁与表锁互不兼容。
  4. 意向锁在保证并发的前提下,实现了行锁和表锁共存满足事务隔离性的要求。

5.1.3 表级别的元数据锁

mysql5.5 引入了 meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。比如如果一个查询正在遍历表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一个字段,那么查询线程拿到的结果跟表结构对不上,肯定不允许的。

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

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

元数据锁.png

此时返现DDL操作的事务处于等待状态,看一下当前正在运行的线程,发现该事务正在进行元数据锁等待。

元数据锁等待.png

5.2 InnoDB行锁

行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录)。需要注意的是,mysql 服务层并没有实现行锁机制,行级锁只在存储引擎层实现

优点:锁定粒度小,发生 锁冲突概率低,可以实现的 并发度搞

缺点:对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

InnoDB与MyISAM最大不同有两点:一是支持事务;二是采用了行级锁。

5.2.1 记录锁

记录锁是有S锁和X锁之分的,称之为S型记录锁X型记录锁

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

5.2.2 间隙锁

间隙锁可以解决mysql中幻读问题。由于间隙锁细节比较多,比如聚簇索引与二级索引的间隙锁表现不同,二级索引中覆盖索引与回表的表现又不同。所以单独拿出一盘文章来详细聊聊,此处不再介绍。

5.2.3 临键锁

临键锁可以看做是间隙锁和记录锁的结合,也跟间隙锁放在同一篇文章里聊。

5.2.4 插入意向锁

5.3 页锁

页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定的粒度介于表锁和行锁之间,并发度一般。

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

六、 悲观锁和乐观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想。

6.1 悲观锁

顾名思义,就是以悲观的态度去看待事务访问。所以为了自己的事务不被其他事务干扰,每次访问数据时都会加锁,这样其他事务想访问数据就会阻塞。悲观锁是一种思想,会通过数据库自身的锁机制来实现,从而保证数据操作的排他性。

在一些商场业务系统中,购买商品的业务可以简化为 查询商品库存-下订单-扣减库存。我们应该保证这三步是原子性操作,如果不加锁,可能最后会出现商品库存是负数的情况。而我们需要将执行的sql语句都放在一个事务中,否则达不到原子性的目的。

# 1. 查询商品库存
select quantity from items where id = 1001 for update ;
# 2. 如果库存大于0,则需要生成订单
insert into orders (……) values (……)# 3. 修改商品的库存,num表示购买的数量
update items set quantity = quantity - num where id = 1001;

select …… for update 是mysql中的悲观锁。此时在items表中,if为1001的商品已经被锁定了,如果其他事务要对这个商品进行购买时,必须等待本次事务提交之后才能执行。这样就可以保证当前的数据不会被其他事务修改。

注意:select …… for update 语句执行过程中所有扫描的行都会被锁上,因此在mysql中用悲观锁,必须确定使用了索引而不是全表扫描,否则将会被这个表锁上。

悲观锁.png

小结:悲观锁不使用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问行,这样对数据库性能开销影响很大,特别是长事务而言,这样的开销往往无法承受,这时候就需要乐观锁。

6.2 乐观锁

乐观锁对并发事务持乐观的态度,不在乎其他事务是否修改了数据,所以乐观锁不采用数据库自身的锁机制,而是通过程序来实现的。乐观锁可以用 版本号时间戳机制来实现。

1. 乐观锁的版本号机制

在数据表中设计一个版本字段 version,在读数据时也要把 version 字段的值查出来。然后对数据进行修改的时候,执行 update …… set version = version + 1 where version = version。此时如果有其他事务对这条数据进行了更改,则 where 条件不成立,所以本次修改就不会成功。

这种方式类似于 SVN、GIT版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一直就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。

2. 乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前库里数据的时间戳和之前查出来的时间戳做比较,如果两者一致,则没有被其他事务更改过,可以更新成功,否则就更新失败。

用乐观锁实现商城业务购买:

# 1. 查询商品库存
select quantity, version from items where id = 1001 ;
# 2. 如果库存大于0,则需要生成订单
insert into orders (……) values (……)# 3. 修改商品的库存,num表示购买的数量
update items 
set quantity = quantity - num, version = version + 1 
where id = 1001 and version = #{version} ;

这里我们也可以根据具体业务,因为 库存不能有小于0的场景,所以还可以加一个 where quantity - num >= 0 的条件。

注意:如果数据库采用了读写分离,当 master 表中写入的数据没有及时同步到 slave 表中的时候,会造成更新一直无效的问题,此时需要强制读取master表的数据(即把select语句放到事务中即可,这时候查询的就是master表)。

6.3 两种锁的使用场景

1、乐观锁适合 读操作多的场景,相对来说写的操作比较少。优点在于 基于程序实现不存在死锁问题,适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

2、悲观锁适合写操作多的场景,因为写的操作具有排他性。采用悲观锁的方式,可以在数据层面阻止其他事务对该数据的操作权限,防止 读写写写的冲突。

七、 显式锁和隐式锁

7.1 显示锁

通过特点的语句进行加锁,我们一般称之为显示加锁,例如:

显示加共享锁:

select …… lock in share mode

显示加排他锁:

select …… for update

7.2 隐式锁

在聊隐式锁之前,我们先回顾一下记录的行格式。在 InnoDB索引数据结构 一文中,提到过行格式的示意图。
Compact 行格式

其他信息中存在着该记录的一些隐藏信息,我们称之为 隐藏列。隐藏列包含以下三部分:

  • row_id: 行ID,记录的唯一标识;当用户在表中定义了主键字段就优先选择用户定义的主键,如果没有,就查找是否有定义不为null的唯一索引,如果有就把该列作为主键,如果没有MySQL就会生成一列row_id隐藏列作为主键。
  • trx_id: 事务的ID;记录最后一次操作该数据的事务ID
  • roll_pointer: 回滚指针,指向的是该记录的上一个版本号,MySQL的MVCC主要就是通过这个字段来实现的。

一个事务在执行insert操作时,如果即将插入的间隙已经被其他事务加了 gap锁,那么本次 insert操作会阻塞,并且当前事务会在该间隙上加一个 插入意向锁,否则一般情况下 insert操作是不加锁的。那如果一个事务T1首先插入了一条记录,在未提交事务之前,其他的事务·T2·:

  • 立即使用 S锁读取这条记录,如果允许,就会产生脏读问题。
  • 立即修改这条记录,也就是给这条记录添加X锁,如果允许,就会产生脏写问题。

这时候上面提到过的 事务id就起作用了。事务T1 在进行 insert操作,这条记录的 trx_id就是 该T1事务的id,此时T2想对该记录添加S/X锁时,首先会看一下该记录的trx_id代表的事务T1 是否处于活跃状态,如果T1处于活跃状态,那么T2就会帮T1创建一个X锁,且锁的 is_waiting属性是false,然后T2进入等待状态(也就是给自己再添加一个锁机构,is_waiting属性是 true)。

即:一个事务新插入记录时,不会产生锁结构,但是由于事务id的存在,相当于加了一个 隐式锁。其他事务在对这条记录加锁的时候,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构并进入等待状态。 所以 隐式锁是一种延迟加载的机制,这样的目的是为了减少加锁的数量。

隐式锁在实际内存对象中并不含有这个锁信息,只有当产生锁等待是,隐式锁才会转换为显式锁。

隐式锁的逻辑过程

A. InnoDB的每条记录中都有一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。

B. 在操作一条记录钱,首先根据记录中的 trx_id 检查该事务是否处于活动中事务(未提交)。如果是活动事务,首先将隐式锁转换为显式锁(就是为该事务添加一个锁)。

C. 检查是否有锁冲突,如果有冲突,创建锁,并设置waiting状态为等待。如果没有冲突则不加锁,跳到步骤 E。

D.等待加锁成功,被唤醒,或者超市。

E. 写数据,并将自己的事务的id 写入行记录的 trx_id 字段

八、 全局锁

全局锁就是对整个数据库实例加锁。当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(增删改查)、数据定义语句(建表、修改表结构等) 和 更新类事务的提交语句。全局锁的典型使用场景是做全库逻辑备份

flush table with read lock

九、 死锁

两个事务都持有对方需要的锁,并且都在等待对方先释放,并且双方都不会释放自己的锁。

9.1 举例

事务1事务2
start transaction;
update account set money = 100 where id = 1
start transaction;
update account set money = 100 where id = 2
update account set money = 200 where id = 2
update account set money = 100 where id = 1

9.2 产生条件

  1. 两个或以上事务。
  2. 每个事务都已经持有锁并且申请新的锁。
  3. 锁资源同事只能被同一个事务持有或者不兼容。
  4. 事务之间因为持有锁和申请锁导致彼此循环等待。

死锁的关键在于:两个或以上的事务加锁的顺序不一致。

9.3 如何处理

方式一:等待,知道超市(innodb_lock_wait_timeout = 50s)

即两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。

缺点:对于在线服务来说,这个等待时间往往是无法接受的。假如把这个配置值改短,比如0.1s,也不合适,容易影响到普通的锁等待。

方式二: 使用死锁检测进行死锁处理

innodb提供了 wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,该算法都会被触发。

这是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表事务等待链表两部分信息。

9.4 如何避免

  • 合理实际索引,使业务sql尽可能通过索引定位更少的行,减少锁竞争。
  • 调整业务逻辑sql执行顺序,避免 update/delete 长时间持有锁的sql 在事务里。
  • 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
  • 在并发比较高的系统中,不要显示加锁,特别是在事务里显示加锁。如 select …… for update 语句,如果实在事务里运行了 start transation 或者 设置了 autocommit 等于0,那么就会锁定所有查找到记录。
  • 降低隔离级别。如果业务允许,将隔离级别调低,比如从 RR调整为 RC,可以避免很多因为gap锁造成的死锁。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值