type
status
date
slug
summary
tags
category
icon
password
mysql是怎么样运行的:从根儿上理解mysql
重新认识MySQL
mysql也是有服务端和客户端。
日常使用的情景:
- 启动 MySQL 服务器程序。
- 启动 MySQL 客户端程序并连接到服务器程序。
- 在客户端程序中输入一些命令语句作为请求发送到服务器程序,服务器程序收到这些请求后,会根据请求的 内容来操作具体的数据并向客户端返回操作结果。
如我们启动的MySQL服务器进程的默认名称为mysqld, 而我们
常用的MySQL 客户端进程的默认名称为mysql
启动客户端
mysql -h主机名 -u用户名 -p密码
如果你的服务器和客户端安装在同一台机器上,-h参数可以省略,就像这样:
mysql -u root -p
客户端进程向服务器进程发送并得到回复的过程本质上是进程间通信的过程。
TCP/IP通信
MySQL 服务器会默认监听3306 端口
如果3306 端口号已经被别的进程占用了或者我们单纯的想自定义该数据库实例监听的端口号,那我们可以在启
动服务器程序的命令行里添加-P参数来明确指定一下端口号,比如这样:
mysqld -P3307
这样MySQL 服务器在启动时就会去监听我们指定的端口号3307
如我们在使用mysql 来启动客户端程序时,在h 参数后必须跟随IP地址来作为需要连接的服务器进程所在主机的主机名,如果客户端进程和服务器进程在一台计算机中的话,我们可以使用127.0.0.1 来代表本机的 IP地址 。另外,如果服务器进程监听的端口号不是默认的3306 ,我们也可以在使用mysql 启动客户端程序时使用-P 参数(大写的P,小写的p是用来指定密码的)来指定需要连接到的端口号。比如我们现在已经在本机启动了服务器进程,监听的端口号为3307
启动客户端程序时可以这样写:
mysql -h127.0.0.1 -uroot -P3307 -p
Unix套接字通信
如果服务器进程和客户端进程都运行在unix上,那么可以使用unix套接字文件来进行进程间通信
怎么找这个文件
SHOW VARIABLES LIKE 'socket';
+---------------+-----------------------------+
| Variable_name | Value |
+---------------+-----------------------------+
| socket | /var/run/mysqld/mysqld.sock |
+---------------+-----------------------------+
在服务器改变了默认的 UNIX 域套接字文件后,如果客户端程序想通
过UNIX 域套接字文件进行通信的话,也需要显式的指定连接到的UNIX域套接字文件路径,就像这样:
mysql -hlocalhost -uroot --socket=/tmp/a.txt -p
这样该客户端进程和服务器进程就可以通过路径为/tmp/a.txt 的 Unix 域套接字文件进行通信了
服务器处理客户端要求
三个部分:
连接管理, 解析与优化,存储引擎

连接管理:
每当有一个客户端进程连接到服务器进程时,服务器进程都会创建一个线程来专门处理与这个
客户端的交互,当该客户端退出时会与服务器断开连接,服务器并不会立即把与该客户端交互的线程销毁掉,而
是把它缓存起来,在另一个新的客户端再进行连接时,把这个缓存的线程分配给该新客户端。这样就起到了不频
繁创建和销毁线程的效果,从而节省开销。从这一点大家也能看出,MySQL服务器会为每一个连接进来的客户端分配一个线程。
然后带上主机信息,用户名,密码
解析与优化:
查询缓存(mysql8已经没了):就是说处理过的查询请求和结构缓存起来。
移除原因:发生变化就要更新,维护内存区域
语法解析:
因为客户端程序发送过来的请求只是一段文本而
已,所以MySQL 服务器程序首先要对这段文本做分析,判断请求的语法是否正确,然后从文本中将要查询的表、各种查询条件都提取出来放到MySQL 服务器内部使用的一些数据结构上来
查询优化:
语法解析之后,服务器程序获得到了需要的信息,比如要查询的列是哪些,表是哪个,搜索条件是什么等等,但光有这些是不够的,因为我们写的MySQL语句执行起来效率可能并不是很高,MySQL的优化程序会对我们的语句做一些优化,如外连接转换为内连接、表达式简化、子查询转为连接吧啦吧啦的一堆东西。优化的结果就是生成一个执行计划,这个执行计划表明了应该使用哪些索引进行查询,表之间的连接顺序是啥样的。我们可以使用
EXPLAIN 语句来查看某个语句的执行计划
存储引擎:
为了管理方便,人们把连接管理、查询缓存、语法解析、查询优化这些并不涉及真实数据存储的功能划分为MySQL server的功能,把真实存取数据的功能划分为存储引擎的功能。各种不同的存储引擎向上边的MySQLserver层提供统一的调用接口(也就是存储引擎API),包含了几十个底层函数,像"读取索引第一条内容"、"读取索引下一条内容"、"插入记录"等等。
所以在MySQL server完成了查询优化后,只需按照生成的执行计划调用底层存储引擎提供的API,获取到数据后返回给客户端就好了
一般常用就InnoDB和MyISAM
比如建一个表
mysql> create table test1(i int)engine = MyISAM;
Query OK, 0 rows affected (0.02 sec)
修改引擎并且查看:
mysql> alter table test1 engine= InnoDB;
Query OK, 0 rows affected (0.09 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> show create table test1\G;
*************************** 1. row ***************************
Table: test1
Create Table: CREATE TABLE
test1
(
i
int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1
1 row in set (0.00 sec)MySQL的调控按钮-启动选项和系统变量
我们的生活将置于尴尬的境地,比如在图书馆里无法把手机设置为静音,无法把流量开关关掉以节省流量,在别人得知解锁密码后无法更改密码~ MySQL的服务器程序和客户端程序也有很多设置项,比如对于MySQL 服务器程序,我们可以指定诸如允许同时连入的客户端数量、客户端和服务器通信方式、表的默认存储引擎、查询缓存的大小吧啦吧啦的设置项。
使用mysql --help 可以看到 mysql 程序支持的启动选项, mysqld_safe --help
可以看到mysqld_safe 程序支持的启动选项。查看 mysqld 支持的启动选项有些特别,需要使用 mysqld -verbose --help
如果同一个启动选项既出现在命令行中,又出现在配置文件中,那么以命令行中的启
动选项为准!比如我们在配置文件中写了:
[server]
default-storage-engine=InnoDB
而我们的启动命令是:
mysql.server start --default-storage-engine=MyISAM
那最后default-storage-engine 的值就是 MyISAM
系统变量
说我们在启动服务器程序时用这个命令:
mysqld --default-storage-engine=MyISAM --max-connections=10
对于大部分系统变量来说,它们的值可以在服务器程序运行过程中进行动态修
改而无需停止并重启服务器
可以根据不同的客户端来设置不同的系统变量,比如:
狗哥使用客户端A,他想让当前客户端对应的默认存储引擎为InnoDB,所以他可以把系
统变量default_storage_engine 的值设置为 InnoDB ;猫爷使用客户端B,他想让当前客户端对应的默认存储引擎为MyISAM ,所以他可以把系统变量 default_storage_engine 的值设置为 MyISAM 。这样可以使狗哥和猫爷的的客户端拥有不同的默认存储引擎,使用时互不影响,十分方便。
举个例子:
如我们想在服务器运行过程中把作用范围为GLOBAL的系统变量default_storage_engine 的值修改为MyISAM ,也就是想让之后新连接到服务器的客户端都用MyISAM 作为默认的存储引擎,那我们可以选择下边两条
语句中的任意一条来进行设置:
语句一:SET GLOBAL default_storage_engine = MyISAM;
语句二:SET @@GLOBAL.default_storage_engine = MyISAM;
如果只想对本客户端生效,也可以选择下边三条语句中的任意一条来进行设置:
语句一:SET SESSION default_storage_engine = MyISAM;
语句二:SET @@SESSION.default_storage_engine = MyISAM;
语句三:SET default_storage_engine = MyISAM;
从上边的语句三也可以看出,如果在设置系统变量的语句中省略了作用范围,默认的作用范围就是SESSION。
也就是说SET 系统变量名 = 值 和 SET SESSION 系统变量名 = 值 是等价的
乱码的前世今生-字符集和比较规则
MySQL 8.0 中,默认字符集已经从 latin1 更改为 utf8mb4,默认比较规则为 utf8mb4_0900_ai_ci(基于 Unicode 9.0 标准)。
常见的charset的 一个字符最多需要多少几个字节

MySQL 有4个级别的字符集和比较规则,分别是:
服务器级别
数据库级别
表级别
列级别
服务器级别:
mysql> show variables like 'character_set_server';
+----------------------+--------+
| Variable_name | Value |
+----------------------+--------+
| character_set_server | latin1 |
+----------------------+--------+
1 row in set (0.00 sec)
mysql> show variables like 'collation_server';
+------------------+-------------------+
| Variable_name | Value |
+------------------+-------------------+
| collation_server | latin1_swedish_ci |
+------------------+-------------------+
1 row in set (0.00 sec)
数据库级别:
CREATE DATABASE 数据库名
CHARACTER SET 字符集名称]
COLLATE 比较规则名称];
比如数据库级别的例子
mysql> create database test2 character set 'utf8' collate 'utf8_general_ci';
Query OK, 1 row affected (0.01 sec)
mysql> use test2;
Database changed
mysql> show variables like 'character_set_database';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| character_set_database | utf8 |
+------------------------+-------+
1 row in set (0.14 sec)
mysql> show variables like 'collation_database';
+--------------------+-----------------+
| Variable_name | Value |
+--------------------+-----------------+
| collation_database | utf8_general_ci |
+--------------------+-----------------+
1 row in set (0.00 sec)
character_set_database 和 collation_database 这两个系统变量是只读的,我们不能通过修改这两个变量的值
而改变当前数据库的字符集和比较规则
数据库的创建语句中也可以不指定字符集和比较规则,比如这样:
CREATE DATABASE 数据库名;
这样的话将使用服务器级别的字符集和比较规则作为数据库的字符集和比较规则
表级别:
CREATE TABLE 表名 (列的信息)
CHARACTER SET 字符集名称]
[COLLATE 比较规则名称]]
mysql> create table t(
-> col varchar(255)
-> )
-> character set utf8 collate utf8_general_ci;
Query OK, 0 rows affected (0.04 sec)
如果创建和修改表的语句中没有指明字符集和比较规则,将使用该表所在数据库的字符集和比较规则作为该表的字符集和比较规则
列级别:
mysql> ALTER TABLE t MODIFY col VARCHAR(10) CHARACTER SET gbk COLLATE gbk_chinese_ci;
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0
总结一下就是说:
如果创建或修改列时没有显式的指定字符集和比较规则,则该列默认用表的字符集和比较规则
如果创建或修改表时没有显式的指定字符集和比较规则,则该表默认用数据库的字符集和比较规则
如果创建或修改数据库时没有显式的指定字符集和比较规则,则该数据库默认用服务器的字符集和比较规则
那么就有利于分析字节:
比方说我们向表t中插入一条记录:
mysql> INSERT INTO t(col) VALUES('我我');
Query OK, 1 row affected (0.00 sec)
mysql> SELECT * FROM t;
+--------+
| s |
+--------+
| 我我 |
+--------+
1 row in set (0.00 sec)
首先列col 使用的字符集是gbk ,一个字符'我'在gbk 中的编码为0xCED2 ,占用两个字节,两个字符的实际数据就占用4个字节。如果把该列的字符集修改为utf8的话,这两个字符就实际占用6个字节啦
客户端和服务器中的字符集
encode和decode如果charset不同,会乱码
charset转换:
如果接收0xE68891 这个字节串的程序按照 utf8 字符集进行解码,然后又把它按照 gbk 字符集进行编码,最后编码后的字节串就是0xCED2 ,我们把这个过程称为字符集的转换,也就是字符串'我'从utf8字符集转换为gbk 字符集


客户端层面:
假设你的客户端采用的字符集和 character_set_client 不一样的话,那么就无法处理这个请求;
假设你的客户端采用的字符集和 character_set_results 不一样的话,这就可能会出现客户端无法解码结果集的情况,结果就是在你的屏幕上出现乱码。比如我的客户端使用的是utf8字符集,如果把系统变量character_set_results 的值设置为 ascii 的话,可能会产生乱码。
服务器:
character_set_connection 时使用,它是什么其实没多重要,但是一定要注意,该字符集包含的字符范围一定涵盖请求中的字符,要不然会导致有的字符无法使用character_set_connection 代表的字符集进行编码。比如你把character_set_client 设置为 utf8 ,把 character_set_connection 设置成 ascii ,那么此
时你如果从客户端发送一个汉字到服务器,那么服务器无法使用ascii字符集来编码这个汉字,就会向用户发出一个警告。
我们通常都把 character_set_client 、character_set_connection、
character_set_results 这三个系统变量设置成和客户端使用的字符集一致的情况,这样减少了很多无谓的字符集转换。为了方便我们设置,MySQL 提供了一条非常简便的语句:
SET NAMES 字符集名
这一条语句产生的效果和我们执行这3条的效果是一样的:
SET character_set_client = 字符集名;
SET character_set_connection = 字符集名;
SET character_set_results = 字符集名
比较规则的应用
比较规则的作用通常体现比较字符串大小的表达式以及对某个字符串列进行排序中
从一条记录说起-InnoDB记录结构
InnoDB页:
真正处理数据的过程是发生在内存,需要将磁盘的加载到内存
InnoDB 存储引擎需要一条一条的把记录从磁盘上读出来么?不,那样会慢死,
InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中
这里也需要理解一下 os里的磁盘和内存的概念:
可以把它们想象成我们工作时的场景:
- 磁盘 (Disk /硬盘 / SSD)
- 类比: 你的文件柜或仓库。
- 作用: 长期、持久地存储数据。即使电脑断电,存储在磁盘上的文件、应用程序、操作系统本身等数据也不会丢失。
- 特点:
- 容量大: 通常比内存大得多(几百GB到几TB)。
- 速度慢: 相对于内存和CPU,读写速度要慢很多个数量级。机械硬盘(HDD)尤其慢,固态硬盘(SSD)快很多,但仍远慢于内存。
- 非易失性 (Non-volatile): 断电后数据依然存在。
- 内存 (Memory / RAM - Random Access Memory)
- 类比: 你的办公桌或工作台。
- 作用: 临时存储正在运行的程序和当前需要处理的数据。CPU(中央处理器,相当于你大脑)直接从内存中读取指令和数据进行计算和处理。
- 特点:
- 容量小: 通常比磁盘小(几GB到几十GB,甚至上百GB,但仍远小于磁盘)。
- 速度快: 读写速度极快,CPU可以快速访问。
- 易失性 (Volatile): 断电后数据会丢失。
- 操作系统 (Operating System - OS) 的作用
- 类比: 你(或者说你的工作流程管理者)。
- 核心任务: 管理计算机的所有硬件资源(包括CPU、内存、磁盘等)和软件资源,并充当用户/应用程序与硬件之间的桥梁。
- 在磁盘和内存之间的关系:
- 加载程序/数据: 当你打开一个应用程序(比如Word)或一个文件时,操作系统会找到存储在磁盘上的程序代码和数据,并将需要的部分复制到内存中。为什么?因为CPU只能直接、快速地与内存交互。如果CPU每次都要去慢速的磁盘上找数据,电脑会卡得无法使用。
- 运行与处理: CPU在内存中执行程序指令,处理数据。所有正在进行的工作都在内存这个“工作台”上完成。
- 数据交换(分页/交换): 内存是有限的。如果需要运行的程序和数据超出了物理内存的大小,操作系统会使用一种叫做虚拟内存的技术。它会在磁盘上开辟一块特殊区域(称为交换分区或页面文件),把内存中暂时不用的数据暂时存放到磁盘上(Swap Out),腾出内存空间给更急需的数据。当需要用到被换出的数据时,再从磁盘读回内存(Swap In)。这个过程比直接用内存慢,但能让系统运行超出物理内存限制的程序。
- 保存数据: 当你在应用程序中保存文件时,操作系统会将内存中修改过的数据写回到磁盘上,这样数据才能被永久保存。
—》所以就是说:
- InnoDB 深知磁盘慢,内存快。
- 它使用内存中的缓冲池 (Buffer Pool) 作为主要的工作区域,尽可能地在内存中完成数据的读写操作,以提高性能。
- 对于读取,尽量从内存缓冲池命中,避免读磁盘。
- 对于写入,先修改内存缓冲池,并通过快速的顺序写 Redo Log 到磁盘来保证事务的持久性。
- 最终,内存中修改过的数据会由后台线程异步地刷回到磁盘上的数据文件中,完成数据的最终落盘。
InnoDB行格式
行格式,分别是Compact 、Redundant 、Dynamic 和 Compressed 行格式
Compact行格式
CREATE TABLE my_table (
id INT PRIMARY KEY,
name VARCHAR(100)
) ROW_FORMAT=COMPACT;
ALTER TABLE my_table ROW_FORMAT=COMPACT;

变长字段长度列表:
作用: 存储表中变长字段(如
VARCHAR
, VARBINARY
, TEXT
, BLOB
类型)实际占用的字节数存储方式: 这个列表位于记录的开头部分(在 NULL 标志位之后,记录头之前)。它逆序存储每个变长字段的结束偏移量(或者理解为长度,可以通过相邻偏移量计算)。为什么逆序?这样可以使得定义在前面的定长字段的访问更快,并且在某些情况下可能稍微节省空间。
空间占用: 每个变长字段的长度信息通常占用 1 或 2 个字节。如果字段最大可能长度超过 255 字节,则用 2 字节;否则用 1 字节
Null值列表:
- 作用: 指示该行中哪些列的值是
NULL
。
- 存储方式: 使用一个或多个字节作为位图(bitmap)。每个允许为 NULL 的列对应位图中的一个比特位。如果该列值为
NULL
,则对应的比特位置为 1;如果不为NULL
,则为 0。注意:定义为NOT NULL
的列不在此位图中占用比特位。
记录的数据:
对于值为
NULL
的列:如果该列在 NULL 标志位中被标记为 1,则不在列数据部分占用任何空间(这是 COMPACT
节省空间的关键点之一)Redundant行格式

- 核心特点:
- 存储所有列(包括定长列如 INT, CHAR)的长度信息在记录头部的“字段长度偏移列表”中。这是其“冗余”的主要原因,因为定长列的长度本可通过表结构得知。
- 对于 NULL 值,即使不存实际数据,也在偏移列表中占位。
- 对于 VARCHAR, TEXT, BLOB 等变长字段,如果数据太大无法完全放在数据页内,它会在数据页内存储该字段的前 768 个字节,然后用一个 20 字节的指针指向存储剩余数据的溢出页(Off-page)。
- 缺点: 存储空间利用率最低,特别是对于包含很多定长列或 NULL 值的表
Dynamic和Compressed行格式

- 核心特点:
- 与 Compact 共享许多头部和 NULL 处理的优化。
- 关键区别在于处理溢出页数据: 对于 TEXT, BLOB 等大字段,Dynamic 不再强制在数据页内存储 768 字节的前缀。它会根据行的大小等因素判断:如果数据非常大,或者行内数据已经放不下,它可能只在数据页中存储一个 20 字节的指针,将所有实际数据都存放在溢出页中。
- 优点:
- 避免了 768 字节前缀可能带来的空间浪费。
- 数据页可以容纳更多的行(因为不被大字段前缀填充),提高了 B+ 树索引的紧密度和查询效率(特别是范围扫描)。
- 更适合包含大量 TEXT 或 BLOB 列的表
演进路线就是:Redundant(冗余多)-> Compact(减少冗余)-> Dynamic(优化大对象存储)-> Compressed(在 Dynamic 基础上增加压缩)。
一个页一般是 16KB ,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为行溢出
InnoDB数据页结构
InnoDB 存储引擎以页(Page)作为磁盘和内存交互的基本单位,默认大小通常是 16KB。数据页是 InnoDB 中最常见、也是最重要的页面类型,因为它直接存储了表的行记录(用户数据)

一开始是没有user records的
,每当我们插入一条记录,都会从 Free Space 部分,也就
是尚未使用的存储空间中申请一个记录大小的空间划分到User Records 部分

为了更好的管理在User Records 中的这些记录, InnoDB 可费了一番力气—》和记录行格式的记录头信息

- 状态管理: 通过 delete_mask 实现逻辑删除,支撑 MVCC 和延迟清理。
- 结构区分: 通过 record_type 和 min_rec_mask 区分 B+ 树的不同节点类型和特殊记录,支撑索引结构。
- 性能优化: 通过 n_owned 配合页面目录实现页内快速查找;通过 next_record 维护逻辑顺序链表,支持高效的顺序扫描和范围查询。
- 物理定位: heap_no 提供了记录在页面堆中的物理标识。
delete_mask (1 bit):
这是记录的删除标记位。当一个记录被删除时,InnoDB 并不会立即将其从数据页中物理移除(这会涉及到大量数据的移动和页面重新组织,成本很高)。相反,它只是将个 delete_mask 位置为 1。这条记录就变成了一个“逻辑删除”的状态,不再对用户查询可见(除非是旧版本的事务通过 MVCC 看到)。这些被标记删除的记录形成了一个垃圾链表(由页头的 PAGE_FREE 指针串联),其空间最终会被 InnoDB 的后台 Purge 线程回收并重新利用。这是 InnoDB 实现快速删除和 MVCC(多版本并发控制)的基础之一。
next_record (16 bits):
这是维持页面内记录逻辑顺序的核心。它存储的是从当前记录头部的起始位置到下一条记录头部(按照主键升序排列)的偏移量(Offset)。通过这个指针,页面内的所有用户记录(以及 Infimum 和 Supremum)被串联成一个单向链表。这个链表保证了即使记录在物理存储上是无序的(由于删除、更新导致的空间重用),逻辑上仍然可以按主键顺序遍历。范围扫描和页面目录查找后的组内遍历都依赖这个指针。 值为 0 表示没有下一条记录(通常指向 Supremum)

不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。
如果删掉一条记录:
只是把delete_mask标为1;最大记录的n_owned值从5变成了4

如果重新插入回去:

InnoDB 并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录
的存储空间
页目录
如果要在页面内查找某一条特定的记录(例如,通过主键进行点查时,B+ 树已经定位到了这个页面),最朴素的方法就是从头(Infimum 记录)开始,沿着这个链表一条一条地比较,直到找到目标记录或者确定记录不存在。当页面中的记录很多时,这种线性扫描(时间复杂度 O(N),N 为页内记录数)的效率会非常低下.
所以设计成:
分组 (Grouping):
- InnoDB 会将页面内的所有用户记录(加上 Infimum 和 Supremum 这两个虚拟记录)划分成若干个组 (Group)。
- 第一个组(Infimum 组)只包含 Infimum 记录。
- 最后一个组(Supremum 组)包含 1 到 8 条记录(包括 Supremum 记录本身)。
- 中间的其他组,每组包含 4 到 8 条记录。这个数量范围是为了平衡目录大小和查找效率。

槽 (Slot):
- 页目录并不存储每个组的所有记录信息,而是为每个组存储一个槽 (Slot)。
- 每个槽里存放的是该组中主键值最大的那条记录在页面内的相对位置(通常是该记录的 heap_no 或相对于页面起始位置的偏移量)。
- 关键点: 槽指向的是组内“最大”的那条记录。
存储位置和顺序:
- 页面目录本身存储在数据页的尾部,紧邻文件尾 (File Trailer) 之前。
- 槽是逆序存放的。也就是说,第 0 个槽(对应 Infimum 组)存放在最靠近文件尾的地方,第 1 个槽次之,依此类推,最后一个槽(对应 Supremum 组)最靠近空闲空间区域。
- 槽本身存储的值(记录位置)是按照主键顺序排列的,这使得可以对槽进行二分查找
那怎么查找:
二分查找槽 (Binary Search Slots):
- 使用目标主键值,对存储在页面尾部的槽数组进行二分查找。
- 目标是找到第一个主键值大于或等于目标主键值的槽。例如,如果要查找主键为 50 的记录,二分查找到的可能是指向组内最大记录主键为 60 的那个槽。
- 这个二分查找的时间复杂度是 O(log M),其中 M 是槽的数量(远小于记录总数 N)。
组内顺序查找 (Linear Scan within Group):
- 二分查找确定了目标记录可能存在的那个组。
- 根据找到的槽(指向组内最大记录)和前一个槽(指向前一个组的最大记录,可以由此推断出当前组的起始记录),InnoDB 知道了在这个组内需要查找的记录范围。
- 然后,InnoDB 在这个非常小的组内(最多 8 条记录),通过遍历记录的 next_record 指针进行顺序查找,直到找到目标记录或者确定该记录不在这个组(也就不在这个页面)中。
- 这一步的时间复杂度是 O(k),其中 k 是组的大小(一个很小的常数,最多是 8)

快速查询的秘籍-B+树索引
页和页
双向链表,里面单向链表

没有索引的查找
比如
SELECT [列名列表] FROM 表名 WHERE 列名 = xxx;
如果在一个页里面查:
if 主键—》二分法找slot
非—》单向链表从最小记录开始遍历
在很多页里查:
由于我们并不能快速的定位到记录所在的
页,所以只能从第一个页沿着双向链表一直往下找,在每一个页中根据我们刚刚唠叨过的查找方式去查找指定的记录。因为要遍历所有的数据页,所以这种方式显然是超级耗时的
—》
需要索引
简单的索引方案:
先定一个前提:下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值
比如插入一个key为4的,因为小于上一个页的最大的5.所以需要记录移动


但是因为可能会有很多的页需要这样的情况—》页分裂
以如果想从这么多页中根据主键值快速定位某些记录所
在的页,我们需要给它们做个目录,每个页对应一个目录项,每个目录项包括下边两个部分:
页的用户记录中最小的主键值,我们用key来表示。页号,我们用page_no 表示。
—》
所以我们就建立一个目录项————》也就是索引了

InnoDB的索引方案
上述提到简单方案问题就是说 如果一个页28的记录没了,那就需要把目录项2后面的都要往前移动;
怎么区分是目录项记录还是用户记录?
头信息里的
record_type 属性,它的各个取值代表的意思如下:
0 :普通的用户记录
1 :目录项记录
2 :最小记录
3 :最大记录

所以:
目录项记录只有主键值和页的编号两个列,而普通的用户记录的列是用户自己定义的,可能包含很多列;
过一个叫min_rec_mask 的属性么,只有在存储 目录项记录的页
中的主键值最小的目录项记录的min_rec_mask 值为 1 ,其他别的记录的 min_rec_mask 值都是 0
是不论怎么说一个页只有16KB 大小,能存放的目录项记录也是有限的,那如果表中的数据太多,以至于一个数据页不足以存放所有的目录项记录,该咋办呢?当然是再多整一个存储目录项记录的页

比如插入了一条key为320的,新生成一个页31.然后目录项也满了,搞多了一个32.
举例子:
如果我们想根据主键值查找一条用户记录大致需要3个步骤,以查
找主键值为20的记录为例:
- 确定 目录项记录 页 我们现在的存储目录项记录的页有两个,即页30和页32,又因为页30表示的目录项的主键值的范围是 [1, 320) , 页32 表示的目录项的主键值不小于 320 ,所以主键值为 20 的记录对应的目录项记录在页30 中。
- 通过 目录项记录 页确定用户记录真实所在的页。 在一个存储目录项记录的页中通过主键值定位一条目录项记录的方式说过了,不赘述了~
- 在真实存储用户记录的页中定位到具体的记录
但是但是,如果在这个查询步骤的第1步中我们需要定位存储目录项记录的页,但是这些页在存储空间中也可能不挨着,如果我们表中的数据非常多则会产生很多存储目录项记录的页,那我们怎么根据主键值快速定位一个存储目录项记录的页呢?其实也简单,为这些存储目录项记录的页再生成一个更高级的目录—》

所以就是一个B+树了。
从图中可以看出来,我们的实际用户记录其实都存放在B+树的最底层的节点
上,这些节点也被称为叶子节点或叶节点,其余用来存放目录项的节点称为非叶子节点或者内节点,其中B+ 树最上边的那个节点也称为根节点
规定最下边的那层,也就是存放我们用户记录的那层为第0层,之后依次往上加。之前的讨论我们做了一个非常极端的假设:存放用户记录的页最多存放3条记录,存放目录项记录的页最多存放4条记录。
—》
那么
假设所有存放用户记录的叶子节点代表的数据页可以存放100条用户记录,所有
存放目录项记录的内节点代表的数据页可以存放1000条目录项记录,那么:
如果B+ 树只有1层,也就是只有1个用于存放用户记录的节点,最多能存放100条记录。
如果B+ 树有2层,最多能存放1000×100=100000 条记录。
如果B+ 树有3层,最多能存放1000×1000×100=100000000 条记录。
如果B+ 树有4层,最多能存放1000×1000×1000×100=100000000000 条记录
用到的 B+ 树都不会超过4层,那我们通过主键
值去查找某条记录最多只需要做4个页面内的查找(查找3个目录项页和一个用户记录页),又因为在每个页面内
有所谓的Page Directory (页目录),所以在页面内也可以通过二分法实现快速定位记录
聚簇索引
1.使用记录主键值的大小进行记录和页的排序,这包括三个方面的含义:
页内的记录是按照主键的大小顺序排成一个单向链表。
各个存放用户记录的页也是根据页中用户记录的主键大小顺序排成一个双向链表。
存放目录项记录的页分为不同的层次,在同一层次中的页也是根据页中目录项记录的主键大小顺序排成一个双向链表。
2. B+ 树的叶子节点存储的是完整的用户记录。
所谓完整的用户记录,就是指这个记录中存储了所有列的值(包括隐藏列)。
我们把具有这两种特性的B+树称为聚簇索引
二级索引
聚簇索引只能在搜索条件是主键值时才能发挥作用,因为B+树中的数据都是按照主键进行排序的。那如果我们想以别的列作为搜索条件—》
那就只能再多建B+树
- 独立的B+树结构:每个二级索引在MySQL中对应一棵独立的B+树。
- 叶子节点存储主键值:二级索引的叶子节点存储的是主键值,而不是完整的数据行。
- 回表查询:通过二级索引查询时,通常需要先找到主键值,然后再通过主键到聚簇索引中查找完整数据,这个过程称为“回表”。
- 支持多个字段的联合索引:可以对多个列创建联合二级索引
根据那两个列来建立b+
举例子:
所以如果我们现在想通过c2列的值查找某些记录的话就可以使用我们刚刚建好的这个B+树了。以查找c2列的
值为4的记录为例,查找过程如下:
- 确定 目录项记录 页 根据根页面,也就是页44,可以快速定位到目录项记录所在的页为页42(因为2 < 4 < 9)。
- 通过 目录项记录 页确定用户记录真实所在的页。 在页42 中可以快速定位到实际存储用户记录的页,但是由于c2列并没有唯一性约束,所以c2列值为4的 记录可能分布在多个数据页中,又因为2 < 4 ≤ 4 ,所以确定实际存储用户记录的页在页34和页35中。
- 在真实存储用户记录的页中定位到具体的记录。到页34 和页35 中定位到具体的记录。
- 但是这个 B+ 树的叶子节点中的记录只存储了c2 和c1 (也就是主键)两个列,所以我们必须再根据主键值去聚簇索引中再查找一遍完整的用户记录 —> 回表
好东西也得先学会怎么用-B+树索引的使用
索引的代价:
空间上:每个页16KB,多个页就很大的存储空间
时间:要修改B+树索引
创建一个索引:
CREATE TABLE person_info(
id INT NOT NULL auto_increment,
name VARCHAR(100) NOT NULL,
birthday DATE NOT NULL,
phone_number CHAR(11) NOT NULL,
country varchar(100) NOT NULL,
PRIMARY KEY (id),
KEY idx_name_birthday_phone_number (name, birthday, phone_number)
)
这个idx_name_birthday_phone_number 索引对应的 B+ 树中页面和记录的排序方式就是
这样的:
先按照name列的值进行排序。
如果name列的值相同,则按照birthday列的值进行排序。
如果birthday 列的值也相同,则按照phone_number 的值进行排序。
如果全值排序:
比如:
SELECT * FROM person_info WHERE name = 'Ashburn' AND birthday = '1990-09-27' AND phone_number = '15123983239'
按索引顺序先找name birth day —》 phone number
(叫查询优化器的东东,会分析这些搜索条件并且按照可以使用的索引中列的顺
序来决定先使用哪个搜索条件,后使用哪个搜索条件)
匹配最左边的列:
if:
SELECT * FROM person_info WHERE birthday = '1990-09-27';
索引没用了
因为B+树的数据页和记录先是按照name列的值排序的,在name列的值相同的情况下才使
用birthday 列进行排序,也就是说 name 列的值不同的记录中 birthday 的值可能是无序的,要的话就搞多个索引。—》必须按顺序 所以怎么设定那个索引就挺重要,因为一旦没匹配到就走不到了。
匹配列前缀:
先按照字符串的第一个字符进行排序。
如果第一个字符相同再按照第二个字符进行排序。
如果第二个字符相同再按照第三个字符进行排序,依此类推
比方说我们想查询名字以'As'开头的记录,那就可以这么写查询语句:
SELECT * FROM person_info WHERE name LIKE 'As%';
但是需要注意的是,如果只给出后缀或者中间的某个字符串,比如这样:
SELECT * FROM person_info WHERE name LIKE '%As%';
MySQL 就无法快速定位记录位置了,因为字符串中间有'As' 的字符串并没有排好序,所以只能全表扫描了
用于排序:
一般情况下,我们只能把记录都加载到内存中,再用一些排序算法,比如快速排序、归并排序、吧啦吧啦排序等等在内存中对这些记录进行排序,有的时候可能查询的结果集太大以至于不能在内存中进行排序的话,还可能暂时借助磁盘的空间来存放中间结果,排序操作完成后再把排好序的结果集返回到客户端。
但是如果用到索引列,就会很方便
比如
SELECT * FROM person_info ORDER BY name, birthday, phone_number LIMIT 10;
就会按照顺序去查
使用联合索引的各个排序列的排序顺序必须是一致的:
因为比如
SELECT * FROM person_info ORDER BY name, birthday DESC LIMIT 10;
这样如果使用索引排序的话过程就是这样的:
先从索引的最左边确定name 列最小的值,然后找到name列等于该值的所有记录,然后从name列等于该值的最右边的那条记录开始往左找10条记录。
如果name 列等于最小的值的记录不足10条,再继续往右找name 值第二小的记录,重复上边那个过程,直到找到10条记录为止—》耗时
分组
SELECT name, birthday, phone_number, COUNT(*) FROM person_info GROUP BY name, birthday, phone_number
这个查询语句相当于做了3次分组操作:
- 先把记录按照 name 值进行分组,所有 name 值相同的记录划分为一组。
- 将每个 name 值相同的分组里的记录再按照 birthday 的值进行分组,将 birthday 值相同的记录放到一个小 分组里,所以看起来就像在一个大分组里又化分了好多小分组。
- 再将上一步中产生的小分组按照 phone_number 的值分成更小的分组,所以整体上看起来就像是先把记录分成一个大分组,然后把大分组分成若干个小分组,然后把若干个小分组再细分成更多的小小分组。然后针对那些小小分组进行统计,比如在我们这个查询语句中就是统计每个小小分组包含的记录条数。如果没有索引的话,这个分组过程全部需要在内存里实现,而如果有了索引的话,恰巧这个分组顺序又和我们的B+树中的索引列的顺序是一致的,而我们的B+树索引又是按照索引列排好序的,这不正好么,所以可以直接使用B+ 树索引进行分组
回表的代价
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow';
在使用idx_name_birthday_phone_number 索引进行查询时大致可以分为这两个步骤:
- 从索引 idx_name_birthday_phone_number 对应的 B+ 树中取出 name 值在 Asa ~ Barlow 之间的用户记录。
- 由于索引 idx_name_birthday_phone_number 对应的 B+ 树用户记录中只包含 name 、 birthday 、phone_number 、 id 这4个字段,而查询列表是 * ,意味着要查询表中所有字段,也就是还要包括 country字段。这时需要把从上一步中获取到的每一条记录的id字段都到聚簇索引对应的B+树中找到完整的用户记录,也就是我们通常所说的回表,然后把完整的用户记录返回给查询用户
但是因为是查* 全部,需要每个都去用id找;但是id在二级索引里不是连续的;在聚簇索引里面又是连续的,那就变成很绕;不如全表扫描
优化器更倾向于选择使用二级索引 + 回表的方式进行查询,因为回表的记录越少,
性能提升就越高,比方说上边的查询可以改写成这样:
SELECT * FROM person_info WHERE name > 'Asa' AND name < 'Barlow' LIMIT 10;
添加了LIMIT 10 的查询更容易让优化器采用 二级索引 + 回表 的方式进行查询。
如何挑选索引
只为用于搜索排序或者分组的列创建索引
索引列的类型要小
让主键具有AUTO_INCREMENT ,让存储引擎自己为表生成主键,而不是我们手动插入

MySQL的数据目录
InnoDB 、 MyISAM 这样的存储引擎都是把表存储在文件系统上的
mysql> show variables like 'datadir';
+---------------+------------------+
| Variable_name | Value |
+---------------+------------------+
| datadir | /data/var/mysql/ |
+---------------+------------------+
1 row in set (0.23 sec)
这样可以查到mysql的数据目录
每次create一个新的database,这个会在上面的目录下:
1.创建一个和数据库名同名的子目录(或者说是文件夹)。
2. 在该与数据库名同名的子目录下创建一个名为db.opt 的文件,这个文件中包含了该数据库的各种属性,比方说该数据库的字符集和比较规则是个啥
存储
特性 | InnoDB | MyISAM |
数据文件 | .ibd (独立表空间)或ibdata1 (共享表空间) | .MYD (数据)、.MYI (索引) |
索引结构 | 聚簇索引(数据与主键索引绑定),二级索引需回表查询 | 非聚簇索引,索引直接指向数据文件地址 |
事务支持 | 支持ACID事务 | 不支持 |
并发控制 | 行级锁 | 表级锁 |
崩溃恢复 | 通过 redo log 和undo log 保障数据完整性 | 依赖手动修复或工具(如 myisamchk ) |
适用场景 | 高并发写入、事务型应用(如电商、金融系统) | 读密集型、静态数据(如日志、报表) |
idb文件是在数据目录那个对应数据库的文件夹下的
单表访问方法
访问方法的概念:
我们平时所写的那些查询语句本质上只是一种声明式的语法,只是告诉MySQL我们要获取的数据符合哪些规则,至于MySQL背地里是怎么把查询结果搞出来的那是MySQL 自己的事儿。对于单个表的查询来说,设计MySQL的大叔把查询的执行方式大致分为下边两种:
使用全表扫描进行查询:
这种执行方式很好理解,就是把表的每一行记录都扫一遍嘛,把符合搜索条件的记录加入到结果集就完了。不管是啥查询都可以使用这种方式执行,当然,这种也是最笨的执行方式。
使用索引进行查询:
因为直接使用全表扫描的方式执行查询要遍历好多记录,所以代价可能太大了。如果查询语句中的搜索条件可以使用到某个索引,那直接使用索引来执行查询可能会加快查询执行的时间。使用索引来执行查询的方式
可以细分为许多种类:
针对主键或唯一二级索引的等值查询
针对普通二级索引的等值查询
针对索引列的范围查询
直接扫描整个索引
执行mysql查询语句的方式称之为访问方法/访问类型:
有以下的类型
举例子用这个表来说:
CREATE TABLE single_table (
id INT NOT NULL AUTO_INCREMENT,
key1 VARCHAR(100),
key2 INT,
key3 VARCHAR(100),
key_part1 VARCHAR(100),
key_part2 VARCHAR(100),
key_part3 VARCHAR(100),
common_field VARCHAR(100),
PRIMARY KEY (id),
KEY idx_key1 (key1),
UNIQUE KEY idx_key2 (key2),
KEY idx_key3 (key3),
KEY idx_key_part(key_part1, key_part2, key_part3)
) Engine=InnoDB CHARSET=utf8;
const
种通过主键或者唯一二级索引列来定位一条记录的访问方法定义为:const,意思是常数级别的,代价是可以忽略不计的。
对于唯一二级索引来说,查询该列为NULL值的情况比较特殊,比如这样:
SELECT * FROM single_table WHERE key2 IS NULL;
因为唯一二级索引列并不限制 NULL 值的数量,所以上述语句可能访问到多条记录,也就是说 上边这个语句不可以使用const 访问方法来执行
ref
SELECT * FROM single_table WHERE key1 = 'abc';
采用二级索引来执行查询的访问方法称为:ref。它用于非唯一索引的等值查询,或者只使用了唯一索引的最左前缀。
但是如果最左边的连续索引列并不全部是等值比较的话,它的访问方法就不能称为ref了,比方说这样:
SELECT * FROM single_table WHERE key_part1 = 'god like' AND key_part2 > 'legendary'
ref_or_null
想找出某个二级索引列的值等于某个常数的记录,还想把该列的值为NULL的记录也找出
来,就像下边这个查询:
SELECT * FROM single_demo WHERE key1 = 'abc' OR key1 IS NULL;
range
SELECT * FROM single_table WHERE key2 IN (1438, 6328) OR (key2 >= 38 AND key2 <= 79);
index
SELECT key_part1, key_part2, key_part3 FROM single_table WHERE key_part2 = 'abc';
由于key_part2 并不是联合索引 idx_key_part 最左索引列,所以我们无法使用 ref 或者 range 访问方法来执行这个语句。但是这个查询符合下边这两个条件:
它的查询列表只有3个列:key_part1 , key_part2 , key_part3 ,而索引 idx_key_part 又包含这三个列。
搜索条件中只有key_part2 列。这个列也包含在索引 idx_key_part 中。
也就是说我们可以直接通过遍历idx_key_part 索引的叶子节点的记录来比较 key_part2 = 'abc' 这个条件是否成立,把匹配成功的二级索引记录的key_part1 , key_part2 , key_part3 列的值直接加到结果集中就行了
all
全表
复杂搜索
SELECT * FROM single_table WHERE
(key1 > 'xyz' AND key2 = 748 ) OR
(key1 < 'abc' AND key1 > 'lmn') OR
(key1 LIKE '%suf' AND key1 > 'zzz' AND (key2 < 8000 OR common_field = 'abc'))
首先查看WHERE 子句中的搜索条件都涉及到了哪些列,哪些列可能使用到索引。
这个查询的搜索条件涉及到了key1、key2 、 common_field 这3个列,然后 key1 列有普通的二级索引
idx_key1 , key2 列有唯一二级索引 idx_key2 。
对于那些可能用到的索引,分析它们的范围区间。
假设我们使用idx_key1 执行查询
我们需要把那些用不到该索引的搜索条件暂时移除掉,移除方法也简单,直接把它们替换为TRUE就好了。上边的查询中除了有关key2和common_field 列不能使用到 idx_key1 索引外, key1LIKE '%suf' 也使用不到索引,所以把这些搜索条件替换为 TRUE 之后的样子就是这样:
(key1 > 'xyz' AND TRUE ) OR
(key1 < 'abc' AND key1 > 'lmn') OR
(TRUE AND key1 > 'zzz' AND (TRUE OR TRUE))
化简一下上边的搜索条件就是下边这样:
(key1 > 'xyz') OR
(key1 < 'abc' AND key1 > 'lmn') OR
(key1 > 'zzz')
替换掉永远为TRUE 或 FALSE 的条件
因为符合key1 < 'abc' AND key1 > 'lmn' 永远为 FALSE ,所以上边的搜索条件可以被写成这样:
(key1 > 'xyz') OR (key1 > 'zzz')
继续化简区间
key1 > 'xyz' 和 key1 > 'zzz' 之间使用 OR 操作符连接起来的,意味着要取并集,所以最终的结果化简的到的区间就是:key1 > xyz 。也就是说:上边那个有一坨搜索条件的查询语句如果使用idx_key1 索引执行查询的话,需要把满足 key1 > xyz 的二级索引记录都取出来,然后拿着这些记录的id再进行回表,得到完整的用户记录之后再使用其他的搜索条件进行过滤。
假设我们使用idx_key2 执行查询
我们需要把那些用不到该索引的搜索条件暂时使用TRUE条件替换掉,其中有关key1和
common_field 的搜索条件都需要被替换掉,替换结果就是:
(TRUE AND key2 = 748 ) OR
(TRUE AND TRUE) OR
(TRUE AND TRUE AND (key2 < 8000 OR TRUE))
哎呀呀,key2 < 8000 OR TRUE 的结果肯定是 TRUE 呀,也就是说化简之后的搜索条件成这样了:
key2 = 748 OR TRUE
这个化简之后的结果就更简单了:
TRUE
这个结果也就意味着如果我们要使用idx_key2 索引执行查询语句的话,需要扫描idx_key2 二级索
引的所有记录,然后再回表,这不是得不偿失么,所以这种情况下不会使用idx_key2索引的
索引合并
好的,我们来解释一下 MySQL
index_merge
优化中的两种主要合并算法:Intersection
合并 和 Union
合并。这两种算法都属于
index_merge
访问方法。当 MySQL 优化器发现可以通过合并单个表上多个不同索引的扫描结果来更有效地执行查询时,就会考虑使用 index_merge
。1. 索引合并交集(Index Merge Intersection)
- 触发条件:通常发生在
WHERE
子句中包含多个条件,这些条件通过AND
连接,并且每个条件都可以利用表上的不同索引时。
- 工作原理:
- MySQL 对每个涉及到的索引分别进行扫描(通常是
range
扫描),获取满足各自条件的行指针(如主键 ID)。 - 然后,它计算这些从不同索引扫描得到的结果集的交集(Intersection),也就是找出同时出现在所有结果集中的行指针。
- 最后,根据这些交集中的行指针去读取实际的数据行。
- 示例:
假设
mytable
表在col1
和col2
上分别有独立的索引idx_col1
和idx_col2
。 如果优化器选择index_merge intersection
,它会: - 使用
idx_col1
找到所有col1 = 'value1'
的行。 - 使用
idx_col2
找到所有col2 = 100
的行。 - 找出同时满足这两个条件的行(取交集)。
EXPLAIN
输出:type
列会显示index_merge
,并且Extra
列会包含类似Using intersect(idx_col1, idx_col2); Using where
的信息。
- 优点:当每个单独的
AND
条件都能通过索引过滤掉大量数据时,取交集可以快速定位到最终的目标行,避免只用一个索引然后对结果进行二次过滤或者全表扫描。
2. 索引合并并集(Index Merge Union)
- 触发条件:通常发生在
WHERE
子句中包含多个条件,这些条件通过OR
连接,并且每个条件都可以利用表上的不同索引时。
- 工作原理:
- MySQL 对每个涉及到的索引分别进行扫描(通常是
range
扫描),获取满足各自条件的行指针。 - 然后,它计算这些从不同索引扫描得到的结果集的并集(Union),也就是合并所有结果集并去除重复的行指针。
- 最后,根据合并后的行指针去读取实际的数据行。
- 示例:
同样假设
mytable
表在col1
和col2
上分别有独立的索引idx_col1
和idx_col2
。 如果优化器选择index_merge union
,它会: - 使用
idx_col1
找到所有col1 = 'value1'
的行。 - 使用
idx_col2
找到所有col2 = 100
的行。 - 合并这两个结果集,并确保每行只包含一次(取并集)。
EXPLAIN
输出:type
列会显示index_merge
,并且Extra
列会包含类似Using union(idx_col1, idx_col2); Using where
的信息。
- 优点:当
OR
连接的条件各自只覆盖表中的一小部分数据,但没有一个单一索引能高效处理所有条件时,合并各自索引扫描的结果通常比全表扫描快得多。
还有一种相关的合并算法:
Sort-Union
- 这是
Union
合并的一种变体。当OR
条件中的某些索引扫描(例如范围扫描)不能保证返回有序的行指针时,MySQL 会先分别获取各个索引扫描的行指针,然后对它们进行排序,最后再执行合并去重操作。
EXPLAIN
输出:Extra
列会包含Using sort_union(...)
。
总结
Intersection
合并用于处理AND
条件,寻找共同满足所有条件的行。
Union
合(包括Sort-Union
)用于处理OR
条件,寻找满足任意一个条件的行。
index_merge
并不总是最优选择,因为它涉及到多次索引扫描和结果合并的开销。有时,即使存在多个可用索引,优化器也可能选择只使用其中一个选择性最好的索引(进行 ref
或 range
扫描),或者在没有合适索引时选择全表扫描 (ALL
)。是否使用 index_merge
取决于 MySQL 对各种执行计划成本的估算。连接的原理
每一条记录与另一个表中的每一条记录相互匹配的组合,像这样的结果
集就可以称之为笛卡尔积
在 MySQL 的多表连接(JOIN)查询中,“驱动表”(Driving Table)和“被驱动表”(Driven Table,也常称为非驱动表)是指明查询执行顺序的概念。理解它们的区别对于优化 JOIN 查询至关重要。
1. 驱动表 (Driving Table)
- 定义:在 JOIN 操作中,驱动表是首先被访问和处理的表。MySQL 会先从驱动表中读取符合条件的记录。
- 角色:它是整个 JOIN 操作的起点。查询执行器会从驱动表中取出一条记录,然后拿着这条记录的关联字段(JOIN Key)去被驱动表中查找匹配的记录。
- 选择:MySQL 优化器会根据成本估算(考虑表大小、索引、WHERE 条件过滤后的结果集大小等因素)来尝试选择最优的驱动表。通常,优化器倾向于选择经过
WHERE
条件过滤后,剩余记录数较少的表作为驱动表。
2. 被驱动表 (Driven Table / 非驱动表)
- 定义:在 JOIN 操作中,被驱动表是在驱动表之后被访问的表。
- 角色:对于从驱动表中取出的每一条符合条件的记录,查询执行器都会拿着这条记录的关联字段去被驱动表中查找匹配的行。
- 访问次数:被驱动表会被访问多次,访问的次数理论上等于驱动表中满足条件的记录数。
核心区别与影响
- 执行顺序:驱动表先查,被驱动表后查。
- 访问次数:驱动表通常只需要扫描一次(或部分扫描),而被驱动表则可能根据驱动表的结果集大小被访问多次。
- 性能关键:
- 驱动表的选择至关重要:如果驱动表过滤后的结果集很大,那么对被驱动表的访问次数就会很多,即使被驱动表上有很好的索引,整体查询效率也可能很低。目标是让驱动表的结果集尽可能小。
- 被驱动表的连接列索引非常重要:因为被驱动表会被反复查询,如果在连接列(JOIN Key)上有合适的索引,MySQL 就可以快速定位匹配的行(例如使用
ref
或eq_ref
访问类型),而不是进行全表扫描 (ALL
)。如果被驱动表的连接列没有索引,性能会急剧下降,可能导致慢查询。
MySQL 的选择策略(简化理解)
MySQL 执行 JOIN 的基本逻辑类似于嵌套循环(Nested-Loop Join):
优化器的工作就是选择哪个表作
Driving_Table
,哪个作 Driven_Table
,使得上述循环的总成本最低。- INNER JOIN:优化器有较大的自由度来选择哪个表作为驱动表。它会估算不同顺序的成本,选择最低的那个。
- LEFT JOIN:左边的表(
LEFT JOIN
左侧的表)通常强制作为驱动表,右边的表作为被驱动表。
- RIGHT JOIN:右边的表通常强制作为驱动表,左边的表作为被驱动表。(实践中
RIGHT JOIN
较少使用,常改写为LEFT JOIN
)。
如何判断和控制
EXPLAIN
命令:EXPLAIN
输出结果中,表的列出顺序通常(但不绝对保证)就是 MySQL 实际执行时访问表的顺序,即排在前面的通常是驱动表。你可以观察每个表的rows
估算值和type
(访问类型)来判断优化器的选择是否合理。
STRAIGHT_JOIN
关键字:如果你认为优化器的选择不是最优的,或者想强制指定连接顺序,可以在SELECT
语句中使用STRAIGHT_JOIN
。STRAIGHT_JOIN
会强制让其左边的表作为驱动表,右边的表作为被驱动表。但这需要你对数据和查询有深入理解,否则可能适得其反。
总结
驱动表是 JOIN 查询的起点,其过滤后的记录数决定了对被驱动表的访问次数。被驱动表是在驱动表确定一行记录后进行查找匹配的表,其连接列上的索引效率直接影响每次查找的速度。优化 JOIN 查询的核心在于让优化器选择合适的驱动表(结果集小),并确保被驱动表的连接列上有高效的索引。
内连接和外连接
对于内连接的两个表,驱动表中的记录在被驱动表中找不到匹配的记录,该记录不会加入到最后的结果集,我们上边提到的连接都是所谓的内连接。
对于外连接的两个表,驱动表中的记录即使在被驱动表中没有匹配的记录,也仍然需要加入到结果集
在MySQL 中,根据选取驱动表的不同,外连接仍然可以细分为2种:
左外连接
选取左侧的表为驱动表。
右外连接
选取右侧的表为驱动表。
left join
SELECT * FROM t1 LEFT [OUTER] JOIN t2 ON 连接条件 [WHERE 普通过滤条件];
对于左(外)连接和右(外)连接来说,必须使用ON子句来指出连接条件。了解了左
(外)连接的基本语法之后,再次回到我们上边那个现实问题中来,看看怎样写查询语句才能把所有的学生的成
绩信息都查询出来,即使是缺考的考生也应该被放到结果集中:
mysql> SELECT s1.number, s1.name, s2.subject, s2.score FROM student AS s1 LEFT JOIN score
AS s2 ON s1.number = s2.number;
+----------+-----------+-----------------------------+-------+
| number | name | subject | score |
+----------+-----------+-----------------------------+-------+
| 20180101 | 杜子腾 | 母猪的产后护理 | 78 |
| 20180101 | 杜子腾 | 论萨达姆的战争准备 | 88 |
| 20180102 | 范统 | 论萨达姆的战争准备 | 98 |
| 20180102 | 范统 | 母猪的产后护理 | 100 |
| 20180103 | 史珍香 | NULL | NULL |
+----------+-----------+-----------------------------+-------+
5 rows in set (0.04 sec)
从结果集中可以看出来,虽然史珍香并没有对应的成绩记录,但是由于采用的是连接类型为左(外)连接,所
以仍然把她放到了结果集中,只不过在对应的成绩记录的各列使用NULL值填充而已。
内连接
SELECT * FROM t1 [INNER | CROSS] JOIN t2 [ON 连接条件] [WHERE 普通过滤条件];
也就是说在MySQL 中,下边这几种内连接的写法都是等价的:
SELECT * FROM t1 JOIN t2;
SELECT * FROM t1 INNER JOIN t2;
SELECT * FROM t1 CROSS JOIN t2
连接的原理
Nested-Loop Join

驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接
使用索引加快连接速度
SELECT * FROM t1, t2 WHERE t1.m1 > 1 AND t1.m1 = t2.m2 AND t2.n2 < 'd';

那么只要在m2建立索引,等值查询,—》假设用ref,回表之后再判断n2列
n2建立索引—》range的方法
只有在二级索引 + 回表的代价比全表扫描的代价更低时才会使用索引
Block Nested-Loop Join
扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足—》
如何减少访问被驱动表的次数
join buffer 就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集
中的记录装在这个join buffer 中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer 中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成

只有查询列表中的列和过滤条件中的列才会被放到join buffer
MySQL基于成本的优化
io成本:MyISAM 、 InnoDB 存储引擎都是将数据和索引都存储到磁盘上的,当我们想查询表中的记录时,需要先把数据或者索引加载到内存中然后再操作。这个从磁盘到内存这个加载的过程损耗
cpu成本:读取以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为CPU成本
读取一个页面花费的成本默认是1.0 ,读取以及检测一条记录是否符合搜索条件的成本默认是0.2
用一个例子说明:
MySQL的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案,这个成本最低的方案就是所谓的执行计划,之后才会调用存储引擎提供的接口真正的执行查询,这个过程总结一下就是这样:
- 根据搜索条件,找出所有可能使用的索引
- 计算全表扫描的代价
- 计算使用不同索引执行查询的代价
- 对比各种执行方案的代价,找出成本最低的那一个
possible keys:idx_key1 和 idx_key2
全表扫描代价:
查询成本=I/O 成本+CPU 成本
所以计算全表扫描的代价需要两个信息:
- 聚簇索引占用的页面数
- 该表中的记录数
计算方式可以是利用:
SHOW TABLE STATUS LIKE 'single_table'\G
然后可以拿到
Rows:多少条记录
Data length:占用的空间字节数,聚簇索引的页面数量 x 每个页面的大小
然后就可以
算io成本=页面数*1.0+1.1
cpu成本:记录数*0.2+1.0
InnoDB的Buffer Pool
在进行完读写访问之后不着急把该页的内存消掉,而是放在缓存里面。
默认128M
free链表

控制信息占用的一块内存称为一个控制块
每当需要从磁盘中加载一个页到Buffer Pool 中时,就从 free链表 中
取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的free链表节点从链表中移除,表示该缓存页已经被使用了
怎么知道某个缓存页有没有被用?:
表空间号 + 页号作为key,缓存页作为value创建一个哈希表,在需要访问某个页的数据
时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从free链表 中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。
flush链表
如果修改了缓存页的数据,每次都要同步到硬盘吗?
脏页:和缓存页不同
凡是修改过的就先放到里面

LRU链表

解决两个问题:
- 加载到Buffer Pool 中的页不一定被用到。
- 如果非常多的使用频率偏低的页被同时加载到Buffer Pool 时,可能会把那些使用频率非常高的页从Buffer Pool 中淘汰掉
如何解决:
第一个问题:当磁盘上的某个页面在初次加载到Buffer Pool中的某个缓存页时,该缓存页对应的控制块会被放到old区域的头部
第二个问题:
记录第一次访问时间:InnoDB会在该页的控制块中记录第一次被访问的时间戳。
后续访问的判断:
时间间隔内(默认1秒):如果后续访问时间与第一次访问的时间差小于innodb_old_blocks_time(默认1000毫秒),说明这是短时间内连续访问(如全表扫描),该页不会移动到Young区域,仍保留在Old区域
时间间隔外:如果两次访问间隔超过阈值,则认为这是“真正的热点数据”,将该页移动到Young区域的头部
事务
锁

锁结构就是事务对这个记录做出改动就会生成
trx信息存哪个事务生成;is_waiting 事务是否等待,false就是拿到了;
行级锁
独占锁:x锁
共享锁:s锁

写操作:
delete:就是在B+树定位这个记录,然后获取这条记录的x锁,再执行delete mark
update:就看是否修改键值
键值说的是主键,索引之类的
情况一:未修改键值且存储空间不变
当你更新一条记录时,如果没有修改它的键值(如主键或索引列)且更新后的存储空间保持不变:
- InnoDB首先在B+树中定位到该记录的位置
- 然后获取该记录的排他锁(X锁)
- 最后直接在原位置上修改该记录
- 这个定位过程实际上是一个获取X锁的"锁定读"操作
情况二:未修改键值但存储空间变化
当你更新一条记录,没有修改键值,但至少有一个列的存储空间发生变化:
- InnoDB首先在B+树中定位到该记录的位置
- 然后获取该记录的排他锁(X锁)
- 将原记录完全删除(移入垃圾链表)
- 插入一条包含更新值的新记录
- 原记录的定位过程是一个获取X锁的"锁定读"
- 新插入的记录由INSERT操作的隐式锁进行保护
情况三:修改了键值
当你更新一条记录并修改了其键值:
- 这相当于先执行DELETE操作然后再执行INSERT操作
- 锁定操作分别遵循DELETE和INSERT的规则
- 原记录使用适当的锁进行删除
- 新记录使用适当的锁进行插入
简而言之,这些情况描述了InnoDB在执行UPDATE操作时为维护数据一致性和处理并发而使用的内部机制。根据键是否更改以及存储需求是否变化,采用的方法各不相同。
insert:隐式锁
多粒度锁
也是s锁和x锁,其实和上面一样的,只是作用的范围是表。
但是可能会有一个问题:
1.如果想对整体表加上一个锁,需要里面是没有的才行。
2.加x锁,也是需要把x撤掉才能有
—》
所以有了一个叫意向共享锁 Intention Shared Lock (IS锁)。 当事务准备在某条记录加上s锁的时候,要在表级别加上一个IS锁
意向独占锁 Intention Exclusive Lock (IX锁),某条记录加上X锁,需要表级别加上一个IX锁。—》都是表级锁
- 如果有领导要参观教学楼,也就是想在教学楼门口前放S锁(表锁)时,首先要看一下教学楼门口有没有IX锁 ,如果有,意味着有教室在维修,需要等到维修结束把IX锁撤掉后才可以在整栋教学楼上加S锁。
- 如果有考试要占用教学楼,也就是想在教学楼门口前放X锁(表锁)时,首先要看一下教学楼门口有没有IS锁 或 IX锁 ,如果有,意味着有教室在上自习或者维修,需要等到学生们上完自习以及维修结束把IS锁和IX锁 撤掉后才可以在整栋教学楼上加X锁。
MYSQL当的行锁和表锁
除了InnoDB都是session级别的表级锁
表级别:
Auto-INC锁
自增的时候,表级别—》保证了一个事务在持有的时候,别的时候插入不进来
某个表执行SELECT 、 INSERT 、 DELETE 、 UPDATE 语句时, InnoDB 存储引擎是不会为这个表添加表级别的S锁或者X锁的。
行锁的算法
语法:
SELECT column1, column2, ...
FROM table_name
WHERE condition
FOR UPDATE;
例子
BEGIN;
-- 锁定账户,确保余额在读取后不会被其他事务修改
SELECT * FROM accounts WHERE account_id = 203 FOR UPDATE;
-- 执行转账操作
UPDATE accounts SET balance = balance - 1000 WHERE account_id = 203;
COMMIT;
1. 记录锁(Record Lock)- 锁定单个座位
锁定索引记录的一个精确值。就像预订特定的10号桌。
SELECT * FROM accounts WHERE account_id = 203 FOR UPDATE
账户表状态:
account_id | customer_name | balance | 锁状态
-----------+--------------+----------+--------
101 | 张三 | 5000 |
203 | 李四 | 3000 | 🔒(事务A持有X锁)
305 | 王五 | 7500 |
410 | 赵六 | 2000 |
2. 间隙锁(Gap Lock)- 锁定座位区间
锁定索引记录之间的间隙。例如,锁定10号桌到15号桌之间的空间,防止新桌子被添加到这个区域。
SELECT * FROM accounts WHERE account_id BETWEEN 200 AND 300 FOR UPDATE
账户表状态:
account_id | customer_name | balance | 锁状态
-----------+--------------+----------+----------
101 | 张三 | 5000 |
(101-203之间的间隙) | | 🔒(间隙锁B)
203 | 李四 | 3000 | 🔒(事务B持有X锁)
(203-305之间的间隙) | | 🔒(间隙锁B)
305 | 王五 | 7500 |
410 | 赵六 | 2000 |
3. Next-Key锁 - 综合防护
结合了记录锁和间隙锁,锁定索引记录及其前面的间隙。这就像预订了10号桌,同时也确保没人能在9号和10号之间插入新桌子。
SELECT * FROM accounts WHERE account_id > 200 AND account_id < 400 FOR UPDATE
账户表状态:
account_id | customer_name | balance | 锁状态
-----------+--------------+----------+----------
101 | 张三 | 5000 |
(101-203之间的间隙) | | 🔒(Next-Key锁)
203 | 李四 | 3000 | 🔒(记录锁)
(203-305之间的间隙) | | 🔒(Next-Key锁)
305 | 王五 | 7500 | 🔒(记录锁)
(305-410之间的间隙) | | 🔒(Next-Key锁)
410 | 赵六 | 2000 |
事务隔离级别 (Transaction Isolation Levels)
想象一下,后厨越来越大,同时开工的厨师(并发事务)越来越多。为了既保证菜品(数据)的质量和一致性,又不至于让大家互相等待太久影响效率,厨房(数据库)就制定了几套不同的协作规则,这就是“隔离级别”。
没有规则可能出现的问题 (并发异常现象):
如果不规定好协作规则,多个厨师同时看、同时改同一本菜谱(数据),可能会出现以下混乱情况:
- 脏读 (Dirty Read): 看到别人“铅笔草稿”
- 场景: 厨师 A 看到厨师 B 在菜谱上用铅笔写了个新的糖量(B 还没最终确认,只是个草稿,即未提交的修改)。厨师 A 信以为真,按这个草稿去配料了。结果厨师 B 突然把铅笔字迹擦掉了(事务 B 回滚了)。
- 后果: 厨师 A 基于一个从未正式生效的“脏”数据做了操作,可能导致后续错误。
- 比喻: 读到了未提交的数据。
- 不可重复读 (Non-Repeatable Read): 发现菜谱内容变了
- 场景: 厨师 A 第一次看菜谱,盐量是 5 克。他去忙了点别的,回来再次看同一页菜谱,发现盐量变成了 7 克!这是因为在他离开期间,厨师 B 完成了修改并用墨水确认了(事务 B 已提交)。或者更糟,厨师 B 把这页菜谱撕掉了(事务 B 已提交 DELETE)。
- 后果: 在同一个任务(事务)中,前后两次读取同一份数据,结果却不一样了(数据被 UPDATE 或 DELETE)。
- 比喻: 同一事务内,多次读取同一行数据,值或存在状态发生变化。
- 幻读 (Phantom Read): 发现多出来新菜谱
- 场景: 厨师 A 向管理员查询:“列出所有‘牛肉’相关的菜谱”,得到了 3 个结果。过了一会儿,他用完全相同的条件再次查询,却得到了 4 个结果!原来在他两次查询之间,厨师 B 新增了一道“香辣牛肉盖饭”并且用墨水确认了(事务 B 已提交 INSERT)。
- 后果: 在同一个任务(事务)中,按相同条件范围查询,结果集里的记录数量变了(好像多出来“幻影”一样)。
- 比喻: 同一事务内,多次按范围查询,结果集中出现了新增的行。
协作规则:四种隔离级别 (The Isolation Levels)
为了解决(或部分解决)这些问题,SQL 标准定义了四种隔离级别,严格程度和对性能的影响依次递增:
- 读未提交 (Read Uncommitted) - “规则:随便看”
- 规则描述: 最宽松的规则。允许一个厨师直接看到其他厨师正在写的“铅笔草稿”(未提交的修改)。
- 能解决的问题: 基本解决不了上述任何问题。允许脏读、不可重复读、幻读。
- 性能: 并发性能最好,因为几乎没有限制。
- 用途: 极少使用,除非对数据一致性要求极低。
- 比喻: 后厨一片混乱,谁的草稿都可以随便看。
- 读已提交 (Read Committed) - “规则:只看墨水字迹”
- 规则描述: 只能读取其他厨师已经用“墨水”确认(已提交)的修改。忽略所有“铅笔草稿”。
- 能解决的问题: 避免了脏读。
- 仍存在的问题: 允许不可重复读和幻读。(因为你看完一次墨水字迹,下次再看时,别人可能已经用墨水更新了内容或增加了新页)。
- 实现: 很多数据库(如 Oracle, SQL Server)的默认级别。通常利用 MVCC 实现:总是读取最新已提交的版本。
- 比喻: 比较礼貌,不看别人草稿,只看定稿。但别人随时可能发布新定稿。
- 可重复读 (Repeatable Read) - “规则:看自己任务开始时的快照”
- 规则描述: 当一个厨师开始一项任务(事务开始)时,他会得到一个当时所有菜谱的“快照”(Read View)。在整个任务期间,他总是参考这份“快照”来读取数据。他看不到其他厨师在他开始任务之后提交的任何修改或新增内容。
- 能解决的问题: 避免了脏读、不可重复读。
- 仍存在的问题: 标准定义下,可能允许幻读。(虽然你看不到内容的修改,但别人新增的符合你查询条件的新菜谱页,理论上可能被你第二次范围查询时看到)。
- 实现: MySQL InnoDB 的默认隔离级别。InnoDB 通过 MVCC 实现。特别注意: MySQL 的 InnoDB 在可重复读级别下,通常使用间隙锁 (Gap Locks) 来额外防止幻读,使得它比 SQL 标准定义的可重复读更严格。
- 比喻: 任务开始时拍个照,之后一直看照片工作,不受外界干扰(基本不受,除了理论上的幻影)。
- 可串行化 (Serializable) - “规则:独占使用,排队来”
- 规则描述: 最严格的规则。当一个厨师要对某些菜谱页进行操作时,他会把这些页面(或整个相关区域)完全锁住,不允许任何其他厨师进行任何读或写操作,直到他完成任务为止。
- 能解决的问题: 避免了脏读、不可重复读、幻读。提供了最高级别的一致性,效果等同于所有事务一个接一个排队执行。
- 性能: 并发性能最差,因为锁的粒度和时间最长,等待最多。
- 实现: 通常通过添加更多的锁(如范围锁、表锁)来实现。
- 比喻: 极其严格,要用哪几页菜谱就先锁起来,别人碰都不能碰,直到你用完。
总结与权衡:
隔离级别 | 允许脏读 | 允许不可重复读 | 允许幻读 | 并发性能 |
读未提交 | 是 | 是 | 是 | 最高 |
读已提交 | 否 | 是 | 是 | 较高 |
可重复读 | 否 | 否 | 是 (标准)/ 否 (MySQL InnoDB 通常) | 中等 |
可串行化 | 否 | 否 | 否 | 最低 |
选择哪个级别?
- 取决于你的应用对数据一致性的要求有多高,以及对并发性能的需求有多大。
- MySQL InnoDB 默认的可重复读 在很多场景下是一个很好的平衡点,因为它提供了较高的一致性(通常还防止了幻读),同时通过 MVCC 保持了不错的并发性能。
- 如果需要绝对的隔离保证,且能接受性能损失,可以选择可串行化。
- 如果能容忍不可重复读和幻读,但不能容忍脏读,读已提交也是常见的选择(尤其是在其他数据库系统中)。
可重复读也存在幻读?
按 SQL 标准定义,“可重复读”(Repeatable Read)确实应该只保证避免脏读和不可重复读,但理论上允许幻读。而我们平时使用 MySQL InnoDB 时,它的默认级别就是可重复读,却通常感觉不到幻读。
这种“矛盾”源于 SQL 标准定义 和 MySQL InnoDB 具体实现 之间的差异。
我们还是用厨房的例子来拆解:
1. 可重复读的核心保证 (基于 MVCC 快照): 防止“内容”变化
- 回忆一下: 可重复读级别下,厨师 A 开始任务时,会拿到一个“厨房快照”(Read View)。之后他所有的读取操作,都基于这个快照。
- 效果: 如果他第一次读取“红烧肉”菜谱,盐量是 5 克。即使在他看快照期间,厨师 B 把实际菜谱(最新版本)的盐量改成 7 克并提交了,厨师 A 再去看他的“快照”,看到的盐量仍然是 5 克。同样,如果厨师 B 删除了这道菜,厨师 A 看快照时,菜谱仍然存在(是旧版本)。
- 结论: MVCC 的快照机制,很好地保证了对于事务开始时已经存在的数据行,其内容不会在事务执行期间发生变化(不会读到更新或删除)。这就避免了不可重复读。
2. 为什么“快照”本身难防“幻影” (新增的行):
- 问题所在: 快照记录的是事务开始时“有哪些数据行,它们是什么状态”。它主要关注的是已经存在的行的版本。
- 场景: 厨师 A 用他的快照查询“所有牛肉类菜谱”,快照显示有 3 道。
- 漏洞: 这个快照本身,并没有明确记录“在‘酱牛肉’和‘葱爆牛肉’之间不允许出现新菜谱”或者“‘西冷牛排’后面不允许再有牛肉类菜谱”这样的信息。它只知道当时存在的具体是哪几道菜。
- 发生幻读(理论上): 如果厨师 B 在厨师 A 的快照生成之后,插入了一道新的“水煮牛肉”并提交了。当厨师 A 在同一个事务中,再次执行“查询所有牛肉类菜谱”时,按照最纯粹的 MVCC 逻辑,数据库需要根据查询条件(“牛肉类”)重新扫描一遍相关范围。这时,它发现了新插入且已提交的“水煮牛肉”。由于这个插入事务已经提交,对于这次新的扫描操作来说是可见的。结果,厨师 A 的第二次查询就得到了 4 道菜谱——“水煮牛肉”像个幻影一样冒了出来。
3. MySQL InnoDB 的“超纲”操作:用锁来防幻影
- 认识到问题: MySQL InnoDB 的开发者显然也认为允许幻读会带来很多麻烦。
- 解决方案: InnoDB 在实现可重复读级别时,除了使用 MVCC 来保证读一致性外,还额外使用了间隙锁 (Gap Locks) 和/或 临键锁 (Next-Key Locks)。
- 工作方式(简化理解):
- 当厨师 A 执行“查询所有牛肉类菜谱”时,InnoDB 不仅仅是基于快照返回结果。对于这种范围查询,它还会悄悄地在索引里,把“牛肉类”这个范围**“锁住”**。
- 这不仅仅是锁住现有的“酱牛肉”、“葱爆牛肉”、“西冷牛排”这几行,更重要的是,它会锁住这些行之间的“间隙”,以及最后一个“牛肉”菜谱之后的位置。
- 效果: 现在,如果厨师 B 想要插入新的“水煮牛肉”(这条记录会落在被锁定的间隙或末尾位置),他的
INSERT
操作就会被阻塞,必须等待厨师 A 的事务结束(提交或回滚)后才能执行。 - 结果: 由于插入操作被阻塞了,厨师 A 在他的事务中无论执行多少次“查询所有牛肉类菜谱”,都不会看到那个“幻影”——因为它根本就没能成功插进来!
总结:
- 标准的可重复读 (RR): 主要靠 MVCC 快照保证对已存在行的读取一致性,防止内容更新或删除(防不可重复读),但快照本身难以阻止符合查询范围的新行插入,故理论上允许幻读。
- MySQL InnoDB 的可重复读 (RR): 结合了 MVCC 和 间隙锁/临键锁。MVCC 保证读快照一致性 (防不可重复读),而锁机制则阻止了其他事务在当前事务的查询范围内插入新行,从而额外地、通常有效地防止了幻读。
所以,你的困惑是完全合理的!记住这个关键点:MySQL InnoDB 的默认可重复读级别,比 SQL 标准定义的可重复读更严格,因为它通过额外的锁机制,通常能把幻读也一并解决了。 这也是为什么很多人会觉得在 MySQL 中 RR 级别下没有幻读的原因。
ACID定义
假设你要从你的账户 A 转 100 元给朋友的账户 B。这个操作看似简单,但背后至少包含两个步骤:
- 从账户 A 扣除 100 元。
- 给账户 B 增加 100 元。
如果这中间出了任何问题(比如扣完 A 的钱,系统崩溃了,没来得及给 B 加钱),那结果就糟了。数据库事务的 ACID 属性就是为了防止这种灾难发生。
ACID 是数据库事务必须具备的四个特性的首字母缩写:
- A - Atomicity (原子性):
- 是什么? 事务是一个不可分割的工作单位。事务中的所有操作,要么全部成功执行,要么全部失败回滚。不会出现只执行了一半的情况。
- 银行转账比喻: 要么转账完全成功(A 减 100,B 加 100),要么转账完全失败(A 和 B 的余额都没变),绝不会出现 A 减了 100,但 B 没加上的情况。就像一个原子,不可再分。
- 一句话理解: All or Nothing.
- C - Consistency (一致性):
- 是什么? 事务执行前后,数据库都必须处于合法的、符合规则的状态。数据不会因为事务的执行而被破坏。
- 银行转账比喻:
- 规则 1:转账前后,银行系统里钱的总量是不变的(只是从一个账户流向另一个)。
- 规则 2:账户余额不能是负数(假设有此规定)。
- 一致性保证:事务要么成功完成,并且满足所有这些规则(总金额不变,余额非负);要么失败回滚,数据库回到事务开始前的状态,规则也满足。事务的中间过程(比如 A 扣了钱,B 还没加钱)对其他用户是不可见的,不会破坏整体的规则。
- 一句话理解: Keep data valid. (保持数据有效/符合规则)
- I - Isolation (隔离性):
- 是什么? 当多个事务并发执行时,一个事务的执行不应被其他事务干扰。即事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。从效果上看,就像是各个事务串行执行一样(虽然实际上可能是并发的)。
- 银行转账比喻: 当你给 B 转账的同时,可能有另一个人 C 也在给 B 转账。隔离性保证你的转账操作和 C 的转账操作互不影响,你俩看到和修改的 B 的余额都是基于一个清晰的、非混乱的起点和终点,不会看到对方操作到一半的奇怪余额。
- 一句话理解: Transactions don't step on each other's toes. (事务间互不干扰)
- D - Durability (持久性):
- 是什么? 一旦事务成功提交,它对数据库所做的更改就是永久性的。即使后续系统发生故障(如断电、崩溃),这些更改也不会丢失。
- 银行转账比喻: 一旦银行系统提示你“转账成功”,这 100 块钱就确确实实地从 A 到了 B。就算这时银行机房突然断电,重启后,B 的账户里也一定会有这 100 元(或者说,数据库恢复后能体现这次转账的结果)。
- 一句话理解: Once committed, it stays committed. (一旦提交,永不丢失)
如何保证ACID
MySQL 实现 ACID 的核心在于其存储引擎的设计,我们主要关注默认且支持事务的 InnoDB 存储引擎:
- 保证原子性 (A):靠 Undo Log (撤销日志)
- 原理: InnoDB 在执行事务修改数据之前,会先把修改前的数据记录到一个叫做 Undo Log 的地方。
- 如何工作:
- 如果事务需要回滚 (Rollback)(比如操作失败或用户主动取消),InnoDB 就会利用 Undo Log 里的信息,把数据恢复到事务开始前的状态,就像时光倒流一样。
- 如果事务提交 (Commit),这些 Undo Log 最终会被清理掉。
- 效果: 通过记录“如何撤销”,保证了要么全部完成,要么能完全退回起点,实现了原子性。
Undo Log
想象一下我们还在那个高档餐厅的后厨。Redo Log 是厨师向前看、确保完成订单(已提交事务)不出错的“工作日志”,那么 Undo Log 更像是厨师的“时光机”或者“草稿纸/修改痕迹记录”,用来处理“反悔”和“给不同人看不同版本”的情况。
核心难题:
- 原子性(Atomicity):要么全做,要么全不做。 如果一个复杂的订单(多步骤事务)执行到一半,客人突然取消了,或者厨房出了点小状况(比如盐没了),怎么能干净利落地撤销掉已经做了一半的准备工作,让一切恢复到好像什么都没发生过一样?
- 隔离性(Isolation)与并发: 厨房里人来人往,很多厨师(并发事务)可能同时在处理不同的菜品,甚至可能需要参考同一份基础菜谱(数据)。怎么能保证:
- 一个厨师正在修改菜谱时(进行写操作),不会影响到旁边只想查阅原始菜谱的另一个厨师(进行读操作)?
- 怎么让查阅菜谱的厨师看到的是一个在他开始查阅那一刻一致性的版本,而不是别人改到一半的“草稿”?这就是**多版本并发控制(MVCC)**要解决的问题。
Undo Log 如何解决这些问题:
- 记录“如何撤销”:时光倒流的指南
- 核心思想: 当厨师要修改主菜谱(数据页中的行)时,他不仅会准备进行修改(这个修改行为本身会被 Redo Log 记录以防丢失),同时他还会在 Undo Log 这本特殊的“修改痕迹记录本”里,记下如何将这个修改撤销掉。
- 具体内容:
- 如果是 UPDATE(修改菜谱某项内容),Undo Log 就记下修改之前的旧值。
- 如果是 INSERT(新加一道菜谱),Undo Log 就记下如何DELETE(删除)这条新加的记录(通常是主键信息)。
- 如果是 DELETE(删除一道菜谱),Undo Log 就记下如何INSERT(重新插入)这条被删除的记录(即保存被删除行的内容)。
- 关键: Undo Log 记录的是逻辑上的反向操作所需的信息。
- 数据行与 Undo Log 的链条:追踪历史版本
- 工作方式: 在主菜谱的每一行记录旁边(数据行的隐藏列),都有一个小标记(隐藏的
DB_TRX_ID
记录了最后修改它的事务ID,DB_ROLL_PTR
则是一个指针),指向 Undo Log 里记录“如何撤销这次修改”的那条笔记。如果一行数据被多次修改,这些 Undo Log 记录会像链条一样串起来,形成该行的历史版本链。 - MySQL 术语: 数据页中的行记录包含隐藏字段
DB_TRX_ID
和DB_ROLL_PTR
(Rollback Pointer)。DB_ROLL_PTR
指向该行记录对应的最新一条 Undo Log 记录。这些 Undo Log 记录内部也可能包含指向更早版本的指针,形成一个版本链。
- 事务回滚(Rollback):按图索骥,撤销操作
- 流程: 当一个事务需要回滚时(执行
ROLLBACK
命令,或者事务执行中途失败),InnoDB 就会找到这个事务对应的 Undo Log 记录链。它会沿着这个链条,读取每一条 Undo Log 信息,执行其中记录的“反向操作”,从而精确地将该事务所做的所有修改都撤销掉,恢复到事务开始前的状态。就像厨师拿出“取消订单”的便条,一步步把准备好的食材放回原处。
- MVCC 实现一致性读:寻找合适的历史版本
- 流程: 当一个事务需要读取数据时(例如执行
SELECT
),为了实现隔离性,它不能读到其他尚未提交事务所做的修改。InnoDB 会为这个读事务创建一个“读视图”(Read View),这个视图定义了在它开始时,哪些事务的修改是对它可见的,哪些是不可见的。 - 当读取某一行数据时,InnoDB 会检查该行记录头部的
DB_TRX_ID
(最后修改它的事务ID)。 - 如果这个事务ID对当前读事务的 Read View 是可见的(比如是读事务自己做的修改,或者是在读事务开始前就已经提交的事务),那么就直接读取这一行的当前值。
- 如果这个事务ID太新了,对当前 Read View 不可见(比如是一个并发的、尚未提交的事务做的修改),InnoDB 就会顺着行记录的
DB_ROLL_PTR
指针,找到对应的 Undo Log 记录。利用 Undo Log 中的信息,它可以在内存中重建出该行的上一个版本。然后,它会检查这个重建出的“旧版本”的DB_TRX_ID
,再次根据 Read View 判断是否可见。这个过程会沿着 Undo Log 版本链一直回溯,直到找到一个对当前 Read View 可见的版本为止。 - 效果: 这就像你想看原始菜谱,但发现当前页面上有个厨师正在用透明纸(进行中的修改)覆盖着写写画画。你就可以根据旁边的“历史版本说明”(Undo Log 指针),找到并查看下面那张原始的、或者更早的、对你来说有效的菜谱版本。这保证了读操作不会被写操作阻塞,并且能读到一致性的数据快照。
- Undo Log 与 Redo Log 的协作:确保“后悔药”本身的安全
- 关键点: 对 Undo Log 本身的所有操作(比如写入一条 Undo 记录,修改数据行的
DB_ROLL_PTR
指针)也都是物理层面的修改,它们同样受到 Redo Log 的保护! - 意义: 这意味着即使系统崩溃,Undo Log 的信息也不会丢失。在恢复时,Redo Log 会确保 Undo Log 的状态是正确的。这样,那些在崩溃时还未完成的事务,就可以在系统重启后,利用恢复好的 Undo Log 信息被正确地回滚。
- 清理(Purge):回收过期的“修改痕迹”
- 机制: 当一个事务提交后,它产生的 Undo Log 记录并不会立即被删除。因为可能还有其他更早启动的事务,在其 Read View 中仍然需要这些旧版本的数据来实现一致性读。
- InnoDB 有一个后台的 Purge 线程,它会定期检查 Undo Log。当确定某个 Undo Log 记录所代表的数据版本,已经不再被任何活跃的事务(的 Read View)需要时,Purge 线程就会安全地将其删除,回收空间。
- 类比: 就像后厨有专门的清洁工,在确认没人再需要参考那些旧的、作废的草稿纸或修改痕迹记录后,会把它们清理掉。
专业人士需要掌握的关键知识点:
- 两大核心功能: 事务原子性(通过回滚实现)和事务隔离性(通过 MVCC 实现)。
- MVCC 与 Read View: 理解 Read View 的概念对于掌握不同隔离级别(如
READ COMMITTED
,REPEATABLE READ
)下的读行为至关重要。
- 逻辑日志: Undo Log 本质上存储的是逻辑上的“如何撤销”信息,这与 Redo Log 的物理页面修改记录不同。
- 存储位置(Undo Tablespaces): Undo Log 存储在 Undo 段(Segments)中,这些段位于 Undo 表空间内。可以通过
innodb_undo_tablespaces
等参数配置使用共享表空间(ibdata1
)中的 Undo 空间,还是使用独立的 Undo 表空间文件(.ibu
文件,推荐方式)。
- Purge 延迟与 History List Length: 如果有长时间运行的事务,或者 Purge 操作跟不上 Undo Log 的产生速度,会导致 Undo 空间持续增长,并且系统中需要维护的版本链变长(History List Length 增大,可在
SHOW ENGINE INNODB STATUS
中查看)。这会影响性能,特别是对于需要查找旧版本数据的读操作。监控 History List Length 是判断 Undo 系统健康状况的重要指标。
- 长事务的影响: 长时间运行的事务(尤其是那些执行了 DML 操作但长时间不提交/回滚的事务)是 Undo Log 空间膨胀和 Purge 滞后的主要原因之一,应尽量避免。
简单总结(回到厨房的比喻):
Undo Log 是厨师的“时光机”和“带修改痕迹的草稿系统”。
- 当你需要取消订单(事务回滚)时,它告诉你如何一步步撤销所有操作,保证了工作的原子性。
- 当很多人同时在厨房工作,有人在修改菜谱,有人在查阅时,它通过提供历史版本(MVCC),让查阅者能看到一个一致的、不受干扰的版本,保证了隔离性,并提高了并发性能(读写不阻塞)。
- 它记录的是“如何退回上一步”的逻辑信息,并且它自身的安全也由 Redo Log 来保障。
- 后台的清洁工(Purge 线程)负责清理不再需要的旧版本记录。
- 保证一致性 (C):靠多方面协同
- 原理: 一致性是一个更宏观的目标,它依赖于其他三个 ACID 特性以及数据库本身的约束。
- 如何工作:
- 原子性保证了不会出现只改一半数据导致不一致的情况。
- 隔离性保证了并发事务不会把数据改乱,破坏一致性规则。
- 持久性保证了成功事务的结果不会丢失,维持了最终的一致状态。
- 数据库自身约束: 如主键约束、外键约束、唯一性约束、CHECK 约束 (MySQL 8.0.16+ 支持)、数据类型检查等,这些在事务执行过程中也会进行检查,防止非法数据写入,从而保证一致性。
- 效果: 通过事务机制和数据库约束共同作用,确保数据总是符合预设规则。
- 保证隔离性 (I):靠 锁 (Locking) 和 MVCC (Multi-Version Concurrency Control) (多版本并发控制)
- 原理: 为了让并发事务互不干扰,需要机制来控制它们对数据的访问。
- 如何工作:
- 锁 (Locking): 当一个事务要修改数据时,InnoDB 会给相关数据(可能是行、可能是间隙)上锁。其他事务如果想修改同一个被锁住的数据,就必须等待锁释放。这是一种比较悲观的方式。
- MVCC (多版本并发控制): 这是 InnoDB 处理读操作的核心。它通过保存数据在某个时间点的快照 (Snapshot) 来实现的。当一个事务需要读取数据时,它不会去读被其他事务锁住的、正在修改的数据,而是去读这个数据的一个历史版本(快照)。这样,“读”操作就不会被“写”操作阻塞,“写”也不会被“读”阻塞,大大提高了并发性能。MVCC 的实现依赖于 Undo Log (用来构建历史版本) 和一些额外的版本信息。
- 隔离级别: MySQL 还提供了不同的事务隔离级别(读未提交、读已提交、可重复读(默认)、串行化),允许用户根据业务需求在性能和隔离严格性之间做权衡。不同的级别下,锁的使用策略和 MVCC 的行为会有所不同。
- 效果: 通过锁机制防止写冲突,通过 MVCC 机制实现读写并发,共同保证了不同事务间的隔离。
保证持久性 (D):靠 Redo Log (重做日志) 和 Double Write Buffer
- 原理: 为了确保已提交事务的修改不丢失,需要把修改记录安全地持久化到磁盘。
- 如何工作:
- Redo Log (重做日志): 当事务修改数据时,InnoDB 不是立刻把修改同步到磁盘上的数据文件(这很慢,随机 IO),而是先把“做了什么修改”(比如,把哪个页的哪个位置改成什么值)顺序地写入到一个叫做 Redo Log 的日志文件中(这是顺序 IO,快很多)。这个写入通常在事务提交时完成。
- 崩溃恢复: 如果 MySQL 宕机,重启时 InnoDB 会检查 Redo Log。如果发现有些修改已经写入 Redo Log 但还没来得及同步到数据文件,就会根据 Redo Log 的记录重新执行这些修改,确保已提交事务的更改最终落到数据文件里。这就是所谓的 "Write-Ahead Logging" (WAL) 策略。
- Double Write Buffer (双写缓冲): 这是防止数据页损坏的额外保险。在把内存中的脏数据页 (modified page) 写回磁盘数据文件之前,InnoDB 会先把这个页完整地写入到一个叫 Double Write Buffer 的磁盘连续区域。然后再把这个页写入它在数据文件中的实际位置。如果写实际位置时发生崩溃导致数据页损坏(只写了一半),MySQL 重启时可以从 Double Write Buffer 中找到这个页的完整副本进行恢复,然后再应用 Redo Log。
- 效果: 通过 Redo Log 保证了修改记录的快速、可靠持久化,通过崩溃恢复机制和 Double Write Buffer 保证了即使系统崩溃,已提交的数据也能被恢复,实现了持久性。
Redo log:
想象一下,你正在经营一家超级火爆、高档餐厅的后厨。订单(数据库事务)像雪片一样飞来!
核心难题:
- 速度 vs. 安全:
- 直接修改“主菜谱”(磁盘上的数据文件):太慢了! 每次接到一个微小的修改要求(比如记录加了一小撮盐),你都要先找到菜谱的准确页面,小心翼翼地写上备注,再把厚重的菜谱完美地放回原位。这就像随机磁盘I/O,严重拖慢效率。
- 只在“脑子里记”(内存/Buffer Pool):非常快,但太危险! 如果厨房突然断电(服务器崩溃),你脑子里记住的所有信息、那些还没来得及永久记录下来的订单细节,就全都丢失了!
Redo Log 就是 InnoDB 引擎为了解决这个两难问题而设计的机制。你可以把它想象成厨师(InnoDB)放在手边的一个特殊的、几乎不会损坏的、按顺序记录的工作日志本(或者像一个持续录音的磁带)。
它是如何工作的,为什么这么厉害?
- 预写式日志 (Write-Ahead Logging - WAL): 黄金法则
- 核心思想: 在厨师动手去修改主菜谱(磁盘上的数据页)之前,他必须先在这本特殊的工作日志(redo log)里,准确记录下“我将要做什么修改”。
- 重要性: 如果厨房突然断电(服务器崩溃),等电力恢复后,副厨师们只需要拿起这本工作日志。他们不需要猜测当时发生了什么。他们从上一个已知的安全时间点开始,读取之后记录的笔记,然后在主菜谱上“重做”这些操作,就能将菜谱恢复到断电前所有已成功下单(已提交事务)的状态。
- MySQL 术语: InnoDB 在允许 Buffer Pool(内存缓冲区)中被修改的数据页写回磁盘上的数据文件之前,会先将描述这些物理变更的记录写入 redo log 文件。
- 顺序写入:风驰电掣!
- 工作方式: 向这本日志本写入是按顺序的,就像写日记一样,一行接一行,或者像磁带录音一样。厨师不会跳来跳去地写,总是在末尾添加新的记录。
- 优势: 磁盘的顺序 I/O 速度远超随机 I/O。这就像写日记比在百科全书里到处翻页做笔记快得多。这使得事务的“提交”动作可以非常迅速地完成(至少日志记录本身很快)。
- MySQL 术语: 对 redo log 文件(如
ib_logfile*
)的写入是追加模式的顺序操作,磁盘处理这种操作效率极高。
- Redo Log Buffer:临时的“草稿区”
- 运作机制: 即便是顺序写入磁盘也需要一点时间。为了进一步提速,厨师可能会先把笔记快速写在旁边的小白板(内存中的 redo log buffer)上,然后在稍后某个时刻(比如订单确认完成时,或者小白板快写满时)再一次性、成批地抄写到那本主要的工作日志(磁盘上的 redo log 文件)里。
- 好处: 写入内存几乎是瞬时的。这在很大程度上让事务提交的速度与直接的磁盘写入速度“解耦”。
- MySQL 术语: Redo log 条目首先被写入由
innodb_log_buffer_size
参数定义的内存缓冲区。然后,根据特定的触发条件(如事务提交、缓冲区满、后台线程定时任务等),这些日志条目会被“刷”(flush)到持久化的 redo log 磁盘文件中。
- 记录物理变更:“只记录事实,不讲故事”
- 记录内容: 日志本上记的不是完整的操作说明(“给第52页的红酒炖牛肉加5克盐”),而是非常精确、底层的指令(“在数据页编号 Z 的偏移量 Y 处,将字节值从 X 改为 W”)。
- 原因: 这使得在系统崩溃恢复时“重放”日志变得非常快,并且是“幂等”的(即同一条日志记录即使被重复应用多次,效果也和只应用一次完全相同)。恢复过程不需要解析复杂的 SQL 或执行业务逻辑。
- MySQL 术语: Redo log 存储的是对数据页面的物理更改,而不是逻辑上的 SQL 语句(这与通常记录 SQL 语句的 binary log 不同)。
- 循环使用文件:旧的不去,新的不来
- 文件管理: 厨师的工作日志不是无限大的。他可能只有固定数量的几本(比如两个大活页夹)。当写满第一本后,他会开始写第二本。当第二本也写满后,他会回到第一本的开头去写,但前提是:第一本里记录的那些旧修改,所对应的菜谱内容必须已经确认被安全地、永久地更新到了主菜谱上。
- 目的: 防止 redo log 文件无限增长,占用过多磁盘空间。
- MySQL 术语: Redo log 由一组固定大小的文件组成(数量由
innodb_log_files_in_group
定义,每个文件的大小由innodb_log_file_size
定义,例如ib_logfile0
,ib_logfile1
)。InnoDB 按顺序写入这些文件。当写到最后一个文件的末尾时,它会绕回到第一个文件,从头开始覆盖旧的日志记录。覆盖的前提是,这些旧记录所对应的、在内存中被修改过的数据页(dirty pages)已经被“刷”到磁盘上的数据文件里了。这个过程由“检查点”(Checkpoint)机制管理。
- 检查点 (Checkpoint):标记安全进度
- 作用: 厨师长会周期性地宣布:“好了,日志本上记录到这个时间点的所有修改,都百分之百已经更新到主菜谱上了!” 这个时间点就是“检查点”。
- 意义: 这告诉崩溃恢复程序:“检查点之前的日志你都不用管了,因为它们对应的更改已经安全落盘了。” 同时,检查点也让系统知道 redo log 文件中的哪些部分可以被安全地覆盖重用。
- MySQL 术语: Checkpointing 过程涉及到将 Buffer Pool 中被修改过的页(dirty pages)刷写到磁盘,然后记录一个重要的标记点——即“尚未被刷到磁盘的最旧的那个修改”对应的日志序列号(LSN)。这个检查点 LSN 标志着崩溃恢复需要从哪里开始向前扫描 redo log。
- 日志序列号 (Log Sequence Number - LSN):全局唯一的“进度尺”
- 概念: 写入 redo log 的每一个字节都会推进一个全局的、单调递增的计数器——LSN。你可以把它想象成一个永不后退的、极其精确的“页码+行号”组合。内存中的每个数据页也知道自己最后一次被修改是对应到哪个 LSN。
- 重要性: LSN 把所有东西都串联起来了。检查点的 LSN 告诉我们安全恢复的起点。通过比较数据页头部的 LSN 和检查点 LSN,可以判断磁盘上的这个数据页是否可能已经过时。在崩溃恢复时,InnoDB 正是利用 LSN 来精确判断哪些 redo log 记录需要被重新应用到哪些数据页上。
- MySQL 术语: LSN 是 InnoDB 中一个非常基础且重要的概念。它是一个不断增长的整数,代表了 redo log 已经产生的总量。它被记录在 redo log 条目中,也记录在每个数据页的头部,是实现崩溃恢复和管理 redo log 循环的关键。
关键知识点:
- 持久性级别控制 (
innodb_flush_log_at_trx_commit
): 这可能是与 redo log 相关的最关键的调优参数。直接影响性能和数据安全性。 1
(默认值):完全符合 ACID 的 D(持久性)要求。 每个事务提交时,redo log buffer 的内容会被写入并强制刷到(fsync) 磁盘。最安全,但可能因为频繁的磁盘同步操作而牺牲一些性能。即使操作系统崩溃或服务器断电,也能保证已提交的事务数据不丢失。2
:事务提交时,redo log buffer 的内容会写入到磁盘(实际是写入操作系统的缓存),但不保证立即刷盘,而是依赖操作系统大约每秒一次的频率刷盘。比1
快,但如果操作系统崩溃或服务器断电,可能会丢失最近一秒内提交的事务数据(MySQL 服务自身崩溃通常是可恢复的)。0
:事务提交时不做任何操作,redo log buffer 的内容大约每秒由后台线程写入并刷盘一次。速度最快,但在 MySQL 服务崩溃、操作系统崩溃或服务器断电时,可能会丢失最多一秒内提交的事务数据。
- Redo Log 文件大小 (
innodb_log_file_size
,innodb_log_files_in_group
): 总大小很重要。 - 太小: 可能导致过于频繁的检查点(因为日志空间很快写满需要循环),引发 I/O 尖峰,影响性能平稳性。但好处是崩溃恢复可能更快(需要扫描的日志量少)。
- 太大: 可以更好地“缓冲”写入压力,平滑 I/O 峰值,可能在高并发写入场景下获得更好的性能。但缺点是崩溃恢复时需要扫描和重放更多的日志,恢复时间可能更长。现代建议通常倾向于设置较大的 redo log(总大小达到几个 GB 甚至几十 GB),尤其是在写入密集型负载和使用快速存储(如 SSD)的情况下。
- Redo Log Buffer 大小 (
innodb_log_buffer_size
): 控制内存缓冲区的大小。如果你的事务很大(一次修改很多数据),或者并发连接数很高,产生了大量日志,那么增大这个缓冲区可以减少因等待缓冲区空间或等待刷盘(尤其在innodb_flush_log_at_trx_commit=1
时)而造成的性能瓶颈。但也不宜设置过大,通常 16MB 到 128MB 就能满足大部分场景,除非有明确证据表明需要更大。
- 崩溃恢复 (Crash Recovery): Redo log 只用于系统崩溃后的恢复过程。它的作用是将数据文件状态前滚,确保那些在崩溃前已经提交但其对应的数据页修改可能还留在内存(Buffer Pool)里没来得及写回磁盘的事务,其修改能够被重新应用到数据文件上。恢复是从最后一个检查点开始向前扫描 redo log。 (这与 undo log 不同,undo log 用于回滚未提交的事务,以及实现 MVCC 多版本并发控制)。
- 组提交 (Group Commit): 为了优化
innodb_flush_log_at_trx_commit=1
时的性能,MySQL 会尝试将多个并发提交的事务的 redo log 刷新操作“组合”在一起,用一次fsync()
调用完成多个事务的日志刷盘,从而提高整体吞吐量。
简单总结(回到厨房的比喻):
Redo Log 就是厨师那个写得飞快、按顺序记录、摔不坏砸不烂的工作日志本 (WAL)。它让厨房(InnoDB)可以迅速确认订单(提交事务),而无需等待慢吞吞地更新主菜谱(数据文件)。万一发生灾难(系统崩溃),这本日志能确保所有已确认的订单信息都不会丢失,让厨房可以快速恢复到崩溃前的准确状态。日志本的大小,以及厨师多久强制要求将日志内容同步到永久存档(
innodb_flush_log_at_trx_commit
),决定了在极致安全和极致速度之间的平衡点。redo log 和 undo log 区别:
- Undo Log 解决了“如果做错了或者需要看旧版本怎么办”的问题,保障了原子性和隔离性。 它确实是关于“回退”和“版本”。
- Redo Log 解决了“如果刚做完就断电了怎么办”以及“怎么能做得更快”的问题,保障了持久性和高性能。 它主要是关于“前进恢复”和“效率”
怎么理解mvcc
想象一下我们的餐厅后厨变得更加繁忙了!
核心难题:多人协作的混乱
- 场景: 假设主厨(事务 A)正在阅读“招牌红烧肉”的菜谱(
SELECT
操作),准备确认食材用量。与此同时,一个新来的帮厨(事务 B)被告知需要更新这道菜谱里的糖量(UPDATE
操作)。
- 没有 MVCC 的旧方法(简单加锁):
- 方法1 (读锁): 主厨 A 先给菜谱加个“阅读锁”,在他读完之前,帮厨 B 就算想改也不能动手,只能干等着。效率低!
- 方法2 (写锁): 帮厨 B 先给菜谱加个“修改锁”,在他改完(并决定是否最终确认这次修改)之前,主厨 A 就算想看也不能看,也得等着。效率同样低!
- 结果: 这种“你要用,我就不能用;我要用,你就不能用”的方式,严重限制了厨房的工作效率(数据库的并发性能)。
MVCC 闪亮登场:神奇的“透明覆盖纸”系统
MVCC 就像是为厨房引入了一套基于“透明覆盖纸”和“时间快照”的高效工作流程,让大家能“同时”工作,互不干扰(或者说,大大减少干扰)。
它是如何工作的?
- 核心思想:多版本共存,各取所需
- 理念: 与其粗暴地锁住整个菜谱,不如允许同一份菜谱(同一行数据)同时存在多个“版本”。修改者创建新版本,而读取者根据自己“开始工作的时间点”,去读取那个时间点有效的旧版本。
- 比喻: 帮厨 B 要改糖量时,他不会直接在原始菜谱上涂改。
- 行版本控制:创建新版本
- 过程: 帮厨 B 拿到一张透明的覆盖纸,把它铺在原始菜谱页面上。他在这张覆盖纸上写下新的糖量。同时,他在这张覆盖纸上做了两个标记:
- “本次修改由 B 完成 (事务ID B)” (对应行的隐藏列
DB_TRX_ID
) - “这张纸下面的原始版本在这里 (指向原始菜谱页/Undo Log记录)” (对应行的隐藏列
DB_ROLL_PTR
) - 结果: 现在,“红烧肉”菜谱有了两个“版本”:覆盖纸上的新版本(帮厨 B 的修改结果)和覆盖纸下面的原始版本。注意: 实际上,旧版本的数据是存储在 Undo Log 中的,
DB_ROLL_PTR
指针指向 Undo Log 记录。
- 读视图(Read View):我的“时间快照”
- 概念: 每个厨师(事务)在开始一项需要一致性视图的任务时(比如主厨 A 开始阅读菜谱时),会得到一个“厨房当前状态快照”(Read View)。这个快照告诉他:“在你开始这一刻,哪些厨师的工作成果(已提交事务)是你看得到的,哪些正在进行中或还未开始的(未提交事务)你是看不到的。”
- 读取过程:按“快照”选择版本
- 主厨 A(他的“时间快照”是在帮厨 B 修改之前生成的)过来读菜谱。他看到了最上面的覆盖纸(帮厨 B 的新版本)。
- 他查了一下自己的“快照”,发现“事务 B”对他来说是不可见的(因为 B 的修改在他快照生成之后才发生,或者 B 还没提交)。
- 于是,主厨 A 忽略这张覆盖纸,顺着上面的标记(
DB_ROLL_PTR
指针),找到了并读取了覆盖纸下面的原始菜谱版本(或者通过 Undo Log 重建出那个他应该看到的旧版本)。 - 关键: 主厨 A 读取到了他事务开始时的一致性数据,并且没有被帮厨 B 的修改操作所阻塞!
- 另一个场景: 如果另一位厨师 C 在帮厨 B 提交了他的修改之后才开始读取,那么厨师 C 的“时间快照”会认为“事务 B”是可见的。当厨师 C 看到覆盖纸时,他会直接读取覆盖纸上的新糖量,因为这个版本对他来说是有效的。
- 写入过程:
- 帮厨 B(写入者)在创建新版本(写覆盖纸)时,仍然需要短暂地锁定一下菜谱页面本身(物理页的锁或行锁),以防止其他帮厨同时在同一页上涂改导致物理性损坏。但这通常非常快。关键在于,这个短暂的写锁定通常不会阻塞那些只需要读取旧版本的读者(像主厨 A)。
MVCC 的重要知识点:
- 核心优势:读写并发。 极大地提高了数据库在混合读写负载下的性能,尤其是“读不阻塞写,写不阻塞读”。
- 一致性读: 保证了在一个事务(或语句,取决于隔离级别)中读取的数据,是来自某个特定时间点的一致性快照。
- 依赖 Undo Log: MVCC 的实现高度依赖 Undo Log 来存储行的旧版本数据,供需要旧版本的读事务查询。
- 隐藏列: 数据行中通常包含隐藏的
DB_TRX_ID
(创建该版本的事务ID) 和DB_ROLL_PTR
(指向前一个版本的 Undo Log 记录的指针) 等字段,用于版本链管理。
- 隔离级别: MVCC 是实现 SQL 标准中的
READ COMMITTED
(读已提交)和REPEATABLE READ
(可重复读)隔离级别的基础技术。
- 潜在成本:
- 需要存储 Undo Log,如果存在长事务,可能导致 Undo 空间膨胀。
- 读取旧版本时,需要额外的 CPU 开销去遍历版本链和从 Undo Log 重建数据。
read view是什么
在 MVCC 的世界里,Read View 是一个数据结构,或者说是一个内部快照信息。当一个事务需要读取数据,并且需要保证读到的是某个一致性时间点的数据时(比如在
READ COMMITTED
或 REPEATABLE READ
隔离级别下执行 SELECT
),InnoDB 就会为它创建(或重用)一个 Read View。这个 Read View 记录了在创建它那一刻,数据库中所有其他事务的状态信息。然后,当这个事务去读取某一行数据的某个版本时,它就会用这个 Read View 来判断:“这个版本的数据,我(当前事务)应不应该看得到?”
Read View 的“镜片”包含哪些信息?(核心组成)
这副“特制眼镜”(Read View)主要包含以下几个关键信息,用来过滤可见的数据版本:
m_ids
(活跃事务 ID 列表): 一个列表,记录了在创建 Read View 时,系统中所有正活跃且未提交的事务的 ID。- 比喻: 眼镜上附带的小纸条写着:“注意,厨师 B、D、F 正在忙,他们的活儿还没干完(事务未提交)。”
m_up_limit_id
(高水位事务 ID): 创建 Read View 时,系统中将要分配给下一个新事务的 ID。任何事务 ID 大于或等于这个值的,都意味着它是在 Read View 创建之后才开始的,因此其所做的修改对于持有该 Read View 的事务来说是绝对不可见的。- 比喻: 纸条上写着:“等厨师 G 开始干活时,他的工单号是 105。所有工单号是 105 或更大的,对你来说都还没发生。”
m_low_limit_id
(低水位事务 ID): 创建 Read View 时,系统中活跃事务列表m_ids
中最小的事务 ID。任何事务 ID 小于这个值的,都表示它在 Read View 创建时就已经提交或回滚了(是很久以前就完成的事务),因此其所做的修改对于持有该 Read View 的事务来说是绝对可见的。- 比喻: 纸条上写着:“目前正在忙的厨师里,工单号最小的是厨师 B(假设是 98)。所有工单号小于 98 的活儿,肯定早就干完了。”
m_creator_trx_id
(创建者事务 ID): 创建这个 Read View 的事务自身的 ID。- 比喻: 纸条上写着:“这是你(厨师 A)自己的工单号(假设是 101)。”
Read View 如何工作?(判断可见性的规则)
当持有 Read View 的厨师 A(事务 A)去读取某行菜谱的一个版本时,他会看这个版本最后是由哪个厨师(事务 X,其 ID 为
row_trx_id
)修改的。然后他会用自己的“特制眼镜”(Read View)按以下顺序判断:- 是我自己改的吗?
row_trx_id
是否等于m_creator_trx_id
? - 是:可见!(自己做的修改当然能看到)
- 是不是太旧了(在我开始前就完成了)?
row_trx_id
是否小于m_low_limit_id
? - 是:可见!(这个修改在我拿到眼镜前早就完成了)
- 是不是太新了(在我开始后才启动)?
row_trx_id
是否大于或等于m_up_limit_id
? - 是:不可见!(这个修改是在我拿到眼镜后才发生的)
- 是不是在我拿到眼镜时还在忙? (此时
m_low_limit_id <= row_trx_id < m_up_limit_id
)检查row_trx_id
是否在m_ids
(活跃事务列表) 中? - 是:不可见!(这个修改是我拿到眼镜时,那个厨师还在忙活,没提交呢)
- 否:可见!(这个修改的厨师在我拿到眼镜时已经完工了,虽然他开始得可能比较晚,但在那一刻他已经提交了)
如果判断结果是“不可见”怎么办?
事务 A 不会读取这个版本的数据。它会顺着这行数据指向前一个版本的指针 (
DB_ROLL_PTR
),去 Undo Log 里找到并重建出上一个版本的数据,然后对这个旧版本重新执行一遍上述的可见性判断流程。这个过程会一直持续,直到找到一个可见的版本为止。Read View 与隔离级别的关系:
Read View 的创建时机,直接决定了事务的隔离行为:
- READ COMMITTED (读已提交): 每执行一条
SELECT
语句时,都会创建一个新的 Read View。 - 效果: 在同一个事务中,两条相邻的
SELECT
语句可能会看到不同的数据,因为它们使用了不同的 Read View,中间可能有其他事务提交了。这就是为什么 RC 级别下会有不可重复读。
- REPEATABLE READ (可重复读): 只有在事务中的第一个读取操作(
SELECT
)发生时,才会创建一个 Read View。之后该事务中所有的读取操作,都会复用这同一个 Read View。 - 效果: 在整个事务期间,看到的总是一致的数据快照(事务开始时的样子),不会看到其他事务在此期间提交的修改。这就避免了不可重复读。(前面说过,InnoDB 通过额外加锁通常还能避免幻读)。
总结:
Read View 就是 MVCC 实现一致性读的核心机制。它像一副为事务量身定做的“时间快照眼镜”,通过记录创建时刻的事务状态信息(活跃列表、高低水位 ID),并遵循一套明确的可见性判断规则,让事务能够读到符合其隔离级别要求的数据版本,而不需要对数据进行长时间的锁定。Read View 的创建时机(每次读 vs. 首次读)是区分
READ COMMITTED
和 REPEATABLE READ
隔离级别的关键所在。