MySQL锁
对数据库的操作有读、写,组合起来就有 读读、读写、写读,写写,读读不存在安全问题,安全问题加锁都可以解决,但所有的操作都加锁太重了,只有写写必须要加锁,读写、写读可以用MVCC。 MySQL的默认隔离级别是RR,但是RR在MVCC的加持下还是存在幻读,这时候还是要加锁,间隙锁就是用来在RR级别下解决幻读的问题.
一、共享锁、排他锁
共享锁(shared lock)也叫读锁、S锁,数据被某个事务获取了共享锁后,还可以被其它事务获取共享锁,但是不能被其它事务获取排他锁。
排他锁(exclusive Lock)也叫写锁、X锁,数据被某个事务获取之后就不能被其它事务获取任何锁。
总结: 共享锁和共享锁之间不冲突,排他锁和共享、排他锁都冲突。 默认select是不加锁的,更新、添加、删除的时候会加排他锁。
强制加共享锁 lock in share mode, 强制加排他锁 for update
select * from my_table where id=1 lock in share mode; -- 共享锁
select * from my_table where id=1 for update; -- 排他锁
二、意向锁
意向共享锁、意向排他锁
加锁的时候可能锁某一行或几行的数据,也可能锁整个表,但共享锁只能和共享锁兼容,排他锁不兼容其它锁。 虽然锁表的场景很少,如果要用共享锁锁表不得一行行去遍历看看数据有没有被排他锁锁过,如果要用排他锁锁表就得一行行去遍历看是否存在数据被共享锁或排他锁占用。
但实际上不可能遍历,数据量一多,遍历的代价就很大,所以在对某些数据加共享锁的时候,就会给表加上意向共享锁, 对某些数据加排他锁的时候就会对表加上意向排他锁。
这样再对表加锁的时候只需要判断是否有对应的意向锁就好了, 不需要遍历, 意向锁之间不冲突。
共享锁、排他锁、意向共享锁、意向排他锁的兼容关系 ✓兼容 x不兼容
排他锁 | 意向排他锁 | 共享锁 | 排他锁 | |
---|---|---|---|---|
排他锁 | x | x | x | x |
意向排他锁 | x | ✓ | x | ✓ |
共享锁 | x | x | ✓ | ✓ |
意向共享锁 | x | ✓ | ✓ | ✓ |
三、记录锁、间隙锁、临键锁
前置
上面说了一些锁的概念,尝试下给数据加锁,
1. 在主键索引形成的B+TREE里, 非叶子节点存储的是主键索引,叶子节点存储的是数据
2. 在非主键索引形成的b+tree里面,非叶子节点存储的是当前索引,叶子节点存储的是主键索引
3. 如果没有主键索引, 会拿第一个唯一索引来做聚簇索引,如果没有唯一索引就会创建一个看不见的唯一键。
4. 当通过非主键索引去找数据时,是先通过非主键索引去找主键索引,再通过主键索引去找数据, 这个过程成为回表。
锁理论:
记录锁(Record Locks): 记录锁是基于索引记录上的锁,它锁定的行数是固定的、明确的,根据情况它可以是共享锁、排他锁。
间隙锁(Gap Locks):间隙锁的目的是在RR级别下,防止幻读,幻读的产生是当前事务多次的查询结果数量上不一致,间隙锁的目的就是保证当前范围内的数据不会被更改,所以它会锁住某些个区间的数据。
临键锁(Nex-key Locks):等于 记录锁+间隙锁, 我们只需要知道这两个所的定义就好了,Mysql默认级别是RR、默认锁上临键锁。
锁总结:
锁都是基于索引去找到数据记录再加锁的,而索引的规则是: 通过其它索引找到主键索引,所以:
1. 没有使用索引做更新相关操作会锁表。
2. 通过唯一/主键索引等值加锁,只会锁具体的行,非唯一索引则不一定,SQL优化器会基于数据分布选择记录锁,或临建锁。
3. 只有在RR级别下才有间隙锁,目的是为了解决幻读, 如果操作的数据是跨多个范围,就会加多个区间的间隙锁。
4. MySQL默认的锁就是【临键锁】,所以在执行SQL时候,记录锁和间隙锁是会同时存在的。范围是左开右闭的区间
在SQL查询时,我们知道是先通过索引去找数据的,起始加锁也是基于索引的,通过索引找到对应的数据,然后把相关的数据行都锁上,如果没有使用索引就会扫描全表,也就会锁表。
锁实践
基于上面的理论和总结,我们实际用SQL来证明下,加深印象。
记录锁:
创建一个表, 并往里塞一些数据,然后来验证
CREATE TABLE `xdx_lock` (
`id` int NOT NULL AUTO_INCREMENT,
`country` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `country_idx` (`country`) USING BTREE,
KEY `name_idx` (`name`) USING BTREE
) ENGINE=InnoDB;
insert into `hall`.`xdx_lock` ( `country`, `name`, `age`) values ( 'java', '张三', '19');
insert into `hall`.`xdx_lock` ( `country`, `name`, `age`) values ( 'c', '李四', '20');
insert into `hall`.`xdx_lock` ( `country`, `name`, `age`) values ( 'lua', '王五', '35');
insert into `hall`.`xdx_lock` ( `country`, `name`, `age`) values ( 'c++', '顺六', '33');
insert into `hall`.`xdx_lock` ( `country`, `name`, `age`) values ( 'js', '田七', '39');
开启一个终端 - 终端1:
BEGIN;
select * from xdx_lock where id = 4 lock in share mode;
开启另一个终端-终端2: (开启两个终端是为了确保操作不在同一个线程)
select * from xdx_lock where country='c++' lock in share mode -- 是能正常查出结果的, 代表其它线程获取共享锁是可以的
select * from xdx_lock where country='c++' for update -- 这个会阻塞 因为排他锁跟共享锁是不兼容的!
锁表案例:
终端1:
Begin;
Select * from xdx_lock where age=19 lock in share mode; -- 因为 字段age 不是索引, 因此会锁表
终端2:
Select * from xdx_lock where country = ‘lua’ for update; -- 会阻塞等待表锁释放
-- 或者直到终端1 输入 commit; 提交后, 才会释放锁, 或者终端2会超时...
间隙锁
让我们来回忆下,间隙锁目的是为了防止mysql在 RR 级别下的幻读, 所以间隙锁的目的就是阻止一些会让当前事务数据量变化的操作。
MySQL的数据存储是b+tree ,以主键建立的b+tree。 我们假设表的数据有5条,它们的id分别是 5,10,15,20,25 (id不连贯的可能性是存在的),虽然他是树结构,但也是排好序的,并且就是以主键为排序的。
间隙锁的原理就是锁住我们查询数据的各种区间, 可能不好理解, 先举个例子:
1. Where id > 9 , 它锁住的区间就是(5,+∞),开头是5不是9哦, 因为id=9并不存在呀,基于索引加锁,没有索引无法加锁;
2. Where id >9 and id <18, 它锁住的区间就是 (5,10]、(10,15]、(15,20] ; (其实就是以实际存在id值作为区间分割)
3. Where id = 10, 它只会锁住这一行数据;
4. Where id = 7, 因为7不存在, 为了防止后续新增, 所以也要锁住最近的一个区间(5, 10)
注意: 间隙锁是 左开右开的区间, 但间隙锁和临键锁一起的,而临键锁是左开右闭。
实验开始, 新建一张表:
CREATE TABLE `xdx_lock_new` (
`id` int NOT NULL COMMENT '主键',
`name` VARCHAR(255) NOT NULL COMMENT '普通数据',
PRIMARY KEY (id)
);
INSERT INTO `xdx_lock_new` (`id`, `name`) values (5,'java');
INSERT INTO `xdx_lock_new` (`id`, `name`) values (10,'c');
INSERT INTO `xdx_lock_new` (`id`, `name`) values (15,'go');
INSERT INTO `xdx_lock_new` (`id`, `name`) values (20,'lua');
INSERT INTO `xdx_lock_new` (`id`, `name`) values (25,'js');
终端1:
Begin;
Select * from xdx_lock_new where id=7 for update; -- 会锁住 5-10 范围内的间隙( 7 相邻的左右两个id记录), 不允许其它线程插入数据,直到终端1提交了数据,释放锁;
终端2:
Begin;
INSERT INTO `xdx_lock_new` (`id`, `name`) values (9,'php'); -- 发现会阻塞, 证明间隙锁生效了。
终端2再次输入
INSERT INTO `xdx_lock_new` (`id`, `name`) values (13,'php'); -- 只要不在范围内, 就可以正常插入!
范围锁实验:
终端1:
Begin;
Select * from xdx_lock_new where id>8 and id<23 for update; -- 会锁住 5 到 25 范围内的间隙
终端2:
Begin;
INSERT INTO `xdx_lock_new` (`id`, `name`) values (24,'php'); -- 会锁住; 因为23右侧的最大数是25, 所以 5-25 id 范围内,是无法插入数据的!
但是 插入 id <5 的就能正常插入
INSERT INTO `xdx_lock_new` (`id`, `name`) values (4, php’); -- 就能顺利插入
有一种特殊情况要注意:
在mysql 5.8 以后, 这种锁,会降级为行锁, 即只锁 1,2,5 三条记录。 5.7以前还是间隙锁, 即 1-5之间是无法重复上锁的.
锁非唯一索引
基于唯一索引或主键索引, 间隙锁的范围很好理解, 但是基于非唯一索引进行数据锁定的时候, SQL优化器最终执行的结果可能和我们想象的并不一样。 比如下面这个案例, 相同的SQL, 但是数据表不一样的时候, 直接结果也不一样。
CREATE TABLE `xdx_lock_normal_primary` (
`id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`age` int(11) not NULL,
PRIMARY KEY (`id`),
KEY `name` (`name`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;
INSERT INTO `xdx_lock_normal_primary` (`id`, `name`) values (5,'java');
INSERT INTO `xdx_lock_normal_primary` (`id`, `name`) values (10,'c');
INSERT INTO `xdx_lock_normal_primary` (`id`, `name`) values (15,'go');
INSERT INTO `xdx_lock_normal_primary` (`id`, `name`) values (20,'lua');
INSERT INTO `xdx_lock_normal_primary` (`id`, `name`) values (25,'js');
结果1:
结果2: (再往表里加几条数据后)
再执行上面的操作, 结果就不一样了; mysql 退化成记录锁,只锁对应条件的行.
临键锁
MySQL默认的锁是临键锁, 当写一个SQL的时候就有可能粗发临键锁, 但临键锁并不是一个单独的锁,临键锁=记录锁+间隙锁, 所以分析的时候我们可以单独的判断它当前加了哪种记录锁, 然后再单独看它是否被加了间隙锁, 再结合起来判断。
表锁、页面锁、行锁
在学习MYSQL锁的时候这些锁给我带来了很大困扰, 其实它们只是表示我们当前这次操作锁了多少数据而已,是一行数据还是整个表的数据。
MySQL官方文档里并未对这几个锁做出解释。
死锁
死锁其实并不属于MySQL锁的范围, 它只是基于锁而产生的一种现象。
A事务 | B事务 |
---|---|
获取 x 表的某些行数据的排他锁 | |
获取 y 表的某些行数据的排他锁 | |
尝试获取 y 表的某些行数据的锁(被B事务锁住获取不到,阻塞等待) | |
尝试获取x表的某些行数据的锁(被A事务锁住获取不到,阻塞等待) | |
互相等待对方释放锁, 死锁了 |