MySQL 锁机制

1 锁的分类

1.1 按照锁的粒度划分

  • 表级锁:
    • 锁定粒度大,发生锁冲突的概率最高,并发度最低;
    • 开销小,加锁快;
    • 不会出现死锁;
    • 支持的存储引擎:MyISAM、Memory、InnoDB、BDB 等。
  • 页级锁:
    • 锁定粒度界于表锁和行锁之间,并发度一般;
    • 开销和加锁时间界于表锁和行锁之间;
    • 会出现死锁;
    • 支持的存储引擎:BDB。
  • 行级锁:
    • 锁定粒度最小,发生锁冲突的概率最低,并发度最高;
    • 开销大,加锁慢;
    • 会出现死锁;
    • 支持的存储引擎:InnoDB。

三种级别的锁分别对应存储引擎关系如下表所示:

存储引擎行锁表锁页锁
InnoDBYY
MyISAMY
BDBYY

1.2 按操作的类型划分

  • 共享锁(Share Lock、S 锁、读锁)
  • 排它锁(eXclusive Lock、X 锁、写锁)
  • 意向锁 (Intention Locks):为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 引入了两种内部使用的意向锁:
    • 意向共享锁(IS 锁):表级锁,在对行添加 S 锁前,需要先获取所在表的 IS 锁。
    • 意向排它锁(IX 锁):表级锁,在对行添加 X 锁前,需要先获取所在表的 IX 锁。

1.3 按加锁思想划分

  • 悲观锁:修改一条数据的时候,悲观的认为这条数据可能也会被别人修改,于是在修改前先锁定。
  • 乐观锁:修改一条数据的时候,乐观的认为不会有它人修改,在更新提交的时候再进行冲突检测。

1.4 InnoDB 行级锁类型

  • Record Lock:单个行记录的锁(锁数据,不锁 Gap)。
  • Gap Lock:间隙锁,锁定一个范围,不包括记录本身(不锁数据,仅仅锁数据前面的 Gap)。
  • Next-key Lock:临键锁,锁住数据,同时锁住数据前面的 Gap。

2 共享锁 & 排它锁

2.1 共享锁 (Share Lock)

共享锁又称读锁,当数据被共享锁锁定时,其它事务可以并发读取数据,但不能对数据进行修改(无法获取数据上的排它锁),直到释放所有共享锁。如果事务 T1 对数据 A 加上了共享锁,则其它事务只能对 A 再加共享锁,不能加排它锁。获取到共享锁的事务只能读取数据,不能修改数据。

时序图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
--共享锁阻塞排它锁,且与其它共享锁并发
T1=============================|T2=============================
BEGIN;                         |BEGIN;
SELECT .. LOCK IN SHARE MODE;  |
   #OK                         |SELECT .. LOCK IN SHARE MODE;
                               |  #OK -- 成功实现并发共享
                               |SELECT ... FOR UPDATE;
                               |  |
                               |  |#Block -- 排它锁被阻塞
                               |  |
COMMIT; =======================+==|
                               |  #OK -- 读锁释放后,排它锁才 ok
                               |COMMIT;
                               V

2.2 排它锁 (Exclusive Lock)

排它锁又称写锁,如果事务 T1 对数据 A 加上排它锁后,其它事务不能再对 A 加任何类型的锁。获取到排它锁的事务既能读数据,又能修改数据。

时序图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 排它锁阻塞共享锁
T1=============================|T2=============================
BEGIN;                         |BEGIN;
SELECT .. FOR UPDATE;          |
   #OK                         |SELECT .. LOCK IN SHARE MODE;
                               |  |
                               |  |#Block... -- 共享锁也被阻塞了
                               |  |
COMMIT; =======================+==|
                               |  #OK -- 写锁释放后,其它锁才 ok
                               |COMMIT;
                               V

2.3 意向共享锁 & 意向排它锁

表锁和行锁虽然锁定范围不同,但仍然会相互冲突,比如在对表施加表锁时,仍需要遍历所有记录,以确定是否有记录被设置了排它行锁,这种遍历检查的方式显然十分低效。InnoDB 引入意向锁,可以有效的避免遍历所有行记录。

意向锁也是表级锁,分为意向共享锁(IS 锁)意向排它锁(IX 锁)。当事务要在记录上加共享锁或排它锁时,首先要在表上加对应的意向锁。如此一来当 InnoDB 要对表加表锁的时候,只需要读取意向锁就可以了解表内的加锁情况,不再需要遍历每条记录了。

意向锁不和其它意向锁冲突,也不和 AUTO_INC 表锁冲突,它只会阻塞表级读锁或表级写锁,另外,意向锁也不会和行锁冲突

意向锁是 InnoDB 自动获取的,不需要用户干预。

3 乐观锁 & 悲观锁

无论是悲观锁还是乐观锁,都不是实际意义上“锁”,而是一种思想。针对不同的业务场景,应该选用不同的并发控制方式。所以,不要把乐观并发控制和悲观并发控制狭义的理解为 MySQL 中的概念,更不要把它们和数据中提供的锁机制(行锁、表锁、排它锁、共享锁)混为一谈。其实,在 MySQL 中,悲观锁正是利用数据库本身提供的锁机制来实现的。

3.1 悲观锁

在关系型数据库中,悲观锁即悲观并发控制 (Pessimistic Concurrency Control, PCC),是一种并发事务控制的方法。

悲观锁正如其名,它指的是对数据被其它事务修改持悲观态度,因此在整个数据处理的过程中,会将数据锁定。悲观锁的实现,往往依靠数据库提供的锁机制。它可以阻止一个事务以影响其它用户的方式来修改数据。如果一个事务执行的操作对某行数据应用了锁,那只有当这个事务把锁释放,其它事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。

悲观锁的实现流程如下:

  • 在对任意记录进行修改前,先尝试为该记录加上排它锁(X Lock)。
  • 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常,具体响应方式由开发者根据实际需要决定。
  • 如果成功加锁,就对记录做修改,事务完成后就会解锁了。
  • 其间如果有其它对该记录做修改或加排它锁的操作,都会等待当前事务解锁或直接抛出异常。

3.2 乐观锁

在关系型数据库中,乐观锁即乐观并发控制 (Optimistic Concurrency Control, OCC),也是一种并发事务控制的方法。

乐观锁假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其它事务又修改了该数据。如果其它事务有更新的话,返回错误信息,然用户决定如何处理。

乐观锁的实现流程如下:

相对于悲观锁,乐观锁并不会使用数据库提供的锁机制,一般的实现方式是记录数据版本 (MVCC)。

  • 当读取数据时,将版本标识的值一同读出;
  • 数据每更新一次,同时对版本标识进行更新。
  • 当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对:
    • 如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新;
    • 否则认为是过期数据。

4 表锁

表级锁是 MySQL 中粒度最大的一种锁,表示当前的操作对整张表加锁,资源开销比行锁少,不会出现死锁的情况,但是发生锁冲突的概率很大。

表锁由 MySQL Server 层实现,尽管存储引擎可以管理自己的锁,MySQL 本身还是会使用各种有效的表锁来实现不同的目的。例如,服务器会为诸如 ALTER TABLE 之类的语句使用表锁,而忽略存储引擎的锁机制。

表锁使用的是一次性锁技术,也就是说,在会话开始的地方使用 lock 命令将后续需要用到的表都加上锁,在表释放前,只能访问这些加锁的表,不能访问其它表,直到最后通过 unlock tables 释放所有表锁。除了使用 unlock tables 显示释放锁之外,会话持有其它表锁时执行 lock table 语句会释放会话之前持有的锁。会话持有其它表锁时执行 start transaction 或者 begin 开启事务时,也会释放之前持有的锁。

在特定的场景中,表锁也可能有良好的性能。例如,READ LOCAL 表锁支持某些类型的并发写操作。

5 页锁

页级锁是 MySQL 中锁定粒度介于行级锁和表级锁中间的一种锁。

表级锁速度快,但冲突多,行级冲突少,但速度慢,因此产生了折衷的页级锁,一次锁定相邻的一组记录。

MySQL 仅有 BDB 支持页级锁。

6 行级锁

行级锁可以在最大程度上支持并发处理(尽管这也带来了最大的锁开销)。行级锁是在存储引擎层实现的,MySQL Server 层对存储引擎中锁的实现完全不了解。

6.1 记录锁 (Record Lock)

记录锁是一种简单而重要的行级锁,用于保护数据库中的单个记录(行)。它分为两种类型:共享锁和排它锁。

在事务需要读取或修改某个记录时,会尝试获取相应的记录锁。如果其它事务已经持有相同的排它锁或其它事务持有共享锁,当前事务可能需要等待,直到锁可用为止。

记录锁会在以下情况下自动释放:

  • 事务结束:当事务 COMMITROLLBACK 时,MySQL 会自动释放该事务持有的所有记录锁。
  • 事务超时:如果一个事务持有的记录锁的时间超过了事务超时设置的时间(通过参数 innodb_lock_wait_timeout 来配置),MySQL 会自动将这些锁释放。
  • 显式释放:通过执行显式的语句来释放记录锁,如以下语句:
    • UNLOCK TABLES:使用该语句可以释放事务中所有表的所有记录锁。

      注意,UNLOCK TABLES 还会释放由 LOCK TABLES 语句获取的表级锁。

    • RELEASE SAVEPOINT:如果事务中使用了保存点 (SAVEPOINT),可以使用 RELEASE SAVEPOINT 语句来释放到指定保存点之间的记录锁。

6.2 间隙锁 (Gap Lock)

间隙锁用于防止其它事务在范围查询期间插入新的数据或更新已存在的数据(幻读问题)。当一个事务执行范围查询时,MySQL 会自动获取范围内的间隙锁,防止其它事务在这个范围内插入新的数据。

在索引中,间隙是指两个索引值之间的区域,即一个范围。

间隙锁不仅会锁定范围内的间隙,还会锁定范围内的记录,即使这些记录并不存在。这就导致了其它事务无法在这个间隙内插入新的记录,即使这些记录不与已存在的记录冲突。

间隙锁存在于 REPEATABLE READ 隔离级别,如需关闭,可以降低隔离级别或者开启 innodb_locks_unsafe_for_binlog(不推荐)。

6.3 临键锁 (Next-key Lock)

临键锁是记录锁和间隙锁的组合,它锁定的范围即包含索引记录又包含索引区间。它指的是加在某条记录以及这条记录前面间隙上的锁。

假设一个索引包含 15、18、20 ,30,49,50 这几个值,可能的 Next-key 锁如下:

1
(-∞, 15],(15, 18],(18, 20],(20, 30],(30, 49],(49, 50],(50, +∞)

7 死锁异常

死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁多个事务同时锁定同一个资源时,也会产生死锁。

例如,设想下面两个事务同时处理 StockPrice 表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
--T1:
START TRANSACTION;
UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 and date = '2021-10-01';
UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 and date = '2021-10-02';
COMMIT;

--T2:
START TRANSACTION;
UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 and date = '2021-10-02';
UPDATE StockPrice SET high = 47.20 WHERE stock_id = 4 and date = '2021-10-01';
COMMIT;

如果不巧的话,两个事务同时执行了第一条 UPDATE 语句并更新了同一行数据,导致该行数据被锁定。接着,每个事务都尝试执行第二条 UPDATE 语句,但发现该行数据已被对方锁定,于是两个事务都在等待对方释放锁,同时又持有对方需要的锁,这样就陷入了死循环。除非有外部因素介入,否则无法解除死锁。

锁的行为和顺序是与存储引擎相关的。在以相同顺序执行语句的情况下,某些存储引擎可能会产生死锁,而其它存储引擎则不会。死锁的产生有两个主要原因:一部分是由于真正的数据冲突,这种情况通常很难避免;另一部分则完全是由存储引擎的实现方式导致的。

7.1 处理死锁的方法

为了解决死锁问题,数据库系统实现了多种死锁检测和死锁超时机制。对于复杂的系统,例如 InnoDB 存储引擎,它能够更有效地检测死锁的循环依赖,并立即返回错误。这种解决方式非常有效,否则死锁会导致查询变得非常缓慢。

另一种解决死锁问题的方式是当查询等待锁的时间达到超时设定后放弃锁请求,尽管这种方式通常并不理想。

对于 InnoDB 存储引擎,它目前采用的死锁处理方法是回滚持有最少行级排它锁的事务(这是一种相对较简单的死锁回滚算法)。

7.2 开发建议

为了降低死锁可能,尽量采用以下开发方法:

  • 更新 SQL 的 WHERE 条件一定要用到索引 (全表扫描会对所有行施加 X 锁,这是灾难级的);
  • 加锁索引准确,缩小锁定范围;
  • 减少范围更新,尤其非主键/非唯一索引上的范围更新。
  • 控制事务大小,减少锁定数据量和锁定时间长度 (innodb_row_lock_time_avg)。
  • 加锁顺序一致,尽可能一次性锁定所有所需的数据行。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容