🎯 适用:后端开发、Java/Go/Python 全栈、DBA、架构师岗位面试
📖 内容覆盖:基础概念 · 存储引擎 · 索引原理 · 事务与锁 · SQL优化 · 日志系统 · 主从复制 · 分库分表 · InnoDB底层结构 · Buffer Pool · MVCC底层 · 优化器原理 · 实战场景
目录
- 一、基础概念(Q1-Q15)
- 二、存储引擎(Q16-Q22)
- 三、索引原理与优化(Q23-Q45)
- 四、事务与ACID(Q46-Q58)
- 五、锁机制与并发控制(Q59-Q70)
- 六、MVCC机制(Q71-Q78)
- 七、日志系统(Q79-Q88)
- 八、SQL优化与执行计划(Q89-Q100)
- 九、主从复制与高可用(Q101-Q108)
- 十、分库分表与架构设计(Q109-Q115)
- 十一、高频场景题(Q116-Q125)
- [十二、InnoDB 存储结构深入(Q126-Q135)](#十二、InnoDB 存储结构深入(Q126-Q135))
- [十三、Buffer Pool 与 Checkpoint 深入(Q136-Q143)](#十三、Buffer Pool 与 Checkpoint 深入(Q136-Q143))
- [十四、Redo Log 底层原理(Q144-Q150)](#十四、Redo Log 底层原理(Q144-Q150))
- [十五、Undo Log 与版本链深入(Q151-Q156)](#十五、Undo Log 与版本链深入(Q151-Q156))
- [十六、Binlog 与复制深入(Q157-Q163)](#十六、Binlog 与复制深入(Q157-Q163))
- 十七、锁机制底层原理(Q164-Q172)
- [十八、MVCC 底层实现深入(Q173-Q178)](#十八、MVCC 底层实现深入(Q173-Q178))
- 十九、查询优化器深入(Q179-Q184)
- [二十、MySQL 8.0 新特性与高级主题(Q185-Q200)](#二十、MySQL 8.0 新特性与高级主题(Q185-Q200))
一、基础概念
Q1: 什么是关系型数据库?MySQL 的特点是什么?
答案 :
关系型数据库(RDBMS)以二维表的形式组织数据,表与表之间通过主键/外键建立关联,使用 SQL(结构化查询语言)进行数据操作。
MySQL 的核心特点:
- 开源免费(社区版),生态成熟
- 支持多种存储引擎(InnoDB、MyISAM 等),插件式架构
- 支持事务、行级锁、外键(InnoDB)
- 主从复制、读写分离、高可用方案成熟
- 适用于 OLTP 场景,单表建议 ≤2000 万行
底层原理 :MySQL 的插件式架构源于其Handler API 设计。Server 层通过 handler 接口与存储引擎交互,每个存储引擎实现 handler 类的子类(如 ha_innobase)。这种设计使得新增存储引擎只需实现接口即可,无需修改 Server 层代码。
💡 要点:面试时要强调"插件式存储引擎架构"和 Handler API,这是 MySQL 区别于其他数据库的核心设计。
Q2: MySQL 的体系结构分为哪几层?
答案 :
MySQL 采用分层架构,共四层:
| 层级 | 核心组件 | 职责 |
|---|---|---|
| 连接层 | 连接池、认证授权 | 处理客户端连接、身份验证、权限校验 |
| 服务层 | SQL 接口、解析器、优化器、缓存 | SQL 解析、优化、执行计划生成 |
| 存储引擎层 | InnoDB、MyISAM 等 | 数据的存储和检索,支持插件式替换 |
| 物理存储层 | 数据文件、日志文件、索引文件 | 实际的磁盘 I/O |
底层执行流程详解:
- 连接器 :验证用户名密码,查询
mysql.user表获取权限,分配线程(thread_cache_size控制复用) - 解析器:词法分析(识别 token)→ 语法分析(生成 AST 语法树)→ 语义检查
- 优化器 :基于代价模型(Cost-Based Optimizer, CBO) 选择最优执行计划,参考表统计信息(
innodb_stats_persistent) - 执行器 :检查表权限 → 调用存储引擎的 Handler API(
index_read、rnd_next等)→ 返回结果集
💡 要点:服务层是通用的,存储引擎层是可插拔的。一条 SQL 从连接层进入,经服务层解析优化后,调用存储引擎层的 API 完成数据读写。
Q3: SQL 语句在 MySQL 中的执行流程是怎样的?
答案 :
以 SELECT * FROM users WHERE id = 1 为例,完整的底层执行流程:
1. 客户端发送 SQL → 连接器(认证 + 权限检查)
2. 解析器:词法分析 → 语法分析 → 生成解析树
3. 优化器:选择索引 → 生成执行计划(Access Path)
4. 执行器:调用 InnoDB 的 handler->index_read()
5. InnoDB:
a. 在 Buffer Pool 中查找 id=1 的数据页
b. 如果不在内存 → 从磁盘读取数据页到 Buffer Pool(一次 16KB 随机 I/O)
c. 在 B+ 树中定位到叶子节点,找到 id=1 的记录
d. 返回记录给 Server 层
6. Server 层将结果返回给客户端
以 UPDATE users SET age = 26 WHERE id = 1 为例:
1. 客户端 → 连接器 → 解析器 → 优化器 → 执行器
2. 执行器调用 InnoDB:
a. 在 Buffer Pool 中定位数据页
b. 写 Undo Log(记录旧值 age=25,用于回滚和 MVCC)
c. 更新 Buffer Pool 中的数据页(标记为脏页)
d. 写 Redo Log Buffer
e. 写 Redo Log(prepare 状态,刷入磁盘)
f. 写 Binlog(刷入磁盘)
g. Redo Log 标记 commit 状态
h. 两阶段提交完成
3. 后台线程择机将脏页刷回磁盘(Checkpoint)
💡 要点 :重点理解两阶段提交(Redo Log prepare → Binlog → Redo Log commit),保证 Redo Log 和 Binlog 的一致性。
Q4: MySQL 支持哪些数据类型?如何选择?
答案:
| 类型 | 常用 | 选择建议 | 底层存储 |
|---|---|---|---|
| 整数 | TINYINT, INT, BIGINT | 主键推荐 BIGINT | 1/4/8 字节定长 |
| 浮点 | FLOAT, DOUBLE, DECIMAL | 金额必须用 DECIMAL | DECIMAL 按 BCD 编码,每 9 位占 4 字节 |
| 字符串 | CHAR, VARCHAR, TEXT | 定长用 CHAR,变长用 VARCHAR | VARCHAR 用 1~2 字节记录长度 + 实际内容 |
| 日期 | DATE, DATETIME, TIMESTAMP | TIMESTAMP 有 2038 年问题 | TIMESTAMP 4 字节(Unix 时间戳),DATETIME 5~8 字节 |
| 二进制 | BLOB, JSON | JSON 5.7+ 支持部分索引 | BLOB 溢出到外部页 |
DECIMAL 底层存储原理 :DECIMAL 使用 BCD(Binary-Coded Decimal) 编码,每 9 个十进制数字占用 4 字节。例如 DECIMAL(18,9) 整数部分 9 位 + 小数部分 9 位 = 共 8 字节。整数部分和小数部分分别存储,不存在浮点数的精度丢失问题。
VARCHAR 长度前缀:
- 列最大长度 ≤ 255 字节 → 用 1 字节记录长度
- 列最大长度 > 255 字节 → 用 2 字节记录长度
- 所以
VARCHAR(255)和VARCHAR(256)在存储上有本质区别
💡 要点 :
VARCHAR(255)与VARCHAR(256)存储有区别------255 以内用 1 字节记录长度,256 以上用 2 字节。
Q5: CHAR 和 VARCHAR 的区别?
答案:
| 对比项 | CHAR | VARCHAR |
|---|---|---|
| 长度 | 固定长度 | 可变长度 |
| 存储 | 不足补空格(尾部空格在比较时被忽略) | 实际长度 + 1~2 字节(长度前缀) |
| 效率 | 查询/比较更快(定长偏移量计算) | 需要先读长度前缀再定位内容 |
| 空间 | 可能浪费 | 节省空间 |
| 适用 | 身份证号、MD5 值等定长数据 | 用户名、邮箱等变长数据 |
底层存储对比:
CHAR(10)存储"abc":在 InnoDB 的 Compact 行格式下,存储为abc(不补空格,但比较时视为等长)VARCHAR(10)存储"abc":存储为[长度=3][abc],共 4 字节
行格式的影响 :InnoDB 有 4 种行格式(Compact、Redundant、Dynamic、Compressed),Compact 格式下 CHAR 在变长字段列表中也可能占用变长空间(对于多字节字符集如 UTF-8MB4,CHAR(10) 实际可能需要最多 40 字节)。
Q6: MySQL 中 NULL 和空字符串 '' 的区别?
答案:
NULL表示未知/不存在,占用额外空间(需要额外字节标记)''是一个确定的空字符串,不额外占用标记空间NULL参与运算结果仍为NULL(1 + NULL = NULL)NULL不能用=比较,必须用IS NULL/IS NOT NULL- 索引列中
NULL值可以被索引 ,但会影响聚合函数(COUNT(字段)不统计 NULL)
底层原理 :在 InnoDB 的 Compact 行格式中,每行有一个 NULL 标志位图(NULL bitmap) ,每个可以为 NULL 的列占 1 bit。对于 NULL 值,数据部分不存储任何内容,仅在 bitmap 中标记。所以 NULL 不占用数据存储空间,但占用 bitmap 空间。
索引中的 NULL :在 B+ 树中,NULL 值被视为最小值 ,存储在索引的最左端。IS NULL 查询可以使用索引。
💡 要点 :建表时尽量设置
NOT NULL DEFAULT '',避免 NULL 带来的额外存储和比较开销。
Q7: MySQL 的 SQL 语句分类?
答案:
| 分类 | 全称 | 关键字 | 作用 |
|---|---|---|---|
| DDL | Data Definition Language | CREATE, ALTER, DROP, TRUNCATE | 定义/修改表结构 |
| DML | Data Manipulation Language | INSERT, UPDATE, DELETE | 增删改数据 |
| DQL | Data Query Language | SELECT | 查询数据 |
| DCL | Data Control Language | GRANT, REVOKE | 权限控制 |
| TCL | Transaction Control Language | COMMIT, ROLLBACK, SAVEPOINT | 事务控制 |
底层区别:
- DDL :会修改数据字典(
mysql.tables、mysql.columns等系统表),InnoDB 会更新数据字典缓存(Data Dictionary Cache),并写 Binlog - DML:会写 Undo Log + Redo Log + Binlog,涉及事务
- DCL :权限变更会写入
mysql.user等权限表,同时刷新内存中的权限缓存
Q8: DROP、DELETE、TRUNCATE 的区别?
答案:
| 对比项 | DELETE | TRUNCATE | DROP |
|---|---|---|---|
| 类型 | DML | DDL | DDL |
| 回滚 | ✅ 可回滚(写 Undo Log) | ❌ 不可回滚 | ❌ 不可回滚 |
| WHERE | ✅ 支持 | ❌ 不支持 | ❌ 不支持 |
| 速度 | 慢(逐行删除,写 Undo Log) | 快(释放数据页,只保留表结构) | 最快(删除表结构和所有数据文件) |
| 自增ID | 不重置 | 重置为 1(重建 AUTO_INCREMENT 计数器) | 表都没了 |
| 触发器 | ✅ 触发 | ❌ 不触发 | ❌ 不触发 |
| 空间 | 不立即释放(可回滚) | 立即释放 | 立即释放 |
底层原理:
DELETE:逐行标记删除,写 Undo Log 用于回滚和 MVCC。删除后的空间在事务提交后被标记为可复用,但不会立即归还操作系统 (除非innodb_file_per_table=1且执行OPTIMIZE TABLE)TRUNCATE:实际上是删除并重建表 。InnoDB 先删除旧的表空间文件(.ibd),再创建一个新的空表空间。自增计数器写入系统表mysql.innodb_table_statsDROP:删除表的数据文件(.ibd)、结构文件(.frm)以及数据字典中的元数据
💡 要点 :
TRUNCATE TABLE t等价于DELETE FROM t但更快且重置自增,不写 Undo Log。
Q9: MySQL 中 COUNT(*)、COUNT(1)、COUNT(字段) 的区别?
答案:
COUNT(*):统计所有行,包括 NULL 行,InnoDB 会选最小的索引树遍历COUNT(1):效果与COUNT(*)基本相同,MySQL 优化器会等价处理COUNT(字段):只统计该字段非 NULL 的行数
底层原理 :InnoDB 的 COUNT(*) 实现:
- 优化器选择最小的辅助索引(而非主键索引),因为辅助索引的 B+ 树更小
- 遍历该索引的叶子节点链表,逐页读取并计数
- 不读取数据行,只统计索引记录数
- 时间复杂度 O(N),N 为索引记录数
为什么不存储行数? MyISAM 将表的总行数存储在表的元数据中,所以 COUNT(*) 是 O(1)。但 InnoDB 支持 MVCC,不同事务看到的行数可能不同(因为有未提交的删除/插入),所以无法维护一个全局行数。
💡 要点 :InnoDB 中
COUNT(*)并不读取所有列,而是遍历最小的辅助索引树,效率很高。
Q10: MySQL 中 IN 和 EXISTS 的区别?
答案:
IN:先执行子查询得到结果集,再在外层查询中匹配。适合子查询结果集小的场景EXISTS:对外层查询的每一行,执行一次子查询判断是否存在。适合外层表小的场景
sql
-- IN:先子查询再匹配
SELECT * FROM A WHERE id IN (SELECT id FROM B);
-- 底层:先执行 SELECT id FROM B 得到结果集,再对 A 做等值匹配
-- EXISTS:逐行判断
SELECT * FROM A WHERE EXISTS (SELECT 1 FROM B WHERE B.id = A.id);
-- 底层:遍历 A 的每一行,对每行执行子查询(相关子查询)
底层优化 :MySQL 优化器会将某些 IN 子查询转换为 semi-join (半连接),性能接近 EXISTS。转换策略包括:Materialization(物化)、LooseScan(松散扫描)、DuplicateWeedout(去重)等。
💡 要点:MySQL 优化器会自动选择更优的方式,但理解原理有助于在复杂场景下手动优化。
Q11: UNION 和 UNION ALL 的区别?
答案:
UNION:合并结果集并去重排序,需要临时表和排序,性能较低UNION ALL:合并结果集,不去重不排序,性能更高
底层原理 :UNION 的执行过程:
- 执行两个子查询
- 将结果写入临时表(Temporary Table)
- 对临时表做
GROUP BY或排序去重 - 返回去重后的结果
UNION ALL 直接将两个结果集拼接返回,不需要临时表和去重操作。
💡 要点 :如果业务上确定没有重复数据,优先使用
UNION ALL。
Q12: MySQL 中 LIMIT offset, size 的工作原理?
答案 :
MySQL 的 LIMIT offset, size 会先扫描 offset + size 行,然后丢弃前 offset 行。
底层原理:
- 在 InnoDB 中,
LIMIT 1000000, 10会从 B+ 树的叶子节点链表头开始,逐行扫描 1000010 行 - 前 1000000 行虽然被丢弃,但已经发生了磁盘 I/O(如果是范围扫描)
- 如果是覆盖索引,扫描的是索引页;否则需要回表
优化方案:
sql
-- 方案1:基于游标(推荐)
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10;
-- 底层:直接定位到 id=1000000 的位置,只扫描 10 行
-- 方案2:延迟关联
SELECT * FROM orders t
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) tmp
ON t.id = tmp.id;
-- 底层:子查询只扫描主键索引(覆盖索引),再通过主键回表取 10 行
Q13: MySQL 的默认端口号是多少?
答案 :默认端口 3306 。可以在 my.cnf(Linux)或 my.ini(Windows)中修改。连接时使用 TCP/IP 协议,也可以使用 Unix Socket 本地连接(跳过网络层,性能更高)。
底层连接流程:
- 客户端发起 TCP 连接到 3306 端口
- MySQL 的主线程(main thread) 接受连接
- 从线程池中分配一个工作线程(或创建新线程)
- 工作线程进行握手认证(Challenge-Response 机制,基于 SHA256)
- 认证成功后,客户端发送 SQL,工作线程处理
Q14: MySQL 中 FLOAT、DOUBLE、DECIMAL 的区别?
答案:
FLOAT:4 字节,精度约 7 位,有精度丢失(IEEE 754 单精度浮点数)DOUBLE:8 字节,精度约 15 位,有精度丢失(IEEE 754 双精度浮点数)DECIMAL:精确存储,由用户指定精度(如DECIMAL(10,2)),无精度丢失
底层原理:
FLOAT/DOUBLE使用 IEEE 754 标准,用二进制近似表示十进制小数,某些十进制小数(如 0.1)无法精确表示DECIMAL使用 BCD 编码 ,每 9 个十进制数字占 4 字节,整数部分和小数部分分别存储。例如DECIMAL(10,2)存储12345678.90需要 5 字节(整数部分 8 位占 4 字节,小数部分 2 位占 4 字节,但因为 2 < 9 所以只需 1 字节)
💡 要点 :涉及金额计算必须用 DECIMAL,绝对不能用 FLOAT/DOUBLE。
Q15: MySQL 如何查看当前连接数和最大连接数?
答案:
sql
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
-- 查看最大连接数
SHOW VARIABLES LIKE 'max_connections';
-- 查看当前所有连接
SHOW PROCESSLIST;
-- 查看线程缓存命中率
SHOW STATUS LIKE 'Threads_created';
-- Threads_created / Connections 越低越好,说明线程复用率高
底层原理 :每个 MySQL 连接对应一个操作系统线程。连接过多会导致:
- 线程切换开销增大(CPU 调度成本)
- 每个线程至少占用
sort_buffer_size + join_buffer_size + read_buffer_size等内存 - 超过
max_connections后新连接被拒绝
thread_cache_size:线程缓存,连接断开后线程不销毁而是放入缓存,新连接复用。
二、存储引擎
Q16: MySQL 有哪些存储引擎?
答案:
| 引擎 | 事务 | 锁 | 外键 | 索引 | 适用场景 |
|---|---|---|---|---|---|
| InnoDB(默认) | ✅ | 行级锁 | ✅ | 聚簇索引 | OLTP、高并发写入 |
| MyISAM | ❌ | 表级锁 | ❌ | 非聚簇索引 | 读多写少、数据仓库 |
| Memory | ❌ | 表级锁 | ❌ | Hash 索引(默认) | 临时表、缓存 |
| CSV | ❌ | 表级锁 | ❌ | ❌ | 数据导入导出 |
| Archive | ❌ | 表级锁 | ❌ | ❌ | 归档数据(只支持 INSERT 和 SELECT) |
| NDB | ✅ | 行级锁 | ✅ | Hash 索引 | 集群、高可用 |
💡 要点:MySQL 5.5 起默认引擎从 MyISAM 切换为 InnoDB。
Q17: InnoDB 和 MyISAM 的核心区别?
答案:
| 对比项 | InnoDB | MyISAM |
|---|---|---|
| 事务 | ✅ 支持 ACID(基于 Undo/Redo Log) | ❌ 不支持 |
| 锁粒度 | 行级锁(锁索引记录) | 表级锁(锁整个 .MYD 文件) |
| 外键 | ✅ 支持(在引擎层维护引用完整性) | ❌ 不支持 |
| 崩溃恢复 | ✅ Redo Log + Checkpoint 保证 | ❌ 无保证(可能数据损坏,需要 REPAIR TABLE) |
| MVCC | ✅ 支持(通过 Undo Log 版本链) | ❌ 不支持 |
| 全文索引 | ✅(5.6+,使用倒排索引 + FTS Auxiliary Table) | ✅(内置 FULLTEXT) |
| 存储文件 | .frm(表结构)+ .ibd(数据+索引) |
.frm + .MYD(数据)+ .MYI(索引) |
| COUNT(*) | 需遍历索引树(O(N)) | 有变量存储行数,O(1) |
| 哈希索引 | 自适应哈希索引(Adaptive Hash Index,自动维护) | ❌ |
聚簇索引 vs 非聚簇索引的文件层面区别:
- InnoDB :数据和主键索引存储在同一个 .ibd 文件中,叶子节点就是数据行
- MyISAM :数据在
.MYD文件,索引在.MYI文件。索引叶子节点存储的是数据行的物理地址(偏移量)
Q18: 什么是聚簇索引和非聚簇索引?
答案:
聚簇索引(Clustered Index):
- 数据行和索引存储在一起,叶子节点直接包含完整的数据行
- 一张表只能有一个聚簇索引(即主键索引)
- InnoDB 的主键索引就是聚簇索引
- 如果没有定义主键,InnoDB 会选择一个唯一的非空索引 作为聚簇索引;如果也没有,则隐式创建一个 6 字节的
ROW_ID作为聚簇索引
非聚簇索引(Secondary Index / 辅助索引):
- 索引和数据分开存储
- 叶子节点存储的是主键值 (而非数据行地址),需要回表查询
- MyISAM 的所有索引都是非聚簇的(叶子节点存物理地址)
底层结构:
InnoDB 聚簇索引 B+ 树:
根节点 → [主键值, 子节点指针]
叶子节点 → [主键值, 完整数据行](按主键顺序物理存储)
InnoDB 辅助索引 B+ 树:
根节点 → [索引列值, 子节点指针]
叶子节点 → [索引列值, 主键值](回表:再查聚簇索引)
MyISAM 索引 B+ 树:
叶子节点 → [索引列值, 数据行物理地址](直接定位 .MYD 文件中的偏移量)
💡 要点:InnoDB 中,如果查询字段全部在辅助索引中(覆盖索引),则不需要回表。
Q19: 为什么 InnoDB 推荐使用自增主键?
答案:
- 顺序插入 :自增主键保证数据按顺序写入 B+ 树的尾部,避免频繁页分裂(Page Split)
- 空间效率:BIGINT 自增主键占 8 字节,比 UUID(36 字节)节省空间
- 索引效率:主键越小,二级索引的叶子节点存储的主键值也越小,整体索引体积更小
- 避免随机 I/O:UUID 作为主键会导致随机插入,产生大量随机磁盘 I/O
页分裂的底层过程 :
当向一个满页(16KB)插入新记录时:
- 如果插入位置在页尾 → 直接追加,无分裂
- 如果插入位置在页中间 → 需要页分裂 :
a. 分配一个新的数据页
b. 将原页的后半部分记录移动到新页
c. 更新父节点的索引(插入新的指针)
d. 如果父节点也满了,递归分裂 - 页分裂导致:随机 I/O、页内碎片、B+ 树高度增加
UUID 做主键的问题:
- UUID 是随机的,每次插入都可能插入到已有页的中间位置
- 导致频繁页分裂,随机 I/O 增加 5~10 倍
- 主键 36 字节 vs BIGINT 8 字节,二级索引体积膨胀 4.5 倍
💡 要点 :如果必须用 UUID 做主键,考虑使用有序 UUID(如 UUID v7,基于时间戳有序生成)。
Q20: InnoDB 的 Buffer Pool 有什么作用?
答案 :
Buffer Pool 是 InnoDB 最重要的内存结构,用于缓存磁盘上的数据页和索引页。
核心作用:
- 读缓存:将热点数据页加载到内存,减少磁盘 I/O
- 写缓冲:修改先在内存中完成,再通过后台线程刷回磁盘
- 使用改进的 LRU(Least Recently Used)算法管理页面淘汰
- 默认大小 128MB,生产环境建议设置为物理内存的 60%~80%
底层结构 :
Buffer Pool 由多个 Chunk 组成,每个 Chunk 大小由 innodb_buffer_pool_chunk_size 控制(默认 128MB)。Buffer Pool 内部维护两个链表:
- Free List:空闲页链表,新数据页从这里分配
- LRU List:已使用的页,按访问时间排序
- Flush List:脏页链表,记录哪些页被修改过
改进的 LRU 算法 :
传统 LRU 会被全表扫描"污染"(一次性加载大量冷数据到 Buffer Pool)。InnoDB 将 LRU 链表分为两部分:
- Young 区(热数据,约 5/8):最近频繁访问的页
- Old 区(冷数据,约 3/8):新加载的页先进入 Old 区
- 只有当 Old 区的页在被访问且间隔超过 1 秒 (
innodb_old_blocks_time)后,才会移动到 Young 区 - 全表扫描加载的页只在 Old 区,很快被淘汰,不会污染热数据
sql
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
Q21: 什么是 Change Buffer?
答案 :
Change Buffer 是 Buffer Pool 的一部分(默认占 Buffer Pool 的 25%),用于缓存对非唯一二级索引页的修改操作。
底层原理 :
当修改的数据页不在 Buffer Pool 中时:
- 不立即从磁盘读取该页(避免随机 I/O)
- 将修改操作记录到 Change Buffer(内存结构)
- Change Buffer 会定期合并(Merge) 到实际的索引页
- Merge 的触发时机:页被读取到 Buffer Pool 时、后台线程定期合并、数据库关闭时
为什么只对非唯一索引有效?
- 唯一索引插入时需要立即检查唯一性,必须读取磁盘上的索引页
- 非唯一索引不需要检查唯一性,可以延迟写入
Change Buffer 的数据持久化 :
Change Buffer 的变更记录会写入系统表空间 (ibdata1),崩溃恢复时可以重建。
💡 要点:写多读少的场景(如日志表),Change Buffer 效果最明显。如果表有大量唯一索引,Change Buffer 几乎无用。
Q22: MySQL 8.0 为什么移除了查询缓存(Query Cache)?
答案 :
查询缓存在 8.0 中被完全移除,原因:
- 命中率低:只要表有更新,该表的所有查询缓存全部失效(表级失效粒度太粗)
- 锁竞争 :查询缓存使用全局锁(
query_cache_lock),在高并发下成为瓶颈 - 维护成本高:每次查询都要检查缓存,更新时要清理缓存
- 适用场景窄 :只适用于读多写极少的场景
底层问题:
- Query Cache 以完整的 SQL 字符串作为 key(包括空格、大小写),任何微小差异都无法命中
- 缓存失效是表级别的,一行数据更新 → 整个表的缓存全部失效
- 在高并发下,Query Cache 的锁(
LOCK_query_cache)成为全局瓶颈
💡 要点:如果需要缓存,应在应用层使用 Redis 等独立缓存,而非依赖 MySQL 查询缓存。
三、索引原理与优化
Q23: 什么是索引?为什么能加快查询?
答案 :
索引是存储引擎用于快速查找数据的一种数据结构(通常是 B+ 树)。
类比:索引就像书的目录,没有目录时需要逐页翻找(全表扫描),有了目录可以直接定位页码。
原理:
- B+ 树索引将数据组织为有序的树形结构
- 从根节点到叶子节点,通常只需要 3~4 次磁盘 I/O 即可定位到亿级数据
- 叶子节点通过双向链表连接,支持高效的范围查询
底层计算:假设每个索引项占 14 字节(8 字节主键 + 6 字节指针),每个页 16KB:
- 一个非叶子节点可以容纳
16384 / 14 ≈ 1170个指针 - 三层 B+ 树可以存储:
1170 × 1170 × 16 ≈ 2190 万条记录(假设每行 1KB,每页存 16 行) - 四层 B+ 树可以存储约
1170³ × 16 ≈ 256 亿条记录
💡 要点 :索引会加速查询 但降低写入速度(每次 INSERT/UPDATE/DELETE 都要维护索引)。
Q24: 为什么 MySQL 使用 B+ 树而不是 B 树、Hash 或红黑树?
答案:
| 数据结构 | 问题 |
|---|---|
| Hash | ❌ 不支持范围查询、不支持排序、存在哈希冲突 |
| 红黑树/AVL | ❌ 树太高(二叉树),亿级数据需要 30+ 层,30+ 次磁盘 I/O |
| B 树 | ❌ 非叶子节点也存数据,导致每个节点能存的 key 更少,树更高 |
B+ 树的底层优势:
- 非叶子节点只存 key,不存数据 → 每个节点能容纳更多 key → 树更矮 → I/O 更少
- B 树非叶子节点也存数据 → 每个节点能存的 key 更少 → 树更高
- 叶子节点通过双向链表连接 → 天然支持范围查询和 ORDER BY
- B 树的范围查询需要中序遍历,可能跨层跳转
- 所有查询都走到叶子节点 → 查询性能稳定(O(logN))
- B 树可能在非叶子节点就找到数据,性能不稳定
- 节点大小对齐磁盘页(通常 16KB)→ 单次 I/O 读取一个完整节点
- 叶子节点的双向链表 → 支持倒序扫描(
ORDER BY DESC)
Hash 索引的特殊场景 :
InnoDB 有自适应哈希索引(Adaptive Hash Index, AHI) :当发现某些索引页被频繁访问时,自动在内存中构建哈希索引,将等值查询从 O(logN) 优化到 O(1)。AHI 由参数 innodb_adaptive_hash_index 控制。
💡 要点 :3~4 层的 B+ 树可以存储约 2000 万~数亿条记录。
Q25: 什么是聚簇索引的 B+ 树和辅助索引的 B+ 树?
答案:
聚簇索引(主键索引):
- 叶子节点存储完整的数据行
- 一张表只能有一个
- 按主键顺序物理存储(数据页之间通过双向链表连接)
- 数据页内部的记录按主键顺序排列,通过页目录(Page Directory) 加速页内查找
辅助索引(二级索引):
- 叶子节点存储的是主键值
- 查询时需要回表:先通过辅助索引找到主键,再通过主键索引找到完整数据
- 一张表可以有多个
页目录的底层原理 :
每个数据页内部维护一个页目录,将页内记录分成若干组(slot),每个 slot 指向该组最后一条记录。查找时先在页目录中二分查找定位 slot,再在 slot 内顺序查找。这使得页内查找从 O(N) 优化到 O(logN)。
辅助索引 B+ 树: 叶子节点 → [索引列值, 主键值]
↓ 回表(根据主键值在聚簇索引中查找)
聚簇索引 B+ 树: 叶子节点 → [主键值, 完整数据行]
Q26: 什么是覆盖索引?
答案 :
当查询所需的所有字段都包含在某个索引中时,MySQL 可以直接从索引返回结果,不需要回表查询聚簇索引。
sql
-- 索引:INDEX(city, age, name)
-- ✅ 覆盖索引(查询字段都在索引中)
SELECT city, age, name FROM users WHERE city = '北京';
-- EXPLAIN 中 Extra 显示:Using index
-- ❌ 需要回表(email 不在索引中)
SELECT city, age, name, email FROM users WHERE city = '北京';
-- EXPLAIN 中无 Using index
底层原理:覆盖索引时,InnoDB 只需要遍历辅助索引的 B+ 树叶子节点链表,不需要访问聚簇索引。省去的回表过程包括:
- 省去了在聚簇索引中定位记录的 B+ 树查找(3~4 次 I/O)
- 省去了读取完整数据行的 I/O
💡 要点 :覆盖索引是 SQL 优化的重要手段,能显著减少随机 I/O。在 EXPLAIN 中
Extra: Using index表示使用了覆盖索引。
Q27: 什么是最左前缀原则?
答案 :
联合索引 (a, b, c) 的查询遵循最左前缀匹配原则:
sql
WHERE a = 1 -- ✅ 使用索引(匹配 a)
WHERE a = 1 AND b = 2 -- ✅ 使用索引(匹配 a, b)
WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 使用索引(匹配 a, b, c)
WHERE b = 2 -- ❌ 不使用索引(跳过 a)
WHERE b = 2 AND c = 3 -- ❌ 不使用索引
WHERE a = 1 AND c = 3 -- ⚠️ 只用到 a(c 无法使用,因为 b 是范围断点)
WHERE a > 1 AND b = 2 -- ⚠️ a 用范围查询,b 不使用索引
WHERE a = 1 AND b > 2 AND c = 3 -- ⚠️ a, b 使用索引,c 不使用(b 是范围查询断点)
底层原理 :B+ 树按索引列的顺序构建:
- 先按
a排序,a相同再按b排序,b相同再按c排序 - 这意味着只有在
a确定的情况下,b才是有序的;只有在a和b都确定的情况下,c才是有序的 - 跳过
a直接查b,b在全局范围内是无序的,无法利用 B+ 树的有序性
范围查询断点 :当遇到范围查询(>、<、BETWEEN、LIKE 'xxx%')后,后续列不再有序,无法使用索引。
💡 要点:设计联合索引时,等值查询的列放前面,范围查询的列放后面。
Q28: 什么是索引下推(Index Condition Pushdown, ICP)?
答案 :
索引下推是 MySQL 5.6 引入的优化,将部分 WHERE 条件下推到存储引擎层在索引遍历时过滤,减少回表次数。
sql
-- 索引:INDEX(city, age)
SELECT * FROM users WHERE city LIKE '北%' AND age > 25;
无 ICP(MySQL 5.6 之前):
- 存储引擎根据
city LIKE '北%'在索引中找到所有匹配记录 - 对每条记录回表获取完整数据行
- 返回给 Server 层
- Server 层再过滤
age > 25
有 ICP(MySQL 5.6+):
- 存储引擎在索引中同时过滤
city LIKE '北%'和age > 25 - 只对两个条件都满足的记录回表
- 返回给 Server 层
底层实现 :ICP 将部分 WHERE 条件从 Server 层下推到存储引擎层的 row_search_mvcc() 函数中。在索引遍历时,先检查索引列上的条件,不满足则跳过,不触发回表。
💡 要点 :ICP 在 EXPLAIN 中显示
Using index condition。
Q29: 哪些情况下索引会失效?
答案(高频考点,至少记住 8 种):
| 场景 | 示例 | 底层原因 |
|---|---|---|
| ① 对索引列使用函数 | WHERE YEAR(date) = 2025 |
函数破坏了索引的有序性,B+ 树无法定位 |
| ② 对索引列做运算 | WHERE id + 1 = 10 |
同上 |
| ③ 隐式类型转换 | WHERE phone = 13800138000(phone 是 VARCHAR) |
等价于 CAST(phone AS BIGINT),触发函数 |
| ④ LIKE 以 % 开头 | WHERE name LIKE '%abc' |
B+ 树基于前缀匹配,%abc 无法利用前缀有序性 |
| ⑤ 违反最左前缀 | 联合索引 (a,b,c),查 WHERE b = 1 |
B+ 树先按 a 排序,b 全局无序 |
| ⑥ OR 条件部分无索引 | WHERE a = 1 OR b = 2(b 无索引) |
优化器判断全扫比索引合并更快 |
| ⑦ 使用 != 或 NOT IN | WHERE status != 1 |
优化器认为不等于条件选择性低,全扫更快 |
| ⑧ IS NULL / IS NOT NULL | 取决于数据分布 | 如果大部分数据为 NULL,优化器选择全扫 |
| ⑨ 字符集不匹配 | JOIN 时两表字段字符集不同(如 utf8 vs utf8mb4) | 隐式转换触发函数 |
| ⑩ 使用 ORDER BY 时索引列方向不一致 | ORDER BY a ASC, b DESC 而索引是 (a ASC, b ASC) |
5.7 之前不支持降序索引 |
💡 要点 :使用
EXPLAIN查看执行计划,type = ALL表示全表扫描,说明索引未生效。
Q30: 如何判断一条 SQL 是否使用了索引?
答案 :
使用 EXPLAIN 命令分析执行计划:
sql
EXPLAIN SELECT * FROM users WHERE city = '北京';
关键字段解读:
| 字段 | 含义 | 关注点 |
|---|---|---|
id |
查询序号 | id 相同从上到下执行,id 不同大的先执行 |
select_type |
查询类型 | SIMPLE/PRIMARY/SUBQUERY/DERIVED/UNION |
table |
访问的表 | 可能是实际表名或别名 |
type |
访问类型 | const > eq_ref > ref > range > index > ALL |
possible_keys |
可能使用的索引 | --- |
key |
实际使用的索引 | NULL 表示未使用索引 |
key_len |
索引使用的字节数 | 越短越好,可判断联合索引用了几列 |
ref |
与索引比较的列或常量 | const/列名 |
rows |
预估扫描行数 | 越小越好 |
filtered |
过滤比例 (%) | 越高越好 |
Extra |
额外信息 | 见下方 |
Extra 常见值:
Using index:覆盖索引 ✅Using where:Server 层过滤(未使用索引过滤)Using temporary:使用临时表 ⚠️(GROUP BY/DISTINCT 可能触发)Using filesort:额外排序 ⚠️(ORDER BY 未使用索引)Using index condition:索引下推 ✅Using join buffer:JOIN 无索引,使用连接缓冲 ⚠️
type 详解(从好到差):
const:主键或唯一索引等值查询,最多一行eq_ref:JOIN 时驱动表的每一行在被驱动表中通过主键/唯一索引找到一行ref:非唯一索引等值查询range:索引范围查询(>、<、BETWEEN、IN)index:全索引扫描(遍历整个索引树)ALL:全表扫描(最差)
💡 要点 :
type = ALL且rows很大时,说明全表扫描,需要优化。
Q31: 索引的类型有哪些?
答案:
| 索引类型 | 说明 | 底层实现 |
|---|---|---|
| 主键索引 | 唯一且非 NULL,每张表只能有一个 | 聚簇索引 B+ 树 |
| 唯一索引 | 值唯一,允许 NULL | 辅助索引 B+ 树 + 唯一性约束检查 |
| 普通索引 | 最基本的索引,无唯一性约束 | 辅助索引 B+ 树 |
| 联合索引 | 多列组合索引,遵循最左前缀原则 | 按列顺序排序的 B+ 树 |
| 全文索引 | 用于全文搜索(FULLTEXT) | 倒排索引(Inverted Index),使用 FTS Auxiliary Table |
| 前缀索引 | 对字符串前 N 个字符建索引 | 截断后的 B+ 树 |
| 空间索引 | 用于 GIS 地理数据 | R-Tree |
| 降序索引 | MySQL 8.0+ 支持 INDEX(a ASC, b DESC) |
B+ 树节点中存储降序排列的 key |
Q32: 什么是前缀索引?如何选择前缀长度?
答案 :
前缀索引只对字符串的前 N 个字符建立索引,减少索引空间。
sql
-- 对 email 前 10 个字符建索引
ALTER TABLE users ADD INDEX idx_email(email(10));
选择前缀长度的方法:
sql
-- 计算不同前缀长度的选择性
SELECT
COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel10,
COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS sel15,
COUNT(DISTINCT email) / COUNT(*) AS sel_full
FROM users;
-- 选择选择性接近 sel_full 的最短长度
底层限制:
- 前缀索引不能用于 ORDER BY(因为前缀可能相同,全局无序)
- 前缀索引不能用于覆盖索引(叶子节点只存前缀,不是完整值)
- 前缀索引不能用于 GROUP BY
Q33: 联合索引的列顺序如何设计?
答案 :
设计原则(按优先级):
- 等值查询的列放前面,范围查询的列放后面
- 选择性高的列放前面(区分度大的列排在前面)
- 查询频率高的列放前面
- 考虑覆盖索引的需求
sql
-- 场景:WHERE city = ? AND age > ? AND name = ?
-- 推荐索引:INDEX(city, age, name)
-- city 等值查询放最前,age 范围查询放中间,name 放最后
-- 计算选择性
SELECT
COUNT(DISTINCT city) / COUNT(*) AS city_sel,
COUNT(DISTINCT age) / COUNT(*) AS age_sel,
COUNT(DISTINCT name) / COUNT(*) AS name_sel
FROM users;
底层原理:B+ 树先按第一列排序,第一列相同再按第二列排序。等值列放前面可以精确定位到一个小范围,范围列放后面在这个小范围内做范围扫描。
Q34: 什么是索引合并(Index Merge)?
答案 :
MySQL 优化器在某些情况下会对多个索引的结果集进行合并,避免全表扫描。
三种类型:
- Index Merge Intersection(交集):多个索引取交集(AND 条件),要求每个索引都是等值查询
- Index Merge Union(并集):多个索引取并集(OR 条件)
- Index Merge Sort-Union(排序并集):先按主键排序再合并
sql
-- 如果 a 和 b 各有索引
SELECT * FROM t WHERE a = 1 OR b = 2;
-- 可能使用 Index Merge Union
-- EXPLAIN 中 type = index_merge, Extra = Using union(idx_a, idx_b)
💡 要点:Index Merge 不一定是最优的,有时联合索引效果更好(避免多次索引查找的开销)。
Q35: 如何优化 ORDER BY 语句?
答案:
- 利用索引的有序性:索引本身是有序的,如果 ORDER BY 的列在索引中,可以避免额外排序
- Using filesort 优化 :
- 增大
sort_buffer_size(默认 256KB),使排序在内存中完成 - 如果排序数据量超过
sort_buffer_size,会使用磁盘临时文件归并排序 - 使用覆盖索引减少排序字段
- 增大
- 避免
SELECT *:只选需要的列,减少排序数据量 - 联合索引优化 :
ORDER BY a, b可以利用INDEX(a, b)
filesort 的底层算法:
- 单次传输排序(推荐):一次读取所有需要的列 → 排序 → 返回结果
- 双次传输排序(旧版):第一次读取排序字段和行指针 → 排序 → 第二次按排序结果读取完整数据
sql
-- EXPLAIN 中出现 Using filesort 表示需要额外排序
-- 出现 Using index 表示使用了覆盖索引
Q36: 如何优化 GROUP BY 语句?
答案:
- GROUP BY 的列如果有索引,可以避免临时表和排序(索引本身就是有序的)
- 如果不需要排序,加
ORDER BY NULL(MySQL 5.7 之前)或ORDER BY NULL(8.0 默认不再排序) - 使用覆盖索引减少回表
- 适当增大
tmp_table_size和max_heap_table_size
底层原理:GROUP BY 的执行方式:
- 松散索引扫描(Loose Index Scan):索引列正好是 GROUP BY 的列,直接跳过重复值(最高效)
- 紧凑索引扫描(Tight Index Scan):遍历索引,逐行分组
- 临时表 + 排序:无索引时,先将数据写入临时表,再排序分组
sql
-- 避免 GROUP BY 默认排序
SELECT city, COUNT(*) FROM users GROUP BY city ORDER BY NULL;
Q37: 如何优化 JOIN 查询?
答案:
- 被驱动表的 JOIN 字段必须有索引
- 小表驱动大表:小结果集作为外层循环(驱动表)
- 避免过多 JOIN:一般不超过 3~5 张表
- 使用 STRAIGHT_JOIN 强制指定驱动表顺序
底层原理------Nested Loop Join :
MySQL 使用 Nested Loop Join(NLJ) 算法:
for each row in 驱动表:
for each row in 被驱动表 (通过索引查找):
if 匹配条件:
输出结果行
- 外层循环遍历驱动表(假设 N 行)
- 内层循环对每一行在被驱动表中通过索引查找(假设 O(logM))
- 总复杂度:O(N × logM)
Block Nested Loop Join(BNL) :
当被驱动表的 JOIN 字段没有索引时,MySQL 使用 BNL:
- 将驱动表的数据加载到 join_buffer
- 扫描被驱动表,每行与 join_buffer 中的所有行比较
- 复杂度:O(N × M)(性能极差)
Hash Join(MySQL 8.0.18+) :
对 BNL 的优化,将驱动表数据构建哈希表,被驱动表通过哈希查找匹配,复杂度 O(N + M)。
sql
-- 小表驱动大表
SELECT * FROM small_table s
INNER JOIN big_table b ON s.id = b.small_id;
-- 强制驱动表顺序
SELECT * FROM small_table s
STRAIGHT_JOIN big_table b ON s.id = b.small_id;
💡 要点 :MySQL 使用 Nested Loop Join,外层表每匹配一行,内层表就要查找一次。内层表有索引时是 O(logN),无索引是 O(N)。
Q38: 什么是回表?如何避免?
答案 :
回表:通过辅助索引查到主键后,再到聚簇索引中查找完整数据行的过程。
辅助索引 B+ 树 → [索引列值, 主键值] → 聚簇索引 B+ 树 → [主键值, 完整数据行]
回表的代价:
- 每次回表相当于一次随机 I/O(在聚簇索引中定位记录)
- 如果辅助索引返回 1000 行,就需要 1000 次随机 I/O
- 回表是 InnoDB 辅助索引查询的主要性能瓶颈
避免回表的方法:
- 覆盖索引 :查询字段全部在索引中,
Extra: Using index - 合理设计联合索引:将高频查询字段加入索引
- 减少 SELECT 的列 :避免
SELECT *
sql
-- 需要回表
SELECT * FROM users WHERE city = '北京';
-- 覆盖索引,不回表
SELECT id, city, name FROM users WHERE city = '北京';
-- 索引 INDEX(city, name)
Q39: 索引建多了有什么影响?
答案:
- 写入性能下降 :每次 INSERT/UPDATE/DELETE 都要维护所有相关索引的 B+ 树
- INSERT:可能触发页分裂、Change Buffer 合并
- UPDATE:如果修改了索引列,需要删除旧索引项 + 插入新索引项
- DELETE:标记删除 + 可能的页合并
- 存储空间增加:索引文件可能比数据文件还大
- Buffer Pool 利用率下降:更多索引页占据内存,热数据被挤出
- 优化器选择困难:索引过多可能导致优化器选错索引(统计信息不准确)
- DDL 操作变慢:ALTER TABLE 时需要重建所有索引
💡 要点 :建议单表索引不超过 5~6 个 ,联合索引列不超过 5 列。
Q40: 如何查看和管理索引?
答案:
sql
-- 查看表的索引
SHOW INDEX FROM table_name;
-- 创建索引
CREATE INDEX idx_name ON users(name);
ALTER TABLE users ADD INDEX idx_name(name);
-- 创建唯一索引
CREATE UNIQUE INDEX idx_email ON users(email);
-- 创建联合索引
ALTER TABLE users ADD INDEX idx_city_age(city, age);
-- 删除索引
DROP INDEX idx_name ON users;
ALTER TABLE users DROP INDEX idx_name;
-- 强制使用指定索引
SELECT * FROM users FORCE INDEX(idx_city) WHERE city = '北京';
-- 忽略索引
SELECT * FROM users IGNORE INDEX(idx_city) WHERE city = '北京';
-- 查看索引使用情况
SELECT * FROM sys.schema_unused_indexes; -- 未使用的索引
SELECT * FROM sys.schema_redundant_indexes; -- 冗余索引
Q41: 什么是索引的选择性?
答案 :
索引选择性 = 不重复的索引值数量 / 总记录数
- 选择性越高(越接近 1),索引的过滤效果越好
- 主键/唯一索引的选择性 = 1(最优)
- 性别字段的选择性 ≈ 0.5(效果差,不适合单独建索引)
- 城市字段的选择性可能在 0.01~0.1 之间
底层含义:选择性决定了索引能过滤掉多少数据。选择性为 0.01 的索引,每次查询平均返回总行数的 1% 数据。选择性越低,回表次数越多,优化器可能认为全扫更快。
sql
SELECT COUNT(DISTINCT city) / COUNT(*) AS selectivity FROM users;
💡 要点:选择性低的列单独建索引效果不好,但可以放在联合索引的后面。
Q42: 什么情况下使用索引反而更慢?
答案:
- 数据量很小的表(几百行),全表扫描比走索引更快(因为索引查找需要额外的 B+ 树遍历 I/O)
- 查询返回大部分数据 (如
WHERE status > 0,90% 的数据满足条件),优化器会选择全扫 - 写入远多于查询的表,索引维护成本高于收益
- 频繁更新的列,索引需要频繁删除旧项+插入新项
- 数据分布不均匀时,优化器可能误判
底层原理 :优化器使用代价模型评估执行计划:
- 全表扫描代价 = 表的数据页数 × 页读取代价
- 索引查找代价 = 索引树遍历代价 + 回表次数 × 回表代价
- 当回表次数接近总行数时,索引查找代价 > 全表扫描代价
Q43: 如何用 EXPLAIN 分析执行计划?各字段含义?
答案:
| 字段 | 含义 | 重点关注 |
|---|---|---|
id |
查询序号 | id 相同从上到下执行,id 不同大的先执行 |
select_type |
查询类型 | SIMPLE/PRIMARY/SUBQUERY/DERIVED/UNION |
table |
访问的表 | --- |
partitions |
匹配的分区 | 未分区表为 NULL |
type |
访问类型 | const > eq_ref > ref > range > index > ALL |
possible_keys |
可能用到的索引 | --- |
key |
实际用到的索引 | NULL = 没用索引 |
key_len |
索引使用的字节数 | 越短越好,可判断联合索引用了几列 |
ref |
与索引比较的列或常量 | const/列名 |
rows |
预估扫描行数 | 越小越好 |
filtered |
过滤比例 (%) | 越高越好 |
Extra |
额外信息 | 见下方 |
key_len 计算规则(判断联合索引用了几列):
- INT:4 字节
- BIGINT:8 字节
- VARCHAR(n):n × 字符集字节数 + 2(长度前缀)
- 允许 NULL:+ 1 字节
- 例如:
INDEX(a INT, b VARCHAR(20) CHARSET utf8mb4),key_len = 4 + 20×4+2+1 = 87表示 a 和 b 都用到了
Extra 常见值:
Using index:覆盖索引 ✅Using where:Server 层过滤Using temporary:使用临时表 ⚠️Using filesort:额外排序 ⚠️Using index condition:索引下推 ✅Using join buffer:JOIN 无索引 ⚠️
Q44: 如何优化 COUNT(*) 查询?
答案 :
InnoDB 的 COUNT(*) 需要遍历索引树统计行数,没有 MyISAM 那样的 O(1) 优化。
底层原因:InnoDB 支持 MVCC,不同事务在同一时刻看到的行数可能不同(例如事务 A 删除了一行但未提交,事务 B 看到的行数比事务 A 多一行),所以无法维护一个全局行数。
优化方案:
- 使用辅助索引而非主键索引(辅助索引更小,遍历更快)
- 维护计数表:用触发器或应用层维护行数
- 使用近似值 :
SHOW TABLE STATUS LIKE 'table_name'中的 Rows 字段(不精确,基于采样估算) - Redis 缓存计数:适合对实时性要求不高的场景
sql
-- 计数表示例
CREATE TABLE table_counts (
table_name VARCHAR(64) PRIMARY KEY,
row_count BIGINT
);
-- 使用触发器同步
CREATE TRIGGER trg_insert AFTER INSERT ON users FOR EACH ROW
UPDATE table_counts SET row_count = row_count + 1 WHERE table_name = 'users';
Q45: 什么情况下不推荐使用索引?
答案:
- 表数据量很小(< 几百行)
- 频繁大量写入、极少查询的表
- 选择性极低的字段(如性别,只有男/女)
- 已有覆盖索引可以满足查询需求
- 查询条件返回表中大部分数据
- 表经常被 TRUNCATE/重建
四、事务与ACID
Q46: 什么是事务?ACID 特性分别是什么?
答案 :
事务是一组不可分割的数据库操作序列,要么全部成功,要么全部失败。
| 特性 | 全称 | 含义 | 实现机制 |
|---|---|---|---|
| A | Atomicity(原子性) | 要么全做,要么全不做 | Undo Log |
| C | Consistency(一致性) | 事务前后数据保持一致 | 由 AID 共同保证 |
| I | Isolation(隔离性) | 并发事务互不干扰 | 锁 + MVCC |
| D | Durability(持久性) | 提交后数据永久保存 | Redo Log |
底层实现详解:
- 原子性 :事务执行前,先将旧值写入 Undo Log。事务回滚时,根据 Undo Log 恢复数据。Undo Log 是一个链表,每个 Undo Log 记录指向前一个版本。
- 一致性 :一致性是目的,由原子性、隔离性、持久性共同保证。数据库的完整性约束(主键、外键、唯一性、CHECK)也保证一致性。
- 隔离性 :通过 锁 控制写-写冲突,通过 MVCC 控制读-写冲突。不同隔离级别使用不同的锁策略和 ReadView 策略。
- 持久性 :事务提交前,Redo Log 必须先于数据页刷入磁盘(WAL 机制)。即使数据页尚未刷盘就崩溃,重启后通过 Redo Log 重放恢复。
💡 要点 :一致性是目的 ,原子性、隔离性、持久性是手段。
Q47: MySQL 的四种事务隔离级别分别是什么?
答案:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| 读未提交 (Read Uncommitted) | ✅ | ✅ | ✅ | 不加锁,直接读最新数据 |
| 读已提交 (Read Committed) | ❌ | ✅ | ✅ | MVCC(每次 SELECT 创建新 ReadView) |
| 可重复读 (Repeatable Read) | ❌ | ❌ | ⚠️ | MVCC(事务首次 SELECT 创建 ReadView)+ Gap Lock |
| 串行化 (Serializable) | ❌ | ❌ | ❌ | 所有读加 S 锁,所有写加 X 锁 |
💡 要点 :MySQL 默认 RR 级别,InnoDB 通过 Next-Key Lock(行锁+间隙锁) 在 RR 级别下解决了大部分幻读问题。
Q48: 什么是脏读、不可重复读、幻读?
答案:
| 问题 | 描述 | 示例 |
|---|---|---|
| 脏读 | 读到了其他事务未提交的数据 | 事务 A 修改了数据但未提交,事务 B 读到了修改后的值,事务 A 回滚 → 事务 B 读到的数据是无效的 |
| 不可重复读 | 同一事务内,两次读取同一行数据结果不同 | 事务 A 第一次读 age=20,事务 B 修改 age=25 并提交,事务 A 再次读变成 25 |
| 幻读 | 同一事务内,两次查询返回的行数不同 | 事务 A 第一次查有 3 行,事务 B 插入 1 行并提交,事务 A 再次查有 4 行 |
底层区分:
- 不可重复读侧重更新(UPDATE):其他事务修改了已读取的行
- 幻读侧重插入/删除(INSERT/DELETE):其他事务插入了满足查询条件的新行
- 在 InnoDB RR 级别下:
- 不可重复读通过 MVCC ReadView 解决(事务内 ReadView 不变)
- 幻读通过 Next-Key Lock 解决(锁住间隙,阻止插入)
Q49: MySQL 如何在可重复读级别下解决幻读?
答案 :
InnoDB 在 RR 级别下通过两种机制解决幻读:
1. MVCC(快照读):
- 事务开始时创建 ReadView,后续的普通 SELECT 读取快照版本
- 其他事务的插入/修改对当前事务不可见
- 保证了快照读场景下不会出现幻读
2. Next-Key Lock(当前读):
- 对于
SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE等当前读操作 - 使用 Next-Key Lock(行锁 + 间隙锁)锁住范围
- 阻止其他事务在锁定范围内插入新行
sql
-- 快照读:不加锁,读历史版本
SELECT * FROM users WHERE age > 20;
-- 底层:通过 MVCC ReadView 读取事务开始时的快照
-- 当前读:加锁,读最新版本
SELECT * FROM users WHERE age > 20 FOR UPDATE;
-- 底层:加 Next-Key Lock,锁住 age > 20 的所有间隙
幻读的特殊场景 :
即使在 RR 级别下,如果事务中先快照读再当前读,仍可能出现幻读:
sql
BEGIN;
SELECT * FROM t WHERE id = 5; -- 快照读,无结果
-- 此时另一个事务插入 id=5 并提交
INSERT INTO t VALUES (5, 'test'); -- 当前读,成功插入(但此时 id=5 已存在)
SELECT * FROM t WHERE id = 5; -- 快照读,看到自己插入的
-- 如果想要完全避免幻读,需要使用 SELECT ... FOR UPDATE 加锁
Q50: 事务隔离级别如何设置?
答案:
sql
-- 查看当前隔离级别
SELECT @@transaction_isolation; -- MySQL 8.0
SELECT @@tx_isolation; -- MySQL 5.7
-- 设置全局隔离级别(对新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 设置当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置下一个事务的隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
底层原理:
- 全局隔离级别存储在系统变量
transaction_isolation中 - 会话级别覆盖全局级别
- 设置后,当前事务的锁策略和 MVCC ReadView 创建策略随之改变
Q51: 事务的实现原理是什么?
答案 :
事务的 ACID 特性通过以下机制实现:
| 特性 | 实现机制 | 底层细节 |
|---|---|---|
| 原子性 | Undo Log | 记录数据修改前的值,回滚时恢复。Undo Log 是链表结构,每个记录指向前一个版本 |
| 持久性 | Redo Log | 记录数据修改后的值(物理日志),崩溃后重放恢复。使用 WAL 机制 |
| 隔离性 | 锁 + MVCC | 行锁控制写并发,MVCC(ReadView + 版本链)控制读并发 |
| 一致性 | 由以上三者共同保证 | 完整性约束(主键、外键、唯一性)也保证一致性 |
Q52: MySQL 事务是如何提交的?什么是两阶段提交?
答案 :
InnoDB 的事务提交采用两阶段提交(Two-Phase Commit, 2PC),保证 Redo Log 和 Binlog 的一致性:
阶段1(Prepare):
1. 事务执行:修改 Buffer Pool 中的数据页(脏页)
2. 写 Redo Log Buffer
3. 将 Redo Log 刷入磁盘(fsync),标记为 prepare 状态
阶段2(Commit):
4. 写 Binlog 刷入磁盘(fsync)
5. 在 Redo Log 中写入 commit 标记
6. 事务提交完成
崩溃恢复的判断逻辑:
- Redo Log 有 prepare 且有 commit → 事务已提交 ✅
- Redo Log 有 prepare 但无 commit → 检查 Binlog 是否完整:
- Binlog 完整 → 事务已提交 ✅(补写 commit)
- Binlog 不完整 → 事务回滚 ❌
为什么需要两阶段提交?
- 如果只写 Redo Log 不写 Binlog → 主库崩溃恢复了数据,但从库没有 Binlog → 主从不一致
- 如果只写 Binlog 不写 Redo Log → 崩溃后从库有数据,但主库丢失了 → 数据丢失
Q53: 事务中可以混合使用 InnoDB 和 MyISAM 表吗?
答案 :
可以,但不推荐。混合使用时:
- MyISAM 表的操作不受事务保护,无法回滚
- 如果事务中 MyISAM 表的操作已提交而 InnoDB 表回滚,会导致数据不一致
- MyISAM 不支持行锁,混合使用时可能引入锁冲突
Q54: 如何在事务中创建保存点(SAVEPOINT)?
答案:
sql
START TRANSACTION;
INSERT INTO users VALUES (1, 'Alice');
SAVEPOINT sp1;
INSERT INTO users VALUES (2, 'Bob');
ROLLBACK TO sp1; -- 只回滚到 sp1,保留 Alice 的插入
COMMIT; -- 最终提交 Alice 的数据
底层原理 :SAVEPOINT 在 Undo Log 中创建一个标记点。ROLLBACK TO sp1 时,只回滚 sp1 之后的 Undo Log 记录,不回滚 sp1 之前的。
Q55: 事务长时间不提交会怎样?
答案:
- 锁长时间持有:其他事务可能被阻塞,甚至死锁
- Undo Log 膨胀:MVCC 需要保留旧版本(被其他事务的 ReadView 引用),导致 undo 表空间增长
- 连接被占用:连接池资源耗尽
- Purge 延迟:后台 Purge 线程无法清理旧版本数据
sql
-- 查看长时间运行的事务
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;
-- 查看 undo 表空间大小
SELECT NAME, FILE_SIZE, ALLOCATED_SIZE FROM information_schema.INNODB_TABLESPACES
WHERE NAME LIKE '%undo%';
💡 要点 :生产环境应设置
innodb_lock_wait_timeout(默认 50 秒)和wait_timeout(默认 8 小时)。
Q56: autocommit 是什么?如何关闭?
答案 :
autocommit = 1(默认)表示每条 SQL 自动提交,单独构成一个事务。
sql
-- 查看 autocommit 状态
SELECT @@autocommit;
-- 关闭 autocommit
SET autocommit = 0;
-- 之后需要手动 COMMIT 或 ROLLBACK
-- 显式开启事务(推荐)
START TRANSACTION; -- 或 BEGIN
-- ... SQL 操作 ...
COMMIT;
底层原理:
autocommit = 1时,每条 DML 语句执行后自动调用trx_commit()提交START TRANSACTION会临时关闭 autocommit,直到 COMMIT/ROLLBACK- 某些语句会隐式提交当前事务:DDL(CREATE/ALTER/DROP)、LOCK TABLES、SET AUTOCOMMIT=1 等
Q57: 如何查看当前有哪些事务在运行?
答案:
sql
-- 查看当前运行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看事务持有的锁(MySQL 8.0)
SELECT * FROM performance_schema.data_locks;
-- 查看锁等待关系(MySQL 8.0)
SELECT * FROM performance_schema.data_lock_waits;
-- 查看当前连接和正在执行的 SQL
SHOW PROCESSLIST;
SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep';
-- 综合查看:哪些事务在等待锁
SELECT
r.trx_id AS waiting_trx,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
Q58: 什么是分布式事务?MySQL 支持吗?
答案 :
分布式事务是指跨越多个数据库实例或服务的事务,需要保证所有参与者要么全部提交,要么全部回滚。
MySQL 支持 XA 分布式事务(两阶段提交协议):
sql
-- 两阶段提交示例
XA START 'xid1';
INSERT INTO orders VALUES (1, 'test');
XA END 'xid1';
XA PREPARE 'xid1';
-- 此时事务处于 prepared 状态,等待协调者指令
XA COMMIT 'xid1'; -- 或 XA ROLLBACK 'xid1';
XA 事务的底层实现:
XA PREPARE:InnoDB 将事务标记为 prepared 状态,写 Redo Log(prepare)XA COMMIT:写 Redo Log(commit)+ Binlog- 崩溃恢复时,prepared 状态的 XA 事务会被恢复
💡 要点 :XA 事务性能较差(锁持有时间长),实际项目中更多使用 TCC (Try-Confirm-Cancel)、SAGA 等柔性事务方案。
五、锁机制与并发控制
Q59: MySQL 有哪些锁?
答案:
| 锁类型 | 粒度 | 说明 |
|---|---|---|
| 共享锁(S Lock) | 行级 | 读锁,多个事务可同时持有 |
| 排他锁(X Lock) | 行级 | 写锁,与任何锁互斥 |
| 意向共享锁(IS) | 表级 | 表示事务打算在表中加行级 S 锁 |
| 意向排他锁(IX) | 表级 | 表示事务打算在表中加行级 X 锁 |
| 间隙锁(Gap Lock) | 行间 | 锁住索引记录之间的"间隙",防止幻读 |
| 临键锁(Next-Key Lock) | 行+间隙 | 行锁 + 间隙锁的组合,InnoDB RR 级别默认使用 |
| 插入意向锁 | 行间 | 插入操作在间隙锁之前的特殊锁 |
| 自增锁(AUTO-INC) | 表级 | 自增列插入时使用 |
| 元数据锁(MDL) | 表级 | DML 加读锁,DDL 加写锁 |
锁兼容矩阵:
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | ✅ | ✅ | ✅ | ❌ |
| IX | ✅ | ✅ | ❌ | ❌ |
| S | ✅ | ❌ | ✅ | ❌ |
| X | ❌ | ❌ | ❌ | ❌ |
💡 要点:意向锁是表级锁,用于快速判断表中是否有行锁,避免逐行检查。意向锁之间不互斥,只与 S 锁和 X 锁互斥。
Q60: 共享锁和排他锁的区别?
答案:
| 对比项 | 共享锁(S) | 排他锁(X) |
|---|---|---|
| 兼容 S 锁 | ✅ 兼容 | ❌ 互斥 |
| 兼容 X 锁 | ❌ 互斥 | ❌ 互斥 |
| 使用场景 | SELECT ... LOCK IN SHARE MODE |
SELECT ... FOR UPDATE、UPDATE、DELETE |
sql
-- 加共享锁(MySQL 8.0 语法)
SELECT * FROM users WHERE id = 1 FOR SHARE;
-- 旧语法
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- 加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
底层实现 :InnoDB 在内存中维护一个锁管理器(Lock Manager) ,使用锁表(Lock Table) 数据结构。每个锁记录包含:事务 ID、锁类型(S/X)、锁定的记录/间隙信息。加锁时检查锁兼容性,不兼容则等待。
Q61: InnoDB 的行锁是锁什么?
答案 :
InnoDB 的行锁锁的是索引记录(Index Record),而不是数据行(Data Row)。
- 如果 SQL 语句有走索引:锁住命中的索引记录
- 如果 SQL 语句没有走索引 :退化为表锁(锁住聚簇索引的所有记录)
- 如果使用了多个索引:InnoDB 会同时在多个索引上加锁
底层原理 :
InnoDB 的锁信息存储在内存中的锁表(Lock Table) 中,而不是数据页中。锁表使用哈希结构,key 是 (space_id, page_no, heap_no) 的组合,value 是锁链表。每个被锁定的记录在锁表中有一个对应的锁记录。
锁表结构:
Hash Table
┌──────────────────────┐
│ (space, page, heap) → [事务A: X锁] → [事务B: S锁] │
│ (space, page, heap) → [事务C: Gap锁] │
└──────────────────────┘
💡 要点:即使表没有索引,InnoDB 也会使用隐藏的聚簇索引加锁,但效果等同于锁全表。
Q62: 什么是间隙锁(Gap Lock)?
答案 :
间隙锁锁住的是索引记录之间的间隙 (不包括记录本身),目的是防止其他事务在间隙中插入数据(防止幻读)。
sql
-- 假设表中有 id = 1, 5, 10 的记录
-- 事务 A 执行:
SELECT * FROM t WHERE id > 5 AND id < 10 FOR UPDATE;
-- 会锁住间隙 (5, 10),其他事务不能在这个范围插入数据
-- 其他事务尝试插入:
INSERT INTO t VALUES (7, 'test'); -- 被阻塞!
INSERT INTO t VALUES (12, 'test'); -- 成功(不在间隙内)
底层原理 :
间隙锁在锁表中记录的是间隙范围 ,而非具体记录。间隙锁之间不互斥 (两个事务可以同时持有同一个间隙锁),但间隙锁与插入意向锁互斥(插入操作需要先获取插入意向锁)。
间隙锁的范围:
- RR 隔离级别下,默认使用 Next-Key Lock(行锁 + 间隙锁)
- RC 隔离级别下,没有间隙锁
- 唯一索引的等值查询:退化为行锁(不需要间隙锁,因为不可能插入重复值)
💡 要点 :间隙锁只在 RR 隔离级别 下生效。间隙锁之间不互斥,但与插入意向锁互斥。
Q63: 什么是 Next-Key Lock?
答案 :
Next-Key Lock = 行锁 + 间隙锁,是 InnoDB 在 RR 隔离级别下的默认锁类型。
sql
-- 假设有索引值 1, 5, 10
-- Next-Key Lock 锁住的范围(左开右闭):
-- (-∞, 1], (1, 5], (5, 10], (10, +∞)
-- 查询 id = 5 FOR UPDATE
-- 锁住 (1, 5] 这个区间
-- 其他事务不能插入 id = 2, 3, 4, 5
Next-Key Lock 的退化规则:
- 唯一索引 + 等值查询 → 退化为行锁(不需要间隙锁)
- 唯一索引 + 范围查询 → 使用 Next-Key Lock
- 非唯一索引 + 等值查询 → 使用 Next-Key Lock + Gap Lock
- 非唯一索引 + 范围查询 → 使用 Next-Key Lock
💡 要点 :Next-Key Lock 是左开右闭区间。唯一索引等值查询时退化为行锁是重要优化。
Q64: 什么是死锁?如何避免?
答案 :
死锁:两个或多个事务互相等待对方持有的锁,导致都无法继续执行。
事务A:锁住行1 → 请求行2(等待事务B释放)
事务B:锁住行2 → 请求行1(等待事务A释放)
→ 死锁!
InnoDB 的死锁检测:
- InnoDB 维护一个等待图(Wait-for Graph)
- 每次加锁等待时,检查等待图中是否存在环路
- 如果存在环路 → 死锁 → 选择回滚代价最小的事务(undo 量最少的)
- 检测频率由
innodb_deadlock_detect(默认 ON)控制 - 高并发下死锁检测本身也有 CPU 开销,可以设置
innodb_lock_wait_timeout作为兜底
避免死锁的方法:
- 按固定顺序访问表和行(避免交叉等待)
- 缩小事务范围,减少锁持有时间
- 使用合理的索引,避免锁升级为表锁
- 设置锁等待超时
innodb_lock_wait_timeout(默认 50 秒) - 避免大事务,拆分为多个小事务
- 使用 乐观锁 替代悲观锁(版本号机制)
Q65: 如何查看当前的锁信息?
答案:
sql
-- MySQL 8.0
-- 查看当前持有的锁
SELECT * FROM performance_schema.data_locks;
-- 查看锁等待关系
SELECT * FROM performance_schema.data_lock_waits;
-- 查看当前运行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看正在等待锁的事务(sys schema)
SELECT * FROM sys.innodb_lock_waits;
-- MySQL 5.7
SELECT * FROM information_schema.INNODB_LOCKS;
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 查看 InnoDB 锁的状态
SHOW ENGINE INNODB STATUS\G
-- 找到 TRANSACTIONS 和 LATEST DETECTED DEADLOCK 部分
Q66: SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE 有什么区别?
答案:
| 对比项 | FOR UPDATE | FOR SHARE (LOCK IN SHARE MODE) |
|---|---|---|
| 锁类型 | 排他锁(X) | 共享锁(S) |
| 其他事务读(快照读) | ✅ 不受影响 | ✅ 不受影响 |
| 其他事务加 S 锁 | ❌ 被阻塞 | ✅ 兼容 |
| 其他事务加 X 锁 | ❌ 被阻塞 | ❌ 被阻塞 |
| 使用场景 | 需要修改数据时 | 只需要读取并保证数据不被修改 |
NOWAIT 和 SKIP LOCKED(MySQL 8.0+):
sql
-- 不等待,立即报错
SELECT * FROM users WHERE id = 1 FOR UPDATE NOWAIT;
-- 跳过已锁定的行
SELECT * FROM users FOR UPDATE SKIP LOCKED;
Q67: InnoDB 的锁升级是怎么回事?
答案 :
InnoDB 不存在锁升级。与 SQL Server 不同,InnoDB 的行锁是逐行加的,不会因为锁的数量太多而升级为表锁。
但需要注意:
- 如果 SQL 没有使用索引,行锁会退化为表锁(因为锁住了聚簇索引的所有记录)
- 这不是"锁升级",而是"无索引导致的表锁"
与 SQL Server 的对比 :
SQL Server 在单个事务持有的行锁超过阈值(默认 5000)时,会自动将行锁升级为表锁。InnoDB 不会这样做,因为行锁的信息存储在内存的锁表中,不占用数据页空间。
Q68: 乐观锁和悲观锁的区别?
答案:
| 对比项 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现方式 | 版本号/CAS 机制(应用层实现) | 数据库锁(S/X 锁,数据库实现) |
| 并发性能 | 高(不阻塞) | 低(阻塞等待) |
| 适用场景 | 读多写少、冲突少 | 写多、冲突多 |
| 失败处理 | 重试 | 等待或回滚 |
| 死锁 | 不会(不使用数据库锁) | 可能 |
sql
-- 乐观锁示例(版本号机制)
-- 查询时获取版本号
SELECT stock, version FROM goods WHERE id = 1001;
-- 返回 stock=100, version=1
-- 更新时检查版本号
UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND version = 1;
-- 影响行数为 0 表示被其他事务修改过,需要重试
-- 悲观锁示例
START TRANSACTION;
SELECT * FROM goods WHERE id = 1001 FOR UPDATE; -- 加 X 锁
UPDATE goods SET stock = stock - 1 WHERE id = 1001;
COMMIT;
Q69: 什么情况下会触发表锁?
答案:
- MyISAM 引擎:所有操作都是表锁
- InnoDB 无索引的 UPDATE/DELETE:行锁退化为表锁(锁住聚簇索引的所有记录)
- DDL 操作(ALTER TABLE、DROP TABLE):加表级元数据锁(MDL 写锁)
- LOCK TABLES 显式加表锁
- FLUSH TABLES WITH READ LOCK:全局读锁
sql
-- 显式加表锁
LOCK TABLES users READ; -- 读锁(所有事务可读,不可写)
LOCK TABLES users WRITE; -- 写锁(仅当前事务可读写)
UNLOCK TABLES;
Q70: 什么是元数据锁(MDL)?
答案 :
元数据锁(Metadata Lock)是 MySQL 5.5 引入的,在 Server 层实现(不是 InnoDB 层):
- DML 操作 (SELECT/INSERT/UPDATE/DELETE)自动加MDL 读锁
- DDL 操作 (ALTER/DROP)自动加MDL 写锁
- MDL 读锁之间兼容,MDL 读写互斥,MDL 写锁之间互斥
经典问题------MDL 导致的阻塞链:
1. 长事务执行 SELECT(持有 MDL 读锁)
2. DDL 操作 ALTER TABLE(等待 MDL 写锁,被长事务阻塞)
3. 后续所有 SELECT/INSERT/UPDATE/DELETE(等待 MDL 读锁,被 DDL 的写锁请求阻塞)
→ 整个表不可用!
💡 要点 :线上执行 DDL 前,先检查是否有长事务:
SHOW PROCESSLIST。MySQL 8.0 引入了ALTER TABLE ... WAIT N语法,可以等待 N 秒获取 MDL 锁。
六、MVCC机制
Q71: 什么是 MVCC?
答案 :
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发的核心机制。
核心思想 :每个数据行保留多个历史版本 ,读操作读取旧版本(快照读),写操作创建新版本,读写互不阻塞。
实现三要素:
- 隐藏字段 :每行数据有
DB_TRX_ID(最后修改的事务ID)和DB_ROLL_PTR(回滚指针,指向 Undo Log 中的旧版本) - Undo Log :存储数据的旧版本,形成版本链
- ReadView:事务执行快照读时生成的一致性视图
隐藏字段详解 :
InnoDB 的每行数据都有三个隐藏字段:
-
DB_TRX_ID(6 字节):最后修改该行的事务 ID -
DB_ROLL_PTR(7 字节):回滚指针,指向 Undo Log 中该行的上一个版本 -
DB_ROW_ID(6 字节):如果没有定义主键,InnoDB 使用此字段作为聚簇索引数据行:[DB_TRX_ID=100][DB_ROLL_PTR → Undo Log][col1=hello][col2=world]
↓
Undo Log 版本链:[trx_id=95, col1=hi] → [trx_id=80, col1=hey] → ...
Q72: ReadView 是什么?包含哪些信息?
答案 :
ReadView 是事务在执行快照读时生成的一致性视图,用于判断版本链中的哪个版本对当前事务可见。
ReadView 包含四个核心字段:
| 字段 | 含义 |
|---|---|
m_ids |
创建 ReadView 时,系统中活跃(未提交)的事务 ID 列表 |
min_trx_id |
m_ids 中的最小值 |
max_trx_id |
系统应该分配的下一个事务 ID(当前最大事务ID + 1) |
creator_trx_id |
创建该 ReadView 的事务 ID |
ReadView 的创建时机:
- RC 级别:每次 SELECT 都创建新的 ReadView
- RR 级别 :事务中第一次 SELECT 时创建,后续复用
底层实现 :ReadView 是一个 C++ 对象(ReadView 类),存储在事务对象(trx_t)中。RR 级别下,ReadView 挂在事务的 trx_view 字段上;RC 级别下,每次 SELECT 临时创建。
Q73: MVCC 如何判断数据版本是否可见?
答案 :
遍历版本链,对每个版本的 DB_TRX_ID 执行以下判断:
1. 如果 DB_TRX_ID == creator_trx_id
→ 自己修改的,可见 ✅
2. 如果 DB_TRX_ID < min_trx_id
→ 该版本在 ReadView 创建前已提交,可见 ✅
3. 如果 DB_TRX_ID >= max_trx_id
→ 该版本在 ReadView 创建后才产生,不可见 ❌
4. 如果 min_trx_id <= DB_TRX_ID < max_trx_id
→ 检查 DB_TRX_ID 是否在 m_ids 中:
- 在 m_ids 中 → 该事务未提交,不可见 ❌
- 不在 m_ids 中 → 该事务已提交,可见 ✅
如果当前版本不可见,沿着 DB_ROLL_PTR 找到旧版本继续判断,直到找到可见版本或版本链结束。
示例:
当前 ReadView: m_ids=[101,102], min_trx_id=101, max_trx_id=104, creator_trx_id=100
版本链:[trx_id=103] → [trx_id=102] → [trx_id=99] → [trx_id=100]
判断过程:
- trx_id=103:103 >= max_trx_id(104)? 否。103 在 m_ids 中? 否。103 不在 [101,102] 中 → 可见 ✅
(实际上 103 >= 101 且 103 < 104,且不在 m_ids 中 → 可见)
不对,重新算:
- trx_id=103:103 >= max_trx_id(104)? 否。min_trx_id(101) <= 103 < max_trx_id(104)? 是。103 在 m_ids=[101,102] 中? 否 → 可见 ✅
Q74: MVCC 在 RC 和 RR 级别下有什么区别?
答案:
| 隔离级别 | ReadView 创建时机 | 效果 |
|---|---|---|
| RC(读已提交) | 每次 SELECT 都创建新的 ReadView | 每次读都能看到其他事务已提交的最新数据 |
| RR(可重复读) | 事务中第一次 SELECT 时创建,后续复用 | 整个事务期间看到的数据一致 |
底层区别:
- RC 级别:每次 SELECT 调用
MVCC::view_open()创建新 ReadView - RR 级别:只有第一次 SELECT 调用
MVCC::view_open(),后续复用trx->read_view
RC 不可重复读的示例:
事务A (RC): 事务B:
BEGIN;
SELECT age FROM users WHERE id=1;
-- ReadView_1: age=20
UPDATE users SET age=25 WHERE id=1;
COMMIT;
SELECT age FROM users WHERE id=1;
-- ReadView_2(新的): age=25 ← 不可重复读!
RR 可重复读的示例:
事务A (RR): 事务B:
BEGIN;
SELECT age FROM users WHERE id=1;
-- ReadView(固定的): age=20
UPDATE users SET age=25 WHERE id=1;
COMMIT;
SELECT age FROM users WHERE id=1;
-- 同一个 ReadView: age=20 ← 可重复读!
💡 要点:这就是为什么 RR 级别能实现"可重复读"------ReadView 是固定的。
Q75: MVCC 能完全解决幻读吗?
答案 :
不完全能 。MVCC 只能解决快照读场景下的幻读。
| 场景 | 是否解决幻读 | 机制 |
|---|---|---|
| 普通 SELECT(快照读) | ✅ | MVCC ReadView |
| SELECT ... FOR UPDATE(当前读) | ✅ | Next-Key Lock |
| 先快照读再当前读的混合场景 | ❌ | 可能出现幻读 |
幻读的经典场景:
sql
-- 事务A (RR)
BEGIN;
SELECT * FROM t WHERE id = 5; -- 快照读,无结果
-- 事务B
INSERT INTO t VALUES (5, 'test');
COMMIT;
-- 事务A
SELECT * FROM t WHERE id = 5; -- 快照读,仍然无结果(MVCC 保护)
UPDATE t SET name = 'updated' WHERE id = 5; -- 当前读,成功!(因为 id=5 已存在)
SELECT * FROM t WHERE id = 5; -- 快照读,现在能看到 id=5 了!(自己修改的版本)
-- 幻读出现了!
为什么会出现? 因为 UPDATE 是当前读,读到了事务B插入的数据并修改了它。修改后的版本的 DB_TRX_ID 变成了事务A自己的 ID,所以快照读可以看到。
解决方案 :在事务开始时就使用 SELECT ... FOR UPDATE 加锁,阻止其他事务插入。
Q76: Undo Log 在 MVCC 中的作用是什么?
答案 :
Undo Log 是 MVCC 实现的核心组件:
- 存储旧版本数据:每次修改时,将修改前的值写入 Undo Log
- 形成版本链 :通过
DB_ROLL_PTR指针将同一行数据的多个版本串联起来 - 支持事务回滚:回滚时根据 Undo Log 恢复数据
- 支持快照读:MVCC 根据版本链找到对当前事务可见的版本
Undo Log 的存储结构:
- Undo Log 存储在回滚段(Rollback Segment) 中
- 每个回滚段包含 1024 个 Undo Log Segment
- Undo Log Segment 中的记录通过链表连接
- MySQL 5.7+ 支持独立的 Undo 表空间(
innodb_undo_tablespaces)
Q77: 什么是当前读和快照读?
答案:
| 对比项 | 快照读 | 当前读 |
|---|---|---|
| 定义 | 读取数据的历史版本 | 读取数据的最新版本 |
| SQL | 普通 SELECT(不加锁) |
SELECT ... FOR UPDATE、SELECT ... FOR SHARE、INSERT、UPDATE、DELETE |
| 加锁 | ❌ 不加锁 | ✅ 加锁(行锁/间隙锁/Next-Key Lock) |
| MVCC | ✅ 通过 ReadView 读取版本链 | ❌ 不走 MVCC,直接读最新数据 |
| 阻塞 | 不阻塞其他事务 | 可能阻塞其他事务 |
底层实现:
- 快照读 :调用
row_search_mvcc()→ 使用 ReadView 判断版本可见性 → 从版本链中读取 - 当前读 :调用
row_search_mvcc()的当前读模式 → 加锁 → 读取最新版本
Q78: MVCC 和锁的关系是什么?
答案 :
MVCC 和锁是 InnoDB 并发控制的两大支柱:
| 操作类型 | 冲突类型 | 使用的机制 |
|---|---|---|
| 读-读 | 不冲突 | 无需控制 |
| 读-写 | 快照读 vs 写 | MVCC(快照读不阻塞写,写不阻塞快照读) |
| 写-写 | 写 vs 写 | 锁(行锁/间隙锁/Next-Key Lock) |
核心思想:
- MVCC 解决了读写并发问题:读操作读旧版本,写操作写新版本,互不阻塞
- 锁解决写写并发问题:同一时刻只有一个事务能修改同一行
- 两者配合实现高效的并发控制
💡 要点:MVCC 解决了读写并发问题,锁解决写写并发问题。两者配合实现高效的并发控制。
七、日志系统
Q79: MySQL 有哪些重要日志?
答案:
| 日志类型 | 层级 | 作用 | 关键特性 |
|---|---|---|---|
| Redo Log | InnoDB | 崩溃恢复,保证持久性 | 物理日志(记录页的修改),循环写,固定大小 |
| Undo Log | InnoDB | 事务回滚 + MVCC | 逻辑日志(记录反向操作),链表结构 |
| Binlog | Server | 主从复制 + 数据恢复 | 逻辑日志(SQL/行变更),追加写,不覆盖 |
| Error Log | Server | 记录错误信息 | 默认开启 |
| Slow Query Log | Server | 记录慢查询 | 需手动开启 |
| General Log | Server | 记录所有 SQL | 默认关闭(性能影响大) |
| Relay Log | 从库 | 存储从主库拉取的 Binlog | 主从复制使用 |
Q80: Redo Log 的作用和原理?
答案 :
作用 :保证事务的持久性(Durability),即使数据库崩溃也能恢复已提交的事务。
WAL 机制(Write-Ahead Logging):
- 事务修改数据时,先修改内存(Buffer Pool)中的数据页(标记为脏页)
- 同时将修改操作写入 Redo Log Buffer
- 事务提交时,将 Redo Log 刷入磁盘(fsync)
- 后台线程择机将脏页刷回磁盘(Checkpoint)
为什么需要 Redo Log?
- 随机 I/O vs 顺序 I/O :修改数据页是随机写(数据页分散在磁盘各处),写 Redo Log 是顺序写(日志文件连续),顺序写比随机写快 10~100 倍
- 刷脏页是异步的:Buffer Pool 中的脏页由后台线程择机刷盘,如果不使用 Redo Log,崩溃时会丢失尚未刷盘的数据
- Redo Log 是同步的:事务提交时必须 fsync Redo Log,保证数据不丢失
Redo Log 的底层结构:
-
由两个固定大小的文件组成(
ib_logfile0、ib_logfile1),默认每个 48MB(MySQL 8.0.30+ 支持动态调整) -
循环写入 :通过
write pos(写入位置)和checkpoint(检查点)两个指针管理 -
write pos之前的空间是已写入未覆盖的空间 -
checkpoint之前的空间是可以覆盖的空间(脏页已刷盘)Redo Log 循环写入示意:
┌─────────────────────────────────┐
│ checkpoint ← 可覆盖 → write pos │ ← 写入空间 → │
└─────────────────────────────────┘如果 write pos 追上 checkpoint → Redo Log 写满 → 必须停下来做 Checkpoint
Q81: Undo Log 的作用是什么?
答案:
- 事务回滚:事务失败时,根据 Undo Log 恢复数据到修改前的状态
- MVCC:存储数据的历史版本,支持快照读
- 崩溃恢复:未提交事务的 Undo Log 用于回滚
Undo Log 的类型:
- Insert Undo Log:INSERT 操作产生,事务提交后即可丢弃(因为 INSERT 的数据对其他事务不可见,不需要保留旧版本)
- Update Undo Log:UPDATE/DELETE 产生,需要保留供 MVCC 使用(其他事务可能需要读取旧版本)
底层存储:
- Undo Log 存储在回滚段(Rollback Segment) 中
- 每个回滚段位于 Undo 表空间中
- 回滚段包含 1024 个 Undo Log Segment
- 每个事务最多使用 4 个 Undo Log Segment(分别用于 INSERT、UPDATE、辅助表操作等)
Q82: Binlog 的作用和格式?
答案 :
作用:
- 主从复制:从库通过重放 Binlog 同步数据
- 数据恢复 :通过
mysqlbinlog工具恢复到某个时间点(Point-in-Time Recovery, PITR)
三种格式:
| 格式 | 内容 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | 记录原始 SQL 语句 | 日志量小 | 某些函数(NOW()、UUID()、RAND())主从不一致 |
| ROW | 记录每行数据的变更前/后值 | 精确,主从一致 | 日志量大(尤其批量更新) |
| MIXED | 默认 STATEMENT,不安全时自动切换 ROW | 兼顾 | --- |
ROW 格式的底层结构 :
ROW 格式的 Binlog Event 包含:
Table_map_event:表的元数据(列名、类型)Write_rows_event:INSERT 操作的行数据Update_rows_event:UPDATE 操作的变更前/后行数据Delete_rows_event:DELETE 操作的行数据
💡 要点 :生产环境推荐
ROW格式,虽然日志量大但数据一致性最好。MySQL 8.0 默认 ROW 格式。
sql
SHOW VARIABLES LIKE 'binlog_format';
Q83: Redo Log 和 Binlog 的区别?
答案:
| 对比项 | Redo Log | Binlog |
|---|---|---|
| 层级 | InnoDB 引擎层(存储引擎特有) | Server 层(所有引擎通用) |
| 内容 | 物理日志(某页某偏移的字节修改) | 逻辑日志(SQL 语句或行变更记录) |
| 写入方式 | 循环写(固定大小,写满后覆盖) | 追加写(不覆盖,文件写满后创建新文件) |
| 作用 | 崩溃恢复 | 主从复制 + 数据恢复 |
| 事务 | InnoDB 特有 | 所有引擎都有 |
| 刷盘时机 | 事务提交时(prepare) | 事务提交时 |
| 文件 | ib_logfile0, ib_logfile1 |
mysql-bin.000001, mysql-bin.000002, ... |
为什么两者都需要?
- Redo Log 是物理日志,只能用于崩溃恢复(将数据恢复到崩溃前的状态)
- Binlog 是逻辑日志,可以用于主从复制(从库重放 SQL/行变更)和数据恢复(恢复到任意时间点)
- 两者通过两阶段提交保证一致性
Q84: 什么是两阶段提交(2PC)?为什么需要?
答案 :
两阶段提交保证 Redo Log 和 Binlog 的一致性:
阶段1(Prepare):
1. 事务修改 Buffer Pool 中的数据页
2. 写 Redo Log(标记为 prepare 状态)
3. Redo Log fsync 刷入磁盘
阶段2(Commit):
4. 写 Binlog
5. Binlog fsync 刷入磁盘
6. Redo Log 标记 commit 状态
如果不用两阶段提交:
- 先写 Redo Log 后写 Binlog:崩溃后主库通过 Redo Log 恢复了数据,但从库没有对应的 Binlog → 主从不一致
- 先写 Binlog 后写 Redo Log:崩溃后从库通过 Binlog 同步了数据,但主库没有 Redo Log → 数据丢失
Q85: Slow Query Log 如何使用?
答案:
sql
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
-- 设置阈值(秒)
SET GLOBAL long_query_time = 1;
-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';
-- 是否记录未使用索引的查询
SET GLOBAL log_queries_not_using_indexes = ON;
-- 查看慢查询统计
SHOW GLOBAL STATUS LIKE 'Slow_queries';
分析工具:
-
mysqldumpslow:MySQL 自带的慢查询日志分析工具bashmysqldumpslow -s t -t 10 /var/lib/mysql/slow.log # 按时间排序取前10 -
pt-query-digest:Percona Toolkit 中的更强大的分析工具bashpt-query-digest /var/lib/mysql/slow.log
Q86: MySQL 如何保证数据不丢失?
答案 :
通过 Redo Log 的 WAL 机制保证:
- 事务提交时,Redo Log 必须先于数据页刷入磁盘
- 即使数据页还没刷入磁盘就崩溃了,重启后可以通过 Redo Log 重放恢复
innodb_flush_log_at_trx_commit控制 Redo Log 刷盘策略:= 1(默认):每次提交都 fsync,最安全(可能丢失 0 数据)= 0:每秒 fsync,可能丢失 1 秒数据= 2:每次提交写入 OS 缓冲,每秒 fsync(OS 崩溃可能丢数据,MySQL 崩溃不丢)
双 1 配置(最安全):
sql
innodb_flush_log_at_trx_commit = 1 -- Redo Log 每次提交 fsync
sync_binlog = 1 -- Binlog 每次提交 fsync
💡 要点 :
innodb_flush_log_at_trx_commit = 1+sync_binlog = 1是"双 1 配置",最安全但性能最低。金融场景必须使用双 1。
Q87: Redo Log 写满怎么办?
答案 :
Redo Log 是固定大小的循环写入,当写满时:
- 必须停下来做 Checkpoint
- 将 Buffer Pool 中的脏页刷回磁盘
- Checkpoint 之后的 Redo Log 空间可以被覆盖重用
Checkpoint 的类型:
- Sharp Checkpoint:数据库关闭时,将所有脏页刷盘
- Fuzzy Checkpoint:运行时逐步刷盘(包括 Master Thread Checkpoint、FLUSH_LRU_LIST Checkpoint、Async/Sync Flush Checkpoint)
如果脏页刷写太慢 → Redo Log 写满 → 系统阻塞(性能严重下降)
💡 要点 :生产环境应适当增大 Redo Log 大小(MySQL 8.0.30+ 使用
innodb_redo_log_capacity),减少阻塞概率。
Q88: Binlog 的写入时机是什么?
答案 :
Binlog 在事务提交时写入(不是执行时)。
Binlog Cache:
- 每个事务有一个独立的 Binlog Cache (内存缓冲区,大小由
binlog_cache_size控制,默认 32KB) - 事务执行过程中,Binlog 写入 Binlog Cache
- 事务提交时,Binlog Cache 的内容 fsync 到 Binlog 文件
- 如果 Binlog Cache 不够,会使用临时文件
sql
-- 控制 Binlog 刷盘策略
-- sync_binlog = 1:每次提交都 fsync(最安全)
-- sync_binlog = 0:由操作系统决定何时刷盘
-- sync_binlog = N:每 N 次提交 fsync 一次
八、SQL优化与执行计划
Q89: 慢查询优化的一般步骤?
答案:
- 开启慢查询日志,定位慢 SQL
- 使用 EXPLAIN 分析执行计划
- 检查是否使用了索引(type 是否为 ALL)
- 优化 SQL 语句:避免 SELECT *、减少子查询、改写 OR
- 优化索引:添加缺失索引、调整联合索引顺序
- 优化表结构:合理分表、归档历史数据
- 优化数据库配置:Buffer Pool 大小、连接数等
Q90: 如何优化 SELECT * ?
答案 :
SELECT * 的问题:
- 消耗更多网络带宽和内存
- 无法使用覆盖索引,必须回表
- 表结构变更后可能影响应用代码
- 传输大字段(TEXT/BLOB)浪费带宽
底层原理 :SELECT * 会导致 InnoDB 从聚簇索引读取完整数据行(包括所有列),即使只需要少数几个列。如果使用覆盖索引,InnoDB 只需要读取辅助索引的叶子节点,不需要访问聚簇索引,I/O 减少数倍。
Q91: 如何优化子查询?
答案 :
子查询(尤其是 IN 子查询)可能导致性能问题。
sql
-- ❌ 低效的 IN 子查询
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE city = '北京');
-- 底层:先执行子查询得到结果集(可能物化为临时表),再在外层查询中匹配
-- ✅ 优化为 JOIN
SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.city = '北京';
-- 底层:使用 Nested Loop Join,利用索引高效匹配
-- ✅ 或使用 EXISTS
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.city = '北京');
-- 底层:相关子查询,对 orders 每一行执行一次子查询
💡 要点:MySQL 8.0 的优化器会自动将某些 IN 子查询转换为 semi-join。
Q92: 如何优化 OR 条件?
答案:
sql
-- ❌ OR 可能导致索引失效
SELECT * FROM users WHERE age = 20 OR city = '北京';
-- ✅ 优化为 UNION ALL(如果两个条件都有索引)
SELECT * FROM users WHERE age = 20
UNION ALL
SELECT * FROM users WHERE city = '北京' AND age != 20;
底层原因:OR 条件中如果有一个字段没有索引,优化器会退化为全表扫描。UNION ALL 拆分为两个独立查询,每个查询都可以独立使用索引。
Q93: 如何优化 LIKE 查询?
答案:
sql
-- ❌ 前缀通配符,索引失效
SELECT * FROM users WHERE name LIKE '%abc';
-- 底层:%abc 无法利用 B+ 树的前缀有序性
-- ✅ 后缀通配符,可以使用索引
SELECT * FROM users WHERE name LIKE 'abc%';
-- 底层:abc% 可以在 B+ 树中定位到 abc 开头的范围
-- ✅ 全文索引(适合全文搜索)
ALTER TABLE articles ADD FULLTEXT INDEX ft_content(content);
SELECT * FROM articles WHERE MATCH(content) AGAINST('关键词');
-- ✅ 前缀索引优化
ALTER TABLE users ADD INDEX idx_name(name(10));
Q94: 如何优化大批量 INSERT?
答案:
- 合并多条 INSERT :
INSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c');(减少网络往返和事务开销) - 关闭自动提交:手动事务包裹大批量插入
- 关闭唯一性检查 :
SET UNIQUE_CHECKS = 0;(避免逐行检查唯一索引) - 关闭外键检查 :
SET FOREIGN_KEY_CHECKS = 0; - 使用 LOAD DATA INFILE:比 INSERT 快 20 倍(直接生成数据页,跳过 SQL 解析)
- 调整 bulk_insert_buffer_size
- 按主键顺序插入(减少页分裂)
Q95: 如何优化 UPDATE/DELETE?
答案:
- 避免全表更新:加 WHERE 条件
- 使用索引字段做 WHERE 条件:避免行锁退化为表锁
- 大批量操作分批执行:每批 1000~5000 行
- 避开高峰期执行
sql
-- ❌ 一次更新太多,长时间持锁
UPDATE orders SET status = 'done' WHERE created_at < '2024-01-01';
-- ✅ 分批更新
UPDATE orders SET status = 'done' WHERE created_at < '2024-01-01' LIMIT 5000;
Q96: 如何优化 COUNT 查询?
答案:
sql
-- 优化方案:
-- 1. 使用更小的辅助索引(InnoDB 自动选择最小的索引)
SELECT COUNT(*) FROM users;
-- 2. 维护计数表
CREATE TABLE table_counts (table_name VARCHAR(64) PRIMARY KEY, row_count BIGINT);
-- 3. 使用 SHOW TABLE STATUS(近似值)
SHOW TABLE STATUS LIKE 'users';
-- 4. Redis 缓存计数(适合对实时性要求不高的场景)
Q97: 如何优化 DISTINCT?
答案:
sql
-- DISTINCT 可能产生临时表和排序
-- 优化方案:
-- 1. 确保 DISTINCT 的列有索引
SELECT DISTINCT city FROM users; -- city 有索引时,利用索引的有序性去重
-- 2. 用 GROUP BY 替代
SELECT city FROM users GROUP BY city;
-- 3. 覆盖索引优化
ALTER TABLE users ADD INDEX idx_city(city);
Q98: 如何优化多表 JOIN?
答案:
- 小表驱动大表:小结果集在外层
- JOIN 字段必须有索引
- 减少 JOIN 表的数量(一般不超过 3~5 张)
- 避免在 ON 条件中使用函数
- 使用 STRAIGHT_JOIN 强制驱动表顺序
- 增大 join_buffer_size(BNL 时需要)
Q99: 如何优化 ORDER BY NULL?
答案 :
MySQL 的 GROUP BY 默认会按分组字段排序。
sql
-- 如果不需要排序
SELECT city, COUNT(*) FROM users GROUP BY city ORDER BY NULL;
-- 避免 Using filesort
-- 如果需要排序,确保排序字段有索引
ALTER TABLE users ADD INDEX idx_city(city);
Q100: MySQL 查询优化器的工作原理?
答案 :
MySQL 优化器负责将 SQL 解析树转换为最优执行计划。
优化过程:
- 常量折叠 :
WHERE 1=1 AND id = 5→WHERE id = 5 - 等值传播 :
WHERE a = b AND b = 5→a = 5 AND b = 5 - 子查询转换:将 IN 子查询转换为 semi-join
- JOIN 顺序优化:选择最优的表连接顺序(NP 问题,使用贪心/动态规划)
- 索引选择:根据统计信息选择最合适的索引
- 代价估算:估算每种执行计划的 I/O 和 CPU 代价,选择最小的
统计信息:
innodb_stats_persistent:持久化统计信息(默认 ON)innodb_stats_auto_recalc:自动重新计算统计信息ANALYZE TABLE:手动更新统计信息
sql
-- 更新统计信息
ANALYZE TABLE users;
-- 查看优化器选择
EXPLAIN SELECT * FROM users WHERE city = '北京';
-- 查看优化器的代价估算
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE city = '北京';
九、主从复制与高可用
Q101: MySQL 主从复制的原理?
答案 :
主从复制通过 Binlog 实现,分三步:
1. 主库(Master)执行数据变更,写入 Binlog
2. 从库(Slave)的 I/O Thread 连接主库,拉取 Binlog 写入本地 Relay Log
3. 从库的 SQL Thread 重放 Relay Log 中的事件,完成数据同步
底层线程:
- Binlog Dump Thread(主库):每个从库连接时,主库创建一个线程发送 Binlog
- I/O Thread(从库):连接主库,接收 Binlog 事件,写入 Relay Log
- SQL Thread(从库):读取 Relay Log,重放事件
复制的三种格式:
- Statement:复制 SQL 语句(主库执行什么,从库也执行什么)
- Row:复制行变更(主库修改了哪些行,从库也修改哪些行)
- Mixed:默认 Statement,不安全时自动切换 Row
Q102: 主从复制有哪些模式?
答案:
| 模式 | 特点 | 数据一致性 | 性能 |
|---|---|---|---|
| 异步复制(默认) | 主库提交后不等从库确认 | 可能丢失数据 | 最好 |
| 半同步复制 | 主库等至少一个从库确认收到 Binlog | 较好 | 中等 |
| 全同步复制 | 主库等所有从库确认 | 最好 | 最差 |
| GTID 复制 | 基于全局事务 ID | --- | --- |
| 组复制(MGR) | 多主模式,基于 Paxos 协议 | 强一致 | 中等 |
半同步复制的底层原理:
- 主库提交时,先写 Binlog
- 等待至少一个从库的 ACK(确认已收到 Binlog 并写入 Relay Log)
- 收到 ACK 后主库才返回客户端"提交成功"
- 如果超时(
rpl_semi_sync_master_timeout,默认 10 秒),退化为异步复制
Q103: 主从复制延迟的原因和解决方案?
答案 :
原因:
- 从库 SQL Thread 单线程重放(MySQL 5.6 之前)
- 从库机器配置低(CPU/磁盘/内存不如主库)
- 大事务(如一次 DELETE 百万行,从库需要重放百万行变更)
- 从库负载过高(大量读请求消耗资源)
- 网络延迟
解决方案:
- 并行复制 (MySQL 5.7+
slave_parallel_workers,按组提交并行重放) - 提升从库硬件配置
- 拆分大事务
- 读写分离,降低从库压力
- 监控
Seconds_Behind_Master(但不精确,推荐使用pt-heartbeat)
sql
-- 查看复制延迟
SHOW SLAVE STATUS\G
-- 关注 Seconds_Behind_Master 字段
-- 开启并行复制
SET GLOBAL slave_parallel_workers = 4;
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
Q104: 如何实现读写分离?
答案 :
读写分离是将写操作发往主库,读操作发往从库。
实现方式:
- 应用层实现:代码中判断 SQL 类型,路由到不同数据源(灵活但侵入性强)
- 中间件代理:MyCat、ProxySQL、ShardingSphere-Proxy(对应用透明)
- 驱动层实现:ShardingSphere-JDBC(Java 应用内嵌,性能好)
Q105: 什么是 GTID 复制?
答案 :
GTID(Global Transaction Identifier)是 MySQL 5.6 引入的全局事务标识符。
格式:server_uuid:transaction_id(如 3E11FA47-71CA-11E1-9E33-C80AA9429562:23)
优势:
- 自动定位 :从库自动找到需要重放的事务位置,无需手动指定
MASTER_LOG_FILE和MASTER_LOG_POS - 简化故障切换:新主库自动同步
- 保证唯一性:每个事务全局唯一
- 跳过重复事务:从库自动跳过已执行的 GTID
sql
-- 开启 GTID
gtid_mode = ON
enforce_gtid_consistency = ON
Q106: 什么是 MHA?
答案 :
MHA(Master High Availability)是 MySQL 高可用的经典方案。
工作原理:
- 监控主库健康状态(心跳检测)
- 主库故障时,自动将数据最新的从库提升为新主库
- 其他从库切换到新主库(CHANGE MASTER TO)
- 补偿主从差异数据(从旧主库的 Binlog 中找到未同步的事务)
组件:
MHA Manager:监控节点,管理故障切换MHA Node:部署在每个 MySQL 节点上的 Agent
Q107: 什么是 ProxySQL?
答案 :
ProxySQL 是一个高性能的 MySQL 中间件代理,支持:
- 读写分离:将写请求路由到主库,读请求路由到从库
- 查询路由:基于规则将不同 SQL 路由到不同后端
- 连接池:复用后端连接
- 查询缓存:缓存查询结果
- 故障检测:自动检测后端节点健康状态
- 查询重写:在代理层修改 SQL
Q108: 如何保证主从数据一致性?
答案:
- 使用半同步复制:保证至少一个从库收到 Binlog
- 使用 GTID 复制:简化复制管理
- 定期校验 :使用
pt-table-checksum检查主从数据一致性 - 自动修复 :使用
pt-table-sync修复不一致数据 - 监控复制延迟 :
Seconds_Behind_Master、pt-heartbeat - 关键操作强制读主库
十、分库分表与架构设计
Q109: 什么时候需要分库分表?
答案 :
当单库或单表遇到以下瓶颈时:
- 单表数据量过大(> 2000 万行),查询变慢(B+ 树层数增加)
- 单库写入 QPS 过高,单机无法承载
- 单库存储空间不足
- 单表索引太大,Buffer Pool 无法缓存
判断标准:
- 单表 > 2000 万行 → 考虑分表
- 单库写入 > 5000 QPS → 考虑分库
Q110: 水平分表和垂直分表的区别?
答案:
| 对比项 | 水平分表 | 垂直分表 |
|---|---|---|
| 方式 | 按行拆分,同一张表结构 | 按列拆分,不同表结构 |
| 示例 | 订单表按 user_id % 8 拆为 8 张表 | 将用户表的大字段(bio、avatar)拆到扩展表 |
| 解决问题 | 单表数据量过大 | 单行字段过多、冷热数据分离 |
Q111: 水平分库分表的常见分片策略?
答案:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Hash 取模 | id % N |
数据均匀分布,但扩容困难 |
| 范围分片 | 按 ID 范围或时间范围 | 时间序列数据,扩容方便 |
| 一致性 Hash | Hash 环 | 扩容方便,数据迁移量小 |
| 基因法 | 分片键嵌入 ID 中 | 跨分片查询方便 |
💡 要点:分片键的选择至关重要,尽量选择查询频率最高的字段。
Q112: 分库分表后会带来哪些问题?
答案:
- 分布式 ID 问题:自增 ID 不再全局唯一
- 跨分片查询:JOIN 和聚合操作变复杂
- 分布式事务:无法使用本地事务
- 分页排序困难:需要合并多个分片的结果
- 扩容复杂:需要数据迁移
分布式 ID 方案:
- UUID(无序,不推荐做主键)
- 雪花算法(Snowflake):64 位 = 1 位符号 + 41 位时间戳 + 10 位机器 + 12 位序列号
- 号段模式(Leaf、美团):批量获取 ID,减少数据库访问
- Redis 自增(INCR 命令)
Q113: 什么是 ShardingSphere?
答案 :
Apache ShardingSphere 是一套开源的分布式数据库中间件生态,包含:
| 组件 | 说明 |
|---|---|
| ShardingSphere-JDBC | Java 应用内嵌的轻量级框架(客户端分片) |
| ShardingSphere-Proxy | 独立部署的代理服务(对应用透明) |
| ShardingSphere-Scaling | 数据迁移工具 |
核心功能:分库分表、读写分离、分布式事务、数据加密、影子库压测。
Q114: MySQL 单表建议多少数据量?
答案:
- 经验值:单表不超过 2000 万行 ,或单表数据文件不超过 2GB
- 超过后查询性能会明显下降(B+ 树层数从 3 层变为 4 层,I/O 次数增加)
- 实际取决于字段数量、索引大小、查询模式
底层计算:
- 假设每行 1KB,每页存 16 行
- 3 层 B+ 树:
1170 × 1170 × 16 ≈ 2190 万行 - 超过 2000 万行后,B+ 树可能变为 4 层,查询多一次 I/O
💡 要点:这是经验值而非绝对值,需要通过压测确定实际阈值。
Q115: 如何设计一个高并发的数据库架构?
答案 :
以电商系统为例:
┌→ Master(写)→ Binlog → Slave1/2/3(读)
App → ShardingSphere-JDBC
├→ 分库:按 user_id 分 4 个库
├→ 分表:每个库内按 order_id % 8 分表
└→ 缓存:Redis 缓存热点数据
优化措施:
1. 读写分离 + 从库水平扩展
2. 分库分表 + 合理分片键
3. 热点数据缓存(Redis)
4. 非核心数据使用 NoSQL(Elasticsearch/MongoDB)
5. 异步化处理(消息队列削峰)
十一、高频场景题
Q116: 如何删除大量数据?
答案:
sql
-- ❌ 一次删除太多,锁表时间长,Undo Log 膨胀
DELETE FROM logs WHERE created_at < '2023-01-01';
-- ✅ 分批删除
WHILE (SELECT COUNT(*) FROM logs WHERE created_at < '2023-01-01') > 0 DO
DELETE FROM logs WHERE created_at < '2023-01-01' LIMIT 5000;
SELECT SLEEP(0.1); -- 降低压力
END WHILE;
-- ✅ 如果不需要回滚,直接 TRUNCATE 或 DROP 分区
ALTER TABLE logs DROP PARTITION p2022;
Q117: 如何实现分布式锁?
答案 :
基于 MySQL 的分布式锁实现:
sql
-- 方式1:基于唯一索引
CREATE TABLE distributed_lock (
lock_key VARCHAR(64) PRIMARY KEY,
lock_value VARCHAR(128),
expire_time DATETIME
);
-- 获取锁
INSERT INTO distributed_lock (lock_key, lock_value, expire_time)
VALUES ('order_lock', 'server_1', NOW() + INTERVAL 30 SECOND);
-- 释放锁
DELETE FROM distributed_lock WHERE lock_key = 'order_lock' AND lock_value = 'server_1';
💡 要点:生产环境更推荐使用 Redis(SETNX + Lua)或 ZooKeeper 实现分布式锁。
Q118: 一张大表如何加字段?
答案 :
直接 ALTER TABLE 加字段在大表上会长时间锁表(复制数据到新表)。
方案:
- MySQL 8.0 Instant DDL :
ALTER TABLE t ADD COLUMN c INT DEFAULT 0, ALGORITHM=INSTANT;(毫秒级,只修改元数据) - Online DDL :
ALTER TABLE t ADD COLUMN c INT, ALGORITHM=INPLACE, LOCK=NONE;(不锁表,但可能需要复制数据) - pt-online-schema-change(Percona 工具):创建新表 → 复制数据 → 触发器同步变更 → 重命名
- gh-ost(GitHub 工具):类似 pt-osc,通过 Binlog 追踪变更(不需要触发器)
Q119: 如何排查 MySQL CPU 飙高?
答案:
sql
-- 1. 查看当前运行的 SQL
SHOW PROCESSLIST;
SELECT * FROM information_schema.PROCESSLIST WHERE COMMAND != 'Sleep' ORDER BY TIME DESC;
-- 2. 查看是否有慢查询
SHOW GLOBAL STATUS LIKE 'Slow_queries';
-- 3. 使用 performance_schema
SELECT * FROM performance_schema.events_statements_current
ORDER BY TIMER_WAIT DESC LIMIT 10;
-- 4. 查看 InnoDB 状态
SHOW ENGINE INNODB STATUS\G
常见原因:
- 慢查询导致并发线程过多
- 锁竞争导致线程等待
- 全表扫描(大表无索引)
- 排序/临时表操作
Q120: MySQL 出现 "Too many connections" 怎么办?
答案:
sql
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
SHOW VARIABLES LIKE 'max_connections';
-- 临时增大
SET GLOBAL max_connections = 500;
-- 查看连接来源
SELECT user, host, db, command, time, state
FROM information_schema.PROCESSLIST
ORDER BY time DESC;
根本解决方案:
- 增大 max_connections(但每个连接消耗内存)
- 使用连接池(如 HikariCP、Druid)
- 优化慢查询,减少连接占用时间
- 排查连接泄漏(应用未正确关闭连接)
Q121: 如何设计一个秒杀系统的数据库?
答案:
sql
-- 核心表
CREATE TABLE goods (
id BIGINT PRIMARY KEY,
name VARCHAR(128),
stock INT,
version INT DEFAULT 0
);
-- 库存扣减(乐观锁)
UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 1001 AND stock > 0 AND version = #{version};
-- 库存扣减(悲观锁)
START TRANSACTION;
SELECT * FROM goods WHERE id = 1001 FOR UPDATE;
UPDATE goods SET stock = stock - 1 WHERE id = 1001;
COMMIT;
优化措施:
- Redis 预减库存:请求先到 Redis 扣减,减少数据库压力
- 异步下单:MQ 异步处理订单
- 热点数据缓存:商品信息缓存到 Redis
- 分库分表:按商品 ID 分片
Q122: 如何实现数据库的软删除?
答案:
sql
-- 方式1:is_deleted 字段
ALTER TABLE users ADD COLUMN is_deleted TINYINT DEFAULT 0;
SELECT * FROM users WHERE is_deleted = 0;
-- 方式2:deleted_at 字段(推荐)
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
SELECT * FROM users WHERE deleted_at IS NULL;
-- 优势:NULL 不占用空间,索引效率更高
💡 要点 :软删除时记得更新索引,
is_deleted字段选择性低,不适合单独建索引。
Q123: 如何优化大表的 ALTER TABLE?
答案:
- 使用 pt-online-schema-change:不锁表,业务无感知
- 使用 gh-ost:GitHub 开源工具
- MySQL 8.0 Instant DDL:部分操作瞬间完成
- 低峰期执行:减少业务影响
- 主从架构下先在从库执行,再切换主从
Q124: 如何处理 MySQL 中的死锁?
答案:
sql
-- 1. 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G
-- 找到 LATEST DETECTED DEADLOCK 部分
-- 2. 查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits;
-- 3. Kill 阻塞的事务
KILL thread_id;
预防措施:
- 事务尽量短小
- 按固定顺序访问行
- 合理使用索引,避免锁升级
- 设置合理的锁等待超时
Q125: 如何监控 MySQL 的性能?
答案:
sql
-- 关键指标
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_%'; -- Buffer Pool 命中率
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_%'; -- 行锁等待
SHOW GLOBAL STATUS LIKE 'Threads_%'; -- 线程状态
SHOW GLOBAL STATUS LIKE 'Slow_queries'; -- 慢查询数
SHOW GLOBAL STATUS LIKE 'Questions'; -- 总查询数
监控工具:
- Percona Toolkit:pt-query-digest、pt-stalk
- Prometheus + Grafana:mysqld_exporter
- PMM(Percona Monitoring and Management)
十二、InnoDB 存储结构深入
Q126: InnoDB 的表空间结构是怎样的?
答案 :
InnoDB 的所有数据都存储在表空间(Tablespace) 中。
表空间类型:
- 系统表空间(ibdata1):存储数据字典、Undo Log(MySQL 5.6 前)、双写缓冲区、Change Buffer
- 独立表空间(.ibd 文件) :每张表一个文件(
innodb_file_per_table=1,默认开启) - 临时表空间:存储临时表数据
- Undo 表空间:MySQL 5.7+ 支持独立的 Undo 表空间
- Redo Log 表空间:MySQL 8.0.30+ 支持
表空间的层级结构:
Tablespace(表空间)
└── Segment(段)
├── Data Segment(数据段,即聚簇索引的叶子节点段)
├── Index Segment(索引段,即非叶子节点段)
└── Rollback Segment(回滚段,存储 Undo Log)
└── Extent(区,1MB = 64 个连续的 16KB 页)
└── Page(页,16KB)
└── Row(行)
Q127: InnoDB 的数据页(Page)结构是怎样的?
答案 :
InnoDB 的最小 I/O 单位是页(Page),默认 16KB。
页的结构:
| 部分 | 大小 | 说明 |
|---|---|---|
| File Header | 38 字节 | 页的通用信息(页号、页类型、前后页指针、LSN 等) |
| Page Header | 56 字节 | 数据页的专有信息(记录数、空闲空间、页目录槽数等) |
| Infimum + Supremum | 26 字节 | 两条虚拟记录,分别表示页中最小和最大的记录 |
| User Records | 可变 | 实际的用户数据记录,通过单向链表连接 |
| Free Space | 可变 | 页中尚未使用的空间 |
| Page Directory | 可变 | 页目录,存储若干 slot,每个 slot 指向一组记录中的最后一条 |
| File Trailer | 8 字节 | 校验和 + LSN,用于检测页是否完整写入 |
页内查找原理:
- 在 Page Directory 中二分查找定位 slot
- 在 slot 指向的记录组内顺序查找
- 时间复杂度 O(logN)(N 为页内记录数)
File Trailer 的作用:
- 存储页的校验和(checksum)和 LSN
- 写入时先写 File Header,再写数据,最后写 File Trailer
- 读取时比较 File Header 和 File Trailer 的校验和,检测页是否损坏(断页检测)
Q128: InnoDB 的行格式(Row Format)有哪些?
答案 :
InnoDB 支持 4 种行格式:
| 行格式 | 特点 | 默认 |
|---|---|---|
| Compact | 紧凑存储,变长字段列表 + NULL 位图 | MySQL 5.0+ |
| Redundant | 兼容旧版,字段偏移量列表 | --- |
| Dynamic | 大字段完全溢出到外部页 | MySQL 5.7+ 默认 |
| Compressed | 支持压缩(zlib) | --- |
Compact 行格式的存储结构:
┌──────────────────────────────────────────────┐
│ 变长字段长度列表(逆序) │ NULL 标志位图 │ 记录头信息(5字节) │ 隐藏列(trx_id + roll_ptr) │ 实际数据 │
└──────────────────────────────────────────────┘
Dynamic 行格式的优势:
- 对于大字段(VARCHAR > 768 字节或 TEXT/BLOB),只在行中存储 20 字节的指针,实际数据完全溢出到外部页(Off-page)
- 这使得每页能存储更多行,提高 Buffer Pool 利用率
Q129: 什么是区(Extent)和段(Segment)?
答案:
区(Extent):
- 由 64 个连续的 16KB 页组成,大小为 1MB
- 是 InnoDB 分配空间的最小单位
- 为什么需要区?减少随机 I/O------同一个区内的页在磁盘上物理连续
段(Segment):
- 是表空间中的逻辑概念,由若干区组成
- 每个索引至少有两个段:
- 叶子节点段(Leaf Node Segment):存储数据页
- 非叶子节点段(Non-Leaf Node Segment):存储索引页
- 段可以动态增长和收缩
碎片区(Fragment Extent):
- 段刚开始只有少量数据时,不会立即分配整个区
- 先从碎片区分配零散的页(最多 32 个)
- 数据增长后才分配完整的区
Q130: InnoDB 的数据文件是如何组织的?
答案 :
在独立表空间模式下(innodb_file_per_table=1),每张表对应一个 .ibd 文件。
.ibd 文件的组织:
.ibd 文件
├── 系统页(FSP Header, XDES Entry 等)
├── 聚簇索引的根页
├── 聚簇索引的非叶子节点页
├── 聚簇索引的叶子节点页(存储实际数据行)
├── 辅助索引的根页
├── 辅助索引的非叶子节点页
├── 辅助索引的叶子节点页
└── 空闲页
页号(Page Number):
- 每个页有唯一的页号(space_id, page_no)
- 聚簇索引的根页号通常是 3(0 号页是 FSP_HDR,1 号页是 IBUF_BITMAP,2 号页是 INODE)
Q131: 什么是双写缓冲区(Doublewrite Buffer)?
答案 :
双写缓冲区是 InnoDB 的数据完整性保护机制 ,防止部分页写入(Partial Page Write) 问题。
问题背景:
- InnoDB 的页大小是 16KB,而操作系统的 I/O 原子单位通常是 4KB 或 512 字节
- 如果数据库在写入 16KB 的过程中崩溃,可能只写入了 4KB,导致页损坏
- Redo Log 记录的是物理修改,如果页本身已损坏,Redo Log 无法恢复
双写机制:
-
将脏页先写入双写缓冲区(系统表空间中的连续区域,2MB)
-
双写缓冲区写入磁盘(顺序 I/O)
-
再将脏页写入实际的数据文件(随机 I/O)
-
如果崩溃恢复时发现数据页损坏,从双写缓冲区中找到完整的页副本
脏页 → [双写缓冲区(顺序写)] → [实际数据文件(随机写)]
↓
磁盘(顺序 I/O,16KB × 128 页 = 2MB)
💡 要点:双写缓冲区只在系统表空间中,性能开销约 5~10%。对于使用原子写(Atomic Write)的存储设备(如 FusionIO),可以关闭双写。
Q132: 什么是自适应哈希索引(Adaptive Hash Index, AHI)?
答案 :
自适应哈希索引是 InnoDB 的自动优化机制 ,当发现某些索引页被频繁等值查询访问时,自动在内存中构建哈希索引,将查找从 O(logN) 优化到 O(1)。
工作原理:
- InnoDB 监控索引页的访问模式
- 如果某个索引页被频繁等值访问(通过 B+ 树查找),自动将其加入 AHI
- 后续的等值查询直接通过哈希表定位,跳过 B+ 树遍历
- AHI 由参数
innodb_adaptive_hash_index(默认 ON)控制
sql
-- 查看 AHI 状态
SHOW ENGINE INNODB STATUS\G
-- 找到 INSERT BUFFER AND ADAPTIVE HASH INDEX 部分
💡 要点:AHI 对等值查询优化明显,但对范围查询无效。高并发下 AHI 的锁竞争可能成为瓶颈。
Q133: InnoDB 如何处理溢出数据(Off-page Storage)?
答案 :
当一行数据超过页大小的一半 (约 8KB)时,大字段会溢出到外部页(Off-page)。
Dynamic 行格式的处理:
-
VARCHAR(8000+) 或 TEXT/BLOB 字段,只在行中存储 20 字节的指针
-
实际数据存储在溢出页链表中
-
每个溢出页存储部分数据,通过指针串联
数据行:[col1][col2][大字段指针(20字节)] → 溢出页1 → 溢出页2 → ...
Compact 行格式的处理:
- 前 768 字节存储在行中,剩余部分溢出到外部页
💡 要点:Dynamic 行格式比 Compact 更高效,因为行中只存指针,每页能存更多行。
Q134: InnoDB 如何管理空闲空间?
答案 :
InnoDB 使用链表管理空闲空间:
- Free List:空闲页链表,新数据从这里分配页
- Frag List:碎片区中的可用页
- 当需要分配新页时:
- 先从 Free List 取空闲页
- 如果 Free List 为空,从碎片区分配
- 如果碎片区也满了,分配新的区(1MB)
空间回收:
- DELETE 操作不会立即释放页空间
- 页被标记为"可复用",但不会归还操作系统
OPTIMIZE TABLE或ALTER TABLE ... ENGINE=InnoDB会重建表,释放空间
Q135: InnoDB 的数据字典(Data Dictionary)是什么?
答案 :
数据字典是 InnoDB 的元数据存储,记录表、索引、列等的定义信息。
MySQL 8.0 的改进:
- MySQL 5.7 及之前:数据字典存储在
.frm文件和系统表空间中 - MySQL 8.0:引入了事务性数据字典 ,存储在
mysql表空间中的隐藏表中mysql.innodb_table_stats:表的统计信息mysql.innodb_index_stats:索引的统计信息mysql.innodb_ddl_log:DDL 操作的日志
优势:
- 原子性 DDL:DDL 操作可以在崩溃后正确回滚
- 不再需要
.frm文件
十三、Buffer Pool 与 Checkpoint 深入
Q136: Buffer Pool 的内部结构是怎样的?
答案 :
Buffer Pool 由多个 Chunk 组成,每个 Chunk 大小由 innodb_buffer_pool_chunk_size 控制(默认 128MB)。
三个核心链表:
- Free List:空闲页链表,新数据页从这里分配
- LRU List:已使用的页,按访问时间排序(改进的 LRU 算法)
- Flush List:脏页链表,按修改时间排序(最早的脏页在头部)
改进的 LRU 算法 :
传统 LRU 会被全表扫描"污染"。InnoDB 将 LRU 链表分为两部分:
-
Young 区(热数据,约 5/8):最近频繁访问的页
-
Old 区(冷数据,约 3/8):新加载的页先进入 Old 区
-
只有当 Old 区的页在被访问且间隔超过 1 秒 (
innodb_old_blocks_time)后,才会移动到 Young 区 -
全表扫描加载的页只在 Old 区,很快被淘汰,不会污染热数据
LRU List:
[Young 区(热数据,5/8)] ←→ [Old 区(冷数据,3/8)]
最近频繁访问的页 新加载的页全表扫描的页在 Old 区,1 秒内再次访问不会移动到 Young 区
Q137: Buffer Pool 的预读(Read-Ahead)机制是什么?
答案 :
InnoDB 的预读机制会提前将可能需要的页加载到 Buffer Pool,减少未来的 I/O。
两种预读:
-
线性预读(Linear Read-Ahead):
- 当顺序读取一个区(Extent)中连续的 N 个页(
innodb_read_ahead_threshold,默认 56)时 - 自动预读该区的后续页
- 当顺序读取一个区(Extent)中连续的 N 个页(
-
随机预读(Random Read-Ahead):
- 当一个区中的 13 个连续页被加载到 Buffer Pool 时
- 自动预读该区的剩余页
- 由
innodb_random_read_ahead控制(默认 OFF)
Q138: Buffer Pool 的脏页刷盘策略是什么?
答案 :
Buffer Pool 中的脏页(被修改过的页)由后台线程择机刷回磁盘。
刷盘的触发条件:
- Checkpoint:Redo Log 写满时,必须刷脏页
- 后台线程定期刷 :
innodb_io_capacity(默认 200)控制每秒刷盘的页数 - LRU 链表淘汰:Free List 不足时,从 LRU 尾部淘汰页,如果是脏页则先刷盘
- FLUSH 链表刷盘:从 Flush List 头部(最早的脏页)开始刷
刷盘的优化:
innodb_io_capacity:控制后台刷盘的 I/O 能力(根据磁盘类型设置,SSD 可设为 2000~10000)innodb_io_capacity_max:紧急情况下的最大 I/O 能力innodb_flush_neighbors:刷脏页时是否一起刷相邻的脏页(SSD 下建议设为 0)
Q139: 什么是 Checkpoint?有哪些类型?
答案 :
Checkpoint 是将 Buffer Pool 中的脏页刷回磁盘的过程,目的是:
- 缩短崩溃恢复时间:脏页刷盘后,Redo Log 中对应的记录可以被覆盖
- 释放 Redo Log 空间:Checkpoint 之后的 Redo Log 空间可以重用
- 释放 Buffer Pool 空间:刷盘后的页可以被新数据覆盖
Checkpoint 的类型:
| 类型 | 触发时机 | 说明 |
|---|---|---|
| Sharp Checkpoint | 数据库关闭时 | 将所有脏页刷盘 |
| Master Thread Checkpoint | 后台线程每秒/每 10 秒 | 异步刷少量脏页 |
| FLUSH_LRU_LIST Checkpoint | LRU 列表需要空闲页时 | 从 LRU 尾部淘汰脏页 |
| Async/Sync Flush Checkpoint | Redo Log 快满时 | 同步刷脏页 |
| Dirty Page too much Checkpoint | 脏页比例超过阈值 | innodb_max_dirty_pages_pct(默认 90%) |
Q140: Buffer Pool 的多实例机制是什么?
答案 :
在高并发下,单一 Buffer Pool 的锁竞争会成为瓶颈。InnoDB 支持将 Buffer Pool 分成多个实例(Instance)。
sql
-- 查看 Buffer Pool 实例数
SHOW VARIABLES LIKE 'innodb_buffer_pool_instances';
-- 生产环境建议设为 8 或 16(Buffer Pool > 1GB 时生效)
底层原理:
- 每个实例有独立的 Free List、LRU List、Flush List
- 每个实例有独立的互斥锁(
buffer_pool_mutex) - 页根据页号哈希到不同的实例
- 减少了锁竞争,提高了并发性能
Q141: 如何监控 Buffer Pool 的使用情况?
答案:
sql
-- Buffer Pool 命中率(应 > 99%)
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
-- Buffer Pool 使用情况
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_%';
-- Innodb_buffer_pool_pages_data:数据页数
-- Innodb_buffer_pool_pages_dirty:脏页数
-- Innodb_buffer_pool_pages_free:空闲页数
-- 预读情况
SHOW STATUS LIKE 'Innodb_buffer_pool_read_ahead%';
Q142: Buffer Pool 的预热(Warmup)是什么?
答案 :
数据库重启后,Buffer Pool 是空的(冷数据),大量查询会触发磁盘 I/O,导致性能下降。
预热方法:
- innodb_buffer_pool_dump_at_shutdown:关闭时将 Buffer Pool 中的页列表保存到文件
- innodb_buffer_pool_load_at_startup:启动时将页列表加载回 Buffer Pool
- innodb_buffer_pool_dump_pct:保存的页比例(默认 25%)
sql
-- 手动保存 Buffer Pool 状态
SET GLOBAL innodb_buffer_pool_dump_now = ON;
-- 手动加载 Buffer Pool 状态
SET GLOBAL innodb_buffer_pool_load_now = ON;
Q143: 什么是 Change Buffer 的 Merge 过程?
答案 :
Change Buffer 的 Merge 是将缓存的修改操作合并到实际索引页的过程。
Merge 的触发时机:
- 页被访问时:当辅助索引页被读取到 Buffer Pool 时,触发 Merge
- 后台线程定期合并:Master Thread 每秒或每 10 秒执行一次
- 数据库关闭时
- Redo Log 写满时
Merge 的过程:
- 从系统表空间读取 Change Buffer 记录
- 将修改操作应用到辅助索引页
- 标记 Change Buffer 记录为已合并
十四、Redo Log 底层原理
Q144: Redo Log 的物理结构是怎样的?
答案 :
Redo Log 由两个固定大小的文件组成(ib_logfile0、ib_logfile1),默认每个 48MB(MySQL 8.0.30+ 使用 innodb_redo_log_capacity 统一管理)。
循环写入:
- 通过
write pos(写入位置)和checkpoint(检查点)两个指针管理 write pos之前的空间是已写入未覆盖的空间checkpoint之前的空间是可以覆盖的空间(脏页已刷盘)
Redo Log 记录的格式:
[type][space_id][page_no][data_len][data]
type:操作类型(如 MLOG_REC_INSERT, MLOG_COMP_REC_UPDATE 等)space_id:表空间 IDpage_no:页号data:修改的具体字节
Q145: Redo Log Buffer 是什么?
答案 :
Redo Log Buffer 是内存中的缓冲区,用于暂存 Redo Log 记录。
刷盘策略 (由 innodb_flush_log_at_trx_commit 控制):
= 1(默认):每次事务提交时,Redo Log Buffer → OS 缓冲 → 磁盘(fsync)= 0:每秒将 Redo Log Buffer → OS 缓冲 → 磁盘(可能丢失 1 秒数据)= 2:每次事务提交时,Redo Log Buffer → OS 缓冲(不 fsync),每秒 OS 缓冲 → 磁盘
Redo Log Buffer 的大小 :由 innodb_log_buffer_size 控制(默认 16MB)
Q146: Redo Log 的 LSN 是什么?
答案 :
LSN(Log Sequence Number)是 Redo Log 的逻辑偏移量,表示日志写入的字节数。
LSN 的用途:
- 标识日志位置:每个 Redo Log 记录有唯一的 LSN
- 崩溃恢复:比较页的 LSN 和 Redo Log 的 LSN,决定是否需要重放
- Checkpoint:记录 Checkpoint 时的 LSN,用于判断哪些 Redo Log 可以覆盖
页中的 LSN :
每个数据页的 File Header 中有一个 FIL_PAGE_LSN 字段,记录该页最后一次修改对应的 LSN。崩溃恢复时,如果页的 LSN < Redo Log 的 LSN,说明该页需要重放。
Q147: Redo Log 的崩溃恢复过程是怎样的?
答案 :
数据库启动时,InnoDB 执行崩溃恢复:
- 扫描 Redo Log:从最近的 Checkpoint LSN 开始,扫描所有 Redo Log 记录
- 重放(Redo):将 Redo Log 中的修改应用到数据页
- 回滚(Undo):对未提交事务的修改,使用 Undo Log 回滚
判断是否需要重放:
- 比较 Redo Log 记录的 LSN 和数据页的
FIL_PAGE_LSN - 如果
Redo Log LSN > Page LSN→ 需要重放 - 如果
Redo Log LSN <= Page LSN→ 不需要(页已经是最新的)
为什么 Redo Log 是物理日志 :
Redo Log 记录的是"在页 X 的偏移 Y 处写入数据 Z",是物理层面的修改。即使逻辑上相同的 SQL,在不同时间点执行可能产生不同的物理修改(因为页的布局可能变化)。物理日志的好处是可以直接应用,不需要重新执行 SQL。
Q148: 什么是 Mini-Transaction(MTR)?
答案 :
Mini-Transaction(MTR)是 InnoDB 内部的最小原子操作单位,用于保证底层数据结构的一致性。
MTR vs 事务:
- 事务:面向用户的逻辑概念,包含多条 SQL
- MTR:面向内部的物理概念,保证单个 B+ 树操作的原子性
MTR 的作用:
- 一次 B+ 树的页分裂可能涉及多个页的修改
- MTR 保证这些页的修改要么全部完成,要么全部不完成
- MTR 的 Redo Log 记录会被一起写入 Redo Log Buffer
Q149: Redo Log 的组提交(Group Commit)是什么?
答案 :
Group Commit 是 MySQL 5.6 引入的优化,将多个事务的 Redo Log 批量 fsync,减少 I/O 次数。
原理:
- 多个事务几乎同时提交
- 第一个事务到达 fsync 点时,等待一小段时间(让其他事务追上)
- 将多个事务的 Redo Log 一起 fsync
- 减少了 fsync 次数,提高了吞吐量
Binlog 的 Group Commit :
同样地,Binlog 也支持 Group Commit,多个事务的 Binlog 一起 fsync。
Q150: Redo Log 的写入性能如何优化?
答案:
- 增大 Redo Log 大小 :减少 Checkpoint 频率(
innodb_redo_log_capacity,MySQL 8.0.30+) - 使用 SSD:fsync 性能比 HDD 快 10~100 倍
- innodb_flush_log_at_trx_commit = 2:牺牲部分安全性换取性能
- Group Commit:自动生效,减少 fsync 次数
- 减少事务大小:小事务的 Redo Log 更小,提交更快
十五、Undo Log 与版本链深入
Q151: Undo Log 的存储结构是怎样的?
答案 :
Undo Log 存储在回滚段(Rollback Segment) 中。
回滚段的结构:
Undo Tablespace
└── Rollback Segment(回滚段)
├── Undo Slot 0 → Undo Log(INSERT 类型)
├── Undo Slot 1 → Undo Log(UPDATE 类型)
├── Undo Slot 2 → Undo Log(辅助表操作)
└── ...(最多 1024 个 Slot)
Undo Log 的类型:
- Insert Undo Log:INSERT 操作产生,事务提交后丢弃
- Update Undo Log:UPDATE/DELETE 产生,保留供 MVCC 使用
Q152: Undo Log 的版本链是如何构建的?
答案 :
每次 UPDATE 操作时,InnoDB 将旧版本数据写入 Undo Log,并通过 DB_ROLL_PTR 指针串联起来。
数据行:[DB_TRX_ID=100, DB_ROLL_PTR → Undo_1, col1=hello]
↓
Undo_1:[DB_TRX_ID=95, DB_ROLL_PTR → Undo_2, col1=hi]
↓
Undo_2:[DB_TRX_ID=80, DB_ROLL_PTR → NULL, col1=hey]
版本链的遍历 :
MVCC 从数据行开始,沿着 DB_ROLL_PTR 遍历版本链,找到对当前 ReadView 可见的版本。
Q153: 什么是 Purge?Purge 线程的作用是什么?
答案 :
Purge 是 InnoDB 的垃圾回收机制,用于清理不再需要的旧版本数据。
Purge 的条件:
- 一个 Undo Log 记录可以被 Purge 的条件:所有活跃事务的 ReadView 都不会引用该版本
- 即:该版本的
DB_TRX_ID< 所有活跃 ReadView 的min_trx_id
Purge 线程:
innodb_purge_threads(默认 4):并发 Purge 线程数- Purge 线程定期扫描 Update Undo Log,清理可回收的记录
- Purge 延迟会导致 Undo 表空间膨胀
sql
-- 查看 Purge 状态
SHOW ENGINE INNODB STATUS\G
-- 找到 PURGE 部分
Q154: Undo 表空间如何管理?
答案 :
MySQL 5.7+ 支持独立的 Undo 表空间。
sql
-- 查看 Undo 表空间
SELECT NAME, FILE_SIZE, ALLOCATED_SIZE
FROM information_schema.INNODB_TABLESPACES
WHERE SPACE_TYPE = 'Undo';
-- MySQL 8.0 支持在线收缩 Undo 表空间
ALTER UNDO TABLESPACE tablespace_name SET INACTIVE;
-- 等待 Purge 完成后自动收缩
Q155: Undo Log 的历史列表长度(History List Length)是什么?
答案 :
History List Length 是 Undo Log 中未被 Purge 的 Update Undo Log 记录数。
sql
-- 查看历史列表长度
SHOW ENGINE INNODB STATUS\G
-- 找到 HISTORY LIST 部分
-- History list length: 12345
含义:
- 值越大,说明 Undo Log 中有越多的旧版本未被清理
- 可能的原因:长事务阻止 Purge、Purge 线程配置不足
- 建议保持在 10 万以下
Q156: 长事务对 Undo Log 有什么影响?
答案:
- Undo Log 膨胀:长事务的 ReadView 引用了旧版本,Purge 无法清理
- Undo 表空间增长:旧版本数据不断累积
- 查询性能下降:MVCC 需要遍历更长的版本链
- Purge 延迟:Purge 线程需要等待长事务结束
解决方案:
- 事务尽量短小
- 设置
innodb_undo_tablespaces(默认 2)分散 Undo I/O - 监控 History List Length
十六、Binlog 与复制深入
Q157: Binlog 的文件格式是怎样的?
答案 :
Binlog 文件由一系列事件(Event) 组成。
Binlog 文件的结构:
binlog.000001
├── Format Description Event(文件头,描述 Binlog 格式版本)
├── Previous Gtid Event(之前的 GTID 集合)
├── Query Event(STATEMENT 格式的 SQL)
├── Table Map Event(表的元数据)
├── Write Rows Event(INSERT 的行数据)
├── Update Rows Event(UPDATE 的变更前/后行数据)
├── Delete Rows Event(DELETE 的行数据)
├── Xid Event(事务提交标记)
└── Rotate Event(指向下一个 Binlog 文件)
每个 Event 的结构:
┌────────────────────────────────────────┐
│ Event Header(19 或 23 字节) │ Event Data │
│ - timestamp (4B) │
│ - type_code (4B) │
│ - server_id (4B) │
│ - event_length (4B) │
│ - next_position (4B) │
│ - flags (2B) │
└────────────────────────────────────────┘
Q158: Binlog 的三种格式在复制中的行为差异是什么?
答案:
STATEMENT 格式:
- 主库执行
INSERT INTO t VALUES (RAND()),从库也执行同样的 SQL - 但
RAND()在主库和从库的值不同 → 数据不一致
ROW 格式:
- 主库记录
INSERT INTO t VALUES (0.123456)(具体值) - 从库直接插入相同的值 → 数据一致
MIXED 格式:
- 默认使用 STATEMENT
- 检测到不安全的函数时自动切换为 ROW
ROW 格式的 Binlog 量问题:
UPDATE t SET status = 1 WHERE id > 1000(影响 10 万行)- STATEMENT:只记录一条 SQL
- ROW:记录 10 万个 Update Rows Event → 日志量暴增
💡 要点 :生产环境推荐 ROW 格式。可以使用
binlog_row_image = MINIMAL减少日志量(只记录变更列)。
Q159: 从库的并行复制原理是什么?
答案 :
MySQL 5.7 引入了基于组提交(Group Commit) 的并行复制。
原理:
- 主库在组提交时,为每个事务标记
last_committed和sequence_number last_committed相同的事务说明在主库是同一组提交的,可以并行执行- 从库的 SQL Thread 分配多个 Worker Thread,
last_committed相同的事务并行重放
sql
-- 开启并行复制
SET GLOBAL slave_parallel_workers = 4;
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
MySQL 8.0 的改进:
- 支持基于写集(Write Set) 的并行复制
- 分析事务的写入集合,无冲突的事务可以并行执行
- 并行度更高
Q160: 什么是 Relay Log?
答案 :
Relay Log 是从库上的中继日志,存储从主库拉取的 Binlog 事件。
Relay Log 的作用:
- 从库的 I/O Thread 将 Binlog 事件写入 Relay Log
- 从库的 SQL Thread 从 Relay Log 中读取事件并重放
- Relay Log 解耦了拉取和重放,允许两者速度不同
Relay Log 的管理:
relay_log_purge(默认 ON):SQL Thread 重放完 Relay Log 后自动删除relay_log_recovery(默认 ON):崩溃恢复时自动从主库重新拉取
Q161: 主从复制中的数据校验如何实现?
答案 :
使用 Percona Toolkit 中的工具:
bash
# 校验主从数据一致性
pt-table-checksum --replicate=percona.checksums h=master_host,u=user,p=pass
# 查看校验结果
pt-table-checksum --replicate=percona.checksums --replicate-check=1 h=master_host
# 修复不一致数据
pt-table-sync --replicate=percona.checksums h=master_host h=slave_host --execute
Q162: 什么是半同步复制的退化和恢复?
答案 :
退化:
- 当从库 ACK 超时(
rpl_semi_sync_master_timeout,默认 10 秒)时 - 主库自动退化为异步复制
- 退化后主库不再等待从库确认
恢复:
- 当从库重新连接并追上主库后,主库自动恢复为半同步复制
- 可以通过
Rpl_semi_sync_master_status变量查看当前状态
sql
-- 查看半同步复制状态
SHOW STATUS LIKE 'Rpl_semi_sync%';
Q163: 什么是组复制(MGR)?
答案 :
MySQL Group Replication(MGR)是 MySQL 5.7.17 引入的高可用集群方案。
特点:
- 多主模式:所有节点都可以读写
- 强一致性:基于 Paxos 协议,事务需要多数节点确认
- 自动故障检测:节点故障自动从集群中移除
- 冲突检测:检测并回滚冲突的事务
架构:
Node1 ←→ Node2 ←→ Node3
↑ ↑ ↑
└──── Paxos 协议 ────┘
十七、锁机制底层原理
Q164: InnoDB 的锁管理器是如何实现的?
答案 :
InnoDB 的锁信息存储在内存中的锁表(Lock Table) 数据结构中。
锁表的结构:
-
使用哈希表 实现,key 是
(space_id, page_no, heap_no) -
value 是一个锁链表,包含所有在该记录上加锁的事务
-
每个锁记录包含:事务 ID、锁类型(S/X/Gap/Next-Key)、等待状态
锁表(Hash Table):
Key: (space=1, page=10, heap=5) → [trx_100: X锁, trx_101: S锁(等待)]
Key: (space=1, page=10, heap=6) → [trx_100: Gap锁]
Key: (space=1, page=10, heap=7) → [trx_102: X锁]
加锁过程:
- 根据记录的
(space, page, heap)计算哈希值 - 在锁表中查找是否已有锁记录
- 检查锁兼容性(参考锁兼容矩阵)
- 兼容则加锁成功,不兼容则加入等待队列
- 定期检查死锁(等待图中是否有环路)
Q165: 什么是等待图(Wait-for Graph)?
答案 :
等待图是 InnoDB 用于死锁检测的数据结构。
结构:
- 节点:事务
- 边:事务 A 等待事务 B 释放锁 → A → B
死锁检测过程:
- 当事务 A 等待事务 B 时,添加边 A → B
- 检查图中是否存在环路
- 如果存在环路 → 死锁 → 选择回滚代价最小的事务
检测频率:
- 每次锁等待时都检测
- 高并发下死锁检测本身也有 CPU 开销
- 可以通过
innodb_deadlock_detect = OFF关闭,使用innodb_lock_wait_timeout作为兜底
Q166: InnoDB 的锁类型在内存中是如何表示的?
答案:
c
// 简化的锁结构
struct lock_t {
trx_t* trx; // 持有锁的事务
lock_type_t type; // LOCK_REC(行锁)或 LOCK_TABLE(表锁)
lock_mode_t mode; // LOCK_S, LOCK_X, LOCK_IS, LOCK_IX
uint32_t space; // 表空间 ID
uint32_t page_no; // 页号
uint16_t heap_no; // 记录在页中的堆号
bool is_gap; // 是否是间隙锁
bool is_waiting; // 是否在等待
};
行锁的加锁位置:
- 行锁加在聚簇索引的记录上
- 如果查询使用辅助索引,会先在辅助索引上加锁,再在聚簇索引上加锁(两个位置都加锁)
Q167: 加锁规则是什么?(RR 级别下)
答案 :
在 RR 隔离级别下,加锁规则如下(基于索引):
| 操作 | 唯一索引等值 | 唯一索引范围 | 非唯一索引等值 | 非唯一索引范围 |
|---|---|---|---|---|
| SELECT ... FOR UPDATE | 行锁 | Next-Key Lock | Next-Key Lock + Gap Lock | Next-Key Lock |
| UPDATE | 行锁 | Next-Key Lock | Next-Key Lock + Gap Lock | Next-Key Lock |
| DELETE | 行锁 | Next-Key Lock | Next-Key Lock + Gap Lock | Next-Key Lock |
| INSERT | 行锁(检查唯一性) | --- | 插入意向锁 | --- |
唯一索引等值查询的优化:
- 查询的记录存在 → 退化为行锁(不需要间隙锁,因为不可能插入重复值)
- 查询的记录不存在 → 退化为间隙锁(锁住插入位置的间隙)
Q168: 什么是插入意向锁(Insert Intention Lock)?
答案 :
插入意向锁是 INSERT 操作在插入之前需要获取的一种特殊间隙锁。
作用:
- 表示事务打算在某个间隙中插入数据
- 插入意向锁之间不互斥(多个事务可以在同一间隙中插入不同的值)
- 但插入意向锁与间隙锁互斥(如果其他事务持有该间隙的间隙锁,则 INSERT 被阻塞)
底层实现:
- 插入意向锁存储在锁表中,key 是间隙的范围
- 与间隙锁的兼容性:间隙锁与插入意向锁互斥
Q169: 什么是谓词锁(Predicate Lock)?
答案 :
InnoDB 没有实现谓词锁。这是 Serializable 隔离级别下理论上需要的锁。
在 Serializable 隔离级别下:
- 所有 SELECT 自动加
LOCK IN SHARE MODE(S 锁) - 这等价于在查询条件涉及的所有记录和间隙上加锁
- 效果类似于谓词锁,但实现方式不同
Q170: 高并发下锁竞争如何优化?
答案:
- 减少锁持有时间:事务尽量短小
- 使用乐观锁:版本号机制,避免使用数据库锁
- 合理使用索引:避免无索引导致的表锁
- 降低隔离级别:RC 级别没有间隙锁,锁竞争更少
- 热点行更新优化 :
- 将热点数据分散到多行(如库存分 10 个子行)
- 使用 Redis 预减库存
- 增大 innodb_lock_wait_timeout:避免频繁超时报错
Q171: 如何分析死锁日志?
答案:
sql
SHOW ENGINE INNODB STATUS\G
找到 LATEST DETECTED DEADLOCK 部分:
*** (1) TRANSACTION: -- 事务1
TRANSACTION 12345, ACTIVE 0 sec
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140xxx
SELECT * FROM users WHERE id = 1 FOR UPDATE
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: -- 等待的锁
RECORD LOCKS space id 0 page no 100 n bits 72 index PRIMARY of table `test`.`users`
lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION: -- 事务2
TRANSACTION 12346, ACTIVE 0 sec
SELECT * FROM users WHERE id = 2 FOR UPDATE
*** (2) HOLDS THE LOCK(S): -- 持有的锁
RECORD LOCKS space id 0 page no 100 n bits 72 index PRIMARY of table `test`.`users`
lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 100 n bits 72 index PRIMARY of table `test`.`users`
lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1) -- 回滚事务1
分析要点:
- 找到两个事务分别持有的锁和等待的锁
- 判断是否形成环路
- 找到回滚的事务(通常是 undo 量最少的)
Q172: 什么是隐式锁(Implicit Lock)?
答案 :
隐式锁是 InnoDB 的优化机制,在某些情况下不需要显式加锁。
原理:
- 当一个事务插入一条新记录时,记录的
DB_TRX_ID是该事务的 ID - 其他事务在访问该记录时,发现
DB_TRX_ID是活跃事务 → 自动等待 - 不需要在锁表中显式创建锁记录
隐式锁转为显式锁:
- 当其他事务尝试对隐式锁保护的记录加锁时
- 隐式锁被转换为显式锁(X 锁)
- 这个过程叫做 implicit-to-explicit lock conversion
💡 要点:隐式锁减少了锁表的内存开销,因为只有在冲突时才创建锁记录。
十八、MVCC 底层实现深入
Q173: ReadView 的创建和销毁时机是什么?
答案:
| 隔离级别 | ReadView 创建时机 | ReadView 销毁时机 |
|---|---|---|
| RC | 每次 SELECT 临时创建 | SELECT 结束后销毁 |
| RR | 事务首次 SELECT 时创建 | 事务 COMMIT/ROLLBACK 时销毁 |
| Serializable | 同 RR | 同 RR |
底层实现:
- RR 级别:ReadView 存储在
trx_t->read_view字段 - RC 级别:ReadView 存储在
trx_t->read_view,但每次 SELECT 前重新初始化
Q174: MVCC 的版本链遍历性能如何优化?
答案 :
版本链过长会导致 MVCC 查询变慢(需要遍历多个版本)。
优化方法:
- 缩短版本链:避免长事务(长事务阻止 Purge,版本链变长)
- 加速 Purge :增大
innodb_purge_threads - 降低隔离级别:RC 级别下旧版本可以更快被 Purge
- 监控 History List Length:保持在 10 万以下
Q175: MVCC 在 Serializable 隔离级别下如何工作?
答案 :
Serializable 级别下:
- 所有 SELECT 自动加
LOCK IN SHARE MODE(S 锁) - 不走 MVCC 的快照读,而是当前读
- 读写完全互斥,保证最高隔离性
sql
-- Serializable 下的 SELECT
SELECT * FROM users WHERE id = 1;
-- 等价于
SELECT * FROM users WHERE id = 1 FOR SHARE;
Q176: MVCC 如何处理 DELETE 操作?
答案 :
DELETE 操作在 InnoDB 中是标记删除(不是物理删除):
- 将记录的删除标记(delete flag)设为 1
- 写入 Update Undo Log(记录删除前的版本)
- 记录的
DB_TRX_ID更新为当前事务 ID - 旧版本通过版本链保留,供其他事务的 ReadView 使用
物理删除(Purge):
- 当所有活跃 ReadView 都不再引用该版本时
- Purge 线程将记录从页中物理删除
Q177: MVCC 如何处理 UPDATE 操作?
答案 :
UPDATE 操作在 InnoDB 中是删除旧记录 + 插入新记录:
- 将旧记录标记删除
- 插入一条新记录(新值)
- 写入 Update Undo Log(记录旧值)
- 旧版本通过版本链保留
原地更新(In-place Update)vs 先删后插:
- 如果 UPDATE 没有修改索引列,且新旧记录大小相同 → 可能原地更新
- 如果修改了索引列,或新旧记录大小不同 → 先删后插
Q178: 什么是 ReadView 的可见性判断的"快照"?
答案 :
ReadView 的"快照"并不是数据的物理副本,而是一个事务 ID 的判断规则。
"快照"的含义:
- 创建 ReadView 时,记录当前所有活跃事务的 ID 列表(
m_ids) - 后续的所有读操作,根据这个列表判断版本的可见性
- 不可见的版本 → 沿着版本链找更旧的版本
- 这个过程不需要复制数据,只需要记录事务 ID
为什么叫"快照"?
- 因为 ReadView 固定了"当前哪些事务已提交、哪些未提交"的视图
- 后续读操作都基于这个固定的视图,就像看到了某个时刻的数据快照
十九、查询优化器深入
Q179: 优化器的代价模型是什么?
答案 :
MySQL 优化器使用代价模型(Cost-Based Optimizer, CBO) 选择最优执行计划。
代价的组成:
- I/O 代价:读取数据页的代价(主要部分)
- CPU 代价:比较、排序、计算的代价
代价计算示例:
全表扫描代价 = 表的数据页数 × 页读取代价
索引查找代价 = 索引树遍历代价 + 回表次数 × 回表代价
优化器选择代价最小的方案
统计信息 :
优化器依赖表的统计信息来估算代价:
innodb_table_stats:表的行数、数据大小innodb_index_stats:索引的选择性、页数ANALYZE TABLE:手动更新统计信息
Q180: 优化器如何选择索引?
答案 :
优化器选择索引时考虑以下因素:
- 索引的选择性:选择性越高,过滤效果越好
- 索引覆盖:如果索引包含所有查询字段,优先选择
- 索引的大小:索引越小,I/O 越少
- 统计信息:索引列的数据分布
- 查询条件:等值查询 vs 范围查询
索引选择错误的场景:
- 统计信息过期 →
ANALYZE TABLE更新 - 数据分布不均匀 → 优化器误判
- 多个索引可选 → 使用
FORCE INDEX或IGNORE INDEX指导
sql
-- 查看优化器选择的索引
EXPLAIN SELECT * FROM users WHERE city = '北京';
-- 强制使用指定索引
SELECT * FROM users FORCE INDEX(idx_city) WHERE city = '北京';
Q181: 什么是索引条件下推(ICP)的实现细节?
答案 :
ICP 在 InnoDB 的 row_search_mvcc() 函数中实现。
实现流程:
1. 优化器判断查询条件中的哪些部分可以在索引中评估
2. 将这些条件下推到存储引擎层
3. 存储引擎在遍历索引时:
a. 先检查索引列上的条件
b. 不满足 → 跳过该记录(不触发回表)
c. 满足 → 回表获取完整数据
4. 返回给 Server 层
ICP 的限制:
- 只对辅助索引有效
- 只能下推索引列上的条件
- 不能下推子查询条件
Q182: 什么是 MRR(Multi-Range Read)优化?
答案 :
MRR 是 MySQL 5.6 引入的优化,将随机 I/O 转换为顺序 I/O。
原理:
- 使用辅助索引查找到主键值列表
- 将主键值排序
- 按排序后的顺序在聚簇索引中回表
- 减少了随机 I/O(因为排序后的主键在聚簇索引中是顺序的)
sql
-- 查看 MRR 是否启用
EXPLAIN SELECT * FROM users WHERE age > 20 AND age < 30;
-- Extra 中显示 Using MRR 表示使用了 MRR 优化
MRR 的好处:
- 将随机回表转换为顺序回表
- 减少磁盘寻道时间
- 对 SSD 效果不明显,对 HDD 效果显著
Q183: 什么是 BKA(Batched Key Access)优化?
答案 :
BKA 是 MySQL 5.6 引入的 JOIN 优化,结合 MRR 优化 JOIN 的回表过程。
原理:
- 驱动表的结果集按 join key 排序
- 将排序后的 join key 批量发送给被驱动表
- 被驱动表使用 MRR 优化,批量回表
sql
-- 开启 BKA
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
Q184: 什么是子查询物化(Materialization)?
答案 :
子查询物化是优化器将子查询的结果集物化为临时表,然后在外层查询中使用。
物化的条件:
- 子查询的结果集较小
- 子查询中没有外部引用(非相关子查询)
- 物化后可以使用索引加速外层查询
物化的过程:
- 执行子查询,将结果写入内存临时表 (如果超过
tmp_table_size则写磁盘) - 在临时表上创建索引
- 外层查询通过临时表的索引匹配
sql
-- 查看是否使用了物化
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE city = '北京');
-- select_type = MATERIALIZED 表示使用了物化
二十、MySQL 8.0 新特性与高级主题
Q185: MySQL 8.0 有哪些重要新特性?
答案:
| 特性 | 说明 |
|---|---|
| 窗口函数 | ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD 等 |
| CTE(公共表表达式) | WITH 语法,支持递归查询 |
| 降序索引 | INDEX(a ASC, b DESC) |
| 不可见索引 | ALTER TABLE t ALTER INDEX idx INVISIBLE |
| 原子 DDL | DDL 操作原子性(基于事务性数据字典) |
| Hash Join | 优化无索引的 JOIN |
| 资源组 | 控制线程的 CPU 资源 |
| JSON 增强 | JSON 表函数、多值索引 |
| 窗口函数 | 不需要 GROUP BY 就能做聚合 |
| 角色 | 权限管理的简化 |
| 数据字典改进 | 移除 .frm 文件,使用事务性数据字典 |
Q186: 什么是窗口函数?
答案 :
窗口函数在不减少结果集行数的情况下,对每一行计算聚合/排名值。
sql
-- ROW_NUMBER:行号
SELECT
name, salary, department,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
FROM employees;
-- RANK:排名(有并列会跳号)
RANK() OVER (ORDER BY salary DESC)
-- DENSE_RANK:密集排名(有并列不跳号)
DENSE_RANK() OVER (ORDER BY salary DESC)
-- LAG/LEAD:前一行/后一行的值
LAG(salary, 1) OVER (ORDER BY id) AS prev_salary
与 GROUP BY 的区别:
- GROUP BY:减少行数(每个分组一行)
- 窗口函数:不减少行数(每行都有计算值)
Q187: 什么是 CTE(公共表表达式)?
答案 :
CTE 是 MySQL 8.0 引入的临时命名结果集,提高 SQL 可读性。
sql
-- 非递归 CTE
WITH dept_stats AS (
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
)
SELECT e.name, e.salary, d.avg_salary
FROM employees e
JOIN dept_stats d ON e.department = d.department;
-- 递归 CTE(查找组织架构)
WITH RECURSIVE org_tree AS (
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, t.level + 1
FROM employees e
JOIN org_tree t ON e.manager_id = t.id
)
SELECT * FROM org_tree;
Q188: 什么是不可见索引(Invisible Index)?
答案 :
不可见索引是 MySQL 8.0 引入的特性,可以让索引对优化器不可见,但仍然被维护。
sql
-- 将索引设为不可见
ALTER TABLE users ALTER INDEX idx_city INVISIBLE;
-- 将索引设为可见
ALTER TABLE users ALTER INDEX idx_city VISIBLE;
-- 查看索引可见性
SHOW INDEX FROM users;
-- Visible 字段显示 YES/NO
使用场景:
- 删除索引前先设为不可见,观察一段时间
- 如果没有性能影响,再真正删除
- 避免删除索引后发现性能下降,需要重建
Q189: 什么是 JSON 类型和多值索引?
答案 :
MySQL 5.7+ 支持 JSON 数据类型,8.0 引入多值索引。
sql
-- JSON 类型
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
metadata JSON
);
INSERT INTO orders VALUES (1, '{"tags": ["electronics", "mobile"], "price": 999}');
-- JSON 查询
SELECT * FROM orders WHERE JSON_EXTRACT(metadata, '$.price') > 500;
-- 多值索引(MySQL 8.0.17+)
ALTER TABLE orders ADD INDEX idx_tags((CAST(metadata->'$.tags' AS CHAR(50) ARRAY)));
-- 使用多值索引查询
SELECT * FROM orders WHERE 'electronics' MEMBER OF(metadata->'$.tags');
Q190: MySQL 8.0 的原子 DDL 是怎么实现的?
答案 :
MySQL 8.0 的 DDL 操作是原子性的(要么完全成功,要么完全回滚)。
实现机制:
- DDL 操作记录到
mysql.innodb_ddl_log表中 - DDL 执行过程中的所有修改都在事务中
- 如果崩溃,InnoDB 通过 DDL Log 回滚或重做
对比 MySQL 5.7:
- MySQL 5.7 的 DDL 不是原子的:
DROP TABLE t1, t2如果在 t1 删除后崩溃,t2 不会被删除 - MySQL 8.0 的 DDL 是原子的:要么 t1 和 t2 都删除,要么都不删除
Q191: 什么是 Hash Join?
答案 :
Hash Join 是 MySQL 8.0.18 引入的 JOIN 优化,替代 BNL(Block Nested Loop)。
适用场景:
- 等值 JOIN 条件
- 被驱动表没有索引
原理:
- 将较小的表(驱动表)构建为哈希表(build phase)
- 遍历较大的表(被驱动表),在哈希表中查找匹配(probe phase)
- 复杂度 O(N + M),比 BNL 的 O(N × M) 快很多
sql
-- Hash Join 自动启用
EXPLAIN SELECT * FROM t1 JOIN t2 ON t1.id = t2.id;
-- Extra 中显示 Using hash join
Q192: MySQL 的字符集和排序规则是什么?
答案:
字符集(Character Set):
utf8:最多 3 字节,不支持 4 字节字符(如 emoji)utf8mb4:最多 4 字节,支持所有 Unicode 字符(推荐)latin1:1 字节,只支持西欧字符
排序规则(Collation):
utf8mb4_general_ci:通用排序,性能好但不精确utf8mb4_unicode_ci:Unicode 标准排序,更精确utf8mb4_bin:二进制比较,区分大小写utf8mb4_0900_ai_ci:MySQL 8.0 默认,基于 Unicode 9.0
字符集的影响:
- 影响索引大小:
utf8mb4的 VARCHAR(255) 索引最多 255×4+2=1022 字节 - 影响比较规则:
_ci不区分大小写,_bin区分大小写 - 不同字符集的字段 JOIN 会导致隐式转换,索引失效
Q193: MySQL 的连接管理是怎样的?
答案 :
每个 MySQL 连接对应一个操作系统线程。
连接的生命周期:
- 客户端发起连接 → 主线程接受
- 从线程缓存中取空闲线程(或创建新线程)
- 进行身份认证(基于
mysql.user表) - 分配连接资源(会话级变量、临时表等)
- 等待客户端发送 SQL
- 处理 SQL → 返回结果 → 等待下一条 SQL
- 连接断开 → 线程返回缓存(或销毁)
相关参数:
max_connections:最大连接数thread_cache_size:线程缓存大小wait_timeout:空闲连接超时时间
Q194: 什么是连接池?为什么需要?
答案 :
连接池在应用端维护一组预创建的数据库连接,避免频繁创建和销毁连接的开销。
为什么需要:
- 创建连接需要 TCP 握手 + 认证,耗时约 1~5ms
- 高并发下频繁创建连接会消耗大量资源
- 连接池复用连接,减少开销
常见连接池:
- Java:HikariCP(推荐)、Druid、DBCP
- Go:database/sql 内置连接池
- Python:SQLAlchemy 内置连接池
Q195: MySQL 的权限系统是怎样的?
答案 :
MySQL 的权限系统基于权限表 ,存储在 mysql 数据库中。
权限表:
mysql.user:用户级权限(全局)mysql.db:数据库级权限mysql.tables_priv:表级权限mysql.columns_priv:列级权限
权限检查流程:
- 连接时检查
mysql.user表的认证信息 - 执行 SQL 时,按层级检查权限:
- 全局权限(
GRANT ALL ON *.*) - 数据库权限(
GRANT ALL ON db.*) - 表权限(
GRANT ALL ON db.table) - 列权限(
GRANT SELECT(col1) ON db.table)
- 全局权限(
sql
-- 创建用户
CREATE USER 'app'@'%' IDENTIFIED BY 'password';
-- 授权
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'app'@'%';
-- 刷新权限
FLUSH PRIVILEGES;
Q196: 什么是角色(Role)?
答案 :
角色是 MySQL 8.0 引入的权限管理简化机制。
sql
-- 创建角色
CREATE ROLE 'app_read', 'app_write';
-- 给角色授权
GRANT SELECT ON mydb.* TO 'app_read';
GRANT INSERT, UPDATE, DELETE ON mydb.* TO 'app_write';
-- 将角色赋给用户
GRANT 'app_read', 'app_write' TO 'app'@'%';
-- 激活角色
SET DEFAULT ROLE ALL TO 'app'@'%';
Q197: MySQL 的安全最佳实践有哪些?
答案:
- 使用强密码 :
validate_password插件强制密码复杂度 - 最小权限原则:只授予必要的权限
- 禁止 root 远程登录 :
GRANT ALL ON *.* TO 'root'@'localhost' - 使用 SSL 加密连接 :
REQUIRE SSL - 定期审计 :
audit_log插件 - 数据加密:TDE(透明数据加密)+ 列级加密
- SQL 注入防护:使用参数化查询
Q198: 什么是资源组(Resource Group)?
答案 :
资源组是 MySQL 8.0 引入的特性,允许将线程绑定到特定的 CPU 核心。
sql
-- 创建资源组
CREATE RESOURCE GROUP batch_group
TYPE = USER
VCPU = 4-7
THREAD_PRIORITY = 10;
-- 将线程分配到资源组
SET RESOURCE GROUP batch_group FOR thread_id;
使用场景:
- 将后台任务(如报表查询)绑定到特定 CPU 核心
- 避免后台任务影响 OLTP 查询的性能
Q199: 如何设计一个高性能的分页查询?
答案:
方案1:基于游标(推荐)
sql
-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 10;
-- 记录最后一条的 id = 100
-- 下一页
SELECT * FROM orders WHERE id > 100 ORDER BY id LIMIT 10;
-- 优势:无论第几页,性能稳定(利用主键索引)
方案2:延迟关联
sql
SELECT * FROM orders t
INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) tmp
ON t.id = tmp.id;
-- 优势:子查询使用覆盖索引,只回表 10 行
方案3:禁止跳页
sql
-- 只支持"上一页/下一页",不支持"跳到第N页"
-- 前端隐藏页码,只显示"上一页/下一页"按钮
Q200: 如何进行 MySQL 的性能基准测试?
答案:
工具:
-
sysbench:最常用的 MySQL 基准测试工具
bashsysbench oltp_read_write --mysql-host=localhost --mysql-user=root \ --mysql-password=pass --mysql-db=test --tables=10 --table-size=100000 \ --threads=16 --time=300 run -
mysqlslap:MySQL 自带的压力测试工具
bashmysqlslap --concurrency=50 --iterations=10 --query="SELECT * FROM users WHERE id=RAND()*10000" -
tpcc-mysql:TPC-C 标准的 OLTP 测试
-
HammerDB:支持多种数据库的基准测试工具
测试指标:
- QPS(Queries Per Second):每秒查询数
- TPS(Transactions Per Second):每秒事务数
- 响应时间:P50、P95、P99
- 并发数:同时执行的线程数
测试方法:
- 预热:先运行一段时间,让 Buffer Pool 充分加载
- 多次测试取平均值
- 控制变量:只改变一个参数,观察性能变化
- 监控系统资源:CPU、内存、磁盘 I/O、网络
附录:速查表
隔离级别速查
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现机制 | MySQL 默认 |
|---|---|---|---|---|---|
| Read Uncommitted | ✅ | ✅ | ✅ | 不加锁 | --- |
| Read Committed | ❌ | ✅ | ✅ | MVCC(每次 ReadView) | Oracle 默认 |
| Repeatable Read | ❌ | ❌ | ⚠️ | MVCC(首次 ReadView)+ Gap Lock | MySQL 默认 |
| Serializable | ❌ | ❌ | ❌ | 所有读加 S 锁 | --- |
索引类型速查
| 类型 | 结构 | 适用 | 叶子节点内容 |
|---|---|---|---|
| 聚簇索引 | B+ 树 | 主键查询 | 完整数据行 |
| 辅助索引 | B+ 辅助索引 | 辅助索引查询 | 主键值 |
| Hash 索引 | 哈希表 | 等值查询(Memory) | 数据指针 |
| 全文索引 | 倒排索引 | 文本搜索 | 文档ID列表 |
| 前缀索引 | B+ 树(截断) | 长字符串 | 前缀+主键值 |
锁类型速查
| 锁 | 粒度 | 互斥关系 | 触发条件 |
|---|---|---|---|
| S 锁 | 行 | 与 X 锁互斥 | SELECT ... FOR SHARE |
| X 锁 | 行 | 与所有锁互斥 | SELECT ... FOR UPDATE, UPDATE, DELETE |
| IS 锁 | 表 | 与 X 锁互斥 | 自动(意向锁) |
| IX 锁 | 表 | 与 S、X 锁互斥 | 自动(意向锁) |
| Gap Lock | 间隙 | 只与插入意向锁互斥 | RR 级别范围查询 |
| Next-Key Lock | 行+间隙 | 行锁+间隙锁组合 | RR 级别默认 |
| Insert Intention | 间隙 | 与 Gap Lock 互斥 | INSERT 操作 |
EXPLAIN type 速查(从好到差)
system > const > eq_ref > ref > fulltext > ref_or_null
> index_merge > unique_subquery > index_subquery
> range > index > ALL
InnoDB 存储结构速查
Tablespace → Segment → Extent(1MB) → Page(16KB) → Row
64 pages File Header(38B)
Page Header(56B)
User Records
Page Directory
File Trailer(8B)
日志系统速查
| 日志 | 层级 | 类型 | 写入方式 | 作用 |
|---|---|---|---|---|
| Redo Log | InnoDB | 物理日志 | 循环写 | 崩溃恢复 |
| Undo Log | InnoDB | 逻辑日志 | 链表 | 回滚+MVCC |
| Binlog | Server | 逻辑日志 | 追加写 | 复制+恢复 |
| Relay Log | 从库 | 逻辑日志 | 追加写 | 复制中继 |
📝 总结 :本文共 200 道 MySQL 面试题,覆盖从基础概念到底层原理的全部知识点。重点掌握:B+ 树索引原理、InnoDB 存储结构、事务 ACID 实现、MVCC 版本链与 ReadView、Redo/Undo/Binlog 三日志、锁机制与死锁、主从复制、SQL 优化。面试时做到"知其然,知其所以然",能从底层原理层面解释每一个概念。