并发事务的本质
在程序操作数据库时,每条发往MySQL的SQL语句都可以视为一个独立的事务。值得注意的是:
- 事务与连接的关系:每个事务都基于特定的数据库连接执行
- 连接与线程的映射:
MySQL为每个数据库连接分配一个独立的工作线程来维护 - 事务执行的本质:实际上就是工作线程在执行
SQL操作
当多个事务同时执行时,我们称之为并发事务,其本质是多个工作线程并发操作数据库的过程
并发事务带来的挑战
多线程并发操作不可避免地会引发一系列数据一致性问题,主要包括:
- 脏读:指一个事务读到了其他事务还未提交的数据
- 不可重复读:指在一个事务中,多次读取同一数据,先后读取到的数据不一致
- 幻读:另外一个事务在第一个事务要处理的目标数据范围之内新增了数据,并先于第一个事务提交导致第一个事务查询结果不一致造成的问题
针对上述问题,MySQL 通过事务隔离级别提供了解决方案。不同隔离级别之所以能避免特定问题,其核心原理在于:在不同隔离级别下,工作线程执行SQL语句时使用的锁粒度和锁类型各不相同
也就是说,数据库的 锁 是为了解决并发事务带来的问题而诞生的,主要是确保数据库中多条工作线程并行执行时的数据安全性
在深入探讨锁机制之前,我们首先需要全面了解MySQL锁的分类体系,这是理解锁机制的基础
MySQL 锁机制的分类
MySQL的锁机制与索引机制一样,都是由存储引擎实现的,这意味着在不同的存储引擎中,支持的锁也并不同(是指不同的引擎实现的锁粒度不同),虽然按照不同的划分维度,锁的称呼能有很多,如 表锁 行锁 乐观锁 悲观锁,但是整体而言,锁就只有共享锁和排他锁两种
共享锁
共享锁是指不同事务之间不会排斥,可以同时获取锁并执行,但这里说的不会排斥,仅仅是指不会排斥其他事务来读数据,但如果是写数据这也是不允许的
在MySQL中,我们可以在SQL语句后加上相关的关键字来使用共享锁
SELECT ... FOR SHARE;
排他锁
排他锁也被称之为独占锁,当一个操作线程获取到独占锁后,会排斥其他线程,如若其他线程也想对共享资源/同一数据进行操作(读操作和写操作都会阻塞),必须等到当前线程释放锁并竞争到锁资源才行
SELECT ... FOR UPTATE;
但是当另一个事务以普通的方式读数据时,它并不会阻塞等待获取排他锁,而是可以直接立刻执行。这是为什么呢,难道是因为读操作默认加 共享锁 吗?实际并不是,这个背后的原理和 MVCC机制 有关,后续再说
接下来一起来聊一聊 MySQL 中不同粒度的锁,即表锁、行锁、页锁等
表锁
表锁 是 MySQL 锁机制中粒度最大的锁,当线程获取表锁后:
- 会锁定整张表
- 其他线程不能对该表进行特定操作
- 直到锁被释放
但是需要注意的是不同存储引擎的表锁在使用方式上有些不同,比如InnoDB是一个支持多粒度锁的存储引擎,它的锁机制是基于聚簇索引实现的,当SQL执行时,如果能在聚簇索引命中数据,则加的是行锁,如无法命中聚簇索引的数据则加的是表锁
聚簇索引: 在 MySQL 中,聚簇索引(Clustered Index) 是一种特殊的索引结构,它决定了表中数据的物理存储顺序,它有以下特点:
- 数据与索引合一:聚簇索引的叶子节点直接包含行数据,而不是指向数据的指针
- 表数据即索引:InnoDB表本身就是按聚簇索引组织的B+树结构
- 唯一性:每张表只能有一个聚簇索引
工作原理:
- 当表定义了主键时,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 这条记录,那么当 事务A
将 user_id > 3 用户的密码统一修改为 password = abcd 时会锁定4、11这两条行数据且默认使用的时临建锁,此时事务B 不仅无法再插入数据,而且也无法修改 id=11 这条数据
行锁的粒度粗化
需要注意的是行锁并不是一成不变的,出现下面两种情况会使得行锁发生粗化:
- 当系统检测到单个事务锁定大量行记录时,InnoDB 会自动将锁升级
行锁(Row Lock)→ 页锁(Page Lock)→ 表锁(Table Lock)
- InnoDb会在内存中专门分配了一块空间来存储和管理锁对象,但当该区域满了后,就会将行锁粗化为表锁,流程为:
- 事务尝试获取新行锁
- 锁系统检测内存不足
- 释放该事务已持有的所有行锁
- 获取表级意向排他锁(IX)
- 将操作标记为使用表锁
在讨论完表锁和行锁,再来看看乐观锁和悲观锁吧~
需要知道的是无论是乐观锁还是悲观锁,他们都只是一种思想
乐观锁和悲观锁
乐观锁是指每次执行都会乐观的认为只会有自身一条线程操作,因此无需拿锁直接执行
在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:
- Compare:
WHERE version = 5(比较当前版本是否匹配) - Swap:
SET version = version + 1(版本号递增更新)
MySQL会通过下面的机制保证 sql CAS操作的原子性:
- 执行前先获取行锁(短暂悲观锁)
- 检查WHERE条件
- 执行SET更新
- 释放行锁
在 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=5 | 5 |
| t3 | - | 业务处理 | 5 |
| t4 | 执行CAS更新成功 | - | 6 |
| t5 | - | 执行CAS更新失败 | 6 |
但是乐观锁也不一定是能满足所有场景的,比如写操作的并发较高时,就容易导致一个事务长时间一直在重试执行,从而导致客户端的响应尤为缓慢,因此乐观锁更加适用于读大于写的业务场景,频繁写库的业务则并不适合加乐观锁
至于悲观锁,其实就是在每次操作前会默认自己会失败,所以得提前获取锁才会继续执行,上面说的 行锁 以及其他排他锁(独占锁)都是悲观锁思想