Featured image of post Java工程师 MySQL中的锁

Java工程师 MySQL中的锁

🌏Java工程师 MySQL中的锁 🎯 这篇文章用于记录MySQL中的锁的学习

概述

MVCC高效地处理了“读-写”冲突,实现了高并发的读;而锁则处理了“写-写”冲突,并作为基础保障,维护了MVCC多版本数据的一致性和正确性。两者协同工作,共同实现了MySQL的隔离级别。

MySQL中的锁,按照锁的粒度分,分为以下三类:

  • 全局锁:锁定数据库中所有表
  • 表级锁:每次操作锁住整张表
  • 行级锁:每次操作锁住对应的行数据

全局锁

全局锁是MySQL中锁粒度最粗的一种锁。当你执行一条加全局锁的命令后,整个MySQL实例(Instance)中的所有数据库和所有表都会被设置为只读状态

在MySQL中,全局锁主要通过一条命令实现:FLUSH TABLES WITH READ LOCK; MySQL确保了在加锁的那一刻,所有正在进行的表操作都已完成。

加锁后的效果

  1. 数据操作语言(DML)阻塞:所有针对数据的写操作INSERT, UPDATE, DELETE, ALTER TABLE, DROP TABLE 等)都会被阻塞。
  2. 数据定义语言(DDL)阻塞:所有修改表结构的操作也会被阻塞。
  3. 数据查询语言(DQL)允许读操作SELECT)可以正常执行。
  4. 事务相关:在显式地提交或回滚后,COMMITROLLBACK 操作也会被阻塞。因为提交事务可能会涉及写入二进制日志(binlog)或重做日志(redo log),这些也被视为写操作。

要释放全局锁,需要执行:UNLOCK TABLES;

主要使用场景

由于其强大的破坏性(让整个实例几乎停滞),全局锁的使用场景非常有限,主要集中于全库逻辑备份

这是全局锁最经典、也是最主要的用途。使用 mysqldump 或其他工具进行逻辑备份时,为了得到一份数据一致性的备份快照,必须确保在备份过程中,数据库的数据不会发生变化。

示例命令:

# 在第一个会话中
mysql> FLUSH TABLES WITH READ LOCK;   -- 加全局锁
mysql> -- 保持这个会话连接打开,一旦断开,锁会自动释放

# 在另一个终端,执行备份(注意不需要进入MySQL的命令窗口 直接在宿主机执行即可)
$ mysqldump -u root -p --all-databases > full_backup.sql  # 备份所有的数据库
$ mysqldump -u root -p DB_NAME > DB_NAME.sql              # 备份某个数据库

# 备份完成后,回到第一个会话释放锁
mysql> UNLOCK TABLES;

缺点和替代方案

FTWRL 的杀伤力太大(整个实例只读),它在生产环境中几乎不可接受。因此,MySQL提供了更好的、对业务影响更小的替代方案。

方案1:使用 --single-transaction 参数 (InnoDB引擎)

对于支持事务的存储引擎(如 InnoDB),mysqldump 提供了 -single-transaction 参数。它通过启动一个事务来获取一份一致性的快照。

  • 原理:它会在备份开始时启动一个事务(START TRANSACTION WITH CONSISTENT SNAPSHOT)。利用 MVCC 的特性,这个事务可以看到一个特定时间点的数据库状态,并且在事务执行期间,即使其他会话修改了数据,该事务看到的仍然是快照时的数据。
  • 优点
    • 备份期间其他会话可以正常进行读写操作,几乎不影响业务。
    • 不需要加全局锁。
  • 前提:所有需要备份的表都必须是 InnoDB 等支持事务的存储引擎。

表级锁

表级锁是MySQL中锁粒度较大的一种锁。当会话对某个表加上表锁后,它会锁定整个表,从而影响其他会话对该表的并发访问。MySQL的表级锁主要有3种类型:

  1. 表锁
    1. 表共享读锁(Table Read Lock):允许其他会话读,但不允许写。
    2. 表独占写锁(Table Write Lock):不允许其他会话读,也不允许写。
  2. 元数据锁
  3. 意向锁

表级锁主要由存储引擎实现,最典型的是 MyISAMMEMORY 引擎,它们仅支持表级锁。而InnoDB引擎主要使用行级锁,但在特定情况下也会使用表级锁

对于MyISAM表,在执行SQL语句时,存储引擎会自动、隐式地加锁,无需用户干预。

-- 给mytable表加读锁(其他会话可读不可写)
LOCK TABLES mytable READ;

-- 给mytable表加写锁(其他会话不可读也不可写)
LOCK TABLES mytable WRITE;

-- 一次性给多个表加锁(例如,为连接查询做准备)
LOCK TABLES mytable1 READ, mytable2 WRITE;

-- 释放当前会话持有的所有表锁
UNLOCK TABLES;

表共享读锁

允许其他会话读,但不允许写。

表独占写锁

不允许其他会话读,也不允许写。

元数据锁(Meta Data Lock)

元数据锁(MDL)的加锁过程是系统自动控制的,无需显示使用,在访问一张表的时候会自动加上。MDL锁的主要作用是维护表元数据的数据一致性,在表上有活动事务的时候,不可以对元数据进行写入操作。

简单来讲,就是要自动避免DML和DDL的冲突从而保证读写的正确性

在MySQL5.5中引入了MDL,当对一张表进行增删改查的时候,加MDL读锁(共享);当对表结构进行变更操作的时候,加MDL写锁(排他)。

操作类型(事务中) 获取的MDL锁 兼容性说明
lock tables 表名 read SHARED_READ_ONLY 表共享读锁
lock tables 表名 write SHARED_NO_READ_WRITE 表排他写锁
select SHARED_READ SHARED_READSHARED_WRITE 兼容,与 EXCLUSIVE 互斥
select ... lock in share mode SHARED_READ SHARED_READSHARED_WRITE 兼容,与 EXCLUSIVE 互斥
insert, update, delete SHARED_WRITE SHARED_READSHARED_WRITE 兼容,与 EXCLUSIVE 互斥
select ... for update SHARED_WRITE SHARED_READSHARED_WRITE 兼容,与 EXCLUSIVE 互斥
alter table ... EXCLUSIVE 与其他的MDL锁都互斥

DML和DDL互斥演示

查看元数据锁的命令

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

意向锁

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

  • 意向共享锁(IS):与表锁共享锁(read)兼容,与表锁排他锁(write)互斥;

  • 意向排他锁(IX):与表锁共享锁(read)及表锁排他锁(write)都互斥。意向排他锁之间不会互斥。

查看意向锁的命令

例如,现在客户端1在事务中执行一个更新操作,使用如下命令查看当前的意向锁信息,更新产生意向排他锁:

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

注意,对于普通的读命令不产生意向共享锁,如果需要意向共享锁,需要手动添加命令:

select * from user where id = 1 LOCK IN SHARE MODE;

行级锁

行级锁,每次操作锁住对应的行数据。锁定粒度最小,发生锁冲突的概率最低,并发度最高。应用在InnoDB存储引擎中。

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

  • 行锁(Record Lock):锁住单个行记录的锁,防止其他事务对此进行update和delete。在RC、RR隔离级别下都支持;
  • 间隙锁(Gap Lock):锁定索引记录间隙(不包该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行insert,产生幻读。在RR隔离级别下支持;
  • 临键锁(Next-Key Lock):行锁和间隙锁组合,同时锁定数据,并锁住数据前面的间隙Gap。在RR隔离级别下支持。
特性 间隙锁 (Gap Lock) 临键锁 (Next-Key Lock)
本质 纯间隙,开区间 (a, b) 行锁 + 间隙锁,左开右闭区间 (a, b]
锁定的内容 阻止在锁定区间内插入新记录 阻止在锁定区间内插入新记录,并锁定区间上界的已存在记录
主要目的 防止插入操作导致的幻读 防止插入更新(更新可能导致记录进入范围)导致的幻读
出现场合 1. REPEATABLE-READ 级别 2. 查询不存在的记录 3. 唯一索引的范围查询 4. 非唯一索引的等值查询 1. REPEATABLE-READ 级别 2. InnoDB 默认的加锁方式 3. 唯一索引的范围查询(更常见) 4. 非唯一索引的查询
兼容性 间隙锁之间是兼容的。多个事务可以持有同一个间隙的间隙锁,因为它们的目标一致:都是阻止插入。 因为包含行锁,所以与其他事务对同一行记录的行锁是互斥的。

行锁

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

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

(2)排他锁(X):允许获取排他锁的事务更新数据,组织其他事务获取相同数据集的共享锁和排他锁。

默认情况下,InnoDB在RR(可重复读)事务隔离级别运行时,会使用临键锁进行搜索和索引扫描,以防止幻读。

  1. 针对唯一索引进行检索时,对已存在的记录进行等值匹配时,将会自动优化为行锁;
  2. InnoDB的行锁是针对处于索引加的锁,如果不通过索引条件检索数据InnoDB就会对表中所有的记录加锁,此时即升级为表锁。

间隙锁

(1)什么情况下会产生间隙锁?

  • 使用范围查询(Range Query)或等值查询不存在的记录时;
  • 普通索引(非唯一索引)最容易产生间隙锁。因为允许多个相同的值存在,所以为了防止在相同的值之间插入新记录,InnoDB必须使用间隙锁来锁定范围。
  • 唯一索引(主键也是一种唯一索引)规则有所放宽。对于唯一索引的等值查询(WHERE id = ?),如果查询的记录存在,InnoDB会退化为只使用行锁。因为唯一性保证了不可能再插入相同的值。但是,如果查询的记录不存在,它依然会产生间隙锁(如上面的示例2)。
  • 没有索引:如果查询条件没有用到任何索引,MySQL会对全表加锁(表锁),但这并不是标准的间隙锁行为,性能极差。

(2)间隙锁唯一的目的是防止其他事务插入间隙。间隙锁可以共享,一个事务采用的间隙锁不会组织另一个事务在同一间隙上采用间隙锁。

(3)场景举例:

临键锁

数据准备

创建一个简单的表: 创建一个名为 students 的表,其中 id 是主键(聚簇索引),score 上有一个二级索引。临键锁通常发生在范围查询或非唯一索引上,用 score 索引来演示。

CREATE TABLE `students` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `score` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_score` (`score`)
) ENGINE=InnoDB;

插入一些测试数据: 插入一些数据,确保 score 值有重复且分布在不同区间,以便演示。

INSERT INTO `students` (`name`, `score`) VALUES
('Alice', 60),
('Bob', 70),
('Charlie', 75),
('David', 75), -- 重复的分数,用于演示
('Eve', 80),
('Frank', 90);

演示步骤

第 1 步:在会话 1 中启动一个事务并执行范围查询

这个查询在 score 索引上进行范围查找,这正是 InnoDB 使用临键锁的典型场景。

-- 在 Session 1 中执行
START TRANSACTION;
SELECT * FROM students WHERE score BETWEEN 71 AND 79 FOR UPDATE;
-- 注意:FOR UPDATE 是显式加锁所必需的

第 2 步:在会话 2 中查看当前的锁信息

在另一个会话中,查询 performance_schema.data_locks 表。

-- 在 Session 2 中执行
SELECT 
    object_schema, 
    object_name, 
    index_name, 
    lock_type, 
    lock_mode, 
    lock_data 
FROM performance_schema.data_locks 
WHERE object_name = 'students';