MySQL系列—14.锁

发布于:2024-10-17 ⋅ 阅读:(43) ⋅ 点赞:(0)

目录

1、锁

读-读情况

写-写情况

读-写或写-读情况

2、锁的分类

2.1 读锁、写锁

2.2 表级锁

2.2.1 表级的S锁/X锁

2.2.2 意向锁

2.2.3 元数据锁(MDL锁)

2.3 行级锁

2.3.1 记录锁(Record Locks)

2.3.2 间隙锁

2.3.3 临键锁(Next-Key Locks)

​编辑

2.4 乐观锁、悲观锁

2.5 全局锁

2.6 死锁

概念

产生死锁的必要条件

如何处理死锁

如何避免死锁

3、 锁的监控

4、锁的内存结构


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 间隙锁

MySQLREPEATABLE 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. 两个或两个以上事务
  2. 每个事务都已经持有锁并且申请新的锁
  3. 锁资源同时只能被同一个事务持有或者不兼容
  4. 事务之间因为持有锁和申请锁导致彼此循环等待

如何处理死锁

方式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_modelock_typerec_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,也就是当前事务获取锁成功。
     


网站公告

今日签到

点亮在社区的每一天
去签到