# 【NO.127】MySQL 的锁机制，那么多的锁，该怎么区分？

## 1.楔子

本篇文章来聊一下 MySQL 的锁，首先不光是数据库，任何的一门高级语言也都内置了锁机制。从本质上讲，锁是一种协调多个进程或多个线程对某一资源进行访问的机制。

而之所以要存在锁，是因为在并发编程中，程序的某一部分在并发访问的时候会导致意想不到的结果。所以这部分程序就需要用锁保护起来，而保护起来的部分就叫做临界区。

在 MySQL 中，按照不同的角度，可以将锁分为如下几种：

![img](https://pic3.zhimg.com/80/v2-d7efe6e913b506ae84d2910728f50e06_720w.webp)

这么多的锁，我们该怎么区分呢？下面就来逐一回答。

## 2.**脏写是如何避免的**

在区分锁之前，先来回顾一个问题，前面我们说四种隔离级别，无论哪一种都可以避免脏写的问题。但怎么避免的当时却没有解释，原因就是涉及到了锁，下面来解释一下。

再来回顾一下什么是脏写，假设事务 A 和 事务 B 同时对张三的账户余额进行更新，初始值为 100，那么两个事务拿到的也都是 100。然后事务 A 给余额增加 100，事务 B 给余额增加 200。理论上最终应该是 400 才对，但如果 A 先提交 B 后提交，最终的结果却是 300；B 先提交 A 后提交，最终的结果就是 200。

以上这种现象就是脏写，具体表现为：两个事务更新同一条数据，后提交的事务将先提交的事务所做的更新给覆盖了。

那么如何避免呢？显然要依赖锁，多个事务在更新同一条数据的时候要串行更新。

所以当事务在更新数据的时候，会先看这条数据有没有人加锁。如果没有，那么该事务就会创建一个锁，里面包含了事务 ID（trx_id） 和等待状态，然后将锁和这条数据关联在一起。

![img](https://pic1.zhimg.com/80/v2-2463872e216709834532b272911e8814_720w.webp)

事务 A 更新数据的时候，会给数据加锁，然后别的事务就不能再更新了。但假设这个时候又来个事务 B 也要更新这条数据，它会怎么做呢？

首先还是判断数据有没有人加锁，结果发现被事务 A 加锁了，就知道自己不能修改这条数据。但事务 B 仍会对这条数据加锁，只不过它处于等待状态。

![img](https://pic1.zhimg.com/80/v2-c2c8e02fad7c0a6110ba28e11ccce120_720w.webp)

当事务 A 更新完数据，就会将自己的锁释放掉，并且还会去找，有没有别人也对这条数据加了锁。显然它会发现该数据也被事务 B 加锁了，于是会把事务 B 锁里的等待状态修改为 false，然后唤醒事务 B 开始执行，此时事务 B 就获取到锁了。

![img](https://pic3.zhimg.com/80/v2-cb9ce999bf20b2752b8e47e1c4181c42_720w.webp)

以上就是 MySQL 锁机制的一个最基本的原理，其实就和 Python 里面的互斥锁是一样的，但是基于此我们又引申出了很多不同种类的锁。

## 3.**MySQL 的读锁和写锁**

先来聊聊读锁和写锁，读锁也被称为共享锁、S 锁，写锁也被称为独占锁、排它锁、X 锁。而上面多个事务在更新数据时加的锁，就是写锁。

那么问题来了，如果一个事务在读数据的时候，发现这条数据被加锁了，那么该事务需要继续加锁吗？如果是更新数据，那么需要加锁，但读数据是不需要的。因为默认情况下，如果是读数据，会走 MVCC 机制。

因为读数据可以根据 ReadView 在 undo log 版本链里找一个能读取的版本，完全不用考虑是否有别的事务在更新，ReadView 机制不允许当前事务读取别的事务已经更新的值。所以默认情况下读数据完全不需要加锁，更不需要关心别的事务是否在更新数据，直接基于 MVCC 机制读某个快照即可。

但如果就是想在读数据的时候加锁呢？答案是使用读锁，也叫 S 锁、共享锁。

```text
SELECT * FROM girl
WHERE age > 16 IN SHARE MODE;
```

在查询语句后面加上 IN SHARE MODE 就代表查询数据的时候施加读锁。

注意：读锁和写锁是互斥的，只能有一把写锁或者任意多把读锁，也就是说如果先施加了写锁，就不能再施加读锁，因为两者互斥，当然更不能施加写锁，因为写锁只能有一把。如果先施加了读锁，那么不能再施加写锁，但是可以继续施加读锁，因为读锁可以有任意把。

所以可以得到如下结论：

- 更新数据的时候必然加写锁（MySQL 自动加），写锁和写锁是互斥的，此时别人不能更新；并且也不能加读锁，因为写锁和读锁也是互斥的；但可以查询，因为查询默认是不加锁的，它走的是 MVCC 机制，会读取快照版本；
- 查询数据的时候可以加读锁，但需要手动加，默认不加锁。并且读锁和写锁是互斥的，施加了读锁就不能再加写锁，但读锁和读锁之间是不互斥的，可以有任意把读锁；

![img](https://pic3.zhimg.com/80/v2-4e1e2fd8278bcb8f8bd29de6a91d3756_720w.webp)

不过说实话，一般开发业务系统的时候，主动给查询加读锁是很少见的。另外，我们说查询的时候默认没有锁，走的是 MVCC，但可以手动加读锁。其实除了读锁，查询的时候还可以手动加写锁。

```text
SELECT * FROM girl
WHERE age > 16 FOR UPDATE;
```

在查询语句后面加上 FOR UPDATE 则表示给该查询语句施加写锁，一般主要出现在事务查询完毕之后还要更新数据的时候。比如该数据非常重要，事务在处理的时候不希望受到干扰。而一旦查询的时候加了写锁，那么在事务提交之前，任何人都不能更新数据了，只能在当前事务里更新数据。而等该事务提交之后，别人才能继续更新。

> 另外，读锁也被称为共享锁和 S 锁，写锁也被称为排它锁、独占锁和 X 锁。这里我们一直说的是读锁和写锁，但在 MySQL 中更常说共享锁和独占锁（排它锁），当然意思都是一样的，我们理解就好。

## 4.**MySQL 的行锁、表锁和页面锁**

基于操作类型，我们将锁分为读锁和写锁，如果基于操作的数据粒度划分的话，还可以将锁分为行锁、表锁和页面锁。

像 IN SHARE MODE 和 FOR UPDATE 施加的都属于行锁，因此也可以说行级读锁和行级写锁。行锁是针对指定行进行加锁，比如：

```text
-- 更新数据，MySQL会自动施加写锁
-- 并且只对 id = 1 的行施加写锁
-- 其它行不受影响
UPDATE * FROM girl
SET age = age + 1 
WHERE id = 1;
```

行锁的特点是开销比较大，加锁速度慢，可能会出现死锁，但锁定的粒度最小，发生锁冲突的概率最小，并发度最高。

而表锁则是在整个数据表上对数据进行加锁和释放锁，特点是开销比较小，加锁速度快，一般不会出现死锁，但锁定的粒度比较大，发生锁冲突的概率最高，并发度最低。

在 MySQL 中可以通过以下方式手动添加表锁：

```text
-- 为 account 表增加表级读锁
lock table account read;

-- 为 account 表增加表级写锁
lock table account write;

-- 查看数据表上增加的锁
show open tables;

-- 删除添加的表锁
unlock tables;
```

但说实话，在工作中我们几乎不会使用表锁，好端端的锁整张表干什么。

最后是页面锁，也称为页级锁，就是在页级别对数据进行加锁和解锁。锁定的粒度介于表锁和行锁之间，并发度一般。

![img](https://pic1.zhimg.com/80/v2-03cea0dc0ddae375a85288657800de74_720w.webp)

工作中最常用的是行锁，表锁和页面锁基本不用，MySQL 也不会自动添加。但使用行锁的时候，有以下几点需要注意：

- 行锁主要加在索引上，如果以非索引字段作为条件进行更新，行锁可能会变成表锁；
- InnoDB 的行锁是针对索引加锁，不是针对记录加锁，并且加锁的索引不能失效，否则行锁可能会变成表锁；

另外行锁、表锁和页面锁都是 InnoDB 存储引擎的特性，可能有人觉得执行 ALTER TABLE 之类的 DDL 语句施加的也是表锁，虽然 DDL 语句和普通的增删改语句之间也是互斥的。但其实 DDL 语句执行时施加的不是表锁，而是元数据锁（metadata locks），这一点要注意。

## 5.**死锁的产生和预防**

虽然锁在一定程度上能够解决并发问题，但稍有不慎，就可能造成死锁。发生死锁的必要条件有 4 个，分别为互斥条件、不可剥夺条件、请求与保持条件和循环等待条件，如下图所示。

![img](https://pic1.zhimg.com/80/v2-11e21d50cc72e963a42a7f74a2970c68_720w.webp)

**1）互斥条件**

在一段时间内，计算机中的某个资源只能被一个进程占用，此时如果其他进程请求该资源，则只能等待。

**2）不可剥夺条件**

某个进程获得的资源在使用完毕之前，不能被其他进程强行夺走，只能由获得资源的进程主动释放。

**3）请求与保持条件**

进程已经获得了至少一个资源，又要请求其他资源，但请求的资源已经被其他进程占有，此时请求的进程就会被阻塞，并且不会释放自己已获得的资源。

**4）循环等待条件**

系统中的进程之间相互等待，同时各自占用的资源又会被下一个进程所请求。例如有进程 A、进程 B 和进程 C 三个进程，进程 A 请求的资源被进程 B 占用，进程 B 请求的资源被进程 C 占用，进程 C 请求的资源被进程 A 占用，于是形成了循环等待条件。

但需要注意的是，只有 4 个必要条件都满足时，才会发生死锁。而处理死锁有 4 种方法，分别为预防死锁、避免死锁、检测死锁和解除死锁。

![img](https://pic1.zhimg.com/80/v2-3df547aaa1243602ab01b9db89868de8_720w.webp)

- 预防死锁：处理死锁最直接的方法就是破坏造成死锁的 4 个必要条件中的一个或多个，以防止死锁的发生。
- 避免死锁：在系统资源的分配过程中，使用某种策略或者方法防止系统进入不安全状态，从而避免死锁的发生。
- 检测死锁：这种方法允许系统在运行过程中发生死锁，但是能够检测死锁的发生，并采取适当的措施清除死锁。
- 解除死锁：当检测出死锁后，采用适当的策略和方法将进程从死锁状态解脱出来。

在实际工作中，通常采用有序资源分配法和银行家算法这两种方式来避免死锁，有兴趣可自行了解一下。

## 6.**MySQL 的死锁问题**

在 MySQL 5.5.5 及以上版本中，默认存储引擎是 InnoDB。该存储引擎使用的是行级锁，在某种情况下会产生死锁问题，所以 InnoDB 存储引擎采用了一种叫作等待图（wait-for graph）的方法来自动检测死锁，如果发现死锁，就会自动回滚一个事务。我们举例说明：

第一步：在终端 1 中将事务隔离级别设置为可重复读，开启事务后为 account 数据表中 id 为 1 的数据添加排他锁。

![img](https://pic1.zhimg.com/80/v2-730be8e334304a2c3486604947f2c170_720w.webp)

第二步：在终端 2 中将事务隔离级别设置为可重复读，开启事务后为 account 数据表中 id 为 2 的数据添加排他锁。

![img](https://pic4.zhimg.com/80/v2-6cf4457fb42a48a5bf8a1a60b21c2c83_720w.webp)

第三步：在终端 1 中为 account 数据表中 id 为 2 的数据添加排他锁。

```text
select * from account 
where id = 2 for update;
```

此时事务 1 会阻塞住，因为它在等待事务 2 释放 id = 2 的排他锁。

第四步：在终端 2 中为 account 数据表中 id 为 1 的数据添加排他锁。

![img](https://pic3.zhimg.com/80/v2-5dda8518183b0d08fbf887ea8a7bdd26_720w.webp)

我们看到死锁了，事务 1 因事务 2 已经处于阻塞了，但此时事务 2 又因事务 1 陷入阻塞，因此出现了循环等待，所以事务 2 直接报错、并且终止。而一旦事务 2 终止，那么它施加的行锁就会失效，然后事务 1 就会给 id = 2 施加行锁成功，不再阻塞。

我们可以通过如下命令查看死锁的日志信息：show engine innodb status \G，或者通过配置 innodb_print_all_deadlocks（MySQL 5.6.2 版本开始提供）参数为 ON，将死锁相关信息打印到 MySQL 错误日志中。

原文地址：https://zhuanlan.zhihu.com/p/560890679

作者：linux