一、锁在sql并发的作用场景
在正式介绍mysql(准确来说是Innodb中)的各种锁之前,我们先说说数据库的锁会在事务中的什么情况下被用到。
事务并发可以分为3种情况:
写写
写写(事务A对某条记录进行写操作的同时事务B也对该记录进行写操作)情况下会发生脏写问题,任何一种隔离级别都不允许脏写的发生。为了避免脏写,多个并发事务间的写写操作只能串行不能并发,而要使得并发事务的写写操作串行就要通过加锁实现。
锁本质上是内存中的一个结构或者对象,对一个写操作加锁本质上是让一个锁对某个事务的一条DML语句涉及到的记录进行关联。
mysql中一个锁的基本属性有:trx信息(表示该锁和哪个事务关联) 和 is_waiting(当前事务是否在等待)。
下图描绘了两个并发事务同时修改一条记录时的锁情况:
事务T1修改行1时,系统为其生成锁1并关联T1和行1,加锁成功;
事务T2修改行1,系统为其生成另一个锁2尝试关联行1,但是由于行1已被锁1关联,因此T2需要等待,is_waiting置为true。
下文中所说的加锁失败,或者获取锁失败是指生成锁结构成功,而 is_waiting被置为true。
当T1事务提交时,锁1被释放(注意,是事务提交时才释放锁,而不是执行完写操作就释放锁),系统再将锁2的is_waiting置为false,并唤醒处理事务T2的线程。
读写和写读
读写/写读((事务A对某条记录进行读操作的同时事务B对该记录进行写操作))可能会出现 脏读、不可重复读和幻读这3种不一致性读。可以使用两种方案避免这3种问题:
1、对读操作使用MVCC,对写操作加锁
事务A读记录时无需上锁,直接使用MVCC进行快照读,读到对本事务可见的历史版本行记录,事务B对该记录的写操作则是针对记录的最新版本进行修改,二者不会冲突,因此读写同一数据可以并发。
思考:为什么这样可以解决不一致读问题?
因为MVCC保证读已提交级别的事务每次select时不会读到未提交事务的更改,因此避免了脏读。MVCC保证可重复读级别的事务不会在第二次读取时读到自它第一次select后其他未提交事务的更改,因此避免了不可重复读和幻读。
而这都归功于并发事务间的读写是作用在某条数据版本链的不同版本上。
使用MVCC的读称为一致性读、无锁读或者快照读。
2、读写操作都加锁
对于一些不允许读取记录的旧版本的场景,只能对读写都加锁。例如减库存,需要应用程序先读后写(事务里包含一条select 和一条 update),如果两个事务并发,T1和T2同时快照读到库存 stock = 1,并在应用程序修改为0,写入数据库,最终数据库内库存 stock = 0,却产生了2笔订单,很明显这就出现了一致性问题。正确的情况是对读加一个排他锁,使T1和T2不能并发select,只能串行select。
总结:MVCC下的读写可并发,性能更高;加锁的读写只能串行,性能低,但某些业务场景需要。
读读
读读操作(事务A对某条记录进行读操作的同时事务B也对该记录进行读操作)不会发生脏读、不可重复读和幻读,因此无需加锁。
二、Innodb中的锁
Innodb的意向锁
锁根据场景分为 读锁(又称为共享锁,S锁) 和 写锁(又称为排他锁,X锁),根据粒度分为行锁和表锁。
Innodb支持使用行锁和表锁,因此Innodb是一种支持多粒度锁的数据库引擎。下面我把“对行加一个X锁”简称为加“行X锁”,“对表加一个X锁”简称为加“表X锁”,其他同理。
当Innodb在上行X锁时会对整个表加一个表级的意向写锁(IX),在上行S锁时会对整个表加一个表级的意向读锁(IS)。
这里引出了意向锁这个概念。意向锁本身是一种表锁,而且是一种不会和行级锁冲突的表锁。Innodb中,意向锁和行锁可以共存。
在innodb中,当mysql要对数据加行锁的时候会先对整个表加一个意向锁,之后才往对应的行加行锁。此时这个表既加了行锁又加了表锁,所以叫做行锁和表锁共存。(意向锁是mysql自动加的,无需我们手动加。)
意向锁分为
意向写锁(IX):当需要对数据加行级写锁时,mysql 会先向整个表加意向写锁。
意向读锁(IS):当需要对数据加行级读锁时,mysql 会先向整个表加意向读锁。
它的作用是什么呢?
假如Innodb需要对一个table上一个表锁(例如数据备份、alter table、drop table、 create index 之类的操作,系统会对整个表锁定,这说的表锁不是意向锁,请大家不要混淆),就必须先判断是否有某个行被上了行锁,如果有的话,说明某个或某些行正在执行读写操作,系统需要等待这个写操作完成了才能做备份之类的工作。
那么系统如何判断这个表是否被上了行锁呢?最粗暴的方法是系统需要对表的所有行进行遍历才能知道表中是否上了行锁,当然啦,遍历是不可能遍历的,这辈子都不可能遍历的。
因此意向锁就被设计了出来,如果在加行锁之前就加了意向锁,那么Innodb马上就能通过检验是否有上意向锁判断出这个表有没有上行锁。
意向锁的设计目的是为了当Innodb需要对一个表上一个表级别的S锁和X锁时,可以快速判断表是否被上行锁,以避免用遍历的方式检验是否上行锁。
下面是表级X锁、S锁 和 意向IX锁、IS锁的兼容性:
意向锁虽然是一种表锁,但和我们普通意义上说的表锁不是一回事。
意向锁和表锁的区别在于两点:
一个是兼容性不同:意向IS锁和IX锁,IX锁和IX锁之间都是兼容的,而表S锁和X锁,以及表X锁与X锁之间是不兼容的。
一个是用途不同:意向锁是在上行锁的时候加的,表锁是做一些需要锁整个表的操作时上的。
对于MyISAM、MEMORY、MERGE这些存储引擎而言,他们只支持表级锁,不支持行锁和事务。因此操作这些表的会话进行写写和写读/读写是串行的,读读并行。这就是为什么我们平时说,MyISAM引擎适合写少读多场景的原因。
Innodb的表锁
实际上除了意向锁之外,innodb也有我们普通意义上的表锁,但Innodb的表级锁非常鸡肋,基本上用不到,要用只能手动加表级锁:
lock tables t read;
lock tables t write;
Innodb的表锁不会提供什么额外保护,只会降低并发能力。
Innodb的auto-inc锁
如果innodb的某个列使用了auto_increment属性,那么插入数据时为插入的行生成自增的列值需要加锁,避免多个事务并发时生成相同的自增值。
为自增列加锁有2种方式:
一种是采用表级的auto-inc锁。当一个事务insert了一条或者多条记录时,会为这个表上一个表级的auto-inc锁,如果其他事务也执行插入语句会被阻塞(但是不阻塞select、update和delete以及指定了自增列值的insert)。
和innodb的行锁不同的是,auto-inc锁会在insert语句执行完之后就释放,而无需等到事务结束时才释放。
使用auto-inc锁的情况下,假如事务A和事务B同时发出的insert请求,他们的insert操作和生成自增值的操作都是串行的。
另一种方式是使用轻量级的锁,和auto-inc锁不同的是,系统会为插入语句生成自增值的过程加锁,而不是对整个插入过程加锁,因此轻量级锁的临界区更小。
auto-inc锁和轻量级锁带来的结果是,前者可以保证一个事务的一次insert语句产生的自增值是连续的(事务A是1/2/3,事务B是4/5/6),而后者会让事务A的一条insert语句产生的多个自增值和事务B的一条insert语句产生的多个自增值有交叉(例如 1/3/5是事务A的自增值,2/4/6是事务B的自增值)。
轻量级锁的性能更高,避免在生成自增列值这件事上锁定整个表。
三、innodb的行锁
innodb的行锁可以再分为 记录锁 record lock、间隙锁 gap lock 和 临键锁 next-key lock。
下面我们以一个例子来说明这些锁具体是作用在哪里,下图是主键索引的一个Page,number列是主键:
记录锁
记录锁仅仅是将一条记录锁上;
间隙锁
间隙锁可以对表的某一条记录加上间隙锁,但间隙锁并不会锁着这条记录,而是会锁住这条记录和上一条记录之间的空隙,使得其他事务在本事务释放该间隙锁之前不能在这个间隙之间插入数据。
如图所示就将 (3, 8)之间的间隙锁住,这是一个开区间,被锁住的不包括3和8记录本身。
gap锁(间隙锁)的目的是为了防止其他事务在上锁的区间插入幻影记录,从而避免了幻读。对某条记录上了gap锁并不影响其他事务对这条记录再上记录锁或者gap锁,也就是说gap锁可以兼容其他事务的行锁,只不过会禁止(或者说阻塞)其他事务的插入操作而已。
问题:对某条记录上gap锁是锁住这条记录和上一条记录之间的空隙,而非锁住和下一条记录之间的间隙,那要怎么禁止其他事务往 (20, +∞) 这个区间插入幻影记录呢?
答案很简单,对叶子节点最后一页的 Supremum 虚拟记录上gap锁即可。(如果不知道Supremum是什么,可以回过头来阅读一下我之前的文章:Mysql系列(三)InnoDB存储结构之行结构和页结构)。
临键锁
对表的某一条记录加上临键锁,会锁着这条记录 和 这条记录与上一条记录之间的空隙。
隐式锁
前面说的锁都需要在内存中生成一个锁结构,而隐式锁是一个内存中不存在的锁,其本质是事务A在修改事务Binsert生成的记录时的延迟加锁。
首先告诉大家,一个事务B在插入一个记录时是不会加锁锁住这个间隙或者锁住任何行的。也就是说事务B生成的新记录M就没有任何锁保护的,如果B没提交,事务A对M进行一个当前读(加读锁或写锁)不会阻塞,这就造成了幻读或者脏读(意思是A会读到记录M);或者对M进行修改或删除,就造成了脏写。
隐式锁可以解决这个问题。隐式锁的实现原理是利用了主键索引记录的trx_id列 和 二级索引页的最大事务id 这两个信息。事务A可能是通过二级索引或者主键索引作为条件找到记录M进行修改,在检测到记录M没有上锁的情况下,会检查记录M的trx_id列判断记录M是否是当前活跃的事务所创建的。
情景1:对于聚簇索引,页记录有一个trx_id。事务A查到M记录后检查它的trx_id是否属于当前活跃的事务的id。如果是,说明记录M是未提交的事务所创建,于是事务A的线程会帮事务B创建一个X锁(锁的trx信息是事务B的信息),is_waiting为false(相当于B插入记录M之后没有上锁,A修改或者当前读记录M的时候才补上这个锁)。再为事务A创建一个锁结构,is_waiting为true,并进入等待状态(等B提交事务释放锁)。
情景2:对于二级索引,页的头部记下了最新修改该页的事务id,事务A查到M记录后检查该事务id是否比当前最小的活跃事务id小,是则说明记录M的修改或创建是已提交的修改或创建,事务A不会被阻塞;否则需要回表,进入情景1的判断。
从上面的过程看出,隐式锁起到了延迟生成锁结构的作用,如果别的事务(事务A)在执行过程中不需要更改或加锁读事务B创建的记录M,就不会在内存中生成对事务B的锁,节省了一次加锁开销。
锁合并
已知一个事务对一条记录修改或当前读,会产生一个关联该事务和该记录的锁。
问题来了,如果一个事务对多条记录加锁,是不是就要创建多个锁结构(或者说锁对象)?
例如:
select * from hero lock in share mode;
如果表里有1万条数据,会产生一万个锁吗?如果真这样就太浪费内存了。
实际上,事务对多条记录上锁也可以只生成一个锁对象或者说锁结构,但需要满足下面条件:
1. 在同一个事务中进行加锁操作;
2. 被加锁的记录在同一个页面;
3. 加锁的类型相同;
4. 等待状态is_waiting相同;
锁的结构
锁所在的事务信息包括生成该锁的事务的事务id等。
我们重点关注“表锁/行锁信息”,如果是行锁,那么该属性包括这个锁所锁住的记录所在的页的页号和表空间。
type_mode是锁类型和模式,它包含3个信息:锁模式(共享锁还是排他锁,还是意向锁)、锁粒度(表锁还是行锁)、锁的具体类型(记录锁、间隙锁还是临键锁)。
一堆比特的每个比特对应一个页的每一行记录,比特位为1表示对应的行被锁住了。
举个例子看看多条记录的锁是如何写入到一个锁结构的。还是这个例子:
假如事务T1对15号记录加行锁,会生成一个这样的锁结构(我们全程忽略意向锁产生的锁结构,只关注行锁产生的锁结构),is_waiting 为 false,加锁成功:
之后事务T2对3、8、15这3条记录加X型的临键锁,T2会生成2个锁结构,其中 3、 8这两条记录的锁会放在一个is_waiting=false的锁结构,记录15的锁会放到另一个is_waiting=true的锁结构。加上T1生成的锁结构,一共就有3个锁结构了。
注意:如果T2一开始先对 记录15 加锁生成锁结构,那么T2生成锁结构后会直接进入等待状态,不再为3、 8这两条记录生成锁结构。等到T2被唤醒,对3、 8加锁时,就可以复用 记录15 的那个锁结构,变成 3、8、15复用一个锁结构。
下一节,将会介绍什么情况下会加临键锁和间隙锁,什么时候只会加记录锁,以及各种sql执行时加锁的分析。