1. 锁定义

客户端发往MySQL的一条条SQL语句 , 实际上都可以理解成一个个单独的事务 ( 一条sql语句默认就是一个事务 ) 。而事务是基于数据库连接的 , 每个数据库连接在MySQL中 , 又会用一条工作线程来维护 , 也意味着一个事务的执行 , 本质上就是一条工作线程在执行 , 当出现多个事务同时执行时 , 这种情况则被称之为并发事务 , 所谓的并发事务也就是指多条线程并发执行。

多线程并发执行自然就会出问题 , 也就是脏写、脏读、不可重复读及幻读问题。而对于这些问题又可以通过调整事务的隔离级别来避免 , 那为什么调整事务的隔离级别后能避免这些问题产生呢?这是因为不同的隔离级别中 , 工作线程执行SQL语句时 , 用的锁粒度、类型不同。

由以上可知 , 数据库的锁机制本身是为了解决并发事务带来的问题而诞生的 , 主要是确保数据库中 , 多条工作线程并行执行时的数据安全性。

锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中 , 除传统的计算资源 ( CPURAMI/O ) 的争用以外 , 数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题 , 锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说 , 锁对数据库而言显得尤其重要 , 也更加复杂。

2. 锁分类

首先我们来看看mariadb的锁的概念 , 所谓锁就是当一个进程或事务在操作某一资源时 , 为了防止其他用户或者进程或事务对其进行资源操作 , 导致资源抢占而发生冲突 , 通常在A进程操作该资源时 , 会对该资源进行加锁 , 实现多进程或多用户操作同一资源时 , 不会发生冲突 ; 通常情况锁的类型分读锁写锁 , 所谓读锁就是共享锁 , 它可以实现多个读操作共享 ; 而写锁就是排它锁 , 独占锁 , 一旦加了写锁 , 其他用户的读写操作将被阻塞 , 直到该写锁被释放或者因超时而被释放 , 在其他用户进行的读写操作 , 此时就会被执行 。

对于锁定范围来讲 , 它又可以分为 , 表锁行锁 , 从字面意思就可以理解到 , 表锁就是针对整张表所施加的锁 , 而这种锁定力度相当粗糙 , 并发相对就比较低 , 但是维持锁状态锁消耗的成本资源就较小。 对于行锁来说 , 它针对的范围就是行级别所施加的锁 , 这种锁的粒度就相对要精细 , 同时并发相对较高 , 但是维护锁状态消耗的成本资源就相对要大 。

对于mysql来讲锁分为存储引擎级别的锁和mysql server级别的锁 , 存储引擎级别的锁指的是对于何时施加锁或者释放锁由存储引擎自行决定 ; mysql server级别锁指的是用户使用命令可自行决定施加锁或释放锁 , 简单讲就是允许用户显式请求加锁或释放锁 。 显式锁就是用户手动用命令施加的锁 , 隐式锁指的是由存储引擎根据需要自行施加的锁 。

对于innodb存储引擎来讲 , 它支持事务 , 行级锁 ; 而早期的MyISAM存储引擎它不支持事务 , 对锁的粒度是表级锁 , 不支持行级锁 ;

MySQL的锁机制与索引机制类似 , 都是由存储引擎负责实现的 , 这也就意味着不同的存储引擎 , 支持的锁也并不同 , 这里是指不同的引擎实现的锁粒度不同。但除开从锁粒度来划分锁之外 , 其实锁也可以从其他的维度来划分 , 因此也会造出很多关于锁的名词 , 下面先简单梳理一下MySQL的锁体系 :

  • 以锁粒度的维度划分

    • 全局锁 : 锁定数据库中的所有表。加上全局锁之后 , 整个数据库只能允许读 , 不允许做任何写操作

    • 表级锁 : 每次操作锁住整张表。主要分为三类

      • 表锁 : 分为表共享读锁 read lock、表独占写锁write lock

      • 元数据锁 ( meta data lock , MDL ) : 基于表的元数据加锁 , 加锁后整张表不允许其他事务操作。这里的元数据可以简单理解为一张表的表结构

      • 意向锁 ( 分为意向共享锁、意向排他锁 ) : 这个是InnoDB中为了支持多粒度的锁 , 为了兼容行锁、表锁而设计的 , 使得表锁不用检查每行数据是否加锁 , 使用意向锁来减少表锁的检查

    • 行级锁 : 每次操作锁住对应的行数据。主要分为三类

      • 记录锁 / Record 锁 : 也就是行锁 , 一条记录和一行数据是同一个意思。防止其他事务对此行进行updatedelete , 在 RCRR隔离级别下都支持

      • 间隙锁 / Gap 锁 : 锁定索引记录间隙 ( 不含该记录 ) , 确保索引记录间隙不变 , 防止其他事务在这个间隙进行insert , 产生幻读。在RR隔离级别下都支持

      • 临键锁 / Next-Key 锁 : 间隙锁的升级版 , 同时具备记录锁+间隙锁的功能 , 在RR隔离级别下支持

  • 以互斥性的角度划分

    • 共享锁 / S锁 : 不同事务之间不会相互排斥、可以同时获取的锁

    • 排他锁 / X锁 : 不同事务之间会相互排斥、同时只能允许一个事务获取的锁

    • 共享排他锁 / SX锁 : MySQL5.7版本中新引入的锁 , 主要是解决SMO带来的问题

  • 以操作类型的维度划分

    • 读锁 : 查询数据时使用的锁

    • 写锁 : 执行插入、删除、修改、DDL语句时使用的锁

  • 以加锁方式的维度划分

    • 显示锁 : 编写SQL语句时 , 手动指定加锁的粒度

    • 隐式锁 : 执行SQL语句时 , 根据隔离级别自动为SQL操作加锁

  • 以思想的维度划分

    • 乐观锁 : 每次执行前认为自己会成功 , 因此先尝试执行 , 失败时再获取锁

    • 悲观锁 : 每次执行前都认为自己无法成功 , 因此会先获取锁 , 然后再执行

总归说来说去其实就共享锁、排他锁两种 , 只是加的方式不同、加的地方不同 , 因此就演化出了这么多锁的称呼。

mariadb-lock-1

锁和事务的实现是存储引擎内的组件管理的 , 而MariaDB/MySQL是插件式的存储引擎实现方式 , 所以不同的存储引擎可以支持不同级别的锁和事务。

3. 共享锁和排他锁

3.1 共享锁(S锁)

定义 : 一个事务已获取共享锁 , 当另一个事务尝试对具备共享锁的数据进行读操作时 , 可正常读;进行写操作时 , 会被共享锁排斥

共享锁的意思很简单 , 也就是不同事务之间不会排斥 , 可以同时获取锁并执行。但这里所谓的不会排斥 , 仅仅只是指不会排斥其他事务来读数据 , 但其他事务尝试写数据时 , 就会出现排斥性 , 举个例子理解 :

事务T1ID=18的数据加了一个共享锁 , 此时事务T2、T3也来读取ID=18的这条数据 , 这时T2、T3是可以获取共享锁执行的;但此刻又来了一个事务T4 , 它则是想对ID=18的这条数据执行修改操作 , 此时共享锁会出现排斥行为 , 不允许T4获取锁执行。

MySQL中 , 我们可以在SQL语句后加上相关的关键字来使用共享锁 , 语法如下 :

SELECT ... LOCK IN SHARE MODE;
-- MySQL8.0之后也优化了写法 , 如下 : 
SELECT ... FOR SHARE;

这种通过在SQL后添加关键字的加锁形式 , 被称为显式锁 , 而实际上为数据库设置了不同的事务隔离级别后 , MySQL也会对SQL自动加锁 , 这种形式则被称之为隐式锁。

样例 : 做个关于共享锁的小测试 , 先打开两个cmd窗口并于mysql建立连接

-- 窗口1 : 
-- 开启一个事务
begin;
-- 获取共享锁并查询 id=2 的数据
select * from bank_balance where id=2 lock in share mode;
-- 窗口2 : 
-- 开启一个事务
begin;
-- 获取共享锁并查询 id=2 的数据
select * from bank_balance where id=2 lock in share mode;
​
-- 尝试修改id=2的数据
update bank_balance set balance=230 where id=2;

mariadb-lock-2

mariadb-lock-3

  • 当窗口1获取了共享锁 , 窗口2执行查询/读操作时 可获取共享锁、正常读;但当窗口2执行修改/写操作时 窗口2没反应、未执行成功。

  • 而当窗口1中事务A提交后 , 窗口2事务B的写操作才能继续往下执行。

由上可见 , 一个事务已获取共享锁 , 当另一个事务尝试对具备共享锁的数据进行读操作时 , 可正常读;进行写操作时 , 会被共享锁排斥。因此从这个实验中可以得知 : 共享锁也具备排他性 , 会排斥其他尝试写的线程 , 当有线程尝试修改同一数据时会陷入阻塞 , 直至持有共享锁的事务结束才能继续执行。

3.2 排他锁(X锁)

当一个线程获取到独占锁后 , 会排斥其他线程 ( 进行读写操作) , 如若其他线程也想对共享资源/同一数据进行操作 , 必须等到当前线程释放锁并竞争到锁资源才行

值得注意的一点是 : 排他锁并不是只能用于写操作 , 对于一个读操作 , 咱们也可以手动地指定为获取排他锁 , 当一个事务在读数据时 , 获取了排他锁 , 那当其他事务来读、写同一数据时 , 都会被排斥。比如事务T1ID=18的这条数据加了一个排他锁 , 此时T2来加排他锁读取这条数据 , T3来修改这条数据 , 都会被T1排斥。

MySQL中 , 可以通过如下方式显式获取独占锁 :

SELECT ... FOR UPTATE;

测试 :

mariadb-lock-4

mariadb-lock-5

当两个事务同时获取排他锁 , 尝试读取一条相同的数据时 , 其中一个事务就会陷入阻塞 , 直至另一个事务结束才能继续往下执行;

但是select * from bank_balance where id=2这种普通读 不会被阻塞 , 也就是另一个事务不获取排他锁读数据 , 而是以普通的方式读数据 , 这种方式则可以立刻执行 , Why?是因为读操作默认加共享锁吗?并不是 , 因为你尝试加共享锁读这条数据时依旧会被排斥。

可以明显看到 , 第二个事务中尝试通过加共享锁的方式读取这条数据 , 依旧会陷入阻塞状态 , 那前面究竟是因为啥原因才导致的能读到数据呢?其实这跟另一种并发控制技术有关 , 即MVCC机制。

增、删、改都会对数据添加X锁 , 在查询语句中使用for update也会添加X

3.3 锁释放

在前面的测试中 , 每次都仅获取了锁 , 但好像从未释放过锁?其实MySQL中释放锁的动作都是隐式的 , 毕竟如果交给咱们来释放 , 很容易由于操作不当造成死锁问题发生。因此对于锁的释放工作 , MySQL自己来干 , 就类似于JVM中的GC机制一样 , 把内存释放的工作留给了自己完成。

  • 但对于锁的释放时机 , 在不同的隔离级别中也并不相同 , 比如在“读未提交”级别中 , 是SQL执行完成后就立马释放锁;而在“可重复读”级别中 , 是在事务结束后才会释放。

如果完全按照数据库规范来实现RC隔离级别 , 为了保证其他事务可以读到未提交的数据 , 那就必须得在SQL执行完成后 , 立马释放掉锁 , 这时另一个事务才能读到SQL对应写的数据 , 但在InnoDB引擎中 , 它基于MVCC机制实现了该效果 , 为此 , InnoDBRC级别中 , SQL执行结束后并不会释放锁。

4 全局锁

4.1 介绍

全局锁就是对整个数据库实例加锁 , 加锁后整个实例就处于只读状态 , 后续的DML的写语句 , DDL语句 , 已经更新操作的事务提交语句都将被阻塞。

其典型的使用场景是做全库的逻辑备份 , 对所有的表进行锁定 , 从而获取一致性视图 , 保证数据的完整性。

为什么全库逻辑备份 , 就需要加全就锁呢? ——我们分析下 不加全局锁 可能存在的问题、以及加了全局锁后的情况。

mariadb-lock-6

4.2 语法

# 加全局锁、获取全局锁
flush tables with read lock;  
​
# 数据备份。具体指令可见 
mysqldump -u 用户名 -p 数据库名 > /back/backup.sql
​
# 释放全局锁
unlock tables;

4.3 特点

数据库中加全局锁 , 是一个比较重的操作 , 存在以下问题 :

  • 如果在主库上备份 , 那么在备份期间都不能执行更新 , 业务基本上就得停摆。

  • 如果在从库上备份 , 那么在备份期间从库不能执行主库同步过来的二进制日志(binlog) , 会导致主从延迟。

InnoDB引擎中 , 我们可以在备份时加上参数 --single-transaction 参数来完成不加锁的一致性数据备份。

mysqldump --single-transaction -uroot –p123456 test > backup.sql

5 表级锁

5.1 介绍

表级锁 , 每次操作锁住整张表。锁定粒度大 , 发生锁冲突的概率最高 , 并发度最低。应用在MyISAMInnoDBBDB等存储引擎中。

表级锁 : 每次操作锁住整张表。主要分为三类

  • 表锁 ( 分为表共享读锁 read lock、表独占写锁 write lock )

  • 元数据锁 ( meta data lock , MDL ) : 基于表的元数据加锁 , 加锁后整张表不允许其他事务操作。这里的元数据可以简单理解为一张表的表结构

  • 意向锁 ( 分为意向共享锁、意向排他锁 ) : 这个是InnoDB中为了支持多粒度的锁 , 为了兼容行锁、表锁而设计的 , 使得表锁不用检查每行数据是否加锁 , 使用意向锁来减少表锁的检查

5.2 表锁

表锁应该是听的最多的一种锁 , 因为实现起来比较简单 , 同时应用范围也比较广泛 , 几乎所有的存储引擎都会支持这个粒度的锁 , 比如常用的MyISAMInnoDBMemory等各大引擎都实现了表锁。

使用表锁的开销相对较小 , 加锁快 , 不会产生死锁;但是加锁粒度大 , 发生锁冲突的概率更高 , 并发度更低。在innoDB存储引擎中不推荐使用表锁 , 只有在没有事务支持的存储引擎中才会使用 , 如MyISAM

对于表锁 , 分为两类 :

  • 表共享读锁 ( read lock )

  • 表独占写锁 ( write lock )

语法 :

  • 加锁 : lock tables 表名… read/write

    LOCK TABLE[S]
        tbl_name [[AS] alias] lock_type
        [, tbl_name [[AS] alias] lock_type] ...
        [WAIT n|NOWAIT]
    ​
    lock_type:
        READ [LOCAL]
      | [LOW_PRIORITY] WRITE
      | WRITE CONCURRENT

  • 释放锁 : unlock tables / 客户端断开连接

特点 :

  • 读锁

    mariadb-lock-7

  • 写锁

    mariadb-lock-8

    • 如果一个线程获得在一个表上的read锁 , 那么该线程和所有其他线程只能从表中读数据 , 不能进行任何写操作 ( 不同的线程 , 可以通过开多个命令行MySQL客户端来实现 )

    • 如果一个线程在一个表上得到一个 WRITE 锁 , 那么只有拥有这个锁的线程可以从表中读取和写表。其它的线程被阻塞

结论: 读锁不会阻塞其他客户端的读 , 但是会阻塞写。写锁既会阻塞其他客户端的读 , 又会阻塞其他客户端的写

mariadb-lock-9

mariadb-lock-10

但要注意 , 不同引擎的表锁也在实现上以及加锁方式上有些许不同 , 但归根结底 , 表锁的意思也就以表作为锁的基础 , 将锁加在表上 , 一张表只能存在一个同一类型的表锁。

上面这段话中提到过 , 不同的存储引擎的表锁在使用方式上也有些不同 , 比如InnoDB是一个支持多粒度锁的存储引擎 , 它的锁机制是基于聚簇索引实现的 , 当SQL执行时 , 如果能在聚簇索引命中数据 , 则加的是行锁 , 如无法命中聚簇索引的数据则加的是表锁 , 比如 :

select * from bank_balance for update;

这条SQL就无法命中聚簇索引 , 此时自然加的就是表级别的排他锁 , 但是这个表级锁 , 并不是真正意义上的表锁 , 是一个“伪表锁” , 但作用是相同的 , 锁了整张表。

而反观MyISAM引擎 , 由于它并不支持聚簇索引 , 所以无法再以InnoDB的这种形式去对表上锁 , 因此如若要在MyISAM引擎中使用表锁 , 又需要使用额外的语法 , 如下 :

-- MyISAM引擎中获取读锁 ( 具备读-读可共享特性 ) 
LOCK TABLES `table_name` READ;

-- MyISAM引擎中获取写锁 ( 具备写-读、写-写排他特性 ) 
LOCK TABLES `table_name` WRITE;

-- 查看目前库中创建过的表锁 ( in_use>0表示目前正在使用的表锁 ) 
SHOW OPEN TABLES WHERE in_use > 0;

-- 释放已获取到的锁
UNLOCK TABLES;

如上便是MyISAM引擎中 , 获取表级别的共享锁和排他锁的方式 , 但这里的关键词其实叫做READWEITE , 翻译过来也就是读、写的意思 , 因此关于共享锁就是读锁、排他锁就是写锁的说法 , 估计就是因此而来的。

不过MyISAM引擎中 , 获取了锁还需要自己手动释放锁 , 否则会造成死锁现象出现 , 因为如果不手动释放锁 , 就算事务结束也不会自动释放 , 除非当前的数据库连接中断时才会释放。

mariadb-lock-11

InnoDB表锁显式获取后 , 必须要自己主动释放 , 否则结合数据库连接池 , 由于数据库连接是长存的 , 就会导致表锁一直被占用。

当你加了read读锁后 , 再尝试加write写锁 , 就会发现无法获取锁 , 当前线程会陷入阻塞 , 反过来也是同理。

5.3 元数据锁(Meta Data Lock)

Meta Data Lock元数据锁 , 也被简称为MDL锁 , 这是基于表的元数据加锁 , 什么意思呢?我们在上文讲过 : 表锁是基于整张表加锁 , 行锁是基于一条数据加锁 , 那这个表的元数据是什么呢?所有存储引擎的表都会存在一个.frm文件 , 这个文件中主要存储表的结构 ( DDL语句 , 包括表结构的定义信息、创建删除修改表等 ) 。而MDL锁就是基于.frm文件中的元数据加锁的。这里的元数据 可以简单理解为就是一张表的表结构。 也就是说 , 某一张表涉及到未提交的事务时 , 是不能够修改这张表的表结构的。

MDL加锁过程是系统自动控制 , 无需显式使用 , 在访问一张表的时候会自动加上 , 当对一张表进行增删改查的时候 , 加MDL读锁(共享);当对表结构进行变更操作的时候 , 加MDL写锁(排他)。MDL锁主要作用是维护表元数据的数据一致性 , 在表上有活动事务的时候 , 不可以对元数据进行写入操作。为了避免DMLDDL冲突 , 保证读写的正确性

MDL是在MySQL5.5版本后再开始支持的 , 一般来说咱们用不上 , 因此也无需手动获取锁 , 主要在 更改表结构时使用 , 比如你要向一张表创建/删除一个索引、修改一个字段的名称/数据类型、增加/删除一个表字段等这类情况。因为毕竟当你的表结构正在发生更改 , 假设此时有其他事务来对表做CRUD操作 , 自然就会出现问题 , 比如我刚删了一个表字段 , 结果另一个事务中又按原本的表结构插入了一条数据 , 这显然会存在风险 , 因此MDL锁在加锁后 , 整张表不允许其他事务做任何操作。

常见的SQL操作 , 所添加的元数据锁 :

演示 :

当执行SELECTINSERTUPDATEDELETE等语句时 , 添加的是元数据共享锁 ( SHARED_READ / SHARED_WRITE ) , 之间是兼容的

mariadb-lock-12

当执行SELECT语句时 , 添加的是元数据共享锁 ( SHARED_READ ) 。此时如果想更改表结构、加元数据排他锁 ( EXCLUSIVE ) , 会阻塞排斥。

mariadb-lock-13

我们可以通过下面的SQL , 来查看数据库中的元数据锁的情况 :

select object_type,object_schema,object_name,lock_type,lock_duration from performance_schema.metadata_locks;

注意版本 , metadata_locks 表是 MySQL 5.6 版本及之后引入的。如果你正在使用的 MySQL 版本低于 5.6 , 那么该表将不存在。你可以通过运行 SELECT VERSION(); 来检查你的 MySQL 版本。

5.4 意向锁 ( Intention Lock )

5.4.1 介绍

为了避免DML在执行时 , 加的行锁与表锁的冲突 , 在InnoDB中引入了意向锁 , 使得表锁不用检查每行数据是否加锁 , 使用意向锁来减少表锁的检查。

InnoDB引擎是一种支持多粒度锁的引擎 , 而意向锁则是InnoDB中为了支持多粒度的锁 , 为了兼容行锁、表锁而设计的 , 怎么理解这句话呢?先来看一个例子 :

mariadb-lock-14

mariadb-lock-15

假设一张表中有一千万条数据 , 现在事务T1ID=8888888的这条数据加了一个行锁 , 此时来了一个事务T2 , 想要获取这张表的表级别写锁 , 经过前面的一系列讲解 , 大家应该知道写锁必须为排他锁 , 也就是在同一时刻内 , 只允许当前事务操作 , 如果表中存在其他事务已经获取了锁 , 目前事务就无法满足“独占性” , 因此不能获取锁。

由于行锁和表锁之间存在兼容性问题 , 提出了意向锁。意向锁实际上也是一种特殊的表锁 , 意向锁其实是一种“挂牌告知”的思想 , 好比日常生活中的出租车 , 一般都会有一个牌子 , 表示它目前是“空车”还是“载客”状态 , 而意向锁也是这个思想。

比如当事务T1打算对ID=8888888这条数据加一个行锁之前 ( 行级别的读锁或写锁 ) , 就会先加一个表级别的意向锁。此时当事务T2尝试获取一个表级锁时 , 就会先看一下表上是否有意向锁 , 如果有的话再判断一下与自身是否冲突 , 比如表上存在一个意向共享锁 , 目前T2要获取的是表级别的读锁 , 那自然不冲突可以获取。但反之 , 如果T2要获取一个表级的写锁时 , 就会出现冲突 , T2事务则会陷入阻塞 , 直至T1释放了锁 ( 事务结束 ) 为止。

5.4.2 分类

  • 意向共享锁 ( IS ) : 由语句select … lock in share mode添加 , 与表锁共享锁 ( read ) 兼容 , 与表锁排他锁 ( write ) 互斥。在准备给表数据添加一个S锁时 , 需要先获得该表的IS锁

  • 意向排他锁 ( IX ) : 由insertupdatedeleteselect…for update添加 。与表锁共享锁(read)及排他锁(write)都互斥 , 意向锁之间不会互斥。在准备给表数据添加一个X锁时 , 需要先获得该表的IX锁

一旦事务提交了 , 意向共享锁、意向排他锁 , 都会自动释放。

可以通过以下SQL , 查看意向锁及行锁的加锁情况 :

select object_schema,object_name,index_name,lock_type,lock_mode,lock_data from performance_schema.data_locks;

注意版本 , MySQL之前某些版本不支持data_locks 表 , 即该表可能不存在。可以通过运行 SELECT VERSION(); 来检查你的 MySQL 版本。

5.4.3 演示

  • 意向共享锁与表读锁是兼容的

    mariadb-lock-16

  • 意向排他锁与表读锁、写锁都是互斥的

    mariadb-lock-17

6 行级锁

6.1 介绍

行级锁 , 每次操作锁住对应的行数据。锁定粒度最小 , 发生锁冲突的概率最低 , 并发度最高。在MySQL诸多的存储引擎中 , 仅有InnoDB引擎支持行锁 ( 不考虑那些闭源自研的 ) , MyISAM等引擎不支持行锁【因为InnoDB支持聚簇索引——将数据存储与索引放到了一块 , 索引结构的叶子节点保存了行数据。在之前简单聊到过 , InnoDB中如果能够命中索引数据 , 就会加行锁 , 无法命中则会加表锁】。

InnoDB的数据是基于索引组织的 , 行锁是通过对索引上的索引项加锁来实现的 , 而不是对记录加的锁。对于行级锁 , 主要分为以下三类 : 行锁、间隙锁、临键锁

  • 行锁 ( Record Lock ) : 锁定单个行记录的锁 , 防止其他事务对此行进行updatedelete。在RCRR隔离级别下都支持

  • 间隙锁 ( Gap Lock ) : 锁定索引记录间隙 ( 不含该记录 ) , 左右开区间 , 确保索引记录间隙不变 , 防止其他事务在这个间隙进行insert , 产生幻读。在RR隔离级别下都支持

  • 临键锁 ( Next-Key Lock ) : 行锁和间隙锁组合 , 同时锁住数据 , 并锁住数据前面的间隙Gap , 左开右闭。 在RR隔离级别下支持

具体细节如下图 :

mariadb-lock-18

6.2 行锁 / 记录锁(Record Lock)

行锁 ( Record Lock ) , 也称为记录锁 , 一行表数据、一条表记录本身就是同一个含义。锁定单个行记录的锁 , 防止其他事务对此行进行updatedelete。在RCRR隔离级别下都支持。

锁住一行数据 , 在索引上才能加锁 , 非索引会升级为表级锁。

介绍

InnoDB实现了以下两种类型的行锁 :

  • 共享锁 ( S ) : 允许一个事务去读一行 , 阻止其他事务获得相同数据集的排它锁。

  • 排他锁 ( X ) : 允许获取排他锁的事务更新数据 , 阻止其他事务获得相同数据集的共享锁和排他锁。

语法

-- 获取行级别的共享锁
select * from bank_balance where id = 1 lock in share mode;

-- 获取行级别的排他锁
select * from bank_balance where id = 1 for update;

想要使用InnoDB的行锁就是这样写的 , 如果你的SQL能命中索引数据 , 那也就自然加的就是行锁 , 反之则是表锁。但网上很多资料都流传着一个说法 : InnoDB引擎的表锁没啥用 , 其实这句话会存在些许误导性 , 因为意向锁、自增锁、MDL锁都是表锁 , 也包括InnoDB的行锁是基于索引实现的 , 例如在update语句修改数据时 , 假设where后面的条件无法命中索引 , 那咋加行锁呢?此时没办法就必须得加表锁了 , 因此InnoDB的表锁是有用的。

6.3 间隙锁(Gap lock)

锁定索引记录间隙 ( 不含该记录 ) , 左右开区间 , 确保索引记录间隙不变 , 防止其他事务在这个间隙进行insert , 产生幻读。在RR隔离级别下都支持

  1. 间隙锁是对行锁的一种补充 , 主要是用来解决幻读问题的 , 但想要理解它 , 咱们首先来理解啥叫间隙

    mysql> select * from bank_balance;
    +----+-----------+---------+--------+
    | id | user_name | balance | wealth |
    +----+-----------+---------+--------+
    |  1 | Jenny     |     300 |      1 |
    |  2 | Tom       |     230 |      1 |
    |  3 | Jack      |     500 |      0 |
    |  9 | Rose      |     360 |      0 |
    +----+-----------+---------+--------+

    上述这张表最后两条数据 , id字段从3跳到了9 , 那么3~9两者之间的范围则被称为”间隙“ , 而间隙锁主要锁定的就是这块范围。

    那为何又说间隙锁是用来解决幻读问题的呢?因为幻读的概念是 : 一个事务在执行时 , 另一个事务插入了一条数据 , 从而导致第一个事务操作完成之后发现结果与预想的不一致 , 跟产生了幻觉一样。 好比拿上述表举例子 , 现在要将ID>2的用户余额改为100 , 因此事务T1先查到了ID>23、9两条数据并上锁了 , 然后开始更改用户余额 , 但此时事务T2过来又插入了一条ID=6、balance=320的数据并提交 , 等T1修改完了3、9两条数据后 , 此时再次查询ID>2的数据时 , 结果发现了ID=6的这条数据余额并未被修改、数据行比原来还多了。

  2. 加间隙锁的规则

    • 索引上的等值查询(唯一索引) , 给不存在的记录加锁时, 优化为间隙锁

    • 索引上的等值查询(非唯一普通索引) , 向右遍历时最后一个值不满足查询需求时 , next-key lock 退化为间隙锁

    • 索引上的范围查询(唯一索引) – 会访问到不满足条件的第一个值为止

    注意 : 间隙锁唯一目的是防止其他事务插入间隙。间隙锁可以共存 , 一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁

    select * from bank_balance where id=6 lock in share mode;

  3. 演示

    • 索引上的等值查询(唯一索引) , 给不存在的记录加锁时, 优化为间隙锁

      select * from bank_balance where id=6 lock in share mode;

      当对一个不存在的数据加锁后 , 默认就是锁定前后两条数据之间的区间 , 左右开区间 , 即锁定(3,9)、不包含39的区域。当其他事务再尝试向该区间插入数据时 , 就会陷入阻塞 , 只有当持有间隙锁的事务结束后 , 才能继续执行插入操作。

      mariadb-lock-19

    • 索引上的等值查询(非唯一普通索引) , 向右遍历时最后一个值不满足查询需求时 , next-key lock 退化为间隙锁

      介绍分析一下 : 我们知道InnoDBB+树索引 , 叶子节点是有序的双向链表。 假如 , 我们要根据这个二级索引查询值为18的数据 , 并加上共享锁 , 我们是只锁定18这一行就可以了吗? 并不是 , 因为是非唯一索引 , 这个结构中可能有多个18的存在 , 所以 , 在加锁时会继续往后找 , 找到一个不满足条件的值 ( 当前案例中也就是29 ) 。此时会对18加临键锁 , 并对29之前的间隙加锁。

      mariadb-lock-20

      假设stu表中age为非唯一普通索引

      select * from sth where age=20 lock in share mode;
    • 索引上的范围查询(唯一索引) —— 会访问到不满足条件的第一个值为止

      mariadb-lock-21

      查询的条件为id>=6 , 并添加共享锁。 此时我们可以根据数据库表中现有的数据 , 将数据分为三个部分 : [6], (6,9], (9,正无穷)

      所以数据库数据在加锁是 , 就是将6加了行锁 , 9的临键锁 ( 包含99之前的间隙 ) , 正无穷的临键锁(正无穷及之前的间隙)

      当对一个不存在的数据加锁后 , 默认就是锁定前后两条数据之间的区间 , 左右开区间 , 即锁定(3,9)、不包含39的区域。当其他事务再尝试向该区间插入数据时 , 就会陷入阻塞 , 只有当持有间隙锁的事务结束后 , 才能继续执行插入操作。

      mariadb-lock-22

6.4 临建锁 ( Next-Key Lock )

临键锁是间隙锁的Plus版本 , 或者可以说成是一种由记录锁+间隙锁组成的锁 :

  • 记录锁 : 锁定的范围是表中具体的一条行数据。

  • 间隙锁 : 锁定的范围是左右开区间 , 但不包含当前这一条真实数据 , 只锁间隙区域。

而临键锁则是两者的结合体 , 加锁后 , 即锁定左开右闭的区间 ( 每个临键锁是左开右闭区间 ) , 也会锁定当前行数据。

实际上在InnoDB中 , 除开一些特殊情况外 , 当尝试对一条数据加锁时 , 默认加的是临键锁 , 而并非记录锁、间隙锁。也就是说 , 在前面举例幻读问题中 , 当T1要对ID>2的用户做修改余额 , 锁定39这两条行数据时 , 默认会加的是临键锁 , 也就是当事务T2尝试插入ID=6的数据时 , 因为有临建锁存在 , 因此无法再插入这条“幻影数据” , 也就至少保障了T1事务执行过程中 , 不会碰到幻读问题。

间隙锁和临建锁的目的都是用来解决可重复读的问题 , 如果在读提交级别 , 间隙锁和临建锁都会失效。

6.5 行锁的粒度粗化

行锁并不是一成不变的 , 行锁会在某些特殊情况下发生粗化 , 主要有两种情况 :

  • 在内存中专门分配了一块空间存储锁对象 , 当该区域满了后 , 就会将行锁粗化为表锁。

  • 当做范围性写操作时 , 由于要加的行锁较多 , 此时行锁开销会较大 , 也会粗化成表锁。

当然 , 这两种情况其实很少见 , 因此只需要知道有锁粗化这回事即可 , 这种锁粗化的现象其实在SQLServer数据库中更常见 , 因为SQLServer中的锁机制是基于行记录实现的 , 而MySQL中的锁机制则是基于事务实现的

7. 页面锁、乐观锁与悲观锁

上述对MySQL两种较为常见的锁粒度进行了阐述 ( 共享锁与排他锁 , 全局锁、表级锁、行级锁 ) , 接着再来看看页面锁、乐观锁与悲观锁

7.1 页面锁

页面锁是Berkeley DB存储引擎支持的一种锁粒度 , 当然 , 由于BDB引擎被Oracle收购的原因 , 因此MySQL5.1以后不再直接性的支持该引擎 ( 需自己整合 ) , 因此页锁见的也比较少 , 大家稍微了解即可。

  • 表锁 : 以表为粒度 , 锁住的是整个表数据。

  • 行锁 : 以行为粒度 , 锁住的是一条数据。

  • 页锁 : 以页为粒度 , 锁住的是一页数据。

唯一有些许疑惑的地方 , 就是一页数据到底是多少呢?其实我也不大清楚 , 毕竟没用过BDB引擎 , 但我估计就是只一个索引页的大小 , 即16KB左右。

简单了解后页锁后 , 接着来看一看从思想维度划分的两种锁 , 即乐观锁与悲观锁。

7.2 乐观锁

乐观锁即是无锁思想。

  • 乐观锁 : 每次执行都认为只会有自身一条线程操作 , 因此无需拿锁直接执行 , 在最后更新数据时进行比较

  • 悲观锁 : 每次执行都认为会有其他线程一起来操作 , 因此每次都需要先拿锁再执行,保证不被其他事务操作;可通过select…fot update实现。

编程中的无锁技术 , 或者说乐观锁机制 , 一般都是基于CAS思想实现的 , 而在MySQL中则可以通过version版本号+CAS的形式实现乐观锁 , 也就是在表中多设计一个version字段 , 然后在SQL修改时以如下形式操作 :

UPDATE ... SET version = version + 1 ... WHERE ... AND version = version;

也就是每条修改的SQL都在修改后 , 对version字段加一 , 比如T1T2两个事务一起并发执行时 , 当T2事务执行成功提交后 , 就会对version+1 , 因此事务T1version=version这个条件就无法成立 , 最终会放弃执行 , 因为已经被其他事务修改过了。

当然 , 一般的乐观锁都会配合轮询重试机制 , 比如上述T1执行失败后 , 再次执行相同语句 , 直到成功为止。

从上述过程中不难看出 , 这个过程中确实未曾添加锁 , 因此也做到了乐观锁/无锁的概念落地 , 但这种形式却并不适合所有情况 , 比如写操作的并发较高时 , 就容易导致一个事务长时间一直在重试执行 , 从而导致客户端的响应尤为缓慢。

7.3 悲观锁

每次执行时都会加锁再执行 , 可通过select…fot update实现。我们之前分析过的 synchronized关键字、AQS-RentrantLock 都属于悲观锁类型 , 即在每次执行前必须获取到锁 , 然后才能继续往下执行 , 而数据库中的排他锁 , 就是一种典型的悲观锁类型。

在数据库中想要使用悲观锁 , 那也就是对一个事务加排他锁for update即可 , 不再重复赘述

8. 加锁规则

MySQL中数据加锁的规则可以归纳为以下三种 :

两个原则

  • 加锁的基本单位是next-key lock , 前开后闭

  • 查找过程中访问到的对象才会加锁

两个优化

  • 索引上的等值查询 , 给唯一索引加锁的时候 , next-key lock退化为行锁

  • 索引上的等值查询 , 向右遍历时且最后一个值不满足等值条件的时候 , next-key lock退化为间隙锁

一个BUG

  • 唯一索引上的范围查询会访问到不满足条件的第一个值为止

    mariadb-lock-23

t中无id=7的记录 , 根据原则1 , 加锁单位为next-key lock , session A 加锁范围为(5,10]

根据优化2 , 这是一个等值查询(id=7)、且id=10不满足查询条件 , next-key lock退化成间隙锁 , 因此最终加锁的范围是(5,10)

所以session B往这个间隙里面插入id=8的记录会被锁住 , 但是session C修改id=10这是可以的

  • 等值查询上MySQL的优化

  • 索引上的等值查询 , 如果是唯一索引 , next-key lock会退化为行锁 - 索引上的等值查询(非唯一普通索引) , 向右遍历时且最后一个值不满足等值条件的时候 , next-key lock退化为间隙锁

9 总结

总结一下本篇所聊到的不同锁 , 它们之间的冲突与兼容关系 :

PS : 表中横向 ( 行 ) 表示已经持有锁的事务 , 纵向 ( 列 ) 表示正在请求锁的事务

由于临建锁也会锁定相应的行数据 , 因此上表中也不再重复赘述记录锁 , 临建锁兼容的 记录锁都兼容 , 同理 , 冲突的记录锁也会冲突 , 再来看看表级别的锁对比 :

其实会发现表级别的锁 , 会有很多很多冲突 , 因为锁的粒度比较大 , 因此很多时候都会出现冲突 , 但对于表级锁 , 咱们只需要关注共享意向锁和共享排他锁即可 , 其他的大多数为MySQL的隐式锁 ( 在这里 , 共享意向锁和排他意向锁 , 也可以理解为MyISAM中的表读锁和表写锁 ) 。

表中的冲突和兼容究竟是啥意思?冲突的意思是当一个事务T1持有某个锁时 , 另一个事务T2来请求相同的锁 , T2会由于锁排斥会陷入阻塞等待状态。反之同理 , 兼容的意思是指允许多个事务一同获取同一个锁。


参考链接

Mariadb之显式使用表锁和行级锁 - Linux-1874 - 博客园

MySQL锁、加锁机制 ( 超详细 ) —— 锁分类、全局锁、共享锁、排他锁


熊熊