源码地址:https://dev.mysql.com/downloads/mysql/
前言
mysql
一、lock与latch
在数据库中,lock与latch都可以被称为“锁”,但是两者有着截然不同的含义
lock事务(transaction)并发访问数据内容时的并发控制;
latch线程(thread)并发访问内存数据结构时的并发控制;
latch
定义:latch锁是内存锁,是一个小型的在内存中保护list的内存锁结构。
latch要求锁定的时间必须非常短。在InnoDB存储引擎中,latch又可以分为 mutex(互斥量)和 rwlock(读写锁)。其目的 是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制;
特点:
1、不排队
2、自旋(spin),一个线程想获得一个锁,但是该锁已被另一线程持有,进行spin(空转随机时间)占用cpu间接性的等待锁的释放,然后获取去进行相关操作。
3、os waits:sleep,spin多次仍然未获取到latch
4、cpu繁忙,latch争用
比如:操作缓冲池汇总的LRU列表,删除、添加、移动LRU列表中的元素,为了保证一致性,必须有锁的介入,这就是latch锁
-- 查看InnoDB存储引擎中的latch
mysql> show engine innodb mutex;
+--------+-----------------------------+----------+
| Type | Name | Status |
+--------+-----------------------------+----------+
| InnoDB | rwlock: dict0dict.cc:2687 | waits=1 |
| InnoDB | rwlock: dict0dict.cc:1184 | waits=13 |
| InnoDB | rwlock: log0log.cc:844 | waits=35 |
| InnoDB | sum rwlock: buf0buf.cc:1457 | waits=4 |
+--------+-----------------------------+----------+
4 rows in set (0.16 sec)
debug版本下Status列会显示下图的数据:
lock
lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在事务commit或rollback后进行释放,不同事务隔离级别释放的时间可能不同,lock是有死锁检测机制的;
-- 查看InnoDB存储引擎中的lock信息
SHOW ENGINE INNODB STATUS
SELECT * from information_schema.INNODB_TRX
SELECT * from information_schema.INNODB_LOCKS
SELECT * from information_schema.INNODB_LOCK_WAITS
二、InnoDB存储引擎的锁
共享锁与排他锁
如果一个事务T1已经获得了行r的共享锁, 那么另外的事务T2可以立即获得行r的共享锁, 因为读取并没有改变行 r 的数据, 称这种情况为锁兼容 (Lock Compatible)。 但若有其他的事务T3想获得行r的排他锁, 则其必须等待事务T1, T2释放行r上的共享锁——这种情况称为锁不兼容。因为获取排他锁一般是为了改变数据,所以不能同时进行读取或则其他写入操作。
共享锁(S Lock):允许事务对一条行数据进行读取
排他锁(X Lock):允许事务对一条行数据进行删除或更新
意向共享锁与意向排他锁
InnoDB 存储引擎支持多粒度锁定, 这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支待在不同粒度上进行加锁操作, InnoDB 存储引擎支持 一种额外的锁方式, 称之为意向锁 (Intention Lock)。意向锁是将锁定的对象分为多个层次, 意向锁意味着事务希望在更细粒度上进行加锁。
InnoDB存储引擎支持意向锁设计比较简练,其意向锁即为表级别的锁。设计目的主要是为了在事务中揭示下一行将被请求的锁类型。其支持两种意向锁:
意向共享锁(IS Lock):事务想要获得一张表中某几行的共享锁
意向排他锁(IX Lock):事务想要获得一张表中某几行的排他锁
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据。
如果没有意向锁,当已经有人使用行锁对表中的某一行进行修改时,如果另外一个请求要对全表进行修改,那么就需要对所有的行是否被锁定进行扫描。在引入意向锁之后,当有人使用行锁对表中的某一行进行修改之前,会先为表添加意向互斥锁(IX),再为行记录添加互斥锁(X),在这时如果有人尝试对全表进行修改就不需要判断表中的每一行数据是否被加锁了,只需要通过等待意向互斥锁被释放就可以了。
一致性非锁定读
一致性非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过多版本控制(MVVC)读取当前数据库中行数据的方式。如果读取的行正在执行DELETE或UPDATE操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB会去读取行的一个快照。
上图直观地展现了InnoDB一致性非锁定读的机制。之所以称其为非锁定读,是因为不需要等待行上排他锁的释放。快照数据是指该行的之前版本的数据,每行记录可能有多个版本,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control, MVVC)。InnoDB是通过undo log来实现MVVC。undo log本身用来在事务中回滚数据,因此快照数据本身是没有额外开销。此外,读取快照数据是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。
一致性非锁定读是InnoDB默认的读取方式,即读取不会占用和等待行上的锁。但是并不是在每个事务隔离级别下都是采用此种方式。此外,即使都是使用一致性非锁定读,但是对于快照数据的定义也各不相同。
在事务隔离级别READ COMMITTED和REPEATABLE READ下,InnoDB使用一致性非锁定读。然而,对于快照数据的定义却不同。
我们下面举个例子来详细说明一下上述的情况。
首先在会话A中显示地开启一个事务,然后读取test表中的id为1的数据,但是事务并没有提交。与此同时,在开启另一个会话B,将test表中id为1的记录修改为id=3,但是事务同样也没有提交,这样id=1的行其实加了一个排他锁。
由于InnoDB在READ COMMITTED和REPEATABLE READ事务隔离级别下使用一致性非锁定读,这时如果会话A再次读取id为1的记录,仍然能够读取到相同的数据。此时,READ COMMITTED和REPEATABLE READ事务隔离级别没有任何区别。
当会话B提交事务后,会话A再次运行SELECT * FROM test WHERE id = 1的SQL语句时,两个事务隔离级别下得到的结果就不一样了:
对于READ COMMITTED的事务隔离级别,它总是读取行的最新版本,如果行被锁定了,则读取该行版本的最新一个快照。因为会话B的事务已经提交,所以在该隔离级别下上述SQL语句的结果集是空的。
对于REPEATABLE READ的事务隔离级别,总是读取事务开始时的行数据,因此,在该隔离级别下,上述SQL语句仍然会获得相同的数据。
在默认情况下,即事务的隔离级别是repeatable read模式下,InnoDB存储引擎的SELECT操作使用的是一致性非锁定读。但是在某些情况下,用户需要显示的读取数据操作进行加锁保证数据逻辑的一致性。
一致性锁定读
InnoDB提供了两种方式实现一致性锁定读:
加锁语法
共享锁 (S):SELECT * FROM table_name WHERE … LOCK IN SHARE MODE;
排他锁 (X):SELECT * FROM table_name WHERE … FOR UPDATE;
1.对于UPDATE、DELETE、INSERT语句,自动给相关数据加上排他锁。
2.如果数据集过滤条件中不走索引的话,会给全表加锁。
需要注意的是,以上两种语句必须在一个事务当中,当事务提交了,锁也就释放了。
-- 会话A
1:begin;
2:SELECT * FROM user WHERE id = 1 lock in share mode;
3:update user set name = 'jsk' where id =1;
4:commit;
-- 会话B
1:begin;
2:SELECT * FROM user WHERE id = 1 lock in share mode;
3:commit;
如果仅执行会话A中的语句,是可以顺利完成的。这里可以证明:只有当前事务加了共享锁,那么该事务可以写
如果仅执行会话A中的前两行,然后执行会话B中前两行,再回头继续执行事务A中的第三行。此时,会一直卡在这里造成阻塞。
阻塞
因为不同锁之间的兼容性关系,有时候一个事务中的锁需要等待另一个事务中的锁释放它所占用的资源,这就是阻塞。
在InnoDB中,参数innodb_lock_wait_timeout用来控制等待的时间,innodb_rollback_on_timeout用来设定是否在等待超时后回滚。
mysql> show variables like 'innodb_lock_wait_timeout';
*************************** 1. row
Variable_name: innodb_lock_wait_timeout
Value: 50
mysql> show variables like 'innodb_rollback_on_timeout';
1. row
: innodb_rollback_on_timeout
Value: OFF
自增长与锁
自增插入不用等事务提交,就会释放自增的锁
create test(`id` int(11) NOT NULL AUTO_INCREMENT)ENGINE=InnoDB;
会话A | 会话B |
---|---|
set autocommit = 0 | set autocommit = 0 |
BEGIN | BEGIN |
INSERT test VALUE() | |
INSERT test VALUE() | |
SELECT * from test ->(1) | |
COMMIT | |
ROLLBACK | SELECT * from test ->(2) |
三、锁的算法
Record Lock
单个行记录上加锁,防止事务间修改或删除数据。Record Lock 永远锁的是索引,而非数据本身,如果Innodb表中没有索引,那么会自动创建一个隐藏的聚集索引,锁住的就是这个聚集索引。所以说当一条sql没有走任何索引时,那么将会在每一条聚集索引后面加X锁,有点类似于表锁。
-- 可以通过检查 InnoDB_row_lock 状态变量来分析系统上的行锁的争夺情况:
show status like 'innodb_row_lock%';
+-------------------------------+-------+
| Variable_name | Value |
+-------------------------------+-------+
| InnoDB_row_lock_current_waits | 0 |
| InnoDB_row_lock_time | 0 |
| InnoDB_row_lock_time_avg | 0 |
| InnoDB_row_lock_time_max | 0 |
| InnoDB_row_lock_waits | 0 |
+-------------------------------+-------+
5 rows in set (0.01 sec)
--如果发现锁争用比较严重
--如 InnoDB_row_lock_waits 和 InnoDB_row_lock_time_avg 的值比较高
--还可以通过设置 InnoDB Monitors 来进一步观察发生锁冲突的表、数据行等,并分析锁争用的原因。
--创建监视器
CREATE TABLE innodb_monitor(a INT) ENGINE=INNODB;
--然后就可以用下面的语句来进行查看:
Show innodb status;
--监视器可以通过发出下列语句来停止查看:
DROP TABLE innodb_monitor;
在监视器日志中,会有详细的当前锁等待的信息,包括表名、锁类型、锁定记录的情况等,便于进行进一步的分析和问题的确定。打开监视器以后,默认情况下每 15 秒会向日志中记录监控的内容,所以用户在确认问题原因之后,要记得删除监控表以关闭监视器,或者通过使用“–console”选项来启动服务器以关闭写日志文件,不然日志会很大,很费资源。
Gap Lock
间隙锁,表示只锁住一段范围,不锁记录本身,通常表示两个索引记录之间,或者索引上的第一条记录之前,或者最后一条记录之后的锁。当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(GAP Lock)。
1)间隙锁锁定的是索引BTree+叶子节点的next指针
2)间隙锁主要用于解决可重复读事务隔离级别中的幻读问题
快照读: 在可重复读事务隔离级别下,快照读读到的是数据的当前版本或历史版本。所以快照读无需加锁也可以防止幻读。
Next-Key Lock(默认使用算法)
Gap Lock + Record Lock,锁定一个范围及锁定记录本身。例如一个索引有10, 11, 13, 20这四个值,那么该索引可能被Next-key Locking的区间为
(负无穷, 10]
(10, 11]
(11, 13]
(12, 20]
(20, 正无穷)
需要理解一点,InnoDB中加锁都是给所有记录一条一条加锁,并没有一个直接的范围可以直接锁住,所以会生成多个区间。
MySQL InnoDB存储引擎中默认使用的是Next-Key Lock,先使用Gap Lock,当通过对应唯一索引查询时,会将Gap Lock降级为Record Lock。
四、隔离级别
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(read-uncommitted) | 是 | 是 | 是 |
读已提交(read-committed ) | 否 | 是 | 是 |
可重复读(repeatable-read)[默认级别] | 否 | 否 | 是(通过锁解决) |
串行化(serializable) | 否 | 否 | 否 |
(1)脏读:事务a读取了事务b更新(插入)的数据,然后b回滚操作,那么a读取的数据是脏数据。即尚未提交(commit)的数据,被其他的事务读取了。
Time | 会话A | 会话B |
---|---|---|
1 | SET @@tx_isolation=‘read-uncommitted’ | SET @@tx_isolation=‘read-uncommitted’ |
2 | Begin; | |
3 | select * from test;[result->a=1] | |
4 | insert test select 2 | |
5 | select * from test;[result->a=1,a=2] | |
6 | commit; |
(2)不可重复读:事务a多次读取同一数据(同一条记录),事务b在事务a多次读取的过程中,对数据做了更新并提交,导致事务a多次读取同一数据时,结果不一致。
Time | 会话A | 会话B |
---|---|---|
1 | SET @@tx_isolation=‘read-committed’ | SET @@tx_isolation=‘read-committed’ |
2 | begin | begin |
2 | select * from test where a=1;[result->a=1] | |
4 | update test set a=2 where a=1 | |
5 | select * from test where a=1;[result->empty] | commit |
6 | commit |
(3)幻读:在同一个事务中,执行两次相同的sql,等到不同的结果集,(新增了部分记录或者缺失了部分记录)(与不可重复读操作对象不一样,此处,不是同一条记录)。
小结:不可重复读和幻读很容易混淆,不可重复读偏重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。
五、死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种相互等待的现象。若无外力作用,事务都将无法推进下去。
解决死锁方案
解决死锁做简单的方法就是超时,即当两个事务互相等待时,当一个等待时间超过了某一阈值,其中一个事务进行回滚,另一个等待的事务就能继续进行。但是如果超时的事务所占权重比较大,如事务更新了很多行,占用了较多的undo log,回滚这个事务的时间相对于另一个事务所占用的时间可能会更多。因此,除了超时机制,还会采用等待图(wait-for graph)的方式来进行主动死锁检测并且回滚数据改动较少的那个事务。
wait-for graph要求数据库保存以下两种信息:
锁的信息链表
事务等待链表
通过上述链表可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,因此资源间相互发生等待。在 wait-for graph中,事务为图中的节点。而在图中,事务T1指向T2边的定义为:
事务T1等待事务T2所占用的资源
事务T1最终等待T2所占用的资源,也就是事务之间在等待相同的资源,而事务T1发生在事务T2的后面
来看一个例子:
在 Transaction Wait Lists中可以看到共有4个事务t1、t2、t3、t4,故在wait-for graph中应有4个节点。
通过上图可以发现存在回路(t1,t2),因此存在死锁。
wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁并发生等待时都会判断是否存在回路,若存在则有死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。
死锁案例
(1)A等待B,B等待A,事务B会自动回滚,从而事务A获取到锁执行。
(2)
create table t(
a INT PRIMARY KEY
)engine=innoDB;
insert t values (1),(2),(4),(5)
创建表T,插入1,2,4,5四条数据,然后执行下面的语句:
会话第三行A对a=4持有X锁
会话B第四行对1,2获取锁成功,获取4的S锁时发生等待。
此时会话A第五行插入记录3,会去获取(2,4]的间隙锁,发生阻塞,从而发生回滚。