从基础架构到生产实践,全面掌握 MySQL 核心技术


第1章 MySQL 体系结构与运行机制

1.1 MySQL 整体架构

MySQL 采用分层架构设计,从上到下分为四层:

┌─────────────────────────────────────────────┐
│            客户端连接层 (Connectors)          │
│  JDBC / ODBC / PHP / Python / Go / Navicat  │
├─────────────────────────────────────────────┤
│            服务层 (MySQL Server)             │
│  ┌─────────┐ ┌──────────┐ ┌──────────────┐ │
│  │连接管理  │ │SQL接口    │ │解析器(Parser)│ │
│  └─────────┘ └──────────┘ └──────────────┘ │
│  ┌───────────┐ ┌─────────────────────────┐  │
│  │优化器      │ │缓存(Buffer/Cache)       │  │
│  │(Optimizer)│ │8.0已移除查询缓存         │  │
│  └───────────┘ └─────────────────────────┘  │
├─────────────────────────────────────────────┤
│            存储引擎层 (Storage Engine)       │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌───────────┐  │
│  │InnoDB│ │MyISAM│ │Memory│ │Archive... │  │
│  └──────┘ └──────┘ └──────┘ └───────────┘  │
├─────────────────────────────────────────────┤
│            文件系统层 (File System)          │
│  数据文件 / 日志文件 / 配置文件 / Socket     │
└─────────────────────────────────────────────┘

1.1.1 连接层

  • 连接管理:处理客户端连接、认证、授权
  • 连接池:复用已建立的连接,减少连接开销
  • 线程模型:每个连接分配一个线程(One-Thread-Per-Connection)
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
-- 查看最大连接数
SHOW VARIABLES LIKE 'max_connections';
-- 查看连接详情
SHOW PROCESSLIST;

1.1.2 服务层

服务层是 MySQL 的核心,包含 SQL 处理的完整流程:

组件功能
连接管理认证、授权、连接池管理
SQL 接口接收 DML/DDL/DCL 语句
解析器词法分析、语法分析,生成解析树
优化器选择最优执行计划(CBO)
执行器调用存储引擎接口执行查询

SQL 执行流程:

客户端发送SQL
    → 连接管理(认证/授权)
    → 解析器(词法/语法分析)
    → 优化器(生成执行计划)
    → 执行器(调用存储引擎API)
    → 返回结果

1.1.3 存储引擎层

MySQL 的插件式存储引擎架构是其最大特色:

-- 查看支持的存储引擎
SHOW ENGINES;

-- 查看表的存储引擎
SHOW TABLE STATUS LIKE 'user'\G

-- 修改表的存储引擎
ALTER TABLE user ENGINE = InnoDB;

1.2 MySQL 8.0 新架构特性

1.2.1 数据字典重构

MySQL 8.0 使用事务性数据字典替代了旧的 .frm 文件:

特性5.78.0
元数据存储.frm/.par 文件InnoDB 表
原子 DDL不支持支持
DDL 回滚不支持支持
信息查询INFORMATION_SCHEMAPERFORMANCE_SCHEMA 优化

1.2.2 原子 DDL

-- MySQL 8.0 原子DDL:要么全部成功,要么全部回滚
CREATE TABLE t1 (c1 INT) ENGINE=InnoDB;
-- 如果创建失败,不会留下残余文件

-- 8.0之前:DROP TABLE t1, t2; 如果t2不存在,t1已被删除
-- 8.0:原子操作,t1和t2要么都删,要么都不删
DROP TABLE IF EXISTS t1, t2;

1.2.3 默认字符集变更

-- 5.7 默认字符集
-- character_set_server = latin1
-- collation_server = latin1_swedish_ci

-- 8.0 默认字符集
-- character_set_server = utf8mb4
-- collation_server = utf8mb4_0900_ai_ci

-- 查看当前字符集
SHOW VARIABLES LIKE 'character_set_server';

1.3 MySQL 查询执行流程详解

1.3.1 查询缓存(8.0 已移除)

MySQL 8.0 移除了查询缓存功能,原因:

  1. 并发性能差:查询缓存加全局锁,高并发下成为瓶颈
  2. 命中率低:任何表修改都导致相关缓存失效
  3. 内存浪费:缓存大量无效结果

替代方案:使用 Redis 等外部缓存。

1.3.2 解析器工作原理

SELECT name, age FROM user WHERE id = 1;

词法分析:识别关键字 SELECT、FROM、WHERE,表名 user,列名 name/age/id

语法分析:验证 SQL 语法正确性,生成解析树(Parse Tree)

1.3.3 优化器工作原理

优化器基于代价模型(CBO)选择执行计划:

-- 查看优化器决策过程
EXPLAIN FORMAT=JSON SELECT * FROM user WHERE age > 20;

-- 查看优化器追踪
SET optimizer_trace = 'enabled=on';
SELECT * FROM user WHERE age > 20;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
SET optimizer_trace = 'enabled=off';

1.3.4 执行器工作原理

执行器根据执行计划调用存储引擎接口:

-- 慢查询日志记录执行时间
SET long_query_time = 1;
SET slow_query_log = ON;

-- 查看执行状态
SHOW STATUS LIKE 'Handler%';

1.4 MySQL 9.0 新特性概览

特性说明
InnoDB 引擎重构事务处理速度提升 30%+
AI/ML 集成内置 ML_SERVICES 插件
云原生优化原生 K8s 支持、S3 云存储
安全增强TLS 1.3 默认、国密 SM4、列级脱敏
性能优化索引推荐、动态内存分配

面试实战题

题目 1:MySQL 一条 SQL 查询语句是如何执行的?

答:

完整执行流程:

  1. 连接器:客户端建立 TCP 连接,进行用户认证和权限校验
  2. 解析器:词法分析识别 SQL 关键字和对象名,语法分析验证语法正确性,生成解析树
  3. 优化器:基于代价模型选择最优执行计划,包括选择索引、决定连接顺序等
  4. 执行器:根据执行计划调用存储引擎 API,逐行获取数据并返回结果

关键点:

  • 连接器会缓存该用户的权限信息,中途修改权限需重新连接才生效
  • 8.0 移除了查询缓存,每次查询都走完整流程
  • 优化器可能选错索引,可通过 FORCE INDEX 强制指定

题目 2:MySQL 为什么采用插件式存储引擎架构?有什么好处?

答:

设计理念:将 SQL 处理与数据存储解耦,服务层统一处理 SQL 解析和优化,存储引擎层负责数据的实际存取。

好处:

  1. 灵活性:不同业务场景选择不同引擎(InnoDB 事务、Memory 临时表、Archive 归档)
  2. 可扩展:第三方可开发自定义存储引擎
  3. 解耦:上层优化器不需要关心底层存储细节
  4. 演进性:引擎可以独立升级和优化

代价:跨引擎功能受限(如跨引擎事务、外键)

题目 3:MySQL 8.0 为什么要移除查询缓存?

答:

移除原因:

  1. 全局锁竞争:查询缓存使用全局互斥锁,高并发下严重阻塞
  2. 命中率低:任何对表的写操作都会使该表所有缓存失效,写密集场景命中率接近 0
  3. 额外开销:每次查询都要检查缓存,缓存未命中时反而增加延迟
  4. 内存浪费:大量无效缓存占用宝贵内存

替代方案:应用层使用 Redis/Memcached 作为缓存层,更灵活且不影响数据库性能。

题目 4:什么是原子 DDL?MySQL 8.0 的原子 DDL 有什么意义?

答:

原子 DDL:DDL 操作(CREATE/DROP/ALTER)要么完全成功,要么完全回滚,不会留下中间状态。

8.0 之前的问题:

  • DROP TABLE t1, t2; 如果 t2 不存在,t1 已被删除,操作部分成功
  • CREATE TABLE 失败可能留下 .frm 文件残留

8.0 的改进:

  • DDL 操作写入 Redo Log 和 Binlog,支持崩溃恢复
  • DROP TABLE t1, t2; 要么都删,要么都不删
  • CREATE TABLE 失败不会留下残余文件
  • 数据字典更新与存储引擎操作在同一事务中

题目 5:MySQL 的连接器、解析器、优化器、执行器各自的作用是什么?

答:

组件作用关键点
连接器管理 client 连接、认证、授权长连接内存占用(8.0 自动断开)
解析器词法/语法分析,生成解析树语法错误在此阶段报出
优化器选择最优执行计划CBO 代价模型,可能选错索引
执行器调用存储引擎 API 执行检查权限,逐行获取数据

执行器权限检查:执行器在获取行之前会检查当前用户是否有该表的查询权限,这也是为什么有时 SELECT 报权限错误而非语法错误。

题目 6:MySQL 8.0 和 5.7 在架构上有哪些核心差异?

答:

维度5.78.0
数据字典.frm 文件InnoDB 事务性数据字典
查询缓存支持移除
原子 DDL不支持支持
默认字符集latin1utf8mb4
认证插件mysql_native_passwordcaching_sha2_password
窗口函数不支持支持
CTE不支持支持(递归/非递归)
降序索引语法支持但实际 ASC真正支持 DESC
函数索引不支持支持
角色管理不支持支持
InnoDB Redo单一 ib_logfile0/1多 redo log 文件
信息 Schema查询慢PERFORMANCE_SCHEMA 优化

第2章 InnoDB 存储引擎架构

2.1 InnoDB 整体架构

┌──────────────────────────────────────────────────────────┐
│                    InnoDB 存储引擎                        │
├────────────────────────┬─────────────────────────────────┤
│    内存结构 (In-Memory) │     磁盘结构 (On-Disk)          │
│                        │                                 │
│  ┌──────────────┐      │  ┌──────────────┐              │
│  │ Buffer Pool  │      │  │ System       │              │
│  │ (缓冲池)     │      │  │ Tablespace   │              │
│  └──────────────┘      │  └──────────────┘              │
│  ┌──────────────┐      │  ┌──────────────┐              │
│  │ Change Buffer│      │  │ File-Per-    │              │
│  │ (写缓冲)     │      │  │ Table        │              │
│  └──────────────┘      │  │ Tablespace   │              │
│  ┌──────────────┐      │  └──────────────┘              │
│  │ Log Buffer   │      │  ┌──────────────┐              │
│  │ (日志缓冲)   │      │  │ General      │              │
│  └──────────────┘      │  │ Tablespace   │              │
│  ┌──────────────┐      │  └──────────────┘              │
│  │ Adaptive Hash│      │  ┌──────────────┐              │
│  │ Index (AHI)  │      │  │ Redo Log     │              │
│  └──────────────┘      │  └──────────────┘              │
│                        │  ┌──────────────┐              │
│                        │  │ Undo         │              │
│                        │  │ Tablespace   │              │
│                        │  └──────────────┘              │
├────────────────────────┴─────────────────────────────────┤
│                  后台线程 (Background Threads)            │
│  Master Thread │ IO Thread │ Purge Thread │ Page Cleaner │
└──────────────────────────────────────────────────────────┘

2.2 Buffer Pool(缓冲池)

2.2.1 核心设计

Buffer Pool 是 InnoDB 内存中最重要的组件,缓存热点数据页和索引页,减少磁盘 I/O:

Buffer Pool
├── 数据页(Data Page)—— 表数据
├── 索引页(Index Page)—— B+树节点
├── Undo 页 —— Undo Log
├── Change Buffer 页 —— 二级索引变更
├── 自适应哈希索引 —— AHI
└── 锁信息 —— 行锁/表锁元数据

2.2.2 LRU 算法改进

InnoDB 对传统 LRU 进行了关键改进,采用冷热分离策略:

┌──────────────── 热端 (Young Region, 5/8) ────────────────┐
│  [热点页1] → [热点页2] → [热点页3] → ... → [热点页N]     │
├──────────────── 中间点 (Midpoint) ────────────────────────┤
│  [新加载页] → [冷页1] → [冷页2] → ... → [冷页N]         │
└──────────────── 冷端 (Old Region, 3/8) ──────────────────┘

关键机制:

  1. 冷热分区:热端占 5/8,冷端占 3/8
  2. 中间插入:新加载的页插入冷端头部,而非热端头部
  3. 老生常谈时间:innodb_old_blocks_time(默认 1000ms),冷端页需被访问超过此时间才晋升热端
  4. 防止全表扫描污染:大表扫描的页先入冷端,短时间不晋升
-- 查看 Buffer Pool 状态
SELECT POOL_ID, POOL_SIZE, DATABASE_PAGES, FREE_BUFFERS,
       MODIFIED_DATABASE_PAGES
FROM information_schema.INNODB_BUFFER_POOL_STATS;

-- 计算缓存命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests

2.2.3 三大链表

链表作用说明
Free List空闲页链表管理未使用的缓存页
LRU List最近最少使用链表冷热分离,管理数据页淘汰
Flush List脏页链表记录被修改但未刷盘的页

2.2.4 关键配置

-- Buffer Pool 大小(建议物理内存的 50%~80%)
SET GLOBAL innodb_buffer_pool_size = 8 * 1024 * 1024 * 1024; -- 8GB

-- 多实例(减少锁竞争,建议每 1GB 一个实例)
SET GLOBAL innodb_buffer_pool_instances = 8;

-- 冷端比例(默认 37%)
SET GLOBAL innodb_old_blocks_pct = 37;

-- 冷端晋升时间(默认 1000ms)
SET GLOBAL innodb_old_blocks_time = 1000;

-- 最大脏页比例
SET GLOBAL innodb_max_dirty_pages_pct = 10;

2.3 Change Buffer(写缓冲)

2.3.1 设计原理

Change Buffer 针对非唯一二级索引的 DML 操作进行优化:

传统流程:
  INSERT → 读取二级索引页(随机IO) → 修改页 → 写回

Change Buffer 流程:
  INSERT → 将变更记录到 Change Buffer → 后台合并(merge)

适用条件:

  1. 仅适用于非唯一二级索引(唯一索引需要立即检查唯一性)
  2. 索引页不在 Buffer Pool 中时才生效
  3. 适合写多读少的场景

2.3.2 工作流程

1. DML 操作修改二级索引
2. 检查索引页是否在 Buffer Pool
   ├── 在 Buffer Pool → 直接修改
   └── 不在 Buffer Pool → 记录到 Change Buffer
3. 后台线程异步合并(merge)
   ├── 访问该索引页时触发合并
   ├── 后台 Master Thread 定期合并
   └── 数据库关闭时合并

2.3.3 配置与监控

-- Change Buffer 最大占比(默认 25%)
SET GLOBAL innodb_change_buffer_max_size = 25;

-- Change Buffer 类型(inserts/deletes/purges/all/none)
SET GLOBAL innodb_change_buffering = all;

-- 查看 Change Buffer 状态
SELECT * FROM information_schema.INNODB_METRICS
WHERE NAME LIKE '%change_buffer%';

2.4 Log Buffer(日志缓冲)

2.4.1 Redo Log Buffer

Log Buffer 是 Redo Log 的内存缓冲区,事务修改先写入 Log Buffer,再刷入磁盘:

事务修改 → Log Buffer → Redo Log File (磁盘)
              ↑              ↑
         innodb_log_buffer_size   innodb_flush_log_at_trx_commit
-- Log Buffer 大小(默认 16MB)
SET GLOBAL innodb_log_buffer_size = 16 * 1024 * 1024;

-- Redo Log 刷盘策略(关键参数)
-- 0: 每秒刷盘(可能丢失1秒数据)
-- 1: 每次事务提交刷盘(最安全,默认)
-- 2: 每次提交写入OS缓存,每秒fsync
SET GLOBAL innodb_flush_log_at_trx_commit = 1;

2.5 自适应哈希索引(AHI)

2.5.1 工作原理

InnoDB 自动为频繁访问的索引页构建哈希索引:

B+树查找:根节点 → 中间节点 → 叶子节点(3~4次比较)
AHI查找:哈希计算 → 直接定位(1次查找)

触发条件:

  • 同一索引页被连续访问超过一定次数
  • 访问模式为等值查询(WHERE id = ?)
-- 查看 AHI 状态
SHOW ENGINE INNODB STATUS\G
-- 搜索 "INSERT BUFFER AND ADAPTIVE HASH INDEX" 部分

-- 开启/关闭 AHI
SET GLOBAL innodb_adaptive_hash_index = ON;

AHI 的利弊:

优势劣势
等值查询加速占用 Buffer Pool 内存
减少 B+ 树遍历高并发更新时 AHI 锁竞争
自动管理Like/Range 查询无效

2.6 后台线程

2.6.1 Master Thread

核心后台线程,负责:

  • 每秒:刷新脏页、合并 Change Buffer、刷新 Redo Log
  • 每 10 秒:刷新脏页、合并 Change Buffer、删除无用 Undo Log
  • 每次空闲:刷新脏页

2.6.2 IO Thread

-- 查看 IO 线程状态
SHOW ENGINE INNODB STATUS\G
-- --------
-- FILE I/O
-- --------
-- I/O thread 0 state: waiting for completed aio requests (insert buffer thread)
-- I/O thread 1 state: waiting for completed aio requests (log thread)
-- I/O thread 2 state: waiting for completed aio requests (read thread)
-- I/O thread 3 state: waiting for completed aio requests (read thread)
-- I/O thread 4 state: waiting for completed aio requests (write thread)
-- I/O thread 5 state: waiting for completed aio requests (write thread)
-- I/O thread 6 state: waiting for completed aio requests (write thread)
-- I/O thread 7 state: waiting for completed aio requests (write thread)

-- 配置 IO 线程数
SET GLOBAL innodb_read_io_threads = 4;
SET GLOBAL innodb_write_io_threads = 4;

2.6.3 Purge Thread

清理无用的 Undo Log:

-- Purge 线程数(默认 4)
SET GLOBAL innodb_purge_threads = 4;

2.6.4 Page Cleaner Thread

负责脏页刷盘,减轻 Master Thread 压力:

-- Page Cleaner 线程数
SET GLOBAL innodb_page_cleaners = 4;

2.7 磁盘结构

2.7.1 表空间类型

表空间说明文件
System Tablespace系统表空间,包含数据字典ibdata1
File-Per-Table每表独立表空间tablename.ibd
General Tablespace通用表空间,多表共享自定义
Undo TablespaceUndo Log 表空间undo_001, undo_002
Temporary Tablespace临时表空间ibtmp1
Redo Log重做日志ib_logfile0/1 → #innodb_redo/*
-- 开启独立表空间(8.0 默认开启)
SET GLOBAL innodb_file_per_table = ON;

-- 查看表空间信息
SELECT * FROM information_schema.INNODB_TABLESPACES
WHERE NAME = 'test/user';

2.7.2 逻辑存储结构

Tablespace (表空间)
  └── Segment (段)
        ├── Data Segment (数据段/叶子节点段)
        ├── Index Segment (索引段/非叶子节点段)
        └── Rollback Segment (回滚段)
              └── Extent (区, 1MB = 64个页)
                    └── Page (页, 16KB)
                          └── Row (行)
                                ├── DB_TRX_ID (6B, 事务ID)
                                ├── DB_ROLL_PTR (7B, 回滚指针)
                                └── DB_ROW_ID (6B, 隐藏主键)

面试实战题

题目 1:Buffer Pool 的 LRU 算法为什么不用传统 LRU?

答:

传统 LRU 的问题:全表扫描会将大量冷数据加载到 LRU 头部,把真正的热数据挤出缓存(缓存污染)。

InnoDB 的改进:

  1. 冷热分区:LRU 分为 Young(5/8)和 Old(3/8)两个区域
  2. 中间插入:新加载的页插入 Old 区头部,而非 Young 区头部
  3. 时间窗口:innodb_old_blocks_time(默认 1000ms),Old 区的页必须被访问超过此时间才晋升到 Young 区
  4. 效果:全表扫描的页进入 Old 区后很快被淘汰,不会污染 Young 区的热数据

题目 2:Change Buffer 的适用场景和限制是什么?

答:

适用场景:

  • 非唯一二级索引的 INSERT/UPDATE/DELETE
  • 写多读少(DML 操作不立即读取索引页)
  • 索引页不在 Buffer Pool 中

限制:

  • 不支持主键索引(聚簇索引)
  • 不支持唯一二级索引(需要立即检查唯一性)
  • 不支持全文索引和空间索引
  • 读取索引页时会触发 merge,读多写少场景反而增加开销

关闭场景:SSD 磁盘随机 I/O 性能好,Change Buffer 收益有限;读多写少场景,频繁 merge 增加开销。

题目 3:innodb_flush_log_at_trx_commit 的三个值有什么区别?

答:

值行为安全性性能
0每秒刷盘,事务提交不触发最低(可能丢1秒数据)最高
1每次事务提交都 fsync最高(不丢数据)最低
2每次提交写 OS 缓存,每秒 fsync中等(OS 崩溃才丢)中等

生产建议:

  • 核心业务(金融/订单):设为 1
  • 非核心业务(日志/统计):可设为 2
  • 配合 sync_binlog = 1 使用,保证主从数据一致

题目 4:InnoDB 的三大链表分别是什么?各自的作用?

答:

  1. Free List(空闲链表):管理未被使用的缓存页。当需要加载新页时,从 Free List 取空闲页;Free List 为空时,从 LRU List 淘汰旧页。

  2. LRU List(最近最少使用链表):按访问顺序管理缓存页,冷热分区。内存不足时从 Old 区尾部淘汰。记录所有被使用的数据页和索引页。

  3. Flush List(脏页链表):记录所有被修改但未刷盘的脏页,按修改时间排序。后台线程从 Flush List 选取脏页刷盘。

关系:一个缓存页可以同时在 LRU List 和 Flush List 中(脏页),但不会在 Free List 中。

题目 5:InnoDB 的逻辑存储结构是怎样的?

答:

从大到小:表空间 → 段 → 区 → 页 → 行

  • 表空间:InnoDB 存储的最高层,一个表空间可包含多个段
  • 段:分为数据段(B+树叶子节点)、索引段(非叶子节点)、回滚段
  • 区:1MB,由 64 个连续的 16KB 页组成,保证页的连续性
  • 页:16KB,InnoDB 磁盘管理的最小单位,包含多行数据
  • 行:每行数据包含用户字段 + 3 个隐藏字段(DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID)

题目 6:自适应哈希索引(AHI)什么时候应该关闭?

答:

关闭场景:

  1. 高并发更新:AHI 使用 rw-lock,高并发更新时锁竞争严重
  2. 多范围查询:AHI 只对等值查询有效,范围查询无收益
  3. 内存紧张:AHI 占用 Buffer Pool 空间,内存不足时得不偿失
  4. 不稳定访问模式:访问模式频繁变化,AHI 频繁构建/拆除

开启场景:

  • 大量等值查询(WHERE id = ?)
  • 访问模式稳定
  • Buffer Pool 充足

可通过 SHOW ENGINE INNODB STATUS 查看 AHI 的命中率和锁等待情况来决定。


第3章 InnoDB 日志系统:Redo Log 与 Undo Log

3.1 WAL 机制

3.1.1 Write-Ahead Logging 原理

WAL(Write-Ahead Logging)是 InnoDB 的核心设计原则:先写日志,再写磁盘。

传统方式:
  修改数据页 → 随机写磁盘(慢)

WAL方式:
  修改数据页(Buffer Pool) → 顺序写Redo Log(快)
  → 后台异步刷脏页到磁盘

核心优势:

  • 顺序写远快于随机写(磁盘顺序写 ~100MB/s vs 随机写 ~1MB/s)
  • 崩溃恢复时重放 Redo Log 即可恢复数据

3.2 Redo Log(重做日志)

3.2.1 Redo Log 架构

┌─────────────────────────────────────────────┐
│              Redo Log 架构                    │
│                                              │
│  事务修改 → Log Buffer → Redo Log File      │
│               ↑              ↑               │
│         log_buffer_size   flush_at_trx_commit│
│                                              │
│  ┌──────────────────────────────────────┐    │
│  │  Redo Log File (循环写入)            │    │
│  │  ┌────┐ ┌────┐ ┌────┐ ┌────┐       │    │
│  │  │ ib1 │ │ ib2 │ │ ib3 │ │ ib4 │   │    │
│  │  └────┘ └────┘ └────┘ └────┘       │    │
│  │   ↑ write pos          ↑ checkpoint │    │
│  │   (写入位置)            (检查点)     │    │
│  └──────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

3.2.2 循环写入机制

Redo Log 采用固定大小、循环写入的方式:

write pos (当前写入位置)
    ↓
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ ✓ │ ✓ │ ✓ │   │   │   │ ✓ │ ✓ │
└───┴───┴───┴───┴───┴───┴───┴───┘
                        ↑
                   checkpoint (已刷盘位置)

空闲空间 = write pos → checkpoint 之间的距离
当 write pos 追上 checkpoint → 需要先推进 checkpoint(刷脏页)

3.2.3 LSN(Log Sequence Number)

LSN 是 Redo Log 的全局递增序号,贯穿整个 InnoDB:

-- 查看当前 LSN
SHOW ENGINE INNODB STATUS\G
-- Log sequence number: 1234567890  (当前Redo Log写入位置)
-- Log flushed up to:   1234567880  (已刷盘位置)
-- Pages flushed up to: 1234567800  (脏页刷盘位置)
-- Last checkpoint at:  1234567700  (检查点位置)

-- LSN 关系
-- Log sequence number >= Log flushed >= Pages flushed >= Last checkpoint

3.2.4 MySQL 8.0 Redo Log 改进

-- 8.0 之前:固定2个文件 ib_logfile0/1
-- 8.0:支持多 Redo Log 文件,动态调整

-- 查看 Redo Log 配置
SELECT @@innodb_log_files_in_group;  -- 文件数量
SELECT @@innodb_log_file_size;       -- 单文件大小
SELECT @@innodb_log_group_home_dir;  -- 存储目录

-- 8.0 动态修改 Redo Log 大小(在线修改)
ALTER INSTANCE ENABLE INNODB REDO_LOG;   -- 启用
ALTER INSTANCE DISABLE INNODB REDO_LOG;  -- 禁用(加速批量导入)

3.2.5 Checkpoint 机制

Checkpoint 推进已刷盘位置,释放 Redo Log 空间:

类型触发条件说明
Sharp Checkpoint正常关闭数据库将所有脏页刷盘
Fuzzy Checkpoint运行时异步刷盘多种触发条件

Fuzzy Checkpoint 触发条件:

  1. Master Thread 定期刷脏
  2. Redo Log 空间不足(write pos 接近 checkpoint)
  3. 脏页比例超过 innodb_max_dirty_pages_pct
  4. 前台查询需要淘汰 LRU 脏页

3.3 Undo Log(回滚日志)

3.3.1 Undo Log 的两大作用

  1. 事务回滚:保存数据修改前的状态,ROLLBACK 时恢复
  2. MVCC:为一致性读提供历史版本

3.3.2 Undo Log 类型

类型产生操作生命周期
Insert Undo LogINSERT事务提交后可立即删除
Update Undo LogUPDATE/DELETE需等所有活跃事务不再需要该版本

3.3.3 版本链

当前行数据 (trx_id=5, roll_ptr → undo3)
    ↓
Undo Log 3 (trx_id=4, roll_ptr → undo2)  -- 第3次修改
    ↓
Undo Log 2 (trx_id=3, roll_ptr → undo1)  -- 第2次修改
    ↓
Undo Log 1 (trx_id=2, roll_ptr = NULL)   -- 第1次插入

3.3.4 Undo Tablespace

-- 8.0 Undo 配置
SELECT @@innodb_undo_tablespaces;    -- Undo 表空间数量
SELECT @@innodb_max_undo_log_size;   -- Undo 表空间最大大小(默认1GB)
SELECT @@innodb_undo_log_truncate;   -- 自动截断(默认ON)

-- 查看 Undo 表空间
SELECT * FROM information_schema.INNODB_TABLESPACES
WHERE SPACE_TYPE = 'Undo';

3.3.5 Purge 线程与 Undo 清理

Purge 线程工作流程:
1. 找到最早活跃事务的 trx_id
2. 遍历 Undo Log 版本链
3. 如果某版本对所有活跃事务都不可见 → 可以清理
4. 物理删除已标记为 deleted 的行
5. 释放 Undo Log 空间

长事务的危害:长事务持有旧的 Read View,导致大量 Undo Log 无法被 Purge,表空间持续膨胀。

-- 查看运行时间最长的事务
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

-- 查看 Undo Log 统计
SHOW STATUS LIKE 'Innodb_undo_log%';

3.4 Redo Log 与 Undo Log 的协作

3.4.1 事务提交流程

BEGIN;
UPDATE user SET name='Tom' WHERE id=1;

1. 读取数据页到 Buffer Pool
2. 记录 Undo Log(旧值 name='Jerry')
3. 修改数据页(name='Tom')
4. 记录 Redo Log(物理修改记录)
5. 事务提交:
   a. Redo Log 刷盘(innodb_flush_log_at_trx_commit=1)
   b. 事务状态标记为已提交

COMMIT;

3.4.2 崩溃恢复流程

MySQL 崩溃重启
    ↓
1. 从 Last Checkpoint LSN 开始扫描 Redo Log
    ↓
2. 重做(Redo):重放 Redo Log,恢复已提交事务的修改
    ↓
3. 回滚(Undo):回滚未提交事务的修改
    ↓
4. 恢复完成,对外提供服务

3.4.3 ACID 特性的实现

特性实现机制
原子性 (A)Undo Log(回滚未提交事务)
一致性 ©原子性 + 隔离性 + 持久性共同保证
隔离性 (I)MVCC + 锁机制
持久性 (D)Redo Log(重放已提交事务)

3.5 Binlog 与 Redo Log 的区别

维度Redo LogBinlog
产生者InnoDB 引擎MySQL Server 层
内容物理日志(页修改)逻辑日志(SQL/行变更)
写入方式循环写入,空间固定追加写入,文件递增
用途崩溃恢复主从复制、数据恢复
引擎仅 InnoDB所有存储引擎

3.5.1 两阶段提交

保证 Redo Log 和 Binlog 的一致性:

事务提交:
1. Prepare 阶段:写入 Redo Log,标记为 prepare 状态
2. 写入 Binlog
3. Commit 阶段:Redo Log 标记为 commit 状态

崩溃恢复规则:
- Redo Log = prepare + Binlog 完整 → 提交事务
- Redo Log = prepare + Binlog 不完整 → 回滚事务

面试实战题

题目 1:WAL 机制是什么?为什么 MySQL 使用 WAL?

答:

WAL(Write-Ahead Logging)即先写日志再写磁盘。核心思想:将随机写转化为顺序写。

为什么使用 WAL:

  1. 性能:顺序写 Redo Log(100MB/s)远快于随机写数据页(1MB/s)
  2. 持久性:事务提交时只需保证 Redo Log 刷盘,数据页可异步刷盘
  3. 崩溃恢复:通过重放 Redo Log 恢复已提交事务的修改

工作流程:修改数据 → 写入 Buffer Pool → 记录 Redo Log → 后台异步刷脏页

题目 2:Redo Log 为什么采用循环写入?写满了怎么办?

答:

循环写入原因:Redo Log 只需要保留从 Checkpoint 到当前的数据,更早的 Redo Log 对应的脏页已经刷盘,不再需要。

写满处理:

  1. write pos 追上 checkpoint,Redo Log 空间不足
  2. 触发 Fuzzy Checkpoint,强制刷脏页
  3. 推进 checkpoint 位置,释放 Redo Log 空间
  4. 如果刷脏速度跟不上写入速度,用户线程会被阻塞

优化:

  • 增大 Redo Log 文件大小
  • 增加脏页刷盘速度(innodb_io_capacity)
  • 避免大事务产生过多 Redo Log

题目 3:什么是两阶段提交?为什么需要两阶段提交?

答:

两阶段提交保证 Redo Log 和 Binlog 的一致性。

流程:

  1. Prepare:写入 Redo Log,标记 prepare
  2. 写入 Binlog
  3. Commit:Redo Log 标记 commit

不用两阶段提交的问题:

  • 先写 Redo Log 后写 Binlog:Redo Log 有记录但 Binlog 没有,从库少数据
  • 先写 Binlog 后写 Redo Log:Binlog 有记录但 Redo Log 没有,主库少数据

崩溃恢复规则:

  • Redo Log prepare + Binlog 完整 → 提交
  • Redo Log prepare + Binlog 不完整 → 回滚

题目 4:长事务对 Undo Log 有什么影响?如何处理?

答:

影响:

  1. Undo Log 无法被 Purge,表空间持续膨胀
  2. 占用大量 Buffer Pool 空间
  3. 影响其他事务的版本链遍历性能
  4. 可能导致 Undo 表空间耗尽

处理方法:

  1. 监控长事务,设置 innodb_kill_idle_transaction
  2. 设置 wait_timeout 和 interactive_timeout 自动断开空闲连接
  3. 开启 innodb_undo_log_truncate 自动截断 Undo 表空间
  4. 业务层避免长事务,拆分大事务
  5. 定期检查 information_schema.INNODB_TRX

题目 5:innodb_flush_log_at_trx_commit 和 sync_binlog 如何配合?

答:

组合数据安全性性能适用场景
flush=1 + sync=1最高最低金融/核心业务
flush=1 + sync=N高中等一般业务
flush=2 + sync=1中高中等可接受少量丢失
flush=2 + sync=N低最高日志/统计

最佳实践:

  • 核心业务:innodb_flush_log_at_trx_commit=1 + sync_binlog=1
  • 非核心业务:可适当降低安全级别换取性能
  • 两者必须配合,否则可能出现主从不一致

题目 6:Redo Log 和 Binlog 有什么区别?

答:

维度Redo LogBinlog
层级InnoDB 引擎层MySQL Server 层
内容物理日志(某个页某个偏移量的修改)逻辑日志(SQL语句或行变更)
写入方式循环写入,空间可复用追加写入,文件不断递增
用途崩溃恢复主从复制、数据恢复
引擎仅 InnoDB所有引擎
事务事务进行中持续写入事务提交时一次性写入

关键区别:Redo Log 是物理日志,记录"页的哪个位置改了什么";Binlog 是逻辑日志,记录"执行了什么SQL"或"行从什么变成了什么"。


第4章 InnoDB 索引原理

4.1 索引概述

4.1.1 索引的分类

分类维度类型说明
数据结构B+树索引、Hash索引、全文索引InnoDB 默认 B+树
物理存储聚簇索引、二级索引主键 vs 非主键
逻辑功能主键索引、唯一索引、普通索引、前缀索引不同约束
字段个数单列索引、联合索引一个 vs 多个字段

4.2 B+树索引

4.2.1 B+树结构

                     [根节点: 50]
                    /            \
          [中间节点: 20,40]    [中间节点: 60,80]
         /     |     \        /     |     \
    [叶:10,20] [叶:30,40] [叶:50,60] [叶:70,80]
         ↔       ↔         ↔         ↔
              双向链表连接

B+树特点:

  1. 非叶子节点只存储键值,不存储数据(扇出大,树矮)
  2. 所有数据存储在叶子节点
  3. 叶子节点通过双向链表连接(范围查询高效)
  4. 树高度通常 3~4 层(千万级数据只需 3~4 次 IO)

4.2.2 B+树 vs B树

特性B+树B树
数据位置仅叶子节点所有节点
叶子链表双向链表无
非叶节点仅键值键值+数据
树高度更矮更高
范围查询高效(链表遍历)需要中序遍历
磁盘IO更少更多

4.2.3 为什么不用 Hash 索引

特性B+树Hash
等值查询O(log n)O(1)
范围查询支持不支持
排序支持不支持
最左前缀支持不支持
模糊查询支持(最左前缀)不支持

4.3 聚簇索引与二级索引

4.3.1 聚簇索引

聚簇索引将数据和索引存储在一起,B+树叶子节点就是完整的数据行:

聚簇索引(主键 id)
┌──────────────────────────────────────────┐
│ 非叶节点: [id=50]                        │
│            /        \                     │
│ 叶子节点:                              │
│ [id=10,name=A,age=20] ↔ [id=20,name=B]  │
│ [id=30,name=C] ↔ [id=40,name=D]         │
└──────────────────────────────────────────┘

聚簇索引规则:

  1. 有主键 → 主键作为聚簇索引
  2. 无主键 → 第一个唯一非空索引
  3. 都没有 → 自动生成 6 字节 DB_ROW_ID

4.3.2 二级索引

二级索引叶子节点存储主键值,而非完整数据行:

二级索引(name 列)
┌──────────────────────────────────┐
│ 非叶节点: [name='M']             │
│            /        \             │
│ 叶子节点:                        │
│ [name=A,id=10] ↔ [name=B,id=20] │
│ [name=C,id=30] ↔ [name=D,id=40] │
└──────────────────────────────────┘

4.3.3 回表查询

通过二级索引查找完整数据的过程:

SELECT * FROM user WHERE name = 'Tom';

1. 在 name 索引 B+树中找到 name='Tom' → id=5
2. 拿 id=5 到聚簇索引 B+树中找到完整行数据
3. 返回结果

这个过程叫"回表"(Bookmark Lookup)

4.3.4 覆盖索引

如果查询的字段都在索引中,就不需要回表:

-- 联合索引 idx_name_age(name, age)
-- 不需要回表(覆盖索引)
SELECT name, age FROM user WHERE name = 'Tom';

-- 需要回表(SELECT * 包含索引外的字段)
SELECT * FROM user WHERE name = 'Tom';

-- EXPLAIN 中 Extra = Using index 表示覆盖索引
EXPLAIN SELECT name, age FROM user WHERE name = 'Tom';

4.4 联合索引与最左前缀原则

4.4.1 联合索引结构

CREATE INDEX idx_name_age_city ON user(name, age, city);
联合索引 B+树排序规则:
先按 name 排序 → name 相同按 age 排序 → age 相同按 city 排序

叶子节点:
[name=A, age=20, city=BJ, id=1]
[name=A, age=25, city=SH, id=2]
[name=B, age=20, city=GZ, id=3]
[name=B, age=30, city=BJ, id=4]

4.4.2 最左前缀原则

-- idx_name_age_city(name, age, city)

-- ✅ 命中索引
SELECT * FROM user WHERE name = 'Tom';
SELECT * FROM user WHERE name = 'Tom' AND age = 20;
SELECT * FROM user WHERE name = 'Tom' AND age = 20 AND city = 'BJ';

-- ✅ 命中索引(优化器会调整顺序)
SELECT * FROM user WHERE age = 20 AND name = 'Tom';

-- ❌ 不命中索引(缺少最左列 name)
SELECT * FROM user WHERE age = 20;
SELECT * FROM user WHERE city = 'BJ';

-- ⚠️ 命中 name,但 age 和 city 无法用索引过滤
SELECT * FROM user WHERE name = 'Tom' AND city = 'BJ';

4.4.3 索引下推(Index Condition Pushdown, ICP)

MySQL 5.6+ 引入,在索引遍历过程中直接过滤,减少回表次数:

-- idx_name_age_city(name, age, city)
SELECT * FROM user WHERE name LIKE 'T%' AND city = 'BJ';

-- 无 ICP:
-- 1. 在 name 索引中找到所有 name LIKE 'T%' 的记录
-- 2. 逐条回表,再过滤 city = 'BJ'

-- 有 ICP:
-- 1. 在索引中找到 name LIKE 'T%' 的记录
-- 2. 直接在索引中过滤 city = 'BJ'(索引包含 city)
-- 3. 只对满足条件的记录回表

-- 查看 ICP 是否生效
EXPLAIN SELECT * FROM user WHERE name LIKE 'T%' AND city = 'BJ';
-- Extra: Using index condition

4.5 索引创建与优化

4.5.1 索引创建原则

-- 1. 在 WHERE/ORDER BY/GROUP BY 的列上建索引
CREATE INDEX idx_status_created ON orders(status, created_at);

-- 2. 选择性高的列优先(区分度高)
-- 选择性 = COUNT(DISTINCT col) / COUNT(*)
SELECT COUNT(DISTINCT name) / COUNT(*) AS selectivity FROM user;

-- 3. 联合索引把选择性高的列放前面
CREATE INDEX idx_high_low ON user(high_cardinality_col, low_cardinality_col);

-- 4. 前缀索引(长字符串列)
CREATE INDEX idx_email_prefix ON user(email(20));

-- 5. 函数索引(8.0+)
CREATE INDEX idx_upper_name ON user((UPPER(name)));

4.5.2 索引失效场景

-- 1. 对索引列使用函数
SELECT * FROM user WHERE LEFT(name, 1) = 'T';  -- ❌
SELECT * FROM user WHERE name LIKE 'T%';         -- ✅

-- 2. 隐式类型转换
SELECT * FROM user WHERE phone = 13800138000;   -- ❌ (phone是varchar)
SELECT * FROM user WHERE phone = '13800138000';  -- ✅

-- 3. LIKE 以通配符开头
SELECT * FROM user WHERE name LIKE '%Tom';       -- ❌
SELECT * FROM user WHERE name LIKE 'Tom%';       -- ✅

-- 4. OR 条件包含非索引列
SELECT * FROM user WHERE name = 'Tom' OR age = 20;  -- ❌ (age无索引)

-- 5. 不满足最左前缀
-- idx(name, age)
SELECT * FROM user WHERE age = 20;              -- ❌

-- 6. NOT IN / NOT EXISTS / != / <>
SELECT * FROM user WHERE status != 1;           -- ❌ (全表扫描)

4.5.3 MySQL 8.0 索引新特性

-- 1. 隐藏索引(测试索引必要性)
ALTER TABLE user ALTER INDEX idx_name INVISIBLE;
ALTER TABLE user ALTER INDEX idx_name VISIBLE;

-- 2. 降序索引(真正支持 DESC)
CREATE INDEX idx_created_desc ON orders(created_at DESC);

-- 3. 函数索引
CREATE INDEX idx_upper_name ON user((UPPER(name)));

-- 4. 不可见索引对优化器不可见,但对 DML 仍需维护

面试实战题

题目 1:为什么 MySQL 使用 B+树而不是 B树作为索引?

答:

  1. IO 次数更少:B+树非叶节点只存键值,单个节点能存更多键值,扇出更大,树更矮。3层 B+树可存千万级数据,只需 3 次 IO
  2. 范围查询高效:叶子节点通过双向链表连接,范围查询只需找到起点后顺序遍历
  3. 查询稳定:所有数据都在叶子节点,每次查询路径长度相同
  4. 更适合磁盘:节点大小等于页大小(16KB),一次 IO 读取一个完整节点

题目 2:什么是回表?如何避免回表?

答:

回表:通过二级索引找到主键值,再到聚簇索引查找完整行数据的过程。

避免回表(覆盖索引):将查询需要的字段都包含在索引中。

-- 需要回表
SELECT * FROM user WHERE name = 'Tom';

-- 覆盖索引,不需要回表
SELECT name, age FROM user WHERE name = 'Tom';
-- Extra: Using index

-- 联合索引覆盖
CREATE INDEX idx_name_age ON user(name, age);
SELECT name, age FROM user WHERE name = 'Tom';  -- 覆盖索引

题目 3:什么是最左前缀原则?联合索引 (a,b,c) 能命中哪些查询?

答:

最左前缀原则:联合索引从最左列开始匹配,遇到范围查询(>/</LIKE/BETWEEN)会停止匹配后续列。

idx(a,b,c) 命中情况:

WHERE 条件命中索引说明
a = 1a命中最左列
a = 1 AND b = 2a, b命中前两列
a = 1 AND b = 2 AND c = 3a, b, c全部命中
b = 2无缺少最左列
a = 1 AND c = 3ac 无法使用索引
a > 1 AND b = 2a范围查询后停止
a = 1 AND b > 2 AND c = 3a, bb 范围查询后 c 停止
b = 2 AND a = 1a, b优化器调整顺序

题目 4:什么是索引下推(ICP)?解决了什么问题?

答:

ICP 是 MySQL 5.6 引入的优化,在索引遍历阶段直接进行条件过滤,减少回表次数。

无 ICP:存储引擎根据索引找到所有满足最左前缀的记录 → 逐条回表 → Server 层过滤其他条件

有 ICP:存储引擎在索引中直接过滤所有可用索引列的条件 → 只对满足条件的记录回表

示例:idx(name, age, city),WHERE name LIKE 'T%' AND city = 'BJ'

  • 无 ICP:找到所有 name LIKE ‘T%’ 的记录,全部回表,再过滤 city
  • 有 ICP:在索引中同时过滤 name 和 city,只回表满足条件的记录

题目 5:索引失效的常见场景有哪些?

答:

  1. 函数操作:WHERE LEFT(name,1) = 'T' → 改为 WHERE name LIKE 'T%'
  2. 隐式类型转换:WHERE phone = 138 (phone 是 varchar) → 改为字符串
  3. LIKE 通配符开头:WHERE name LIKE '%Tom' → 全文索引或 ES
  4. OR 含非索引列:WHERE a=1 OR b=2 (b 无索引) → 给 b 加索引
  5. 不满足最左前缀:联合索引跳过左列
  6. NOT IN/!=:优化器认为全表扫描更快
  7. 索引列参与计算:WHERE id + 1 = 10 → 改为 WHERE id = 9

题目 6:聚簇索引和非聚簇索引有什么区别?

答:

维度聚簇索引非聚簇索引(二级索引)
叶子节点完整行数据主键值
数量每表仅一个可多个
物理顺序索引顺序即数据物理顺序独立的 B+树
查询直接获取数据需要回表
插入顺序建议按主键递增插入无特殊要求
页分裂随机插入可能触发影响较小

核心区别:聚簇索引的叶子节点就是数据本身,二级索引的叶子节点是主键值,需要回表才能获取完整数据。


第5章 MySQL 数据类型与 SQL 高级特性

5.1 数据类型深入

5.1.1 整数类型

类型字节范围(有符号)范围(无符号)
TINYINT1-128~1270~255
SMALLINT2-32768~327670~65535
MEDIUMINT3-8388608~83886070~16777215
INT4-231~231-10~2^32-1
BIGINT8-263~263-10~2^64-1
-- INT(M) 的 M 不影响存储范围,只影响显示宽度(配合 ZEROFILL)
CREATE TABLE t (id INT(5) ZEROFILL);
INSERT INTO t VALUES (42);  -- 显示为 00042

-- 8.0.17+ INT(M) 的显示宽度已废弃,建议用标准格式
CREATE TABLE t (id INT);

5.1.2 字符串类型

类型最大长度存储适用场景
CHAR(N)255字符定长MD5、手机号
VARCHAR(N)65535字节变长姓名、地址
TEXT64KB变长文章内容
MEDIUMTEXT16MB变长长文本
LONGTEXT4GB变长超长文本
-- VARCHAR 长度选择
-- N 是字符数,不是字节数
-- utf8mb4 下,VARCHAR(255) 最多占 255*4 + 2 = 1022 字节

-- CHAR vs VARCHAR
-- CHAR:定长,不足补空格,最大255字符
-- VARCHAR:变长,按实际长度存储,最大65535字节

-- 索引前缀长度限制
-- InnoDB 索引键最大 3072 字节
-- utf8mb4 下 VARCHAR(768) 可完整索引
-- 超过需用前缀索引
CREATE INDEX idx_content ON articles(content(100));

5.1.3 时间类型

类型字节格式范围
DATE3YYYY-MM-DD1000~9999
TIME3HH:MM:SS-838~838小时
DATETIME8YYYY-MM-DD HH:MM:SS1000~9999
TIMESTAMP4YYYY-MM-DD HH:MM:SS1970~2038
YEAR1YYYY1901~2155
-- TIMESTAMP vs DATETIME
-- TIMESTAMP:4字节,自动时区转换,范围到2038年
-- DATETIME:8字节,无时区转换,范围到9999年

-- 8.0 TIMESTAMP 改进
-- 5.7:默认 NOT NULL,第一个 TIMESTAMP 列自动更新
-- 8.0:默认 NULL,不再自动更新,需显式指定

CREATE TABLE t (
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 推荐使用 DATETIME(3) 存储毫秒精度
CREATE TABLE orders (
    created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3)
);

5.1.4 JSON 类型

-- MySQL 8.0 JSON 增强
CREATE TABLE user_profile (
    id INT PRIMARY KEY,
    profile JSON,
    INDEX idx_profile_name ((CAST(profile->'$.name' AS CHAR(50))))
);

-- JSON 操作
INSERT INTO user_profile VALUES (1, '{"name": "Tom", "age": 25, "hobbies": ["reading", "coding"]}');

-- 提取值
SELECT profile->'$.name' FROM user_profile;           -- "Tom"(带引号)
SELECT profile->>'$.name' FROM user_profile;          -- Tom(不带引号)

-- JSON_TABLE(8.0):将 JSON 数组转为关系表
SELECT * FROM JSON_TABLE(
    '[{"id":1,"name":"A"},{"id":2,"name":"B"}]',
    '$[*]' COLUMNS (id INT PATH '$.id', name VARCHAR(50) PATH '$.name')
) AS jt;

-- JSON 聚合
SELECT JSON_ARRAYAGG(name) FROM users;               -- ["A","B","C"]
SELECT JSON_OBJECTAGG(name, age) FROM users;          -- {"A":20,"B":25}

5.2 窗口函数

5.2.1 基本语法

函数名() OVER (
    [PARTITION BY 分区列]
    [ORDER BY 排序列 [ASC|DESC]]
    [frame_clause]
)

5.2.2 排名函数

-- ROW_NUMBER:连续排名,无并列
-- RANK:并列排名,跳号(1,2,2,4)
-- DENSE_RANK:并列排名,不跳号(1,2,2,3)

SELECT
    name,
    score,
    ROW_NUMBER() OVER (ORDER BY score DESC) AS rn,
    RANK() OVER (ORDER BY score DESC) AS rk,
    DENSE_RANK() OVER (ORDER BY score DESC) AS drk
FROM students;

-- 分区排名
SELECT
    dept,
    name,
    salary,
    RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS dept_rank
FROM employees;

5.2.3 聚合窗口函数

-- 累计求和
SELECT
    order_date,
    amount,
    SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders;

-- 移动平均
SELECT
    order_date,
    amount,
    AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg
FROM orders;

-- 分区聚合
SELECT
    dept,
    name,
    salary,
    SUM(salary) OVER (PARTITION BY dept) AS dept_total
FROM employees;

5.2.4 偏移函数

-- LEAD/LAG:前后行偏移
SELECT
    order_date,
    amount,
    LAG(amount, 1) OVER (ORDER BY order_date) AS prev_amount,
    LEAD(amount, 1) OVER (ORDER BY order_date) AS next_amount
FROM orders;

-- FIRST_VALUE/LAST_VALUE
SELECT
    dept,
    name,
    salary,
    FIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary DESC) AS dept_max
FROM employees;

5.3 公用表表达式(CTE)

5.3.1 非递归 CTE

-- 简化复杂查询,提高可读性
WITH dept_avg AS (
    SELECT dept_id, AVG(salary) AS avg_salary
    FROM employees
    GROUP BY dept_id
)
SELECT e.name, e.salary, d.avg_salary
FROM employees e
JOIN dept_avg d ON e.dept_id = d.dept_id
WHERE e.salary > d.avg_salary;

5.3.2 递归 CTE

-- 生成数字序列
WITH RECURSIVE numbers(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;

-- 组织架构树遍历
WITH RECURSIVE org_tree(id, name, manager_id, level) AS (
    -- 锚点:顶级管理者
    SELECT id, name, manager_id, 1
    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 ORDER BY level;

5.4 其他 8.0 SQL 新特性

5.4.1 CHECK 约束

-- 8.0 真正支持 CHECK 约束(5.7 语法支持但不生效)
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    price DECIMAL(10,2),
    CONSTRAINT chk_price CHECK (price > 0),
    CONSTRAINT chk_name CHECK (CHAR_LENGTH(name) >= 2)
);

-- 查看约束
SELECT * FROM information_schema.CHECK_CONSTRAINTS;

5.4.2 隐藏列

-- 隐藏列对 SELECT * 不可见
CREATE TABLE t (
    id INT,
    secret_data VARCHAR(100) INVISIBLE
);

INSERT INTO t(id, secret_data) VALUES (1, 'hidden');
SELECT * FROM t;           -- 只返回 id
SELECT id, secret_data FROM t;  -- 返回两列

5.4.3 NOWAIT 和 SKIP LOCKED

-- NOWAIT:锁等待立即报错
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR 3572: Statement aborted because lock(s) could not be acquired immediately

-- SKIP LOCKED:跳过已锁定的行
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
-- 只返回未被锁定的行(适合任务队列)

5.4.4 VALUES 语句

-- 8.0.19+ VALUES 可作为表源
SELECT * FROM (
    VALUES ROW(1, 'a'), ROW(2, 'b'), ROW(3, 'c')
) AS t(id, name);

面试实战题

题目 1:VARCHAR 和 CHAR 有什么区别?如何选择?

答:

维度CHARVARCHAR
存储定长,不足补空格变长,按实际长度
最大长度255字符65535字节
额外开销无1~2字节长度前缀
更新不会产生碎片可能产生碎片
适用MD5、手机号等定长姓名、地址等变长

选择建议:

  • 长度固定或接近固定 → CHAR(如手机号、MD5)
  • 长度变化大 → VARCHAR(如姓名、地址)
  • VARCHAR(N) 的 N 是字符数不是字节数,utf8mb4 下 VARCHAR(255) 最多占 1022 字节

题目 2:DATETIME 和 TIMESTAMP 有什么区别?

答:

维度DATETIMETIMESTAMP
字节84
范围1000~9999年1970~2038年
时区不转换自动转换
默认NULL8.0默认NULL
NULL允许允许
精度微秒微秒

选择建议:

  • 需要时区支持 → TIMESTAMP
  • 超出2038年 → DATETIME
  • 8.0 推荐用 DATETIME(3) 存储毫秒精度

题目 3:窗口函数和 GROUP BY 有什么区别?

答:

维度GROUP BY窗口函数
结果行数聚合为一行保留原始行数
用途分组聚合分组计算但保留明细
语法GROUP BYOVER(PARTITION BY)

示例:

-- GROUP BY:每个部门一行
SELECT dept, AVG(salary) FROM employees GROUP BY dept;

-- 窗口函数:每行都显示部门平均
SELECT name, dept, salary, AVG(salary) OVER(PARTITION BY dept) FROM employees;

题目 4:JSON 类型在 MySQL 中如何高效使用?

答:

  1. 索引优化:对 JSON 字段中的常用路径创建生成列 + 索引
ALTER TABLE t ADD COLUMN name_char VARCHAR(50)
    GENERATED ALWAYS AS (JSON_UNQUOTE(profile->'$.name'));
CREATE INDEX idx_name ON t(name_char);
  1. 8.0 函数索引:直接对 JSON 路径创建索引
CREATE INDEX idx_name ON t((CAST(profile->'$.name' AS CHAR(50))));
  1. JSON_TABLE:将 JSON 数组展开为关系表
  2. 避免过度使用:频繁更新的字段不适合用 JSON,更新代价大
  3. 部分更新:8.0 支持 JSON 部分更新,只记录变更部分到 Binlog

题目 5:递归 CTE 有哪些典型应用场景?

答:

  1. 组织架构树:查询某人的所有下属(含多级)
  2. 菜单树:查询某菜单的所有子菜单
  3. 评论树:查询某评论的所有回复
  4. 图遍历:好友关系、路径查找
  5. 日期序列:生成日期维度表
  6. 数字序列:生成连续数字
-- 典型:查询所有下属
WITH RECURSIVE subordinates(id, name, level) AS (
    SELECT id, name, 1 FROM employees WHERE id = 1  -- 起始人
    UNION ALL
    SELECT e.id, e.name, s.level + 1
    FROM employees e JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates;

题目 6:NOWAIT 和 SKIP LOCKED 有什么实际用途?

答:

NOWAIT:如果获取不到锁立即报错,不等待。适合需要实时响应的场景。

SKIP LOCKED:跳过已锁定的行,返回未被锁定的行。适合任务队列场景。

-- 任务队列:多个消费者竞争处理任务
-- 消费者1
SELECT * FROM task_queue WHERE status = 'pending'
    FOR UPDATE SKIP LOCKED LIMIT 10;
-- 返回未被其他消费者锁定的任务

-- 库存扣减:如果锁不住立即返回
SELECT * FROM inventory WHERE product_id = 1
    FOR UPDATE NOWAIT;
-- 锁不住立即报错,避免长时间等待

注意:SKIP LOCKED 返回的结果不是一致性读,可能跳过某些行,不适合需要完整结果集的查询。


第6章 SQL 查询优化实战

6.1 EXPLAIN 执行计划

6.1.1 EXPLAIN 输出字段

EXPLAIN SELECT * FROM user WHERE age > 20;
字段说明
id查询序号,越大越先执行
select_type查询类型(SIMPLE/PRIMARY/SUBQUERY等)
table访问的表
partitions匹配的分区
type访问类型(重要)
possible_keys可能使用的索引
key实际使用的索引
key_len索引使用长度
ref索引查找的参考值
rows预估扫描行数
filtered过滤比例
Extra额外信息(重要)

6.1.2 type 字段详解

性能从好到差排列:

type说明场景
system表中只有一行系统表
const主键/唯一索引等值查询WHERE id = 1
eq_ref连接时主键/唯一索引JOIN ON t1.id = t2.id
ref非唯一索引等值查询WHERE name = ‘Tom’
ref_or_nullref + NULL 查询WHERE name = ‘Tom’ OR name IS NULL
range索引范围扫描WHERE age > 20
index全索引扫描覆盖索引但无过滤
ALL全表扫描无索引或索引失效
-- type = const(最优)
EXPLAIN SELECT * FROM user WHERE id = 1;

-- type = ref
EXPLAIN SELECT * FROM user WHERE name = 'Tom';

-- type = range
EXPLAIN SELECT * FROM user WHERE age BETWEEN 20 AND 30;

-- type = ALL(最差,需优化)
EXPLAIN SELECT * FROM user WHERE age + 1 > 20;

6.1.3 Extra 字段详解

Extra说明优化建议
Using index覆盖索引,无需回表最优,无需优化
Using whereServer 层过滤检查是否可下推到索引
Using index condition索引下推(ICP)较优
Using temporary使用临时表需优化,加索引
Using filesort文件排序需优化,加排序索引
Using join buffer连接缓冲检查连接条件索引
Select tables optimized away优化器直接返回最优(如 COUNT(*))

6.2 慢查询定位与分析

6.2.1 慢查询日志

-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;  -- 超过1秒记录
SET GLOBAL log_queries_not_using_indexes = ON;  -- 记录未用索引的查询

-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';

-- 8.0 增强慢日志
SET GLOBAL log_slow_extra = ON;  -- 记录额外信息

6.2.2 mysqldumpslow 分析

# 按查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log

# 按查询次数排序
mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log

# 参数说明
# -s t: 按查询时间排序
# -s c: 按查询次数排序
# -s l: 按锁定时间排序
# -s r: 按返回记录数排序
# -t N: 显示前N条

6.2.3 Performance Schema 监控

-- 8.0 查询最耗时的SQL
SELECT DIGEST_TEXT, COUNT_STAR,
       SUM_TIMER_WAIT/1000000000 AS total_ms,
       AVG_TIMER_WAIT/1000000 AS avg_us
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

-- 查看正在执行的SQL
SELECT * FROM sys.session WHERE command = 'Query';

6.3 常见查询优化

6.3.1 分页优化

-- 传统分页(深分页性能差)
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
-- 扫描 1000010 行,丢弃前 1000000 行

-- 方案1:延迟关联(推荐)
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) t
ON o.id = t.id;
-- 子查询走覆盖索引,只扫描索引列

-- 方案2:游标分页(适合连续翻页)
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10;

-- 方案3:WHERE 条件过滤
SELECT * FROM orders WHERE id BETWEEN 1000001 AND 1000010;

6.3.2 COUNT 优化

-- COUNT(*) vs COUNT(1) vs COUNT(列)
-- COUNT(*) 和 COUNT(1) 等价,统计总行数
-- COUNT(列) 统计该列非 NULL 的行数

-- 优化1:使用覆盖索引
CREATE INDEX idx_status ON orders(status);
SELECT COUNT(*) FROM orders WHERE status = 1;

-- 优化2:近似计数(不需要精确值)
SHOW TABLE STATUS LIKE 'orders';  -- Rows 列为近似值

-- 优化3:汇总表(适合实时性要求不高的场景)
CREATE TABLE order_stats (
    stat_date DATE PRIMARY KEY,
    order_count INT
);

6.3.3 ORDER BY 优化

-- 原则:ORDER BY 的列在索引中,且顺序一致

-- 联合索引 idx(a, b)
SELECT * FROM t WHERE a = 1 ORDER BY b;     -- ✅ 索引排序
SELECT * FROM t ORDER BY a, b;              -- ✅ 索引排序
SELECT * FROM t ORDER BY a DESC, b DESC;    -- ✅ 8.0降序索引
SELECT * FROM t ORDER BY a ASC, b DESC;     -- ❌ 排序方向不一致
SELECT * FROM t ORDER BY b, a;              -- ❌ 顺序不一致

-- 查看是否使用文件排序
EXPLAIN SELECT * FROM t ORDER BY b;
-- Extra: Using filesort → 需要优化

6.3.4 GROUP BY 优化

-- 8.0 不再隐式排序,需显式 ORDER BY
-- 5.7: GROUP BY 默认按分组列排序
-- 8.0: GROUP BY 不保证排序

-- 优化:使用索引避免临时表
CREATE INDEX idx_dept_salary ON employees(dept, salary);
SELECT dept, AVG(salary) FROM employees GROUP BY dept;
-- Extra: Using index → 覆盖索引,无需临时表

-- 松散索引扫描(Loose Index Scan)
SELECT COUNT(DISTINCT dept) FROM employees;
-- 如果索引 idx(dept) 可用松散扫描

6.3.5 JOIN 优化

-- 原则:被驱动表的连接列有索引

-- Nested Loop Join
-- 驱动表每行 → 查找被驱动表 → 索引查找
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- u.id 是主键,查找高效

-- Block Nested Loop Join(无索引时)
-- 使用 join_buffer 缓存驱动表数据,减少被驱动表扫描次数
SET join_buffer_size = 8 * 1024 * 1024;  -- 8MB

-- 8.0 Hash Join(替代 BNL)
-- 对无索引的等值连接使用 Hash Join
EXPLAIN FORMAT=TREE
SELECT * FROM t1 JOIN t2 ON t1.col = t2.col;
-- → Hash join

6.4 优化器 Hint

6.4.1 索引 Hint

-- 强制使用索引
SELECT * FROM user FORCE INDEX(idx_name) WHERE name = 'Tom';

-- 建议使用索引
SELECT * FROM user USE INDEX(idx_name) WHERE name = 'Tom';

-- 忽略索引
SELECT * FROM user IGNORE INDEX(idx_name) WHERE name = 'Tom';

6.4.2 优化器 Switch

-- 关闭 ICP
SET optimizer_switch = 'index_condition_pushdown=off';

-- 关闭 MRR
SET optimizer_switch = 'mrr=off';

-- 查看 optimizer_switch
SELECT @@optimizer_switch;

面试实战题

题目 1:EXPLAIN 中 type 字段的各个值代表什么?哪些需要优化?

答:

type 从优到差:system > const > eq_ref > ref > range > index > ALL

  • const:主键/唯一索引等值查询,一次 IO,最优
  • eq_ref:JOIN 时主键/唯一索引查找,每次一行
  • ref:非唯一索引等值查询,可能多行
  • range:索引范围扫描,BETWEEN/IN/>/<
  • index:全索引扫描,比 ALL 好(索引比数据小)
  • ALL:全表扫描,必须优化

优化目标:至少达到 range 级别,最好 ref 及以上。ALL 必须加索引优化。

题目 2:深分页如何优化?

答:

问题:LIMIT 1000000, 10 扫描 1000010 行,前 1000000 行被丢弃。

优化方案:

  1. 延迟关联:子查询走覆盖索引只取 id,再回表
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) t ON o.id = t.id;
  1. 游标分页:记录上一页最后一条的 id
SELECT * FROM orders WHERE id > 上一页最后id ORDER BY id LIMIT 10;
  1. WHERE 过滤:业务上限制查询范围
SELECT * FROM orders WHERE id BETWEEN 1000001 AND 1000010;
  1. 业务限制:不允许跳页,只提供"上一页/下一页"

题目 3:Using filesort 和 Using temporary 分别代表什么?如何优化?

答:

Using filesort:MySQL 需要额外排序操作,无法通过索引直接获取有序结果。

优化:在 ORDER BY 的列上创建合适的索引,确保排序方向一致。

Using temporary:MySQL 使用临时表存储中间结果,常见于 GROUP BY + ORDER BY 不同列、DISTINCT、UNION。

优化:

  1. GROUP BY 和 ORDER BY 使用相同列
  2. 在分组/排序列上创建联合索引
  3. 避免不必要的 DISTINCT

题目 4:MySQL 8.0 的 Hash Join 有什么优势?

答:

8.0 引入 Hash Join 替代 Block Nested Loop Join:

BNL:驱动表每批数据加载到 join_buffer → 被驱动表全表扫描匹配 → O(M*N)

Hash Join:

  1. 构建阶段:扫描小表,在内存中构建 Hash 表
  2. 探测阶段:扫描大表,在 Hash 表中查找匹配 → O(M+N)

优势:

  • 无索引等值连接性能大幅提升
  • 时间复杂度从 O(M*N) 降为 O(M+N)
  • 适合数据仓库/分析查询

限制:仅支持等值连接(=/<=>),非等值连接仍用 NLJ。

题目 5:如何定位和分析慢查询?

答:

  1. 开启慢查询日志:slow_query_log=ON, long_query_time=1
  2. 分析工具:mysqldumpslow 按时间/次数排序
  3. Performance Schema:events_statements_summary_by_digest 查看最耗时 SQL
  4. sys 库:sys.statements_with_runtimes_in_95th_percentile
  5. EXPLAIN 分析:查看执行计划,关注 type/key/Extra
  6. Optimizer Trace:查看优化器决策过程
SET optimizer_trace='enabled=on';
SELECT * FROM user WHERE age > 20;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G

题目 6:覆盖索引是什么?为什么能提升性能?

答:

覆盖索引:查询所需的所有字段都包含在索引中,无需回表。

原理:二级索引叶子节点存储索引列值 + 主键值。如果查询只需要索引列和主键,直接从索引获取,不需要到聚簇索引查找完整行。

优势:

  1. 减少IO:索引比数据小,更多记录可缓存在Buffer Pool
  2. 避免回表:省去聚簇索引查找的开销
  3. 随机IO变顺序IO:索引按顺序存储

使用方法:

  • 避免 SELECT *,只查需要的列
  • 将查询列加入联合索引
  • EXPLAIN 中 Extra = Using index 表示覆盖索引

第7章 索引优化进阶

7.1 索引设计原则

7.1.1 建索引的场景

-- 1. WHERE 条件列
SELECT * FROM orders WHERE user_id = 100;
CREATE INDEX idx_user_id ON orders(user_id);

-- 2. ORDER BY / GROUP BY 列
SELECT * FROM orders ORDER BY created_at DESC;
CREATE INDEX idx_created_at ON orders(created_at DESC);

-- 3. JOIN 连接列
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users.id 已是主键,orders.user_id 需要索引
CREATE INDEX idx_user_id ON orders(user_id);

-- 4. DISTINCT 列
SELECT DISTINCT status FROM orders;
CREATE INDEX idx_status ON orders(status);

-- 5. 覆盖索引(避免回表)
SELECT user_id, status FROM orders WHERE user_id = 100;
CREATE INDEX idx_user_status ON orders(user_id, status);

7.1.2 不建索引的场景

  1. 区分度低的列:如性别(只有2~3个值),索引过滤效果差
  2. 频繁更新的列:每次更新都需维护索引
  3. 小表:全表扫描比索引查找更快
  4. 查询很少的列:索引占空间,维护有开销

7.1.3 联合索引设计

-- 原则:选择性高的列在前,范围查询列在后

-- 反例:status 区分度低,放前面浪费
CREATE INDEX idx_status_created ON orders(status, created_at);

-- 正例:created_at 区分度高,放前面
CREATE INDEX idx_created_status ON orders(created_at, status);

-- 计算选择性
SELECT
    COUNT(DISTINCT status) / COUNT(*) AS status_selectivity,
    COUNT(DISTINCT created_at) / COUNT(*) AS created_selectivity
FROM orders;
-- status_selectivity: 0.0001 (低)
-- created_selectivity: 0.85 (高)

7.2 索引优化策略

7.2.1 前缀索引

-- 长字符串列,只索引前N个字符
CREATE INDEX idx_email_prefix ON user(email(20));

-- 确定前缀长度:选择性接近完整列
SELECT
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS p10,
    COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS p15,
    COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS p20,
    COUNT(DISTINCT email) / COUNT(*) AS full
FROM user;
-- 选择接近 full 的最小前缀长度

前缀索引的限制:

  • 无法用于覆盖索引(Extra 不会出现 Using index)
  • 无法用于 ORDER BY / GROUP BY
  • 无法用于索引下推

7.2.2 函数索引(8.0+)

-- 对列的函数结果建索引
CREATE INDEX idx_upper_name ON user((UPPER(name)));

-- 查询时自动使用
SELECT * FROM user WHERE UPPER(name) = 'TOM';
-- 命中 idx_upper_name

-- JSON 路径索引
CREATE INDEX idx_profile_age ON user((CAST(profile->'$.age' AS UNSIGNED)));

7.2.3 降序索引(8.0+)

-- 8.0 真正支持降序索引
CREATE INDEX idx_created_desc ON orders(created_at DESC, id ASC);

-- 5.7 语法支持 DESC 但实际创建为 ASC
-- 8.0 真正按 DESC 存储,避免 filesort

-- 验证
EXPLAIN SELECT * FROM orders ORDER BY created_at DESC, id ASC LIMIT 10;
-- 8.0: Extra 无 Using filesort
-- 5.7: Extra 有 Using filesort

7.2.4 隐藏索引

-- 测试索引是否必要:先隐藏,观察性能
ALTER TABLE orders ALTER INDEX idx_created_at INVISIBLE;

-- 确认无影响后删除
ALTER TABLE orders DROP INDEX idx_created_at;

-- 恢复可见
ALTER TABLE orders ALTER INDEX idx_created_at VISIBLE;

7.3 索引统计信息

7.3.1 统计信息收集

-- 查看索引统计信息
SHOW INDEX FROM orders;

-- 关键字段
-- Cardinality: 索引中唯一值的估计数量
-- Cardinality / 表行数 ≈ 选择性

-- 手动更新统计信息
ANALYZE TABLE orders;

-- 自动更新配置
SET GLOBAL innodb_stats_auto_recalc = ON;  -- 默认ON
SET GLOBAL innodb_stats_persistent = ON;   -- 持久化统计信息
SET GLOBAL innodb_stats_persistent_sample_pages = 20;  -- 采样页数

7.3.2 统计信息不准确的影响

-- Cardinality 不准确 → 优化器选错索引
-- 现象:某天突然查询变慢,EXPLAIN 发现换了索引

-- 解决
ANALYZE TABLE orders;  -- 重新收集统计信息

-- 8.0 直方图(Histogram)
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, user_id WITH 100 BUCKETS;

-- 查看直方图
SELECT * FROM information_schema.COLUMN_STATISTICS;

7.4 Online DDL

7.4.1 DDL 算法

-- ALGORITHM 选项
-- COPY: 创建临时表 → 复制数据 → 替换原表(锁表)
-- INPLACE: 在原表上修改(部分操作不锁表)
-- INSTANT: 只修改元数据(8.0.12+,最快)

-- 添加索引(INPLACE,不锁表)
ALTER TABLE orders ADD INDEX idx_status(status), ALGORITHM=INPLACE;

-- 添加列(INSTANT,8.0.12+)
ALTER TABLE orders ADD COLUMN remark VARCHAR(200), ALGORITHM=INSTANT;

-- 查看DDL是否支持INSTANT
SELECT * FROM information_schema.INNODB_TABLES;

7.4.2 Online DDL 最佳实践

-- 1. 选择低峰期执行
-- 2. 使用 ALGORITHM=INPLACE, LOCK=NONE
ALTER TABLE orders ADD INDEX idx_created(created_at),
    ALGORITHM=INPLACE, LOCK=NONE;

-- 3. 大表加索引使用 pt-online-schema-change
-- pt-online-schema-change --alter "ADD INDEX idx_status(status)" D=db,t=orders

-- 4. 监控 DDL 进度
SHOW ALTER TABLE STATUS;  -- 8.0+

7.5 索引监控与维护

7.5.1 未使用索引

-- 查找未使用的索引
SELECT s.table_schema, s.table_name, s.index_name,
       s.non_unique, s.seq_in_index, s.column_name
FROM information_schema.statistics s
LEFT JOIN sys.schema_unused_indexes u
    ON s.table_schema = u.object_schema
    AND s.table_name = u.object_name
    AND s.index_name = u.index_name
WHERE u.index_name IS NOT NULL
    AND s.table_schema NOT IN ('mysql', 'sys', 'performance_schema');

7.5.2 冗余索引

-- 查找冗余索引
-- 如果有 idx(a,b),则 idx(a) 是冗余的
SELECT * FROM sys.schema_redundant_indexes;

-- 冗余索引的危害
-- 1. 占用磁盘空间
-- 2. DML 需要维护多个索引
-- 3. 优化器可能选错索引

7.5.3 索引碎片整理

-- 查看表碎片
SELECT table_name, data_free / 1024 / 1024 AS free_mb
FROM information_schema.tables
WHERE table_schema = 'test' AND data_free > 0;

-- 整理碎片
ALTER TABLE orders ENGINE=InnoDB;  -- 重建表
OPTIMIZE TABLE orders;              -- 同上

-- 在线整理(8.0)
ALTER TABLE orders ENGINE=InnoDB, ALGORITHM=INPLACE;

面试实战题

题目 1:联合索引的设计原则是什么?

答:

  1. 选择性高的列在前:区分度高的列放最左边,过滤效果最好
  2. 范围查询列在后:范围查询后的列无法使用索引
  3. 覆盖索引优先:将查询需要的列都加入索引
  4. 排序/分组列考虑:ORDER BY/GROUP BY 的列纳入索引
  5. 避免冗余:idx(a,b) 已包含 idx(a) 的功能

示例:查询 WHERE a=1 AND b>2 ORDER BY c

  • 索引设计:idx(a, b, c) — a 等值过滤,b 范围过滤,c 排序

题目 2:什么是索引下推?什么场景下有效?

答:

ICP(Index Condition Pushdown)将部分 WHERE 条件下推到存储引擎层,在索引遍历时直接过滤。

有效场景:

  • 联合索引 idx(name, age)
  • WHERE name LIKE 'T%' AND age > 20
  • name 走索引范围扫描,age 在索引中直接过滤
  • 减少回表次数

无效场景:

  • WHERE 条件的列不在索引中
  • 聚簇索引(不需要回表)
  • 子查询条件

题目 3:如何判断一个索引是否需要删除?

答:

  1. sys.schema_unused_indexes:查看从未使用的索引
  2. 隐藏索引测试:ALTER INDEX idx INVISIBLE,观察业务是否受影响
  3. Performance Schema:统计索引使用频率
  4. 判断标准:
    • 从未使用 → 删除
    • 偶尔使用但性能影响小 → 考虑删除
    • 冗余索引(被更宽的联合索引覆盖)→ 删除
  5. 注意:唯一索引用于约束而非查询,不能仅凭使用频率删除

题目 4:Online DDL 的三种算法有什么区别?

答:

算法原理锁表耗时适用
INSTANT只改元数据不锁极快8.0.12+,加列等
INPLACE原表修改部分锁中等加索引、改列类型
COPY创建新表复制锁表慢改字符集等

INSTANT:只修改数据字典,不修改数据文件,瞬间完成
INPLACE:在原表上直接修改,允许并发 DML(取决于操作类型)
COPY:创建临时表 → 复制数据 → 替换原表,期间锁表

题目 5:统计信息不准确会导致什么问题?如何解决?

答:

问题:优化器基于统计信息选择执行计划,统计信息不准确会导致选错索引。

表现:

  • 查询突然变慢
  • EXPLAIN 发现换了索引
  • Cardinality 与实际差异大

解决:

  1. ANALYZE TABLE 手动更新统计信息
  2. 开启 innodb_stats_persistent 持久化统计信息
  3. 增大 innodb_stats_persistent_sample_pages 采样页数
  4. 8.0 使用直方图:ANALYZE TABLE t UPDATE HISTOGRAM ON col
  5. 定期在低峰期执行 ANALYZE TABLE

题目 6:前缀索引有什么限制?如何确定前缀长度?

答:

限制:

  1. 无法用于覆盖索引(EXPLAIN 不会出现 Using index)
  2. 无法用于 ORDER BY / GROUP BY
  3. 无法用于索引下推(ICP)
  4. 区分度可能不如完整索引

确定前缀长度:

SELECT
    COUNT(DISTINCT LEFT(col, 5)) / COUNT(*) AS p5,
    COUNT(DISTINCT LEFT(col, 10)) / COUNT(*) AS p10,
    COUNT(DISTINCT LEFT(col, 15)) / COUNT(*) AS p15,
    COUNT(DISTINCT col) / COUNT(*) AS full
FROM t;

选择区分度接近 full 的最小前缀长度。一般 email 取 20~30,URL 取 30~50。


第8章 事务与 ACID 特性

8.1 事务基础

8.1.1 事务的定义

事务是一组操作的逻辑单元,具有 ACID 四大特性:

-- 典型事务:银行转账
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- A扣款
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- B收款
COMMIT;

-- 如果中间出错
ROLLBACK;  -- 所有修改回滚

8.1.2 ACID 特性详解

特性含义InnoDB 实现
原子性 (Atomicity)事务中的操作要么全部成功,要么全部回滚Undo Log
一致性 (Consistency)事务前后数据库从一个一致状态到另一个一致状态A+I+D 共同保证
隔离性 (Isolation)并发事务之间互不干扰MVCC + 锁
持久性 (Durability)事务提交后修改永久保存Redo Log

8.1.3 事务控制语句

-- 开始事务
START TRANSACTION;  -- 或 BEGIN

-- 提交
COMMIT;

-- 回滚
ROLLBACK;

-- 保存点
SAVEPOINT sp1;
ROLLBACK TO SAVEPOINT sp1;
RELEASE SAVEPOINT sp1;

-- 隐式提交(以下语句会自动提交当前事务)
-- DDL: CREATE/ALTER/DROP/TRUNCATE
-- DCL: GRANT/REVOKE
-- LOCK TABLES
-- SET AUTOCOMMIT = 1

8.2 事务隔离级别

8.2.1 四种隔离级别

隔离级别脏读不可重复读幻读说明
READ UNCOMMITTED可能可能可能能读到未提交数据
READ COMMITTED (RC)不会可能可能Oracle 默认
REPEATABLE READ (RR)不会不会部分解决MySQL 默认
SERIALIZABLE不会不会不会完全串行化
-- 查看当前隔离级别
SELECT @@transaction_isolation;  -- 8.0
SELECT @@tx_isolation;           -- 5.7

-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

8.2.2 并发问题详解

脏读:读到其他事务未提交的数据

-- 事务A                    -- 事务B
SET TX RC;
START TRANSACTION;
                           START TRANSACTION;
                           UPDATE account SET balance = 200 WHERE id = 1;
SELECT balance FROM account WHERE id = 1;
-- 读到 200(未提交)
                           ROLLBACK;
-- 实际 balance 仍为 100,但 A 读到了 200 → 脏读

不可重复读:同一事务内两次读同一行结果不同

-- 事务A                    -- 事务B
SET TX RC;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;
-- 读到 100
                           UPDATE account SET balance = 200 WHERE id = 1;
                           COMMIT;
SELECT balance FROM account WHERE id = 1;
-- 读到 200 → 不可重复读

幻读:同一事务内两次范围查询结果集不同

-- 事务A                    -- 事务B
SET TX RR;
START TRANSACTION;
SELECT * FROM account WHERE balance < 200;
-- 3行
                           INSERT INTO account VALUES(4, 50);
                           COMMIT;
SELECT * FROM account WHERE balance < 200;
-- 4行 → 幻读

8.3 MVCC 多版本并发控制

8.3.1 MVCC 核心组件

  1. 隐藏列:DB_TRX_ID(事务ID)、DB_ROLL_PTR(回滚指针)
  2. Undo Log 版本链:通过 DB_ROLL_PTR 串联历史版本
  3. Read View:一致性读视图

8.3.2 版本链

当前行: {id=1, name='Tom', DB_TRX_ID=5, DB_ROLL_PTR → undo3}
    ↓
Undo3: {id=1, name='Jerry', DB_TRX_ID=4, DB_ROLL_PTR → undo2}
    ↓
Undo2: {id=1, name='Bob', DB_TRX_ID=3, DB_ROLL_PTR → undo1}
    ↓
Undo1: {id=1, name='Alice', DB_TRX_ID=2, DB_ROLL_PTR = NULL}

8.3.3 Read View

Read View 记录生成时刻的活跃事务状态:

字段说明
creator_trx_id创建该 Read View 的事务 ID
m_ids生成时活跃的读写事务 ID 列表
min_trx_idm_ids 中的最小值(低水位)
max_trx_id下一个待分配的事务 ID(高水位)

8.3.4 可见性判断规则

遍历版本链,对每个版本的 trx_id 判断:

1. trx_id == creator_trx_id → 可见(自己修改的)
2. trx_id < min_trx_id → 可见(ReadView 创建前已提交)
3. trx_id >= max_trx_id → 不可见(ReadView 创建后启动的事务)
4. min_trx_id <= trx_id < max_trx_id:
   - trx_id 在 m_ids 中 → 不可见(创建时仍活跃)
   - trx_id 不在 m_ids 中 → 可见(创建时已提交)

如果当前版本不可见 → 沿 DB_ROLL_PTR 找上一个版本

8.3.5 RC vs RR 的本质差异

RC(读已提交):每次 SELECT 生成新的 Read View

事务A (RC):
  T1: SELECT → ReadView1 → 读到版本X
  -- 事务B 修改并提交
  T2: SELECT → ReadView2 → 读到版本Y(新ReadView看到已提交的修改)

RR(可重复读):事务内复用同一个 Read View

事务A (RR):
  T1: SELECT → ReadView1 → 读到版本X
  -- 事务B 修改并提交
  T2: SELECT → ReadView1 → 仍读到版本X(同一ReadView)

8.4 当前读与快照读

8.4.1 快照读

普通 SELECT 语句,读取 MVCC 历史版本,不加锁:

-- 快照读(不加锁)
SELECT * FROM user WHERE id = 1;

8.4.2 当前读

读取最新已提交数据,并加锁:

-- 当前读(加锁)
SELECT * FROM user WHERE id = 1 FOR UPDATE;        -- 排他锁(X)
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁(S) 5.7
SELECT * FROM user WHERE id = 1 FOR SHARE;          -- 共享锁(S) 8.0

-- DML 也是当前读
UPDATE user SET name = 'Tom' WHERE id = 1;  -- 加X锁
DELETE FROM user WHERE id = 1;               -- 加X锁

8.4.3 RR 下的幻读问题

-- RR 隔离级别下,快照读不会幻读(MVCC保证)
-- 但当前读可能幻读:

-- 事务A                          -- 事务B
START TRANSACTION;
SELECT * FROM user WHERE age > 20;
-- 3行
                                 INSERT INTO user VALUES(4, 'Tom', 25);
                                 COMMIT;
UPDATE user SET name = 'new' WHERE age > 20;
-- 影响4行!(包括事务B新插入的行)
SELECT * FROM user WHERE age > 20;
-- 4行 → 幻读!
COMMIT;

InnoDB 通过临键锁(Next-Key Lock)解决当前读的幻读问题:

-- 事务A                          -- 事务B
START TRANSACTION;
SELECT * FROM user WHERE age > 20 FOR UPDATE;
-- 加 Next-Key Lock,锁定 (20, +∞)
                                 INSERT INTO user VALUES(4, 'Tom', 25);
                                 -- 阻塞!等待锁
COMMIT;
                                 -- 此时才能插入

面试实战题

题目 1:MySQL 的四种隔离级别分别解决了什么问题?

答:

隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED❌❌❌无隔离
READ COMMITTED✅❌❌MVCC(每次SELECT新ReadView)
REPEATABLE READ✅✅⚠️MVCC(复用ReadView)+ Next-Key Lock
SERIALIZABLE✅✅✅所有读加共享锁
  • RC 解决脏读:每次 SELECT 生成新 ReadView,只能看到已提交的数据
  • RR 解决不可重复读:事务内复用同一 ReadView,保证多次读结果一致
  • RR 部分解决幻读:快照读通过 MVCC 解决,当前读通过 Next-Key Lock 解决
  • SERIALIZABLE 完全串行化,性能最差

题目 2:MVCC 的实现原理是什么?

答:

MVCC 通过三个组件实现读写不冲突:

  1. 隐藏列:每行记录包含 DB_TRX_ID(最后修改事务ID)和 DB_ROLL_PTR(回滚指针)

  2. Undo Log 版本链:每次修改产生 Undo Log,通过 DB_ROLL_PTR 将历史版本串联

  3. Read View:记录生成时刻的活跃事务状态(m_ids、min_trx_id、max_trx_id)

查询流程:

  1. 生成 Read View(RC 每次 SELECT,RR 事务首次 SELECT)
  2. 从最新版本开始遍历版本链
  3. 根据 Read View 可见性规则判断当前版本是否可见
  4. 不可见则沿 DB_ROLL_PTR 找上一个版本
  5. 找到可见版本返回结果

题目 3:RC 和 RR 的本质区别是什么?

答:

核心区别:Read View 的生成时机不同。

  • RC:每次 SELECT 都生成新的 Read View → 能看到其他事务已提交的最新修改 → 不可重复读
  • RR:事务内第一次 SELECT 生成 Read View,后续复用 → 只能看到事务开始时已提交的数据 → 可重复读

其他差异:

  • RR 下存在 Gap Lock / Next-Key Lock,RC 下只有行锁
  • RC 的 Gap Lock 不生效,不会阻塞 INSERT
  • RC 适合需要看到最新数据的场景(如电商详情页)
  • RR 适合需要数据一致性的场景(如金融对账)

题目 4:什么是快照读和当前读?有什么区别?

答:

维度快照读当前读
语句普通 SELECTSELECT FOR UPDATE/SHARE, DML
读取MVCC 历史版本最新已提交数据
加锁不加锁加行锁/间隙锁
幻读RR下不会需 Next-Key Lock 防止

关键点:

  • 快照读通过 MVCC 实现,读写不冲突
  • 当前读读取最新数据并加锁,保证一致性
  • 同一事务中,先快照读后当前读,可能导致看到不一致的数据

题目 5:RR 隔离级别下是否完全解决了幻读?

答:

不完全解决。RR 下幻读有两种情况:

  1. 快照读:通过 MVCC 解决,不会幻读
  2. 当前读:通过 Next-Key Lock 解决,不会幻读

但存在例外:

-- 事务A                          -- 事务B
START TRANSACTION;
SELECT * FROM user WHERE id = 5;
-- 不存在
                                 INSERT INTO user VALUES(5, 'Tom', 25);
                                 COMMIT;
UPDATE user SET name = 'new' WHERE id = 5;
-- 影响1行(当前读能看到新插入的行)
SELECT * FROM user WHERE id = 5;
-- 存在!→ 幻读

原因:先快照读(id=5 不存在,不加锁),再 UPDATE(当前读),导致幻读。

解决:使用 SELECT FOR UPDATE 代替普通 SELECT。

题目 6:长事务有什么危害?如何避免?

答:

危害:

  1. Undo Log 膨胀:长事务持有旧 Read View,大量 Undo Log 无法 Purge
  2. 锁持有时间长:增加死锁概率,阻塞其他事务
  3. Buffer Pool 污染:长事务可能加载大量冷数据
  4. 主从延迟:长事务的 Binlog 在提交时一次性发送

避免方法:

  1. 设置 innodb_kill_idle_transaction 自动杀空闲事务
  2. 设置 wait_timeout 自动断开空闲连接
  3. 业务层拆分大事务为小事务
  4. 监控长事务:information_schema.INNODB_TRX
  5. 避免在事务中做 RPC 调用等耗时操作

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐