楔子
本篇文章来聊一下 MySQL 的锁,首先不光是数据库,任何的一门高级语言也都内置了锁机制。从本质上讲,锁是一种协调多个进程或多个线程对某一资源进行访问的机制。
而之所以要存在锁,是因为在并发编程中,程序的某一部分在并发访问的时候会导致意想不到的结果。所以这部分程序就需要用锁保护起来,而保护起来的部分就叫做临界区。
在 MySQL 中,按照不同的角度,可以将锁分为如下几种:
这么多的锁,我们该怎么区分呢?下面就来逐一回答。
脏写是如何避免的
在区分锁之前,先来回顾一个问题,前面我们说四种隔离级别,无论哪一种都可以避免脏写的问题。但怎么避免的当时却没有解释,原因就是涉及到了锁,下面来解释一下。
再来回顾一下什么是脏写,假设事务 A 和 事务 B 同时对张三的账户余额进行更新,初始值为 100,那么两个事务拿到的也都是 100。然后事务 A 给余额增加 100,事务 B 给余额增加 200。理论上最终应该是 400 才对,但如果 A 先提交 B 后提交,最终的结果却是 300;B 先提交 A 后提交,最终的结果就是 200。
以上这种现象就是脏写,具体表现为:两个事务更新同一条数据,后提交的事务将先提交的事务所做的更新给覆盖了。
那么如何避免呢?显然要依赖锁,多个事务在更新同一条数据的时候要串行更新。
所以当事务在更新数据的时候,会先看这条数据有没有人加锁。如果没有,那么该事务就会创建一个锁,里面包含了事务 ID(trx_id) 和等待状态,然后将锁和这条数据关联在一起。
事务 A 更新数据的时候,会给数据加锁,然后别的事务就不能再更新了。但假设这个时候又来个事务 B 也要更新这条数据,它会怎么做呢?
首先还是判断数据有没有人加锁,结果发现被事务 A 加锁了,就知道自己不能修改这条数据。但事务 B 仍会对这条数据加锁,只不过它处于等待状态。
当事务 A 更新完数据,就会将自己的锁释放掉,并且还会去找,有没有别人也对这条数据加了锁。显然它会发现该数据也被事务 B 加锁了,于是会把事务 B 锁里的等待状态修改为 false,然后唤醒事务 B 开始执行,此时事务 B 就获取到锁了。
以上就是 MySQL 锁机制的一个最基本的原理,其实就和 Python 里面的互斥锁是一样的,但是基于此我们又引申出了很多不同种类的锁。
MySQL 的读锁和写锁
先来聊聊读锁和写锁,读锁也被称为共享锁、S 锁,写锁也被称为独占锁、排它锁、X 锁。而上面多个事务在更新数据时加的锁,就是写锁。
那么问题来了,如果一个事务在读数据的时候,发现这条数据被加锁了,那么该事务需要继续加锁吗?如果是更新数据,那么需要加锁,但读数据是不需要的。因为默认情况下,如果是读数据,会走 MVCC 机制。
因为读数据可以根据 ReadView 在 undo log 版本链里找一个能读取的版本,完全不用考虑是否有别的事务在更新,ReadView 机制不允许当前事务读取别的事务已经更新的值。所以默认情况下读数据完全不需要加锁,更不需要关心别的事务是否在更新数据,直接基于 MVCC 机制读某个快照即可。
但如果就是想在读数据的时候加锁呢?答案是使用读锁,也叫 S 锁、共享锁。
SELECT * FROM girl WHERE age > 16 IN SHARE MODE;
在查询语句后面加上 IN SHARE MODE 就代表查询数据的时候施加读锁。
注意:读锁和写锁是互斥的,只能有一把写锁或者任意多把读锁,也就是说如果先施加了写锁,就不能再施加读锁,因为两者互斥,当然更不能施加写锁,因为写锁只能有一把。如果先施加了读锁,那么不能再施加写锁,但是可以继续施加读锁,因为读锁可以有任意把。
所以可以得到如下结论:
- 更新数据的时候必然加写锁(MySQL 自动加),写锁和写锁是互斥的,此时别人不能更新;并且也不能加读锁,因为写锁和读锁也是互斥的;但可以查询,因为查询默认是不加锁的,它走的是 MVCC 机制,会读取快照版本;
- 查询数据的时候可以加读锁,但需要手动加,默认不加锁。并且读锁和写锁是互斥的,施加了读锁就不能再加写锁,但读锁和读锁之间是不互斥的,可以有任意把读锁;
不过说实话,一般开发业务系统的时候,主动给查询加读锁是很少见的。另外,我们说查询的时候默认没有锁,走的是 MVCC,但可以手动加读锁。其实除了读锁,查询的时候还可以手动加写锁。
SELECT * FROM girl WHERE age > 16 FOR UPDATE;
在查询语句后面加上 FOR UPDATE 则表示给该查询语句施加写锁,一般主要出现在事务查询完毕之后还要更新数据的时候。比如该数据非常重要,事务在处理的时候不希望受到干扰。而一旦查询的时候加了写锁,那么在事务提交之前,任何人都不能更新数据了,只能在当前事务里更新数据。而等该事务提交之后,别人才能继续更新。
另外,读锁也被称为共享锁和 S 锁,写锁也被称为排它锁、独占锁和 X 锁。这里我们一直说的是读锁和写锁,但在 MySQL 中更常说共享锁和独占锁(排它锁),当然意思都是一样的,我们理解就好。
MySQL 的行锁、表锁和页面锁
基于操作类型,我们将锁分为读锁和写锁,如果基于操作的数据粒度划分的话,还可以将锁分为行锁、表锁和页面锁。
像 IN SHARE MODE 和 FOR UPDATE 施加的都属于行锁,因此也可以说行级读锁和行级写锁。行锁是针对指定行进行加锁,比如:
-- 更新数据,MySQL会自动施加写锁 -- 并且只对 id = 1 的行施加写锁 -- 其它行不受影响 UPDATE * FROM girl SET age = age + 1 WHERE id = 1;
行锁的特点是开销比较大,加锁速度慢,可能会出现死锁,但锁定的粒度最小,发生锁冲突的概率最小,并发度最高。
而表锁则是在整个数据表上对数据进行加锁和释放锁,特点是开销比较小,加锁速度快,一般不会出现死锁,但锁定的粒度比较大,发生锁冲突的概率最高,并发度最低。
在 MySQL 中可以通过以下方式手动添加表锁:
-- 为 account 表增加表级读锁 lock table account read; -- 为 account 表增加表级写锁 lock table account write; -- 查看数据表上增加的锁 show open tables; -- 删除添加的表锁 unlock tables;
但说实话,在工作中我们几乎不会使用表锁,好端端的锁整张表干什么。
最后是页面锁,也称为页级锁,就是在页级别对数据进行加锁和解锁。锁定的粒度介于表锁和行锁之间,并发度一般。
工作中最常用的是行锁,表锁和页面锁基本不用,MySQL 也不会自动添加。但使用行锁的时候,有以下几点需要注意:
- 行锁主要加在索引上,如果以非索引字段作为条件进行更新,行锁可能会变成表锁;
- InnoDB 的行锁是针对索引加锁,不是针对记录加锁,并且加锁的索引不能失效,否则行锁可能会变成表锁;
另外行锁、表锁和页面锁都是 InnoDB 存储引擎的特性,可能有人觉得执行 ALTER TABLE 之类的 DDL 语句施加的也是表锁,虽然 DDL 语句和普通的增删改语句之间也是互斥的。但其实 DDL 语句执行时施加的不是表锁,而是元数据锁(metadata locks),这一点要注意。
死锁的产生和预防
虽然锁在一定程度上能够解决并发问题,但稍有不慎,就可能造成死锁。发生死锁的必要条件有 4 个,分别为互斥条件、不可剥夺条件、请求与保持条件和循环等待条件,如下图所示。
1)互斥条件
在一段时间内,计算机中的某个资源只能被一个进程占用,此时如果其他进程请求该资源,则只能等待。
2)不可剥夺条件
某个进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得资源的进程主动释放。
3)请求与保持条件
进程已经获得了至少一个资源,又要请求其他资源,但请求的资源已经被其他进程占有,此时请求的进程就会被阻塞,并且不会释放自己已获得的资源。
4)循环等待条件
系统中的进程之间相互等待,同时各自占用的资源又会被下一个进程所请求。例如有进程 A、进程 B 和进程 C 三个进程,进程 A 请求的资源被进程 B 占用,进程 B 请求的资源被进程 C 占用,进程 C 请求的资源被进程 A 占用,于是形成了循环等待条件。
但需要注意的是,只有 4 个必要条件都满足时,才会发生死锁。而处理死锁有 4 种方法,分别为预防死锁、避免死锁、检测死锁和解除死锁。
- 预防死锁:处理死锁最直接的方法就是破坏造成死锁的 4 个必要条件中的一个或多个,以防止死锁的发生。
- 避免死锁:在系统资源的分配过程中,使用某种策略或者方法防止系统进入不安全状态,从而避免死锁的发生。
- 检测死锁:这种方法允许系统在运行过程中发生死锁,但是能够检测死锁的发生,并采取适当的措施清除死锁。
- 解除死锁:当检测出死锁后,采用适当的策略和方法将进程从死锁状态解脱出来。
在实际工作中,通常采用有序资源分配法和银行家算法这两种方式来避免死锁,有兴趣可自行了解一下。
MySQL 的死锁问题
在 MySQL 5.5.5 及以上版本中,默认存储引擎是 InnoDB。该存储引擎使用的是行级锁,在某种情况下会产生死锁问题,所以 InnoDB 存储引擎采用了一种叫作等待图(wait-for graph)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务。我们举例说明:
第一步:在终端 1 中将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 1 的数据添加排他锁。
第二步:在终端 2 中将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 2 的数据添加排他锁。
第三步:在终端 1 中为 account 数据表中 id 为 2 的数据添加排他锁。
select * from account where id = 2 for update;
此时事务 1 会阻塞住,因为它在等待事务 2 释放 id = 2 的排他锁。
第四步:在终端 2 中为 account 数据表中 id 为 1 的数据添加排他锁。
我们看到死锁了,事务 1 因事务 2 已经处于阻塞了,但此时事务 2 又因事务 1 陷入阻塞,因此出现了循环等待,所以事务 2 直接报错、并且终止。而一旦事务 2 终止,那么它施加的行锁就会失效,然后事务 1 就会给 id = 2 施加行锁成功,不再阻塞。
我们可以通过如下命令查看死锁的日志信息:show engine innodb status \G,或者通过配置 innodb_print_all_deadlocks(MySQL 5.6.2 版本开始提供)参数为 ON,将死锁相关信息打印到 MySQL 错误日志中。
本文参考自:
- 儒猿技术窝《MySQL 实战高手》