MVCC解决了快照读和写之间的并发问题,但对于写和写之间、当前读和写之间的并发,MVCC就无能为力了,这时就需要用到锁。
InnoDB的锁,我们可以从级别和类型这两个维度来分析。
级别
- 行级锁
- 表级锁
类型
- 共享锁(S),也称为读锁, 级别:行级锁
- 意向共享锁(IS),也称为意向读锁 级别:表级锁
- 排他锁(X),也称为写锁 属于行级锁 级别:行级锁
- 意向排他锁(IX),也称为意向写锁 级别:表级锁
锁的兼容性
表锁
表锁(lock table)是MySQL中最基本也是开销最小的锁策略,它会锁定整张表。当客户端想对表进行写操作(插入,删除,更新等)时,需要先获得一个写锁,这会阻塞其他客户端对该表的读写操作,只有没有人执行写操作时,其他读取的客户端才能获得读锁,读锁之间不会相互阻塞。
表锁可以在特定情况下提高性能。例如,READ LOCAL表锁支持某些类型的并发写操作。写锁队列和读锁队列是分开的,但写锁队列的优先级绝对高于读队列。
表锁的加锁和解锁语句分别是 LOCK TABLES 和 UNLOCK TABLES,具体信息请参考
https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html
行级锁
使用行级锁(row lock)可以最大程度的支持并发处理(也带来了最大的锁开销)。拿共享电子表格为例,行锁机就等同于锁定电子表格中的某一行。这种策略允许多人同时编辑不同的行,而不会阻塞彼此。这使得服务器可以执行更多的并发写操作,带来的代价则是需要承担更多开销,以跟踪谁拥有这些行级锁,已经锁定了多长时间,行级锁的类型,以及合适该清理不再需要的行级锁。
行级锁是在存储引擎而不是服务器中完成的,服务器通常不清楚存储引擎中锁的实现。
读锁
- 允许持有锁的事务读取行
- 假如有一个a事务获取了x行的读锁,这时候b事务也来请求x行的锁,那么会进行以下处理
- 如果b请求的是x行的读锁,那么会立刻授予,这时候a事务和b事务都拥有x行的读锁
- 如果b请求的是x行的写锁,那么不会立刻授予,因为读锁和写锁是不兼容的
写锁
- 允许持有锁的事务更新或删除行。
- 假如有一个a事务获取了x行的写锁,这时候b事务也来请求x行的锁,这时候不管b事务请求的锁是读锁还是写锁,都不能立即授予,只能等到a事务释放了在x行的写锁才能授予b事务,因为写锁与任何的锁都不兼容
意向锁
有了读锁和写锁,为什么还会有“意向锁”呢?那么我们举一个例子来说明下:
假设:
- 事务A对表C中的某一行记录加了写锁(行锁)。
- 事务B想对表C加表级写锁(排他锁)。
假设事务A给C表中的某一行记录加了一行写锁,现在事务B要给C表加表写锁。显然事务B加锁不会成功,因为C表中的某一行正在被A修改。但事务B怎么知道C表其中有一行在修改呢?
显然,事务B无法立即加锁,因为表C中的某一行已被事务A锁定。然而,事务B如何知道表中存在被加锁的行呢?
传统(笨方法):事务B遍历表C的每一行,检查是否有行锁存在。如果发现某一行加了写锁(事务A加的锁),事务B需要等待事务A释放锁。这种方法效率非常低,尤其是在表记录较多的情况下,遍历会导致性能严重下降。
为了解决这一问题,MySQL引入了意向锁(Intent Lock),用以快速检测锁冲突,避免逐行扫描。
事务A加行写锁时:
- 除了给目标行加写锁外,MySQL还会在表C上加一个意向排他锁(IX)。
- 意向锁表示:“表中部分行被加锁,具体是排他锁(写锁)。”
事务B加表级写锁时:
- 首先检查表C是否已有锁。
- 因为表上存在意向排他锁(IX),MySQL立即得知“表中有行被加锁”,无需遍历整个表逐行检查。
根据锁兼容性规则(表级排他锁与意向排他锁冲突),事务B会等待事务A释放锁。
InnoDB存储引擎为了支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB存储引擎支持一种额外的锁方式,称为意向锁。
意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度上进行加锁。如下图所示:
若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁,如图中所示,如果需要对页中的记录r加写锁,那么分别需要对数据库A,表,页加意向写锁,最后才对记录r加写锁。若其中一个任何一个部分导致等待,那么该操作需要等待粗粒度锁的完成。
有了意向锁,事务 B 要给整张表加写锁,就不用遍历所有记录了。只要看一下这张表有没有被其他事务加意向写锁或者意向读锁就能做出判断。
意向锁是表级锁,设计目的主要是为了在下一个事务中揭示下一行将被请求的锁类型,由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫描以外的任何请求。
意向锁案例:
mysql> show create table student;
±——–±—————————————–+
| Table | Create Table |
±——–±—————————————–+
| student | CREATE TABLE student (
id int(11) NOT NULL AUTO_INCREMENT,
student_num int(11) NOT NULL DEFAULT ‘0’ COMMENT ‘学号’,
name varchar(32) NOT NULL DEFAULT ‘’ COMMENT ‘学生姓名’,
PRIMARY KEY (id),
UNIQUE KEY uqidx_student_num (student_num)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 |
±——–±—————————————–+
1 row in set (0.00 sec)
mysql> select * from student;
±—±————±———+
| id | student_num | name |
±—±————±———+
| 1 | 1 | zhangsan |
| 2 | 2 | lisi |
| 3 | 3 | wangwu |
| 4 | 4 | zhaoliu |
| 5 | 5 | liqi |
±—±————±———+
5 rows in set (0.00 sec)
客户端A:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from student where student_num=4 for update;
±—±————±——–+
| id | student_num | name |
±—±————±——–+
| 4 | 4 | zhaoliu |
±—±————±——–+
1 row in set (0.00 sec)
客户端B:
mysql> LOCK TABLE student write;
这时候我们发现客户端B一直在等待,因为客户端B想获取student整个表的排他锁,如果客户端B申请成功了, 它是可以修改student表的任意一行的。
但这个时候客户端A已经获取了student_num=4的写锁,那么客户端B肯定是会阻塞的,也就是客户端B检查到了student表被其他的事务加了意向写锁了。
注意:
对于insert、update、delete,InnoDB会自动给涉及的数据加写锁;对于一般的 select 语句,InnoDB 不会加任何锁。
可能有人会有疑问,什么是一般的select,什么是特殊的select呢?
一般的select就是:
select column from table where x=1
特殊的select就是:
select column from table where x=1 lock in share mode
select column from table where x=1 for udpate
lock in share mode 加读锁,for update加写锁
行锁的算法
- Record Lock
- Gap Lock
- Next-Key Lock
Record Lock
记录锁是索引记录上的锁,例如SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;防止任何其他事务插入,更新或删除t.c1的值为10的行
记录锁始终锁定索引记录,如果一个表没有定义任何的索引,像这种情况,innodb会创建一个隐藏的聚簇索引并且使用此索引进行记录锁定
需要注意的是:
innodb的记录锁是针对索引加锁,不是针对物理记录加锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,将出现锁冲突
Gap Lock
间隙锁是锁定索引记录之间的间隙,也就是说Gap Lock 是针对记录前面和后面的间隙都上锁
例如 SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE。由于范围内所有现有值之间的间隔都被锁定,因此可以防止其他事务向列t.c1中插入值15,无论该列中是否已有任何此类值。
对于使用唯一索引锁定行的语句,不需要使用间隙锁(这不包括搜索条件仅包括多列唯一索引的一些列的情况; 在这种情况下,确实会出现间隙锁)。
例如id列是一个唯一索引,那么下面的语句只会在id=100的那行上面加一个索引记录锁,而不会关心别的会话(session)是否在前面的间隙中插入数据
SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE;
如果id没有索引或者没有唯一索引,则该语句将锁定前述的间隙
间隙锁在不同的隔离级别下,有着不同的作用范围,能发挥间隙锁作用的,是’REPEATTABLE READ’隔离级别。在这个级别下使用带有间隙锁的Next-Key锁,解决了幻读的问题。
Next-Key Lock
它是一种组合锁,它包含:
- 索引记录锁(Record Lock):锁住目标的索引记录,防止其他事务修改或删除。
- 前后间隙的间隙锁(Gap Lock):锁住目标记录与其他记录之间的间隙,防止其他事务在这些间隙中插入新记录。
这个机制确保了查询范围内的数据一致性,并且防止了其他事务在锁定范围内插入新记录。
三者的区别
锁类型 | 锁定范围 | 使用场景 |
---|---|---|
Record Lock | 仅锁定索引记录本身 | 针对单条记录的操作,如更新或删除。 |
Gap Lock | 锁定索引记录之间的间隙 | 防止幻读;只在 RR 隔离级别下使用。 |
Next-Key Lock | 锁定索引记录及其前后的间隙 | 防止幻读,同时保护查询范围的一致性。 |
行锁案例
mysql> create table goods(
-> id int not null auto_increment primary key,
-> title varchar(32) not null default '' comment '商品名称',
-> classify tinyint not null default 0 comment '商品类型',
-> index `idx_classify` (`classify`)
-> )engine=innodb charset=utf8;
mysql> insert into goods (title,classify) values (‘商品1’,1),(‘商品2’,3),(‘商品3’,5),(‘商品4’,8),(‘商品5’,10),(‘商品6’,1),(‘商品7’,3),(‘商品8’,5),(‘商品9’,8),(‘商品10’,10);
mysql> select * from goods;
±—±———±———+
| id | title | classify |
±—±———±———+
| 1 | 商品1 | 1 |
| 2 | 商品2 | 3 |
| 3 | 商品3 | 5 |
| 4 | 商品4 | 8 |
| 5 | 商品5 | 10 |
| 6 | 商品6 | 1 |
| 7 | 商品7 | 3 |
| 8 | 商品8 | 5 |
| 9 | 商品9 | 8 |
| 10 | 商品10 | 10 |
±—±———±———+
10 rows in set (0.00 sec)
mysql> select distinct(classify) from goods;
±———+
| classify |
±———+
| 1 |
| 3 |
| 5 |
| 8 |
| 10 |
±———+
5 rows in set (0.02 sec)
在REPEATTABLE READ隔离级别下,InnoDB对于行的查询都是采用了Next-Key Lock的算法,锁定的不是单个值,而是一个范围(GAP)。上面索引值有1,3,5,8,10,其记录的GAP的区间如下:是一个左开右闭的空间
(-∞,1) (1,3) (3,5) (5,8) (8,10) (10,∞)
客户端A:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from goods where classify=3 for update;
±—±——–±———+
| id | title | classify |
±—±——–±———+
| 2 | 商品2 | 3 |
| 7 | 商品7 | 3 |
±—±——–±———+
2 rows in set (0.00 sec)
对于辅助索引,其加的是Next-Key锁,锁定的范围是(1,3)。
特别需要注意的是,在REPEATTABLE READ隔离级别下,innodb为了解决幻读问题还会对辅助索引下一个键值加上gap lock,也就是会针对(3,5)加锁,也就是1-5之间的值的时候都会被锁定。
客户端B:
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into goods (title,classify) select ‘商品11’,4;
#这时候可以看到客户端B给阻塞了,因为客户端A的Next-Key的锁定范围是(1,3),(3,5),正好包含了4
mysql> insert into goods (title,classify) select ‘商品11’,6;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0
插入classify=6的就立刻成功了, 因为6不在(1,3),(3,5)的范围内
参考资料
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
《高性能MySQL》
《MySQL技术内幕 innoDB存储引擎》
《数据库事务处理的艺术 事务管理与并发控制》
《软件架构设计 大型网站技术架构与业务架构融合之道》