MySQL进阶11 — 锁机制5

概述

本章,您将学习到 MySQL 8 中有关锁机制的知识,内容包括:

  • 锁机制的分类
  • 演示 InnoDB 存储引擎的行锁
  • 演示 InnoDB 存储引擎的表锁
  • 锁的查看
  • 有关乐观锁、全局锁和死锁的内容
  • MVCC

由于内容较多,本文档说明最后一部分内容 —— MVCC

基本概念

隔离级别(从低到高) 脏读 不可重复读 幻读
1(READ UNCOMMITTED,读取未提交的数据) 会出现 会出现 会出现
2(READ COMMITTED,读取提交的数据) 不会出现 会出现 会出现
3(REPEATABLE-READ,可重复读,全局默认隔离级别) 不会出现 不会出现 会出现
4(SERIALIZABLE,序列化) 不会出现 不会出现 不会出现

MVCC(Multi-Version Concurrency Control,多版本并发控制):一种提高并发性的强大技术,其通过数据行的的多个版本管理(多个版本需要依赖 Undo Log )来实现数据库的并发控制。对于 InnoDB 而言,其主要作用是 —— 提高高并发环境下的读写性能,减少锁竞争,确保数据一致性。

在 InnoDB 存储引擎中,为了支撑起 MVCC 的高效运转,有两大核心读取机制:

  1. 快照读(consistent read)- 事务读取的是数据的‌历史快照版本‌,而非当前最新值,属于‌非阻塞读‌
  2. 当前读(current read) - 事务读取的是数据的‌最新提交版本‌,并‌加锁‌以保证一致性

两者的对比如下表所示:

对比项 快照读 当前读
触发 普通 SELECT 语句(未加锁)在 READ COMMITTED 和 REPEATABLE READ 隔离级别下自动触发 排他锁(select ... for update;)和共享锁(select ... lock in share mode;)显式触发;DML 操作(insertupdatedelete)隐式触发
隔离级别 READ COMMITTED、REPEATABLE READ 隔离级别下默认使用 全部
版本控制 通过 MVCC(多版本并发控制)读取历史版本数据 读取最新提交版本数据,不依赖历史版本
锁机制 不加锁,避免锁竞争 加排他锁(X 锁)或共享锁(S 锁),防止数据修改
数据一致性 避免脏读、不可重复读(REPEATABLE READ 隔离级别下) 避免脏读、不可重复读、幻读(REPEATABLE READ 隔离级别下)
性能 高并发读性能优异,减少锁开销 读写冲突时加锁可能导致性能下降
适用场景 读操作频繁、对数据一致性要求较低的场景 需要数据一致性保证的场景(如事务内修改数据)
示例 SQL SELECT * FROM table WHERE id = 1; SELECT * FROM table WHERE id = 1 FOR UPDATE;

并发场景

在 InnoDB 存储引擎中,当使用者需要对相同的行数据进行操作时,可以出现以下三种情况:

  • 读-读(Read-Read)
  • 读-写 或 写-读(Read-Write / Write-Read)
  • 写-写(Write-Write)

三种情况的对比如下表所示:

对比项 Read-Read Read-Write / Write-Read Write-Write
定义 多个事务同时读取同一行数据 一个事务读取,另一个事务修改同一行数据 多个事务同时修改同一行数据
是否发生冲突 无冲突 可能引发脏读、不可重复读、幻读(取决于隔离级别) 严格冲突,不允许并发
核心机制 MVCC + 快照读 快照读(普通 SELECT)与当前读(显式加锁)协同;隔离级别决定默认行为 排他锁(X 锁)串行化执行
是否加锁 不加锁 快照读不加锁;当前读加共享锁(S 锁)或排他锁(X 锁) 加排他锁(X 锁)
隔离级别影响 所有隔离级别均无影响 READ UNCOMMITTED 下允许脏读;READ COMMITTED 下避免脏读,允许不可重复读;REPEATABLE READ 下避免前两者,需间隙锁防幻读 所有隔离级别下均禁止并发写入,机制一致
性能影响 极高并发且无阻塞,性能最优 快照读下是高性能;当前读下可能阻塞,导致性能下降 串行化,吞吐量低,易形成瓶颈
示例 SQL SELECT * FROM users WHERE id = 100; 快照读:SELECT * FROM users WHERE id = 100;
当前读:SELECT * FROM users WHERE id = 100 FOR UPDATE;
UPDATE users SET name = 'Alice' WHERE id = 100;
解决的核心问题 实现高并发读取,消除读-读竞争 解决读写并发下的数据一致性问题(脏读、不可重复读、幻读) 保证数据最终一致性,防止脏写
是否依赖 MVCC 完全依赖 快照读依赖 MVCC;当前读绕过 MVCC,直接读最新版本 不依赖 MVCC,仅依赖锁机制
术语
脏写:指‌两个未提交的事务同时修改同一行数据‌,且其中一个事务回滚后,另一个事务的修改被错误保留,导致数据状态不一致。

了解 MVCC

主要包括三部分的内容:

  1. 隐藏字段
  2. Undo Log
  3. ReadView

隐藏字段

当使用者在当前库中新创建一张表后,无论是使用 show createa table 表名; 还是 desc 表名; ,都不能看见隐藏字段,这是因为 InnoDB 的底层机制已经帮你自动配置了。

在 InnoDB 中,所谓 MVCC 的隐藏字段,指的是 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID,如下表的说明:

DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
是否必须存在
大小 6 字节 7 字节 6 字节
作用 记录最后一次修改该行的事务 ID 指向 Undo Log 的回滚指针 隐藏的行唯一标识符
说明 每次更新行数据时,InnoDB 会将当前事务的 ID 写入此字段,用于判断版本可见性 通过该指针可追溯该行的历史版本,是实现快照读和事务回滚的核心机制 当 Innodb 自动产生聚集索引时,聚集索引会包括这个行标识符的值。
提示
聚集索引:MySQL 中,聚集索引通常指主键约束生成的主键索引。若表中的列无主键约束(主键索引),则会选择首个具有唯一约束(唯一索引) 的列作为聚集索引。若一张表既没有主键约束(主键索引),也没有唯一约束(唯一索引),那么 InnoDB 会自动创建一个 GEN_CLUST_INDEX 的隐式聚集索引。请注意!一张表只能有一个聚集索引。

Undo Log

MVCC(多版本并发控制)中的多个数据版本管理是依靠什么来实现的?

Undo Log 的版本链。

首先我们回顾下前面关于表空间的知识

表空间:

表空间是逻辑层与物理层的中间桥梁,它是用户逻辑对象(表、索引等)的存储空间,用来统一管理空间中的数据文件。

表空间的属性包括:

  • 表空间在物理层上对应着若干个容器 ,容器可以是目录名称、文件名或者设备名称
  • 一个库可以包含多个表空间,但一个表空间只能属于一个库
  • 一个表空间可以包含多个数据文件,但一个数据文件只能属于一个表空间

表空间是逻辑存储,其按照表空间(Tablespaces) ---> 段(Segment) ---> 区(Extent) ---> 页(Page) ---> 行(Row)的层级结构构成。

撤销表空间(回滚表空间,Undo Tablespaces)专门用于存储撤销日志(Undo log),MySQL 8.x 及以上版本进行初始化时,默认会在数据目录中创建两个撤销表空间文件(无 .ibu 后缀标识),这些文件以回滚段的结构形式存储回滚日志。

Shell > ls -lh /usr/local/mysql8/data/undo_00*
-rw-r----- 1 mysql mysql 16M Nov 18 19:07 /usr/local/mysql8/data/undo_001
-rw-r----- 1 mysql mysql 16M Nov 18 19:07 /usr/local/mysql8/data/undo_002

除了初始化时默认创建的两个撤销表空间之外,用户可使用 create undo tablespace 语法来创建专属的撤销表空间,该语法关联到文件时必须有 .ibu 后缀标识,语法如下:

CREATE UNDO TABLESPACE tablespace_name ADD DATAFILE 'file_name.ibu';

提示
一个 MySQL 实例最多支持 127 个撤销表空间(包括 MySQL 实例初始化时创建的两个默认撤销表空间)。

Q:Undo Log 什么时候生成记录呢?

数据修改时:每当事务修改数据(如 updatedelete 操作)时,InnoDB 会为该操作生成一个 Undo Log 记录,存储修改前的数据值(即数据的前映像)。例如,事务 A 更新 balance 字段时,Undo Log 记录旧的 balance 值。

insert 操作也会生成 Undo Log 记录,但仅用于事务的回滚,并不参与到 MVCC 多版本管理。

操作类型 是否生成 Undo Log 是否用于 MVCC 多版本链 提交后是否保留
insert 是(仅用于回滚) 否(可立即清理)
update 是(需维持版本链)
delete 是(需维持版本链)

假设您当前库下有个 mvcc 的表,包含 id(主键约束,自增列,int 类型) 和 name(varchar(10) 类型) 两个字段。

连接会话的事务1 执行 insert into mvcc values(10,'king'); 并提交。因为已经有主键约束,所以不存在 DB_ROW_ID 隐藏字段。

id name DB_TRX_ID DB_ROLL_PTR
10 king 1024(事务 ID 默认自增,此处假设为 1024) null

连接会话的事务2 执行 update mvcc set name='wang' where id=10; 更新操作并提交,此时除了修改 name 这个字段下面的数据记录外,事务 ID 以及回滚指针字段下的数据记录都会变化。回滚指针指向上一个版本。

file

连接会话的事务3 执行 update mvcc set id=20 where id=10; 更新操作并提交,此时出现了多个历史版本记录,如下所示。多次修改后,就构成了我们说的 Undo Log 版本链。从链表角度来看,这是一个单向链表,其中链头节点是最新版本,链尾节点是最早的历史版本。

file

Q:Undo Log 中,对于同一行数据的修改而言,版本链的长度(修改所产生的历史版本数量)有无限制?

无显式限制,但有实际运行时的资源约束。当版本链的长度过长时,查询需遍历更多历史版本,导致读取性能显著下降,甚至引发 "版本链爆炸" 问题,影响整个数据库的稳定性。当版本链过长时,MySQL 通过专门的 Purge 线程来自动清理那些不再需要的 Undo Log 版本,释放出存储空间并带来数据库的稳定性。

Purge 线程会检查每个 Undo Log 版本是否仍被当前活跃事务的读视图(ReadView)所引用。只有当所有可能访问该版本的事务均已提交或回滚,该版本才会被标记为可清理。

ReadView(读视图)

在 MVCC 机制中,多个事务对同一行记录进行更新时会产生多个历史版本,这些修改的历史版本保存在 Undo Log 中,若需要读取该行数据的某个历史版本,就需要 ReadView。换言之,ReadView 是用来管理数据历史版本的规则或机制,判断版本链中的哪些版本是当前事务可见的。

因为 READ UNCOMMITTED 隔离级别可以读到未提交的事务的数据(即允许脏读),所以其读取的是最新版本的数据,换言之,RU 隔离级别不依赖 MVCC ,也不需要 ReadView。至于 SERIALIZABLE 隔离级别,它采用‌加锁机制‌(如间隙锁、临键锁)来完全阻止并发写入,从而保证事务串行执行,它‌不依赖 MVCC,也不需要 ReadView。

因此,ReadView 或 MVCC 只针对 RC 和 RR 这两个隔离级别。

提示
快照读是行为,ReadView 是实现快照读的机制。没有 ReadView,就无法确定哪些版本对当前事务可见,快照读也就无从谈起。

Q:什么情况下会生成 ReadView 这种规则呢?

  • RC 隔离级别下:每次执行 SELECT 语句时,InnoDB 都会‌动态创建一个新的 ReadView‌。这意味着每次查询都能看到其他事务‌已提交的最新数据‌,因此可能在同一个事务中读取到不同版本的数据(也就是 "不可重复读")。
  • RR 隔离级别下:事务开始后(start transaction;)首次执行普通 SELECT 语句,InnoDB 会为该事务创建一个固定不变的 ReadView。此后,事务内所有后续查询都复用这个初始 ReadView,确保在整个事务生命周期内读取到一致的历史版本数据,从而避免 "不可重复读",结合间隙锁(Gap Locks)和临键锁(Next-key Locks),可进一步防止 "幻读" 出现 。

单个 ReadView 包含以下四个核心字段:

字段名称 含义 说明
m_ids 活跃事务 ID 列表 生成 ReadView 时,所有‌未提交‌事务的 ID 组成的集合
m_low_limit_id 最小活跃事务 ID 最早开始但尚未提交的事务 ID
m_up_limit_id 下一个预分配事务 ID 当前系统中‌已分配的最大事务 ID + 1‌,代表未来将要分配的事务 ID
m_creator_trx_id 创建者事务 ID 生成该 ReadView 的当前事务的 ID

活跃事务:已开启但尚未提交或回滚的事务,其事务 ID 在系统中仍处于 "活跃" 状态,因此称为活跃事务。

ReadView 还涉及到可见性判断算法(有些资料也称可见性算法):

  • 若记录 DB_TRX_ID = m_creator_trx_id ,表示当前事务正在访问自身的修改记录,所以该版本对当前事务可见
  • 若记录 DB_TRX_ID < m_low_limit_id ,表示修改记录的事务早于 ReadView 生成前提交,所以该版本对当前事务可见
  • 若记录 DB_TRX_ID >= m_up_limit_id ,表示修改记录的事务在 ReadView 生成后才启动,所以该版本对当前事务不可见
  • 若记录 m_low_limit_id <= DB_TRX_ID < m_up_limit_id,则需要看情况

    • 如果 DB_TRX_ID 在 m_ids 的事务 ID 集合中,则表示生成 ReadView 时,修改这条数据的事务还处于 "活跃未提交" 状态,所以该版本对当前事务不可见
    • 如果 DB_TRX_ID 不在 m_ids 的事务 ID 集合中,则表示生成 ReadView 时,修改这条数据的事务已经提交,所以该版本对当前事务可见

在 RR 隔离级别下,假设你开启了一个事务(ID=10),执行普通 select 语句时生成了 ReadView:

  • m_ids={101,103,105}(这三个事务还没提交)
  • m_low_limit_id=100(当前最小的活跃事务 ID 是 100)
  • m_up_limit_id=200(下一个要分配的事务 ID 是 200)

此时你查询一条数据,不同数据版本的可见性如下:

  • 数据版本的 DB_TRX_ID=10 - 是你自己改的,所以该版本对当前事务可见
  • 数据版本的 DB_TRX_ID=99 - 事务 99 在你生成 ReadView 前就提交了,所以该版本对当前事务可见
  • 数据版本的 DB_TRX_ID=200 - 事务 200 是你生成 ReadView 后才启动的,所以该版本对当前事务不可见
  • 数据版本的 DB_TRX_ID=101 - 事务 101 在活跃集合里(没提交),所以该版本对当前事务不可见
  • 数据版本的 DB_TRX_ID=102 - 事务 102 不在活跃集合里(已提交),所以该版本对当前事务可见

ReadView 和 MVCC 的实际案例

使用的是默认 RR 隔离级别。

use locks;

select * from tlock1;
+----+------+
| id | name |
+----+------+
|  1 | kone |
|  7 | john |
| 15 | lee  |
+----+------+
3 rows in set (0.03 sec)

desc tlock1;
+-------+-------------+------+-----+---------+----------------+
| Field | Type        | Null | Key | Default | Extra          |
+-------+-------------+------+-----+---------+----------------+
| id    | int         | NO   | PRI | NULL    | auto_increment |
| name  | varchar(10) | YES  |     | NULL    |                |
+-------+-------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)
时间步骤 Session 1 Sessios 2 Session 3
步骤1 use locks;
set autocommit=0;
start transaction;
update tlock1 set name='Jack' where id=15;
步骤2 use locks;
set autocommit=0;
start transaction;
步骤3 use locks;
select * from information_schema.innodb_trx;
步骤4 commit;
步骤5 select * from tlock1;
步骤6 select * from information_schema.innodb_trx;
步骤 7 rollback;
  • Session 1 中,update tlock1 set name='Jack' where id=15; 表示持有 MDL 读锁

  • Session 3 的步骤3中,通过 select * from information_schema.innodb_trx; 的输出可知, Session 1 的事务 ID 为 31270

    select * from information_schema.innodb_trx\G;
    *************************** 1. row ***************************
                    trx_id: 31270
                 trx_state: RUNNING
               trx_started: 2025-12-21 18:04:33
    ...
  • Session 3 的步骤6中,通过 select * from information_schema.innodb_trx;\G 的输出可知, Session 2 的事务 ID 为 421805598646272

    select * from information_schema.innodb_trx\G;
    *************************** 1. row ***************************
                    trx_id: 421805598646272
                 trx_state: RUNNING
               trx_started: 2025-12-21 18:07:54
    ...
  • Session 2 中,select * from tlock1; 表示生成 ReadView,又由于 Session 1 的事务已经提交,相当于记录的 DB_TRX_ID < m_low_limit_id,因此其他事务的提交修改在当前事务中是可见的

    select * from tlock1;
    +----+------+
    | id | name |
    +----+------+
    |  1 | kone |
    |  7 | john |
    | 15 | Jack |
    +----+------+
    3 rows in set (0.00 sec)
  • 请注意! Session 2 中的 select * from tlock1; 并不是 "不可重复读"

不可重复读 - 指在一个事务内,最开始读到的数据和事务结束之前读到的数据不一致,其产生原因如下:

file

Q:为什么 RR 隔离级别可以不出现 "不可重复读" 现象呢?

结合 ReadView 可知,因为第一次 select 读取后就将 ReadView 固定了。

时间步骤 Session 3 Session 4
步骤1 use locks;
set autocommit=0;
start transaction;
select * from tlock1;
步骤2 use locks;
set autocommit=0;
start transaction;
update tlock1 set name='J' where id=7;
commit;
步骤3 select * from tlock1;
步骤4 rollback;
  • Session 3 的步骤1 中,select * from tlock1; 的输出如下:

    select * from tlock1;
    +----+------+
    | id | name |
    +----+------+
    |  1 | kone |
    |  7 | john |
    | 15 | Jack |
    +----+------+
    3 rows in set (0.00 sec)
  • Session 3 的步骤3 中,select * from tlock1; 的输出如下:

    select * from tlock1;
    +----+------+
    | id | name |
    +----+------+
    |  1 | kone |
    |  7 | john |  ←← 无变化
    | 15 | Jack |
    +----+------+
    3 rows in set (0.00 sec)

RR 隔离级别要想解决 "不可重复读" 现象,有一个重要的条件 —— 必须在其他事务提交之前读,将 ReadView 固定。

Avatar photo

关于 陸風睿

GNU/Linux 从业者、开源爱好者、技术钻研者,撰写文档既是兴趣也是工作内容之一。Q - "281957576";WeChat - "jiulongxiaotianci",Github - https://github.com/jimcat8
用一杯咖啡支持我们,我们的每一篇[文档]都经过实际操作和精心打磨,而不是简单地从网上复制粘贴。期间投入了大量心血,只为能够真正帮助到您。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇