MySQL 的锁

4,432 阅读10分钟

MySQL里有非常多锁的概念,经常可以听到的有:乐观锁、悲观锁、行锁、表锁、Gap锁(间隙锁)、MDL锁(元数据锁)、意向锁、读锁、写锁、共享锁、排它锁。这么锁一听就让人头大,于是去看一些博客,有些讲乐观锁、悲观锁,有些在讲读锁、写锁,于是乐观锁和悲观锁好像理解了,读锁写锁好像也理解了,但是我任然不知道怎么用,也不知道乐观锁与读锁写锁有没有什么关系?再看了很多文章后,逐渐弄懂了它们之间的关系,于是写下这篇文章来梳理思路。能力有限,难免有误,请酌情参考。

虽然上面列举了很多锁的名词,但是这些锁其实并不是在同一个维度上的,这就是我之所以含糊不清的原因。接下来从不同的维度来分析 MySQL 的锁。

读锁和写锁

首先读锁还有一个名称叫共享锁,写锁也相应的还有个名称叫排它锁,也就是说共享锁和读锁是同一个东西,排它锁和写锁是同一个东西。读锁、写锁是系统实现层面上的锁,也是最基础的锁。读锁和写锁还是锁的一种性质,比如行锁里,有行写锁和行读锁。MDL 锁里也有 MDL 写锁和 MDL 读锁。读锁和写锁的加锁关系如下,Y 表示可以共存,X 表示互斥。

读锁 写锁
读锁 Y X
写锁 X X

从这个表格里可以知道读锁和写锁不能共存,请考虑这样一个场景,一个请求占用了读锁,这时又来了一个请求要求加写锁,但是资源已经被读锁占据,写锁阻塞。这样本没有问题,但是如果后续不断的有请求占用读锁,读锁一直没有释放,造成写锁一直等待。这样写锁就被饿死了,为了避免这种情况发生,数据库做了优化,当有写锁阻塞时,后面的读锁也会阻塞,这样就避免了饿死现象的发生。后面还会再次提到这个现象。

之前的文章已经介绍了 MySQL 的存储模型,对于 InnoDB 引擎而言,采用的是 B+ 树索引,假设需要将整个表锁住那么需要在整个 B+ 树的每个节点上都加上锁,显然这是个非常低效的做法。因此,MySQL 提出了意向锁的概念,意向锁就是如果要在一个节点上加锁就必须在其所有的祖先节点加上意向锁。关于意向锁还有更多复杂设计,如果想了解可以查看 《数据库系统概率》 一书。

表锁和行锁

表锁和行锁是两种不同加锁粒度的锁。除了表锁和行锁以外还有更大粒度的锁——全局锁。

全局锁: 全局锁会锁住整个数据库,MySQL 使用 flush tables with read lock 命令来加全局锁,使用 unlock tables 解锁。线程退出后锁也会自动释放。当加上全局锁以后,除了当前线程以外,其他线程的更新操作都会被阻塞,包括增删改数据表中的数据、建表、修改表结构等。全局锁的典型使用场景是全库的逻辑备份。

表锁: 表锁会锁住一张表,MySQL 使用 lock tables

read/write 命令给表加上读锁或写锁,通过 unlock tables 命令释放表锁。通过 lock tables t read 给表 t 加上读锁后,当前线程只能访问表 t,不能访问数据库中的其他表,对表 t 也只有读权限,不能进行修改操作。通过 lock tables t write 给表 t 加上写锁后,当前线程只能访问表 t,不能访问数据库中的其他表,对表 t 有读写权限。

行锁: 行锁会锁锁住表中的某一行或者多行,MySQL 使用 lock in share mode 命令给行加读锁,用 for update 命令给行加写锁,行锁不需要显示释放,当事务被提交时,该事务中加的行锁就会被释放。通过 select k from t where k = 1 for update 命令可以锁住 k 为 1 的所有行。另外当使用 update 命令更新表数据时,会自动给命中的行加上行锁。另外 MySQL 加行锁时并不是一次性把所有的行都加上锁,执行一个 update 命令之后,server 层将命令发送给 InnoDB 引擎,InnoDB 引擎找到第一条满足条件的数据,并加锁后返回给 server 层,server 层更新这条数据然后传给 InnoDB 引擎。完成这条数据的更新后,server 层再取下一条数据。

我们用一个例子来验证这个过程,首先执行如下命令建表并插入几行数据

mysql-> create table t(id int not null auto_increment, c int not null, primary key(id))ENGINE=InnoDB;
mysql-> insert into t(id, c) values (1, 1), (2, 2), (3, 3);
事务 A 事务 B 事务 C
begin
select * from t where id = 3 for update;
update t set c = 0 where id = c;
set session transaction isolation level READ UNCOMMITTED; select * from t;
commit

事务 A 执行 select * from t where id = 3 for update 将 id 等于3的行锁住,事务 B 执行 update 命令的时候被阻塞。这时候再开启事务 C,并且将事务 C 的隔离级别修改为未提交读,得到的如下表所示,发现前两行已经被更新,最后 id 为 3 的行没有更新,说明事务 B 是阻塞在这里了。

mysql> select *  from t;
+----+---+
| id | c |
+----+---+
|  1 | 0 |
|  2 | 0 |
|  3 | 3 |
+----+---+

乐观锁和悲观锁

乐观锁

乐观锁总是假设不会发生冲突,因此读取资源的时候不加锁,只有在更新的时候判断在整个事务期间是否有其他事务更新这个数据。如果没有其他事务更新这个数据那么本次更新成功,如果有其他事务更新本条数据,那么更新失败。

悲观锁

悲观锁总是假设会发生冲突,因此在读取数据时候就将数据加上锁,这样保证同时只有一个线程能更改数据。文章前面介绍的表锁、行锁等都是悲观锁。

乐观锁和悲观锁是两种不同的加锁策略。乐观锁假设的场景是冲突少,因此适合读多写少的场景。悲观锁则正好相反,合适写多读少的场景。乐观锁无需像悲观锁那样维护锁资源,做加锁阻塞等操作,因此更加轻量化。

乐观锁的实现

乐观锁的实现有两种方式:版本号和 CAS 算法

版本号

通过版本号来实现乐观锁主要有以下几个步骤:

1 给每条数据都加上一个 version 字段,表示版本号

2 开启事务后,先读取数据,并保存数据里的版本号 version1,然后做其他处理

3 最后更新的时候比较 version1 和数据库里当前的版本号是否相同。用 SQL 语句表示就是 update t set version = version + 1 where version = version1。 根据前面事务的文章我们知道,update 操作时会进行当前读,因此即使是在可重复读的隔离级别下,也会取到到最新的版本号。如果没有其他事务更新过这条数据,那么 version 等于 version1,于是更新成功。如果有其他事务更新过这条数据,那么 version 字段的值会被增加,那么 version 不等于 version1,于是更新没有生效。

CAS 算法

CAS 是 compare and swap 的缩写,翻译为中文就是先比较然后再交换。CAS 实现的伪代码:

<< atomic >>
bool cas(int* p, int old, int new)  
{
    if (*p != old)
    {
        return false
    }
    *p = new
    return true
}

其中,p 是要修改的变量指针,old 是修改前的旧值,new 是将要写入的新值。这段伪代码的意思就是,先比较 p 所指向的值与旧值是否相同,如果不同说明数据已经被其他线程修改过,返回 false。如果相同则将新值赋值给 p 所指向的对象,返回 true。这整个过程是通过硬件同步原语来实现,保证整个过程是原子的。

大多数语言都实现了 CAS 函数,比如 C 语言在 GCC 实现:

bool__sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

无锁编程实际上也是通过 CAS 来实现,比如无锁队列的实现。CAS 的引入也带来了 ABA 问题。关于 CAS 后面再开一篇专门的文章来总结无锁编程。

MDL 锁和 Gap 锁

MDL 锁

MDL 锁也是一种表级锁,MDL 锁不需要显示使用。MDL 锁是用来避免数据操作与表结构变更的冲突,试想当你执行一条查询语句时,这个时候另一个线程在删除表中的一个字段,那么两者就发生冲突了,因此 MySQL 在5.5版本以后加上了 MDL 锁。当对一个表做增删查改时会加 MDL 读锁,当对一个表做结构变更时会加 MDL 写锁。读锁相互兼容,读锁与写锁不能兼容。

MDL 需要注意的就是避免 MDL 写锁阻塞 MDL 读锁。

事务 A 事务 B 事务 C 事务 D
select * from t
select * from t
alter table t add c int
select * from t

事务 A 执行 select 后给表 t 加 MDL 读锁。事务 B 执行 select 后给表再次加上 MDL 读锁,读锁和读锁可以兼容。事务 C 执行 alter 命令时会阻塞,需要对表 t 加 MDL 写锁。事务 C 被阻塞问题并不大,但是会导致后面所有的事务都被阻塞,比如事务 D。这是为了避免写锁饿死的情况发生,MySQL 对加锁所做的优化,当有写锁在等待的时候,新的读锁都需要等待。如果事务 C 长时间拿不到锁,或者事务 C 执行的时间很长都会导致数据库的操作被阻塞。

为了避免这种事情发生有以下几点优化思路:

1 避免长事务。事务 A 和事务 B 如果是长事务就可能导致事务 C 阻塞在 MDL 写锁的时间比较长。

2 对于大表,修改表结构的语句可以拆分成多个小的事务,这样每次修改表结构时占用 MDL 写锁的时间会缩短。

3 给 alter 命令加等待超时时间

Gap 锁

Gap 锁是 InnoDB 引擎为了避免幻读而引入的。在 MySQL的事务一文中已经谈到,InnoDB 引擎在可重复读隔离级别下可以避免幻读。间隙锁就是锁住数据行之间的间隙,避免新的数据插入进来。只有在进行当前读的时候才会加 gap 锁。关于什么是当前读,可以看我的上一篇文章《MySQL的事务》。

加锁实践

一条语句会如何加锁,单纯这一句话是无法分析出来的。对加锁的分析,必须结合事务的隔离级别和索引来看,阿里数据库专家对此已经写了非常详细分析的文章,直接贴出来大家一起学习,MySQL 加锁处理分析

思维导图

文章最后,放上我总结的 MySQL 的思维导图,算是对 MySQL 系列文章的一个总结。

参考

[1] 数据库系统概念(第6版)

[2] MySQL实战45讲,林晓斌

[3] 高性能MySQL(第3版)

[4] 事务的隔离级别和mysql事务隔离级别修改

[5] MySQL 加锁处理分析, 何登成

[6] 乐观锁、悲观锁,这一篇就够了!