跳至主要內容

MySQL 中的锁

AruNi_Lu数据库MySQL约 5069 字大约 17 分钟

本文内容

1. MySQL 中的锁有哪些

根据加锁的范围,MySQL 中的锁分为 全局锁、表锁和行级锁

全局锁和表级锁是在 Server 层实现的,而行级锁是在存储引擎层实现的。

2. 全局锁

2.1 什么是全局锁

全局锁,顾名思义会对整个 MySQL 实例加锁,也就是锁库中所有的表。

加了全局锁后,整个数据库就处于只读状态,其他线程执行 DML、DDL 都会被阻塞。

MySQL 中关于全局锁的命令:

  • 加全局锁:flush tables with read lock(简称 FTWRL)。

  • 释放全局锁:unlock tables,连接会话断开也会自动释放。

2.2 全局锁的使用场景

全局锁的一个典型使用场景就是 全库逻辑备份,也就是把数据库中每个表的所有记录都查询出来保存。

进行全库逻辑备份的时候,需要让备份的数据和数据库的数据保持一致性,所以肯定是需要加锁的。

假设在备份的时候不加锁,会出现什么情况呢?

比如用户在购物网站上买东西,数据库表的备份顺序可能是下面这样的:

  • 先备份有用户余额的表;
  • 接着用户点击下单,扣除余额,减少商品库存;
  • 最后备份商品表。

可以发现备份的数据中,用户的余额还是原来的,但是商品的库存却减少了,资本家能允许这样的事情发生?

所以,在全库逻辑备份时,需要加上全局锁,那么上面用户下单的操作,就会被阻塞。

那如果有许多剁手党刚好在备份的这个时间段剁手呢?总不能让这些财神爷白白的等待数据库备份完成吧。

在 InnoDB 存储引擎下,事务的隔离级别为 REPEATABLE READ 时,每次开始事务时会开启一个 ReadView,这个 ReadView 是具有一致性的,即这个事务过程中看到的数据都是一致的。

所以官方自带了一个逻辑备份的工具,是 mysqldump。当 mysqldump 使 用参数 -single-transaction 时,导出数据之前就会开启一个事务,确保在这个事务中的数据是一致的。由于 MVCC 的支持,这个过程中数据是可以正常更新的(备份过程中更新的记录不会保留到备份的数据里,这才满足一致性要求)。

MVCC 和 ReadView 在 InnoDB 里面是非常重要的,如果你还不了解 ,可以看看相关的文章。

所以,在支持 REPEATABLE READ 这个隔离级别的存储引擎中(比如 InnoDB),可以使用这个一致性视图来进行备份,但是如果不支持(比如 MyISAM),那就只能使用全局锁来备份了。

3. 表级锁

MySQL 中的表级锁有下面几种:

  • 表锁(注意表锁属于表级锁的一种);
  • 元数据所 MDL(Meta Data Lock);
  • 意向锁
  • AUTO-INC 锁(主键自增锁);

3.1 表锁

关于表锁的相关命令:

  • 加共享式表锁:lock tables xx_table read
  • 加独占式表锁:lock tables xx_table write
  • 释放表锁:和全局锁一样,使用 unlock tables,连接会话断开也会自动释放。

注意:表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作

例如,线程 A 执行了 lock tables t1 read, t2 write;,那么其他线程写 t1、读写 t2 都会被阻塞。同时,在线程 A 释放锁之前,执行写 t1 也会被阻塞

不过对于 InnoDB 这种支持行级锁的存储引擎来说,我们一般不使用表锁,因为它的锁粒度太大,对并发性能有很大的影响。

3.2 元数据锁

3.2.1 什么是元数据锁

元数据锁 MDL 主要作用于 DML 语句(CRUD)和 DDL 语句(改变表结构),它能保证读写的正确性。例如一个查询正在遍历一个表中的数据时,另一个线程此时改变了表的结构,删了一列,就会导致查询到的结果与表结构对不上,这肯定是不行的。

MDL 不需要我们显示使用,当我们对表执行相关操作时,MySQL 会自动给表加上 MDL:

  • 对表做 CRUD 时,对表加 DML 读锁

    DML 读读锁是不互斥的,所以可以有多个线程同时对一张表做 CRUD。
    但是加了 DML 读锁时,一旦有线程对表结构做修改,就会被阻塞。

  • 对表做 结构改变 时,对表加 DML 写锁

    DML 的读写锁、写写锁之间是互斥的,例如加了 DML 写锁后,连 select 也会被阻塞。

既然 MDL 不需要我们显示使用,那它什么时候被释放呢?这是个重点,MDL 在事务结束后才会被释放

3.2.2 元数据锁带来的坑

我们知道 MDL 在事务结束后才会被释放,也就是说 在事务执行期间,MDL 是一直存在的,由此可以看出,在长事务中做表结构的更改时,可能会出现一些坑

在给一个数据量很大的大表添加字段,或修改字段时,需要扫描全表的数据。因为要修改每一行记录中对应列的数据。另外在加索引时,也需要扫描全表的数据来创建索引。所以我们在对大表做以上操作时,肯定会格外的小心,避免对线上业务造成影响。

那我们对一个小表,就可以随意进行上面的操作了吗?实际上不是的,操作不慎也会出现问题,而且问题还不小。例如下面这个操作序列:

image-20230220202758945

  • session A 开始一个事务后,执行 select 操作,此时的表会加 MDL 读锁;
  • session B 也执行 select 操作,此时不会被阻塞,因为 MDL 读读锁不互斥;
  • session C 执行 DDL 操作,需要申请 DML 写锁,但此时 session A 事务还未关闭,表还有 MDL 读锁,所以 session C 被阻塞

此时不会有什么问题,但是后面如果还有线程在这个表上申请 MDL 读锁,就会被阻塞

因为 申请 DML 锁的操作会被放入一个队列,队列中写锁获取的优先级高于读锁。故一旦某个表的 DML 写锁进入阻塞状态时,该表后面的所有操作都将被阻塞

如果这个表此时有大量的查询请求到来,又都被阻塞了,那这个库的线程很快就会爆满

所以在对表结构做修改之前,要先看看数据库中是否有长事务,然后再做对应的修改。

3.3 意向锁

在 InnoDB 存储引擎中是支持行级锁的,那么我们 在对记录加行锁时,会先在表级别上加一个对应的意向锁

  • 如果对记录加共享锁,那么会在表上加意向共享锁;
  • 如果对记录加独占锁,那么会在表上加意向独占锁。

所以 在增删改操作时,会先在表上加意向独占锁,然后对某条记录加行锁。InnoDB 中普通的 select 是不需要加锁的,通过 MVCC 来实现一致性读。

意向锁 的提出是为了能够在 O(1) 的时间复杂度 判断到某个表中的记录是否有锁

设想一下,如果我要对表加独占式的表锁,由于读写、写写都是互斥的,所以我要知道表中是否有加了读锁或写锁的记录,如果没有意向锁,那么就需要去遍历表中的记录来判断是否有记录加了锁。而如果有意向锁,在加独占式的表锁时,就可直接根据该表是否有意向锁来判断。

注意:意向锁虽然是表级锁,但是不会和行级别的锁发生冲突,而且意向锁之间也不会发生冲突。它的提出仅仅是为了能快速判断一个表里是否有记录被加锁了

3.4 AUTO-INC 锁

我们在定义主键时,可以将主键设置成自增的,之后在插入数据的时候,就无需输入主键,MySQL 会 自动给主键赋上递增的值,这主要就是 通过 AUTO-INC 锁来实现的

AUTO-INC 锁并不是等事务结束后才释放,而是 执行完插入语句就会立即释放,以便其他事务再申请。

AUTO-INC 锁保证了被设置成自增的主键是连续递增的,一个事务在插入记录时,会给该表加一个 AUTO-INC 锁(表级锁),这样当其他事务执行插入操作时,就会被阻塞。

不过,当有多个事务进行数据的插入时,性能无疑会受到影响。

所以后面 InnoDB 存储引擎提供了一种 轻量级的锁 来实现自增:

  • 当执行插入操作时,给主键赋上一个自增的值后,就可以把这个锁释放了,无需等待整个插入语句执行完。

InnoDB 提供了一个系统变量 innodb_autoinc_lock_mode 来控制自增锁的类型:

  • 参数 = 0,AUTO-INC 锁;
  • 参数 = 2,轻量级锁;
  • 参数 = 1(默认值):
    • 普通 insert:轻量级锁;
    • 批量插入(insert ... select):AUTO-INC 锁。

你肯定会想,轻量级锁效率这么高,为什么这个变量的默认值不是 2 呢?

这是因为,在批量插入时,如果使用轻量级锁可能会造成数据不一致。例如下面这个场景:

image-20230220215028676

在这个例子里,往表 t1 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后 两个 session 同时执行向表 t2 中插入数据

如果使用轻量级锁,在申请完自增主键后就释放锁,那么可能会出现下面这样的情况:

  • session B 先插入两个记录 (1,1,1)、(2,2,2);
  • 这时,session A 恰巧申请到自增 id = 3,插入 (3,5,5)
  • 最后,session B 继续插入 (4,3,3)、(5,4,4)。

可以发现,session B 在批量插入时,被 session A 插队了,导致 session B 插入的记录 id 不连续。这会有什么问题呢?

如果我们现在的 binlog_format = statement(即 binlog 记录的是原始 SQL 语句,而不是具体的数据),那么 binlog 会怎么记录?

由于两个 session 是同时执行插入数据命令的,所以 binlog 里面对表 t2 的更新日志只有两种情况:要么先记 session A 的,要么先记 session B 的,因为 一个事务中的 binlog 是不能被拆开的

但不论是哪一种,这个 binlog 拿去从库执行,或者用来恢复临时实例,表 t2 中的数据是下面这样的:

  • 先记 session A,再记 session B:(1,5,5)、(2,1,1)、(3,2,2)、(4,3,3)、(5,4,4);
  • 先记 session B,再记 session A:(1,1,1)、(2,2,2)、(3,3,3)、(4,4,4)、(5,5,5);

可以发现,从库或临时实例里面,session B 这个语句执行出来的结果,id 都是连续的。这时,该库就发生了 数据不一致

其实,这个原因就是 原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的,是得不到不连续的 id 的

要解决这个问题有两个思路:

  • 让原库 批量插入 数据时,不使用轻量级锁,binlog 的格式随意;
  • binlog 格式设为 row,批量插入也可以使用轻量级锁

但是还是建立采用思路二,即 binlog_format = row ,并且 innodb_autoinc_lock_mode = 2,这样做既能提升并发性能,又不会出现数据一致性问题。

4. 行级锁

行级锁是由存储引擎实现的,常见的 InnoDB 就支持行级锁,而 MyISAM 不支持。

普通的 select 语句 不需要加锁,这种查询操作称为快照读,是通过 MVCC 机制来实现的。

但是我们也可以 显示的给 select 语句加锁,这种读操作称为 当前读,或者 锁定读,加锁方式如下:

  • 对读取的记录加共享锁:select ... lock in share mode;
  • 对读取的记录加独占锁:select ... for update;

insert、delete、update 操作也是属于当前读,它们会获取数据的最新版本。

当前读会获取数据的最新版本,而且需要先获取到对应记录的锁

注意:

上面的语句 需要在事务中执行,因为它们申请到的锁,在事务结束时才会被释放。而 MySQL 默认开启事务自动提交,即每条 SOL 语句都会被当做一个单独的事务自动执行提交。

所以我们在执行上面的语句时,需要显式的开启事务(BEGIN 或 START TRANSACTION),或者关闭自动提交(SET autocommit = 0),否则上面的语句在数据读取完后就释放锁了。

InnoDB 中主要通过 Next-Key Lock 来实现当前读,Next-Key LockRecord Lock(记录锁)和 Gap Lock(间隙锁)组成。下面主要来介绍这三种锁,行级锁的类型也主要是这三种。

4.1 两阶段锁协议

在讲行级锁之前,先来看看什么是两阶段锁协议。其实我们上面也提到了,在事务结束时才会释放锁。

在 InnoDB 中,两阶段锁协议 的含义指:行锁是在需要时才加上,但是并不是不需要了就立马释放,而是需要等到事务结束(ROLLBACK or COMMIT)时才释放

知道了这个协议,对我们使用事务和行锁有什么帮助呢?那就是,如果我们的事务中需要锁多个行,那么应该将可能造成锁冲突、影响并发量的锁往后放。

由于两阶段锁协议,如果把锁放前面,那么并发操作时,假设一个事务先获取前面的锁,此时该事务后面还有很多与此锁无关的操作(迟迟不提交),那么就相对于该事务白白占用了这把锁,导致其他事务需要获取该锁时,阻塞的时间就比较长。

假设让你负责一个影院电影票交易系统,顾客 1 要在影院 A 买票,该业务简化后的操作如下:

  1. 从顾客 1 账户余额扣除票价;
  2. 给影院 A 账户余额增加票价;
  3. 记录一条交易日志。

为了保证原子性,这三个操作肯定要放到同一个事务中执行。那么如何安排这三个操作的顺序呢?

我们常见的场景是多名客户在同一家影院买票,即此时还有其他客户 2、3、4 ... 也在影院 A 买票,那么这几个事务冲突的操作就是第二条,因为他们都需要给影院的账户余额增加票价。

按照上面的思路,我们将可能造成锁冲突的操作二放到最后,执行顺序改为 3、1、2,这样就能使得操作二中的锁加锁时间最短,从而大大提高并发度。

4.2 Record Lock

Record Lock 称为记录锁,顾名思义,它只锁一行记录。记录锁也有 S 锁(共享锁)和 X 锁(排他锁)之分。

例如,我们执行下面这条 SQL 语句:

begin;	// 记得开启事务
select * from user where id = 1 for update;

在事务结束之前,id = 1 这行记录都会持有 X 型的记录锁,其他事务就不可以对此记录加 S 型或 X 型的记录锁了。

事务结束后,这个事务持有的锁就会被释放。

4.3 Gap Lock

Gap Lock 称为间隙锁,它的加锁方式是在 两条记录的缝隙加锁,即两记录之间(前开后开区间)

为什么要在缝隙加锁呢?两条记录之间的缝隙又没有记录,既然锁不到记录,要它干什么?还会影响数据的插入。

为什么它要阻止数据插入呢?这时候不知道你有没有想到事务中的 幻读,就是 一个事务中读取到的记录数量会出现不一致的情况,注意是记录的数量

例如我在事务 A 中一开始根据某个条件查询出的记录数量为 n,在事务结束之前,我再查,结果发现此时的记录数量为 n + 1,这就出现的幻读。

所以 间隙锁的提出就是为了解决上面这种情况的幻读

需要注意的是,间隙锁只在可重复读(REPEATABLE READ)隔离级别出现,它的提出只是为了解决可重复读隔离级别下的幻读问题

间隙锁虽然也有 X 型和 S 型之分,但是 间隙锁之间是兼容的。也就是不同事务中可以持有同一个区间的间隙锁,因为 间隙锁只是防止插入幻影记录而提出的,只用于防止在这个区间插入数据

4.4 Next-Key Lock

Next-Key Lock 称为临键锁,是 Record Lock 和 Gap Lock 的组合,它会锁记录本身和一个间隙

Gap Lock 加的锁是前开后开区间,Next-Key Lock 是前开后闭,具体看 MySQL 的加锁规则。

所以 Next-Key Lock 既能保证记录不被修改,也能阻止其他事务在被锁的间隙插入记录。

需要注意,Gap Lock 之间是不冲突的,但由于 Next-Key Lock 中有记录锁(记录锁需要考虑冲突),所以 Next-Key Lock 之间会冲突。比如一个事务中获取了一个 X 型的 Next-Key Lock,那么其他事务就不能再获取相同范围的 Next-Key Lock 了。

5. 总结

MySQL 中的锁分为全局锁、表级锁和行级锁,其中行级锁是在存储引擎层实现的,常用的 InnoDB 就支持行级锁。

全局锁会锁整个库中的所有表,一般用于全库逻辑备份,但在支持 MVCC 的存储引擎中,一般使用一致性图来备份,从而避免全局锁给业务带来的影响。

表级锁分为下面几种:

  • 表锁:锁一个表,锁粒度较大,一般不会使用;
  • 元数据锁 MDL:主要作用于 DML 和 DDL 之间,不需要我们显示使用,事务结束后自动释放;
  • 意向锁:为了快速判断一个表中是否有记录被上了锁;
  • AUTO-INC 锁:主键自增时使用的锁,可以降级为轻量级锁,在申请完主键后就释放锁,无需等到插入操作结束才释放。不过需要注意 binlog_format = statement 时的数据不一致问题。

行级锁分为下面几种:

  • Record Lock:记录锁,只锁一条记录;
  • Gap Lock:间隙锁,锁一个区间,只是为了防止幻影记录的插入,Gap Lock 之间是不冲突的;
  • Next-Key Lock:临键锁,是 Record Lock 和 Gap Lock 的组合,Next-Key Lock 之间会冲突。

MySQL 中常见的锁到这里就介绍完了,但是本文章仅仅是介绍了锁的类型,并没有讲解 MySQL 中锁的加锁规则,也就是一条语句是如何加锁的。加锁规则会在后面的文章中讲解。

6. 参考文章

上次编辑于: