1. 锁机制详解
锁是计算机协调多个进程或线程并发访问某一资源的机制。
在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。
2. 锁分类
2.1. 性能上分类
乐观锁
乐观锁(用版本对比或CAS机制),乐观锁适合读操作较多的场景,写太多版本号变更太快,就会导致循环太多,cpu空转。
悲观锁
悲观锁适合写操作较多的场景,会对操作的数据进行加锁,其它事务操作需要堵塞等待。
注意
1. 乐观锁和悲观锁区别一个是不等待锁(更新不成功不等待),一个是等待锁。
2.2. 数据操作的粒度分
分为表锁、页锁、行锁。
表锁
每次操作锁住整张表。开销小,加锁快(对这张表,记录锁字段属性进行标识);不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景。
注意
InnoDB支持表锁,MyISAM是只支持表锁。
例子
1. 创建示例数据。

#--建表SQL
CREATE TABLE`mylock`(
`id`INT(11)NOT NULL AUTO_INCREMENT,
`name`VARCHAR(20) DEFAULT NULL,
PRIMARY KEY(`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3;
#--插入数据
INSERT INTO`mytest`.`mylock`(`id`,`name`)VALUES('1','a');
INSERT INTO`mytest`.`mylock`(`id`,`name`)VALUES('2','b');
INSERT INTO`mytest`.`mylock`(`id`,`name`)VALUES('3','c');
INSERT INTO`mytest`.`mylock`(`id`,`name`)VALUES('4','d')
2. 加锁,查看加锁的表,解锁所有表锁
加锁语法:
#读锁---对读操作共享,写互斥 写锁---对任何操作都互斥
lock table 表名称 read(write),表名称2 read(write);
例子:
lock table mylock(加锁的表名) write 或者 read (写或者读锁);
#查看所有表加锁的状态
show open tables;
#删除所有表锁
unlock tables
页锁
知道概念就行,只有BDB存储引擎支持页锁(InnoDB,MyISAM不支持),页锁就是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。
页相当于B+tree树叶子节点一个磁盘页数据,也就是这个磁盘页的所有数据去加锁。
行锁
每次操作锁住一行数据。开销大,加锁慢(需要遍历找到这行数据再标记);会出现死锁;锁定粒度最小,发生锁冲突的概率最低,可并发度最高。
InnoDB支持行级锁,MYSAM不支持。
注意
1. InnoDB的行锁实际上是针对索引加的锁(在索引对应的索引项上,底层C语言实现。有个索引项做标记),不是针对整个行记录加的锁。该索引不能失效,否则会从行锁升级为表锁。(RR级别会升级为表锁,RC级别不会升级为表锁,我猜RC最终会对查找的索引数据的索引项加锁,可能是聚集索引或者辅助索引的数据)。
例子
在RR级别事务下执行如下SQL语句,name不是索引字段,事务不提交。这时候则其它会话对该表任意一行记录做修改操作都会被阻塞住。这时候是表锁,通过命令查看所有表锁能看到该表加了锁(这边需要别的会话进行操作数据堵塞,才会触发看到表锁)。
#where条件里的name字段无索引
#对这个操作加写锁,查询也能加写锁
START TRANSACTION
set transaction_isolation='REPEATABLE-READ';
SELECT * FROM account WHERE `name`='lilei' for update;
# COMMIT
START TRANSACTION
set transaction_isolation='READ-COMMITTED';
UPDATE account SET balance=300 where `name`='lilei'
#COMMIT
在RR级别事务下。执行如下SQL,balance是索引字段,事务不提交。其它会话对该条数据更新是堵塞的,其它数据更新是不堵塞,这时候是行锁。
START TRANSACTION
set transaction_isolation='REPEATABLE-READ';
SELECT * FROM account WHERE balance = 400 for update;
#COMMIT
START TRANSACTION
set transaction_isolation='READ-COMMITTED';
UPDATE account SET balance=300 where id=1
UPDATE account SET balance=700 where id=2
#COMMIT
2. 在RR或者RC隔离级别下,需要通过给查询数据加写锁,解决脏写和幻读问题。在遍历扫索引记录时候(看情况可能最终扫描查询是主键索引的记录,还是辅助索引的记录),为了防止扫描过的索引数据,被其它事务修改(查询数据被修改,拿错误的数据做操作)或间隙被其它事务插入记录(幻读问题),从而导致数据不一致。所以MySQL的解决方案就是把所有扫描过的索引数据和间隙都锁上。
但是RR级别比较特别直接加表锁,可能并不是直接将整张表加表锁,因为不一定能加上表锁,可能会有其它事务锁住了表里的其它行记录。正常应该都是等待别事务行锁释放直接加表锁
2.3. 数据库操作的类型分
分为读锁和写锁(都属于悲观锁),还有意向锁。
读锁和写锁在不同数据操作粒度下,可以分为表锁,页锁和行锁。
读锁(共享锁,S锁(Shared))
针对同一份数据,多个读操作可以同时进行而不会互相影响。
SELECT * FROM account lock in share mode
写锁(排它锁,X锁(eXclusive))
当前写操作没有完成前(在事务下,事务没提交前),它会堵塞其它事务加了写锁和读锁的操作。MySQL数据修改操作都会自动加写锁,查询也可以通过for update加写锁。
SELECT * FROM account for update
意向锁(IntentionLock)
又称I锁,针对表锁,主要是为了提高加表锁的效率,是MySQL数据库自己加的。当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低,而这个标识就是意向锁。
意向锁主要分为:
意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁(说明可以对表加共享锁)。
意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁(说明可以对表加排他锁)。
间隙锁(GapLock)
间隙锁,锁的就是两个值之间的空隙,间隙锁是在可重复读(Repeatable Read)和串行化(Serializable)隔离级别下生效。用在查询数据范围内禁止其它事务增删改。
MySQL默认事务级别是repeatable-read,有幻读问题,间隙锁是可以解决幻读问题的。
假设account表里数据如下
那么间隙,就有 id 为 (3,10),(10,20),(20,正无穷) 这三个区间,不包括边界,间隙随着插入成功数据不断变更。
在一个会话中执行如下sql
select * from account where id = 18 for update;
则其它会话没法在这个(10,20)这个间隙范围里插入任何数据。
如果执行下面这条sql
select * from account where id = 25 for update;
则其它会话没法在这个(20,正无穷)这个间隙范围里插入任何数据。
可以理解为在叶节点的两个索引数据的间隙加了标志。
注意
1. 间隙锁情况下,在其它事务操作加锁间隙时候会堵塞,上升为表锁。
2. 如果间隙锁是字符串,那么就是按字符串逐位比对得到的间隙。
3. 间隙锁可以加读锁和写锁。
解决幻读问题
只要在间隙范围内锁一条不存在的记录,会锁住整个间隙范围,不锁边界记录,这样就能防止其它会话在这个间隙范围内插入数据,就解决了可重复读隔离级别的幻读问题。
临键锁(Next-keyLocks)
就是行锁与间隙锁的组合。
如下面的SQL语句,意思是锁住 id 3到10数据的间隙,同时锁住id=10的数据。
select * from account where id > 3 and id<=10 for update;
注意
1. 临键锁情况下,在其它事务操作加锁间隙和数据时候会堵塞,上升为表锁。
总结
1. MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁。没有事务相当于一条SQL语句就是一个事务。
2. InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁,加的是写锁。读锁会阻塞写,但是不会阻塞读。写锁则会把读和写都阻塞。
3. 锁等待分析
通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况。
show status like 'innodb_row_lock%';
#8.0开始时间单位是ms
# 对各个状态量的说明如下:
# 当前正在等待锁定的数量,事务要操作数据锁等待的总数量
Innodb_row_lock_current_waits
# 从系统启动到现在锁定总时间长度,所有锁的总时长
Innodb_row_lock_time
# 每次等待所花平均时间,每次堵塞获取锁等待时间
Innodb_row_lock_time_avg
# 从系统启动到现在等待最长的一次所花时间,堵塞获取锁最长的一次时间
Innodb_row_lock_time_max
# 系统启动后到现在总共等待的次数,堵塞获取锁的总次数
Innodb_row_lock_waits
#对于这5个状态变量,比较重要的主要是:
Innodb_row_lock_time_avg(等待平均时长)
Innodb_row_lock_waits(等待总次数)
Innodb_row_lock_time(等待总时长)
尤其是当等待次数很高,而且每次等待时长也不小的时候,就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。
查看INFORMATION_SCHEMA系统库锁相关数据表
#查看正在执行事务,停止的不算
select * from INFORMATION_SCHEMA.INNODB_TRX;
#查看加的锁包括还没加上的锁,8.0之后需要换成performance_schema.data_locks这张表
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
select * from performance_schema.data_locks;
#查看正在的锁等待(请求锁的事务id,当前加锁的事务id等),8.0之后需要换成 performance_schema.data_lock_waits这张表
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
select * from performance_schema.data_lock_waits;
#释放锁,根据正在运行事务查出其关联的线程id,借助kill指令释放
select concat('KILL ',id,';') from information_schema.processlist p inner
join information_schema.INNODB_TRX x on p.id=x.trx_mysql_thread_id
where db='指定数据库或者不加where查所有库的事务';
![[Pasted image 20230817142804.png]]
trx_mysql_thread_id可以从INNODB_TRX表里查看到,是事务的id
kill 56 就是杀死线程
#查看锁等待详细信息,可以看status的信息数据
show engine innodb status
注意
1. 分析锁等待可以用下面SQL语句分析
#查看正在执行事务,停止的不算
select * from INFORMATION_SCHEMA.INNODB_TRX;
#查看加的锁包括还没加上的锁,8.0之后需要换成这张表
performance_schema.data_locks
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
select * from performance_schema.data_locks;
#查看正在锁等待(请求锁的事务id,当前加锁的事务id等),8.0之后需要换成这张表
performance_schema.data_lock_waits
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;
select * from performance_schema.data_lock_waits;
如果锁等待很长,可以释放锁,并分析为啥等待这么久。
2. 分析死锁可以用
#查看锁等待详细信息,可以看status的信息数据
show engine innodb status;
4. 死锁问题分析
# 设置事务为可重复读
set tx_isolation='repeatable-read';
#Mysql 8.0设置事务为可重复读
set transaction_isolation='repeatable-read';
#会话1执行
select * from account where id=1 for update;
#会话2执行
select * from account where id=2 for update;
#会话1执行
select * from account where id=2 for update;
#会话2执行
select * from account where id=1 for update;
#查看近期死锁日志信息
show engine innodb status;
在死锁日志信息搜Deadlock。
大多数情况MySQL可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况MySQL没法自动检测死锁,这种情况可以通过日志分析找到对应事务线程id,可以通过kill杀掉。
5. 锁优化实践
1. 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁,因为MySQL默认事务是RR级别。
2. 合理设计索引,尽量缩小锁的范围。比如id 20-100不能插入数据,可以先插入id=100数据,然后查询id=30数据,加写锁进行间隙锁,锁id 20-100的数据。
3. 尽可能减少检索条件范围,避免间隙锁。
4. 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的SQL尽量放在事务最后执行尽可能用低的事务隔离级别。