原文地址:http://www.linzichen.cn/article/1571531799593484288
在程序开发中,当多个线程并发操作共享数据时,我们需要保证在任何时刻最多只能有一个线程在操作,以保证数据的 完整性
和一致性
。比如在 JAVA 中,单体应用有 synchornized
和 ReentrantLock
锁,分布式应用有 分布式锁
。同样在数据库中,用户数据作为一种共享资源,同样也提供对应的锁机制。
一、多事务访问的几种情况
并发事务访问相同记录的情况大致可以分为三种:
读读
、写写
、读写或写读
。
1.1 读 - 读
即并发事务相继读取相同的记录。读取操作不会对记录有任何影响,也不会引起什么问题,所以允许这种情况发生。
1.2 写 - 写
即并发事务相继对相同的记录做出改动。
在这种情况下会发生脏写
的问题,所以在多个事务对一条记录做改动时,需要让它们 排队执行
。排队过程就是通过锁
来实现的。它们的流程为:
1、当事务 T1
想对记录改动时,会先判断内存中是否存在当前记录的锁。如果不存在,则 T1
会在内存中生成一个锁结构,与当前记录关联。
锁结构中有很多信息,其中有两个重要的属性:
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, '张三');
但事务T2
对T1
未提交的数据进行了修改:
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中的锁。
四、操作角度:读写锁
对于数据库中并发事务的读-读
不会引起什么问题。对于 写-写
、读-写
或写-读
这些情况可能会引起一些问题,需要通过 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锁
释放掉后才可以。
读-读:
读-写:
4.4 写锁案例
- 对读取记录加
X锁
:
在有些场景下,比如涉及到金额存取款问题等,我们需要对读操作加X锁
。即当前事务在读取的时候,不需要其他的事务进行读写操作。
select …… for update;
在普通的select语句后加for update
,如果当前事务执行了该语句,那么事务会为读取的记录加X锁
。此时其他事物,无论是对这些记录加S锁
还是 X锁
,都会进入阻塞状态。
写-读:
写-写:
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 添加NOWAIT
、SKIP 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)
结构来实现的。
手动获取表 的 S锁
或者X锁
:
lock tables t read
:对表t
加S锁
。lock tables t write
:对表t
加X锁
。lock tables t1 read, t2 write;
:对表t1
加S锁
,t2
加写锁。
查看正在加锁的表:
show open tables where In_use > 0;
释放当前会话的读写锁:
unlock tables
:只能释放当前会话的锁,其他会话的锁释放不了。
小结
关于表锁的读写类型,可以用下面表格来概述:
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
读锁 | 是 | 否 | 否 | 是 | 否,等 |
写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
一般情况下不会对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
锁可以共存,其余的表锁与意向锁互斥。
小结
- InnoDB支持
多粒度锁
,行级锁可以与表级锁共存。 - 意向锁之间互相兼容,意向锁与行级锁之间互相兼容。
- 意向共享锁与表共享锁兼容,其余的意向锁与表锁互不兼容。
- 意向锁在保证并发的前提下,实现了
行锁和表锁共存
且满足事务隔离性
的要求。
5.1.3 表级别的元数据锁
mysql5.5 引入了 meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。比如如果一个查询正在遍历表中的数据,而执行期间另一个线程对这个表结构做变更
,增加了一个字段,那么查询线程拿到的结果跟表结构对不上,肯定不允许的。
因此,当对一个表做增删改查操作的时候,加MDL读锁;当对表做结构变更操作的时候,加MDL写锁。
读锁之间不互斥,因此可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显示调用
,在访问一个表的时候会自动加上。
此时返现DDL操作的事务处于等待状态,看一下当前正在运行的线程,发现该事务正在进行元数据锁
等待。
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中用悲观锁,必须确定使用了索引而不是全表扫描,否则将会被这个表锁上。
小结:悲观锁不使用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问行,这样对数据库性能开销影响很大,特别是长事务
而言,这样的开销往往无法承受
,这时候就需要乐观锁。
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索引数据结构 一文中,提到过行格式的示意图。
在 其他信息
中存在着该记录的一些隐藏信息,我们称之为 隐藏列
。隐藏列包含以下三部分:
- 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 产生条件
- 两个或以上事务。
- 每个事务都已经持有锁并且申请新的锁。
- 锁资源同事只能被同一个事务持有或者不兼容。
- 事务之间因为持有锁和申请锁导致彼此循环等待。
死锁的关键在于:两个或以上的事务加锁的顺序不一致。
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
锁造成的死锁。