MySQL中的锁有很多种,各种锁应用在不同的地方。「MySQL依靠锁机制可以让多个事务更新一行数据的时候串行化」。
MySQL中锁总的来说有两种概念:Lock和Latch
Latch
称为闩锁(轻量级的锁),因为Latch要求锁定的时间非常短。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。在InnoDB引擎中,Latch又分为mutex(互斥量)和rwlock(读写锁)。
mutex:互斥量;有时候有些资源需要共享和并发,但是又不是分频繁,所以向操作系统申请一个mutex,mutex都是排他的。
RW-LATCH : 读写锁
Lock
「Lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行」。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。
锁的分类
实际上MySQL的锁在不同的维度上划分是多种多样的,在特地的场景下,发挥不一样的作用,下面来看看锁的分类。
按照粒度划分:
全局锁:
「全局锁,即对整个数据库实例加锁」。一般当我们需要让整个库处于只读状态的时候,可以给数据库加上全局锁。「加上全局锁之后其他线程的:数据更新语句(增删改)、数据定义语句(包括建表、修改表结构等)都会被阻塞」。
表级锁(粒度大,性能开销小,并发量小):
「表级别的锁定是MySQL各【存储引擎中】最大颗粒度的锁定机制」。由于直接锁定一张表,所以获取锁和释放锁的速度很快,避免了死锁问题,但是出现锁定资源争用的概率也最高,并发量降低。
「表锁的加锁语法」
1 | #隐式上锁(默认,自动加锁自动释放 |
「表锁的释放锁语法」
1 | UNLOCK TABLES |
客户端断开的时候也会自动释放锁。
「查看表上加过的锁」
1 | show open tables; |
「MyISAM引擎默认的锁是表锁」。表锁一般是在数据库引擎不支持行锁的时候才会被用到的。
「表级读锁」
当前表加read锁,当前连接和其他的连接都可以读操作;但是当前连接写操作会报错,其他连接写操作会被阻塞。
「表级写锁」
当前表加write锁,当前连接可以对表做读写操作,其他连接对该表所有操作(读写操作)都被阻塞。
「意向锁」
意向锁是什么
「意向锁(Intention Lock)简称I锁,是一种表级锁」。
「InnoDB 实现了标准的行级锁,包括:共享锁(S锁)、排它锁(X锁)」,那么为什么需要引入意向锁呢?意向锁解决了什么问题?
:::tips
假设,事务A获取了某一行记录的排它锁,事物A尚未提交,事务B想要获取表锁时,则事物B必须要确认表的每一行都不存在排他锁,需要进行全表扫描,效率很低,此时就引入意向锁
:::
- 如果事务A获取了某一行记录的排它锁,实际此时表存在两种锁,行记录的排他锁和表上的意向排他锁。
- 如果事务B试图在该表加表级锁时,则会被意向锁阻塞,因此事物B不必检查各个页锁或行锁,而只需检查表上的意向即可。
如上,数据库中存储数据,范围由大到小:表–>页–>行,加锁也是分别加在表–>页–>行中,当我们把锁加在更大一级范围时,也就不需要全表扫描下一级的某些锁,可以很大程度提升性能。
「锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,即意向锁」
通过上述描述我们知道「意向锁是加在表上,用于防止全表扫描的一种锁,即意向锁是表锁」。意向锁分为两种类型:
- 「意向共享锁(intention shared lock)」简称IS锁,事务想要给某一个数据行加行级共享锁(S锁)之前必须先获取该表的IS锁(表级锁)
- 「意向排他锁(intention exclusive lock)」简称IX锁,事务想要给某一个数据行加行级排他锁(X锁)之前必须先获取该表的IX锁(表级锁)
「【意向锁都是InnoDB存储引擎自己维护的,用户是无法操作意向锁的】」。
「在为数据行加共享锁/排他锁之前,InooDB会先获取该数据行所在在数据表的对应意向锁(表级锁)」,如果没有获取到,否则等待innodb_lock_wait_timeout超时后根据innodb_rollback_on_timeout决定是否回滚事务。
:::tips
从锁粒度角度:InnoDB 允许行级锁与表级锁共存,而意向锁是表锁;
从锁模式角度:意向锁是一种独立类型,辅助解决记录锁效率不及的问题;
从兼容性角度:意向锁包含了共享/排他两种。
:::
意向锁的兼容互斥性
- 意向锁之间的兼容互斥性:意向锁之间是互相兼容的
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
- 意向锁与其他锁兼容互斥性:意向锁与普通的排他锁/共享锁互斥
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
表级共享锁(S) | 兼容 | 互斥 |
表级排他锁(X) | 互斥 | 互斥 |
「上述的排他锁(X锁)共享锁(S锁)指的都是表锁,意向锁不会与行级的共享锁/排他锁互斥」
「自增锁」
所有插入数据的方式总共分三类,分别是:
- Simple inserts (简单插入),可以预先确定要插入的行数。
- Bulk inserts (批量插入),事先不知道要插入的行数。
- Mixed-mode-inserts (混合模式插入),只指定了部分id的值,还有未知id。
在插入时,mysql采用自增锁的方式来实现。当向使用auto_increment列插入数据时需要获取一种特殊的表级锁,在插入语句时加一个自增锁。然后再语句执行后,再把自增锁释放掉。一个事务再持有锁时,其他事务的插入语句都要被阻塞,所以并发性并不高。所以innodb通过innodb_autoinc_lock_mode的不同取值来提供不同的锁定机制。
0 (传统锁定模式),并发差,就如上面所说的流程。
1 (连续锁定模式) ,mysql8.0之前默认的模式。对于插入数量已知情况下,只在分配过程中保持,而不是直到语句完成。
2 (交错锁定模式),在这种模式下,所有类insert语句都不会使用表级自增锁。自动递增保证在所有并发执行中是唯一且单调递增的。但是可能存在间隙。
「元数据锁」
:::tips
当我们查询查询一个表中的数据时,另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构就不一致了,这肯定是允许。这里就用到了元数据锁
:::
在MySQL 5.5版本中引入了MDL,「元数据锁(MDL) 不需要显式使用,在访问一个表的时候会被自动加上」。
- 「当对一个表做增删改查的时候会加上【MDL读锁】」
读锁之间不互斥,因此可以有多个线程同时对一张表增删改查操作。
- 「当对一个表做结构变更的时候会加上【MDL写锁】」
读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。
页锁
「每次锁定相邻的一组记录」,锁定粒度、开销和加锁时间都界于表锁和行锁之间,并发度一般。
应用在BDB 存储引擎中
行级锁(粒度小,加锁开销大,并发量好):
「行锁顾名思义就是对数据行进行加锁。行锁的锁定颗粒度在 MySQL中是最细的,应用于 InnoDB 存储引擎,通过对索引数据页上的记录加锁实现的【即行锁是针对索引加锁】」
「行锁的优缺点」
并发情况下,产生锁等待的概率较低,支持较大的并发数,但开销大,加锁慢,而且会出现死锁。
「行锁的前提条件」
「检索数据时需要通过索引,【因为 InnoDB 是通过给索引的索引项加锁来实现行锁的】」。
- 在不通过索引条件查询的时候,InnoDB 会使用表锁,表锁会产生锁冲突
- 「行锁是针对索引加锁」,所以即使访问的不同记录,只要使用的是同一索引项,也可能会出现锁冲突。
:::tips
MySQL会比较不同执行计划,当全表扫描比索引效率更高时,InnoDB就使用表锁。因此不一定使用了索引就一定会使用行锁,也有可能使用表锁。
:::
「行锁会产生死锁」
「实际上InnoDB 的行锁也是分为两步获得的:锁住主键索引,锁住非主键索引」。
:::tips
当两个事务同时执行时,
一个锁住了主键索引,在等待其他索引;
另一个锁住了非主键索引,在等待主键索引,
这样就可能会发生死锁。
:::
「InnoDB可以检测到这种死锁,检测到后会让其中一个事务释放锁回退,另一个获取锁完成事务」。
「行锁的实现算法」
前面讲到「InnoDB行锁是通过对 索引数据页上的记录加锁实现的」,接下来看看它具体是怎么实现,
InnoDB存储引擎有3种实现行锁的算法:
- 「【Record Lock】:记录锁,单个行记录上的锁」
RC、RR隔离级别都支持,如果表中没有主键和任何一个索引,那InnoDB会使用隐式的主键来进行锁定。
- 「【Gap Lock】:间隙锁,锁定一个范围,但不包含记录本身」
范围锁,锁定索引记录范围,确保索引记录的间隙不变,RR隔离级别支持
- 「【Next-Key Lock】:Gap Lock与Record Lock的组合」
锁定数据前后范围,并且锁定记录本身,RR隔离级别支持
「在RR隔离级别,InnoDB对于行的查询都是采用【Next-Key Lock】的组合锁定算法」,但是「在查询的列是唯一索引(包含主键索引)的情况下,Next-key Lock会降级为Record Lock,仅锁住索引本身而非范围」。
下面具体看下针对不同的sql语句采用的是那种加锁方式:
查询语句类型一
1 | select ... from ... |
「对于普通的select语句,InnoDB引擎采用MVCC机制实现非阻塞读,【InnoDB引擎不加锁】」。
查询语句类型二
1 | select ... from ... lock in share mode |
「添加共享锁,InnoDB会使用Next-Key Lock锁进行处理,扫描如果有唯一索引,则降级为RecordLock锁」。
查询语句类型三
1 | select ... from ... for update |
「添加排他锁,InnoDB会使用Next-Key Lock锁进行处理,扫描如果有唯一索引,则降级为RecordLock锁」。
修改语句
1 | update ... from ... where ... |
「InnoDB会使用Next-Key Lock锁进行处理,扫描如果有唯一索引,则降级为RecordLock锁」。
删除语句
1 | delete ... from ... where |
「InnoDB会使用Next-Key Lock锁进行处理,扫描如果有唯一索引,则降级为RecordLock锁」。
插入语句
1 | insert ... from ... |
「InnoDB会在将要插入的那一行设置一个排他的RecordLock锁」。
「行锁的分类」
「共享锁(Shared Lock)又称为读锁,简称S锁,是一种行级锁」
顾名思义:「共享锁就是多个事务对于同一数据共享一把锁,都能访问到数据,但是只能读不能修改」。
- 「加锁方式」
1 | select ... from ... lock in share mode |
- 「释放方式」:
1 | commit; |
- 「共享锁工作原理」
「一个事务获取了一条记录的共享锁后,其他事务也能获得该记录对应的共享锁,但不能获得排他锁」。即一个事务使用了共享锁(读锁),其他事务只能读取,不能写入,写操作被阻塞。
「排他锁(EXclusive Lock)又称为写锁,简称X锁,是一种行锁也可以是表锁」
顾名思义:「排他锁就是不能与其他锁并存,即当前写操作没有完成前,会阻断其他写锁和读锁」。
- 「加锁方式」
innodb引擎默认会在update,delete语句加上 for update
1 | SELECT * FROM student FOR UPDATE; # 排他锁 |
- 「释放方式」:
1 | commit; |
- 「共享锁工作原理」
「如一个事务获取了一条记录的排他锁,其他事务就不能对该行记录做其他操作,也不能获取该行的锁(共享锁、排他锁),但是获取到排他锁的事务可以对数据进行读写操作」。
:::tips
这里要注意一下,其他事务不加锁的读是不会被阻塞的,阻塞的是加锁的读
:::
「间隙锁 Gap Locks」
间隙锁(Gap Locks)是数据库中用于锁定索引范围的一种锁。它们的主要目的是防止其他事务在给定范围内插入新的数据,保证范围内数据的一致性和避免幻读现象。
:::tips
间隙锁的锁定范围是指在索引范围之间的间隙
:::
举个简单例子来说明:
假设有一个名为products的表,其中有一个整型列product_id作为主键索引。现在有两个并发事务:事务A和事务B。
事务A执行以下语句:
1 | BEGIN; |
事务B执行以下语句:
1 | BEGIN; |
在这种情况下,事务A会在products表中product_id值在 100 和 200 之间的范围上设置间隙锁。因此,在事务A运行期间,其他事务无法在这个范围内插入新的数据,在事务B尝试插入product_id为150的记录时,由于该记录位于事务A锁定的间隙范围内,事务B将被阻塞,直到事务A释放间隙锁为止。
触发条件:
在可重复读(Repeatable Read)事务隔离级别下,以下情况会产生间隙锁:
- 使用普通索引锁定:当一个事务使用普通索引进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。
- 使用多列唯一索引:如果一个表存在多列组成的唯一索引,并且事务对这些列进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。
- 使用唯一索引锁定多行记录:当一个事务使用唯一索引来锁定多行记录时,MySQL会在这些记录之间的间隙上生成间隙锁,以确保其他事务无法在这个范围内插入新的数据。
需要注意的是,上述情况仅在可重复读隔离级别下才会产生间隙锁。在其他隔离级别下,如读提交(Read Committed)隔离级别,MySQL可能会使用临时的意向锁来避免并发问题,而不是生成真正的间隙锁。
为什么这里强调的是普通索引呢?因为对唯一索引锁定并不会触发间隙锁,请看下面这个例子:
假设我们有一个名为students的表,其中有两个字段:id 和 name。id是主键,现在有两个事务同时进行操作:
事务A执行以下语句:
1 | SELECT * FROM students WHERE id = 1 FOR UPDATE; |
事务B执行以下语句:
1 | INSERT INTO students (id, name) VALUES (2, 'John'); |
由于事务A使用了唯一索引锁定,它会锁定id为1的记录,不会触发间隙锁。同时,在事务B中插入id为2的记录也不会受到影响。这是因为唯一索引只会锁定匹配条件的具体记录,而不会锁定不存在的记录(如间隙)。
当使用唯一索引锁定一条存在的记录时,会使用记录锁,而不是间隙锁
但是当搜索条件仅涉及到多列唯一索引的一部分列时,可能会产生间隙锁。以下是一个例子:
假设students表,包含三个列:id、name和age。我们在(name, age)上创建了一个唯一索引。
现在有两个事务同时进行操作:
事务A执行以下语句:
1 | SELECT * FROM students WHERE name = 'John' FOR UPDATE; |
事务B执行以下语句:
1 | INSERT INTO students (id, name, age) VALUES (2, 'John', 25); |
在这种情况下,事务A搜索的条件只涉及到了唯一索引的一部分列(name),而没有涉及到完整的索引列(name, age)。因此,MySQL会对匹配的记录加上行锁,并且还会对与该条件范围相邻的间隙加上间隙锁。
间隙锁加锁规则:
间隙锁有以下加锁规则:
- 规则1:加锁的基本单位是 Next-Key Lock,左开右闭区间。
- 规则2:查找过程中访问到的对象才会加锁。
- 规则3:唯一索引上的范围查询会上锁到不满足条件的第一个值为止。
- 规则4:唯一索引等值查询,并且记录存在,Next-Key Lock 退化为行锁。
- 规则5:索引上的等值查询,会将距离最近的左边界和右边界作为锁定范围,如果索引不是唯一索引还会继续向右匹配,直到遇见第一个不满足条件的值,如果最后一个值不等于查询条件,Next-Key Lock 退化为间隙锁。
案例演示
环境:MySQL,InnoDB,RR隔离级别。
数据表:
1 | CREATE TABLE `user` ( |
数据:
id | age | name |
---|---|---|
1 | 1 | 小明 |
5 | 5 | 小王 |
7 | 7 | 小张 |
11 | 11 | 小陈 |
在进行测试之前,我们先来看看 user 表中存在的隐藏间隙:
- (-∞, 1]
- (1, 5]
- (5, 7]
- (7, 11]
- (11, +∞]
案例一:唯一索引等值锁定存在的数据
如下是事务A和事务B执行的顺序:
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | select * from user where id = 5 for update | |
T3 | insert into user value(3,3,”小黑”) —不阻塞 | |
T4 | insert into user value(6,6,”小蓝”) —不阻塞 | |
T5 | commit | commit |
根据规则4,加的是记录锁,不会使用间隙锁,所以只会锁定 5 这一行记录。
案例二:索引等值锁定
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | select * from user where id = 3 for update — 不存在的数据 | |
T3 | insert into user value(6,6,”小蓝”) — 不阻塞 | |
T4 | insert into user value(2,2,”小黄”) — 阻塞 | |
T5 | commit |
这是一个索引等值查询,根据规则1和规则5,加锁范围是( 1,5 ] ,又由于向右遍历时最后一个值 5 不满足查询需求,Next-Key Lock 退化为间隙锁。也就是最终锁定范围区间是 ( 1,5 )。
案例三:唯一索引范围锁定
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | select * from user where id >= 5 and id<6 for update | |
T3 | insert into user value(7,7,”小赵”) — 阻塞 | |
T4 | commit |
根据规则3,会上锁到不满足条件的第一个值为止,也就是7,所以最终加锁范围是 [ 5,7 ]。
其实这里可以分为两个步骤,第一次用 id=5 定位记录的时候,其实加上了间隙锁 ( 1,5 ],又因为是唯一索引等值查询,所以退化为了行锁,只锁定 5。
第二次用 id<6 定位记录的时候,其实加上了间隙锁( 5,7 ],所以最终合起来锁定区间是 [ 5,7 ]。
案例四:非唯一索引范围锁定
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | select * from user where age >= 5 and age<6 for update | |
T3 | insert into user value(8,8,”小青”) — 不阻塞 | |
T4 | insert into user value(2,2,”小黄”) — 阻塞 | |
T5 | commit |
参考上面那个例子。
第一次用 age =5 定位记录的时候,加上了间隙锁 ( 1,5 ],不是唯一索引,所以不会退化为行锁,根据规则5,会继续向右匹配,所以最终合起来锁定区间是 ( 1,7 ]。
案例五:间隙锁死锁
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | select * from user where id = 3 for update | |
T3 | select * from user where id = 4 for update | |
T4 | insert into user value(2,2,”小黄”) — 阻塞 | |
T5 | insert into user value(4,4,”小紫”) — 阻塞 |
间隙锁之间不是互斥的,如果一个事务A获取到了( 1,5 ] 之间的间隙锁,另一个事务B仍然可以获取到( 1,5 ] 之间的间隙锁。这时就可能会发生死锁问题。
在事务A事务提交,间隙锁释放之前,事务B也获取到了间隙锁( 1,5 ] ,这时两个事务就处于死锁状态。
案例六:limit对加锁的影响
时刻 | 事务A | 事务B |
---|---|---|
T1 | begin | begin |
T2 | deletet user where age = 6 limt 1 | |
T3 | insert into user value(7,7,”小赵”) — 不阻塞 | |
T4 | ||
T5 | commit | commit |
根据规则5,锁定区间应该是 ( 5,7 ],但是因为加了 limit 1 的限制,因此在遍历到 age=6 这一行之后,循环就结束了。
根据规则2,查找过程中访问到的对象才会加锁,所以最终锁定区间应该是:( 5,6 ]。
总结
在本文中,我们讨论了间隙锁的加锁规则。间隙锁是MySQL中用于保护范围查询和防止并发问题的重要机制,了解间隙锁的加锁规则对于优化数据库性能、减少数据冲突以及提高并发性能非常重要。
「临界锁 Next-Key locks」
临界锁是记录锁和该记录之前间隙锁的组合。
当InnoDB在查询或者遍历一个索引时,它会给所有遍历的行添加共享锁或者排它锁,这就是行级锁。因此,行级锁就是记录锁。临界锁(Next-key lock)就是记录锁加上该记录前的间隙锁。如果一个会话在R记录上持有共享锁或者排它锁,另一个会话不能在R之前的间隙插入数据。加入一个索引包含值为10,11,13,20,那么可能得临界锁范围是:
1 | (negative infinity, 10] |
小括号表示不包含该值,大括号表示包含该值左开右闭。InnoDB默认使用可重复读(REPEATABLE READ)的隔离级别,且使用临界锁防止幻读(Phantom rows)
临界锁在事务数据中的表现为:
1 | RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t` |
「插入意向锁(Insert Intention Locks)」
插入意向锁是INSERT语句中,在行写入之前添加的一种间隙锁。多个事务在写入同一个索引间隙时,如果他们插入的不是同一个位置,那么就不会相互阻塞。比如有两个索引记录为4和7,不同的事务分别准备写入数据5和6,他们都会在4-7上增加插入意向锁锁住这个间隙,但是不会相互阻塞,因为他们写入的是不同的行。
下面这个例子表明,事务在获取排它锁之前会先获取插入意向锁。ClientA新建两条行记录90和102
1 | mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB; |
此时ClientA使用排它间隙锁锁住了(100, 102]。ClientB开启一个事务,并尝试插入101。ClientB会被ClientA的间隙阻塞,等待获取排它锁。这个时候,ClientB获取了插入意向锁。
1 | mysql> START TRANSACTION; |
插入意向锁在事务数据中的表现为:
1 | RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child` |
按照情绪情况来划分:
「乐观锁/悲观锁其实都是概念上的,只是在并发下防止数据被修改的一种加锁形式」。
悲观锁
「对数据的修改抱有悲观态度的一种并发控制方式,悲观的认为自己(当前线程)拿到的数据是被修改过的,所以在操作数据之前先加锁」。
- 「悲观锁的形式(类型)」
「数据库的行锁、表锁、读锁、写锁、共享锁、排他锁等,以及syncronized 实现的锁都是悲观锁的范畴」。
- 「优点」
「可以保证数据的独占性和正确性」。
- 「缺点」
「每次请求都需要加锁、释放锁,这个过程会降低系统性能」。
乐观锁
「乐观锁是对于数据冲突保持一种乐观态度,每次读取数据的时都认为其他线程不会修改数据,所以不上锁,只是在数据修改后提交时才通过【版本号机制或者CAS算法】来验证数据是否被其他线程更新」。
因为乐观锁中并没有【加锁和解锁】操作,因此乐观锁策略也被称为「无锁编程」。
- 「乐观锁实现的关键点」:检测冲突
- 「乐观锁实现方式」
- 版本号机制(常用)
- CAS算法实现
- 「优点」
「没有加锁和解锁操作,可以提高吞吐量」
- 「缺点」
乐观锁需要自己实现,且外部系统不受控制
- 「乐观锁的应用」
在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS。
- 「适用场景」:读多写少
- 「注意」
乐观锁不是数据库提供的功能,需要开发者自己去实现。
:::tips
除了开发者自己手动实现乐观锁之外,很多数据库访问框架也封装了乐观锁的实现
比如 hibernate框架,MyBatis框架的OptimisticLocker插件。
:::
版本号机制实现乐观锁
版本号机制有两种方式:使用版本字段(version)和使用时间戳(Timestamp),两者实现原理是一样的。
前文中提到「乐观锁需要开发者自己去实现,所以版本号实现时通过在表中加字段的形式实现的」。
- 「使用版本字段(version)」
「在数据表增加一个版本(version) 字段,每操作一次,将那条记录的版本号加 1」。version 是用来查看被读的记录有无变化,防止记录在业务处理期间被其他事务修改。
- 「使用时间戳(Timestamp)」
与使用version版本字段基本一致,「同样需要给在数据表增加一个字段,字段类型使用timestamp时间戳,通过时间戳比较数据版本」。
- 「乐观锁实现案例」
修改用户表中Id为1的用户姓名
- <font style="color:black;">第一步:查询记录信息</font>
1 | #使用版本字段(version) |
- <font style="color:black;">第二步:逻辑处理之后,修改姓名为张三</font>
1 | #使用版本字段(version) |
CAS算法实现乐观锁
「CAS算法即compare and swap(比较与交换),是一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是无锁编程」。
- 「特点」
不加锁,即使没有线程被阻塞的情况下实现变量的同步,也叫非阻塞同步
CAS算法涉及到三个操作数
「当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作」(比较和替换是一个原子操作),一般情况下是一个自旋操作,即不断的重试。
- 变量当前内存值 V
- 旧的预期值 A
- 要写入的新值 B
- 「CAS缺点」
- 「ABA问题」
:::tips
当线程1读到某变量的值为A,在其逻辑处理的过程中,另外一个线程2将该变量的值从A先修改为B、然后又将其从B修改回A。此时,当线程1通过CAS操作进行新值写入虽然可以成功,而实际上线程1执行CAS操作时预期值的A 和读取该变量当前值的A已经不是同一个了,后者是线程2修改的
:::
- **<font style="color:rgb(145, 109, 213);">「CPU开销大」</font>**
虽然CAS算法是非阻塞的,但如果CAS操作一直不成功不断循环,会浪费CPU资源
- **<font style="color:rgb(145, 109, 213);">「只能保证一个共享变量的原子性」</font>**
当对多个变量进行操作时,CAS算法无法保证原子性。
:::tips
可以将多个变量封装为一个对象再使用CAS算法(Java中的AtomicReference)
:::
死锁和死锁检测
「死锁是指两个或两个以上的事务在执行过程等中,因争夺资源而造成的一种相互等待的现象」。
- 「死锁产生本质原因」
- 系统资源有限
- 进程推进顺序不合理
- 「死锁产生的4个必要条件」
- 「互斥条件(Mutual exclusion,简称Mutex)」资源要么被一个线程占用,要么是可用状态
- 「不可抢夺(No preemption)」资源被占用后,除非占有线程主动释放,其他线程不能把它从该线程占用中抢夺
- 「占有和等待(Hold and wait)」一个进程必须占有至少一个资源,并等待另一资源,而该资源被其他进程占用
- 「循环等待(Circular wait)」一组等待进程{P0, P1…Pn-1, Pn},P0等待资源被P1占有,P1等待资源被P2占有,Pn-1等待资源被Pn占有,Pn等待资源被P0占有,循环等待,则形成环形结构。
「死锁发生的以上四个条件缺一都无法导致死锁,而由于互斥条件是非共享资源所必须的,不仅不能改变,还应加以保证,所以恢复死锁主要是破坏产生死锁的其他三个条件」。
常见死锁现象和解决方案
表级锁死锁
- 「案例」
有线程A、B分别需要访问用户表与订单表,访问表的时候都会加表级锁。线程A访用户表,并对用户表加锁(线程A锁住了用户表),然后又访问订单表;此时线程B先访问订单表,并对订单表加锁(线程B锁住了订单表),然后线程想访问用户表。
「产生原因」
上述案例由于线程B已经锁住订单表,线程A必须等待线程B释放订单表能继续,同样线程B要等线程A释放用户表才能继续,「线程A、B相互等待对方释放锁,就产生了死锁」。
- 「解决方案」
「这种死锁是由于程序的BUG产生的,比较常见,只能通过调整程序的逻辑来解决」。
行级锁死锁
行级锁产生死锁有两种情况,一直是资源争夺,一种是行级锁升级为表级锁
- 资源争夺
- 「产生原因」
当事务中某个查询没有走索引时,就会走全表扫描,把行级锁上升为全表记录锁定(等价于表级锁),并发下多个线程同时执行,就可能会产生死锁和阻塞
- **<font style="color:rgb(145, 109, 213);">「解决方案」</font>**
SQL语句中尽量不要有太复杂的多表关联查询,并通过执行对SQL语句进行分析,建立索引优化,避免全表扫描和全表锁定。
- 行级锁升级为表级锁
- **<font style="color:rgb(145, 109, 213);">「产生原因」</font>**
两个事务分别想拿到对方持有的锁,互相等待,于是产生死锁。
- **<font style="color:rgb(145, 109, 213);">「解决方案」</font>**
* <font style="color:black;">在同一个事务中,尽量一次锁定需要的所有资源</font>
* <font style="color:black;">将每个资源编号,通过资源编号的线性顺序来预防死锁,当一个进程占有编号为i的资源时,那么它下一次只能申请编号大于i的资源。</font>
共享锁转换为排他锁
- 「案例」
事务A有两个操作,首先查询一条纪录M,然后更新纪录M;此时事务B在事物A查询之后更新之前去更新纪录M,此时事物A获取了记录M的共享锁,事物B获取了记录M的排他锁, 事务B的排他锁由于事务A有共享锁,必须等A释放共享锁后才可以获取,事物B只能排队等待。
- 「产生原因」
案例中事物B已经进入等待,事物A更新M需要排他锁,而此时事务B已经有一个排他锁请求,并且正在等待事务A释放其共享锁,因此无法给事物A授予排他锁锁请求,事物A也进入排队等待
:::tips
注意:这里事物B还没有拿到M的排它锁,只是进入排队等到状态
:::
- 「解决方案」
通过「手动实现乐观锁」进行控制,乐观锁的无锁机制可以避免长事务中的数据库加锁开销,增大并发量,提升系统性能。
死锁排查
MySQL提供了几个与锁有关的参数和命令,可以辅助我们优化锁操作,减少死锁发生。
- 「查看近期死锁日志信息」
1 | show engine innodb status; |
通过以上命令查看近期死锁日志信息,然后使用执行计划进行SQL优化
- 「查看锁状态变量」
通过以下命令可以检查锁状态变量,从而分析系统中的行锁的争夺情况
1 | show status like'innodb_row_lock%'; |
- <font style="color:rgb(1, 1, 1);">Innodb_row_lock_current_waits:当前正在等待锁的数量</font>
- <font style="color:rgb(1, 1, 1);">Innodb_row_lock_time:从系统启动到现在锁定总时间长度</font>
- <font style="color:rgb(1, 1, 1);">Innodb_row_lock_time_avg:每次等待锁的平均时间</font>
- <font style="color:rgb(1, 1, 1);">Innodb_row_lock_time_max:从系统启动到现在等待最长的一次锁的时间</font>
- <font style="color:rgb(1, 1, 1);">Innodb_row_lock_waits:系统启动后到现在总共等待的次数</font>
「如果等待次数高,而且每次等待时间长,则需要对其进行分析优化」。
星河之码
不惟有超世之才,亦必有坚忍不拔之志。
94篇原创内容
公众号