前言
上一篇博客【Mysqls深度讲解 – 事务】详细说的了Mysql中的事务,以及多版本并发控制的实现方式,基本上多版本并发控制是为了提高数据库的并发效率而设置的。有了多版本并发控制的保证,Mysql在读数据的过程中就不需要一直对着某个资源进行加锁操作,依然可以获取数据保证事务正常进行。读数据可以使用多版本并发控制,但是写数据包括串行化 (Serializable)隔离级别就不能这么做了,这种情况下只能使用锁。因此锁依然是Mysql中必不可少的组成部分,本篇就详细讲一下Mysql中的锁。更多Mysql调优内容请点击【Mysql优化-深度讲解系列目录】。
读锁与写锁
锁粗分为读锁与写锁。当对某一个资源加了读锁,那么别人能够读这一资源,但是不可以进行写。反之当对某一个资源加了写锁,那么别人既不能写,也不可以读,只能够加锁的人去读写。因此读锁也叫做共享锁、Shared Locks、S锁。写锁就被称为排他锁、Exclusive Locks、X锁。简单来说:
- 读锁:自己可以读写,他人可读不可写。
- 写锁:自己可以读写,他人不可读写。
- Select:不加锁,一般的select语句不受锁的影响。
他们的关系如下表所示:
X锁 | S锁 | |
---|---|---|
X锁 | 冲突 | 冲突 |
S锁 | 冲突 | 不冲突 |
读操作
对于普通 Select 语句,InnoDB 不会加任何锁,也就谈不上读写锁的冲突,因此普通的Select语句和锁没有任何关系。只有在针对某些数据显示加锁的情况下才会有冲突一说。怎么才能加锁呢?只要给Select语句后面添加相应的关键字即可。
# 给Select加读锁
Select … lock in share mode;
将查找到的数据加上一个S锁,此操作允许其他事务继续给这些记录添加新的S锁,但是不能给这些记录添加新的X锁(会阻塞)。一般用于:读取数据后,其他事务不能修改的情况。但是由于S锁互相不冲突,因此其他事务有可能也给该数据加了S锁,所以自己也不一定能修改。
# 给Select加写锁
Select … for update;
将查找到的数据加上一个X锁,不允许其他事务对这些记录添加新的S锁和X锁。一般用于:读取数据后,其他事务不能读也不能写,不能加新的写锁和读锁,只有自己能够操作数据。注:即便是加了锁依然挡不住Select语句,因为此语句不加锁。比如Session1执行了语句 select * from t1 where a=1 for update
,对a=1
这行数据加了写锁,那么其他SessionX就不可以再对该行数据加写锁或者读锁,也不可以对该行进行增删改操作。但是直接执行select * from t1 where a=1
可以查出记录的,普通的Select不加锁这点要好好体会下。
写操作
所谓的写操作就是对数据进行修改的操作,包括插入Insert,修改Update,删除Delect这些操作数据的内容动作。正是因为这些操作可以修改数据,因此Mysql在执行这些操作的时候会加上一些锁。其中插入操作比较特殊,因为数据在插入之前还没有内容,无法进行正常的加锁,因此这里会被Mysql加上一把隐式锁。当事务插入一条数据以后还没有进行提交时,Mysql会保存这条数据的当前事务id然后加一把X锁,从而阻止其他事务来修改/查看本条数据,这种没有显示声明语句的锁,就是隐式锁。
- DELECT:删除一条数据时,先对记录加X锁,再执行删除操作。
- INSERT:插入一条记录时,会先加隐式锁(X锁)来保护这条新插入的记录在本事务提交前不被别的事务访问到。
- UPDATE:如果被更新的列,修改前后没有导致存储空间变化,那么会先给记录加X锁,再直接 对记录进行修改。如果被更新的列,修改前后导致存储空间发生了变化,那么会先给记录加X锁,然后将记录删掉,再Insert一条新记录。
这里可以在Mysql中做一个测试:开启两个Session,Session1对某一行执行update语句,那么Session2的所有加锁操作和修改操作都会停止处于Wait状态,直到Session1提交当前事务。
行锁
从概念上说就是锁住了表中的某一行,但是Mysql中分的更细致,一般指下面三种:
- 记录锁(LOCK_REC_NOT_GAP):加在某一条记录上的锁。
- 间隙锁(LOCK_GAP):记录之间的锁,加锁后锁住的不是一行数据,而是一行数据上下的两个的间隙,因此叫间隙锁。由于锁住的是间隙,因此本行可以更新数据,但是本行上下不可以插入数据。用于解决幻读。
- 间隙记录锁(LOCK_ORDINARY):不仅仅能够锁一行数据,而且这一行上下的间隙也会被锁住。用于解决幻读。
行锁的表现
我们并不能直接操作行锁,因为行锁是Mysql内部用于实现不同隔离性的方式。但是我们可以通过使用不同的隔离性,去验证Mysql中行锁的实现方式。示例表如下,其中有主键索引PRIMARY,有辅助联合索引bcd:
a(primary key,int) | b(int) | c(int) | d(int) | e(varchar) |
---|---|---|---|---|
1 | 6 | 2 | 1 | ‘1’ |
3 | 3 | 3 | 1 | ‘3’ |
4 | 2 | 1 | 1 | ‘4’ |
6 | 4 | 5 | 1 | ‘5’ |
7 | 9 | 3 | 1 | ‘6’ |
9 | 1 | 1 | 1 | ‘7’ |
12 | 1 | 2 | 1 | ‘8’ |
15 | 2 | 2 | 1 | ‘9’ |
行锁在已提交读Read Uncommit下的表现:
主键索引上加锁
Session1:对a=1这条记录加锁。
select * from t1 where a = 1 for update; -- 加x锁
Session2:尝试获取锁
select * from t1 where a = 1 for update; -- 阻塞
select * from t1 where a = 3 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(2,2,2,2,'b'); -- 不阻塞
select * from t1 for update; -- 阻塞
总结:也就是说查询使用的是主键时,只需要在主键值对应的拿一条数据加锁即可。同理如果使用的是select * from t1 for update
就会对整个表所有的行加锁。由于insert
语句中a=2
可以执行,记录a=1
的间隙并没有被锁住,那么也就是说此时加的锁只是记录锁(LOCK_REC_NOT_GAP
)。
辅助索引上加锁
Session1:对b=1这个条件的记录加锁,其中b数据联合索引bcd中的一个。
select * from t1 where b = 1 for update; -- 加x锁到a=9和a=12两行
Session2:尝试获取锁
select * from t1 where b = 1 for update; -- 阻塞
select * from t1 where b = 2 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(2,2,2,2,'b'); -- 不阻塞 插入b=2成功
select * from t1 where a=9 for update; -- 阻塞
select * from t1 where a=7 for update; -- 不阻塞 结果7,9,3,1,6
select * from t1 where d=3 for update; -- 阻塞
select * from t1 where b = 9 for update; -- 不阻塞 结果7,9,3,1,6
select * from t1 where c=3 for update; -- 阻塞
select * from t1 for update; -- 阻塞
总结:根据查询结果看,本次加锁会对符合该索引的b=1
的值全部加锁。此外对所有该索引其他的所有字段加锁,比如b=9
时可以查出,但是单独查询d=3
或者c=3
都会阻塞,因此c
、d
都无法所谓单独的查询条件查询。而a=9
也会被阻塞说明:查询使用的是辅助索引时,会对满足条件的索引记录都加上锁,同时对该索引记录以外的所有字段加锁,也会对这些索引记录对应的主键索引上的项也加锁。但是Insert
到加锁附近的行依然可以执行,所以仍然是记录锁(LOCK_REC_NOT_GAP
)。
单一索引上加锁:对 e列 创建索引
Session1:对e > ‘6‘这个条件的记录加锁,e='7'、'8'、'9'将会被加锁
select * from t1 where e > ‘6’ for update; -- 加x锁
Session2:尝试获取锁
select * from t1 where a=3 for update; -- 阻塞
select * from t1 where a=4 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(2,2,2,2,'71'); -- 不阻塞
select * from t1 where e = '8' for update; -- 不阻塞
总结:查询使用的是单一索引时,只对查询行所对应的唯一索引记录项和对应的主键索引上的项加锁。不仅能够对 e列 的其数据进行查找,而且能够在该条件行后插入数据,所以是记录锁(LOCK_REC_NOT_GAP
)。
全表扫描加锁:即查询时不使用索引,将 e列 索引删除。
Session1:对e = '3'这个条件的记录加锁。
select * from t1 where e = '3' for update; -- 加x锁到 3, 3, 3, 1, '3'这行
Session2:尝试获取锁
select * from t1 where a = 1 for update; -- 不阻塞
select * from t1 where a = 3 for update; -- 阻塞
select * from t1 where e = '5' for update; -- 阻塞
insert t1(a,b,c,d,e) values(222,2,2,2,'31'); -- 不阻塞
update t1 set e='7' where a=222; -- 不阻塞
select * from t1 where b = 1 for update; -- 不阻塞
总结:当进行全表扫描时,本次加锁会对所有e='3'
的条件行加锁,除此造成整个 e列 无法作为条件使用。因为当 e列 中的某一个值被锁住时,无法进行对比,必须等待释放锁才能够作为一个sql中的条件。但是对于非 e列 为条件的其他操作仍然可以进行,比如修改。而且由于Insert
语句中e='31'
是可以执行的,所以是记录锁(LOCK_REC_NOT_GAP
)。
行锁在重复读Repeatable Read下的表现
主键索引上加锁
Session1:对a=1这条记录加锁。
select * from t1 where a = 1 for update; -- 加x锁
Session2:尝试获取锁
select * from t1 where a = 1 for update; -- 阻塞
select * from t1 where a = 3 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(2,2,2,2,'b'); -- 不阻塞
select * from t1 for update; -- 阻塞
总结:同上。
辅助索引上加锁
Session1:对b=1这个条件的记录加锁,其中b数据联合索引bcd中的一个。
select * from t1 where b = 1 for update; -- 加x锁到a=9和a=12两行
Session2:尝试获取锁
select * from t1 where b = 1 for update; -- 阻塞
select * from t1 where b = 2 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(2,2,2,2,'b'); -- 不阻塞 插入b=2成功
select * from t1 where a = 9 for update; -- 阻塞
select * from t1 where a = 7 for update; -- 不阻塞 结果7,9,3,1,6
select * from t1 where d = 3 for update; -- 阻塞
select * from t1 where b = 9 for update; -- 不阻塞 结果7,9,3,1,6
select * from t1 where c = 3 for update; -- 阻塞
select * from t1 for update; -- 阻塞
总结:同上
单一索引上加锁:对e列创建索引
Session1:对e > '6'这个条件的记录加锁,e=‘7‘、’8‘、’9‘将会被加锁
select * from t1 where e > '6' for update; -- 加锁
Session2:尝试获取锁
select * from t1 where a = 3 for update; -- 阻塞
select * from t1 where a = 7 for update; -- 阻塞
select * from t1 where a = 9 for update; -- 不阻塞
insert t1(a,b,c,d,e) values(22,2,2,2,'51'); -- 阻塞
insert t1(a,b,c,d,e) values(22,2,2,2,'61'); -- 阻塞
总结:当对e = '6'
进行加锁的时候,我们发现不仅被加锁行加锁了,而且当时图向被加锁行的前后两个位置插入新的数据的时候也一样被阻塞了,这就说明这个条件下使用的是间隙锁(LOCK_GAP
)。当锁住e='6'
条件的数据时,满足其条件的所有行的上下间隙都会被锁住,那么就意味着其他事务无法对该行的数据进行更新。因此select * from t1 where e > '6'
将永远查询出来相同的数据,从而解决了幻读的问题。所以可以说REPEATABLE READ
级别可以解决幻读,就是因为加了GAP
锁。
全表扫描加锁:即查询时不使用索引,将 e列 索引删除。
Session1:对e = '6'这个条件的记录加锁,加锁3,3,3,1,’6’和7,9,3,1,’6’两行数据
select * from t1 where e = '6' for update;
Session2:尝试获取锁
select * from t1 where a = 1 for update; -- 阻塞
select * from t1 where a = 3 for update; -- 阻塞
select * from t1 where a = 9 for update; -- 阻塞
select * from t1 where a = 12 for update; -- 阻塞
insert t1(a,b,c,d,e) values(22,2,2,2,'61'); -- 阻塞
总结:在之前的读可提交下,只会对查出来的e='3'
这些数据加锁。但是在可重复读的情况下发现所有的数据和间隙都被加锁了。这是因为如果继续允许读出来a=1
这种条件的数据,很可能a=1
的值会被修改,那么就会出现幻读,可重复读就无法实现了。根据其表现来看,这里把整个表的数据和间隙都枷锁,只有间隙记录锁(LOCK_ORDINARY
)能做到这点。因为这里测试的是全表扫描,对于 e列 没有任何约束,对于整表其他的数据可以任意修改,因此全表扫描不同于使用索引解决幻读的方法,而对整个表的数据和间隙进行了加锁。
表锁
表锁一样分为S锁和X锁,在对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,InnoDB存储引擎并不会为这个表添加表级别的锁。在对某个表执行ALTER TABLE
、DROP TABLE
这些DDL语句时,其他事务对这个表执行SELECT
、INSERT
、 DELETE
、UPDATE
的语句会发生阻塞。或者某个事务对某个表执行SELECT
、INSERT
、DELETE
、UPDATE
语句时,其他事务对这个表执行DDL语句也会发生阻塞。这个过程是通过使用的元数据锁Metadata Locks(MDL)
来实现的,并不是使用的表级别的S锁和X锁。元数据锁是表创建时候加的锁,是最原始的一把锁,对表的修改会加上这种锁。Mysql依然支持这两个语法如下,但是尽量不用这两种方式去加锁,因为InnoDB的优点就是行锁,所以尽量使用行锁,性能更高。
LOCK TABLES t1 READ;
LOCK TABLES t1 WRITE;
IS锁和IX锁
IS、IX锁也是表级锁,它们的提出仅仅为了在之后加表级别的 S锁
和 X锁
时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录。举个例子来说,如果表中有一行数据已经被加了 X锁
,而后又有一个 事务2
想要对这个表也加上一个X锁,肯定是要冲突的。但是 事务2
在加 X锁
之前是不知道里面已经有了一个 X锁
的行,所以要想直到能不能对表 X锁 ,就只好把整个表遍历,看看是不是所有的行都没有 X锁
,这样效率无疑很低。Mysql就给了这样的一个办法去节省遍历的步骤,当数据行被加了X锁,那么就直接给表加一个 IX锁
,表示表里面有数据行被加了 X锁
,那么其他加表锁的事务来了直接就能看到表被加了 IX锁
,于是原地等地释放 IX锁
就可以了,而不需要遍历整个表。IS锁
也是同理,也是帮助判断数据行是否加了 S锁
用的。注,意向锁之间是不冲突的,可以加多个,也可以混合加。
- IS锁:意向共享锁、Intention Shared Lock。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
- IX锁:意向排他锁、Intention Exclusive Lock。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
AUTO – INC锁
自增锁,我们都知道表中一般是有自增变量的,既然是唯一递增的就一定会有一个基数,那么这个自增锁(Auto-Inc
),就是为这个基数准备的。类比于Java中变量a=0
,如果不加锁并发时执行a++
就无法保证a
变量数据的正确性。一般来说有两种实现方式:
- 在执行插入语句时就在表级别加一个
AUTO-INC
锁,然后为每条待插入记录的AUTO_INCREMENT
修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC
锁释放掉。这样一个事务在持有AUTO-INC
锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。 - 采用一个轻量级的锁,在为插入语句生成
AUTO_INCREMENT
修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT
列的值之后,就把该轻量级锁释放掉, 并不需要等到整个插入语句执行完才释放锁。
查询这个数值的系统变量为innodb_autoinc_lock_mode
,Mysql默认值为2,即轻量级锁:
- innodb_autoinc_lock_mode值为0:采用AUTO-INC锁。
- innodb_autoinc_lock_mode值为2:采用轻量级锁。
- 当innodb_autoinc_lock_mode值为1:当插入记录数不确定是采用AUTO-INC锁,当插入记录数确定时采用轻量级锁。
悲观锁与乐观锁
这两个锁都是一种概念,并不是实实在在的锁。悲观锁:在Mysql中用的就是数据库的行锁,认为数据库会发生并发冲突,直接上来就把数据锁住,其他事务不能修改,直至提交了当前事务,我们之前说的锁全部都是悲观锁。乐观锁这种概念是指:某个事务会在不锁定的情况下去更新数据,如果发现异常(比如更新前后,两次查出来的版本不一致),才不更新(回滚),因此也可以说乐观锁根本就不是锁,只是一种行为,这种行为的控制往往是在数据库添加一个version字段来实现。
死锁
Mysql中的死锁和Java中的概念类似,比如:
Session1:select * from t1 where a =1 for update; -- a =1加锁
Session2:select * from t1 where a =4 for update; -- a =4加锁
Session1:update t1 set b = 1 where a = 4; -- 此时必须等待Session2对a=4的锁释放
Session2:update t1 set b = 1 where a = 1; -- 此时必须等待Session1对a=1的锁释放
Session1和Session2互相等待就形成了死锁,但是Mysql有死锁检查的机制,让死锁中断。当Mysql发现死锁的时候,InnoDB会在导致死锁的事务中选择一个权重比较小的事务执行回滚,这个权重可能是该事务影响的行数(增删查)决定的。可以用show engine innodb status来查看最近的死锁日志,在任何情况下都要尽量避免死锁:
- 以固定的顺序访问表和行
- 大事务拆小,大事务更容易产生死锁
- 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率
- 为表添加合理的索引
- 降低隔离级别
死锁相关的系统变量:
名字 | 默认值 | 说明 |
---|---|---|
innodb_deadlock_detect | ON | 是否打开死锁检测 |
innodb_print_all_deadlocks | OFF | 是否将所有死锁日志写入Mysql错误日志 |
innodb_lock_wait_timeout | 50 | 锁的等待超时时间,50s |
总结
本篇博客在介绍完Mysql锁和其加锁原理以后,希望大家在以后写sql的时候可以思考一下当前的sql会加什么样的锁。对于select不加锁,对于update一类会对where条件的行加上写锁,那么另外的更新事务可能无法成功执行,或者插入事务可能无法插入数据等等情况,能够做一个预判,来提高sql语句的性能。