谈谈我对 MySQL 中锁的理解

140 阅读6分钟

🙊谈谈我对 MySQL 中锁的理解

MySQL 是可以存在多个链接同时使用数据库读写的,正因为存在这种并发读写的情况,才需要引入锁和 MVCC(多版本并发控制管理)来保证数据的一致性。 这里我们不过多地谈论 MVCC,简单来说,MVCC 就是每一份记录都有一份快照数据或者说备份,事务根据记录的版本号检查是否能够读取当前记录,如果不能读取当前的记录,就会去读取之前的备份数据。

接下来讨论我对 MySQL 中锁的理解1 2 3

MySQL 如何显式加锁?

首先,我们是可以使用语句显式地对表中记录或者整个表加锁的,加锁的语句可以看下面的代码块:

# 行级 共享锁
select ... from 表名 where ... lock in share mode

# 行级 排它锁
select ... from 表名 where ... for update

# 表级 共享锁
lock tables 表名 READ
# 表级 排它锁
lock tables 表名 WRITE
# 解除表级锁
unlock tables

值得注意的是:

  • 行级锁在事务结束后会自动释放,因此我们不需要显式地解锁
  • 多个会话中,会话 A 的表级加锁操作,不会被会话 B 的表级解锁操作所解除加锁

一个简单的实验验证表级锁:

  1. 打开 navicat,新建两个查询窗口
  2. 窗口 1 执行 lock tables 表名 WRITE
  3. 窗口 2 执行 select * from 表名,会阻塞住,没有返回查询结果
  4. 窗口 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 中,有三种行级锁类型:

  1. 记录锁(Record Lock):只对当前记录加锁
  2. 间隙锁(Gap Lock):只锁住一定范围,但是不对记录本身加锁
  3. 临键锁(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 位置的元素也被加了锁,其他线程无法修改这里的记录。

结语

以上的知识点如有错漏,烦请指正。

Footnotes

  1. 《高性能 MySQL 第 3 版》

  2. 《高性能 MySQL 第 4 版》

  3. 网络博客