MySQL锁
锁机制:数据库为了保证 共享资源被并发访问时 数据的安全性和一致性,所设计的一种规则。
一、锁的内存结构
加锁的本质,就是在内存中创建一个 锁结构
与之关联。InnoDB 存储引擎中的 锁结构
如下图所示:
那么是不是对多条记录加锁,就要创建多个
锁结构
呢?
答案当然是否,不然一个事务要获取10000条记录的锁,生成10000个锁结构也太崩溃了!
所以决定在对不同记录加锁时,如果符合下边这些条件的记录,会放到同一个锁结构中。
- 在同一个事务中进行的加锁操作
- 加锁的记录在同一个页面中。
- 加锁的类型是一样的。
- 锁等待状态是一样的
1、锁所在的事务信息
不论是 表锁
还是 行锁
,都是在事务执行过程中生成的,哪个事务生成了这个锁结构 ,这里就记录这个事务的信息。
锁所在的事务信息
在内存结构中只是一个指针
,通过指针可以找到内存中关于该事务的更多信息,比如事务id
等。
2、索引信息
对于 行锁
来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针
。
3、表锁结构 & 行锁结构
表锁结构 和 行锁结构 在这个位置的内容是不同的
-
表锁结构
-
对哪个表加的锁
-
其他的一些信息
-
-
行锁结构
-
Space ID
:记录所在的表空间
。 -
Page Number
:记录所在的页号
。 -
n_bits
:哪一条记录加了锁(为此在行锁结构的末尾放置了一堆比特位)对于
行锁
来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。n_bits
的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后也不至于重新分配锁结构。
-
4、type_mode
这是一个32位的数,被分成了 lock_mode
、 lock_type
、 rec_lock_type
三个部分,如图所示:
1)lock_mode
锁的模式( 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锁 。
在InnoDB存储引擎中,IS锁、IX锁、AUTO-INC锁 都算是表级锁的模式;S锁、X锁 既可以算是表级锁的模式,也可以是行级锁的模式。
2)lock_type
锁的类型( lock_type ),占用低5~8位,不过现阶段只有第5位和第6位被使用:
LOCK_TABLE
(十进制的 16 ),也就是当第5个比特位置为1时,表示表级锁
。LOCK_REC
(十进制的 32 ),也就是当第6个比特位置为1时,表示行级锁
。
3)rec_lock_type
行锁的具体类型( rec_lock_type ),使用其余的位来表示。
只有在 lock_type = LOCK_REC
时,也就是只有在锁的类型是行级锁
时,才会被细分为更多的类型:
LOCK_ORDINARY
(十进制的 0 ):表示next-key锁
。LOCK_GAP
(十进制的 512 ):也就是当第10个比特位置为1时,表示间隙锁
。LOCK_REC_NOT_GAP
(十进制的 1024 ):也就是当第11个比特位置为1时,表示正经 记录 锁 。LOCK_INSERT_INTENTION
(十进制的 2048 ):也就是当第12个比特位置为1时,表示插入意向锁
。- 其他的类型:还有一些不常用的类型我们就不多说了。
4)is_waiting
基于内存空间的节省,所以把 is_waiting 属性 放到了 type_mode 的低9位
LOCK_WAIT
(十进制的 256 )- 当第9个比特位置为 1 时,表示
is_waiting
为true
,也就是当前事务尚未获取到锁
,处在等待状态; - 当第9个比特位置为 0 时,表示
is_waiting
为false
,也就是当前事务获取锁成功
。
- 当第9个比特位置为 1 时,表示
5、其他信息
为了更好的管理系统运行过程中生成的各种锁结构,设计了各种哈希表
和链表
。
6、一堆比特位
如果是 行锁结构
的话,在该结构末尾还放置了一堆比特位,比特位的数量是由 行锁结构 的 n_bits
属性表示的。
InnoDB 数据页中的每条记录 在 记录头信息
中都包含一个 heap_no
属性
- 伪记录
Infimum
的heap_no
值为0
,Supremum
的heap_no
值为1
- 之后每插入一条记录,
heap_no
值就+1
。
锁结构 最后的一堆比特位 就对应着 一个页面中的记录,一个比特位 映射一个 heap_no
,即 一个比特位 映射到 页内的一条记录。
二、读锁 & 写锁
按 对数据操作的类型 可以分为
读锁
和写锁
:
读锁
,也叫共享锁
和S锁
。一个事务加了读锁,其他事务依然能获取该数据的读锁,但是无法获取该数据的写锁。写锁
,也叫排他锁
和X锁
,一个事务加了写锁,其他事务就不能再获取该数据的其他锁(包括读锁和写锁)
读锁 | 写锁 | |
---|---|---|
读锁 | 兼容 | 冲突 |
写锁 | 冲突 | 冲突 |
三、表锁 & 行锁 & 页锁
按 对数据操作的粒度 可以分为
表锁
、行锁
和页锁
锁类型 | 锁定的数据 | 粒度 | 开销 | 锁冲突 | 并发度 |
---|---|---|---|---|---|
表锁 | 整个表 | 大 | 小 | 概率高 | 低 |
行锁 | 操作行 | 小 | 大 | 概率低 | 高 |
页锁 | 整个页 | 两者之间 | 两者之间 | 可能会死锁 | 一般 |
MySQL中,不同的存储引擎支持不同的锁机制:
存储引擎 | 表锁 | 行锁 |
---|---|---|
InnoDB | ✓ | ✓(默认) |
MyISAM | ✓ | × |
Memory | ✓ | × |
InnoDB
默认是行锁
。更新
操作会自动加写锁
,查询
操作不会自动加任何锁,可以手动加锁。MyISAM
默认是表锁
。更新
操作会自动加写锁
,查询
操作会自动加读锁
。(MyISAM
的锁调度是写锁优先
)
1、表锁
MyISAM
默认是表锁
。更新
操作会自动加写锁
,查询
操作会自动加读锁
。(MyISAM
的锁调度是写锁优先
)
InnoDB
也支持表锁
,但是一般不会使用,只会在一些特殊情况下,比方说崩溃恢复过程中用到。
InnoDB
在系统变量 autocommit=0,innodb_table_locks=1
时,可以手动获取表级别
的S锁
和X锁
lock table tableName read; # 表级别的S锁
lock table tableName write; # 表级别的X锁
unlock tables; # 释放全部锁
1)意向锁 (Intention Lock)
InnoDB
支持多粒度锁 multiple granularity locking
,它允许 行级锁
与 表级锁
共存,而意向锁
就是其中一种 表锁
意向锁
的存在是为了协调行锁
和表锁
的关系,实现了 行锁与表锁共存 且 满足事务隔离性 的要求。意向锁
的读写锁
是表级锁
,不会和行级别
的读写锁
发生冲突,只会和表级别
的读写锁
发生冲突。- 因此,
意向锁
并不会影响到 对 不同数据行 加锁 时的并发性。
- 因此,
意向锁
会告诉 其他事务 已经有人锁定了表中的某些记录。
意向锁的分类
- 意向共享锁(
intention shared lock, IS
):事务有意向对表中的某些行加共享锁- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
- 意向排他锁(
intention exclusive lock, IX
):事务有意向对表中的某些行加排他锁- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
意向锁的作用
现在有两个事务,分别是T1和T2,其中T2试图在表级别
上应用 共享锁
或 排它锁
- 如果不存在
意向锁
,那么T2就需要检查各个页或行是否存在锁; - 如果存在
意向锁
,那么T2在锁定该表前,只需要检查表上的意向锁,而不必检查各个页或行是否存在锁
简单来说就是给更大一级别的空间示意里面是否已经上过锁。
意向锁的使用
意向锁由 存储引擎 自己维护,用户无法手动操作。
在为 数据行
加 读写锁
之前,InooDB
会自动
给该数据行所在的更大一级
的空间,比如数据页或数据表加上对应的意向锁
- 事务想要获得数据表中某些行记录的
共享锁
,就需要在数据表
上添加意向共享锁
。 - 事务想要获得数据表中某些行记录的
排他锁
,就需要在数据表
上添加意向排他锁
。
这样,当有其他事务想要获取数据表的锁时,意向锁
会告诉其他事务已经有人锁定了表中的某些记录。
2)自增锁(AUTO-INC Lock)
我们可以为表的某个列添加 AUTO_INCREMENT
属性,如果在插入语句中没有给该列显示赋值,系统会自动
为它赋上递增
的值
CREATE TABLE `teacher` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
所有插入数据的方式总共分为三类:
-
Simple inserts(简单插入)
预先确定要插入的行数。例如:
INSERT INTO ... VALUES()
语句 -
Bulk inserts(批量插入)
预先不知道要插入的行数。例如:
INSERT ... SELECT
,REPLACE ... SELECT
和LOAD DATA
语句。InnoDB 每处理一行,为
AUTO_INCREMENT
列分配一个新值。 -
Mixed-mode inserts(混合模式插入)
-
是 Simple inserts 语句,但是只指定了部分新行的自动递增值。
例如:
INSERT INTO xxx (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');
-
存在则更新,不存在则新增
例如:
INSERT ... ON DUPLICATE KEY UPDATE
。
-
自增锁 AUTO-INC
:向含有AUTO_INCREMENT
列的表中插入数据时,需要获取的一种特殊的表级锁。
- 在执行插入语句时,就在表级别加一个
AUTO-INC锁
- 然后为每条待插入记录的
AUTO_INCREMENT
修饰的列分配递增的值
。 - 在该插入语句执行结束后,再把
AUTO-INC锁
释放掉。
事务在持有AUTO-INC锁
的过程中,其他事务的插入语句都要被阻塞
,可以保证一个语句中分配的递增值是连续的。但是当我们向一个含有 AUTO_INCREMENT
关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的。
InnoDB
通过 innodb_autoinc_lock_mode
的不同取值来提供不同的锁定机制,来显著提高SQL语句的可伸缩性和性能。
-
innodb_autoinc_lock_mode = 0(“传统”锁定模式)
每当执行
insert
的时候,都会得到一个表级锁 AUTO-INC
,保证语句中生成的auto_increment
为顺序的,且在binlog
中重放的时候,可以保证master
与slave
中数据的auto_increment
是相同的。因为是表级锁,当在同一时间多个事务中执行
insert
的时候,对于AUTO-INC锁
的争夺会限制并发能力
。 -
innodb_autoinc_lock_mode = 1(“连续”锁定模式)
Bulk inserts
仍然使用表级锁 AUTO-INC
,并保持到语句结束,同一时刻只有一个语句可以持有AUTO-INC锁
。Simple inserts
则通过在mutex轻量锁
的控制下 获得所需数量的自动递增值 来避免AUTO-INC表级锁
。它只在分配过程的持续时间内保持,而不是直到语句完成。 -
innodb_autoinc_lock_mode = 2(“交错”锁定模式)
在此锁定模式下,自动递增值 保证 在所有并发执行的所有类型的insert语句中是
唯一且单调递增
的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号), 为任何给定语句插入的行生成的值可能 不是连续的。
在 MySQL 8.0 之前,默认使用连续锁定模式
;从 MySQL 8.0 开始,默认使用交错锁定模式
。
3)元数据锁(MDL Lock)
MDL(MetaData Lock)的作用是,在读写时发生表结构的变更,也能保证读写的正确性。
在MySQL 5.5版本中引入了MDL:
- 对一个表进行 增删改查 操作的时候,自动加 MDL读锁
- 对一个表进行 结构变更 操作的时候,自动加 MDL写锁。
MDL锁通过以下机制,保证表结构变更时 读写的正确性:
- 读锁 和 读锁 之间不互斥,因此可以有多个线程同时对一张表增删改查。
- 读锁 和 写锁之间、写锁 和 写锁之间是互斥的,因此表结构变更操作只能在没有任何MDL锁的时候执行。
执行流程如下:
- session A先启动,这时候会对表t加一个
MDL读锁
。 - session B需要的也是
MDL读锁
,读锁之间不互斥,因此可以正常执行。 - session C需要
MDL写锁
,而session A的MDL读锁
还没有释放,因此只能被阻塞。 - 之后所有要在表t上新申请
MDL读锁
的请求也会被session C阻塞。
所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表现在完全不可读写了。
那么如何安全的给表加字段呢?
-
MySQL的 information_schema 库的 innodb_trx 表中,可以查到当前执行中的事务。
如果你要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。
-
如果要变更的表是一个热点表,这时候kill可能未必管用,因为新的请求马上就来了。
这时可以在alter table语句里面设定等待时间,如果在等待时间内拿不到MDL写锁,就先放弃,之后再重试。
# MariaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n这个语法。
ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...
2、行锁
只有InnoDB
存储引擎支持行锁
。可以通过一下命令手动 获取 和 释放 行级别
的S锁
和X锁
select * from tableName where ... lock in share mode; # 行级别的S锁
select * from tableName where ... for update; # 行级别的X锁
commit; # 提交事务即解锁
1)记录锁(Record Locks)
记录锁也就是仅仅把一条记录锁上,对周围的数据没有影响。官方的类型名称为 LOCK_REC_NOT_GAP
。
记录锁是有S锁和X锁之分的,称之为 S型记录锁 和 X型记录锁 。
- 当一个事务获取了一条记录的
S型记录锁
后,其他事务也可以
继续获取该记录的S型记录锁
,但不可以
继续获取X型记录锁
; - 当一个事务获取了一条记录的
X型记录锁
后,其他事务既不可以
继续获取该记录的S型记录锁
,也不可以
继续获取X型记录锁
。
2)间隙锁(Gap Locks)
顾名思义,间隙锁,锁的就是两个值之间的空隙。例如下列的insert语句,插入了6个记录,这就产生了7个间隙。
insert into t values (0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。当前读
在一行行扫描的过程中,不仅给行加上了行锁
,还给行两边的空隙,也加上了间隙锁
。
注意:间隙锁在可重复读级别下才会生效。所以,如果把隔离级别设置为读已提交的话,就没有间隙锁了。
1、
行锁
和行锁
之间是存在冲突的。
SessionA | SessionB | |
---|---|---|
T1 | begin; select * from t where d=5 for update; # 行锁 | |
T2 | begin; select * from t where d=5 for update; # 会阻塞 update t set c=6 where d=5; # 会阻塞 |
2、
间隙锁
和间隙锁
之间是不存在冲突关系的。
SessionA | SessionB | |
---|---|---|
T1 | begin; select * from t where d=7 for update; # 间隙锁 (5, 10) | |
T2 | begin; select * from t where d=7 for update; # 不会阻塞 |
- 因为表t里并没有c=7这个记录,因此session A加的是间隙锁 (5,10)
- session B 加的也是间隙锁 (5,10),但是它们之间是不冲突的。
3、跟间隙锁存在冲突关系的,是
往这个间隙中插入一个记录
这个操作。
SessionA | SessionB | |
---|---|---|
T1 | begin; select * from t where d=4 for update; # 间隙锁 (0, 5) | |
T2 | begin; insert into t values (2,2,2); # 会阻塞 insert into t values (7,7,7); # 不会阻塞 |
4、间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的,还有可能会导致死锁
两个间隙锁形成了死锁,按语句执行顺序来分析一下:
- session A 执行select … for update语句,由于id=9这一行并不存在,因此会加上间隙锁 (5,10);
- session B 执行select … for update语句,同样会加上间隙锁 (5,10),间隙锁之间不会冲突,因此执行成功;
- session B 试图插入一行 (9,9,9),被session A的间隙锁挡住了,只好进入等待;
- session A试图插入一行 (9,9,9),被session B的间隙锁挡住了。
至此,两个session进入互相等待状态,形成死锁。InnoDB的死锁检测发现了这对死锁关系,让session A的insert语句报错返回了。
3)临键锁(Next-Key Locks)
间隙锁
和 行锁
合称 next-key lock
。(Gap Locks
是 开区间,next-key lock
是 左开右闭区间)
# 锁住整张表
select * from t for update;
# 形成了7个next-key lock
(-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +suprenum]
因为+∞是开区间,实现上,为了满足左开右闭区间的规则,InnoDB给每个索引加了一个不存在的最大值suprenum
4)插入意向锁(Insert Intention Lock)
一个事务在执行 INSERT
操作时,如果即将插入的间隙已经被其他事务加了间隙锁
,那么本次 INSERT
操作会阻塞。而 InnoDB
规定:事务在等待的时候,也需要在内存中生成一个锁结构,所以此时会在该间隙上加一个插入意向锁
。
插入意向锁 Insert Intention Locks
是一种特殊的间隙锁
,不是意向锁,是在插入一条记录行前由 INSERT 操作产生的
插入意向锁
之间互不排斥
,即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,事务之间就不会冲突
从图中可以看到:
- 由于T1持有
间隙锁
,所以T2和T3需要生成一个插入意向锁
的锁结构并且处于等待状态(is_waiting=true
) - T1提交后会释放
间隙锁
,这样T2和T3就能获取到对应的插入意向锁
了 (即插入意向锁
对应锁结构的is_waiting=false
) - T2和T3之间
不会相互阻塞
,它们可以同时获取
到id值为8的插入意向锁
,然后执行插入操作
。
所以,插入意向锁
并不会阻止别的事务继续获取该记录上任何类型的锁。
3、页锁
页锁就是在 页的粒度
上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。
- 页锁的
开销
介于表锁和行锁之间,会出现死锁
。 - 页锁的
粒度
介于表锁和行锁之间,并发度一般
。
当我们使用页锁的时候,会出现数据浪费
的现象,但这样的浪费最多也就是一个页上的数据行。
每个层级的锁数量是有限制的
,因为锁会占用内存空间, 锁空间的大小是有限的 。当某个层级的锁数量超过了这个层级的阈值时,就会进行 锁升级
(就是用更大粒度的锁替代多个更小粒度的锁),比如InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。
4、两阶段锁协议
两阶段锁协议:加锁
和 解锁
分为两个阶段进行
-
在
InnoDB
事务中,行锁在需要的时候加上,但不是立即释放,而是要等事务结束时才释放。 -
如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
5、无索引,行锁升级为表锁
- 使用无索引的列,行锁升级为表锁(因为没有索引,不知道锁定哪一行,就锁定整张表)
- 如果不通过索引条件检索数据,那么InnoDB将对表中的所有记录加锁,实际效果跟表锁一样。
6、行锁优化建议
InnoDB
存储引擎由于实现了行级锁定
,虽然在锁定机制的实现方面带来的性能损耗可能比表锁
会更高一些,但在整体并发处理能力方面要远远优于MyISAM
存储引擎的表锁
。当系统并发量较高的时候,InnoDB
性能较高。
但是,InnoDB
的行级锁同样也有其脆弱的一面,当我们使用不当的时候,可能会让InnoDB
的整体性能表现不仅不能比 MyISAM
高,甚至可能会更差。
优化建议:
-
尽可能让所有数据检索都能通过索引来完成,避免无索引行锁升级为表锁。【表锁的并发度低】
-
合理设计索引,尽量缩小锁的范围。【提高并发度】
-
尽可能减少索引条件,及索引范围,避免间隙锁。【并发度低,容易出现异常】
-
尽量控制事务大小,减少锁定资源量和时间长度。【事务大,加锁时间长,并发度低】
-
在满足业务层面需求的情况下,尽可使用低级别的事务隔离。
四、显示锁 & 隐式锁
按 加锁的方式 可以分为
显示锁
和隐式锁
1、显示锁
显式锁
就是通过特定的语句显示加锁。
# 手动加表锁
lock table tableName read; # 表级别的S锁
lock table tableName write; # 表级别的X锁
unlock tables; # 释放全部锁
# 手动加行锁
select * from tableName where ... lock in share mode; # 行级别的S锁
select * from tableName where ... for update; # 行级别的X锁
commit; # 提交事务即解锁
2、隐式锁
隐式锁
是一种 延迟加锁
的机制,从而来减少加锁的数量。
一个事务对新插入的记录,可以不显示的加锁,但是由于 事务id
的存在,相当于加了一个隐式锁
。别的事务在对这条记录加 S锁
或 X锁
时,由于隐式锁
的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构并进入等待状态。
实际内存对象中,并不含有隐式锁的相关信息。只有产生锁等待时,才会将隐式锁
转化为显式锁
,此时才可以通过命令查看锁信息:
# 查看锁信息
SELECT * FROM performance_schema.data_lock_waits;
隐式锁的两种场景(聚簇索引 & 二级索引)
对于聚簇索引
来说:
- 每条记录中,都一个隐含的
DB_TRX_ID
字段,记录着最后改动该记录的事务id
- 其他事务想对该记录添加
S锁
或X锁
时,会先看一下该记录的DB_TRX_ID
检查该事务是否是活跃的事务(未提交或回滚)- 如果是活跃的事务,就为该事务添加一个
X锁
(is_waiting = false
,表示获取锁成功)
- 如果是活跃的事务,就为该事务添加一个
- 检查是否有锁冲突(
当前读
可能获取S锁
或X锁
;更新
操作 要获取X锁
)- 如果有冲突,为自己也创建一个锁结构(
is_waiting = true
,表示尚未获取到锁),进入等待状态 - 如果没有冲突,不加锁,直接进行第5步。
- 如果有冲突,为自己也创建一个锁结构(
- 等待加锁成功,被唤醒,或者超时。
- 写数据,并将自己的
DB_TRX_ID
写入DB_TRX_ID
字段。
对于二级索引
来说:
- 本身并没有
DB_TRX_ID
隐藏列,但是在二级索引页面的Page Header
部分有一个PAGE_MAX_TRX_ID
属性- 该属性代表对该页面做改动的
最大的事务id
- 该属性代表对该页面做改动的
- 如果
PAGE_MAX_TRX_ID < 当前最小的活跃事务id
,说明对该页面做修改的事务都已经提交了 - 否则就需要在页面中定位到对应的二级索引记录,然后
回表
找到它对应的聚簇索引记录,然后再重复 聚簇索引 的做法。
五、乐观锁 & 悲观锁
按 对待数据并发的态度 可以分为
乐观锁
和悲观锁
(乐观锁和悲观锁并不是实际意义上的锁,而是锁的设计思想
)
1、悲观锁(Pessimistic Locking)
悲观锁就是认为数据一定会被其他事务修改,所以每次在拿数据的时候都会上锁
,这样别人想拿这个数据就会 阻塞
直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞, 用完后再把资源转让给其它线程),从而保证数据操作的排它性。
比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁
,当其他线程想要访问数据时,都需要阻塞挂起。
Java 中 synchronized
和 ReentrantLock
等独占锁就是 悲观锁思想 的实现。
2、乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,不用每次都对数据上锁,但是在更新的时候会判断一下,在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过 程序 来判断当前拿到的数据是否最新。。
在程序上,我们可以采用 版本号机制
、时间戳机制
或 CAS机制
实现。
Java 中 java.util.concurrent.atomic
包下的 原子变量类 就是使用了乐观锁 的一种实现方式:CAS实现的。
乐观锁的版本号机制
在表中设计一个 版本字段 version
- 第一次读的时候,会获取
version
字段的取值。 - 对数据进行 更新 或 删除 操作时,会执行
UPDATE x SET version=version+1 WHERE version=version
。 - 如果已经有事务对这条数据进行了更改,修改就不会成功。
乐观锁的时间戳机制
时间戳和版本号机制一样,也是在更新提交的时候,将 当前数据的时间戳
和 更新之前取得的时间戳
进行比较,如果两者一致则更新成功,否则就是版本冲突。
可以看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或 者时间戳),从而证明当前拿到的数据是否最新。
3、两种锁的适用场景
从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:
- 乐观锁:
- 适合
读操作多
的场景,可以提高吞吐量。 - 优点在于通过程序实现,
不存在死锁问题
,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
- 适合
- 悲观锁:
- 适合
写操作多
的场景,因为写的操作具有排它性
。 - 采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止
读-写
和写-写
的冲突。
- 适合
六、全局锁
全局锁
就是 对整个数据库实例加锁。
- 加锁影响:阻塞 数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
- 使用场景:需要让整个库处于’只读状态’的时候,可以使用这个命令。(例如:做全库逻辑备份)
# 加锁命令(FTWRL)
Flush tables with read lock;
既然要全库只读,为什么不使用 set global readonly = true
的方式,而是使用全局锁
的方式呢?
- 在有些系统中,
readonly
的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。 - 在异常处理机制上有差异。
- 执行FTWRL命令之后,如果客户端发生异常断开,那么MySQL会自动释放这个全局锁。
- 将整个库设置为
readonly
之后,如果客户端发生异常,则数据库就会一直保持readonly
状态。
七、死锁
1、什么是死锁?
死锁:并发系统中,不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源,导致这几个线程都进入无限等待的状态。
上图的例子中,事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁,互相等待,进入了死锁状态。
2、超时时间 vs 死锁检测
通过参数 innodb_lock_wait_timeout
,可以设置锁等待超时时间。(默认是50s)
设置参数 innodb_deadlock_detect = on
,开启死锁检测:(默认就是on)
- 发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。(回滚持有最少行级排他锁的事务)
选择 设置超时时间 还是 开启死锁检测 ?
设置超时时间
- 默认超时时间是50s,对于在线服务来说,这个等待时间往往无法接受。
- 如果时间设置的很短,那么就会误伤到一些简单的锁等待。
因此,一般采用死锁检测的策略。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的。
死锁检测的弊端?
死锁检测的过程:
每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
热点行更新导致的性能问题:
每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度 O(n)
的操作。
假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是100万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。
因此,你就会看到CPU利用率很高,但是每秒却执行不了几个事务。
怎么解决 热点行更新 导致的性能问题呢?(高并发情况下,死锁检测要耗费大量的CPU资源)
1、关闭死锁检测
- 确保业务一定不会出现死锁,可以临时把死锁检测关掉。(风险比较大)
2、控制并发度
-
在 客户端 做并发控制(不可行)
假设客户端很多,即使每个客户端并发线程都很少,汇总到数据库服务端以后,峰值并发数也可能达到很高。
-
在 数据库服务端 做并发控制
如果你有中间件,可以考虑在中间件实现;如果有能修改MySQL源码的人,也可以做在MySQL里面。
基本思路:对于相同行的更新,在进入引擎之前排队。这样在InnoDB内部就不会有大量的死锁检测工作了。
3、从设计上做优化
-
将一行改成逻辑上的多行来减少锁冲突。
以影院账户为例,可以考虑放在多条记录上,影院的账户总额等于这多条记录的值的总和。每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的1/n,可以减少锁等待个数,也就减少了死锁检测的CPU消耗。
这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理。
3、怎么看待死锁?
出现死锁后,执行 show engine innodb status 命令会输出很多信息,有一节LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。
我们来看看这图中的几个关键信息。
(1) TRANSACTION
,是第一个事务的信息;(2) TRANSACTION
,是第二个事务的信息;WE ROLL BACK TRANSACTION (1)
,是最终的处理结果,表示回滚了第一个事务。
第一个事务的信息中:
-
(1) WAITING FOR THIS LOCK TO BE GRANTED
,表示的是这个事务在等待的锁信息; -
index c of table test.t
,说明在等的是表t的索引c上面的锁; -
lock mode S waiting
,表示这个语句要自己加一个读锁,当前的状态是等待中; -
Record lock
,说明这是一个记录锁; -
n_fields 2
,表示这个记录是两列,也就是字段c和主键字段id; -
0: len 4; hex 0000000a; asc ;;
是第一个字段,也就是c。值是十六进制a,也就是10;1: len 4; hex 0000000a; asc ;;
是第二个字段,也就是主键id,值也是10;这两行的asc表示的是,接下来要打印出值里面的“可打印字符”,但10不是可打印字符,因此就显示空格。
-
第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。
-
既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。可以从第二个事务的信息中推导出来。
第二个事务显示的信息要多一些:
(2) HOLDS THE LOCK(S)
,用来显示这个事务持有哪些锁;index c of table test.t
,表示锁是在表t的索引c上;hex 0000000a
和hex 00000014
表示这个事务持有c=10和c=20这两个记录锁;(2) WAITING FOR THIS LOCK TO BE GRANTED
,表示在等(c=5,id=5)这个记录锁。
从上面这些信息中,我们就知道:
- “lock in share mode”的这条语句,持有c=5的记录锁,在等c=10的锁;
- “for update”这个语句,持有c=20和c=10的记录锁,在等c=5的记录锁。
因此导致了死锁。这里,我们可以得到两个结论:
-
由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
-
在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,
所以InnoDB选择了回滚成本更小的 lock in share mode 语句,来回滚。
八、锁监控与排查
1、InnoDB_row_lock 等状态变量
通过检查 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
:系统启动后到现在总共等待的次数;(等待总次数) ☆
2、其他监控方法
MySQL5.7及之前,MySQL把事务和锁的信息记录在了 information_schema
库中:
- 涉及
INNODB_TRX
、INNODB_LOCKS
和INNODB_LOCK_WAITS
这三张表
# 查询正在被锁阻塞的sql语句
SELECT * FROM information_schema.INNODB_TRX;
# 查看事务的锁情况(只能看到阻塞事务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况)
SELECT * FROM information_schema.INNODB_LOCKS;
# 查看锁等待情况
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
MySQL8.0
information_schema.INNODB_TRX
仍然在使用information_schema.INNODB_LOCKS
被performance_schema.DATA_LOCKS
代替information_schema.INNODB_LOCK_WAITS
被performance_schema.DATA_LOCKS_WAITS
代替。
# 查询正在被锁阻塞的sql语句
SELECT * FROM information_schema.INNODB__TRX;
# 查看事务的锁情况(不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁)
SELECT * from performance_schema.DATA_LOCKS;
# 查看锁等待情况
SELECT * from performance_schema.DATA_LOCKS_WAITS;
3、其他的命令
# 查询锁等待情况
select * from data_lock_waits;
# 查询是否锁表
show open tables where In_use > 0;
# 直接找出造成阻塞的process id
select blocking_pid from sys.schema_table_lock_waits;
# 查询前100条进程(可以获取进程id)
show processlist;
# 查询所有进程
show full processlist;
# 杀死进程
kill id;