作者:技术只适用于干活
链接:https://www.jianshu.com/p/615f3c7fbe6f
加锁的目的:
数据库是一个多用户使用的共享资源。当多个用户并发地存取数据时,在数据库中就会产生多个事务同时存取同一数据的情况。若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。锁是用于管理对公共资源的并发控制。也就是说在并发的情况下,会出现资源竞争,所以需要加锁。
加锁解决了 多用户环境下保证数据库完整性和一致性。
使用锁的对象是事务,事务用来锁定数据库的对象是表、页、行。并且一般锁定的对象仅在事务commit或rollback后进行释放(不同事务隔离级别释放的时间可能不同)。
锁的分类
锁粒度
一、行锁
行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。有可能会出现死锁的情况,出现死锁的解决办法就是必须有一方事务回滚或者同时回滚。另外,行级锁按照使用方式分为共享锁和排他锁。
二、表锁
表级锁是mysql锁中粒度最大的一种锁,表示当前的操作对整张表加锁,资源开销比行锁少,不会出现死锁的情况,但是发生锁冲突的概率很大。被大部分的mysql引擎支持,MyISAM和InnoDB都支持表级锁,但是InnoDB默认的是行级锁。
Mysql的表级别锁分为两类:元数据锁(Metadata Lock,MDL)、表锁。
# 元数据锁(Metadata Lock,MDL)
元数据锁(MDL) 不需要显式使用,在访问一个表的时候会被自动加上。这个特性需要MySQL5.5版本以上才会支持,当对一个表做增删改查的时候,该表会被加MDL读锁当对表做结构变更的时候,加MDL写锁
-
MDL锁规则:
1、读锁之间不互斥
2、读写锁、写锁之间是互斥的,为了保证表结构变更的安全性,所以如果要多线程对同一个表加字段等表结构操作,就会变成串行化,需要进行锁等待
3、MDL的写锁优先级比MDL读锁的优先级高
4、MDL的锁释放必须要等到事务结束才会释放
# MDL锁的例子
若没有MDL锁的保护,则事务2可以直接执行DDL操作,并且导致事务1出错,5.1版本即是如此。5.5版本加入MDL锁就在于保护这种情况的发生,由于事务1开启了查询,那么获得了MDL锁,锁的模式为SHARED_READ(读锁模式),事务2要执行DDL,则需获得EXCLUSIVE锁(写锁),两者互斥,所以事务2需要等待。
页锁
页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。
兼容性
共享锁||读锁||S 锁(share lock)
共享锁||读锁||S 锁(share lock):其他事务可以读,但不能写。允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
例如:若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
select ... lockinsharemode;
排他锁||写锁||X 锁(exclusive)
其他事务不能读取,也不能写。允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
例如:若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。
select ... forupdate;
锁模式
一、意向锁
1、意向共享锁(IS Lock/intent share lock)
作用是表明事务想要获得一张表中某几行的共享锁
事务打算给数据行加行共享锁,那么就必须得先取得该表的 IS 锁。
2、意向排他锁||互斥锁(IX Lock/intent exclusive lock)
作用是表明事务想要获得一张表中某几行的排他锁
事务打算给数据行加行排他锁,那么就必须得先取得该表的 IX 锁。
意向锁的加锁方式
意向锁是 InnoDB 自动加的, 不需用户干预。
意向锁有什么用?
主要作用是处理行锁和表锁之间的矛盾,能够显示“某个事务正在某一行上持有了锁,或者准备去持有锁”,当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定
比如事务A要在一个表上加S锁,如果表中的一行已被事务B加了X锁,那么该锁的申请也应被阻塞。如果表中的数据很多,逐行检查锁标志的开销将很大,系统的性能将会受到影响。为了解决这个问题,可以在表级上引入新的锁类型来表示其所属行的加锁情况,这就引出了“意向锁”的概念。
举个例子,如果表中记录1亿,事务A把其中有几条记录上了行锁了,这时事务B需要给这个表加表级锁,如果没有意向锁的话,那就要去表中查找这一亿条记录是否上锁了。如果存在意向锁,那么假如事务A在更新一条记录之前,先加意向锁,再加X锁,事务B先检查该表上是否存在意向锁,存在的意向锁是否与自己准备加的锁冲突,如果有冲突,则等待直到事务A释放,而无须逐条记录去检测。事务B更新表时,其实无须知道到底哪一行被锁了,它只要知道反正有一行被锁了就行了。
二、行锁的算法
1、Record Lock(单行记录)
单条索引上加锁,record lock 永远锁的是索引,而非数据本身,如果innodb表中没有索引,那么会自动创建一个隐藏的聚集索引,锁住的就是这个聚集索引。当一条sql没有走任何索引时,那么将会在每一条聚集索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。
-
记录锁的条件
1、命中单行记录并且命中的条件字段是唯一索引或者主索引;
update user_info setname=’张三’ whereid=1;//这里的id是唯一索引,使用了Record Lock
Record Lock总是会去锁住索引记录,如果InnoDB存储引擎表在建立的时候没有设置任何一个索引,那么这时InnoDB存储引擎会使用隐式的主键来进行锁定。
2、Gap Lock(间隙锁)
间隙锁是封锁索引记录中的间隔,并且包括最前面的索引记录之前的范围(-infinity, index)和最后面的索引之后的范围(index,+infinity)。它能防止间隙内有新数据被插入。
-
产生间隙的条件
1、使用普通索引锁定;
2、使用多列唯一索引;
3、使用唯一索引锁定多行记录。
我们先理解什么是间隙,如
CREATETABLE`test` (
`id`int(1) NOTNULL AUTO_INCREMENT,
`name`varchar(8) DEFAULTNULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8;
INSERTINTO`test`VALUES ('1', '小罗');
INSERTINTO`test`VALUES ('5', '小黄');
INSERTINTO`test`VALUES ('7', '小明');
INSERTINTO`test`VALUES ('11', '小红');
上面数据的间隙就是:
-
(-infinity, 1)
-
(1, 5)
-
(5, 7)
-
(7, 11)
-
(11, +infinity)
在test中使用间隙锁的话,锁的就是上面的范围
基于上面的表,看看下面的例子:
BEGIN;
/* 查询 id 在 7 - 11 范围的数据并加记录锁 */
SELECT * FROM`test`WHERE`id`BETWEEN5AND7FORUPDATE;
/* 延迟30秒执行,防止锁释放 */
SELECTSLEEP(30);
产生的间隙锁范围是,会锁住 (5, 7)、(7, 11) 这两个区间。
BEGIN;
SELECT * FROM`test`WHERE`id` = 3FORUPDATE;
SELECTSLEEP(30);
由于3的记录并不存在,所以也没有单行记录可言,也会产生间隙锁,范围是(1,3)、(3,5)
BEGIN;
SELECT * FROM`test`WHERE`id` > 5FORUPDATE;
SELECTSLEEP(30);
会产生间隙锁,范围是(5,+infinity)
理解了间隙,我们看看下面的例子:
mysql> select * from product_copy;
+----+--------+-------+-----+
| id | name | price | num |
+----+--------+-------+-----+
|1| 伊利 |68| 1 |
| 2 | 蒙牛 | 88 |1|
|6| tom |2788| 3 |
| 10 | 优衣库 | 488 |4|
+----+--------+-------+-----+
其中id为主键 num为普通索引
窗口A:
mysql> select * from product_copy where num=3 for update;
+----+------+-------+-----+
| id | name | price | num |
+----+------+-------+-----+
| 6 | tom | 2788 |3|
+----+------+-------+-----+
1 row in set
窗口B:
mysql> insert into product_copy values(5,'kris',1888,2);
这里会等待 直到窗口A commit才会显示下面结果
Query OK, 1 row affected
但是下面是不需要等待的
mysql> update product_copy set price=price+100 where num=1;
Query OK, 2 rows affected
Rows matched: 2 Changed: 2 Warnings: 0
mysql> insert into product_copy values(5,'kris',1888,5);
Query OK, 1 row affected
通过上面的例子可以看出Gap 锁的作用是在的范围是(1,3)U(3,4)。
但是要记住,使用主键索引/唯一索引条件的单行记录不会使用间隙锁,会使用记录锁,但是如果查询出来的是多行记录,使用的就是间隙锁
下面的例子,id是主键索引,使用的是记录锁
窗口A:
mysql> select * from product_copy where id=6for update;
+----+------+-------+-----+
| id | name | price | num |
+----+------+-------+-----+
| 6 | tom | 2788 | 3 |
+----+------+-------+-----+
窗口B:并不会发生等待
mysql> insert into product_copy values(5,'kris',1888,3);
Query OK, 1 row affected
窗口A:
mysql> select * from product_copy where id>6for update;
+----+------+-------+-----+
| id | name | price | num |
+----+------+-------+-----+
| 10 | 优衣库 | 488 | 4 |
+----+------+-------+-----+
窗口B:会发生等待
mysql> insert into product_copy values(9,'kris',1888,3);
Query OK, 1 row affected
从上面的例子可以看到,第一条sql是使用了主键索引的单行记录,使用了记录锁,第二个sql即使使用了主键索引,但是查询的数据多于一条,使用的间隙锁,间隙锁锁的范围是(6,+infinity)
牢记:记录锁是加在索引上的锁,间隙锁是加在索引之间的。
3、Next-Key Lock(Record Lock + Gap Lock,临键锁)
临键锁,是记录锁与间隙锁的组合,它的封锁范围,既锁住记录本身还锁住索引之间的间隙。
注:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。
在RR级别中不使用记录锁的情况下会默认使用的是Next-Key Lock,下面是锁的默认使用范围:
# 一、SELECT … FROM … LOCK IN SHARE MODE
-
在所有索引扫描范围的索引记录上加上共享的next key锁;如果是唯一索引,就是在相应记录上加record lock。这些被共享lock住的行无法进行update/delete。
-
允许其它事务对这些记录再加SHARE锁
-
如果没有使用到索引,则锁住全表(表级的排他锁),无法进行insert/update/delete。
# 二、SELECT … FROM … FOR UPDATE
-
在所有索引扫描范围的索引记录上加上排他的next key锁。如果是唯一索引,就是在相应记录上加record lock。
-
如果没有利用到索引将锁住全表(表级的排他锁),其它事务无法进行insert/update/delete操作。
# 三、UPDATE … WHERE …
-
语句在所有索引扫描范围的索引记录上加上排他的next key锁。如果是唯一索引,就是在相应记录上加record lock。
-
如果没有利用到索引将锁住全表(表级的排他锁),其它事务无法进行其它的insert/update/delete操作。
# 四、DELETE FROM … WHERE …
-
语句在所有索引扫描范围的索引记录上加上排他的next key锁。如果是唯一索引,就是在相应记录上加record lock。
-
如果没有利用到索引将锁住全表(表级的排他锁),其它事务无法进行其它的insert/update/delete操作。
# 五、INSERT
-
在插入的记录上加一把排他锁,这个锁是一个record lock,并不是next-key 锁,因此就没有gap 锁,他将不会阻止其他会话在该条记录之前的gap插入记录。
next-key锁的范围
看下面的例子:
CREATETABLE`test` (
`id`int(1) NOTNULL AUTO_INCREMENT,
`name`varchar(8) DEFAULTNULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8;
INSERTINTO`test`VALUES ('1', '小罗');
INSERTINTO`test`VALUES ('5', '小黄');
INSERTINTO`test`VALUES ('7', '小明');
INSERTINTO`test`VALUES ('11', '小红');
上面数据的间隙就是:
-
(-infinity, 1]
-
(1, 5]
-
(5, 7]
-
(7, 11]
-
(11, +infinity]
记得左开右闭
MVCC,多版本的并发控制,Multi-Version Concurrency Control。
MVCC的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突。使用锁和锁协议来实现相应的隔离级别来进行并发控制会因为锁会造成事务阻塞。而多版本并发控制使得对同一行记录做读写的事务之间不用相互阻塞等待,提高了事务的并发能力,可以认为MVCC是一种解决读写阻塞等待的行级锁。
MVCC的数据库表中每一行数据都可能存在多个版本,对数据库的任何修改的提交都不会直接覆盖之前的数据,而是产生一个新的版本与老版本共存,通过读写数据时读不同的版本来避免加锁阻塞
-
1、MVCC只支持(已提交读)和(可重复读)隔离级别。
-
2、MVCC能解决脏读、不可重复读问题,不能解决幻读问题。
-
3、MVCC是用来解决读写操作之间的阻塞问题。
隐式字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段
-
DB_TRX_ID:数据行版本号:大小为6byte,记录最近修改(修改/插入)事务ID,记录创建这条记录/最后一次修改该记录的事务ID
-
DB_ROLL_PTR:删除版本号:大小为7byte,记录回滚指针,指向当前记录行的undo log信息(指向该数据的前一个版本数据)
-
DB_ROW_ID:行数据隐式id:大小为6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID
产生一个聚簇索引
ReadView
read view是读视图,其实就相当于一种快照,里面记录了系统中当前活跃事务的ID以及相关信息,主要用途是用来做可见性判断,判断当前事务是否有资格访问该行数据。read view有多个变量:
-
trx_ids: 它里面的trx_ids变量存储了活跃事务列表,也就是Read View开始创建时其他未提交的活跃事务的ID列表。例如事务A在创建read view(快照)时,数据库中事务B和事务C还没提交或者回滚结束事务,此时trx_ids就会将事务B和事务C的事务ID记录下来。
假设当前事务生成了一个ReadView,trx_ids列表里的事务id为[60,100]。
1、如果你要访问的记录版本的事务id为50,比当前列表最小的id 60还小,那说明这个事务在ReadView生成之前就提交了,所以对当前活动的事务来说是可访问的。
2、如果你要访问的记录版本的事务id为70,发现此事务在列表id最大值和最小值之间,那就再判断一下 70 这个id是否在列表内,如果在那就说明此事务还未提交,所以版本不能被访问。如果不在那说明事务已经提交,所以版本可以被访问。
3、如果你要访问的记录版本的事务id为110,那比事务列表最大id100都大,那说明这个版本是在ReadView生成之后才发生的,所以不能被访问。
Undo log
Undo log中存储的是老版本数据,当一个事务需要读取记录行时,如果当前记录行不可见,可以通过回滚指针顺着undo log链找到满足其可见性条件的记录行版本。
在InnoDB里,undo log分为如下两类:
①insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
②update undo log : 事务对记录进行delete和update操作时产生的undo log,在事务回滚时需要
实际还有一个删除flag隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除flag变了
插入:获取最新的事务版本号n,保存n到对应行的行版本号
删除:获取最新的事务版本号n,保存到对应行的删除版本号
修改:变为insert和delete操作的组合,先获取最新的事务版本号n,然后进行数据行拷贝,插入拷贝的数据,保存n到新插入数据行行版本号的字段中,然后保存n到旧的数据行的删除版本号字段中
查询:获取最新的事务版本号n,查询行版本号小于或者等于n的行数据,防止读到其他事务提交的数据
MVCC实现过程原理主要的原理:
参考:https://baijiahao.baidu.com/s?id=1629409989970483292&wfr=spider&for=pc
版本记录都是去版本链里面找的,然后根据不同隔离级别生成的ReadView就会有所不同
-
例如:在一个读已提交或者是重复读的级别事务中
有一个事务id为100的事务,修改了name,使得的name等于小明2,但是事务还没提交。则此时的版本链如下:
此时另一个事务发起了select 语句要查询id为1的记录,那此时生成的ReadView 列表就是[100]。最新版本100是活跃事务不能访问,那就得使用版本链去找了,首先找100的下个版本name为小明1的记录,发现trx_id是60,小于列表中的最小id,所以可以访问,直接访问结果为小明1。那这时候我们把事务id为100的事务提交了,并且新建了一个事务id为110也修改id为1的记录,name修改为小明3,并且不提交事务
这时候之前那个select事务又执行了一次查询,要查询id为1的记录。这时候会发生两种情况
-
1、已提交读隔离级别
会重新一个生成一个ReadView,那你的活动事务列表中的值就变了,变成了[110],通过版本链查trx_id对比,查到的只能是小明2。 -
2、可重复读隔离级别
ReadView还是第一次select时候生成的ReadView,也就是列表的值还是[100]。所以select的结果是小明1。所以第二次select结果和第一次一样,所以叫可重复读!
也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。
当前读与快照读
-
1、当前读:
即加锁读,读取记录的最新版本,会加锁保证其他并发事务不能修改当前记录,直至获取锁的事务释放锁;
使用当前读的操作主要包括:显式加锁的读操作与插入/更新/删除等写操作,如下所示:
select * fromtablewhere ? lockinsharemode;
select * fromtablewhere ? forupdate;
insertintotablevalues (…);
updatetableset ? where ?;
deletefromtablewhere ?;
-
2、快照读:
即不加锁读,读取记录的快照版本而非最新版本,通过MVCC实现;
当前读
需要特别注意的是在MVCC下的可重复读在读操作是防止了幻读,读操作下完全就是按照ReadView进行的快照读。但是对于会对数据的操作例如:
select * from .... where ... forupdate
select * from .... where ... lockinsharemode
update .... set .. where ...
delete from. . where ..
都是采用当前读的模式。在执行这几个操作时会读取最新的记录,即使是别的事务提交的数据也可以查询到。假设要update一条记录,但是在另一个事务中已经delete掉这条数据并且commit了,如果update就会产生冲突,所以在update的时候需要知道最新的数据。如果事务中都使用快照读,那么就不会产生幻读现象,但是快照读和当前读混用就会产生幻读。
解决幻读的方案
事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读
一、mvcc只进行快照读(性能好)
在可重复读的隔离级别下只要不发生当前读就能避免幻读
二、next-key 锁 (性能差)
由于next-key 锁是记录锁与间隙锁的组合,它的封锁范围,既锁住记录本身还锁住索引之间的间隙。这样将当前数据行与上一条数据和下一条数据之间的间隙锁定,保证此范围内读取的数据是一致的。
select * from T wherenumber = 1forupdate;
select * from T wherenumber = 1lockinsharemode;