目录
1、锁
锁
是计算机协调多个进程或线程并发访问某一资源
的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程
在访问,保证数据的完整性
和一致性
。
为保证数据的一致性,需要对并发操作进行控制
,因此产生了锁
。同时锁机制也为实现MySQL的各个隔离级别
提供了保证。
Mysql并发事务访问相同记录的情况大致可以划分为3种:
读-读情况
读-读
情况,即并发事务相继读取相同的记录
。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。
写-写情况
写-写
情况,即并发事务相继对相同的记录做出改动。在多个未提交事务相继对一条记录做改动时,需要让它们排队执行
,这个排队的过程其实是通过锁
来实现的。
读-写或写-读情况
读-写
或写-读
,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读
、不可重复读
、幻读
的问题。
怎么解决脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:
方案一:读操作利用多版本并发控制(MVCC
),写操作加锁
。
所谓的MVCC,就是生成一个ReadView,通过ReadView找到符合条件的记录版本(历史版本由undo日志构建)。查询语句只能读到在生成ReadView之前已提交事务所做的更改。
而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。
普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。
在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一
个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;
在REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会
生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读
和幻读的问题。
方案二:读、写操作都采用加锁
的方式。
采用加锁的方式的话,读-写
操作彼此需要排队执行
,影响性能。采用MVCC
方式的话,读-写
操作彼此并不会冲突,性能更高
。一般情况下我们当然更愿意采用MVCC
来解决读-写
操作解决并发执行的问题,但是业务在某些特殊情况下(例如某些银行业务),要求采用加锁
的方式执行。
2、锁的分类
2.1 读锁、写锁
读锁
:也称为共享锁
(Shared Lock,SLock)、英文用S
表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。
写锁
:也称为排他锁(
Exclusive Lock,XLock)
、英文用X
表示。当前写操作没有完成前,它会阻断
其他写锁和读锁。这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
写操作:
DELETE:先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark 操作(将delete mark由0改成1)。
UPDATE:则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。
INSERT:不加锁
对读取记录加S锁
:
SELECT ... LOCK IN SHARE MODE
# 或
SELECT ... FOR SHARE; # (mysql8.0新增语法)
对读取的记录加X锁
:
SELECT ... FOR UPDATE;
Mysql在RR和RC隔离级别下,相同记录的S锁和X锁会相互阻塞
例:
开启一个会话
此时,查询事务和锁的信息:
select * from information_schema.INNODB_TRX ;
会有一条事务的记录信息:
SELECT * FROM performance_schema.data_locks;
显示有S锁的信息:
再开启一个会话,加X锁:
select * from t for update;
如上,会卡住
select * from information_schema.INNODB_TRX ;
多了一条事务信息,状态为LOCK_WAIT
SELECT * FROM performance_schema.data_locks;
显示线程51加了S锁,线程53加了X锁
SELECT * FROM performance_schema.data_lock_waits;
出现一条记录,显示引起阻塞的线程是51
报ERROR 1205 (HY000): Lock wait timeout exceeded,发生了锁等待超时
mysql> show variables like 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
2.2 表级锁
2.2.1 表级的S锁/X锁
MyISAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。
LOCK TABLES t READ
: InnoDB存储引擎会对表t
加表级别的S锁
。
LOCK TABLES t WRITE
: InnoDB存储引擎会对表t
加表级别的X锁
。
2.2.2 意向锁
InnoDB 支持多粒度锁(multiple granularity locking)
,它允许行级锁
与 表级锁
共存,而意向锁就是其中的一种表锁
。
意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
SELECT column FROM table ... FOR UPDATE;
lX,IS是表级锁
,不会和行级
的X,S锁发生冲突。只会和表级的X,S发生冲突。
意向锁是由存储引擎自己维护的
,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB会先获取该数据行所在数据表的对应意向锁
。
2.2.3 元数据锁(MDL锁)
meta data lock,简称MDL锁,属于表锁范畴。MDL的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更
,增加了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加 MDL写锁。
2.3 行级锁
2.3.1 记录锁(Record Locks)
行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录row)。
InnoDB与MylSAM的最大不同有两点:一是支持事务(TRANSACTION)﹔二是采用了行级锁。
2.3.2 间隙锁
MySQL
在REPEATABLE READ
隔离级别下是可以解决幻读问题
的,解决方案有两种,可以使用MVCC方案
解决,也可以采用加锁方案
解决。
但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们可以简称为gap锁。比如,把id值为8的那条记录加一个gap锁的示意图如下。
图中id值为8的记录加了gap锁,意味着不允许别的事务在id值为8的记录前边的间隙插入新记录,其实就是id列的值(3, 8)这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为4的新
记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入
操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(3, 8)中的新记录才可以被插入。
gap锁的提出仅仅是为了防止插入幻影记录而提出的。
2.3.3 临键锁(Next-Key Locks)
next-key锁
的本质就是一个记录锁
和一个gap锁
的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙
。
begin;
select * from student where id <=8 and id > 3 for update;
2.4 乐观锁、悲观锁
从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待数据并发的思维方式
。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想
。
悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。
案例1:
商品秒杀过程中,库存数量的减少,避免出现超卖
的情况。比如,商品表中有一个字段为quantity表示当前该商品的库存量。假设商品为华为mate40,id为1001,quantity=100个。如果不使用锁的情况下,操作方法如下所示
#第1步:查出商品库存
select quantity from items where id = 1001;
#第2步:如果库存大于日,则根据商品信息生产订单
insert into orders (item_id)values ( 1001 ) ;
#第3步:修改商品的库存,num表示购买数量
update items set quantity = quantity-num where id = 1001 ;
这样写的话,在并发量小的公司没有大的问题I但是如果在高并发环境
下可能出现以下问题
其中线程B此时已经下单并且减完库存,这个时候线程A依然去执行step3,就造成了超卖。
我们使用悲观锁可以解决这个问题,商品信息从查询出来到修改,中间有一个生成订单的过程,使用悲观锁的原理就是,当我们在查询items信息后就把当前的数据锁定,直到我们修改完毕后再解锁。那么整个过程中,因为数据被锁定了,就不会出现有第三者来对其进行修改了。而这样做的前提是需要将要执行的SQL语句放在同一个事务中,否则达不到锁定数据行的目的。
修改如下:
#第1步:查出商品库存
select quantity from items where id = 1001 for update; # 加上X锁
#第2步:如果库存大于日,则根据商品信息生产订单
insert into orders (item_id) values( 1001)
;#第3步:修改商品的库存,num表示购买数量
update items set quantity = quantity-num where id = 1001;
注意: select ... for update
语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制
或者CAS机制
实现。乐观锁适用于多读的应用类型。
乐观锁的版本号机制
在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时
如果已经有事务对这条数据进行了更改,修改就不会成功。
2.5 全局锁
全局锁就是对整个数据库实例加锁
。当你需要让整个库处于只读状态
的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景
是:做全库逻辑备份
。
FLUSH TABLE WITH READ LOCK;
2.6 死锁
概念
两个事务都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁。
举例1:
解释一下下面的过程,事务1开始开始事务,更新id为1的记录获得id=1的记录的排他锁,事务2再开始事务,更新id=2获得id=2的记录的排他锁,再接着事务1去获取id为2的记录的排他锁,等待。事务2去获取id为1记录的排他锁,也是等待。
产生死锁的必要条件
- 两个或两个以上事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被同一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁导致彼此循环等待
如何处理死锁
方式1:等待超时
在innodb中,参数innodb_lock_wait_timeout
用来设置超时时间
。
mysql> show variables like 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
1 row in set (0.00 sec)
方式2:死锁检测进行死锁处理
innodb还提供了wait-for graph算法
来主动进行死锁检测
一旦检测到回路、有死锁,这时候InnoDB存储引擎会选择回滚undo量最小的事务
,让其他事务继续执行( innodb_deadlock_detect=on
表示开启这个逻辑)。
mysql> show variables like 'innodb_deadlock_detect';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_deadlock_detect | ON |
+------------------------+-------+
1 row in set (0.00 sec)
如何避免死锁
1、合理设计索引,使业务SQL尽可能通过索引定位更少的行,减少锁竞争。
2、调整业务逻辑SQL执行顺序,避免update/delete长时间持有锁的SQL在事务前面。
3、避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
4、在并发比较高的系统中,不要显式加锁,特别是在事务里显式加锁。如select ... for update
语句,如果是在事务里运行了start transaction
或设置了autocommit
等于0,那么就会锁定所查找到的记录。
5、降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。
3、 锁的监控
show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name | Value |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0 | # 表示当前正在等待的锁定的数量
| Innodb_row_lock_time | 317735 | # 从机器启动到现在锁定的总时长
| Innodb_row_lock_time_avg | 16722 | # 从机器启动到现在锁定的平均花费时长
| Innodb_row_lock_time_max | 50133 | # 锁等待最多花费时长
| Innodb_row_lock_waits | 19 | # 锁等待的次数
+-------------------------------+--------+
5 rows in set (0.00 sec)
Innodb_row_lock_current_waits:当前正在等待锁定的数量;
Innodb_row_lock_time :从系统启动到现在锁定总时间长度;(等待总时长)
Innodb_row_lock_time_avg :每次等待所花平均时间;(等待平均时长)
Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
Innodb_row_lock_waits :系统启动后到现在总共等待的次数;(等待总次数)
MySQL5.7及之前:
事务信息:information_schema.INNODB_TRX
锁信息:information_schema.INNODB_LOCKS
阻塞信息:information_schema.INNODB_LOCK_WAITS
MySQL8.0以后:
事务信息:information_schema.INNODB_TRX
锁信息:performance_schema.data_locks
阻塞信息:performance_schema. data_lock_waits
4、锁的内存结构
符合下边这些条件的记录会放到一个锁结构
中:
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
结构解析:
锁所在的事务信息:
不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个表结构,这里就记录这个事务的信息。
此锁所在的事务信息在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。
索引信息:
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。
表锁 / 行锁信息:
表锁结构和行锁结构在这个位置的内容是不同的:
表锁:
记载的是对哪个表加的锁,还有其他的一些信息。
行锁
记录三个重要的信息
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锁。在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。
锁的类型(lock_type ),占用第5~8位,不过现阶段只有第5位和第6位被使用:
LOCK_TABLE (十进制的16),也就是当第5个比特位置为1时,表示表级锁。
LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁。
LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁。
LOCK_INSERT_INTENTION (十进制的2848 )︰也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。is_waiting属性呢?基于内存空间的节省,所以把is_waiting属性放到了type_mode这个32位的数字中:
LOCK_WAIT (十进制的256))︰当第9个比特位置为1时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waiting为false,也就是当前事务获取锁成功。