进学阁

业精于勤荒于嬉,行成于思毁于随

0%

MySQL中的锁有很多种,各种锁应用在不同的地方。「MySQL依靠锁机制可以让多个事务更新一行数据的时候串行化」

MySQL中锁总的来说有两种概念:Lock和Latch

Latch

称为闩锁(轻量级的锁),因为Latch要求锁定的时间非常短。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。在InnoDB引擎中,Latch又分为mutex(互斥量)和rwlock(读写锁)。

mutex:互斥量;有时候有些资源需要共享和并发,但是又不是分频繁,所以向操作系统申请一个mutex,mutex都是排他的。

RW-LATCH : 读写锁

Lock

「Lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行」。并且一般lock的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。

锁的分类

实际上MySQL的锁在不同的维度上划分是多种多样的,在特地的场景下,发挥不一样的作用,下面来看看锁的分类。

按照粒度划分:

全局锁:

「全局锁,即对整个数据库实例加锁」。一般当我们需要让整个库处于只读状态的时候,可以给数据库加上全局锁。「加上全局锁之后其他线程的:数据更新语句(增删改)、数据定义语句(包括建表、修改表结构等)都会被阻塞」

表级锁(粒度大,性能开销小,并发量小):

「表级别的锁定是MySQL各【存储引擎中】最大颗粒度的锁定机制」。由于直接锁定一张表,所以获取锁和释放锁的速度很快,避免了死锁问题,但是出现锁定资源争用的概率也最高,并发量降低。

「表锁的加锁语法」

1
2
3
4
5
#隐式上锁(默认,自动加锁自动释放
insert、update、delete //上写锁
#显式上锁(手动)
lock table tableName read;//读锁
lock table tableName write;//写锁

「表锁的释放锁语法」

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
2
commit;
rollback;
  • 「共享锁工作原理」

「一个事务获取了一条记录的共享锁后,其他事务也能获得该记录对应的共享锁,但不能获得排他锁」。即一个事务使用了共享锁(读锁),其他事务只能读取,不能写入,写操作被阻塞。

「排他锁(EXclusive Lock)又称为写锁,简称X锁,是一种行锁也可以是表锁」

顾名思义:「排他锁就是不能与其他锁并存,即当前写操作没有完成前,会阻断其他写锁和读锁」

  • 「加锁方式」

innodb引擎默认会在update,delete语句加上 for update

1
2
3
4
SELECT * FROM student FOR UPDATE; # 排他锁
INSERT INTO student values ... # 排他锁
DELETE FROM student WHERE ... # 排他锁
UPDATE student SET ... # 排他锁
  • 「释放方式」
1
2
commit;
rollback;
  • 「共享锁工作原理」

「如一个事务获取了一条记录的排他锁,其他事务就不能对该行记录做其他操作,也不能获取该行的锁(共享锁、排他锁),但是获取到排他锁的事务可以对数据进行读写操作」

:::tips
这里要注意一下,其他事务不加锁的读是不会被阻塞的,阻塞的是加锁的读

:::

「间隙锁 Gap Locks」

间隙锁(Gap Locks)是数据库中用于锁定索引范围的一种锁。它们的主要目的是防止其他事务在给定范围内插入新的数据,保证范围内数据的一致性和避免幻读现象。

:::tips
间隙锁的锁定范围是指在索引范围之间的间隙

:::

举个简单例子来说明:

假设有一个名为products的表,其中有一个整型列product_id作为主键索引。现在有两个并发事务:事务A和事务B。

事务A执行以下语句:

1
2
BEGIN;
SELECT * FROM `products` WHERE `product_id` BETWEEN 100 and 200 FOR UPDATE;

事务B执行以下语句:

1
2
BEGIN;
INSERT INTO `products` (`product_id`, `name`) VALUES (150, 'Product 150');

在这种情况下,事务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
2
3
4
5
6
7
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`age` int DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`)
KEY `age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

数据:

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
2
3
4
5
(negative infinity, 10]
(10, 11]
(11, 13]
(13, 20]
(20, positive infinity)

小括号表示不包含该值,大括号表示包含该值左开右闭。InnoDB默认使用可重复读(REPEATABLE READ)的隔离级别,且使用临界锁防止幻读(Phantom rows)

临界锁在事务数据中的表现为:

1
2
3
4
5
6
7
8
9
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10080 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;
「插入意向锁(Insert Intention Locks)」

插入意向锁是INSERT语句中,在行写入之前添加的一种间隙锁。多个事务在写入同一个索引间隙时,如果他们插入的不是同一个位置,那么就不会相互阻塞。比如有两个索引记录为4和7,不同的事务分别准备写入数据5和6,他们都会在4-7上增加插入意向锁锁住这个间隙,但是不会相互阻塞,因为他们写入的是不同的行。

下面这个例子表明,事务在获取排它锁之前会先获取插入意向锁。ClientA新建两条行记录90和102

1
2
3
4
5
6
7
8
9
10
11
mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
mysql> INSERT INTO child (id) values (90),(102);

mysql> START TRANSACTION;
mysql> SELECT * FROM child WHERE id > 100 FOR UPDATE;
+-----+
| id |
+-----+
| 102 |
+-----+

此时ClientA使用排它间隙锁锁住了(100, 102]。ClientB开启一个事务,并尝试插入101。ClientB会被ClientA的间隙阻塞,等待获取排它锁。这个时候,ClientB获取了插入意向锁。

1
2
3
mysql> START TRANSACTION;
mysql> INSERT INTO child (id) VALUES (101);

插入意向锁在事务数据中的表现为:

1
2
3
4
5
6
RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000066; asc f;;
1: len 6; hex 000000002215; asc " ;;
2: len 7; hex 9000000172011c; asc r ;;...

按照情绪情况来划分:

「乐观锁/悲观锁其实都是概念上的,只是在并发下防止数据被修改的一种加锁形式」

悲观锁

「对数据的修改抱有悲观态度的一种并发控制方式,悲观的认为自己(当前线程)拿到的数据是被修改过的,所以在操作数据之前先加锁」

  • 「悲观锁的形式(类型)」

「数据库的行锁、表锁、读锁、写锁、共享锁、排他锁等,以及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
2
3
4
#使用版本字段(version)
select name,version from user where id=1;
#使用时间戳(Timestamp)
select name,timestamp from user where id=1;
- <font style="color:black;">第二步:逻辑处理之后,修改姓名为张三</font>
1
2
3
4
5
#使用版本字段(version)
update user set name = '张三',version=version+1 where id=1 and version = #{version};#version 为第一步查询的值

#使用时间戳(timestamp)
update user set name = '张三',timestamp=now() where id=1 and time
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篇原创内容

公众号