MySQL 锁机制

64 阅读17分钟

并发事务的本质

在程序操作数据库时,每条发往MySQL的SQL语句都可以视为一个独立的事务。值得注意的是:

  1. 事务与连接的关系:每个事务都基于特定的数据库连接执行
  2. 连接与线程的映射MySQL 为每个数据库连接分配一个独立的工作线程来维护
  3. 事务执行的本质:实际上就是工作线程在执行 SQL 操作

当多个事务同时执行时,我们称之为并发事务,其本质是多个工作线程并发操作数据库的过程

并发事务带来的挑战

多线程并发操作不可避免地会引发一系列数据一致性问题,主要包括:

  • 脏读:指一个事务读到了其他事务还未提交的数据
  • 不可重复读:指在一个事务中,多次读取同一数据,先后读取到的数据不一致
  • 幻读:另外一个事务在第一个事务要处理的目标数据范围之内新增了数据,并先于第一个事务提交导致第一个事务查询结果不一致造成的问题

针对上述问题,MySQL 通过事务隔离级别提供了解决方案。不同隔离级别之所以能避免特定问题,其核心原理在于:在不同隔离级别下,工作线程执行SQL语句时使用的锁粒度锁类型各不相同

也就是说,数据库的 是为了解决并发事务带来的问题而诞生的,主要是确保数据库中多条工作线程并行执行时的数据安全性

在深入探讨锁机制之前,我们首先需要全面了解MySQL锁的分类体系,这是理解锁机制的基础

MySQL 锁机制的分类

MySQL的锁机制与索引机制一样,都是由存储引擎实现的,这意味着在不同的存储引擎中,支持的锁也并不同(是指不同的引擎实现的锁粒度不同),虽然按照不同的划分维度,锁的称呼能有很多,如 表锁 行锁 乐观锁 悲观锁,但是整体而言,锁就只有共享锁和排他锁两种

共享锁

共享锁是指不同事务之间不会排斥,可以同时获取锁并执行,但这里说的不会排斥,仅仅是指不会排斥其他事务来读数据,但如果是写数据这也是不允许的

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

SELECT ... FOR SHARE;

排他锁

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

SELECT ... FOR UPTATE;

但是当另一个事务以普通的方式读数据时,它并不会阻塞等待获取排他锁,而是可以直接立刻执行。这是为什么呢,难道是因为读操作默认加 共享锁 吗?实际并不是,这个背后的原理和 MVCC机制 有关,后续再说

接下来一起来聊一聊 MySQL 中不同粒度的锁,即表锁、行锁、页锁等

表锁

表锁 是 MySQL 锁机制中粒度最大的锁,当线程获取表锁后:

  • 会锁定整张表
  • 其他线程不能对该表进行特定操作
  • 直到锁被释放

但是需要注意的是不同存储引擎的表锁在使用方式上有些不同,比如InnoDB是一个支持多粒度锁的存储引擎,它的锁机制是基于聚簇索引实现的,当SQL执行时,如果能在聚簇索引命中数据,则加的是行锁,如无法命中聚簇索引的数据则加的是表锁

聚簇索引: 在 MySQL 中,聚簇索引(Clustered Index)  是一种特殊的索引结构,它决定了表中数据的物理存储顺序,它有以下特点:

  1. 数据与索引合一:聚簇索引的叶子节点直接包含行数据,而不是指向数据的指针
  2. 表数据即索引:InnoDB表本身就是按聚簇索引组织的B+树结构
  3. 唯一性:每张表只能有一个聚簇索引

工作原理:

  • 当表定义了主键时,MySQL会自动使用主键作为聚簇索引
  • 如果没有主键,MySQL会选择第一个非空的唯一索引作为聚簇索引
  • 如果两者都没有,MySQL会创建一个隐藏的row_id作为聚簇索引

表锁的实现方式

-- 加读锁(具备读-读可共享特性)
LOCK TABLES table_name READ;

-- 加写锁(具备写-读、写-写排他特性)
LOCK TABLES table_name WRITE;

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

-- 释放锁
UNLOCK TABLES;

但是对于表锁,还有更细粒度的划分,即元数据锁、意向锁、自增锁、全局锁

元数据锁

元数据锁(Metadata Lock),又称 MDL锁,是 MySQL 中一种特殊的表级锁,是用于保护数据库对象的元数据(表结构)不被并发修改,比如你要在一张表中创建/删除一个索引、修改一个字段的名称/数据类型、增加/删除一个表字段等情况时会获取该锁,防止在此期间存在其他事务对表进行 CRUD 操作,确保事务执行期间表结构的一致性

元数据锁的类型
MDL 共享锁(MDL_SHARED)
  • 获取时机:执行 SELECT 查询时
  • 特性:允许多个事务同时持有
  • 冲突:与排他锁互斥
-- 事务1
BEGIN;
SELECT * FROM users; -- 获取MDL_SHARED

-- 事务2
BEGIN;
ALTER TABLE users ADD COLUMN age INT; -- 等待MDL锁释放
MDL 共享排他锁(MDL_SHARED_UPGRADABLE)
  • 获取时机:准备修改数据时(如 INSERT/UPDATE/DELETE)
  • 特性:可升级为排他锁
  • 冲突:与排他锁互斥
MDL 排他锁(MDL_EXCLUSIVE)
  • 获取时机:执行 DDL 操作时(ALTER TABLE 等)
  • 特性:完全独占
  • 冲突:与其他所有 MDL 锁互斥

锁升级流程

SELECT → MDL_SHARED
    ↓
UPDATE → MDL_SHARED_UPGRADABLE
    ↓
ALTER → MDL_EXCLUSIVE

意向锁

在了解意向锁之前,先考虑下面一个场景:

一张表中有一千万条数据,现在 事务1 对 其中某条数据(假如是 id = 6666666) 加了一个行锁,此时来了一个 事务2,想要获取这张表的表级别 写锁,但是在前面已经说明,大家应该知道 写锁 是独占锁,即同一时间内只允许当前事务操作,如果表中存在其他事务已经获取了锁,目前事务就无法获取锁进行写操作

此时对于 事务2 来说,他在获取 表锁 之前,需要判断表中是否已经存在 行锁 ,那么就要循环遍历表中数据,但是对于大数据量的表来说,这是一个耗时操作,另外还存在一个问题,当 事务2 在遍历表中数据时,此时又来了一个 事务3事务2 已经遍历过的数据加了一个 行锁 ,那么 事务2 的遍历操作就像是个小丑,没有意义

上面的情况说明 行锁表锁 出现了兼容的问题,那么意向锁 的出现就是为了解决了 行锁表锁 共存时的冲突检测问题

定义:

意向锁是 InnoDB 引擎中一种特殊的表级锁,它作为行锁与表锁之间的协调机制,是实现多粒度锁定的关键

"意向"表示事务打算在表的某些行上加锁:

  • 意向锁是表级锁
  • 先获取意向锁,才能获取行锁
  • 意向锁之间相互兼容
意向锁的类型
  • 意向共享锁(IS锁

    • 语句:SELECT ... For Share
    • 含义:事务打算在某些行上加共享锁(S锁)
    • 兼容性:与IX锁兼容
  • 意向排他锁(IX锁

    • 语句:SELECT ... FOR UPDATE / UPDATE / DELETE
    • 含义:事务打算在某些行上加排他锁(X锁)
    • 兼容性:与IS锁兼容
意向锁的兼容矩阵:
当前锁 \ 请求锁IS (意向共享锁)IX (意向排他锁)S (共享锁)X (排他锁)
IS✔️✔️✔️✖️
IX✔️✔️✖️✖️
S✔️✖️✔️✖️
X✖️✖️✖️✖️
意向锁的工作流程
事务开始
    ↓
获取表级意向锁(IS/IX)
    ↓
获取行级锁(S/X)
    ↓
执行操作

自增锁

一般我们在建表时,都会对一张表的主键设置自增属性,语句为:

create table if not exists user (
  id bigint(20) not null auto_increment primary key,
  ...
)engine = innodb;

当该字段设置为 AUTO_INCREMENT 后,后续向该表中插入数据时无需为该字段赋值。但如果在高并发的场景下,可能会遇到同一时间存在两个事务同时向该表插入树,比如目前表中最大的 id=88,如果两个并发事务一起对表执行插入语句,由于是并发执行的原因,所以有可能会导致插入两条 id=89 的数据。那么此时这里必须要加上一个排他锁来确保并发插入时的安全性,但是锁的存在会导致插入的效率降低

那么为了改善高并发下插入数据时的性能,自增锁诞生了。自增锁是 MySQL 中一种特殊的表级锁,专用于处理自增列(AUTO_INCREMENT)的并发分配问题,它仅为具备 AUTO_INCREMENT 自增字段的表服务。另外自增锁也分成了不同的级别,可以通过innodb_autoinc_lock_mode参数控制

  • innodb_autoinc_lock_mode = 0:传统模式
  • innodb_autoinc_lock_mode = 1:连续模式(MySQL8.0以前的默认模式)
  • innodb_autoinc_lock_mode = 2:交错模式(MySQL8.0之后的默认模式)
传统模式:

事务T1获取自增锁插入数据时,当事务T2也要插入数据的话只能阻塞等待,也就是传统模式下的自增锁,同时只允许一条线程执行,这种形式显然性能较低

连续模式:

在该模式下,对于能够提前确定数量的插入语句,则不会再获取自增锁。也就是对于“普通插入类型”的语句,因为在插入之前就已经确定了要插入多少条数据,因此会直接分配范围自增值

如存在事务采用 INSERT INTO table_name(...) VALUES(...),(...) 这种插入数据的方式,因为可以得知其插入的数据行数,那么该模式会选择预先将自增值分配给插入的数据,且该模式此时不会选择使用自增锁,而是使用一种叫做 Mutex-Lock 的轻量锁来防止自增值重复

交错模式

在该模式下,完全放弃使用了自增锁,而是采用 Mutex-Lock 的轻量锁来保证自增列的数据安全性,因为在该模式下,不同事务同时插入数据时,自增列的值是交错插入的,如事务 T1、T2 都要执行批量插入的操作时,因为不确定各自要插入多少条数据,所以在连续模式下的“连续预分配”的思想就无法实现,但是换个角度,无法实现连续预分配,那交错预分配呢?如给事务 T1 分配{1、3、5、7、9....},给事务 T2 分配{2、4、6、8、10.....},然后两个事务交错插入,那么此时就会存在另外一种情况,插入完成的的数据自增列的值无法保证连续,但是一定可以保证唯一,这也是该模式一个很重要的特点

全局锁

全局锁是 MySQL 中最高级别的锁机制,它会对整个数据库实例加锁,是最严格的锁类型,加上全局锁之后,整个数据库只允许读,不允许做任何写操作,一般全局锁是在对整库做数据备份时使用

-- 会话1(管理员)
FLUSH TABLES WITH READ LOCK; -- 获取全局锁

-- 会话2(其他用户)
UPDATE account SET balance = 100; -- 所有DML操作被阻塞
SELECT * FROM account; -- 只读查询仍可执行

行锁

经过上面的分析,发现表锁会将整张表“锁”起来,当一个事务获得表锁时,其他事务会因为无法获取表锁而阻塞。这会导致并发环境下效率很低,所以就出现了更细粒度的锁 -- 行锁,顾名思义,行锁 是对表中每条记录进行加锁,这样即使多个事务来操作时,只要不是操作同一条数据,那么互相之间是不会阻塞的,这大大提高了效率

InnoDB 的行锁实现

MySQL 诸多的存储引擎中,只有 InnoDB 引擎支持行锁,这是因为只有 InnoDB 支持聚簇索引,在上面已经说过,InnoDB 引擎中如果能够命中索引数据,就会加 行锁,否则因为无法命中而会加 表锁

-- 获取行级别的 共享锁
select * from `users` where user_id = 1 for share;

-- 获取行级别的 排他锁
select * from `users` where user_id = 1 for update;
间隙锁

间隙锁是 InnoDB 引擎中一种特殊的行锁机制,是对行锁的一种补充,专门用于解决幻读问题

定义:

  • 锁定范围:索引记录之间的"间隙"(不存在数据的区间)
  • 设计目的:防止其他事务在范围内插入新数据(幻读)
  • 触发条件:在 REPEATABLE READ 隔离级别下自动启用

间隙示意图:

SELECT * FROM `users`;
-------------------------------------------------------------------
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 上三      || sdad     | 2025-06-14 15:22:01 |
|       2 | 里斯      || dass     | 2025-06-14 16:17:44 |
|       3 | 王五      || dsad     | 2025-06-16 07:42:21 |
|       4 | 柱子      || dsca     | 2025-06-27 17:22:59 |
|      11 | 铁子      || dsas     | 2025-06-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+

此时的间隙为:(4,11) (11,+∞)

间隙锁 和间隙又有什么关系,为什么说 间隙锁 是为了解决幻读的问题?

对于幻读的概念大家都清楚,即:事务B事务A 要处理的目标数据范围之内新增了数据,并先于 事务A 提交导致 事务A 两次前后查询结果不一致的问题

直接上例子:

在上面的表数据中,现在管理员想要将 user_id > 3 用户的密码统一修改为 password = abcd,此时该任务交予 事务A 进行操作,但是待 事务A 操作完成后,再次查询 user_id > 3 用户的密码时,发现出现了一条 user_id = 7 的新数据,且密码并不为 abcd。这就是因为幻读的原因导致的,因为在 事务A 操作期间,出现了 事务B 在数据间隙 (4,11) 中新增了一条user_id = 7 的新数据且先于 事务A 提交。但是事务A并不知道,所以无法防止 事务B 进行数据插入的操作,但是如果间隙锁存在,此时 事务A 操作时会对范围 (4,11) 进行加间隙锁,那么 事务B 也就无法再插入数据,也就解决了幻读的问题

临键锁

临键锁是 InnoDB 引擎在 REPEATABLE READ 隔离级别下的默认行锁算法,也是解决幻读问题的核心机制

临键锁 = 记录锁(Record Lock)  + 间隙锁(Gap Lock)
同时锁定索引记录本身和该记录之前的间隙

现有数据:id=10, id=20, id=30  
临键锁范围(左开右闭):
(-∞,10] (10,20] (20,30] (30,+∞)

依旧拿上面的例子说明,临键锁不知会锁住范围(4,11),也会锁住 id=11 这条记录,那么当 事务Auser_id > 3 用户的密码统一修改为 password = abcd 时会锁定4、11这两条行数据且默认使用的时临建锁,此时事务B 不仅无法再插入数据,而且也无法修改 id=11 这条数据

行锁的粒度粗化

需要注意的是行锁并不是一成不变的,出现下面两种情况会使得行锁发生粗化:

  • 当系统检测到单个事务锁定大量行记录时,InnoDB 会自动将锁升级
行锁(Row Lock)→ 页锁(Page Lock)→ 表锁(Table Lock)
  • InnoDb会在内存中专门分配了一块空间来存储和管理锁对象,但当该区域满了后,就会将行锁粗化为表锁,流程为:
    1. 事务尝试获取新行锁
    2. 锁系统检测内存不足
    3. 释放该事务已持有的所有行锁
    4. 获取表级意向排他锁(IX)
    5. 将操作标记为使用表锁

在讨论完表锁和行锁,再来看看乐观锁和悲观锁吧~

需要知道的是无论是乐观锁还是悲观锁,他们都只是一种思想

乐观锁和悲观锁

乐观锁是指每次执行都会乐观的认为只会有自身一条线程操作,因此无需拿锁直接执行

MySQL中可以通过version版本号+CAS的形式实现乐观锁,也就是在表中多设计一个version字段,其工作流程为:

-- 读锁阶段
SELECT id, name, stock, version FROM products WHERE id = 100;
-- 返回:id=100, stock=50, version=5
# 应用层处理业务逻辑
int new_stock = original_stock - order_quantity
-- 提交验证
UPDATE products 
SET stock = 45, version = 6 
WHERE id = 100 AND version = 5;
-- 返回受影响行数

如果返回受影响行数为0,则会触发重试或者报错

但是上面的工作流程好像不太能看出来 CAS 的作用

其实在提交验证时执行的 sql 就是在进行 CAS 操作,下面来解析一下这个 sql

  • CompareWHERE version = 5(比较当前版本是否匹配)
  • SwapSET version = version + 1(版本号递增更新)

MySQL会通过下面的机制保证 sql CAS操作的原子性:

  1. 执行前先获取行锁(短暂悲观锁)
  2. 检查WHERE条件
  3. 执行SET更新
  4. 释放行锁

InnoDb引擎 里的内部处理,这里用伪代码表示:

// 伪代码展示InnoDB内部处理
bool innodb_cas_update() {
    row_lock(row);  // 短暂获取行锁
    if(row.version == expected_version) {
        row.value = new_value;
        row.version++;
        row_unlock(row);
        return true;
    }
    row_unlock(row);
    return false;
}

一次完整的事务时序

时间事务A事务B版本值
t1读取version=5-5
t2业务处理读取version=55
t3-业务处理5
t4执行CAS更新成功-6
t5-执行CAS更新失败6

但是乐观锁也不一定是能满足所有场景的,比如写操作的并发较高时,就容易导致一个事务长时间一直在重试执行,从而导致客户端的响应尤为缓慢,因此乐观锁更加适用于读大于写的业务场景,频繁写库的业务则并不适合加乐观锁

至于悲观锁,其实就是在每次操作前会默认自己会失败,所以得提前获取锁才会继续执行,上面说的 行锁 以及其他排他锁(独占锁)都是悲观锁思想