一、背景
工作经常遇到在编写并发场景sql时考虑欠缺,造成过一些bug和sql死锁。于是乎,笔者查阅了不少相关资料。 本文就MySQL/InnoDB的加锁问题,展开较为深入的分析与讨论,主要是介绍一种思路,运用此思路,拿到任何一条SQL语句,都能完整的分析出这条语句会加什么锁?会有什么样的使用风险?甚至是分析线上的一个死锁场景,了解死锁产生的原因。
注:MySQL是一个支持插件式存储引擎的数据库系统。本文下面的所有介绍,都是基于InnoDB存储引擎,其他引擎的表现,会有较大的区别。
二、InnoDB的多版本并发控制MVCC
InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处就是:读不加锁,读写不冲突。
在MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。当前读,读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
现在以MySQL InnoDB为例来看看:
快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)。
select * from table where ?
当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
所有以上的五条语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句(lock in share mode;),对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。
一个Update SQL (update set x where ?)操作的具体流程:
当Update SQL被发给MySQL后,MySQL Server会根据where条件,读取第一条满足条件的记录,然后InnoDB引擎会将第一条记录返回,并加锁 (current read)。
MySQL Server收到这条加锁的记录之后,会再发起一个Update请求,更新这条记录。一条记录操作完成,再读取下一条记录,直至没有满足条件的记录为止。
因此,Update操作内部,就包含了一个当前读。同理,Delete操作也一样。
Insert操作会稍微有些不同,简单来说,就是Insert操作可能会触发Unique Key的冲突检查,也会进行一个当前读。(针对一条当前读的SQL语句,InnoDB与MySQL Server的交互,是一条一条进行的,因此,加锁也是一条一条进行的。先对一条满足条件的记录加锁,返回给MySQL Server,做一些DML操作;然后在读取下一条加锁,直至读取完毕。)
三、事务隔离级别Isolation Level
不同的事物隔离级别下,对SQL加的锁有所区别。本文不打算讨论数据库理论中,是如何定义这4种隔离级别的含义的,而是来看在MySQL/InnoDB在4种隔离级别下,SQL是如何加锁的。
MySQL/InnoDB定义的4种隔离级别:
- Read Uncommited
可以读取未提交记录。此隔离级别,一般不会使用。本文不考虑
- Read Committed (RC)
快照读忽略。针对当前读,RC隔离级别保证对读取到的记录加锁 (记录锁),但可能存在幻读现象。
- Repeatable Read (RR)
快照读忽略。针对当前读,RR隔离级别保证对读取到的记录加锁(记录锁),同时保证对读取的范围加锁,新的满足查询条件的记录不能够插入(间隙锁),不存在幻读现象。
- Serializable
从MVCC并发控制退化为基于锁的并发控制(Lock-Based Concurrency Control))。不区别快照读与当前读,所有的读操作均为当前读,读加读锁 (S锁),写加写锁 (X锁)。
该隔离级别导致读写冲突,因此并发度急剧下降,在MySQL/InnoDB下不建议使用。
在MYSQL的事务引擎中,INNODB是使用范围最广的。它默认的事务隔离级别是REPEATABLE READ(可重复读),在标准的事务隔离级别定义下,REPEATABLE READ是不能防止幻读产生的。INNODB使用了next-key locks实现了防止幻读的发生。
四、针对RC与RR的简单SQL加锁分析
针对如下两条简单SQL的执行过程来分析InnoDB的加锁策略。
SQL1:select * from t1 where id = 10;
SQL2:delete from t1 where id = 10;
InnoDB存储引擎的数据组织方式,是聚簇索引表:完整的记录,存储在主键索引的B TREE树的叶子节点中,通过主键索引,就可以获取记录所有的列。
而对于普通二级索引,BTree 树的叶子节点存储的是该索引数据和主键索引。也就是说在通过二级索引查数据的时候,是先获取到主键ID,在通过主键ID去聚簇索引表获取完整记录。
关于聚簇索引表的组织方式,可以参考MySQL的官方文档:Clustered and Secondary Indexes 。而对于二级索引
现在基于以上关于InnDB索引查数据的知识和前面隔离级别。要对上述SQL加锁分析,需要给定一些前提条件,如下所示:
- 前提一:id列是不是主键?
- 前提二:当前系统的隔离级别是什么?
- 前提三:id列如果不是主键,那么id列上有索引吗?
- 前提四:id列上如果有二级索引,那么这个索引是唯一索引吗?
- 前提五:两个SQL的执行计划是什么?索引扫描?全表扫描?
现在我们基于这写前提,一个个的分析,在这些前提下,SQL分别会加什么锁。
组合一:id主键+RC
这个组合,是最简单,最容易分析的组合。id是主键,Read Committed隔离级别,给定SQL:delete from t1 where id = 10; 只需要将主键上,id = 10的记录加上X锁即可。SELECT不加锁。
组合二:id唯一索引+RC
id不是主键,是一个Unique的二级索引键值,而主键是其他列。加锁的情况由于组合一有所不同。由于id是unique索引,因此delete语句会选择走id列的索引进行where条件的过滤,在找到id=10的记录后,首先会将unique索引上的id=10索引记录加上X锁,同时,会根据读取到的主键列,回主键索引(聚簇索引),然后将聚簇索引上的主键列对应的索引项加X锁。
为什么聚簇索引上的记录也要加锁?试想一下,如果并发的一个SQL,是通过主键索引来更新:update t1 set id = 100 where PRIMARY KEY = 'xxx’; 此时,如果delete语句没有将主键索引上的记录加锁,那么并发的update就会感知不到delete语句的存在,违背了同一记录上的更新/删除需要串行执行的约束。
若id列是unique列,其上有unique索引。那么SQL需要加两个X锁,一个对应于id unique索引上的id = 10的记录,另一把锁对应于聚簇索引上的数据。
组合三:id非唯一索引+RC
与组合二很类似,区别在于id列上的约束又降低了,id列不再唯一,只有一个普通的索引。假设delete from t1 where id = 10; 语句,仍旧选择id列上的索引进行过滤where条件,那么此时首先,id列索引上,满足id = 10查询条件的记录,均已加锁。同时,这些记录对应的主键索引上的记录也都加上了锁。
组合二最多只有一个满足等值查询的记录,而组合三会将所有满足查询条件的记录都加锁。
组合四:id无索引+RC
当id列上没有索引,where id = 10;这个过滤条件,没法通过索引进行过滤,因此只能走聚簇索引,那么只能走全表扫描做过滤。
当全表扫描时,即使满足删除条件的记录有两条,但是,聚簇索引上所有的记录,都被加上了X锁。无论记录是否满足条件,全部被加上X锁。既不是加表锁,也不是在满足条件的记录上加行锁。
MySQL的实现决定了。如果一个条件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行过滤。因此也就把所有的记录,都锁上了。
在实际的实现中,MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁。保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。
结论:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁。
组合五:id主键+RR
id列是主键列,RR隔离级别,针对delete from t1 where id = 10; 这条SQL,加锁与RC隔离级别一致。
组合六:id唯一索引+RR
与RC隔离级别一致。两个X锁,id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个。
组合七:id非唯一索引+RR
前文提到,RC隔离级别允许幻读,而RR隔离级别,不允许存在幻读。但是在组合五、组合六中,加锁行为又是与RC下的加锁行为完全一致。那么RR隔离级别下,id上有一个非唯一索引,执行delete from t1 where id = 10; 会比在RC隔离级别多一个GAP锁。
假设 t1 表中,name是主键PRIMARY KEY。

GAP锁的目的,就是RR隔离级别,相对于RC隔离级别,不会出现幻读的关键,是为了防止同一事务的两次当前读,出现幻读的情况。GAP锁锁住的位置,也不是记录本身,而是两条记录之间的位置。
要保证两次当前读返回一致的记录,那就需要在第一次当前读与第二次当前读之间,其他的事务不会插入新的满足条件的记录并提交。
如图中所示,为了保证[6,c]与[10,b]间,[10,b]与[10,d]间,[10,d]与[11,f]不会插入新的满足条件的记录,MySQL选择了用GAP锁,将这三个GAP给锁起来。因此在其他连接执行插入前,会检查这个GAP是否已经被锁上,如果被锁上,则Insert不能插入记录。因此,通过第一遍的当前读,不仅将满足条件的记录锁上 (X锁),与组合三类似。同时还是增加3把GAP锁,将可能插入满足条件记录的3个GAP给锁上,保证后续的Insert不能插入新的id=10的记录,也就杜绝了同一事务的第二次当前读,出现幻象的情况。
组合八:id无索引+RR
RR隔离级别下,id列上没有索引。此时SQL:delete from t1 where id = 10; 没有其他的路径可以选择,只能进行全表扫描。最终的加锁是一个很恐怖的现象。首先,聚簇索引上的所有记录,都被加上了X锁。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁。
在这种情况下,这个表上,除了不加锁的快照度,其他任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死。
MySQL针对这种情况也做了一些优化,就是所谓的semi-consistent read。semi-consistent read开启的情况下,对于不满足查询条件的记录,MySQL会提前放锁。针对上面的这个用例,就是除了记录[d,10],[g,10]之外,所有的记录锁都会被释放,同时不加GAP锁。关于semi-consistent read的详情本文不介绍。
组合九:Serializable
Serializable隔离级别。对于SQL2:delete from t1 where id = 10; 来说,Serializable隔离级别与Repeatable Read隔离级别完全一致。
Serializable隔离级别,影响的是SQL1:select * from t1 where id = 10; 这条SQL,在RC,RR隔离级别下,都是快照读,不加锁。但是在Serializable隔离级别,SQL1会加读锁,也就是说快照读不复存在,MVCC并发控制降级为Lock-Based CC。
五,复杂SQL的加锁分析
到这里MySQL的加锁实现也已经介绍的八八九九。只要将本文上面的分析思路,大部分的SQL,都能分析出其会加哪些锁。而这里,再来看一个稍微复杂点的SQL,用于说明MySQL加锁的另外一个逻辑。表结构和SQL用例如下:
CREATE TABLE `t1` (
`id` char(32) NOT NULL COMMENT 'id',
`user_id` varchar(5) NOT NULL DEFAULT '' ,
`blog_id` varchar(5) NOT NULL DEFAULT '',
`pubtime` int(10) DEFAULT NULL,
`comment` varchar(255) NOT NULL DEFAULT '' ,
PRIMARY KEY (`id`),
KEY `idx_t1_pu` (`pubtime`,`pubtime`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
在上述表结构下,执行如下SQL
delete from t1 where pubtime>1 and pubtime<20 and userid = 'hbc' and comment is not null;
假定在默认的Repeatable Read隔离级别下。
在详细分析这条SQL的加锁情况前,还需要有一个知识储备,那就是一个SQL中的where条件如何拆分?笔者不过多介绍,直接说结论。
- Index key:pubtime > 1 and puptime < 20。此条件,用于确定SQL在idx_t1_pu索引上的查询范围。
- Index Filter:userid = ‘hdc’ 。此条件,可以在idx_t1_pu索引上进行过滤,但不属于Index Key。
- Table Filter:comment is not NULL。此条件,在idx_t1_pu索引上无法过滤,只能在聚簇索引上过滤。
在分析出SQL where条件的构成之后,再来看看这条SQL的加锁情况 (RR隔离级别),如下图所示:

在RR隔离级别下,由Index Key所确定的范围,被加上了GAP锁;Index Filter锁给定的条件 (userid = ‘hdc’)何时过滤,视MySQL的版本而定,在MySQL 5.6版本之前,不支持Index Condition Pushdown(ICP),因此Index Filter在MySQL Server层过滤,在5.6后支持了Index Condition Pushdown,则在index上过滤。
若不支持ICP,不满足Index Filter的记录,也需要加上记录X锁,若支持ICP,则不满足Index Filter的记录,无需加记录X锁 (图中,用红色箭头标出的X锁,是否要加,视是否支持ICP而定);
而Table Filter对应的过滤条件,则在聚簇索引中读取后,在MySQL Server层面过滤,因此聚簇索引上也需要X锁。最后,选取出了一条满足条件的记录[8,hdc,d,5,good],但是加锁的数量,要远远大于满足条件的记录数量。
结论:在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件。Index Key确定的范围,需要加上GAP锁;Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记录,不加X锁,否则需要X锁;Table Filter过滤条件,无论是否满足,都需要加X锁。
六、总结
要做的完全掌握MySQL/InnoDB的加锁规则,甚至是其他任何数据库的加锁规则,需要具备以下的一些知识点:
了解数据库的一些基本理论知识:
- 数据的存储格式 (堆组织表 vs 聚簇索引表);并发控制协议 (MVCC vs Lock-Based CC);Two-Phase Locking;数据库的隔离级别定义 (Isolation Level);
- 了解SQL本身的执行计划 (主键扫描 vs 唯一键扫描 vs 范围扫描 vs 全表扫描);
- 了解数据库本身的一些实现细节 (过滤条件提取;Index Condition Pushdown;Semi-Consistent Read);
- 了解死锁产生的原因及分析的方法 (加锁顺序不一致;分析每个SQL的加锁顺序)
有了这些知识点,再加上适当的实战经验,全面掌控MySQL/InnoDB的加锁规则,当不在话下。
本文深入探讨了MySQL InnoDB的SQL加锁机制,分析了不同事务隔离级别下的加锁行为,包括快照读与当前读的区别,并通过实例详细解析了在不同场景下,如主键、唯一索引、非唯一索引和无索引时的加锁策略。在RC和RR隔离级别下,对简单SQL和复杂SQL的加锁进行了详细阐述,旨在帮助理解SQL加锁原理,预防并发场景下的死锁问题。
935

被折叠的 条评论
为什么被折叠?



