🙊谈谈我对 MySQL 中锁的理解
MySQL 是可以存在多个链接同时使用数据库读写的,正因为存在这种并发读写的情况,才需要引入锁和 MVCC(多版本并发控制管理)来保证数据的一致性。 这里我们不过多地谈论 MVCC,简单来说,MVCC 就是每一份记录都有一份快照数据或者说备份,事务根据记录的版本号检查是否能够读取当前记录,如果不能读取当前的记录,就会去读取之前的备份数据。
MySQL 如何显式加锁?
首先,我们是可以使用语句显式地对表中记录或者整个表加锁的,加锁的语句可以看下面的代码块:
# 行级 共享锁
select ... from 表名 where ... lock in share mode
# 行级 排它锁
select ... from 表名 where ... for update
# 表级 共享锁
lock tables 表名 READ
# 表级 排它锁
lock tables 表名 WRITE
# 解除表级锁
unlock tables
值得注意的是:
- 行级锁在事务结束后会自动释放,因此我们不需要显式地解锁
- 多个会话中,会话 A 的表级加锁操作,不会被会话 B 的表级解锁操作所解除加锁
一个简单的实验验证表级锁:
- 打开 navicat,新建两个查询窗口
- 窗口 1 执行
lock tables 表名 WRITE - 窗口 2 执行
select * from 表名,会阻塞住,没有返回查询结果 - 窗口 1 执行
unlock tables,窗口 2 会返回结果
行级锁和表级锁
从上面我们可以看到,MySQL 中支持两种锁:表级锁和行级锁。 简单来说,表级锁会锁住整张表,但是它针对的是非索引字段,实现简单、加锁快,但是并发度不好; 行级锁只会锁住相关的记录,但是只针对索引字段,因为只有通过索引才能精确地锁住某个记录或某些记录,如果在加锁时,where 语句没能够命中索引,就会锁住整张表。
共享锁和排它锁
上面的 SQL 语句我们也能看到,MySQL 还支持共享锁、排它锁,也就是读锁和写锁。 行级锁和表级锁能够再细分为共享锁和排它锁。
共享锁和排它锁的区别如下:
- 在事务读取记录时获取共享锁;事务修改记录时获取排它锁
- 如果目标对象(某条记录或整张表)被加上了共享锁,那么其他事务仍旧能够对它加上共享锁或者排它锁;
- 如果目标对象被加上了排它锁,那么其他事务不能对它再加上任何锁,其他事物要加锁只能阻塞住 也就是说,共享锁是兼容的,排它锁是互斥的,他们的关系还可已通过下表更明显地看出来:
| 共享锁 | 排它锁 | |
|---|---|---|
| 共享锁 | 兼容 | 互斥 |
| 排它锁 | 互斥 | 互斥 |
意向锁
MySQL 中还有意向锁,其主要作用为在向表中记录加表级锁前,快速判断是否可以加锁,而不用去扫描表中是否已经有记录被加了行级锁,从而提高效率。
同样,意向锁也分为共享锁和排它锁:
- 意向共享锁:在向表中某些记录加共享锁之前,需要先获得意向共享锁。
- 意向排它锁:在向表中某些记录加排它锁之前,需要先获得意向排它锁。
意向锁之间相互兼容,和行级锁兼容,意向共享锁和表级共享锁兼容,其余意向锁和表级锁互斥,其关系如下表:
| 意向共享锁 | 意向排它锁 | 表级共享锁 | 表级排它锁 | |
|---|---|---|---|---|
| 意向共享锁 | ✅兼容 | ✅兼容 | ✅兼容 | ❌互斥 |
| 意向排它锁 | ✅兼容 | ✅兼容 | ❌互斥 | ❌互斥 |
| 表级共享锁 | ✅兼容 | ❌互斥 | ✅兼容 | ❌互斥 |
| 表级排它锁 | ❌互斥 | ❌互斥 | ❌互斥 | ❌互斥 |
举一个例子方便我们理解意向锁:
- 事务 A 执行语句
select * from user where id = 1 for update,从而获取了 user 表的意向排它锁,也获取了 user 表 id = 1 的记录的行级排它锁 - 同时,事务 B 启动,执行语句
update user set ... where name = ...,假设 name 没有建索引,此时事务 B 需要对 user 表加表级锁,因此获取到了意向排它锁,但是获取表级排它锁失败了 - 因为表级排它锁和意向排它锁互斥
还有一个例子:
- 事务 A 执行语句
select * from user where id = 1 for update,从而获取了 user 表的意向排它锁,也获取了 user 表 id = 1 的记录的行级排它锁 - 同时,事务 B 启动,执行语句
select * from user wehre name = ... lock in share mode,此时事务 B 获取了意向共享锁,但是获取不到表级共享锁 - 因为表级共享锁和意向排它锁互斥
做个简单的实验验证一下: 在 navicat 中开启两个查询窗口:
- 窗口 A 执行下列语句:
begin; # 开启一个事务
select * from 表名 where id = ... for update; # 加排它锁
- 窗口 B 执行下列语句,会阻塞住:
BEGIN;
update 表名 set ... where 非索引列 = ...;
commit;
- 窗口 A 分步执行下列语句:
# 事务仍旧是同样的记录,没有被修改,可重复读
select * from 表名 where id = ...;
# 提交事务
commit;
# 再次查询,B窗口已经执行完毕,记录被修改
select * from 表名 where id = ...;
行级锁的类型
在 MySQL 的存储引擎 InnoDB 中,有三种行级锁类型:
- 记录锁(Record Lock):只对当前记录加锁
- 间隙锁(Gap Lock):只锁住一定范围,但是不对记录本身加锁
- 临键锁(Next-Key Lock):锁住一个范围,并且同时锁住记录本身
间隙锁的主要作用是为了解决事务并发问题中的幻读现象的,它使得 MySQL 在可重复读隔离级别下,就能解决幻读现象。 我们举个例子解释一下间隙锁:
- 有 user 表,其中有 id 为 1,4,6 的记录
- 此时,执行语句
select * from user where id between 1 and 4 for update - 由于 id 为 2,3 的记录不存在,即 id 为 1 和 4 之间的空间存在间隙,MySQL 会锁住这个间隙
- 由于锁住了这个间隙,其他事务如果想要往这个空间中插入数据就会阻塞住,从而保证了幻读现象不会发生
- 同时,id 为 1,4 的记录也会被加上记录锁,从而和间隙锁组成了临键锁,保证不会发生不可重复读
- 可以简单理解为,有这么一个数组
[obj, null, null, obj, null],数组下标从 1 开始, 上面的查询语句执行后,下标为 2,3 的位置就被加了锁,其他线程就不能往这个位置插入元素,需要等待锁释放。同时,1,4 位置的元素也被加了锁,其他线程无法修改这里的记录。
结语
以上的知识点如有错漏,烦请指正。