程序员scholar 程序员scholar
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
首页
  • Java 基础

    • JavaSE
    • JavaIO
    • JavaAPI速查
  • Java 高级

    • JUC
    • JVM
    • Java新特性
    • 设计模式
  • Web 开发

    • Servlet
    • Java网络编程
  • Web 标准

    • HTML
    • CSS
    • JavaScript
  • 前端框架

    • Vue2
    • Vue3
    • Vue3 + TS
    • 微信小程序
    • uni-app
  • 工具与库

    • jQuery
    • Ajax
    • Axios
    • Webpack
    • Vuex
    • WebSocket
    • 第三方登录
  • 后端与语言扩展

    • ES6
    • Typescript
    • node.js
  • Element-UI
  • Apache ECharts
  • 数据结构
  • HTTP协议
  • HTTPS协议
  • 计算机网络
  • Linux常用命令
  • Windows常用命令
  • SQL数据库

    • MySQL
    • MySQL速查
  • NoSQL数据库

    • Redis
    • ElasticSearch
  • 数据库

    • MyBatis
    • MyBatis-Plus
  • 消息中间件

    • RabbitMQ
  • 服务器

    • Nginx
  • Spring框架

    • Spring6
    • SpringMVC
    • SpringBoot
    • SpringSecurity
  • SpringCould微服务

    • SpringCloud基础
    • 微服务之DDD架构思想
  • 日常必备

    • 开发常用工具包
    • Hutoll工具包
    • IDEA常用配置
    • 开发笔记
    • 日常记录
    • 项目部署
    • 网站导航
    • 产品学习
    • 英语学习
  • 代码管理

    • Maven
    • Git教程
    • Git小乌龟教程
  • 运维工具

    • Docker
    • Jenkins
    • Kubernetes
  • 算法笔记

    • 算法思想
    • 刷题笔记
  • 面试问题常见

    • 十大经典排序算法
    • 面试常见问题集锦
关于
GitHub (opens new window)
npm

(进入注册为作者充电)

  • MySQL基础

  • MySQL高级

    • 第01章_Linux下安装MySQL
    • 第02章_MySQL的数据目录
    • 第03章_用户与权限管理
    • 第04章_逻辑架构
    • 第05章_存储引擎
    • 第06章_索引的数据结构
    • 第07章_InnoDB数据结构
    • 第08章_索引的创建与设计
    • 第09章_性能分析工具
    • 第10章_索引优化与优化
    • 第11章_数据库的设计规范
    • 第12章_数据库调优策略
    • 第13章_事务基础知识
    • 第14章_MySQL事务日志
    • 第15章_锁
      • 1. 概述
      • 2. MySQL并发事务访问相同记录
        • 2.1 读-读情况
        • 2.2 写-写情况
        • 2.3 读-写或写-读情况
        • 2.4 并发问题的解决方案
      • 3. 锁的不同角度分类
        • 3.1 从数据操作的类型划分:读锁、写锁
        • 1.锁定读
        • 2. 写操作
        • 3.2 从数据操作的粒度划分:表级锁、页级锁、行锁
        • 1. 表锁(Table Lock)
        • ① 表级别的S锁、X锁
        • ② 意向锁 (intention lock)
        • ③ 自增锁(AUTO-INC锁)
        • ④ 元数据锁(MDL锁)
        • 2. InnoDB中的行锁
        • ① 记录锁(Record Locks)
        • ② 间隙锁(Gap Locks)
        • 例子1:简单的间隙锁
        • 例子2:防止幻读
        • 例子3:间隙锁与行锁共存
        • ③ 临键锁(Next-Key Locks)
        • ④ 插入意向锁 (Insert Intention Locks)
        • 3. 页锁
        • 3.3 从对待锁的态度划分:乐观锁、悲观锁
        • 1. 悲观锁(Pessimistic Locking)
        • 2. 乐观锁(Optimistic Locking)
        • 3. 两种锁的适用场景
        • 3.4 按加锁的方式划分:显式锁、隐式锁
        • 1. 隐式锁
        • 2. 显式锁
        • 3.5 其它锁之:全局锁
        • 3.6 其它锁之:死锁
        • 1. 概念
        • 2. 产生死锁的必要条件
        • 3. 如何处理死锁
        • 4. 如何避免死锁
      • 4. 锁的内部结构
      • 5. 锁监控
      • 6. 附录
    • 第16章_多版本并发控制
    • 第17章_其他数据库日志
    • 第18章_主从复制
    • 第19章_数据库备份与恢复
    • 第20章_Docker实现主从复制
  • MySQL
  • MySQL高级
scholar
2023-08-29
目录

第15章_锁

友情提示

转载须知: 以下所有文章整理于B站宋红康老师的《MySQL数据库入门到大牛》。MySQL (opens new window)

# 1. 概述

锁是计算机协调多个进程或线程并发访问某一资源的机制。在程序开发中会存在多线程同步的问题,当多个线程并发访问某个数据的时候,尤其是针对一些敏感的数据(比如订单、金额等),我们就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性。在开发过程中加锁是为了保证数据的一致性,这个思想在数据库领域中同样很重要。

在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的 资源。为保证数据的一致性,需要对 并发操作进行控制 ,因此产生了 锁 。同时 锁机制 也为实现MySQL 的各个隔离级别提供了保证。 锁冲突 也是影响数据库 并发访问性能 的一个重要因素。所以锁对数据库而言显得尤其重要,也更加复杂。

# 2. MySQL并发事务访问相同记录

并发事务访问相同记录的情况大致可以划分为3种:

# 2.1 读-读情况

读-读情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生。

# 2.2 写-写情况

写-写 情况,即并发事务相继对相同的记录做出改动。

在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们 排队执行 ,这个排队的过程其实是通过 锁 来实现的。这个所谓的锁其实是一个内存中的结构 ,在事务执行前本来是没有锁的,也就是说一开始是没有 锁结构 和记录进行关联的,如图所示:

image-20220711181120639

当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的 锁结构 ,当没有的时候就会在内存中生成一个 锁结构 与之关联。比如,事务T1 要对这条记录做改动,就需要生成一个 锁结构 与之关联:

image-20220711192633239

在锁结构里有很多信息,为了简化理解,只把两个比较重要的属性拿了出来:

  • trx信息:代表这个锁结构是哪个事务生成的。
  • is_waiting:代表当前事务是否在等待。

在事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称值为获取锁成功,或者加锁成功,然后就可以继续执行操作了。

在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,图示:

image-20220711193732567

在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。效果就是这样。

image-20220711194904328

小结几种说法:

  • 不加锁:意思就是不需要在内存中生成对应的 锁结构 ,可以直接执行操作。

  • 获取锁成功,或者加锁成功:意思就是在内存中生成了对应的 锁结构 ,而且锁结构的 is_waiting 属性为 false ,也就是事务 可以继续执行操作。

  • 获取锁失败,或者加锁失败,或者没有获取到锁:意思就是在内存中生成了对应的 锁结构 ,不过锁结构的 is_waiting 属性为 true ,也就是事务 需要等待,不可以继续执行操作。

# 2.3 读-写或写-读情况

读-写 或 写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、 不可重复读 、 幻读 的问题。

各个数据库厂商对 SQL标准 的支持都可能不一样。比如MySQL在 REPEATABLE READ 隔离级别上就已经解决了 幻读 问题。

# 2.4 并发问题的解决方案

怎么解决 脏读 、 不可重复读 、 幻读 这些问题呢?其实有两种可选的解决方案:

  • 方案一:读操作利用多版本并发控制( MVCC ,下章讲解),写操作进行 加锁 。

所谓的 MVCC,就是生成一个 ReadView,通过 ReadView 找到符合条件的记录版本(历史版本由 undo 日志构建)。查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用 MVCC 时,读-写操作并不冲突。

普通的SELECT语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用到MVCC读取记录。

  • 在 READ COMMITTED 隔离级别下,一个事务在执行过程中每次执行SELECT操作时都会生成一个ReadView,ReadView的存在本身就保证了事务不可以读取到未提交的事务所做的更改 ,也就是避免了脏读现象;
  • 在 REPEATABLE READ 隔离级别下,一个事务在执行过程中只有 第一次执行SELECT操作 才会生成一个ReadView,之后的SELECT操作都 复用 这个ReadView,这样也就避免了不可重复读和幻读的问题。
  • 方案二:读、写操作都采用 加锁 的方式。

① 如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本。比如,在银行存款的事务中,你需要先把账户的余额读出来,然后将其加上本次存款的数额,最后再写到数据库中。在将账户余额读取出来后,就不想让别的事务再访问该余额,直到本次存款事务执行完成,其他事务才可以访问账户的余额。这样在读取记录的时候就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。

② 脏读的产生是因为当前事务读取了另一个未提交事务写的一条记录,如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就无法继续读取该记录了,所以也就不会有脏读问题的产生了。

③ 不可重复读的产生是因为当前事务先读取一条记录,另外一个事务对该记录做了改动之后并提交之后,当前事务再次读取时会获得不同的值,如果在当前事务读取记录时就给该记录加锁,那么另一个事务就无法修改该记录,自然也不会发生不可重复读了。

④ 幻读问题的产生是因为当前事务读取了一个范围的记录,然后另外的事务向该范围内插入了新记录,当前事务再次读取该范围的记录时发现了新插入的新记录。采用加锁的方式解决幻读问题就有一些麻烦,因为当前事务在第一次读取记录时幻影记录并不存在,所以读取的时候加锁就有点尴尬(因为你并不知道给谁加锁)。

  • 小结对比发现:

    • 采用 MVCC 方式的话, 读-写 操作彼此并不冲突, 性能更高 。
    • 采用 加锁 方式的话, 读-写 操作彼此需要 排队执行 ,影响性能。

    一般情况下我们当然愿意采用 MVCC 来解决 读-写 操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用 加锁的方式执行。下面就讲解下MySQL中不同类别的锁。

# 3. 锁的不同角度分类

锁的分类图,如下:

image-20220711203519162

# 3.1 从数据操作的类型划分:读锁、写锁

对于数据库中并发事务的读-读情况并不会引起什么问题。对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用 MVCC 或者加锁的方式来解决它们。在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以 MySQL 实现一个由两种类型的锁组成的锁系统来解决。这两种类型的锁通常被称为共享锁 (Shared Lock, SLock) 和排他锁 (Exclusive Lock, X Lock),也叫读锁 (readlock) 和写锁 (write lock)。

  • 读锁 :也称为 共享锁 、英文用 S 表示。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。
  • 写锁 :也称为 排他锁 、英文用 X 表示。当前写操作没有完成前,它会阻断其他写锁和读锁。这样 就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

需要注意的是对于 InnoDB 引擎来说,读锁和写锁可以加在表上,也可以加在行上。

举例(行级读写锁): 如果一个事务 T1 已经获得了某个行 r 的读锁,那么此时另外的一个事务 T2 是可以去获得这个行 r 的读锁的,因为读取操作并没有改变行 r 的数据;但是,如果某个事务 T3 想获得行 r 的写锁,则它必须等待事务 T1、T2 释放掉行 r 上的读锁才行。

总结:这里的兼容是指对同一张表或记录的锁的兼容性情况。

X 锁 S 锁
X 锁 不兼容 不兼容
S 锁 不兼容 兼容

# 1.锁定读

在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取该记录的S 锁,其实是不严谨的,有时候需要在读取记录时就获取记录的X 锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:

  • 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
# 或
SELECT ... FOR SHARE; # (8.0 新增语法)
1
2
3

在普通的 SELECT 语句后边加 LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加 S 锁,这样允许别的事务继续获取这些记录的 S 锁(比方说别的事务也使用 SELECT … LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的 X 锁(比如使用 SELECT … FOR UPDATE 语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 S 锁释放掉。

  • 对读取的记录加X锁:
SELECT ... FOR UPDATE;
1

在普通的 SELECT 语句后边加 FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加 X 锁,这样既不允许别的事务获取这些记录的 S 锁(比方说别的事务使用 SELECT … LOCK IN SHARE MODE 语句来读取这些记录),也不允许获取这些记录的 X 锁(比如使用 SELECT … FOR UPDATE 语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的 S 锁或者 X 锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的 X 锁释放掉。

MySQL 8.0 新特性

在 5.7 及之前的版本,对于 SELECT … FOR UPDATE,如果获取不到锁,会一直等待,直到 innodb_lock_wait_timeout 超时。在 8.0 版本中,通过在 SELECT … FOR UPDATE, SELECT … FOR SHARE 后面添加 NOWAIT、SKIP LOCKED 可跳过锁等待,或者跳过锁定。如果查询的行已经加锁:

  • NOWAIT:如果查询的行已经加锁,会立即报错返回;
  • SKIP LOCKED:如果查询的行已经加锁,只返回结果中不包含被锁定的行;
# session 1
mysql> begin;
mysql> select * from t1 where c1 = 2 for update;
+------+-------+
| c1   | c2l   |
+--—---+-------+
| 2    | 60530 |
| 2    | 24678 |
+------+-—-----+
2 rows in set ( 0.00 sec)

# session 2
mysql> select * from t1 where c1 = 2 for update nowait;
ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
mysql> select * from t1 where c1 = 2 for update skip locked;
Empty set (0.00 sec)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 2. 写操作

平常所用到的写操作无非是 DELETE、UPDATE、INSERT 这三种:

DELETE:对一条记录做 DELETE 操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的X锁,再执行delete mark操作。

UPDATE:在对一条记录做UPDATE操作时分为三种情况:

  • 情况1: 未修改该记录的键值,并且被更新的列占用的存储空间在修改前后未发生变化。则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原纪录的位置进行修改操作。
  • 情况2: 未修改该记录的键值,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。新插入的记录由INSERT操作提供的隐式锁进行保护。
  • 情况3: 修改该记录的键值,则相当于在原纪录上做DELECT操作之后再来一次INSERT操作。

INSERT:一般情况下,新插入一条记录的操作并不加锁,通过一种称之为隐式锁的结构来保护这条新插入的记录在本事务提交前不被别的事务访问。

# 3.2 从数据操作的粒度划分:表级锁、页级锁、行锁

为了尽可能提高数据库的并发度,每次锁定的数据范围越小越好,理论上每次只锁定当前操作的数据的方案会得到最大的并发度,但是管理锁是很耗资源的事情(涉及获取、检查、释放锁等动作)。因此数据库系统需要在高并发响应和系统性能两方面进行平衡,这样就产生了 “锁粒度 (Lock granularity)” 的概念。

对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。锁的粒度主要分为表级锁、页级锁和行锁。

# 1. 表锁(Table Lock)

该锁会锁定整张表,它是 MySQL 中最基本的锁策略,并不依赖于存储引擎,并且表锁是开销最少的策略。由于表级锁一次会将整个表锁定,所以可以很好的避免死锁的问题。当然,锁的粒度大所带来最大的负面影响就是出现锁资源争用的概率也会最高,导致并发率大打折扣。

# ① 表级别的S锁、X锁

在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的 S锁 或者 X锁 的。在对某个表执行一些诸如 ALTER TABLE 、 DROP TABLE 这类的 DDL 语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。

同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行 DDL 语句也会 发生阻塞。这个过程其实是通过在 server层使用一种称之为 元数据锁 (英文名: Metadata Locks , 简称 MDL )结构来实现的。

一般情况下,不会使用InnoDB存储引擎提供的表级别的 S锁 和 X锁 。只会在一些特殊情况下,比方说 崩溃恢复 过程中用到。比如,在系统变量 autocommit=0,innodb_table_locks = 1 时, 手动 获取 InnoDB存储引擎提供的表t 的 S锁 或者 X锁 可以这么写:

  • LOCK TABLES t READ :InnoDB存储引擎会对表 t 加表级别的 S锁。

  • LOCK TABLES t WRITE :InnoDB存储引擎会对表 t 加表级别的 X锁 。

不过尽量避免在使用InnoDB存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句,它们并不会提供 什么额外的保护,只是会降低并发能力而已。InnoDB的厉害之处还是实现了更细粒度的 行锁 ,关于 InnoDB表级别的 S锁 和X锁 大家了解一下就可以了。

举例: 下面我们讲解MyISAM引擎下的表锁。

步骤1:创建表并添加数据

use atguigudb3;

CREATE TABLE mylock(
id INT NOT NULL PRIMARY KEY auto_increment,
NAME VARCHAR(20)
)ENGINE myisam;  # 存储引擎使用 InnoDB 也可以,只是不建议

# 插入一条数据
INSERT INTO mylock(NAME) VALUES('a');

# 查询表中所有数据
SELECT * FROM mylock;
+----+------+
| id | Name |
+----+------+
| 1  | a    |
+----+------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

步骤二:查看表上加过的锁

SHOW OPEN TABLES; # 主要关注In_use字段的值
或者
SHOW OPEN TABLES where In_use > 0;
1
2
3

image-20240310054830332

或者

image-20240310054847182

上面的结果表明,当前数据库中没有被锁定的表

步骤3:手动增加表锁命令

LOCK TABLES t READ; # 存储引擎会对表t加表级别的共享锁。共享锁也叫读锁或S锁(Share的缩写)
LOCK TABLES t WRITE; # 存储引擎会对表t加表级别的排他锁。排他锁也叫独占锁、写锁或X锁(exclusive的缩写)
1
2

比如:

image-20240310055012715

步骤4:释放表锁

UNLOCK TABLES; # 使用此命令解锁当前加锁的表
1

比如:

image-20240310055043109

MySQL的表级锁有两种模式:(以MyISAM引擎进行操作的演示)

  • 表共享读锁(Table Read Lock)

  • 表独占写锁(Table Write Lock)

    image-20220711220929248

MyISAM引擎:

  • MyISAM只支持表级锁,不支持行级锁;

  • 执行SELECT语句时,MyISAM会自动对涉及的所有表加上读锁(共享锁S)。

  • 执行INSERT、UPDATE、DELETE等修改数据的操作时,MyISAM会自动对涉及的表加上写锁(排他锁X)。

InnoDB引擎:

  • InnoDB支持行级锁,也支持表级锁。

  • InnoDB处理SELECT语句时,默认不会加任何锁;需要手动通过LOCK IN SHARE MODE(共享锁)或FOR UPDATE(排他锁)来指定。

  • 对于INSERT、UPDATE、DELETE等修改数据的操作,InnoDB会自动在涉及的数据行上加上排他锁X。

# ② 意向锁 (intention lock)

InnoDB 支持 多粒度锁(multiple granularity locking) ,它允许 行级锁 与 表级锁 共存,而意向锁就是其中的一种 表锁 。

  1. 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁和行锁)的锁并存。
  2. 意向锁是一种不与行级锁冲突表级锁,这一点非常重要。
  3. 表明“某个事务正在某些行持有了锁或该事务准备去持有锁”。

笔记

  • 意向锁(Intention Locks)是InnoDB存储引擎特有的一种锁机制,主要用于支持行级锁定。意向锁是一种表级锁,但它并不阻止对任何行的访问,而是表明一个事务打算在表的哪些行上加行级锁。这样的设计是为了在使用行级锁时,提高系统的并发能力,并且能有效地避免死锁的发生。
  • 通过意向锁的存在,数据库在尝试加表级锁时能快速判断是否存在冲突,而不必检查每一行是否被锁定。比如,当一个事务试图对一个表加排他锁时,它必须首先检查是否存在任何意向锁,如果要添加的锁和存在的意向锁发生冲突,可以反馈添加失败。

意向锁分为两种:

  • 意向共享锁(intention shared lock, IS):事务有意向对表中的某些行加共享锁(S锁)

    -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
    SELECT column FROM table ... LOCK IN SHARE MODE;
    
    1
    2
  • 意向排他锁(intention exclusive lock, IX):事务有意向对表中的某些行加排他锁(X锁)

    -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
    SELECT column FROM table ... FOR UPDATE;
    
    1
    2

即:意向锁是由存储引擎 自己维护的 ,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前, InooDB 会先获取该数据行 所在数据表的对应意向锁 。

意向锁笔记

  1. 自动获取意向锁: 当你在事务中请求行级锁(无论是共享锁S还是排他锁X)时,InnoDB自动为整个表加上相应的意向锁(IS或IX)。这个过程对用户是透明的,您不需要显式地去请求意向锁。意向锁的机制确保了数据库能在保持高并发的同时,也保证了事务的隔离级别。
  2. 表级锁与意向锁: 当你请求表级锁时(比如一个完整的表的共享锁S或排他锁X),InnoDB会检查是否有与之冲突的意向锁存在。如果没有冲突,那么表级锁可以被授予。在这种情况下,不会去获取"更大级别"的意向锁,因为意向锁的设计初衷是为了声明事务对行级锁的意图,而不是表级锁。

1. 意向锁要解决的问题

现在有两个事务,分别是 T1 和 T2,其中 T2 试图在该表级别上应用共享锁或排它锁,如果没有意向锁存在,那么 T2 就需要去检查各个页或行是否存在锁;如果存在意向锁,那么此时就会受到由 T1 控制的表级别意向锁的阻塞。T2 在锁定该表前不必检查各个页或行锁,而只需检查表上的意向锁。简单来说就是给更大一级别的空间示意里面是否已经上过锁。

在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。

  • 如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。
  • 如果事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。

这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录。

举例: 创建表teacher,插入6条数据,事务的隔离级别默认为Repeatable-Read,如下所示。

CREATE TABLE `teacher` (
	`id` int NOT NULL,
    `name` varchar(255) NOT NULL,
    PRIMARY KEY (`id`)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

INSERT INTO `teacher` VALUES
('1', 'zhangsan'),
('2', 'lisi'),
('3', 'wangwu'),
('4', 'zhaoliu'),
('5', 'songhongkang'),
('6', 'leifengyang');
1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1
2
3
4
5
6

假设事务A获取了某一行的排他锁,并未提交,语句如下所示:

BEGIN;

SELECT * FROM teacher WHERE id = 6 FOR UPDATE;
1
2
3

事务B想要获取teacher表的表读锁,语句如下:

BEGIN;

LOCK TABLES teacher READ;
1
2
3

因为共享锁与排他锁互斥,所以事务 B 在试图对 teacher 表加共享锁的时候,必须保证两个条件。

  1. 当前没有其他事务持有 teacher 表的排他锁
  2. 当前没有其他事务持有 teacher 表中任意一行的排他锁。

为了检测是否满足第二个条件,事务B必须在确保teacher表不存在任何排他锁的前提下,去检测表中的每一行是否存在排他锁。很明显这是一个效率很差的做法,但是有了意向锁之后,情况就不一样了。

意向锁是怎么解决这个问题的呢?首先,我们需要知道意向锁之间的兼容互斥性,如下所示。

image-20240310092053263

即意向锁之间是互相兼容的,虽然意向锁和自家兄弟互相兼容,但是它会与普通的排他/共享锁互斥。

image-20240310092121903

注意这里的排他 / 共享锁指的都是表锁,意向锁不会与行级的共享/排他锁互斥。回到刚才teacher表的例子。事务A获取了某一行的排他锁,并未提交:

BEGIN;

SELECT * FROM teacher WHERE id = 6 FOR UPDATE;
1
2
3

此时teacher表存在两把锁:teacher表上的意向排他锁与id为6的数据行上的排他锁。事务B想要获取teacher表的共享锁。

BEGIN;

LOCK TABLES teacher READ;
1
2
3

此时事务B检测事务A持有teacher表的意向排他锁,就可以得知事务A持有该表中某些数据行的排他锁,那么事务B对teacher表的加锁请求就会被排斥(阻塞),而无需去检测表中的每一行数据是否存在排他锁。

意向锁的并发性

意向锁不会与行级的共享 / 排他锁互斥!正因为如此,意向锁并不会影响到多个事务对不同数据行加排他锁时的并发性。(不然我们直接用普通的表锁就行了)

我们扩展一下上面 teacher表的例子来概括一下意向锁的作用(一条数据从被锁定到被释放的过程中,可能存在多种不同锁,但是这里我们只着重表现意向锁)。

事务A先获得了某一行的排他锁,并未提交:

BEGIN;

SELECT * FROM teacher WHERE id = 6 FOR UPDATE;
1
2
3

事务A获取了teacher表上的意向排他锁。事务A获取了id为6的数据行上的排他锁。之后事务B想要获取teacher表上的共享锁。

BEGIN;

LOCK TABLES teacher READ;
1
2
3

事务B检测到事务A持有teacher表的意向排他锁。事务B对teacher表的加锁请求被阻塞(排斥)。最后事务C也想获取teacher表中某一行的排他锁。

BEGIN;

SELECT * FROM teacher WHERE id = 5 FOR UPDATE;
1
2
3

事务C申请teacher表的意向排他锁。事务C检测到事务A持有teacher表的意向排他锁。因为意向锁之间并不互斥,所以事务C获取到了teacher表的意向排他锁。因为id为5的数据行上不存在任何排他锁,最终事务C成功获取到了该数据行上的排他锁。

从上面的案例可以得到如下结论:

意向共享锁 (IS) 意向排他锁 (IX)
意向共享锁 (IS) 兼容 兼容
意向排他锁 (IX) 兼容 兼容
意向共享锁 (IS) 意向排他锁 (IX)
表级共享锁 (S) 兼容 互斥
表级排他锁 (X) 互斥 互斥
  1. InnoDB 支持 多粒度锁 ,特定场景下,行级锁可以与表级锁共存。
  2. 意向锁之间互不排斥,但除了 IS(意向共享锁) 与 S(共享锁) 兼容外, 意向锁会与 共享锁 / 排他锁 互斥 。
  3. IX,IS是表级的锁,不会和行级的X(排他锁),S(共享锁)发生冲突。只会和表级的X,S发生冲突。
  4. 意向锁在保证并发性的前提下,实现了 行锁和表锁共存 且 满足事务隔离性 的要求。
# ③ 自增锁(AUTO-INC锁)

自增锁的工作方式:当你插入数据到包含AUTO_INCREMENT列的表时,MySQL为了保证这些自增值的连续性和唯一性,会在插入操作期间获取一个自增锁。这个锁保证了在当前插入语句完成之前,其他的插入操作不能在这个自增列上进行,从而保证了自增值的连续性和唯一性。

在使用MySQL过程中,我们可以为表的某个列添加 AUTO_INCREMENT 属性。举例:

CREATE TABLE `teacher` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
1
2
3
4
5

由于这个表的id字段声明了AUTO_INCREMENT,意味着在书写插入语句时不需要为其赋值,SQL语句修改 如下所示。

INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi');
1

上边的插入语句并没有为id列显式赋值,所以系统会自动为它赋上递增的值,结果如下所示。

mysql> select * from teacher;
+----+----------+
| id | name     |
+----+----------+
| 1  | zhangsan |
| 2  | lisi     |
+----+----------+
2 rows in set (0.00 sec)
1
2
3
4
5
6
7
8

现在我们看到的上面插入数据只是一种简单的插入模式,所有插入数据的方式总共分为三类,分别是 Simple inserts ,Bulk inserts 和 Mixed-mode inserts。

1. “Simple inserts” (简单插入)

可以 预先确定要插入的行数 (当语句被初始处理时)的语句。包括没有嵌套子查询的单行和多行INSERT...VALUES()和 REPLACE 语句。比如我们上面举的例子就属于该类插入,已经确定要插入的行 数。

2. “Bulk inserts” (批量插入)

事先不知道要插入的行数 (和所需自动递增值的数量)的语句。比如 INSERT ... SELECT , REPLACE ... SELECT 和 LOAD DATA 语句,但不包括纯INSERT。 InnoDB在每处理一行,为AUTO_INCREMENT列

3. “Mixed-mode inserts” (混合模式插入)

这些是Simple inserts语句但是指定部分新行的自动递增值。例如

INSERT INTO teacher (id,name) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d'); 
1

只是指定了部分id的值。另一种类型的“混合模式插入”是 INSERT ... ON DUPLICATE KEY UPDATE 。

对于上面数据插入的案例,MySQL 中采用了自增锁的方式来实现,AUTO-INC 锁是当向使用含有 AUTO_INCREMENT 列的表中插入数据时需要获取的一种特殊的表级锁,在执行插入语句时就在表级别加一个 AUTO-INC 锁,然后为每条待插入记录的 AUTO_INCREMENT 修饰的列分配递增的值,在该语句执行结束后,再把 AUTO-INC 锁释放掉。一个事务在持有 AUTO-INC 锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。

也正因为此,其并发性显然并不高,当我们向一个有 AUTO_INCREMENT 关键字的主键插入值的时候,每条语句都要对这个表锁进行竞争,这样的并发潜力其实是很低下的,所以 InnoDB 通过 innodb_autoinc_lock_mode 的不同取值来提供不同的锁定机制,来显著提高 SQL 语句的可伸缩性和性能。

InnoDB的自增锁模式(innodb_autoinc_lock_mode):提供了三种模式来优化自增锁的性能和并发性。

  • 0(传统):对所有插入操作使用自增锁,确保了严格的顺序性,但并发性最低。
  • 1(连续):对批量插入使用自增锁,对简单插入尝试优化以提高并发性,但可能会有少量性能开销。
  • 2(交错):允许更高的并发性,自增值唯一但不保证连续,是MySQL 8.0默认设置,最适合高并发场景。

(1)innodb_autoinc_lock_mode = 0(“传统”锁定模式)

在此锁定模式下,所有类型的insert语句都会获得一个特殊的表级AUTO-INC锁,用于插入具有 AUTO_INCREMENT列的表。这种模式其实就如我们上面的例子,即每当执行insert的时候,都会得到一个 表级锁(AUTO-INC锁),使得语句中生成的auto_increment为顺序,且在binlog中重放的时候,可以保证 master与slave中数据的auto_increment是相同的。因为是表级锁,当在同一时间多个事务中执行insert的 时候,对于AUTO-INC锁的争夺会 限制并发 能力。

(2)innodb_autoinc_lock_mode = 1(“连续”锁定模式)

在 MySQL 8.0 之前,连续锁定模式是 默认 的。

在这个模式下,“bulk inserts”仍然使用AUTO-INC表级锁,并保持到语句结束。这适用于所有INSERT ... SELECT,REPLACE ... SELECT和LOAD DATA语句。同一时刻只有一个语句可以持有AUTO-INC锁。

对于“Simple inserts”(要插入的行数事先已知),则通过在 mutex(轻量锁) 的控制下获得所需数量的自动递增值来避免表级AUTO-INC锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。不使用表级AUTO-INC锁,除非AUTO-INC锁由另一个事务保持。如果另一个事务保持AUTO-INC锁,则“Simple inserts”等待AUTO-INC锁,如同它是一个“bulk inserts”。

(3)innodb_autoinc_lock_mode = 2(“交错”锁定模式)

从 MySQL 8.0 开始,交错锁模式是 默认 设置。

在此锁定模式下,自动递增值 保证 在所有并发执行的所有类型的insert语句中是 唯一 且 单调递增 的。但是,由于多个语句可以同时生成数字(即,跨语句交叉编号),为任何给定语句插入的行生成的值可能不是连续的。

如果执行的语句是“simple inserts",其中要插入的行数已提前知道,除了"Mixed-mode inserts"之外,为单个语句生成的数字不会有间隙。然后,当执行"bulk inserts"时,在由任何给定语句分配的自动递增值中可能存在间隙。

# ④ 元数据锁(MDL锁)

MySQL5.5引入了meta data lock,简称MDL锁,属于表锁范畴。MDL 的作用是,保证读写的正确性。比如,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个 表结构做变更 ,增加了一 列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

笔记

  1. 增删改查操作:当一个事务要对表进行增删改查(DML)操作时,它会请求MDL的共享锁(读锁)。这是因为增删改查操作主要涉及到数据行,而不直接影响表的结构。因此,其他事务可以并发执行相同的操作,这些操作之间不会相互阻塞,除非它们需要访问相同的数据行并且涉及到行级锁。
  2. 表结构变更操作:对于DDL操作(如ALTER TABLE、DROP TABLE、CREATE INDEX等),需要请求MDL的排他锁(写锁)。这类操作会修改表的结构定义,因此必须确保在操作执行期间没有其他操作能够并发访问或修改这个表。如果已有事务持有针对该表的MDL锁(无论是读锁还是写锁),那么DDL操作需要等待直到所有这些锁被释放。

读锁之间不互斥,因此你可以有多个线程同时对一张表增删查改。读写锁之间、写锁之间都是互斥的,用来保证变更表结构操作的安全性,解决了DML和DDL操作之间的一致性问题。不需要显式使用,在访问一个表的时候会被自动加上。

举例:元数据锁的使用场景模拟

会话A: 从表中查询数据

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
# 会话A执行查询操作
mysql> SELECT COUNT(1) FROM teacher;
+----------+
| COUNT(1) |
+----------+
| 2        |
+----------+
1 row int set (7.46 sec)
1
2
3
4
5
6
7
8
9
10

会话B: 修改表结构,增加新列

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
# 会话B修改表结构,此时由于会话A还未提交事务导致会话B阻塞
mysql> alter table teacher add age int not null;  
1
2
3
4

会话C: 查看当前MySQL的进程

mysql> show processlist;
1

image-20240310130023812

通过会话C可以看出会话B被阻塞,这是由于会话A拿到了teacher表的元数据读锁,会话B想申请teacher表的元数据写锁,由于读写锁互斥,会话B需要等待会话A释放元数据锁才能执行。

元数据可能带来的问题?

image-20240310101251547

我们可以看到 session A会对表teacher加一个 MDL 读锁,之后 session B要加MDL写锁会被 blocked,因为 session A的 MDL 读锁还没有释放,而 session C要在表 teacher 上新申请 MDL 读锁的请求也会被 session B 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请MDL 读锁,就都被阻塞,等于这个表现在完全不可读写了。

# 2. InnoDB中的行锁

行锁(Row Lock)也称为记录锁,顾名思义,就是锁住某一行(某条记录 row)。需要注意的是,MySQL服务器层并没有实现行锁机制,行级锁只在存储引擎层实现。

优点: 锁定力度小,发生锁冲突概率低,可以实现的并发度高。

缺点: 对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。

首先我们创建表如下:

CREATE TABLE student (
	id INT,
    name VARCHAR(20),
    class VARCHAR(10),
    PRIMARY KEY (id)
) Engine=InnoDB CHARSET=utf8;
1
2
3
4
5
6

向这个表里插入几条记录:

INSERT INTO student VALUES
(1, '张三', '一班'),
(3, '李四', '一班'),
(8, '王五', '二班'),
(15, '赵六', '二班'),
(20, '钱七', '三班');

mysql> SELECT * FROM student;
1
2
3
4
5
6
7
8

image-20240310131050863

student表中的聚簇索引的简图如下所示。

image-20220713163353648

这里把B+树的索引结构做了超级简化,只把索引中的记录给拿了出来,下面看看都有哪些常用的行锁类型。

# ① 记录锁(Record Locks)

记录锁也就是仅仅把一条记录锁,官方的类型名称为:LOCK_REC_NOT_GAP。比如我们把id值为8的那条记录加一个记录锁的示意图如果所示。仅仅是锁住了id值为8的记录,对周围的数据没有影响。

笔记

  • 记录锁的主要目的是防止多个事务同时修改同一数据记录,避免数据不一致的问题。当一个事务对数据行加锁后,其他事务就必须等待这个锁被释放后才能对该数据行进行修改。
  • 记录锁是在进行数据修改操作(如INSERT、UPDATE、DELETE)或明确请求锁定某些行时(例如通过SELECT ... FOR UPDATE语句)由InnoDB存储引擎动态添加的。在读取数据时不会自动添加记录锁,除非你使用了锁定读(locking read),如SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE。

image-20220713164811567

举例如下:

image-20220713164948405

记录锁主要有两种类型,共享锁(Shared Locks,简称S锁)和排他锁(Exclusive Locks,简称X锁)。

  • 共享锁(S锁):允许一个事务读取一行数据,并且多个事务可以同时对同一数据行加共享锁。
  • 排他锁(X锁):允许一个事务修改一行数据,并且其他事务不能同时对这行数据加任何类型的锁。

记录锁与表级锁(表级的S锁和X锁)主要的区别在于锁定的粒度和对并发性能的影响:

  • 记录锁(Row Lock)是针对数据库表中单个行记录进行锁定。它是一种细粒度锁,只影响被操作的具体行,不会对其他行造成影响。
  • 表级锁(Table Lock)是对整个表进行锁定。无论是共享锁(S锁)还是排他锁(X锁),一旦加锁,会影响到表中的所有行。这是一种粗粒度锁,操作简单,开销较小,但并发性能较低。
# ② 间隙锁(Gap Locks)

InnoDB提出了一种称之为 Gap Locks 的锁,官方的类型名称为:LOCK_GAP ,我们可以简称为 gap(间隙)锁 。

MySQL 在 REPEATABLE READ 隔离级别下就是利用MVCC提供非锁定读操作的数据一致性视图,同时通过自动应用间隙锁来防止在查询范围内插入新的数据,综合解决了幻读问题。

笔记

  • MySQL的间隙锁(Gap Lock)是InnoDB存储引擎特有的一种锁机制,用于解决幻读(Phantom Read)问题,保证事务的可重复读(Repeatable Read)隔离级别下的一致性。间隙锁锁定的是索引记录之间的间隙,或者是第一个索引记录之前的间隙,或者最后一个索引记录之后的间隙。间隙锁不锁定记录本身,而是锁定一个范围。
  • 间隙锁通常伴随着行锁一起工作,而不是单独使用。在进行范围查询并对结果集加锁时(例如,通过SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE等语句),InnoDB会自动对查询范围内的行记录加上行锁,并对查询范围内尚不存在的数据加上间隙锁。
  • 间隙锁只在REPEATABLE READ和SERIALIZABLE这两个较高的事务隔离级别下工作。在READ COMMITTED和READ UNCOMMITTED这两个较低的隔离级别下,InnoDB不使用间隙锁。

比如,把id值为8的那条 记录加一个gap(间隙)锁的示意图如下。

image-20220713171650888

图中id值为8的记录加了间隙锁,意味着 不允许别的事务在id值为8的记录前边的间隙插入新记录 ,其实就是 id列的值(3, 8)这个区间的新记录是不允许立即插入的。比如,有另外一个事务再想插入一条id值为4的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个间隙锁,所以就会阻塞插入操作,直到拥有这个间隙锁的事务提交了之后,id列的值在区间(3, 8)中的新记录才可以被插入。

gap锁的提出仅仅是为了防止插入幻影记录而提出的。 虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用是相同的。而且如果对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁。

举例:

Session1 Session2
select * from student where id=5 lock in share mode;
select * from student where id=5 for update;

这里session2并不会被堵住。因为表里并没有id=5这条记录,所以session1加的是间隙锁(3,8)的范围。而session2也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙锁,不允许插入值。但它们之间是不冲突的。

注意,给一条记录加了 gap 锁只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是 student 表中 id 值为 20 的记录之后的间隙该怎么处理呢?

也就是说给哪条记录加 gap 锁才能阻止其他事务插入 id 值在 (20, +∞) 这个区间的新记录呢?这时数据页中的两条伪记录派上用场了:

  • Infimum记录,表示该页面中最小的记录。
  • Supremun记录,表示该页面中最大的记录。

为了实现阻止其他事务插入id值再(20,正无穷)这个区间的新纪录,我们可以给索引中的最后一条记录,也就是id值为20的那条记录所在页面的Supremun记录加上一个gap锁,如图所示。

image-20220713174108634

mysql> begin;  # 开启事务
Query OK, 0 rows affected (0.00 sec)

# id值为20的那条记录所在页面的Supremun记录加上一个gap锁
mysql> select * from student where id > 20 lock in share mode; 
Empty set (0.01 sec)
1
2
3
4
5
6

开启另一个会话检查锁:

# 查询 performance_schema 数据库中的 data_locks 表
mysql> select * from performance_schema.data_locks\G 
*************************** 1. row ***************************
               ENGINE: INNODB                        -- 引擎类型
       ENGINE_LOCK_ID: 140610262256856:1132:140610182893424 -- 锁的唯一标识
ENGINE_TRANSACTION_ID: 422085238967512              -- 事务ID
            THREAD_ID: 387                          -- 线程ID
             EVENT_ID: 68                           -- 事件ID
        OBJECT_SCHEMA: atguigudb3                   -- 数据库名
          OBJECT_NAME: student                      -- 表名
       PARTITION_NAME: NULL                         -- 分区名,这里没有使用分区
    SUBPARTITION_NAME: NULL                         -- 子分区名,这里没有使用子分区
           INDEX_NAME: NULL                         -- 索引名,表级锁不涉及具体的索引
OBJECT_INSTANCE_BEGIN: 140610182893424              -- 锁对象的内存地址
            LOCK_TYPE: TABLE                        -- 锁的类型,这里是表级锁
            LOCK_MODE: IS                           -- 锁的模式,意向共享锁
          LOCK_STATUS: GRANTED                      -- 锁的状态,已授予
            LOCK_DATA: NULL                         -- 锁定的数据,表级锁不锁定具体的行

*************************** 2. row ***************************
               ENGINE: INNODB                        -- 引擎类型
       ENGINE_LOCK_ID: 140610262256856:70:4:1:140610182890368 -- 锁的唯一标识
ENGINE_TRANSACTION_ID: 422085238967512              -- 事务ID
            THREAD_ID: 387                          -- 线程ID
             EVENT_ID: 68                           -- 事件ID
        OBJECT_SCHEMA: atguigudb3                   -- 数据库名
          OBJECT_NAME: student                      -- 表名
       PARTITION_NAME: NULL                         -- 分区名,这里没有使用分区
    SUBPARTITION_NAME: NULL                         -- 子分区名,这里没有使用子分区
           INDEX_NAME: PRIMARY                      -- 主键索引
OBJECT_INSTANCE_BEGIN: 140610182890368              -- 锁对象的内存地址
            LOCK_TYPE: RECORD                       -- 锁的类型,记录锁
            LOCK_MODE: S                            -- 锁的模式,共享锁
          LOCK_STATUS: GRANTED                      -- 锁的状态,已授予
            LOCK_DATA: supremum pseudo-record       -- 锁定的数据,虚拟的最高记录
2 rows in set (0.00 sec)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

这样就可以阻止其他事务插入id值在(20,+∞)这个区间的新记录。

间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。下面的例子会产生死锁

Session1 Session2
begin; select * from student where id = 5 for update; begin; select * from student where id = 5 for update;
insert into student values(5, ‘宋红康’, ‘二班’); 此时阻塞
insert into student values(5, ‘宋红康’, ‘二班’); (Error 1213(40001):Deadlock found when trying to get lock; try restarting transaction)
  • ① session 1 执行 select … for update 语句,由于 id = 5 这一行并不存在,因此会加上间隙锁 (3, 8);
  • ② session 2 执行 select … for update 语句,同样会加上间隙锁 (3, 8),间隙锁之间不会冲突,因此这个语句可以执行成功;
  • ③ session 2 试图插入一行 (5, ‘宋红康’, ‘二班’),被 session 1 的间隙锁挡住了,只好进入等待;
  • ④ session 1 试图插入一行 (5, ‘宋红康’, ‘二班’),被 session 2 的间隙锁档住了,至此,两个 session 进入互相等待状态,即产生了死锁。至于 MySQL 对待死锁的策略,后面的第 3.6 节会详细介绍。
# 例子1:简单的间隙锁

假设有一个student表,其中只有三条记录,id分别为1,3,5。

场景:在REPEATABLE READ隔离级别下,一个事务(事务A)执行以下查询并请求共享锁:

SELECT * FROM student WHERE id > 2 FOR SHARE;
1

由于id值为3和5的记录符合条件,事务A不仅会对这两条记录加上共享锁,还会对id值在(2,3]、(3,5]的间隙加上间隙锁,以及(5,+∞)的间隙。

解释:这防止了其他事务在这些间隙中插入新的记录。例如,如果另一个事务(事务B)尝试插入id为4的记录,它会被阻塞,因为事务A已经在(3,5]间隙上加了间隙锁。

# 例子2:防止幻读

假设student表中id的值为[1,2,3,4,5]。

场景:事务A执行查询并请求共享锁:

SELECT * FROM student WHERE id > 3 FOR SHARE;
1

此时,事务A会对id值为4和5的记录加上共享锁,并对(3,4]、(4,5]以及(5,+∞)的间隙加上间隙锁。

解释:这意味着如果事务B尝试在这些间隙中插入一个id为6的记录,操作会被阻塞,因为事务A已经在(5,+∞)间隙上加了间隙锁,这就防止了幻读的发生。

笔记

当你在一个事务内对一定范围的数据进行查询时并加上行锁,InnoDB存储引擎会自动在这个范围上加上间隙锁。这样,即使在这个事务执行期间,其他事务试图在这个范围内插入新的数据,也会因为间隙锁的存在而被阻塞,直到原事务完成。这就确保了在事务的执行期间,查询范围内的数据集是不会发生变化的,从而防止了幻读问题的发生。

# 例子3:间隙锁与行锁共存

假设student表中id的值为[1,2,3,4,5]。

场景:事务A想要删除一个范围内的记录:

DELETE FROM student WHERE id > 3;
1

这个操作会对匹配条件的记录(id为4和5)加上排他行锁,同时对(3,4]、(4,5]以及(5,+∞)的间隙加上间隙锁。

解释:如果此时事务B尝试插入id为6的记录,它会被阻塞,直到事务A提交或回滚。这样确保了在事务A的视图中,删除操作后不会出现新的幻读记录。

通过上述例子,你可以看到间隙锁如何在MySQL中被用来防止幻读,确保数据的一致性。间隙锁的存在使得InnoDB能够在REPEATABLE READ隔离级别下避免幻读问题,同时也影响了并发性能。

# ③ 临键锁(Next-Key Locks)

有时候我们既想 锁住某条记录 ,又想 阻止 其他事务在该记录前边的 间隙插入新记录 ,所以InnoDB就提 出了一种称之为 Next-Key Locks 的锁,官方的类型名称为: LOCK_ORDINARY ,我们也可以简称为 next-key锁 。Next-Key Locks是在存储引擎 innodb 、事务级别在 可重复读 的情况下使用的数据库锁, innodb默认的锁就是Next-Key locks。

提示

  • 临键锁是在可重复读(REPEATABLE READ)隔离级别下自动使用的。当你对数据进行查询并加锁时(例如,使用SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE),InnoDB会自动对符合条件的索引项加上临键锁。
  • 临键锁对于精确匹配到的数据项,会添加记录锁(Row Lock)来直接锁定这个特定的数据记录。而对于查询条件匹配到的范围内,对于其中不存在的数据(即索引项之间的"间隙"),会添加间隙锁(Gap Lock)来防止在这些间隙内插入新的数据。

比如,我们把id值为8的那条记录加一个next-key锁的示意图如下:

image-20220713192549340

next-key锁的本质就是一个记录锁和一个gap锁的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。

begin;
select * from student where id <=8 and id > 3 for update;
1
2

这个查询会对符合条件id <= 8 AND id > 3的所有记录应用Next-Key锁。这意味着:

  1. 对查询到的记录加上记录锁:即直接锁定id值为4、5、6、7、8的这些记录。
  2. 对查询范围内的间隙加上间隙锁:这将锁定id值为3和8之间的所有间隙,包括3和8之间不存在的记录的间隙。
# ④ 插入意向锁 (Insert Intention Locks)

我们说一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了gap锁(next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB 规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB 就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们称为插入意向锁。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。

笔记

  • 插入意向锁的引入是为了解决在存在间隙锁的情况下,并发插入可能导致的性能问题。它允许多个事务声明它们打算在同一个间隙内插入不同位置的行,从而提高了InnoDB处理并发插入的能力。

插入意向锁是在插入一条记录行前,由INSERT 操作产生的一种间隙锁。该锁用以表示插入意向,当多个事务在同一区间 (gap) 插入位置不同的多条数据时,事务之间不需要互相等待。假设存在两条值分别为 4 和 7 的记录,两个不同的事务分别试图插入值为 5 和 6 的两条记录,每个事务在获取插入行上独占的(排他)锁前,都会获取 (4, 7)之间的间隙锁,但是因为数据行之间并不冲突,所以两个事务之间并不会产生冲突(阻塞等待)。总结来说,插入意向锁的特性可以分成两部分:

  1. 插入意向锁是一种特殊的间隙锁―—间隙锁可以锁定开区间内的部分记录。
  2. 插入意向锁之间互不排斥,所以即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待。

注意,虽然插入意向锁中含有意向锁三个字,但是它并不属于意向锁而属于间隙锁,因为意向锁是表锁而插入意向锁是行锁。比如,把 id 值为 8 的那条记录加一个插入意向锁的示意图如下:

在这里插入图片描述

再比如,现在 T1 为 id 值为 s 的记录加了一个 gap(间隙) 锁,然后 T2 和 T3 分别想向 student 表中插入 id 值分别为 4、5 的两条记录,所以现在为 id 值为 8 的记录加的锁的示意图就如下所示:

在这里插入图片描述

假设有三个事务T1、T2、T3,它们都想要在同一个间隙中插入数据。以下是过程的简化说明:

  1. T1获取间隙锁:T1首先到达,对特定间隙获取间隙锁,并进行查询操作。
  2. T2和T3尝试插入:在T1持有间隙锁的同时,T2和T3尝试在同一间隙中插入数据。由于T1的间隙锁,它们无法直接进行插入。
  3. 插入意向锁生成:T2和T3在尝试插入时,InnoDB会为它们各自创建插入意向锁。这表明了它们插入的意图,尽管实际上它们不能立即执行插入,因为间隙被T1锁定。
  4. T1提交事务:当T1完成其查询操作并提交事务时,它持有的间隙锁被释放。
  5. T2和T3并行插入:随着T1释放间隙锁,T2和T3可以并行执行它们的插入操作,前提是它们插入的记录位于不同的间隙或间隙的不同部分。这里的关键是,虽然它们都有插入意向锁,但实际上不会相互阻塞,因为插入意向锁仅表明了插入的意图,并不阻止其他事务对同一间隙的访问。

插入意向锁的作用

插入意向锁的主要作用是优化并发插入操作。它们使得多个事务可以在表明了插入意图后,并行地进行插入操作,只要这些操作不冲突。插入意向锁确保了即使多个事务想要在同一间隙内插入数据,这些事务也能在某个间隙锁被释放后,根据它们自己的插入位置,高效地并行执行。

# 3. 页锁

页锁就是在 页的粒度 上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我 们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

每个层级的锁数量是有限制的,因为锁会占用内存空间, 锁空间的大小是有限的 。当某个层级的锁数量 超过了这个层级的阈值时,就会进行 锁升级 。锁升级就是用更大粒度的锁替代多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这样做的好处是占用的锁空间降低了,但同时数据的并发度也下降了。

# 3.3 从对待锁的态度划分:乐观锁、悲观锁

从对待锁的态度来看锁的话,可以将锁分成乐观锁和悲观锁,从名字中也可以看出这两种锁是两种看待 数据并发的思维方式 。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的 设计思想 。

# 1. 悲观锁(Pessimistic Locking)

悲观锁是一种思想,顾名思义,就是很悲观,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会 阻塞 直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞, 用完后再把资源转让给其它线程)。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

秒杀案例1:

  • ①商品秒杀过程中,库存数量的减少,避免出现超卖的情况。比如,商品表中有一个字段为 quantity 表示当前该商品的库存量。假设商品为华为 mate40,id 为1001,quantity = 100。如果不使用锁的情况下,操作方法如下所示:
# 第 1 步:查出商品库存
select quantity from items where id = 1001;
# 第 2 步:如果库存大于 8,则根据商品信息生产订单
insert into orders (item_id) values(1001);
# 第 3 步:修改商品的库存,num 表示购买数量
update items set quantity = quantity - num where id = 1001;
1
2
3
4
5
6
  • ② 这样写的话,在并发量小的公司没有大的问题,但是如果在高并发环境下可能出现以下问题:
线程A 线程B
1 step1(查询还有 100 部手机) step1(查询还有 100 部手机)
2 step2(生成订单)
3 step2(生成订单)
4 step3(减库存1)
5 step3(减库存2)

其中线程 B 此时已经下单并且减完库存,这个时候线程 A 依然去执行 step3,就造成了超卖。我们使用悲观锁可以解决这个问题,商品信息从查询出来到修改,中间有一个生成订单的过程,使用悲观锁的原理就是,当我们在查询 items 信息后就把当前的数据锁定,直到我们修改完毕后再解锁。那么整个过程中,因为数据被锁定了,就不会出现有第三者来对其进行修改了。而这样做的前提是需要将要执行的 SQL 语句放在同一个事务中,否则达不到锁定数据行的目的。

  • ③ 修改如下:
# 第 1 步:查出商品库存
select quantity from items where id = 1001 for update;
# 第 2 步:如果库存大于8,则根据商品信息生产订单
insert into orders (item_id) values(1001);
# 第 3 步:修改商品的库存,num 表示购买数量
update items set quantity = quantity - num where id = 1001;
1
2
3
4
5
6
  • ④ select … for update 是 MySQL 中悲观锁。此时在items 表中,id 为 1001 的那条数据就被我们锁定了,其他的要执行 select quantity from items where id = 1001 for update;语句的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。注意,当执行 select quantity from items where id = 1001 for update;语句之后,如果在其他事务中执行 select quantity from items where id = 1001 语句,并不会受第一个事务的影响,仍然可以正常查询出数据。

注意事项

select … for update 语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。悲观锁不适用的场景较多,它存在一些不足,因为悲观锁大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受,这时就需要乐观锁。

# 2. 乐观锁(Optimistic Locking)

乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用 版本号机制 或者 CAS机制 实现。乐观锁适用于多读的应用类型, 这样可以提高吞吐量。在Java中java.util.concurrent.atomic 包下的原子变量类就是使用了乐观锁的一种实现方式:CAS实现的。

1. 乐观锁的版本号机制

在表中设计一个 版本字段 version ,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行 UPDATE ... SET version=version+1 WHERE version=version 。此时 如果已经有事务对这条数据进行了更改,修改就不会成功。

这种方式类似我们熟悉的SVN、CVS版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。

2. 乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行 比较,如果两者一致则更新成功,否则就是版本冲突。

你能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

秒杀案例 2:依然使用上面秒杀的案例,执行流程如下:

# 第 1 步:查出商品库存
select quantity from items where id = 1001;
# 第 2 步:如果库存大于日,则根据商品信息生产订单
insert into orders (item_id) values(1001) ;
# 第 3 步:修改商品的库存,num 表示购买数量
update items set quantity = quantity - num, version = version + 1 where id = 1001 and version = #{version};
1
2
3
4
5
6

注意,如果数据表是读写分离的表,当 matser 表中写入的数据没有及时同步到 slave 表中时,会造成更新一直失败的问题。此时需要强制读取 master 表中的数据(即将 select 语句放到事务中即可,这时候查询的就是 master 主库了。)

如果我们对同一条数据进行频繁的修改的话,那么就会出现这么一种场景,每次修改都只有一个事务能更新成功,在业务感知上面就有大量的失败操作。我们把代码修改如下:

# 第1 步:查出商品库存
select quantity from items where id = 1001;
# 第 2 步:如果库存大于日,则根据商品信息生产订单
insert into orders (item_id) values(1001);
# 第 3 步:修改商品的库存,num 表示购买数量
update items set quantity = quantity - num where id = 1001 and quantity - num > 0;
1
2
3
4
5
6

这样就会使每次修改都能成功,而且不会出现超卖的现象。

# 3. 两种锁的适用场景

从这两种锁的设计思想中,我们总结一下乐观锁和悲观锁的适用场景:

  1. 乐观锁 适合 读操作多 的场景,相对来说写的操作比较少。它的优点在于 程序实现 , 不存在死锁 问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。
  2. 悲观锁 适合 写操作多 的场景,因为写的操作具有 排它性 。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止 读 - 写 和 写 - 写 的冲突。
image-20220713211417909

# 3.4 按加锁的方式划分:显式锁、隐式锁

# 1. 隐式锁

一个事务在执行 INSERT 操作时,如果即将插入的间隙已经被其他事务加了 gap 锁,那么本次 INSERT 操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下 INSERT 操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有在内存生产与该记录关联的锁结构),然后另一个事务:

  • 立即使用 SELECT … LOCK IN SHARE MODE 语句读取这条记录,也就是要获取这条记录的 S 锁,或者使用 SELECT … FOR UPDATE 语句读取这条记录,也就是要获取这条记录的 X 锁,怎么办?如果允许这种情况的发生,那么可能产生脏读问题。
  • 立即修改这条记录,也就是要获取这条记录的 X 锁,怎么办?如果允许这种情况的发生,那么可能产生脏写问题。

这时候我们前边提过的事务 id 又要起作用了。我们把聚簇索引和二级索引中的记录分开看一下:

  • 情景一:对于聚簇索引记录来说,有一个 trx_id 隐藏列,该隐藏列记录着最后改动该记录的 事务 id 。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的 trx_id 隐藏列代表的的就是 当前事务的 事务id ,如果其他事务此时想对该记录添加 S锁 或者 X锁 时,首先会看一下该记录的 trx_id 隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个 X 锁 (也就是为当前事务创建一个锁结构, is_waiting 属性是 false ),然后自己进入等待状态 (也就是为自己也创建一个锁结构, is_waiting 属性是 true )。

  • 情景二:对于二级索引记录来说,本身并没有 trx_id 隐藏列,但是在二级索引页面的 Page Header 部分有一个 PAGE_MAX_TRX_ID 属性,该属性代表对该页面做改动的最大的 事务id ,如 果 PAGE_MAX_TRX_ID 属性值小于当前最小的活跃 事务id ,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复 情景一 的做法。

即:一个事务对新插入的记录可以不显示的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种延迟加锁的机制,从而来减少加锁的数量。

隐式锁在实际内存对象中并不含有这个锁信息。只有当产生锁等待时,隐式锁转化为显式锁。InnoDB的 INSERT 操作,对插入的记录不加锁,但是此时如果另一个线程进行当前读,类似以下的用例,session 2 会锁等待 session 1,那么这是如何实现的呢?

session 1:

# session 1
mysqls-> begin;
Query OK,0 rows affected (0.00 sec)
mysql> insert INTO student VALUES(34, "周八","二班");
Query OK,1 row affected (0.00 sec)
1
2
3
4
5

session 2:

# session 2:
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from student lock in share mode;	# 执行完,当前事务被阻塞
1
2
3
4

执行下述语句,输出结果:

mysql> SELECT * FROM performance_schema.data_lock_waits\G;
*************************** 1. row ***************************
						ENGINE: INNODB
		REQUESTING_ENGINE_LOCK_ID: 140562531358232:7:4:9:140562535668584
REQUESTING_ENGINE_TRANSACTION_ID: 422037508068888
			REQUESTING_THREAD_ID: 64
			REQUESTING_EVENT_ID: 6
REQUESTING_OBJECT_INSTANCE_BEGIN: 140562535668584
		BLOCKING_ENGINE_LOCK_ID: 140562531351768:7:4:9:140562535619104
BLOCKING_ENGINE_TRANSACTION_ID: 15902
			BLOCKING_THREAD_ID: 64
			BLOCKING_EVENT_ID: 6
BLOCKING_OBJECT_INSTANCE_BEGIN: 140562535619104
1 row in set (0.00 sec)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

隐式锁的逻辑过程如下:

A. InnoDB的每条记录中都一个隐含的trx_id字段,这个字段存在于聚簇索引的B+Tree中。

B. 在操作一条记录前,首先根据记录中的trx_id检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将 隐式锁 转换为 显式锁 (就是为该事务添加一个锁)。

C. 检查是否有锁冲突,如果有冲突,创建锁,并设置为waiting状态。如果没有冲突不加锁,跳到E。

D. 等待加锁成功,被唤醒,或者超时。

E. 写数据,并将自己的trx_id写入trx_id字段。

# 2. 显式锁

通过特定的语句进行加锁,我们一般称之为显示加锁,例如:

显示加共享锁:

select .... lock in share mode
1

显示加排它锁:

select .... for update
1

# 3.5 其它锁之:全局锁

全局锁就是对 整个数据库实例 加锁。当你需要让整个库处于 只读状态 的时候,可以使用这个命令,之后 其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结 构等)和更新类事务的提交语句。全局锁的典型使用 场景 是:做 全库逻辑备份 。

全局锁的命令:

Flush tables with read lock
1

# 3.6 其它锁之:死锁

# 1. 概念

两个事务都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁。

举例1:

image-20220713220714098

举例2:

用户A给用户B转账100,再次同时,用户B也给用户A转账100。这个过程,可能导致死锁。

image-20220713220936236

# 2. 产生死锁的必要条件

  1. 两个或者两个以上事务
  2. 每个事务都已经持有锁并且申请新的锁
  3. 锁资源同时只能被同一个事务持有或者不兼容
  4. 事务之间因为持有锁和申请锁导致彼此循环等待

死锁的关键在于:两个(或以上)的Session加锁的顺序不一致。

# 3. 如何处理死锁

方式1: 等待,直到超时(innodb_lock_wait_timeout=50s)

即当两个事务互相等待时,当一个事务等待时间超过设置的阈值时,就将其回滚,另外事务继续进行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。

mysql> show variables like "innodb_lock_wait_timeout";
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50    |
+--------------------------+-------+
1 row in set (0.00 sec)
1
2
3
4
5
6
7

缺点:对于在线服务来说,这个等待时间往往是无法接受的。

那将此值修改短一些,比如1s,0.1s是否合适?不合适,容易误伤到普通的锁等待。

方式2: 使用死锁检测处理死锁程序

方式1检测死锁太过被动,innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法都会被触发。

这是一种较为主动的死锁检测机制,要求数据库保存锁的信息链表和事务等待链表两部分信息。

image-20220713221758941

基于这两个信息,可以绘制wait-for graph(等待图)

image-20220713221830455

死锁检测的原理是通过构建一个有向图来实现的,其中图的顶点代表事务,而图的边代表事务之间的锁请求。如果在这个有向图中存在环,那么就表示发生了死锁。

一旦检测到回路、有死锁,这时候InnoDB存储引擎会选择回滚undo量最小的事务,让其他事务继续执行。(innodb_deadlock_detect=on表示开启这个死锁检测)。

mysql> show variables like "innodb_deadlock_detect";
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| innodb_deadlock_detect | ON    |
+------------------------+-------+
1 row in set (0.00 sec)
1
2
3
4
5
6
7

回滚Undo量最小的事务意味着什么?

当InnoDB检测到死锁时,它需要选择一个事务来回滚,以解除死锁。InnoDB采用的策略是回滚那些所需“undo量”最小的事务。这里的“undo量”指的是事务执行过程中产生的、用于回滚该事务所需的数据修改量。换句话说,它反映了事务自开始以来对数据库所做更改的数量。

缺点:每个新的被阻塞的线程,都要判断是不是由于自己的加入导致了死锁,这个操作时间复杂度是O(n)。如果100个并发线程同时更新同一行,意味着要检测100*100=1万次,1万个线程就会有1千万次检测。

如何解决?

  • 方式1:关闭死锁检测,但意味着可能会出现大量的超时,会导致业务有损。
  • 方式2:控制并发访问的数量。比如在中间件中实现对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作。

进一步的思路:

可以考虑通过将一行改成逻辑上的多行来减少锁冲突。比如,连锁超市账户总额的记录,可以考虑放到多条记录上。账户总额等于这多个记录的值的总和。

# 4. 如何避免死锁

  • 合理设计索引,使业务 SQL 尽可能通过索引定位更少的行,减少锁竞争。
  • 调整业务逻辑 SQL 执行顺序,避免 update/delete 长时间持有锁的 SQL 在事务前面。
  • 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
  • 在并发比较高的系统中,不要显式加锁,特别是是在事务里显式加锁。如 select … for update 语句,如果是在事务里运行了 start transaction 或设置了autocommit 等于 0,那么就会锁定所查找到的记录。
  • 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,此如将隔离级别从 RR 调整为 RC,可以避免掉很多因为 gap 锁造成的死锁。

# 4. 锁的内部结构

我们前边说对一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比如:

# 事务T1
SELECT * FROM user LOCK IN SHARE MODE;
1
2

理论上创建多个锁结构没问题,但是如果一个事务要获取10000条记录的锁,生成10000个锁结构也太崩溃了!所以决定在对不同记录加锁时,如果符合下边这些条件的记录会放在一个锁结构中。

  • 在同一个事务中进行加锁操作
  • 被加锁的记录在同一个页面中
  • 加锁的类型是一样的
  • 等待状态是一样的

InnoDB 存储引擎中的 锁结构 如下:

image-20220714132306208

结构解析:

1. 锁所在的事务信息:

不论是 表锁 还是 行锁 ,都是在事务执行过程中生成的,哪个事务生成了这个锁结构 ,这里就记录这个 事务的信息。

此 锁所在的事务信息 在内存结构中只是一个指针,通过指针可以找到内存中关于该事务的更多信息,比方说事务id等。

2. 索引信息 :

对于 行锁 来说,需要记录一下加锁的记录是属于哪个索引的。这里也是一个指针。

3. 表锁/行锁信息 :

表锁结构 和 行锁结构 在这个位置的内容是不同的:

  • 表锁:

    记载着是对哪个表加的锁,还有其他的一些信息。

  • 行锁:

    记载了三个重要的信息:

    • Space ID :记录所在表空间。
    • Page Number :记录所在页号。
    • n_bits:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同 的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bis属性代表使用了多少比特位。

    n_bits的值一般都比页面中记录条数多一些。主要是为了之后在页面中插入了新记录后 也不至于重新分配锁结构

4. 锁的模式( lock_mode )

这是一个32位的数,被分成了 lock_mode 、 lock_type 和 rec_lock_type 三个部分,如图所示:

image-20220714133319666

  • 锁的模式( lock_mode ),占用低4位,可选的值如下:
    • LOCK_IS (十进制的 0 ):表示共享意向锁,也就是 IS锁 。
    • LOCK_IX (十进制的 1 ):表示独占意向锁,也就是 IX锁 。
    • LOCK_S (十进制的 2 ):表示共享锁,也就是 S锁 。
    • LOCK_X (十进制的 3 ):表示独占锁,也就是 X锁 。
    • LOCK_AUTO_INC (十进制的 4 ):表示 AUTO-INC锁 。

在InnoDB存储引擎中,LOCK_IS,LOCK_IX,LOCK_AUTO_INC都算是表级锁的模式,LOCK_S和 LOCK_X既可以算是表级锁的模式,也可以是行级锁的模式。

  • 锁的类型( lock_type ),占用第5~8位,不过现阶段只有第5位和第6位被使用:
    • LOCK_TABLE (十进制的 16 ),也就是当第5个比特位置为1时,表示表级锁。
    • LOCK_REC(十进制的 32 ),也就是当第6个比特位置为1时,表示行级锁。
  • 行锁的具体类型( rec_lock_type ),使用其余的位来表示。只有在 lock_type 的值为 LOCK_REC 时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
    • LOCK_ORDINARY (十进制的 0 ):表示 next-key锁 。
    • LOCK_GAP (十进制的 512 ):也就是当第10个比特位置为1时,表示 gap锁 。
    • LOCK_REC_NOT_GAP (十进制的 1024 ):也就是当第11个比特位置为1时,表示正经 记录锁 。
    • LOCK_INSERT_INTENTION (十进制的 2048 ):也就是当第12个比特位置为1时,表示插入意向锁。其他的类型:还有一些不常用的类型我们就不多说了。
  • is_waiting 属性呢?基于内存空间的节省,所以把 is_waiting 属性放到了 type_mode 这个32 位的数字中:
    • LOCK_WAIT (十进制的 256 ) :当第9个比特位置为 1 时,表示 is_waiting 为 true ,也 就是当前事务尚未获取到锁,处在等待状态;当这个比特位为 0 时,表示 is_waiting 为 false ,也就是当前事务获取锁成功。

5. 其他信息 :

为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。

6. 一堆比特位 :

如果是 行锁结构 的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上边提到的 n_bits 属性 表示的。InnoDB数据页中的每条记录在 记录头信息 中都包含一个 heap_no 属性,伪记录 Infimum 的 heap_no 值为 0 , Supremum 的 heap_no 值为 1 ,之后每插入一条记录, heap_no 值就增1。 锁结 构 最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个 heap_no ,即一个比特位映射 到页内的一条记录。

# 5. 锁监控

关于MySQL锁的监控,我们一般可以通过检查 InnoDB_row_lock 等状态变量来分析系统上的行锁的争夺情况

mysql> show status like 'innodb_row_lock%';
+-------------------------------+-------+
| Variable_name                 | Value |
+-------------------------------+-------+
| Innodb_row_lock_current_waits | 0     |
| Innodb_row_lock_time          | 0     |
| Innodb_row_lock_time_avg      | 0     |
| Innodb_row_lock_time_max      | 0     |
| Innodb_row_lock_waits         | 0     |
+-------------------------------+-------+
5 rows in set (0.01 sec)
1
2
3
4
5
6
7
8
9
10
11

对各个状态量的说明如下,可以使用show variables like "状态变量" 去查看;

  • Innodb_row_lock_current_waits:当前正在等待锁定的数量;
  • Innodb_row_lock_time :从系统启动到现在锁定总时间长度;(等待总时长)
  • Innodb_row_lock_time_avg :每次等待所花平均时间;(等待平均时长)
  • Innodb_row_lock_time_max:从系统启动到现在等待最常的一次所花的时间;
  • Innodb_row_lock_waits :系统启动后到现在总共等待的次数;(等待总次数)

对于这5个状态变量,比较重要的3个见上面(灰色)。

尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手指定优化计划。

其他监控方法:

MySQL把事务和锁的信息记录在了 information_schema 库中,涉及到的三张表分别是 INNODB_TRX 、 INNODB_LOCKS 和 INNODB_LOCK_WAITS ,我们查询的时候都是以数据库名.表名的形式进行查询。

MySQL5.7及之前 ,可以通过information_schema.INNODB_LOCKS查看事务的锁情况,但只能看到阻塞事 务的锁;如果事务并未被阻塞,则在该表中看不到该事务的锁情况。

MySQL8.0删除了information_schema.INNODB_LOCKS,添加了 performance_schema.data_locks 表,可以通过performance_schema.data_locks表去查看事务的锁情况,和MySQL5.7及之前不同, performance_schema.data_locks表不但可以看到阻塞该事务的锁,还可以看到该事务所持有的锁。

同时,information_schema.INNODB_LOCK_WAITS也被 performance_schema.data_lock_waits 所代替。

简单查看一下performance_schema.data_locks表的结构,对我们后续分析事务的锁情况有所帮助

mysql> desc performance_schema.data_locks;
+-----------------------+-----------------+------+-----+---------+-------+
| 字段名                | 类型            | 空   | 键  | 默认值  | 额外信息 |
+-----------------------+-----------------+------+-----+---------+-------+
| ENGINE                | varchar(32)     | NO   | PRI | NULL    |       | -- 存储引擎
| ENGINE_LOCK_ID        | varchar(128)    | NO   | PRI | NULL    |       | -- 锁ID
| ENGINE_TRANSACTION_ID | bigint unsigned | YES  | MUL | NULL    |       | -- 事务ID
| THREAD_ID             | bigint unsigned | YES  | MUL | NULL    |       | -- 线程ID
| EVENT_ID              | bigint unsigned | YES  |     | NULL    |       | -- 事件ID
| OBJECT_SCHEMA         | varchar(64)     | YES  | MUL | NULL    |       | -- 对象所在的数据库名
| OBJECT_NAME           | varchar(64)     | YES  |     | NULL    |       | -- 对象名,如表名
| PARTITION_NAME        | varchar(64)     | YES  |     | NULL    |       | -- 分区名
| SUBPARTITION_NAME     | varchar(64)     | YES  |     | NULL    |       | -- 子分区名
| INDEX_NAME            | varchar(64)     | YES  |     | NULL    |       | -- 索引名
| OBJECT_INSTANCE_BEGIN | bigint unsigned | NO   |     | NULL    |       | -- 对象实例起始位置
| LOCK_TYPE             | varchar(32)     | NO   |     | NULL    |       | -- 锁类型
| LOCK_MODE             | varchar(32)     | NO   |     | NULL    |       | -- 锁模式
| LOCK_STATUS           | varchar(32)     | NO   |     | NULL    |       | -- 锁状态
| LOCK_DATA             | varchar(8192)   | YES  |     | NULL    |       | -- 锁定的数据
+-----------------------+-----------------+------+-----+---------+-------+
15 rows in set (0.01 sec)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

我们模拟一个锁等待的场景,以下是从这三张表收集的信息

锁等待场景,我们依然使用记录锁中的案例,当事务2进行等待时,查询情况如下:

(1)查询正在被锁阻塞的sql语句。

SELECT * FROM information_schema.INNODB_TRX\G;
1

重要属性代表含义已在上述中标注。

(2)查询锁等待情况

SELECT * FROM data_lock_waits\G;
*************************** 1. 行 ***************************
ENGINE: INNODB                                  -- 存储引擎
REQUESTING_ENGINE_LOCK_ID: 139750145405624:7:4:7:139747028690608 -- 请求锁的锁ID
REQUESTING_ENGINE_TRANSACTION_ID: 13845         -- 请求锁的事务ID(被阻塞的事务ID)
REQUESTING_THREAD_ID: 72                       -- 请求锁的线程ID
REQUESTING_EVENT_ID: 26                        -- 请求锁的事件ID
REQUESTING_OBJECT_INSTANCE_BEGIN: 139747028690608 -- 请求锁的对象实例起始位置
BLOCKING_ENGINE_LOCK_ID: 139750145406432:7:4:7:139747028813248 -- 阻塞锁的锁ID
BLOCKING_ENGINE_TRANSACTION_ID: 13844          -- 阻塞锁的事务ID(正在执行的事务ID,阻塞了13845)
BLOCKING_THREAD_ID: 71                         -- 阻塞锁的线程ID
BLOCKING_EVENT_ID: 24                          -- 阻塞锁的事件ID
BLOCKING_OBJECT_INSTANCE_BEGIN: 139747028813248 -- 阻塞锁的对象实例起始位置
1 row in set (0.00 sec)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

(3)查询锁的情况

mysql > SELECT * from performance_schema.data_locks\G;
*************************** 1. 行 ***************************
ENGINE: INNODB                  -- 存储引擎
ENGINE_LOCK_ID: 139750145405624:1068:139747028693520 -- 锁ID
ENGINE_TRANSACTION_ID: 13847    -- 事务ID
THREAD_ID: 72                   -- 线程ID
EVENT_ID: 31                    -- 事件ID
OBJECT_SCHEMA: atguigu          -- 数据库名
OBJECT_NAME: user               -- 表名
PARTITION_NAME: NULL            -- 分区名(此处无分区)
SUBPARTITION_NAME: NULL         -- 子分区名(此处无子分区)
INDEX_NAME: NULL                -- 索引名(不适用于表级锁)
OBJECT_INSTANCE_BEGIN: 139747028693520 -- 对象实例起始位置
LOCK_TYPE: TABLE                -- 锁类型(表级锁)
LOCK_MODE: IX                   -- 锁模式(意向共享锁)
LOCK_STATUS: GRANTED            -- 锁状态(已授予)
LOCK_DATA: NULL                 -- 锁定的数据(不适用于表级锁)

*************************** 2. 行 ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139750145405624:7:4:7:139747028690608
ENGINE_TRANSACTION_ID: 13847
THREAD_ID: 72
EVENT_ID: 31
OBJECT_SCHEMA: atguigu
OBJECT_NAME: user
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY             -- 索引名(主键索引)
OBJECT_INSTANCE_BEGIN: 139747028690608
LOCK_TYPE: RECORD               -- 锁类型(记录锁)
LOCK_MODE: X,REC_NOT_GAP        -- 锁模式(排他锁,非间隙锁)
LOCK_STATUS: WAITING            -- 锁状态(等待)
LOCK_DATA: 1                    -- 锁定的数据(主键值为1的记录)

*************************** 3. 行 ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139750145406432:1068:139747028816304
ENGINE_TRANSACTION_ID: 13846
THREAD_ID: 71
EVENT_ID: 28
OBJECT_SCHEMA: atguigu
OBJECT_NAME: user
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 139747028816304
LOCK_TYPE: TABLE
LOCK_MODE: IX
LOCK_STATUS: GRANTED
LOCK_DATA: NULL

*************************** 4. 行 ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139750145406432:7:4:7:139747028813248
ENGINE_TRANSACTION_ID: 13846
THREAD_ID: 71
EVENT_ID: 28
OBJECT_SCHEMA: atguigu
OBJECT_NAME: user
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139747028813248
LOCK_TYPE: RECORD
LOCK_MODE: X,REC_NOT_GAP
LOCK_STATUS: GRANTED
LOCK_DATA: 1
4行记录 (0.00 秒)

ERROR:
No query specified  # 未指定查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

从锁的情况可以看出来,两个事务分别获取了IX锁,我们从意向锁章节可以知道,IX锁互相时兼容的。所以这里不会等待,但是事务1同样持有X锁,此时事务2也要去同一行记录获取X锁,他们之间不兼容,导 致等待的情况发生。

# 6. 附录

间隙锁加锁规则(共11个案例)

间隙锁是在可重复读隔离级别下才会生效的: next-key lock 实际上是由间隙锁加行锁实现的,如果切换 到读提交隔离级别 (read-committed) 的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁 的部分。而在读提交隔离级别下间隙锁就没有了,为了解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row 。也就是说,许多公司的配置为:读提交隔离级别加 binlog_format=row。业务不 需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。

next-key lock的加锁规则

总结的加锁规则里面,包含了两个 “ “ 原则 ” ” 、两个 “ “ 优化 ” ” 和一个 “bug” 。

  1. 原则 1 :加锁的基本单位是 next-key lock 。 next-key lock 是前开后闭区间。
  2. 原则 2 :查找过程中访问到的对象才会加锁。任何辅助索引上的锁,或者非索引列上的锁,最终 都要回溯到主键上,在主键上也要加一把锁。
  3. 优化 1 :索引上的等值查询,给唯一索引加锁的时候, next-key lock 退化为行锁。也就是说如果 InnoDB扫描的是一个主键、或是一个唯一索引的话,那InnoDB只会采用行锁方式来加锁
  4. 优化 2 :索引上(不一定是唯一索引)的等值查询,向右遍历时且最后一个值不满足等值条件的 时候, next-keylock 退化为间隙锁。
  5. 一个 bug :唯一索引上的范围查询会访问到不满足条件的第一个值为止。

我们以表test作为例子,建表语句和初始化语句如下:其中id为主键索引

CREATE TABLE `test` (
`id` int(11) NOT NULL,
`col1` int(11) DEFAULT NULL,
`col2` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into test values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
1
2
3
4
5
6
7
8
9

案例一:唯一索引等值查询间隙锁

image-20220714134603698

由于表 test 中没有 id=7 的记录

根据原则 1 ,加锁单位是 next-key lock , session A 加锁范围就是 (5,10] ; 同时根据优化 2 ,这是一个等 值查询 (id=7) ,而 id=10 不满足查询条件, next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)

案例二:非唯一索引等值查询锁

image-20220714134623052

这里 session A 要给索引 col1 上 col1=5 的这一行加上读锁。

  1. 根据原则 1 ,加锁单位是 next-key lock ,左开右闭,5是闭上的,因此会给 (0,5] 加上 next-key lock 。
  2. 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的(可能有col1=5的其他记 录),需要向右遍历,查到c=10 才放弃。根据原则 2 ,访问到的都要加锁,因此要给 (5,10] 加 next-key lock 。
  3. 但是同时这个符合优化 2 :等值判断,向右遍历,最后一个值不满足 col1=5 这个等值条件,因此退化成间隙锁 (5,10) 。
  4. 根据原则 2 , 只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。

但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住 这个例子说明,锁是加在索引上的。

执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

如果你要用 lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,因为覆盖索引不会访问主键索引,不会给主键索引上加锁

案例三:主键索引范围查询锁

上面两个例子是等值查询的,这个例子是关于范围查询的,也就是说下面的语句

select * from test where id=10 for update
select * from tets where id>=10 and id<11 for update;
1
2

这两条查语句肯定是等价的,但是它们的加锁规则不太一样

image-20220714134742049

  1. 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10] 。 根据优化 1 ,主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
  2. 它是范围查询, 范围查找就往后继续找,找到 id=15 这一行停下来,不满足条件,因此需要加 next-key lock(10,15] 。

session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15] 。首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。

案例四:非唯一索引范围查询锁

与案例三不同的是,案例四中查询语句的 where 部分用的是字段 c ,它是普通索引

这两条查语句肯定是等价的,但是它们的加锁规则不太一样

image-20220714134822160

在第一次用 col1=10 定位记录的时候,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 col1 是非唯 一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-keylock 。

这里需要扫描到 col1=15 才停止扫描,是合理的,因为 InnoDB 要扫到 col1=15 ,才知道不需要继续往后找了。

案例五:唯一索引范围查询锁 bug

image-20220714134846740

session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15] 这个 next-key lock ,并且因 为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。

但是实现上, InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20 。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock 也会被锁上。照理说,这里锁住 id=20 这一行的行为,其实是没有必要的。因为扫描到 id=15 ,就可以确定不用往后再找了。

案例六:非唯一索引上存在 " " 等值 " " 的例子

这里,我给表 t 插入一条新记录:insert into t values(30,10,30);也就是说,现在表里面有两个c=10的行

但是它们的主键值 id 是不同的(分别是 10 和 30 ),因此这两个c=10 的记录之间,也是有间隙的。

image-20220714134923414

这次我们用 delete 语句来验证。注意, delete 语句加锁的逻辑,其实跟 select ... for update 是类似的, 也就是我在文章开始总结的两个 “ 原则 ” 、两个 “ 优化 ” 和一个 “bug” 。

这时, session A 在遍历的时候,先访问第一个 col1=10 的记录。同样地,根据原则 1 ,这里加的是 (col1=5,id=5) 到 (col1=10,id=10) 这个 next-key lock 。

由于c是普通索引,所以继续向右查找,直到碰到 (col1=15,id=15) 这一行循环才结束。根据优化 2 ,这是 一个等值查询,向右查找到了不满足条件的行,所以会退化成 (col1=10,id=10) 到 (col1=15,id=15) 的间隙锁。

image-20220714134945012

这个 delete 语句在索引 c 上的加锁范围,就是上面图中蓝色区域覆盖的部分。这个蓝色区域左右两边都 是虚线,表示开区间,即 (col1=5,id=5) 和 (col1=15,id=15) 这两行上都没有锁

案例七: limit 语句加锁

例子 6 也有一个对照案例,场景如下所示:

image-20220714135007118

session A 的 delete 语句加了 limit 2 。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2 ,删除的效果都是一样的。但是加锁效果却不一样

这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (col1=10, id=30) 这一行之后, 满足条件的语句已经有两条,循环就结束了。因此,索引 col1 上的加锁范围就变成了从( col1=5,id=5) 到( col1=10,id=30) 这个前开后闭区间,如下图所示:

image-20220714135025045

这个例子对我们实践的指导意义就是, 在删除数据的时候尽量加 limit 。

这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。

案例八:一个死锁的例子

image-20220714135047760

  1. session A 启动事务后执行查询语句加 lock in share mode ,在索引 col1 上加了 next-keylock(5,10] 和 间隙锁 (10,15) (索引向右遍历退化为间隙锁);
  2. session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待; 实际上分成了两步, 先是加 (5,10) 的间隙锁,加锁成功;然后加 col1=10 的行锁,因为sessionA上已经给这行加上了读 锁,此时申请死锁时会被阻塞
  3. 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁, InnoDB 让 session B 回滚

案例九:order by索引排序的间隙锁1

如下面一条语句

begin;
select * from test where id>9 and id<12 order by id desc for update;
1
2

下图为这个表的索引id的示意图。

image-20220714135130668

  1. 首先这个查询语句的语义是 order by id desc ,要拿到满足条件的所有行,优化器必须先找到 “ 第 一个 id<12 的值 ” 。
  2. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到 id=12 的这个值,只是最终 没找到,但找到了 (10,15) 这个间隙。( id=15 不满足条件,所以 next-key lock 退化为了间隙锁 (10, 15) 。)
  3. 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到 id=5 这一行,又因为区间是左开右 闭的,所以会加一个next-key lock (0,5] 。 也就是说,在执行过程中,通过树搜索的方式定位记录 的时候,用的是 “ 等值查询 ” 的方法。

案例十:order by索引排序的间隙锁2

image-20220714135206504

  1. 由于是 order by col1 desc ,第一个要定位的是索引 col1 上 “ 最右边的 ”col1=20 的行。这是一个非唯一索引的等值查询:

    左开右闭区间,首先加上 next-key lock (15,20] 。 向右遍历,col1=25不满足条件,退化为间隙锁 所以会 加上间隙锁(20,25) 和 next-key lock (15,20] 。

  2. 在索引 col1 上向左遍历,要扫描到 col1=10 才停下来。同时又因为左开右闭区间,所以 next-key lock 会加到 (5,10] ,这正是阻塞session B 的 insert 语句的原因。

  3. 在扫描过程中, col1=20 、 col1=15 、 col1=10 这三行都存在值,由于是 select * ,所以会在主键 id 上加三个行锁。 因此, session A 的 select 语句锁的范围就是:

    1. 索引 col1 上 (5, 25) ;
    2. 主键索引上 id=15 、 20 两个行锁。

案例十一:update修改数据的例子-先插入后删除

image-20220714135300189

注意:根据 col1>5 查到的第一个记录是 col1=10 ,因此不会加 (0,5] 这个 next-key lock 。

session A 的加锁范围是索引 col1 上的 (5,10] 、 (10,15] 、 (15,20] 、 (20,25] 和(25,supremum] 。

之后 session B 的第一个 update 语句,要把 col1=5 改成 col1=1 ,你可以理解为两步:

  1. 插入 (col1=1, id=5) 这个记录;
  2. 删除 (col1=5, id=5) 这个记录。

通过这个操作, session A 的加锁范围变成了图 7 所示的样子:

image-20220714135333089

好,接下来 session B 要执行 update t set col1 = 5 where col1 = 1 这个语句了,一样地可以拆成两步:

  1. 插入 (col1=5, id=5) 这个记录;
  2. 删除 (col1=1, id=5) 这个记录。 第一步试图在已经加了间隙锁的 (1,10) 中插入数据,所以就被堵住了。

公众号封面

编辑此页 (opens new window)
上次更新: 2024/12/28, 18:32:08
第14章_MySQL事务日志
第16章_多版本并发控制

← 第14章_MySQL事务日志 第16章_多版本并发控制→

Theme by Vdoing | Copyright © 2019-2025 程序员scholar
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式