锁
事务的隔离性
由锁
来实现
1.概述
锁
在计算机中多个进程或线程并发访问某个资源
的机制。当多个线程并发访问某个数据时,我们需要保证这个数据在任何时刻最多只有一个线程
在对其进行访问,保证数据的完整性
和一致性
。
在数据库中,为了保证数据的一致性,我们需要对并发操作进行控制
,所以有了锁
。与此同时锁机制
也为实现 MySQL 的各个隔离级别提供了保障。
2.MySQL 并发事务访问相同记录
并发事务访问相同数据可以分为 3 种情况:
2.1 读-读情况
读-读
情况,即并发事务读取相同的数据
,读操作本身不会对数据有影响,所以并不会有什么问题
2.2 写-写情况
可能会发生脏写
的问题,所有的隔离级别都解决了这种问题。所以在多个未提交事务想要同时对一个数据进行改动时,需要进行排队执行
,排队过程是通过锁
机制来实现的。锁其实是内存中的结构,在事务执行前本来是没有锁的,也就是在事务执行之前,锁结构
和数据并没有关联。
当事务开启后,想对该条记录做改动时,会看内存中这条记录是否有关联的锁结构
,当没有时则会在内存中生成一个锁结构
与其关联。(锁结构与事务相关,有几个事务就有几个锁结构)
在锁结构
中有很多信息,只将其中两个属性说明:
trx信息
:表示这个锁结构是哪个事务生成的is_waiting
:表示当前事务是否在等待
当事务 T1
改动该条记录后,就生成了一个锁结构
与这条记录关联,因为之前没有别的事物为这条记录加锁,所以 is_waiting 为 false,这个场景称为获取锁成功
,或加锁成功
。
事务 T1
提交之前,事务 T2
也想对该记录做改动,需要先看有没有锁结构
与这条记录关联,若有,则也生成一个锁结构与其关联,不过该锁结构的is_waiting
属性值为 true
,表示当前事务需要等待。这个场景称为 获取锁失败
或 加锁失败
事务 T1 提交后,该事务生成的锁结构就会释放
,然后再看看有没有其它事务正在等待获取锁,发现 T2 正在等待获取锁,则会将 T2 对应的锁结构的 is_waiting
属性设置为 false
,然后将该事务对应的线程唤醒,让其继续执行,此时 T2 就后去到了锁
小结:
-
不加锁
不需要在内存中生成对应的
锁结构
,可以直接执行操作 -
获取锁成功,或加锁成功
在内存中生成了对应的
锁结构
,而且锁结构的is_waiting
属性为false
,也就是事务可以继续执行操作 -
获取锁失败,或加锁失败,没有获取到锁
在内存中生成了对应的
锁结构
,但是锁结构的is_waiting
属性为true
,也就是事务需要等待
2.3 读-写或写-读 情况
即一个事务在进行读取操作,另外一个事务进行修改操作。可能会发生脏读
、不可重复读
、幻读
2.4 并发问题的解决方案
解决:脏读
、不可重复读
、幻读
-
方案一:读操作用多版本并发控制(
MVCC
)写操作进行加锁
MVCC:生成一个
ReadView
,通过ReadView 找到符合条件的记录版本(历史版本由undo日志
构成)。查询操作只能读
到生成 ReadView 之前已提交事务所做的更改
,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是无法看到的。而写操作
是针对最新版本的记录
,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用 MVCC 时,读-写
操作并不冲突普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用 MVCC 读取记录
- 在
READ COMMITTED
隔离级别下,一个事务在执行过程中每次执行 SELECT 操作都会生成一个 ReadView ,ReadView 的存在本身就保证了事务不可读取到未提交事务所做的修改
,就避免了脏读 - 在
REPEATABLE READ
隔离级别下,一个事务在执行过程中只有第一次执行 SELECT操作
才会生成ReadView ,之后的 SELECT 操作都复用
这个ReadView ,就避免了不可重复读和幻读问题
- 在
-
方案二:读、写操作都
加锁
在某些业务场景中,必须要读取记录的最新版本。
-
小结对比:
- 采用
MVCC
方式,读-写
操作不冲突,性能更高
- 采用
加锁
方式,读-写
操作需要排队执行
,影响性能。
- 采用
一般情况下:采用 MVCC
解决 读-写
操作并发执行的问题,但是在某些业务特殊情况下,必须采用 加锁
的方式执行
3. 锁的不同角度分类
3.1 从数据操作的类型划分:读锁、写锁
使用加锁
的方式解决问题时,既要允许读-读
情况不受影响,又要使读-写
或写-读
、写-写
情况中的操作相互阻塞
,MySQL 实现一个由两种类型的锁组成的锁系统解决。这种类型的锁通常被称为 共享锁(Shared Lock, S Lock) 和 排他锁(Exclusive Lock,X Lock),也叫读锁(readlock) 和写锁(write lock)
读锁
:也称为共享锁
,英文用S
表示。针对同一份数据,多个事务的读操作可以同时进行且不会互相影响,不会相互阻塞。写锁
:也称为排他锁
,英文用X
表示。当前写操作没有完成前,会阻断其他写锁和读锁。这样可以确保在给定的时间内,只有一个事务可以写入,并且防止其他事务读取正在写入的同一数据
在 InnoDB 引擎中,读锁和写锁可以加在表上,也可以加在行上
这里的兼容指的是针对同一张表或记录的锁的兼容情况
X锁 | S锁 | |
---|---|---|
X锁 | 不兼容 | 不兼容 |
S锁 | 不兼容 | 兼容 |
1.锁定读
读操作可以加共享锁也可以加排他锁
-
对读取的记录加
S锁
:SELECT ... LOCK IN SHARE MODE; 或 SELECT ... FOR SHARE; 8.0新语法
-
对读取的记录加
X锁
:SELECT ... FOR UPDATE;
MySQL 8.0 新特性:
在 5.7 及之前的版本,SELECT … FOR UPDATE,若获取不到锁,则会一直等待,直到 innodb_lock_wait_timeout
超时。在 8.0 中,SELECT … FOR UPDATE,SELECT …. FOR SHARE 添加 NOWAIT
、SKIP LOCKED
语法,跳过锁等待,或者跳过锁定
- 通过添加 NOWAIT、SKIP LOCKED 语法,能够立刻返回。若查询的行已经加锁:
- 则 NOWAIT 会立即报错返回
- SKIP LOCKED 也会立即返回,只是返回的结果中不包含被锁定的行
2.写操作
写操作:增删改,只能加排他锁
-
DELETE
:DELETE 一条记录的过程是先在
B+
树种定位到这条记录的位置,然后获取到该条记录的X锁
,再执行delete mark
操作。可以这个定位待删除数据在B+树中位置的过程看成是一个获取X锁
的锁定读
-
UPDATE
:分成三种情况-
情况1:未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化
则先在
B+
树种定位到记录的位置,后再获取记录的X锁
,最后在原记录位置进行修改操作。可以将这个过程看成是一个获取X锁
的锁定读
-
情况2:未修改该记录的键值,被修改的列占用存储空间发生变化
仍然是在
B+
树种定位到该条记录的位置,再获取该记录的X锁
,将该记录删除(放入垃圾链表),最后插入一条新记录。定位过程可以看成是获取X锁
的锁定读
,新插入的记录由INSERT
操作提供的隐式锁
进行保护 -
情况3:修改了记录的键值,相当于在原记录上做
DELETE
操作后再进行一次INSERT
操作,加锁操作需要按照DELETE
和INSERT
规则进行
-
-
INSERT
:一般情况下,插入记录的操作不加锁。通过一种叫
隐式锁
的结构来保护新插入数据在事务提交前不被其他事务访问
3.2 从操作的粒度划分:表级锁、页级锁、行锁
1.表锁(Table Lock)
该锁会锁定整张表,是 MySQL 中最基本的策略,不依赖于存储引擎,并且表锁是开销最小
的策略(粒度大),可以避免死锁
问题,但是会导致并发率降低
① 表级别的S锁、X锁
InnoDB中,DDL操作会加表锁,DML操作加行锁
一般情况下,不会使用 InnoDB 的表级的S锁
和 X锁
。除了一些特殊情况,例如崩溃恢复
过程。手动获取InnoDB 存储引擎提供的表级的S锁
和 X锁
:
LOCK TABLES 表名 READ
:InnoDB 会对表加表级别的S锁
LOCK TABLES 表名 WRITE
:InnoDB 会对表加表级别的X锁
尽量避免在 InnoDB 的表上手动锁表,只会降低并发性。应该使用颗粒度更细的行锁
。
总结:
MyISAM 在执行查询语句前,会给涉及的表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB
不会为表添加表级别的读锁
或者写锁
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
读锁 | 是 | 否 | 否 | 是 | 否,等 |
写锁 | 是 | 是 | 否 | 否,等 | 否,等 |
② 意向锁(intention lock)
InnoDB支持多粒度锁(multiple granularity locking)
,它允许行级锁
和表级锁
共存,而意向锁就是其中的一种表锁
- 意向锁是为了协调行锁和表锁的关系,支持多粒度的锁并存
- 意向锁是一种
不与行级锁冲突表级锁
- 表明某个事物在某行持有锁或者该事务准备就获取锁
意向锁分为两种:
-
意向共享锁(IS):事务有意向对表中某些记录加共享锁(S锁)
事务要获取记录的 S 锁,则必须先获得表的 IS 锁 SELECT 列 FROM 表... LOCK IN SHARE MODE;
-
意向排他锁(IX):事务有意向对表中某些记录加排他锁(X锁)
事务要获取记录的 X 锁,则必须先获得表的 IX 锁 SELECT 列 FROM 表.. FOR UPDATE;
意向锁是存储引擎自己维护的
1. 意向锁要解决的问题
若有两个事务A和B,事务A会表中某条记录添加了锁,事务B想要操作表为表加锁时,则需要遍历扫描整个表,看看是否有记录加了锁。这样严重影响了性能。
在数据表的场景中,若我们给某行记录加了排他锁,则数据库会自动给更大一级空间,例如数据页或数据表加上意向锁,来告知其他事务这个数据页或表已经加上了排他锁了
- 若事务想要获取表中某些记录的共享锁,则需要在表上
添加意向共享锁
- 若事务想要获取表中某些记录的排他锁,则需要在表上
添加意向排他锁
意向锁之间的兼容性
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
意向锁之间是互相兼容的
意向共享锁(IS) | 意向排他锁(IX) | |
---|---|---|
共享锁(S) | 兼容 | 互斥 |
排他锁(X) | 互斥 | 互斥 |
这里的排他/共享锁指的都是表级锁,意向锁不会与行级的共享/ 排他锁互斥
结论:
- InnoDB 支持
多粒度锁
,在特定场景下,行级锁可以和表级锁共存 - 意向锁之间互不排斥,除了IS 与 S 兼容,
意向锁会与 共享锁 / 排他锁 互斥
- IX ,IS 是表级锁,不会和行级的 X,S 锁发生互斥,只会和表级的 X,S发生互斥
- 意向锁在保证并发性的前提下,实现了
行锁和表锁共存
且满足事务隔离性
的要求
③自增锁(AUTO-INC锁)
创建表时,为某个列添加AUTO_INCREMENT
属性
CREATE TABLE `teacher`(
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
PRIMARY KEY(`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
AUTO-INC 锁是当向含有AUTO-INCREMENT 列的表中插入数据时需要获取的一种特殊的表级锁
插入数据的方式共分为三类:Simple inserts
、Bulk inserts
和 Mixed-mode inserts
Simple inserts(简单插入)
可以确定插入的行数
Bulk inserts(批量插入)
实现不知道要插入的行数。例如:INSERT ... SELECT
Mixed-mode inserts(混合模式插入)
是 Simple inserts 语句但是指定部分新行的自动递增值。例如:INSERT INTO teacher(id ,name) vaules (1,'a'),(NULL,'b'),(5,'c')
④元数据锁(MDL 锁)
问题:一个事务正在遍历表中数据,另外一个事务正在对这个表结构修改。
所以,当对一个表做增删改查操作时,加 MDL 读锁;当要对表结构修改时,加 MDL 写锁
MDL 读锁之间不互斥。读写锁、写锁之间是互斥的。不需要显式使用
2.InnoDB 中的行锁
行锁也称为记录锁。注意:MySQL 服务层没有实现行锁机制,行级锁只在存储引擎层实现
**优点:**锁定粒度小,发生锁冲突概率低
,并发性高
缺点:锁的开销较大
,加锁速度慢,容易出现死锁
情况
InnoDB 与 MyISAM 最大不同有两点:支持事务、采用行级锁
举例:
创建表:
CREATE TABLE student(
id INT,
name VARCHAR(20),
class VARCHAR(10),
PRIMARY KEY(id)
) Engine=InnoDB CHARSET=utf8;
插入数据:
INSERT INTO student VALUES
(1,'张三','一班'),
(3,'李四','一班'),
(8,'王五','二班'),
(15,'赵六','二班'),
(20,'钱七','三班'),
①记录锁(Record Locks)
也就是锁住一条记录。官方类型名称为:LOCK_REC_NOT_GAP
。
记录锁分为:S型记录锁
和X型记录锁
②间隙锁(Gap Locks)
MySQL 在 REPEATABLE READ 隔离级别下可以解决幻读问题,方案有两种:MVCC 方案,加锁方案。加锁方案最大的问题是在事务第一次读取操作时,幻影记录尚不存在,无法给这些幻影记录
加上记录锁
。InnoDB 提出了一种Gap Locks
的锁,官方名称为:LOCK_GAP
,可以简称为gap锁
。
图中为 id 值为 8 记录加了 gap 锁,也就是不允许别的事务在 id 值为 8 的记录前边的间隙插入新的记录
,也就是id值为3和8之间的位置新记录不允许立即插入。例如:一个事务再想插入id 值为 4 的新记录,它定位到该条记录的下一条记录 id 值为8,而 id 值为 8 记录上又有一个 gap 锁,就会阻塞插入操作。
gap锁的提出仅仅只是为了防止插入幻影记录而提出的
间隙锁可能会出现死锁的问题
③临键锁(Next-Key Locks)
有时既想锁住某条记录
,又想阻止
其他事务在该记录前边的间隙插入新记录
。官方类型名称为:LOCK_ORDINARY
,也可简称为next-key锁
。Next-Key Locks 是在存储引擎 InnoDB
,事务级别在可重复读
的情况下使用的数据库锁,InnoDB默认的锁就是Next-Key locks
Next-key锁
(临键锁)本质上是记录锁
和gap锁(间隙锁)
的合体,既可以锁住该条记录,又能阻止其他事务插入新记录在该记录前的间隙
begin;
select * from student where id <= 8 and id > 3 for update;
④插入意向锁(Insert Intention Locks)
一个事务在插入
一条记录时需要判断插入的位置是否被别的事务加了gap锁
(next-key锁
也包含有gap锁
),若有的话,插入操作需要等待,直到有gap锁
的事务提交。但是InnoDB规定事务在等待时需要在内存中生成一个锁结构,有事务想在某个间隙
中插入新记录,但是现在处于等待状态。InnoDB就把这种类型的锁命名为:Insert Intention Locks
,官方类型名称为:LOCK_INSERT_INTENTION
,称为:插入意向锁
。插入意向锁也是一种Gap
锁,不是意向锁,在 insert 操作时产生。
插入意向锁是在插入一条记录行前,由 INSERT 操作产生的一种间隙锁
。
事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
3. 页锁
页锁,锁定的数据比行锁多。当使用页锁时,会出现数据浪费的现象。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般
每个层级的锁是有数量限制的,因为锁会占用内存空间,锁空间的大小是有限的
。当某个层级的锁数量超过了阈值,则会进行锁升级
。锁升级就是用粒度更大的锁替代多个粒度小的锁。好处:占用锁空间降低。坏处:数据并发度降低
3.3 从对待锁的态度划分:乐观锁、悲观锁
悲观锁和乐观锁并不是锁,而是锁的设计思想
1.悲观锁
悲观锁是一种思想,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制实现,来保证数据操作的排他性。
悲观锁总是假设最坏的情况,每次拿数据时都认为其他事务会修改,所以每次拿数据时都会上锁,这样其他事务想拿这个数据就会阻塞
直到它拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程)
秒杀案例1:
商品秒杀过程中,库存数量减少,要避免超卖
的情况。例如:商品表中有一个字段为 quantity 表示当前商品的库存。在不使用锁的情况下
第 1 步:查出商品库存
select quantity from items where id = 1001;
第 2 步:若库存大于0,则根据商品信息生产订单
insert into orders(item_id) values(1001);
第 3 步:修改商品的库存,num表示购买数量
update items set quantity = quantity - num where id = 1001;
并发量小时没有大问题,但是若在高并发
下可能会出现以下问题
线程A | 线程B | |
---|---|---|
1 | step1(查询还有100部手机) | step1(查询还有100部手机) |
2 | step2(生成订单) | |
3 | step2(生成订单) | |
4 | step3(减库存1) | |
5 | step3(减库存2) |
线程B已经下单并且减完库存,此时线程A仍然执行step3,就会出现超卖
现象
可以使用悲观锁解决这个问题。前提是将需要执行的SQL 语句放在同一事务中,否则达不到锁定数据行的目的。
第一步:查询商品库存
select quantity from items where id = 1001 for update;
第二步:若库存大于0,则根据商品信息生成订单
insert into orders(item_id) values(1001);
第三步:修改商品的库存,num表示购买数量
update items set quantity = quantity - num where id = 1001;
此时:select ... for update
是MySQL 中悲观锁。此时在表中,id 为 1001 的这条数据就被锁定了,其他事务要执行select quantity from items where id = 1001 for update
必须要等本次事务提交之后才能执行。
注意:当执行select quantity from items where id = 1001 for update
语句之后,若在其他事务中执行 select quantity from items where id = 1001
可以正常查询出数据,不会受第一个事务的影响。
注意:select… for update 语句执行过程中将所有扫描到的行都会上锁,所以在 MySQL 中用悲观锁必须确定使用了索引,而不是全表扫描,否则会将整个表锁住
悲观锁并不适用很多场景,缺点:依靠锁机制实现,降低并发性,影响性能,特别是长事务
,开销无法承受
,需要乐观锁
2.乐观锁(Optimistic Locking)
乐观的认为对同一数据并发操作发生概率较小,不用每次都上锁,但是会在更新时判断在此期间是否有其他事务更新这个数据。也就是不采用数据库自身的锁机制,而是通过程序实现。在程序上,可以采用版本号机制
或CAS机制
实现。乐观锁适用于多读的应用,可以提高吞吐量。在 Java 中 java.util.concurrent.atomic
包下的原子变量类就是用乐观锁的一种实现方式:CAS实现的
1.乐观锁的版本号机制
在表中设计一个版本字段 version
,第一次读时,会获取 version 的值,后对数据进行更新或删除操作时,会执行UPDATE ... SET version = version + 1 WHERE version = version
。若此时有事务对该条数据修改,则修改就不会成功
类似于 SVN、CVS 版本管理系统。当我们修改了代码进行提交时,首先检查当前版本号与服务器上的版本号是否一致,若一致则可以直接提交,若不一致就需要更新服务器上的最新代码,后再提交
2.乐观锁的时间戳机制
时间戳和版本号机制一样,也就是在更新提交时,将当前数据的时间戳和更新之前获取到的时间戳进行比较,若相同则更新成功,否则版本冲突
秒杀案例2
第一步:查询商品库存
select quantity from items where id = 1001;
第二步:若库存大于0,则根据商品信息生成订单
insert into orders(item_id) values(1001);
第三步:修改商品的库存,num表示购买数量
update items set quantity = quantity - num,version = version + 1 where id = 1001 and version = #{version};
注意:若数据表是读写分离
的表,当 master 表中写入数据没有及时更新到slave 表中,会出现更新一直失败的情况。此时需要强制读取 master 表
中的数据(即将 select 语句放在事务即可,此时查询的就是 master 主库了)
若要对同一数据进行频繁的修改
,则会出现每次修改时只有一个事务能更新成功,其他事务都失败
第一步:查询商品库存
select quantity from items where id = 1001;
第二步:若库存大于0,则根据商品信息生成订单
insert into orders(item_id) values(1001);
第三步:修改商品的库存,num表示购买数量
update items set quantity = quantity - num where id = 1001 and quantity - num > 0
3. 两种锁的适用场景
乐观锁
适用于读操作较多
的场景,相对于写操作较少。优点在于程序实现
、不存在死锁
问题悲观锁
适用于写操作较多
的场景,因为写操作有排他性
。可以防止读 - 写
和写 - 写
的冲突
3.4 按加锁的方式划分:显式锁、隐式锁
1. 隐式锁
一个事务在执行 INSERT
操作时,若插入位置的间隙被其他事务加了 gap锁
,则 INSERT
操作会阻塞,且当前事务会在该间隙中加上一个插入意向锁
,否则一般情况下INSERT
操作是不加锁的。若一个事务插入一条记录,另外一个事务:
-
立即使用
SELECT ... LOCK IN SHARE MODE
语句读取该条记录,也就是获取该记录的S锁
或使用SELECT ... FOR UPDATE
语句读取该条记录,也就是获取该记录的X锁
若允许这种情况发生,则可能产生
脏读
问题 -
立即修改该条记录,也就是要获取这条记录的
X锁
若允许这种情况发生,可能出现
脏写
问题
一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id
的存在,相当于加了一个隐式锁
。别的事物在对该条记录加S锁
或X锁
时,由于隐式锁
的存在,会帮当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁
是一种延迟加锁
的机制。
session1
begin;
OK
insert INTO student VALUES(34,'qwe','qwe');
OK
执行下列语句:没有查询到锁结构
mysql> SELECT * FROM performance_schema.data_lock_waits\G
Empty set (0.04 sec)
session2
begin;
ok
select * from student lock in share mode; 当前事务被阻塞
执行下述语句:
mysql> SELECT * FROM performance_schema.data_lock_waits\G
*************************** 1. row ***************************
ENGINE: INNODB
REQUESTING_ENGINE_LOCK_ID: 140216438665456:32:4:4:140216320995944
REQUESTING_ENGINE_TRANSACTION_ID: 421691415376112
REQUESTING_THREAD_ID: 48
REQUESTING_EVENT_ID: 11
REQUESTING_OBJECT_INSTANCE_BEGIN: 140216320995944
BLOCKING_ENGINE_LOCK_ID: 140216438664600:32:4:4:140216320989440
BLOCKING_ENGINE_TRANSACTION_ID: 31754
BLOCKING_THREAD_ID: 48
BLOCKING_EVENT_ID: 11
BLOCKING_OBJECT_INSTANCE_BEGIN: 140216320989440
1 row in set (0.00 sec)
隐式锁逻辑:
- InnoDB每条记录都含有一个隐藏的 trx_id 字段,这个字段存在于聚簇索引的B+树种
- 在操作一条记录前,先根据记录种的 trx_id 检查该事务是否为活动事务(未提交或回滚)。若是活动的事务,首先将
隐式锁
转换为显式锁
(为该事务添加一个锁) - 检查是否有锁冲突,若有冲突,创建锁,并设置为 waiting 状态。若没有冲突则跳到第5步
- 等待加锁成功,被唤醒,或超时
- 写数据,并且将自己的 trx_id 写入 trx_id 字段
2. 显式锁
通过特定语句加锁
显式加共享锁
select ... lock in share mode
显式加排它锁
SELECT ... FOR UPDATE
3.5 全局锁
全局锁就是对整个数据库实例
加锁。当需要让整个库处于只读状态
时,可以加全局锁。典型使用场景
:做全库逻辑备份
FLUSH TABLES with read lock;
3.6 死锁
1. 概念
两个事务都持有对方需要的锁,并且在等待对方释放,且双方都不会释放自己的锁
举例:
事务1 | 事务2 | |
---|---|---|
1 | begin; update account set money = 100 where id = 1; |
begin; |
2 | update account set money = 100 where id = 2; | |
3 | update account set money = 200 where id = 2; | |
4 | update account set money = 200 where id = 1; |
举例2:
用户A 给用户 B 转账 100 ,同时 ,用户 B 也给用户A转账 100。可能导致死锁
事务1
update account set balance = balance - 100 where name = 'A';操作1
update account set balance = balance + 100 where name = 'A';操作3
事务2
update account set balance = balance - 100 where name = 'B';操作2
update account set balance = balance + 100 where name = 'A';操作4
2. 产生死锁的必要条件
- 两个或两个以上事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被同一个事务持有
- 事务之间因为持有锁和申请锁导致彼此循环等待
死锁关键在于:两个(或以上)的 Session 加锁的顺序不一致
3. 如何处理死锁
方式1:等待直到超时(innodb_lock_wait_timeout=50s)
**方式2:**使用死锁检测进行死锁处理
innodb 提供了wait-for graph算法
主动进行死锁检测,每当加锁情况无法立即满足需求并进入等待时,wait-for graph 算法都会被触发
这是一种主动的死锁检测机制
,要求数据库保存锁的信息链表
和事务等待链表
两部分信息
基于两个信息,可以绘制 wait-for graph(等待图)
死锁检测原理是构建一个以事务为顶点,锁为边的有向图,判断有向图是否存在环,存在即有死锁
一旦检测到死锁,InnoDB 会回滚undo 量最小的事务
,让其他事务继续执行
**缺点:**每个新被阻塞的线程,都要判断是否因为自己的加入导致了死锁,时间复杂度为O(n)。若有 100 并发线程同时更新同一行,则要检测 100 * 100 = 1w 次
如何解决?
- 方式1:关闭死锁检测
- 方式2:控制并发访问的数量
进一步思路:
通过将一行改成逻辑上的多行减少锁冲突
4. 如何避免死锁
- 合理设计索引
- 调整业务逻辑 SQL 执行顺序
- 避免大事务,将大事务拆分为多个小事务
- 在并发较高的系统,不要显式加锁
- 降低隔离级别。
4. 锁的内存结构
一个事务对多条记录加锁,就要创建多个锁结构
?
SELECT * FROM user LOCK IN SHARE MODE;
在对不同记录加锁时,满足以下条件的记录会放在同一个锁结构
中
- 在同一事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
InnoDB
存储引擎的锁结构
结构解析:
- 锁所在的事务信息
无论是表锁
还是行锁
,都是在事务执行过程中生成的,这个部分的结构就是一个指针,指向内存中关于该事务的更多信息
- 索引信息
记录加锁记录属于哪个索引的,也是一个指针
- 表锁/行锁信息:
表锁结构
和行锁结构
内容是不同的
-
表锁
记录了是对哪个表加的锁,还有其他的一些信息
-
行锁:
记录了三个重要信息:
Space ID
:记录所在表空间Page Number
:记录所在页号n_bits
:对于行锁来说,一条记录就对应着一个比特位,一个页中包含有多个记录,用不同的比特位来区分到底是哪条记录加了锁。为此在行锁结构的末尾放了一堆比特位。这个属性代表使用了多少比特位
n_bits 的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入新记录后不至于重新分配锁结构
-
type_mode
是一个 32 位的数,被分为了
lock_mode
、lock_type
、rec_lock_type
三个部分
-
锁的模式(
lock_mode
),占用低4位,可选值如下:LOCK_IS
(十进制的0
):表示共享意向锁,也就是IS锁
。LOCK_IX
(十进制的1
):表示独占意向锁,也就是IX锁
。LOCK_S
(十进制的2
):表示共享锁,也就是S锁
LOCK_X
(十进制的3
):表示独占锁,也就是X锁
。LOCK_AUTO_INC
(十进制的4
):表示AUTO-INC锁
。
-
锁的类型( lock_type ),占用第5~8位,不过现阶段只有第5位和第6位被使用:
LOCK_TABLE
(十进制的16
),也就是当第5个比特位置为1时,表示表级锁。LOCK_REC
(十进制的32
),也就是当第6个比特位置为1时,表示行级锁
-
行锁的具体类型(
rec_lock_type
),使用其余的位来表示。只有在lock_type
的值为LOCK_REC
时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:LOCK_ORDINARY
(十进制的0
):表示next-key锁
。LOCK_GAP
(十进制的512
):也就是当第10个比特位置为1时,表示gap锁
。LOCK_REC_NOT_GAP
(十进制的1024
):也就是当第11个比特位置为1时,表示正经记录锁
LOCK_INSERT_INTENTION
(十进制的2048
):也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
-
is_waiting
属性 。基于内存空间的节省,所以把is_waiting
属性放到了type_mode
这个32
位的数字中LOCK_WAIT
(十进制的256
) :当第9个比特位置为1
时,表示is_waiting
为true
,也
就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0
时,表示is_waiting
为
false
,也就是当前事务获取锁成功。
5. 锁监控
一般使用InnoDB_row_lock
变量分析系统上行锁的争夺情况
mysql> show status like 'innodb_row_lock%';
+-------------------------------+-------+
| Variable_name | Value |
+-------------------------------+-------+
| Innodb_row_lock_current_waits | 0 |
| Innodb_row_lock_time | 0 |
| Innodb_row_lock_time_avg | 0 |
| Innodb_row_lock_time_max | 0 |
| Innodb_row_lock_waits | 0 |
+-------------------------------+-------+
5 rows in set (0.01 sec)
- Innodb_row_lock_current_waits:当前正在等待锁定的数量;
Innodb_row_lock_time
:从系统启动到现在锁定总时间长度;(等待总时长)Innodb_row_lock_time_avg
:每次等待所花平均时间;(等待平均时长)- Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
Innodb_row_lock_waits
:系统启动后到现在总共等待的次数;(等待总次数)