一文带你了解MySQL之锁
目录
一、解决并发事务带来问题的两种基本方式
上一篇文章主要学习了事务并发执行时可能带来的各种问题,并发事务访问相同记录的情况我们大致可以划分为3种:
-
读-读
情况:即并发事务相继读取相同的记录,我们需要知道的是读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。 -
写-写
情况:即并发事务相继对相同的记录做出改动,我们前边说过,在这种情况下会发生脏写
的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行
,这个排队的过程其实是通过锁
来实现的。这个所谓的锁
其实是一个内存中的结构
,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的,如图所示:
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构
,当没有的时候就会在内存中生成一个锁结构
与之关联。比方说事务T1要对这条记录做改动,就需要生成一个锁结构
与之关联:其实在
锁结构
里有很多信息,我们现在只把两个比较重要的属性拿了出来:-
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
,也就是事务需要等待,不可以继续执行操作。
-
-
读-写
或写-读
情况:也就是一个事务进行读取操作,另一个进行改动操作。我们前边说过,这种情况下可能发生脏读
、不可重复读
、幻读
的问题小提示:
幻读问题的产生是因为某个事务读了一个范围的记录,之后别的事务在该范围内插入了新记录,该事务再次读取该范围的记录时,可以读到新插入的记录,所以幻读问题准确的说并不是因为读取和写入一条相同记录而产生的,这一点要注意一下在上一篇文章中,我们也知道
SQL标准
规定不同隔离级别下可能发生的问题也不一样:-
在
READ UNCOMMITTED
隔离级别下,脏读
、不可重复读
、幻读
都可能发生 -
在
READ COMMITTED
隔离级别下,不可重复读
、幻读可
能发生,脏读
不可以发生 -
在
REPEATABLE READ
隔离级别下,幻读
可能发生,脏读
和不可重复读
不可以发生 -
在
SERIALIZABLE
隔离级别下,上述问题都不可以发生
不过各个数据库厂商对
SQL标准
的支持都可能不一样,与SQL标准不同的一点就是,MySQL在REPEATABLE READ隔离级别实际上就已经解决了幻读问题
怎么解决
脏读
、不可重复读
、幻读
这些问题呢?其实有两种可选的解决方案:-
方案一:
读操作利用多版本并发控制(MVCC),写操作进行加锁所谓的
MVCC
我们在前一篇文章有过详细的描述,就是通过生成一个ReadView
,然后通过ReadView
找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView
的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView
之前已提交事务所做的更改,在生成ReadView
之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC
时,读-写
操作并不冲突。小提示:
我们说过普通的SELECT语句在READ COMMITTED和REPEATABLE READ隔离级别下会使用到MVCC读取记录。在READ COMMITTED隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的SELECT操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题 -
方案二:
读、写操作都采用加锁的方式如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,比方在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候也就需要对其进行
加锁
操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行
小提示:
我们说脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。我们说幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录,我们把新插入的那些记录称之为幻影记录。采用加锁的方式解决幻读问题就有那么一丢丢麻烦了,因为当前事务在第一次读取记录时那些幻影记录并不存在,所以读取的时候加锁就有点尴尬 —— 因为你并不知道给谁加锁,没关系,这难不倒InnoDB,我们稍后揭晓答案,稍安勿躁。
-
很明显,采用MVCC
方式的话,读-写
操作彼此并不冲突,性能更高,采用加锁
方式的话,读-写
操作彼此需要排队执行,影响性能。一般情况下我们当然愿意采用MVCC
来解决读-写
操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行,那也是没有办法的事。
1.1 一致性读(Consistent Reads)
事务利用MVCC
进行的读取
操作称之为一致性读
,或者一致性无锁读
,有的地方也称之为快照读
。所有普通的SELECT
语句(plain SELECT
)在READ COMMITTED
、REPEATABLE READ
隔离级别下都算是一致性读,比如:
SELECT * FROM t;SELECT * FROM t1 INNER JOIN t2 ON t1.col1 = t2.col2
我们需要知道的是,一致性读
并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。
1.2 锁定读(Locking Reads)
1.2.1 共享锁和独占锁
我们前边说过,并发事务的读-读
情况并不会引起什么问题,不过对于写-写
、读-写
或写-读
这些情况可能会引起一些问题,需要使用MVCC
或者加锁
的方式来解决它们。在使用加锁
的方式解决问题时,由于既要允许读-读
情况不受影响,又要使写-写
、读-写
或写-读
情况中的操作相互阻塞,所以MySQL给锁分了个类:
-
共享锁
,英文名:Shared Locks
,简称S锁
。在事务要读取一条记录时,需要先获取该记录的S锁
。 -
独占锁
,也常称排他锁
,英文名:Exclusive Locks
,简称X锁
。在事务要改动一条记录时,需要先获取该记录的X锁
。
假如事务T1
首先获取了一条记录的S锁
之后,事务T2
接着也要访问这条记录:
-
如果事务
T2
想要再获取一个记录的S锁
,那么事务T2
也会获得该锁,也就意味着事务T1
和T2
在该记录上同时持有S锁
。 -
如果事务
T2
想要再获取一个记录的X锁
,那么此操作会被阻塞,直到事务T1
提交之后将S锁
释放掉。
如果事务T1
首先获取了一条记录的X锁
之后,那么不管事务T2
接着想获取该记录的S锁
还是X锁
都会被阻塞,直到事务T1
提交。
所以我们说S锁
和S锁
是兼容的,S锁
和X锁
是不兼容的,X锁
和X锁
也是不兼容的,画个表表示一下就是这样:
兼容性 | X | S |
---|---|---|
X | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 |
1.2.2 锁定读的语句
我们前边说在采用加锁
方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取一下该记录的S锁
,其实这是不严谨的,有时候想在读取记录时就获取记录的X锁
,来禁止别的事务读写该记录,为此MySQL的提出了两种比较特殊的SELECT
语句格式:
对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
也就是在普通的SELECT
语句后边加LOCK IN SHARE MODE
,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁
(比方说别的事务也使用SELECT ... LOCK IN SHARE MODE
语句来读取这些记录),但是不能获取这些记录的X锁
(比方说使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁
释放掉
对读取的记录加X锁:
SELECT ... FOR UPDATE;
也就是在普通的SELECT
语句后边加FOR UPDATE
,如果当前事务执行了该语句,那么它会为读取到的记录加X锁
,这样既不允许别的事务获取这些记录的S锁
(比方说别的事务使用SELECT ... LOCK IN SHARE MODE
语句来读取这些记录),也不允许获取这些记录的X锁
(比如说使用SELECT ... FOR UPDATE
语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁
或者X锁
,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁
释放掉
关于更多锁定读
的加锁细节
我们稍后会详细讲解,稍安勿躁
1.3 写操作
我们平常所用到的写操作无非是DELETE
、UPDATE
、INSERT
这三种
-
DELETE:
对一条记录做DELETE
操作的过程其实是先在B+树
中定位到这条记录的位置,然后获取一下这条记录的X锁
,然后再执行delete mark
操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁
的锁定读
。 -
UPDATE:
在对一条记录做UPDATE操作时分为三种情况:-
如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在
B+
树中定位到这条记录的位置,然后再获取一下记录的X锁
,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
。 -
如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在
B+
树中定位到这条记录的位置,然后获取一下记录的X锁
,将该记录彻底删除掉
(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+
树中位置的过程看成是一个获取X锁
的锁定读
,新插入的记录由INSERT
操作提供的隐式锁
进行保护。 -
如果修改了该记录的键值,则相当于在原记录上做
DELETE
操作之后再来一次INSERT
操作,加锁操作就需要按照DELETE
和INSERT
的规则进行了。
-
-
INSERT:
一般情况下,新插入一条记录的操作并不加锁,InnoDB
通过一种称之为隐式锁
来保护这条新插入的记录在本事务提交前不被别的事务访问,更多细节我们后边看哈~小提示:
当然,在一些特殊情况下INSERT操作也是会获取锁的,具体情况我们后边学习
二、多粒度锁
我们前边提到的锁
都是针对记录的,也可以被称之为行级锁
或者行锁
,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别
进行加锁,自然就被称之为表级锁
或者表锁
,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁
(S锁
)和独占锁
(X锁
)
-
给表加
S锁
:如果一个事务给表加了S锁,那么:-
别的事务可以继续获得该表的S锁
-
别的事务可以继续获得该表中的某些记录的S锁
-
别的事务不可以继续获得该表的X锁
-
别的事务不可以继续获得该表中的某些记录的X锁
-
-
给表加
X锁
:如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:-
别的事务不可以继续获得该表的S锁
-
别的事务不可以继续获得该表中的某些记录的S锁
-
别的事务不可以继续获得该表的X锁
-
别的事务不可以继续获得该表中的某些记录的X锁
-
上边看着有点啰嗦,为了更好的理解这个表级别的S锁
和X锁
,我们举一个现实生活中的例子。不知道各位同学都上过大学没,我们以大学教学楼中的教室为例来分析一下加锁的情况:
-
教室一般都是公用的,我们可以随便选教室进去上自习。当然,教室不是自家的,一间教室可以容纳很多同学同时上自习,每当一个人进去上自习,就相当于在教室门口挂了一把
S锁
,如果很多同学都进去上自习,相当于教室门口挂了很多把S锁(类似行级别的S锁
)。 -
有的时候教室会进行检修,比方说换地板,换天花板,换灯管啥的,这些维修项目并不能同时开展。如果教室针对某个项目进行检修,就不允许别的同学来上自习,也不允许其他维修项目进行,此时相当于教室门口会挂一把
X锁
(类似行级别的X锁
)。
上边提到的这两种锁都是针对教室而言的,不过有时候我们会有一些特殊的需求:
-
有领导要来参观教学楼的环境。
校领导考虑并不想影响同学们上自习,但是此时不能有教室处于维修状态,所以可以在教学楼门口放置一把
S锁
(类似表级别的S锁
)。此时:-
来上自习的学生们看到教学楼门口有
S锁
,可以继续进入教学楼上自习。 -
修理工看到教学楼门口有
S锁
,则先在教学楼门口等着,啥时候领导走了,把教学楼的S锁
撤掉再进入教学楼维修。
-
-
学校要占用教学楼进行考试。
此时不允许教学楼中有正在上自习的教室,也不允许对教室进行维修。所以可以在教学楼门口放置一把
X锁
(类似表级别的X锁
)。此时:-
来上自习的学生们看到教学楼门口有
X锁
,则需要在教学楼门口等着,啥时候考试结束,把教学楼的X锁
撤掉再进入教学楼上自习。 -
修理工看到教学楼门口有
X锁
,则先在教学楼门口等着,啥时候考试结束,把教学楼的X锁
撤掉再进入教学楼维修。
-
但是这里头有两个问题:
-
如果我们想对教学楼整体上
S锁
,首先需要确保教学楼中的没有正在维修的教室,如果有正在维修的教室,需要等到维修结束才可以对教学楼整体上S锁
。 -
如果我们想对教学楼整体上
X锁
,首先需要确保教学楼中的没有上自习的教室以及正在维修的教室,如果有上自习的教室或者正在维修的教室,需要等到全部上自习的同学都上完自习离开,以及维修工维修完教室离开后才可以对教学楼整体上X锁
。
我们在对教学楼整体上锁(表锁
)时,怎么知道教学楼中有没有教室已经被上锁(行锁
)了呢?依次检查每一间教室门口有没有上锁?那这效率也太慢了吧!遍历是不可能遍历的,这辈子也不可能遍历的,于是InnoDB
的提出了一种称之为意向锁
(英文名:Intention Locks
)的东东:
-
意向共享锁
,英文名:Intention Shared Lock
,简称IS锁
。当事务准备在某条记录上加S锁
时,需要先在表级别加一个IS锁
。 -
意向独占锁
,英文名:Intention Exclusive Lock
,简称IX锁
。当事务准备在某条记录上加X锁
时,需要先在表级别加一个IX锁
。
视角回到教学楼和教室上来:
-
如果有学生到教室中上自习,那么他先在整栋教学楼门口放一把
IS锁
(表级锁
),然后再到教室门口放一把S
锁(行锁
)。 -
如果有维修工到教室中维修,那么它先在整栋教学楼门口放一把
IX锁
(表级锁
),然后再到教室门口放一把X锁
(行锁
)。
之后:
-
如果有领导要参观教学楼,也就是想在教学楼门口前放
S锁
(表锁
)时,首先要看一下教学楼门口有没有IX锁
,如果有,意味着有教室在维修,需要等到维修结束把IX锁
撤掉后才可以在整栋教学楼上加S锁
。 -
如果有考试要占用教学楼,也就是想在教学楼门口前放
X锁
(表锁
)时,首先要看一下教学楼门口有没有IS锁
或IX锁
,如果有,意味着有教室在上自习或者维修,需要等到学生们上完自习以及维修结束把IS锁
和IX锁
撤掉后才可以在整栋教学楼上加X
锁。
小提示:
学生在教学楼门口加IS锁时,是不关心教学楼门口是否有IX锁的,维修工在教学楼门口加IX锁时,是不关心教学楼门口是否有IS锁或者其他IX锁的。IS和IX锁只是为了判断当前时间教学楼里有没有被占用的教室用的,也就是在对教学楼加S锁或者X锁时才会用到。
总结一下:IS
、IX锁
是表级锁
,它们的提出仅仅为了在之后加表级别的S锁
和X锁
时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁
和IX锁
是兼容的,IX锁
和IX
锁是兼容的。我们画个表来看一下表级别的各种锁的兼容性:
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
三、MySQL中的行锁和表锁
上边说的都算是些理论知识,其实MySQL
支持多种存储引擎,不同存储引擎对锁的支持也是不一样的。当然,我们重点还是讨论InnoDB
存储引擎中的锁,其他的存储引擎只是稍微提一下~
3.1 其他存储引擎中的锁
对于MyISAM
、MEMORY
、MERGE
这些存储引擎来说,它们只支持表级锁
,而且这些引擎并不支持事务
,所以使用这些存储引擎的锁一般都是针对当前会话来说的。比方说在Session 1
中对一个表执行SELECT
操作,就相当于为这个表加了一个表级别的S锁
,如果在SELECT
操作未完成时,Session 2
中对这个表执行UPDATE
操作,相当于要获取表的X锁
,此操作会被阻塞,直到Session 1
中的SELECT
操作完成,释放掉表级别的S锁
后,Session 2
中对这个表执行UPDATE
操作才能继续获取X锁
,然后执行具体的更新语句
3.2 InnoDB存储引擎中的锁
InnoDB
存储引擎既支持表锁
,也支持行锁
。表锁
实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁
粒度更细,可以实现更精准的并发控制。下边我们详细看一下
3.2.1 InnoDB中的表级锁
表级别的S锁、X锁
在对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,InnoDB
存储引擎是不会为这个表添加表级别
的S锁
或者X锁
的。
另外,在对某个表执行一些诸如ALTER TABLE
、DROP TABLE
这类的DDL
语句时,其他事务对这个表并发执行诸如SELECT
、INSERT
、DELETE
、UPDATE
的语句会发生阻塞,同理,某个事务中对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,在其他会话中对这个表执行DDL
语句也会发生阻塞。这个过程其实是通过在server
层使用一种称之为元数据锁
(英文名:Metadata Locks
,简称MDL
)来实现的,一般情况下也不会使用InnoDB
存储引擎自己提供的表级别的S锁
和X锁
。
小提示:
在事务简介的文章中我们说过,DDL语句执行时会隐式的提交当前会话中的事务,这主要是DDL语句的执行一般都会在若干个特殊事务中完成,在开启这些特殊事务前,需要将当前会话中的事务提交掉。
其实这个InnoDB
存储引擎提供的表级S锁
或者X锁
是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1
时,手动获取InnoDB
存储引擎提供的表t的S锁
或者X锁
可以这么写:
-
LOCK TABLES t READ:
InnoDB存储引擎会对表t
加表级别的S锁
。 -
LOCK TABLES t WRITE
:InnoDB存储引擎会对表t
加表级别的X锁
不过我们尽量避免在使用InnoDB
存储引擎的表上使用LOCK TABLES
这样的手动锁表语句,它们并不会提供什么额外的保护,只是会降低并发能力而已。InnoDB
的厉害之处还是实现了更细粒度的行锁,关于表级别的S锁
和X锁
大家了解一下就罢了。
表级别的IS锁、IX锁
当我们在对使用InnoDB
存储引擎的表的某些记录加S锁
之前,那就需要先在表级别加一个IS锁
,当我们在对使用InnoDB
存储引擎的表的某些记录加X锁
之前,那就需要先在表级别加一个IX锁
。IS锁
和IX锁
的使命只是为了后续在加表级别的S锁
和X锁
时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。更多关于IS锁
和IX锁
的解释我们上边已经讲解了,就不赘述了。
表级别的AUTO-INC锁
在使用MySQL
过程中,我们可以为表的某个列添加AUTO_INCREMENT
属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值,比方说我们有一个表:
CREATE TABLE t ( id INT NOT NULL AUTO_INCREMENT, c VARCHAR(100), PRIMARY KEY (id));
由于这个表的id字段声明了AUTO_INCREMENT
,也就意味着在书写插入语句时不需要为其赋值,比方说这样:
INSERT INTO t(c) VALUES('aa'), ('bb');
上边的插入语句并没有为id
列显式赋值,所以系统会自动为它赋上递增的值,效果就是这样:
mysql> SELECT * FROM t;+----+------+| id | c |+----+------+| 1 | aa || 2 | bb |+----+------+2 rows in set (0.00 sec)
系统实现这种自动给AUTO_INCREMENT
修饰的列递增赋值的原理主要是两个:
-
采用
AUTO-INC
锁,也就是在执行插入语句时就在表级别加一个AUTO-INC
锁,然后为每条待插入记录的AUTO_INCREMENT
修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC
锁释放掉。这样一个事务在持有AUTO-INC
锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。如果我们的插入语句在执行前不可以确定具体要插入多少条记录(无法预计即将插入记录的数量),比方说使用
INSERT ... SELECT、REPLACE ... SELECT
或者LOAD DATA
这种插入语句,一般是使用AUTO-INC
锁为AUTO_INCREMENT
修饰的列生成对应的值。小提示:
需要注意一下的是,这个AUTO-INC锁的作用范围只是单个插入语句,插入语句执行完成后,这个锁就被释放了,跟我们之前介绍的锁在事务结束时释放是不一样的 -
采用一个轻量级的锁,在为插入语句生成
AUTO_INCREMENT
修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT
列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。如果我们的插入语句在执行前就可以确定具体要插入多少条记录,比方说我们上边举的关于表t的例子中,在语句执行前就可以确定要插入2条记录,那么一般采用轻量级锁的方式对
AUTO_INCREMENT
修饰的列进行赋值。这种方式可以避免锁定表,可以提升插入性能。小提示:
InnoDB提供了一个称之为innodb_autoinc_lock_mode
的系统变量来控制到底使用上述两种方式中的哪种来为AUTO_INCREMENT
修饰的列进行赋值
当innodb_autoinc_lock_mode
值为0
时,一律采用AUTO-INC锁
;
当innodb_autoinc_lock_mode
值为2
时,一律采用轻量级锁
;
当innodb_autoinc_lock_mode
值为1
时,两种方式混着来
(也就是在插入记录数量确定时采用轻量级锁,不确定时使用AUTO-INC锁)。
不过当innodb_autoinc_lock_mode
值为2
时,可能会造成不同事务中的插入语句为AUTO_INCREMENT
修饰的列生成的值是交叉的,在有主从复制的场景中是不安全的。
3.2.2 InnoDB中的行级锁
很遗憾的通知大家一个不好的消息,上边讲的都是铺垫,本章真正的重点才刚刚开始
行锁
,也称为记录锁
,顾名思义就是在记录上加的锁。不过InnoDB
把一个行锁玩出了各种花样,也就是把行锁分成了各种类型。换句话说即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。为了学习的顺利发展,我们还是先将之前学习的MVCC
时用到的表抄一遍:
mysql> CREATE TABLE hero(number INT PRIMARY KEY,name VARCHAR(4),country VARCHAR(2));Query OK, 0 rows affected (0.03 sec)
我们主要是想用这个表存储王者的英雄,然后向这个表里插入几条记录:
mysql> INSERT INTO hero VALUES (1, 'l刘备', '蜀国'), (3, 'z诸葛亮', '蜀国'), (8, 'c曹操', '蜀国'), (15, 'x项羽', '西楚'), (20, 's孙权', '吴国');Query OK, 5 rows affected (0.01 sec)Records: 5 Duplicates: 0 Warnings: 0
小提示:
为啥要在’刘备’、‘曹操’、‘孙权’前边加上’l’、‘c’、‘s’这几个字母呀?这个主要是因为我们采用utf8mb4字符集,该字符集并没有对应的按照汉语拼音进行排序的比较规则,也就是说’刘备’、‘曹操’、'孙权’这几个字符串的排序并不是按照它们汉语拼音进行排序的,所以在汉字前边加上了汉字对应的拼音的第一个字母,这样在排序时就是按照汉语拼音进行排序。
另外,我们故意把各条记录number
列的值搞得很分散,后边会用到,稍安勿躁哈~我们把hero
表中的聚簇索引的示意图画一下:
当然,我们把B+树
的索引结构做了一个超级简化,只把索引中的记录给拿了出来,我们这里只是想强调聚簇索引中的记录是按照主键大小排序的,并且省略掉了聚簇索引中的隐藏列,大家心里明白就好(不理解索引结构的同学可以去前边的文章中查看)。
现在准备工作做完了,下边我们来看看都有哪些常用的行锁类型。
-
Record Locks:
我们前边提到的记录锁
就是这种类型,也就是仅仅把一条记录锁上,我决定给这种类型的锁起一个比较不正经的名字:正经记录锁
(请允许我皮一下,我实在不知道该叫个啥名好)。官方的类型名称为:LOCK_REC_NOT_GAP
。比方说我们把number
值为8
的那条记录加一个正经记录锁的示意图如下:
正经记录锁是有S锁
和X锁
之分的,让我们分别称之为S型正经记录锁
和X型正经记录锁
吧,当一个事务获取了一条记录的S型正经记录锁
后,其他事务也可以继续获取该记录的S型正经记录锁
,但不可以继续获取X型正经记录
锁;当一个事务获取了一条记录的X型正经记录锁
后,其他事务既不可以继续获取该记录的S型正经记录锁
,也不可以继续获取X型正经记录锁
; -
Gap Locks:
我们说MySQL
在REPEATABLE READ
隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC
方案解决,也可以采用加锁
方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上正经记录锁。不过这难不倒InnoDB
,他们提出了一种称之为Gap Locks
的锁,官方的类型名称为:LOCK_GAP
,我们也可以简称为gap锁
。比方说我们把number
值为8
的那条记录加一个gap
锁的示意图如下:
如图中为number
值为8
的记录加了gap锁
,意味着不允许别的事务在number
值为8
的记录前边的间隙插入新记录,其实就是number
列的值(3, 8)
这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number
值为4
的新记录,它定位到该条新记录的下一条记录的number
值为8
,而这条记录上又有一个gap锁
,所以就会阻塞插入操作,直到拥有这个gap锁
的事务提交了之后,number
列的值在区间(3, 8)
中的新记录才可以被插入。这个
gap锁
的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁
和独占gap锁
这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了gap锁
(不论是共享gap锁
还是独占gap锁
),并不会限制其他事务对这条记录加正经记录锁
或者继续加gap锁
不知道大家发现了一个问题没,给一条记录加了
gap锁
只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero
表中number
值为20
的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁
才能阻止其他事务插入number
值在(20, +∞)
这个区间的新记录呢?这时候应该想起我们在前边唠叨数据页时介绍的两条伪记录了-
Infimum
记录,表示该页面中最小的记录。 -
Supremum
记录,表示该页面中最大的记录。
为了实现阻止其他事务插入
number
值在(20, +∞)
这个区间的新记录,我们可以给索引中的最后一条记录,也就是number
值为20
的那条记录所在页面的Supremum
记录加上一个gap锁
,画个图就是这样:
这样就可以阻止其他事务插入number值在(20, +∞)
这个区间的新记录。为了大家理解方便,之后的索引示意图中都会把这个Supremum
记录画出来 -
-
Next-Key Locks:
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB
就提出了一种称之为Next-Key Locks
的锁,官方的类型名称为:LOCK_ORDINARY
,我们也可以简称为next-key
锁。比方说我们把number
值为8
的那条记录加一个next-key
锁的示意图如下:
next-key锁
的本质就是一个正经记录锁
和一个gap锁
的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。 -
Insert Intention Locks:
我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁
(next-key锁
也包含gap锁,后边就不强调了),如果有的话,插入操作需要等待,直到拥有gap锁
的那个事务提交。但是InnoDB
规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB
把这种类型的锁命名为Insert Intention Locks
,官方的类型名称为:LOCK_INSERT_INTENTION
,我们也可以称为插入意向锁
。比方说我们把
number
值为8
的那条记录加一个插入意向锁的示意图如下:
为了让大家彻底理解这个插入意向锁
的功能,我们还是举个例子然后画个图表示一下。比方说现在T1
为number
值为8
的记录加了一个gap锁
,然后T2
和T3
分别想向hero
表中插入number
值分别为4
、5
的两条记录,所以现在为number
值为8
的记录加的锁的示意图就如下所示:小提示:
我们在锁结构中又新添了一个type属性,表明该锁的类型。稍后会全面介绍InnoDB存储引擎中的一个锁结构到底长什么样从图中可以看到,由于
T1
持有gap锁
,所以T2
和T3
需要生成一个插入意向锁
的锁结构
并且处于等待状态
。当T1
提交后会把它获取到的锁都释放掉,这样T2
和T3
就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false
),T2
和T3
之间也并不会相互阻塞,它们可以同时获取到number
值为8
的插入意向锁
,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁
(插入意向锁
就是这么鸡肋)。 -
隐式锁:
我们前边说一个事务在执行INSERT
操作时,如果即将插入的间隙已经被其他事务加了gap锁
,那么本次INSERT
操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁
,否则一般情况下INSERT
操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:-
立即使用
SELECT ... LOCK IN SHARE MODE
语句读取这条记录,也就是在要获取这条记录的S锁
,或者使用SELECT ... FOR UPDATE
语句读取这条记录,也就是要获取这条记录的X锁
,该咋办?如果允许这种情况的发生,那么可能产生脏读问题。
-
立即修改这条记录,也就是要获取这条记录的
X锁
,该咋办?如果允许这种情况的发生,那么可能产生脏写问题。
这时候我们前边唠叨了很多遍的
事务id
又要起作用了。我们把聚簇索引
和二级索引
中的记录分开看一下:-
情景一:
对于聚簇索引记录来说,有一个trx_id
隐藏列,该隐藏列记录着最后改动该记录的事务id
。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id
隐藏列代表的的就是当前事务的事务id
,如果其他事务此时想对该记录添加S锁
或者X锁
时,首先会看一下该记录的trx_id
隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁
(也就是为当前事务创建一个锁结构,is_waiting属性是false
),然后自己进入等待状态(也就是为自己也创建一个锁结构
,is_waiting属性是true
)。 -
情景二:
对于二级索引记录来说,本身并没有trx_id
隐藏列,但是在二级索引页面的Page Header
部分有一个PAGE_MAX_TRX_ID
属性,该属性代表对该页面做改动的最大的事务id
,如果PAGE_MAX_TRX_ID
属性值小于当前最小的活跃事务id
,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一
的做法。
通过上边的叙述我们知道,一个事务对新插入的记录可以
不显式的加锁(生成一个锁结构)
,但是由于事务id
的存在,相当于加了一个隐式锁
。别的事务在对这条记录加S锁
或者X锁
时,由于隐式锁
的存在,会先帮助当前事务生成一个锁结构
,然后自己再生成一个锁结构
后进入等待状态小提示:
除了插入意向锁,在一些特殊情况下INSERT还会获取一些锁,我们稍后学习~ -
四、InnoDB锁的内存结构
我们前边说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比方说事务T1要执行下边这个语句:
# 事务T1SELECT * FROM hero LOCK IN SHARE MODE;
很显然这条语句需要为hero
表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构
呢?其实理论上创建多个锁结构没问题,反而更容易理解,但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧!InnoDB
的决定在对不同记录加锁时,如果符合下边这些条件:
-
在同一个事务中进行加锁操作
-
被加锁的记录在同一个页面中
-
加锁的类型是一样的
-
等待状态是一样的
那么这些记录的锁就可以被放到一个锁结构中。当然,这么空口白牙的说有点儿抽象,我们还是画个图来看看InnoDB
存储引擎中的锁结构
具体长啥样吧:
我们看看这个结构里边的各种信息都是干嘛的:
-
锁所在的事务信息:
不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。小提示:
实际上这个所谓的锁所在的事务信息在内存结构中只是一个指针而已,所以不会占用多大内存空间,通过指针可以找到内存中关于该事务的更多信息,比方说事务id是什么。下边介绍的所谓的索引信息其实也是一个指针 -
索引信息:
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。 -
表锁/行锁信息:
表锁结构和行锁结构在这个位置的内容是不同的-
表锁:
记载着这是对哪个表加的锁,还有其他的一些信息。 -
行锁:
记载了三个重要的信息-
Space ID:
记录所在表空间。 -
Page Number:
记录所在页号。 -
n_bits:
对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位小提示:
并不是该页面中有多少记录,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_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
属性呢?这主要还是InnoDB把is_waiting
属性也放到了type_mode
这个32位的数字中:LOCK_WAIT
(十进制的256
) :也就是当第9个比特位置为1时,表示is_waiting
为true
,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0
时,表示is_waiting
为false
,也就是当前事务获取锁成功。
-
-
-
其他信息:为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表,为了简化讨论,我们忽略这部分信息哈~
-
一堆比特位:
如果是行锁结构
的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的n_bits
属性表示的。我们前边学习InnoDB
记录结构的时候说过,页面中的每条记录在记录头信息中都包含一个heap_no
属性,伪记录Infimum
的heap_no
值为0
,Supremum
的heap_no
值为1
,之后每插入一条记录,heap_no
值就增1
。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no
,不过为了编码方便,映射方式有点怪:小提示:
这么怪的映射方式纯粹是为了敲代码方便,大家不要大惊小怪,只需要知道一个比特位映射到页内的一条记录就好了
可能上边的描述大家觉得还是有些抽象,我们还是举个例子说明一下。比方说现在有两个事务T1
和T2
想对hero
表中的记录进行加锁,hero
表中记录比较少,假设这些记录都存储在所在的表空间号为67
,页号为3
的页面上,那么如果:
-
T1
想对number
值为15
的这条记录加S型正常记录锁
,在对记录加行锁之前,需要先加表级别的IS
锁,也就是会生成一个表级锁的内存结构,不过我们这里不关心表级锁,所以就忽略掉了哈~ 接下来分析一下生成行锁结构的过程:-
事务
T1
要进行加锁,所以锁结构的锁所在事务信息
指的就是T1
。 -
直接对聚簇索引进行加锁,所以索引信息指的其实就是
PRIMARY
索引。 -
由于是行锁,所以接下来需要记录的是三个重要信息:
-
Space ID
:表空间号为67
。 -
Page Number
:页号为3
。 -
n_bits
:我们的hero
表中现在只插入了5条用户记录,但是在初始分配比特位时会多分配一些,这主要是为了在之后新增记录时不用频繁分配比特位。其实计算n_bits
有一个公式:n_bits = (1 + ((n_recs + LOCK_PAGE_BITMAP_MARGIN) / 8)) * 8
其中
n_recs
指的是当前页面中一共有多少条记录(算上伪记录和在垃圾链表中的记录),比方说现在hero
表一共有7
条记录(5条用户记录和2条伪记录),所以n_recs
的值就是7
,LOCK_PAGE_BITMAP_MARGIN
是一个固定的值64,所以本次加锁的n_bits
值就是:n_bits = (1 + ((7 + 64) / 8)) * 8 = 72
-
type_mode
是由三部分组成的:-
lock_mode
,这是对记录加S锁
,它的值为LOCK_S
。 -
lock_type
,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC
。 -
rec_lock_type
,这是对记录加正经记录锁
,也就是类型为LOCK_REC_NOT_GAP
的锁。另外,由于当前没有其他事务对该记录加锁,所以应当获取到锁,也就是LOCK_WAIT
代表的二进制位应该是0。
-
综上所属,此次加锁的
type_mode
的值应该是:type_mode = LOCK_S | LOCK_REC | LOCK_REC_NOT_GAP
也就是:
type_mode = 2 | 32 | 1024 = 1058
-
-
其他信息
:略~ -
一堆比特位
:因为number
值为15
的记录heap_no
值为5
,根据上边列举的比特位和heap_no
的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:
-
综上所述,事务T1
为number
值为5
的记录加锁生成的锁结构就如下图所示:
-
T2
想对number
值为3
、8
、15
的这三条记录加X
型的next-key
锁,在对记录加行锁之前,需要先加表级别的IX锁
,也就是会生成一个表级锁的内存结构
,不过我们这里不关心表级锁,所以就忽略掉了哈~现在
T2
要为3
条记录加锁,number
为3
、8
的两条记录由于没有其他事务加锁,所以可以成功获取这条记录的X型next-key锁
,也就是生成的锁结构的is_waiting
属性为false
;但是number
为15
的记录已经被T1
加了S型正经记录锁
,T2
是不能获取到该记录的X型next-key锁
的,也就是生成的锁结构的is_waiting
属性为true
。因为等待状态不相同,所以这时候会生成两个锁结构
。这两个锁结构中相同的属性如下:-
事务
T2
要进行加锁,所以锁结构的锁所在事务
信息指的就是T2
。 -
直接对聚簇索引进行加锁,所以索引信息指的其实就是
PRIMARY
索引。 -
由于是行锁,所以接下来需要记录是三个重要信息:
-
Space ID
:表空间号为67。 -
Page Number
:页号为3。 -
n_bits
:此属性生成策略同T1中一样,该属性的值为72 -
type_mode
是由三部分组成的:-
lock_mode
,这是对记录加X锁
,它的值为LOCK_X
。 -
lock_type
,这是对记录进行加锁,也就是行锁,所以它的值为LOCK_REC
。 -
rec_lock_type
,这是对记录加next-key
锁,也就是类型为LOCK_ORDINARY
的锁
-
-
-
其他信息
:略~
不同的属性如下:
-
为
number
为3
、8
的记录生成的锁结构:-
type_mode
值:由于可以获取到锁,所以is_waiting
属性为false
,也就是LOCK_WAIT
代表的二进制位被置0
。所以:type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY
也就是
type_mode = 3 | 32 | 0 = 35
-
一堆比特位
:因为number
值为3
、8
的记录heap_no
值分别为3
、4
,根据上边列举的比特位和heap_no
的映射图来看,应该是第一个字节从低位往高位数第4
、5
个比特位被置为1
,就像这样:
综上所述,事务T2为number值为3、8两条记录加锁生成的锁结构就如下图所示:
-
-
为number为15的记录生成的锁结构:
-
type_mode值:
由于不可以获取到锁,所以is_waiting
属性为true
,也就是LOCK_WAIT
代表的二进制位被置1。所以:type_mode = LOCK_X | LOCK_REC |LOCK_ORDINARY | LOCK_WAIT
也就是
type_mode = 3 | 32 | 0 | 256 = 291
-
一堆比特位:
因为number
值为15
的记录heap_no
值为5
,根据上边列举的比特位和heap_no
的映射图来看,应该是第一个字节从低位往高位数第6个比特位被置为1,就像这样:
综上所述,事务
T2
为number
值为15
的记录加锁生成的锁结构就如下图所示: -
综上所述,事务
T1
先获取number
值为15
的S型正经记录锁
,然后事务T2
获取number
值为3
、8
、15
的X型正经记录锁
共需要生成3
个锁结构
小提示:
上边事务T2在对number值分别为3、8、15这三条记录加锁的情景中,是按照先对number值为3的记录加锁、再对number值为8的记录加锁,最后对number值为15的记录加锁的顺序进行的,如果我们一开始就对number值为15的记录加锁,那么该事务在为number值为15的记录生成一个锁结构后,直接就进入等待状态,就不为number值为3、8的两条记录生成锁结构了。在事务T1提交后会把在number值为15的记录上获取的锁释放掉,然后事务T2就可以获取该记录上的锁,这时再对number值为3、8的两条记录加锁时,就可以复用之前为number值为15的记录加锁时生成的锁结构了。 -
至此今天的学习就到此结束了,愿您成为坚不可摧的自己~~~
You can’t connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future.You have to trust in something - your gut, destiny, life, karma, whatever. This approach has never let me down, and it has made all the difference in my life
如果我的内容对你有帮助,请 点赞
、评论
、收藏
,创作不易,大家的支持就是我坚持下去的动力!
本文章参考:小孩子《MySQL是怎样运行的》
来源地址:https://blog.csdn.net/liang921119/article/details/131060086
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341