mysql高频八股问答200

🎯 适用:后端开发、Java/Go/Python 全栈、DBA、架构师岗位面试

📖 内容覆盖:基础概念 · 存储引擎 · 索引原理 · 事务与锁 · SQL优化 · 日志系统 · 主从复制 · 分库分表 · InnoDB底层结构 · Buffer Pool · MVCC底层 · 优化器原理 · 实战场景


目录


一、基础概念

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

底层执行流程详解

  1. 连接器 :验证用户名密码,查询 mysql.user 表获取权限,分配线程(thread_cache_size 控制复用)
  2. 解析器:词法分析(识别 token)→ 语法分析(生成 AST 语法树)→ 语义检查
  3. 优化器 :基于代价模型(Cost-Based Optimizer, CBO) 选择最优执行计划,参考表统计信息(innodb_stats_persistent
  4. 执行器 :检查表权限 → 调用存储引擎的 Handler API(index_readrnd_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 参与运算结果仍为 NULL1 + 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.tablesmysql.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_stats
  • DROP:删除表的数据文件(.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(*) 实现:

  1. 优化器选择最小的辅助索引(而非主键索引),因为辅助索引的 B+ 树更小
  2. 遍历该索引的叶子节点链表,逐页读取并计数
  3. 不读取数据行,只统计索引记录数
  4. 时间复杂度 O(N),N 为索引记录数

为什么不存储行数? MyISAM 将表的总行数存储在表的元数据中,所以 COUNT(*) 是 O(1)。但 InnoDB 支持 MVCC,不同事务看到的行数可能不同(因为有未提交的删除/插入),所以无法维护一个全局行数。

💡 要点 :InnoDB 中 COUNT(*) 并不读取所有列,而是遍历最小的辅助索引树,效率很高。


Q10: MySQL 中 INEXISTS 的区别?

答案

  • 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 的执行过程:

  1. 执行两个子查询
  2. 将结果写入临时表(Temporary Table)
  3. 对临时表做 GROUP BY 或排序去重
  4. 返回去重后的结果

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 本地连接(跳过网络层,性能更高)。

底层连接流程

  1. 客户端发起 TCP 连接到 3306 端口
  2. MySQL 的主线程(main thread) 接受连接
  3. 线程池中分配一个工作线程(或创建新线程)
  4. 工作线程进行握手认证(Challenge-Response 机制,基于 SHA256)
  5. 认证成功后,客户端发送 SQL,工作线程处理

Q14: MySQL 中 FLOATDOUBLEDECIMAL 的区别?

答案

  • 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 连接对应一个操作系统线程。连接过多会导致:

  1. 线程切换开销增大(CPU 调度成本)
  2. 每个线程至少占用 sort_buffer_size + join_buffer_size + read_buffer_size 等内存
  3. 超过 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 推荐使用自增主键?

答案

  1. 顺序插入 :自增主键保证数据按顺序写入 B+ 树的尾部,避免频繁页分裂(Page Split)
  2. 空间效率:BIGINT 自增主键占 8 字节,比 UUID(36 字节)节省空间
  3. 索引效率:主键越小,二级索引的叶子节点存储的主键值也越小,整体索引体积更小
  4. 避免随机 I/O:UUID 作为主键会导致随机插入,产生大量随机磁盘 I/O

页分裂的底层过程

当向一个满页(16KB)插入新记录时:

  1. 如果插入位置在页尾 → 直接追加,无分裂
  2. 如果插入位置在页中间 → 需要页分裂
    a. 分配一个新的数据页
    b. 将原页的后半部分记录移动到新页
    c. 更新父节点的索引(插入新的指针)
    d. 如果父节点也满了,递归分裂
  3. 页分裂导致:随机 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 中时:

  1. 不立即从磁盘读取该页(避免随机 I/O)
  2. 将修改操作记录到 Change Buffer(内存结构)
  3. Change Buffer 会定期合并(Merge) 到实际的索引页
  4. Merge 的触发时机:页被读取到 Buffer Pool 时、后台线程定期合并、数据库关闭时

为什么只对非唯一索引有效?

  • 唯一索引插入时需要立即检查唯一性,必须读取磁盘上的索引页
  • 非唯一索引不需要检查唯一性,可以延迟写入

Change Buffer 的数据持久化

Change Buffer 的变更记录会写入系统表空间ibdata1),崩溃恢复时可以重建。

💡 要点:写多读少的场景(如日志表),Change Buffer 效果最明显。如果表有大量唯一索引,Change Buffer 几乎无用。


Q22: MySQL 8.0 为什么移除了查询缓存(Query Cache)?

答案

查询缓存在 8.0 中被完全移除,原因:

  1. 命中率低:只要表有更新,该表的所有查询缓存全部失效(表级失效粒度太粗)
  2. 锁竞争 :查询缓存使用全局锁(query_cache_lock),在高并发下成为瓶颈
  3. 维护成本高:每次查询都要检查缓存,更新时要清理缓存
  4. 适用场景窄 :只适用于读多写极少的场景

底层问题

  • 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+ 树的底层优势

  1. 非叶子节点只存 key,不存数据 → 每个节点能容纳更多 key → 树更矮 → I/O 更少
    • B 树非叶子节点也存数据 → 每个节点能存的 key 更少 → 树更高
  2. 叶子节点通过双向链表连接 → 天然支持范围查询和 ORDER BY
    • B 树的范围查询需要中序遍历,可能跨层跳转
  3. 所有查询都走到叶子节点 → 查询性能稳定(O(logN))
    • B 树可能在非叶子节点就找到数据,性能不稳定
  4. 节点大小对齐磁盘页(通常 16KB)→ 单次 I/O 读取一个完整节点
  5. 叶子节点的双向链表 → 支持倒序扫描(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+ 树叶子节点链表,不需要访问聚簇索引。省去的回表过程包括:

  1. 省去了在聚簇索引中定位记录的 B+ 树查找(3~4 次 I/O)
  2. 省去了读取完整数据行的 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 才是有序的;只有在 ab 都确定的情况下,c 才是有序的
  • 跳过 a 直接查 bb 在全局范围内是无序的,无法利用 B+ 树的有序性

范围查询断点 :当遇到范围查询(><BETWEENLIKE '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 之前)

  1. 存储引擎根据 city LIKE '北%' 在索引中找到所有匹配记录
  2. 对每条记录回表获取完整数据行
  3. 返回给 Server 层
  4. Server 层再过滤 age > 25

有 ICP(MySQL 5.6+)

  1. 存储引擎在索引中同时过滤 city LIKE '北%'age > 25
  2. 只对两个条件都满足的记录回表
  3. 返回给 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:索引范围查询(><BETWEENIN
  • index:全索引扫描(遍历整个索引树)
  • ALL:全表扫描(最差)

💡 要点type = ALLrows 很大时,说明全表扫描,需要优化。


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: 联合索引的列顺序如何设计?

答案

设计原则(按优先级):

  1. 等值查询的列放前面,范围查询的列放后面
  2. 选择性高的列放前面(区分度大的列排在前面)
  3. 查询频率高的列放前面
  4. 考虑覆盖索引的需求
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 语句?

答案

  1. 利用索引的有序性:索引本身是有序的,如果 ORDER BY 的列在索引中,可以避免额外排序
  2. Using filesort 优化
    • 增大 sort_buffer_size(默认 256KB),使排序在内存中完成
    • 如果排序数据量超过 sort_buffer_size,会使用磁盘临时文件归并排序
    • 使用覆盖索引减少排序字段
  3. 避免 SELECT *:只选需要的列,减少排序数据量
  4. 联合索引优化ORDER BY a, b 可以利用 INDEX(a, b)

filesort 的底层算法

  • 单次传输排序(推荐):一次读取所有需要的列 → 排序 → 返回结果
  • 双次传输排序(旧版):第一次读取排序字段和行指针 → 排序 → 第二次按排序结果读取完整数据
sql 复制代码
-- EXPLAIN 中出现 Using filesort 表示需要额外排序
-- 出现 Using index 表示使用了覆盖索引

Q36: 如何优化 GROUP BY 语句?

答案

  1. GROUP BY 的列如果有索引,可以避免临时表和排序(索引本身就是有序的)
  2. 如果不需要排序,加 ORDER BY NULL(MySQL 5.7 之前)或 ORDER BY NULL(8.0 默认不再排序)
  3. 使用覆盖索引减少回表
  4. 适当增大 tmp_table_sizemax_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 查询?

答案

  1. 被驱动表的 JOIN 字段必须有索引
  2. 小表驱动大表:小结果集作为外层循环(驱动表)
  3. 避免过多 JOIN:一般不超过 3~5 张表
  4. 使用 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:

  1. 将驱动表的数据加载到 join_buffer
  2. 扫描被驱动表,每行与 join_buffer 中的所有行比较
  3. 复杂度: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 辅助索引查询的主要性能瓶颈

避免回表的方法

  1. 覆盖索引 :查询字段全部在索引中,Extra: Using index
  2. 合理设计联合索引:将高频查询字段加入索引
  3. 减少 SELECT 的列 :避免 SELECT *
sql 复制代码
-- 需要回表
SELECT * FROM users WHERE city = '北京';

-- 覆盖索引,不回表
SELECT id, city, name FROM users WHERE city = '北京';
-- 索引 INDEX(city, name)

Q39: 索引建多了有什么影响?

答案

  1. 写入性能下降 :每次 INSERT/UPDATE/DELETE 都要维护所有相关索引的 B+ 树
    • INSERT:可能触发页分裂、Change Buffer 合并
    • UPDATE:如果修改了索引列,需要删除旧索引项 + 插入新索引项
    • DELETE:标记删除 + 可能的页合并
  2. 存储空间增加:索引文件可能比数据文件还大
  3. Buffer Pool 利用率下降:更多索引页占据内存,热数据被挤出
  4. 优化器选择困难:索引过多可能导致优化器选错索引(统计信息不准确)
  5. 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: 什么情况下使用索引反而更慢?

答案

  1. 数据量很小的表(几百行),全表扫描比走索引更快(因为索引查找需要额外的 B+ 树遍历 I/O)
  2. 查询返回大部分数据 (如 WHERE status > 0,90% 的数据满足条件),优化器会选择全扫
  3. 写入远多于查询的表,索引维护成本高于收益
  4. 频繁更新的列,索引需要频繁删除旧项+插入新项
  5. 数据分布不均匀时,优化器可能误判

底层原理 :优化器使用代价模型评估执行计划:

  • 全表扫描代价 = 表的数据页数 × 页读取代价
  • 索引查找代价 = 索引树遍历代价 + 回表次数 × 回表代价
  • 当回表次数接近总行数时,索引查找代价 > 全表扫描代价

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 多一行),所以无法维护一个全局行数。

优化方案:

  1. 使用辅助索引而非主键索引(辅助索引更小,遍历更快)
  2. 维护计数表:用触发器或应用层维护行数
  3. 使用近似值SHOW TABLE STATUS LIKE 'table_name' 中的 Rows 字段(不精确,基于采样估算)
  4. 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: 什么情况下不推荐使用索引?

答案

  1. 表数据量很小(< 几百行)
  2. 频繁大量写入、极少查询的表
  3. 选择性极低的字段(如性别,只有男/女)
  4. 已有覆盖索引可以满足查询需求
  5. 查询条件返回表中大部分数据
  6. 表经常被 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 UPDATEINSERTUPDATEDELETE 等当前读操作
  • 使用 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: 事务长时间不提交会怎样?

答案

  1. 锁长时间持有:其他事务可能被阻塞,甚至死锁
  2. Undo Log 膨胀:MVCC 需要保留旧版本(被其他事务的 ReadView 引用),导致 undo 表空间增长
  3. 连接被占用:连接池资源耗尽
  4. 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 UPDATEUPDATEDELETE
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 作为兜底

避免死锁的方法

  1. 按固定顺序访问表和行(避免交叉等待)
  2. 缩小事务范围,减少锁持有时间
  3. 使用合理的索引,避免锁升级为表锁
  4. 设置锁等待超时 innodb_lock_wait_timeout(默认 50 秒)
  5. 避免大事务,拆分为多个小事务
  6. 使用 乐观锁 替代悲观锁(版本号机制)

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: 什么情况下会触发表锁?

答案

  1. MyISAM 引擎:所有操作都是表锁
  2. InnoDB 无索引的 UPDATE/DELETE:行锁退化为表锁(锁住聚簇索引的所有记录)
  3. DDL 操作(ALTER TABLE、DROP TABLE):加表级元数据锁(MDL 写锁)
  4. LOCK TABLES 显式加表锁
  5. 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 实现高并发的核心机制。

核心思想 :每个数据行保留多个历史版本 ,读操作读取旧版本(快照读),写操作创建新版本,读写互不阻塞

实现三要素:

  1. 隐藏字段 :每行数据有 DB_TRX_ID(最后修改的事务ID)和 DB_ROLL_PTR(回滚指针,指向 Undo Log 中的旧版本)
  2. Undo Log :存储数据的旧版本,形成版本链
  3. 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 实现的核心组件:

  1. 存储旧版本数据:每次修改时,将修改前的值写入 Undo Log
  2. 形成版本链 :通过 DB_ROLL_PTR 指针将同一行数据的多个版本串联起来
  3. 支持事务回滚:回滚时根据 Undo Log 恢复数据
  4. 支持快照读: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 UPDATESELECT ... FOR SHAREINSERTUPDATEDELETE
加锁 ❌ 不加锁 ✅ 加锁(行锁/间隙锁/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)

  1. 事务修改数据时,先修改内存(Buffer Pool)中的数据页(标记为脏页)
  2. 同时将修改操作写入 Redo Log Buffer
  3. 事务提交时,将 Redo Log 刷入磁盘(fsync)
  4. 后台线程择机将脏页刷回磁盘(Checkpoint)

为什么需要 Redo Log?

  • 随机 I/O vs 顺序 I/O :修改数据页是随机写(数据页分散在磁盘各处),写 Redo Log 是顺序写(日志文件连续),顺序写比随机写快 10~100 倍
  • 刷脏页是异步的:Buffer Pool 中的脏页由后台线程择机刷盘,如果不使用 Redo Log,崩溃时会丢失尚未刷盘的数据
  • Redo Log 是同步的:事务提交时必须 fsync Redo Log,保证数据不丢失

Redo Log 的底层结构

  • 由两个固定大小的文件组成(ib_logfile0ib_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 的作用是什么?

答案

  1. 事务回滚:事务失败时,根据 Undo Log 恢复数据到修改前的状态
  2. MVCC:存储数据的历史版本,支持快照读
  3. 崩溃恢复:未提交事务的 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 的作用和格式?

答案

作用

  1. 主从复制:从库通过重放 Binlog 同步数据
  2. 数据恢复 :通过 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 自带的慢查询日志分析工具

    bash 复制代码
    mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log  # 按时间排序取前10
  • pt-query-digest:Percona Toolkit 中的更强大的分析工具

    bash 复制代码
    pt-query-digest /var/lib/mysql/slow.log

Q86: MySQL 如何保证数据不丢失?

答案

通过 Redo Log 的 WAL 机制保证:

  1. 事务提交时,Redo Log 必须先于数据页刷入磁盘
  2. 即使数据页还没刷入磁盘就崩溃了,重启后可以通过 Redo Log 重放恢复
  3. 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: 慢查询优化的一般步骤?

答案

  1. 开启慢查询日志,定位慢 SQL
  2. 使用 EXPLAIN 分析执行计划
  3. 检查是否使用了索引(type 是否为 ALL)
  4. 优化 SQL 语句:避免 SELECT *、减少子查询、改写 OR
  5. 优化索引:添加缺失索引、调整联合索引顺序
  6. 优化表结构:合理分表、归档历史数据
  7. 优化数据库配置:Buffer Pool 大小、连接数等

Q90: 如何优化 SELECT * ?

答案

SELECT * 的问题:

  1. 消耗更多网络带宽和内存
  2. 无法使用覆盖索引,必须回表
  3. 表结构变更后可能影响应用代码
  4. 传输大字段(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?

答案

  1. 合并多条 INSERTINSERT INTO t VALUES (1,'a'), (2,'b'), (3,'c');(减少网络往返和事务开销)
  2. 关闭自动提交:手动事务包裹大批量插入
  3. 关闭唯一性检查SET UNIQUE_CHECKS = 0;(避免逐行检查唯一索引)
  4. 关闭外键检查SET FOREIGN_KEY_CHECKS = 0;
  5. 使用 LOAD DATA INFILE:比 INSERT 快 20 倍(直接生成数据页,跳过 SQL 解析)
  6. 调整 bulk_insert_buffer_size
  7. 按主键顺序插入(减少页分裂)

Q95: 如何优化 UPDATE/DELETE?

答案

  1. 避免全表更新:加 WHERE 条件
  2. 使用索引字段做 WHERE 条件:避免行锁退化为表锁
  3. 大批量操作分批执行:每批 1000~5000 行
  4. 避开高峰期执行
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?

答案

  1. 小表驱动大表:小结果集在外层
  2. JOIN 字段必须有索引
  3. 减少 JOIN 表的数量(一般不超过 3~5 张)
  4. 避免在 ON 条件中使用函数
  5. 使用 STRAIGHT_JOIN 强制驱动表顺序
  6. 增大 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 解析树转换为最优执行计划

优化过程

  1. 常量折叠WHERE 1=1 AND id = 5WHERE id = 5
  2. 等值传播WHERE a = b AND b = 5a = 5 AND b = 5
  3. 子查询转换:将 IN 子查询转换为 semi-join
  4. JOIN 顺序优化:选择最优的表连接顺序(NP 问题,使用贪心/动态规划)
  5. 索引选择:根据统计信息选择最合适的索引
  6. 代价估算:估算每种执行计划的 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: 主从复制延迟的原因和解决方案?

答案

原因

  1. 从库 SQL Thread 单线程重放(MySQL 5.6 之前)
  2. 从库机器配置低(CPU/磁盘/内存不如主库)
  3. 大事务(如一次 DELETE 百万行,从库需要重放百万行变更)
  4. 从库负载过高(大量读请求消耗资源)
  5. 网络延迟

解决方案

  1. 并行复制 (MySQL 5.7+ slave_parallel_workers,按组提交并行重放)
  2. 提升从库硬件配置
  3. 拆分大事务
  4. 读写分离,降低从库压力
  5. 监控 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: 如何实现读写分离?

答案

读写分离是将写操作发往主库,读操作发往从库

实现方式:

  1. 应用层实现:代码中判断 SQL 类型,路由到不同数据源(灵活但侵入性强)
  2. 中间件代理:MyCat、ProxySQL、ShardingSphere-Proxy(对应用透明)
  3. 驱动层实现:ShardingSphere-JDBC(Java 应用内嵌,性能好)

Q105: 什么是 GTID 复制?

答案

GTID(Global Transaction Identifier)是 MySQL 5.6 引入的全局事务标识符。

格式:server_uuid:transaction_id(如 3E11FA47-71CA-11E1-9E33-C80AA9429562:23

优势:

  1. 自动定位 :从库自动找到需要重放的事务位置,无需手动指定 MASTER_LOG_FILEMASTER_LOG_POS
  2. 简化故障切换:新主库自动同步
  3. 保证唯一性:每个事务全局唯一
  4. 跳过重复事务:从库自动跳过已执行的 GTID
sql 复制代码
-- 开启 GTID
gtid_mode = ON
enforce_gtid_consistency = ON

Q106: 什么是 MHA?

答案

MHA(Master High Availability)是 MySQL 高可用的经典方案。

工作原理

  1. 监控主库健康状态(心跳检测)
  2. 主库故障时,自动将数据最新的从库提升为新主库
  3. 其他从库切换到新主库(CHANGE MASTER TO)
  4. 补偿主从差异数据(从旧主库的 Binlog 中找到未同步的事务)

组件

  • MHA Manager:监控节点,管理故障切换
  • MHA Node:部署在每个 MySQL 节点上的 Agent

Q107: 什么是 ProxySQL?

答案

ProxySQL 是一个高性能的 MySQL 中间件代理,支持:

  • 读写分离:将写请求路由到主库,读请求路由到从库
  • 查询路由:基于规则将不同 SQL 路由到不同后端
  • 连接池:复用后端连接
  • 查询缓存:缓存查询结果
  • 故障检测:自动检测后端节点健康状态
  • 查询重写:在代理层修改 SQL

Q108: 如何保证主从数据一致性?

答案

  1. 使用半同步复制:保证至少一个从库收到 Binlog
  2. 使用 GTID 复制:简化复制管理
  3. 定期校验 :使用 pt-table-checksum 检查主从数据一致性
  4. 自动修复 :使用 pt-table-sync 修复不一致数据
  5. 监控复制延迟Seconds_Behind_Masterpt-heartbeat
  6. 关键操作强制读主库

十、分库分表与架构设计

Q109: 什么时候需要分库分表?

答案

当单库或单表遇到以下瓶颈时:

  1. 单表数据量过大(> 2000 万行),查询变慢(B+ 树层数增加)
  2. 单库写入 QPS 过高,单机无法承载
  3. 单库存储空间不足
  4. 单表索引太大,Buffer Pool 无法缓存

判断标准

  • 单表 > 2000 万行 → 考虑分表
  • 单库写入 > 5000 QPS → 考虑分库

Q110: 水平分表和垂直分表的区别?

答案

对比项 水平分表 垂直分表
方式 按行拆分,同一张表结构 按列拆分,不同表结构
示例 订单表按 user_id % 8 拆为 8 张表 将用户表的大字段(bio、avatar)拆到扩展表
解决问题 单表数据量过大 单行字段过多、冷热数据分离

Q111: 水平分库分表的常见分片策略?

答案

策略 说明 适用场景
Hash 取模 id % N 数据均匀分布,但扩容困难
范围分片 按 ID 范围或时间范围 时间序列数据,扩容方便
一致性 Hash Hash 环 扩容方便,数据迁移量小
基因法 分片键嵌入 ID 中 跨分片查询方便

💡 要点:分片键的选择至关重要,尽量选择查询频率最高的字段。


Q112: 分库分表后会带来哪些问题?

答案

  1. 分布式 ID 问题:自增 ID 不再全局唯一
  2. 跨分片查询:JOIN 和聚合操作变复杂
  3. 分布式事务:无法使用本地事务
  4. 分页排序困难:需要合并多个分片的结果
  5. 扩容复杂:需要数据迁移

分布式 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 加字段在大表上会长时间锁表(复制数据到新表)。

方案:

  1. MySQL 8.0 Instant DDLALTER TABLE t ADD COLUMN c INT DEFAULT 0, ALGORITHM=INSTANT;(毫秒级,只修改元数据)
  2. Online DDLALTER TABLE t ADD COLUMN c INT, ALGORITHM=INPLACE, LOCK=NONE;(不锁表,但可能需要复制数据)
  3. pt-online-schema-change(Percona 工具):创建新表 → 复制数据 → 触发器同步变更 → 重命名
  4. 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;

根本解决方案:

  1. 增大 max_connections(但每个连接消耗内存)
  2. 使用连接池(如 HikariCP、Druid)
  3. 优化慢查询,减少连接占用时间
  4. 排查连接泄漏(应用未正确关闭连接)

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;

优化措施

  1. Redis 预减库存:请求先到 Redis 扣减,减少数据库压力
  2. 异步下单:MQ 异步处理订单
  3. 热点数据缓存:商品信息缓存到 Redis
  4. 分库分表:按商品 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?

答案

  1. 使用 pt-online-schema-change:不锁表,业务无感知
  2. 使用 gh-ost:GitHub 开源工具
  3. MySQL 8.0 Instant DDL:部分操作瞬间完成
  4. 低峰期执行:减少业务影响
  5. 主从架构下先在从库执行,再切换主从

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;

预防措施

  1. 事务尽量短小
  2. 按固定顺序访问行
  3. 合理使用索引,避免锁升级
  4. 设置合理的锁等待超时

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,用于检测页是否完整写入

页内查找原理

  1. 在 Page Directory 中二分查找定位 slot
  2. 在 slot 指向的记录组内顺序查找
  3. 时间复杂度 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 无法恢复

双写机制

  1. 将脏页先写入双写缓冲区(系统表空间中的连续区域,2MB)

  2. 双写缓冲区写入磁盘(顺序 I/O)

  3. 再将脏页写入实际的数据文件(随机 I/O)

  4. 如果崩溃恢复时发现数据页损坏,从双写缓冲区中找到完整的页副本

    脏页 → [双写缓冲区(顺序写)] → [实际数据文件(随机写)]

    磁盘(顺序 I/O,16KB × 128 页 = 2MB)

💡 要点:双写缓冲区只在系统表空间中,性能开销约 5~10%。对于使用原子写(Atomic Write)的存储设备(如 FusionIO),可以关闭双写。


Q132: 什么是自适应哈希索引(Adaptive Hash Index, AHI)?

答案

自适应哈希索引是 InnoDB 的自动优化机制 ,当发现某些索引页被频繁等值查询访问时,自动在内存中构建哈希索引,将查找从 O(logN) 优化到 O(1)。

工作原理

  1. InnoDB 监控索引页的访问模式
  2. 如果某个索引页被频繁等值访问(通过 B+ 树查找),自动将其加入 AHI
  3. 后续的等值查询直接通过哈希表定位,跳过 B+ 树遍历
  4. 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:碎片区中的可用页
  • 当需要分配新页时:
    1. 先从 Free List 取空闲页
    2. 如果 Free List 为空,从碎片区分配
    3. 如果碎片区也满了,分配新的区(1MB)

空间回收

  • DELETE 操作不会立即释放页空间
  • 页被标记为"可复用",但不会归还操作系统
  • OPTIMIZE TABLEALTER 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。

两种预读

  1. 线性预读(Linear Read-Ahead)

    • 当顺序读取一个区(Extent)中连续的 N 个页(innodb_read_ahead_threshold,默认 56)时
    • 自动预读该区的后续页
  2. 随机预读(Random Read-Ahead)

    • 当一个区中的 13 个连续页被加载到 Buffer Pool 时
    • 自动预读该区的剩余页
    • innodb_random_read_ahead 控制(默认 OFF)

Q138: Buffer Pool 的脏页刷盘策略是什么?

答案

Buffer Pool 中的脏页(被修改过的页)由后台线程择机刷回磁盘。

刷盘的触发条件

  1. Checkpoint:Redo Log 写满时,必须刷脏页
  2. 后台线程定期刷innodb_io_capacity(默认 200)控制每秒刷盘的页数
  3. LRU 链表淘汰:Free List 不足时,从 LRU 尾部淘汰页,如果是脏页则先刷盘
  4. 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 中的脏页刷回磁盘的过程,目的是:

  1. 缩短崩溃恢复时间:脏页刷盘后,Redo Log 中对应的记录可以被覆盖
  2. 释放 Redo Log 空间:Checkpoint 之后的 Redo Log 空间可以重用
  3. 释放 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,导致性能下降。

预热方法

  1. innodb_buffer_pool_dump_at_shutdown:关闭时将 Buffer Pool 中的页列表保存到文件
  2. innodb_buffer_pool_load_at_startup:启动时将页列表加载回 Buffer Pool
  3. 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 的触发时机

  1. 页被访问时:当辅助索引页被读取到 Buffer Pool 时,触发 Merge
  2. 后台线程定期合并:Master Thread 每秒或每 10 秒执行一次
  3. 数据库关闭时
  4. Redo Log 写满时

Merge 的过程

  1. 从系统表空间读取 Change Buffer 记录
  2. 将修改操作应用到辅助索引页
  3. 标记 Change Buffer 记录为已合并

十四、Redo Log 底层原理

Q144: Redo Log 的物理结构是怎样的?

答案

Redo Log 由两个固定大小的文件组成(ib_logfile0ib_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:表空间 ID
  • page_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 的用途

  1. 标识日志位置:每个 Redo Log 记录有唯一的 LSN
  2. 崩溃恢复:比较页的 LSN 和 Redo Log 的 LSN,决定是否需要重放
  3. Checkpoint:记录 Checkpoint 时的 LSN,用于判断哪些 Redo Log 可以覆盖

页中的 LSN

每个数据页的 File Header 中有一个 FIL_PAGE_LSN 字段,记录该页最后一次修改对应的 LSN。崩溃恢复时,如果页的 LSN < Redo Log 的 LSN,说明该页需要重放。


Q147: Redo Log 的崩溃恢复过程是怎样的?

答案

数据库启动时,InnoDB 执行崩溃恢复:

  1. 扫描 Redo Log:从最近的 Checkpoint LSN 开始,扫描所有 Redo Log 记录
  2. 重放(Redo):将 Redo Log 中的修改应用到数据页
  3. 回滚(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 次数。

原理

  1. 多个事务几乎同时提交
  2. 第一个事务到达 fsync 点时,等待一小段时间(让其他事务追上)
  3. 将多个事务的 Redo Log 一起 fsync
  4. 减少了 fsync 次数,提高了吞吐量

Binlog 的 Group Commit

同样地,Binlog 也支持 Group Commit,多个事务的 Binlog 一起 fsync。


Q150: Redo Log 的写入性能如何优化?

答案

  1. 增大 Redo Log 大小 :减少 Checkpoint 频率(innodb_redo_log_capacity,MySQL 8.0.30+)
  2. 使用 SSD:fsync 性能比 HDD 快 10~100 倍
  3. innodb_flush_log_at_trx_commit = 2:牺牲部分安全性换取性能
  4. Group Commit:自动生效,减少 fsync 次数
  5. 减少事务大小:小事务的 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 有什么影响?

答案

  1. Undo Log 膨胀:长事务的 ReadView 引用了旧版本,Purge 无法清理
  2. Undo 表空间增长:旧版本数据不断累积
  3. 查询性能下降:MVCC 需要遍历更长的版本链
  4. Purge 延迟:Purge 线程需要等待长事务结束

解决方案

  1. 事务尽量短小
  2. 设置 innodb_undo_tablespaces(默认 2)分散 Undo I/O
  3. 监控 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) 的并行复制。

原理

  1. 主库在组提交时,为每个事务标记 last_committedsequence_number
  2. last_committed 相同的事务说明在主库是同一组提交的,可以并行执行
  3. 从库的 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 的作用

  1. 从库的 I/O Thread 将 Binlog 事件写入 Relay Log
  2. 从库的 SQL Thread 从 Relay Log 中读取事件并重放
  3. 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锁]

加锁过程

  1. 根据记录的 (space, page, heap) 计算哈希值
  2. 在锁表中查找是否已有锁记录
  3. 检查锁兼容性(参考锁兼容矩阵)
  4. 兼容则加锁成功,不兼容则加入等待队列
  5. 定期检查死锁(等待图中是否有环路)

Q165: 什么是等待图(Wait-for Graph)?

答案

等待图是 InnoDB 用于死锁检测的数据结构。

结构

  • 节点:事务
  • 边:事务 A 等待事务 B 释放锁 → A → B

死锁检测过程

  1. 当事务 A 等待事务 B 时,添加边 A → B
  2. 检查图中是否存在环路
  3. 如果存在环路 → 死锁 → 选择回滚代价最小的事务

检测频率

  • 每次锁等待时都检测
  • 高并发下死锁检测本身也有 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: 高并发下锁竞争如何优化?

答案

  1. 减少锁持有时间:事务尽量短小
  2. 使用乐观锁:版本号机制,避免使用数据库锁
  3. 合理使用索引:避免无索引导致的表锁
  4. 降低隔离级别:RC 级别没有间隙锁,锁竞争更少
  5. 热点行更新优化
    • 将热点数据分散到多行(如库存分 10 个子行)
    • 使用 Redis 预减库存
  6. 增大 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

分析要点

  1. 找到两个事务分别持有的锁和等待的锁
  2. 判断是否形成环路
  3. 找到回滚的事务(通常是 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 查询变慢(需要遍历多个版本)。

优化方法

  1. 缩短版本链:避免长事务(长事务阻止 Purge,版本链变长)
  2. 加速 Purge :增大 innodb_purge_threads
  3. 降低隔离级别:RC 级别下旧版本可以更快被 Purge
  4. 监控 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 中是标记删除(不是物理删除):

  1. 将记录的删除标记(delete flag)设为 1
  2. 写入 Update Undo Log(记录删除前的版本)
  3. 记录的 DB_TRX_ID 更新为当前事务 ID
  4. 旧版本通过版本链保留,供其他事务的 ReadView 使用

物理删除(Purge)

  • 当所有活跃 ReadView 都不再引用该版本时
  • Purge 线程将记录从页中物理删除

Q177: MVCC 如何处理 UPDATE 操作?

答案

UPDATE 操作在 InnoDB 中是删除旧记录 + 插入新记录

  1. 将旧记录标记删除
  2. 插入一条新记录(新值)
  3. 写入 Update Undo Log(记录旧值)
  4. 旧版本通过版本链保留

原地更新(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: 优化器如何选择索引?

答案

优化器选择索引时考虑以下因素:

  1. 索引的选择性:选择性越高,过滤效果越好
  2. 索引覆盖:如果索引包含所有查询字段,优先选择
  3. 索引的大小:索引越小,I/O 越少
  4. 统计信息:索引列的数据分布
  5. 查询条件:等值查询 vs 范围查询

索引选择错误的场景

  • 统计信息过期 → ANALYZE TABLE 更新
  • 数据分布不均匀 → 优化器误判
  • 多个索引可选 → 使用 FORCE INDEXIGNORE 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

原理

  1. 使用辅助索引查找到主键值列表
  2. 将主键值排序
  3. 按排序后的顺序在聚簇索引中回表
  4. 减少了随机 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 的回表过程。

原理

  1. 驱动表的结果集按 join key 排序
  2. 将排序后的 join key 批量发送给被驱动表
  3. 被驱动表使用 MRR 优化,批量回表
sql 复制代码
-- 开启 BKA
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

Q184: 什么是子查询物化(Materialization)?

答案

子查询物化是优化器将子查询的结果集物化为临时表,然后在外层查询中使用。

物化的条件

  • 子查询的结果集较小
  • 子查询中没有外部引用(非相关子查询)
  • 物化后可以使用索引加速外层查询

物化的过程

  1. 执行子查询,将结果写入内存临时表 (如果超过 tmp_table_size 则写磁盘)
  2. 在临时表上创建索引
  3. 外层查询通过临时表的索引匹配
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 操作是原子性的(要么完全成功,要么完全回滚)。

实现机制

  1. DDL 操作记录到 mysql.innodb_ddl_log 表中
  2. DDL 执行过程中的所有修改都在事务中
  3. 如果崩溃,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 条件
  • 被驱动表没有索引

原理

  1. 将较小的表(驱动表)构建为哈希表(build phase)
  2. 遍历较大的表(被驱动表),在哈希表中查找匹配(probe phase)
  3. 复杂度 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 连接对应一个操作系统线程

连接的生命周期

  1. 客户端发起连接 → 主线程接受
  2. 从线程缓存中取空闲线程(或创建新线程)
  3. 进行身份认证(基于 mysql.user 表)
  4. 分配连接资源(会话级变量、临时表等)
  5. 等待客户端发送 SQL
  6. 处理 SQL → 返回结果 → 等待下一条 SQL
  7. 连接断开 → 线程返回缓存(或销毁)

相关参数

  • max_connections:最大连接数
  • thread_cache_size:线程缓存大小
  • wait_timeout:空闲连接超时时间

Q194: 什么是连接池?为什么需要?

答案

连接池在应用端维护一组预创建的数据库连接,避免频繁创建和销毁连接的开销。

为什么需要

  1. 创建连接需要 TCP 握手 + 认证,耗时约 1~5ms
  2. 高并发下频繁创建连接会消耗大量资源
  3. 连接池复用连接,减少开销

常见连接池

  • Java:HikariCP(推荐)、Druid、DBCP
  • Go:database/sql 内置连接池
  • Python:SQLAlchemy 内置连接池

Q195: MySQL 的权限系统是怎样的?

答案

MySQL 的权限系统基于权限表 ,存储在 mysql 数据库中。

权限表

  • mysql.user:用户级权限(全局)
  • mysql.db:数据库级权限
  • mysql.tables_priv:表级权限
  • mysql.columns_priv:列级权限

权限检查流程

  1. 连接时检查 mysql.user 表的认证信息
  2. 执行 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 的安全最佳实践有哪些?

答案

  1. 使用强密码validate_password 插件强制密码复杂度
  2. 最小权限原则:只授予必要的权限
  3. 禁止 root 远程登录GRANT ALL ON *.* TO 'root'@'localhost'
  4. 使用 SSL 加密连接REQUIRE SSL
  5. 定期审计audit_log 插件
  6. 数据加密:TDE(透明数据加密)+ 列级加密
  7. 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 基准测试工具

    bash 复制代码
    sysbench 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 自带的压力测试工具

    bash 复制代码
    mysqlslap --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
  • 并发数:同时执行的线程数

测试方法

  1. 预热:先运行一段时间,让 Buffer Pool 充分加载
  2. 多次测试取平均值
  3. 控制变量:只改变一个参数,观察性能变化
  4. 监控系统资源: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 优化。面试时做到"知其然,知其所以然",能从底层原理层面解释每一个概念。

相关推荐
Yan_chen6662 小时前
CTFHub SQL 布尔盲注实战攻略
数据库·sql·实战·布尔盲注·ctfhub闯关
杨_晨2 小时前
LLM输出康熙部首冲突
数据库·python·mysql·ai
爱看报的猿2 小时前
【金仓数据库征文】MySQL至金仓KES异构数据库平滑迁移与性能深度调优实战
数据库·数据仓库·mysql·金仓数据库征文
Csvn3 小时前
📊 SQL 入门 Day 16:数据插入技巧
后端·sql
ERD Online3 小时前
MySQL/Oracle/PG/SQLServer 存量库一键逆向成关系图
mysql·oracle·sqlserver
NeilYuen4 小时前
Mysql实战——图片读写
数据库·mysql
vHelios4 小时前
【电商项目】商品服务模块的问题解决与代码逻辑思考
java·sql·mybatis
正在走向自律4 小时前
【金仓数据库征文】从 MySQL 迁移到金仓数据库:哈工大智能造价项目的一次信创改造实践
数据库·mysql·性能优化·国产数据库·数据库迁移·信创适配·金仓数据库征文
奇特認5 小时前
MySQL 集群技术 1.源码编译
数据库·mysql