一条SQL的完整旅程
从客户端发送SQL到存储引擎返回数据,MySQL内部经历了哪些环节?每一步对应的源码函数是什么?
Client
发送SQL
①
连接处理
权限/阈值检查
②
← check_access()
查询缓存
8.0已移除
③
解析器
词法/语法分析
生成AST
④
← mysql_parse()
优化器
选择执行计划
成本计算
⑤
← optimize_sql()
执行器
调用引擎API
迭代器模型
⑥
← mysql_execute_command()
InnoDB
存储引擎
读写数据
⑦
← ha_innobase::write_row()
Buffer Pool
Disk
返回结果集
各阶段源码映射:
mysql_parse()
→
optimize_sql()
→
mysql_execute_command()
→
ha_innobase::write_row()
图1-1:MySQL SQL执行完整流水线 — 从客户端到磁盘再返回
⚙ 核心机制
SQL = 查询指令 (你要查什么数据)
Parser = 词法/语法解析 (SQL格式对不对?SELECT能解析,SELCT就报错)
Optimizer = 优化器选择执行计划 (走索引还是全表扫描?先过滤还是先JOIN?选成本最低的方案)
Executor = 执行器调用存储引擎接口 (按计划逐行获取数据)
InnoDB = 存储引擎层 (数据在哪,Buffer Pool=内存缓存,Disk=磁盘)
// sql/sql_parse.cc — MySQL查询执行核心调度
int mysql_execute_command (THD *thd) {
switch (thd->lex->sql_command) {
case SQLCOM_SELECT:
// ④ 解析阶段已完成,此时进入执行
res = execute_sqlcom_select (thd, all_tables);
break ;
case SQLCOM_UPDATE:
res = mysql_update (thd, all_tables);
break ;
}
}
bool execute_sqlcom_select (THD *thd, TABLE_LIST *tables) {
// ⑤ 优化器:选择最优执行计划
optimize_sql (thd); // ← optimize_sql()
// ⑥ 执行器:按计划调用存储引擎API
Query_expression::Execute (thd);
// 内部循环调用 handler->ha_index_read()
// 最终调用 ha_innobase::index_read()
}
各阶段核心职责
① 客户端发送SQL — 通过TCP连接发送文本SQL语句到MySQL Server
② 连接处理 — 验证用户权限、检查连接数阈值、分配线程
③ 查询缓存 — MySQL 8.0已移除此功能(命中率低、维护成本高)
④ 解析器 — 词法分析(拆token) + 语法分析(建AST) + 语义检查(表/列是否存在)
⑤ 优化器 — 基于成本选择执行计划:选哪个索引、JOIN顺序、是否走全表扫描
⑥ 执行器 — 按执行计划迭代调用存储引擎API,逐行返回结果
⑦ InnoDB — 在Buffer Pool中查找数据页,未命中则从磁盘加载
💡 为什么MySQL 8.0移除了查询缓存?
查询缓存在高并发写入场景下频繁失效,命中率极低。每次表数据变更都需要清空相关缓存,锁竞争严重。8.0用prepared statement缓存替代。
InnoDB存储引擎内部 — 数据住在哪里
数据在内存和磁盘之间如何流转?B+Tree如何组织数据?Buffer Pool如何管理热数据?
内存 (InnoDB Buffer)
Buffer Pool
数据页缓存
索引页缓存
自适应哈希索引
← buf_LRU_add_block()
Log Buffer
Redo Log缓冲
循环写入
← log_buffer_write()
Change Buffer
二级索引变更缓存
刷盘
异步刷脏
磁盘 (ibd files)
System Tablespace
数据字典
Doublewrite Buffer
Redo Log Files
ib_logfile0/1
WAL保证持久性
*.ibd 文件 (每表独立表空间)
B+Tree组织的页数据 (每页16KB)
Undo Tablespace
MVCC版本链 / 事务回滚
缺页时从磁盘加载
图2-1:InnoDB内存与磁盘架构总览
B+Tree 查找路径:SELECT * FROM user WHERE id=5
1
5
10
∞
Root (Page 3)
1
3
5
7
Branch (Page 9)
key range [5,10)
key range [10,∞)
id=3 | 张三
id=5 | 李四
id=6 | 王五
id=7 | 赵六
Leaf (Page 42) — 聚簇索引叶节点存完整行数据
双向链表
查找路径 (3次IO)
1. Root Page 3: 5 ∈ [1, 10) → 走中间指针
2. Branch Page 9: 5 ∈ [3, 7) → 走第3指针
3. Leaf Page 42: 找到 id=5 → 返回整行
← btr_cur_search_to_nth_level() B+Tree逐层查找
树高=3时只需3次磁盘IO
图2-2:B+Tree 查找 id=5 的完整路径 — 从Root到Leaf只需3层IO
📖 查字典类比
B+Tree = 查字典 (部首检字表 → 页码 → 正文,逐层缩小范围)
Root = 部首目录 (先定位到哪个大部),Branch = 检字表 (再定位页码),Leaf = 正文页 (最终找到内容)
Page = 书的一页 (每页16KB,InnoDB最小IO单位)
Buffer Pool LRU链 — 改进的中点插入策略
Young Zone (前 5/8)
Page A
Page B
Page C
Page D
Page E
Page F
← Head (MRU)
Old Zone (后 3/8)
Page G
Page H
新页!
← Tail (LRU, 先淘汰)
Midpoint
新读入的页插入Old区头部
再次被访问才移入Young区
中点插入策略
1. 新页不直接进Young区头部
2. 而是插入Old区头部(midpoint)
3. Old区页被再次访问 → 升入Young区
4. 防止全表扫描把热数据挤出去
← buf_LRU_add_block() 中点插入
innodb_old_blocks_time 控制停留时间
图2-3:Buffer Pool改进LRU — 中点插入防止全表扫描污染热数据
📚 书架类比
Buffer Pool = 书架 (常用书放前面,新来的书先放中间观察区)
Young区 = 书架前排 (经典好书,反复翻阅不会被挤走)
Old区 = 书架后排 (新书先放这,被再次翻到才升入前排)
// storage/innobase/btr/btr0cur.cc — B+Tree逐层查找
void btr_cur_search_to_nth_level (
dict_index_t *index, ulint level,
const dtuple_t *tuple, ulint mode,
ulint latch_mode, btr_cur_t *cursor)
{
// 从Root开始逐层向下搜索
page_id_t page_id(dict_index_get_space(index),
dict_index_get_page(index)); // Root Page
while (page_level > level) {
// 在当前页内二分查找,确定子节点指针
offset = page_cur_search (block->frame, tuple);
// 获取子节点页号
child_page = page_get_child(page, offset);
// 加载子节点到Buffer Pool
block = buf_page_get (child_page); // 可能触发磁盘IO
page_level = page_get_level(block->frame);
}
// 到达目标层,cursor指向找到的记录
cursor->page_cur.rec = page_cur_get_rec();
}
// storage/innobase/buf/buf0lru.cc — 中点插入策略
void buf_LRU_add_block (buf_block_t *block, ibool old) {
if (old) {
// 新页插入LRU链中点(Old区头部)
UT_LIST_INSERT(LRU, buf_pool->LRU, old_start, block);
} else {
// 被再次访问,移到Young区头部
UT_LIST_INSERT(LRU, buf_pool->LRU, start, block);
}
}
特性 B+Tree B-Tree Hash索引
结构
非叶节点只存key,数据全在叶节点
每个节点都存key+data
哈希表,key→slot
范围查询
叶节点链表,天然支持
需要中序遍历,效率低
不支持
等值查询
O(log N)
O(log N)
O(1) 最快
磁盘IO
扇出大,树矮,IO少
扇出小,树高,IO多
1次(内存)或1次IO
排序
叶节点有序,支持ORDER BY
不支持高效排序
无序
InnoDB使用
所有索引的底层结构
不使用
自适应哈希索引(AHI)
索引查找的完整路径 — 从Root到Leaf
同一个查询,走索引和全表扫描有多大差距?二级索引回表是什么?覆盖索引为何是最优解?
SELECT * FROM user WHERE name='张三' — 三种查找路径对比
SELECT * FROM user WHERE name='张三'
Case 1: 二级索引 + 回表
有name索引,但SELECT *
name_idx B+Tree → 找到 name='张三'
← row_search_mvcc()
叶节点得到 主键 id=5
回表!聚簇索引再查 id=5
← row_sel_get_clust_rec()
返回完整行数据
IO次数: 树高2 + 回表树高3
≈ 5次 (若全在Buffer则0次磁盘)
Case 2: 覆盖索引(不回表)
联合索引(name,age),SELECT name,age
idx_name_age B+Tree查找
← row_search_mvcc()
叶节点已包含name+age
无需回表!直接返回
← 不需要row_sel_get_clust_rec()
IO次数: 树高2
≈ 2次,最快路径!
Case 3: 全表扫描(无索引)
name上无任何索引
扫描 Page 1 → 不匹配
扫描 Page 2 → 不匹配
扫描 Page 3 → 不匹配
... 一直扫到最后 ...
扫描 Page N → 找到!
← row_search_mvcc() 全表扫描
IO次数: 全部数据页
≈ N次,最慢路径
性能对比(100万行表)
二级索引+回表 ≈ 5ms
覆盖索引 ≈ 2ms ⭐
全表扫描 ≈ 500ms+
图3-1:同一条SQL的三种执行路径 — 索引决定性能差距百倍
📖 查书类比
二级索引 = 书的目录 (知道页码后还要翻到那一页,即"回表")
覆盖索引 = 目录里已有答案 (不需要翻页,目录直接告诉你结果)
全表扫描 = 从第一页翻到最后一页 (逐页找,最笨但有时是唯一选择)
PRO TIP
覆盖索引是最优解 。EXPLAIN的Extra列出现Using index说明命中覆盖索引,无需回表。设计索引时,尽量让查询列都包含在索引中。
-- Case 1: 二级索引 + 回表
EXPLAIN SELECT * FROM user WHERE name='张三' ;
-- type: ref, key: idx_name, Extra: NULL (需要回表)
-- Case 2: 覆盖索引 (联合索引 name+age)
EXPLAIN SELECT name, age FROM user WHERE name='张三' ;
-- type: ref, key: idx_name_age,
-- Extra: Using index ← 命中覆盖索引!不回表
-- Case 3: 全表扫描 (name无索引)
EXPLAIN SELECT * FROM user WHERE name='张三' ;
-- type: ALL, key: NULL, rows: 1000000
-- Extra: Using where ← 全表扫描+服务层过滤
事务的一生 — 从BEGIN到COMMIT
WAL如何保证数据不丢失?MVCC如何实现快照读?Undo Log版本链如何工作?
WAL(Write-Ahead Logging) 流程 — 两阶段提交保证一致性
BEGIN
← trx_start()
修改数据页
(Buffer Pool)
Redo Log
Prepare
← mtr_commit()写Redo
Binlog
写入Binlog
← MYSQL_BIN_LOG::write()
Redo Log
Commit
← trx_commit()
OK
返回
两阶段提交 (2PC) 详解
1. 修改Buffer Pool数据页(内存中,尚未落盘)
2. 写Redo Log到Log Buffer → 刷盘(Prepare状态)← 保证崩溃可恢复
3. 写Binlog(主从复制依赖)
4. Redo Log标记Commit(原子性保证)
5. 返回客户端OK,后续异步刷脏页到磁盘
如果步骤3之后崩溃:Redo是Prepare + Binlog完整 → 提交;Binlog不完整 → 回滚
图4-1:WAL两阶段提交流程 — 先写日志再写数据,崩溃也不丢
📝 草稿盖章类比
WAL = 先写草稿再正式提交 (Redo Log = 草稿,Commit = 盖章生效)
两阶段提交 = 草稿先签"待定",正式文件写完再签"确认" (保证Redo和Binlog一致)
Buffer Pool脏页 = 改了但还没归档的文件 (后台线程异步刷盘)
MVCC — READ COMMITTED vs REPEATABLE READ
当前行 (Latest Version)
id=5, name='张三'
DB_TRX_ID=100, DB_ROLL_PTR →
Undo Log 1
name='李四', trx_id=99
DB_ROLL_PTR →
Undo Log 2
name='王五', trx_id=95
(链尾,无更早版本)
READ COMMITTED (每次SELECT新建ReadView)
T1: BEGIN (trx_id=90)
→ SELECT: ReadView={m_ids=[95,99,100]}
→ 当前行trx_id=100 ∈ m_ids → 不可见
→ 沿Undo链找: trx_id=99 ∈ m_ids → 不可见
→ trx_id=95 ∈ m_ids → 不可见
→ 每次SELECT重新创建ReadView
→ 所以能看到其他事务已提交的最新数据
← trx_read_view_sees() 判断可见性
REPEATABLE READ (事务开始时创建ReadView)
T1: BEGIN (trx_id=90)
→ 第1次SELECT: 创建ReadView快照
→ 第2次SELECT: 复用同一ReadView
→ 第N次SELECT: 仍是同一ReadView
→ 同一事务内多次读取结果一致
→ 这就是"可重复读"的含义
→ 仅第一次SELECT时创建ReadView
← row_search_mvcc() 创建ReadView
图4-2:MVCC版本链 + ReadView — 不同隔离级别看到不同版本
🕵 眼镜类比
MVCC = 每个人戴不同颜色眼镜看同一件商品 (看到的是不同版本)
Undo Log = 商品的历史版本 (商品被改过,但旧版本还在仓库里)
ReadView = 你入场时拿的入场券 (上面写着你能看到哪个时间点之后的版本)
RC = 每看一次换一张新入场券 (能看到别人刚提交的),RR = 全程用同一张入场券 (看到的一直不变)
// storage/innobase/trx/trx0trx.cc — 事务生命周期
void trx_start (trx_t *trx) {
trx->state = TRX_STATE_ACTIVE;
trx->id = trx_sys_get_new_trx_id (); // 分配事务ID
}
void trx_commit (trx_t *trx) {
// 两阶段提交:
// Phase 1: Redo Log Prepare
trx_commit_prepare (trx); // ← mtr_commit()写Redo
// 写Binlog (Server层)
MYSQL_BIN_LOG::write (thd, &binlog_evt);
// Phase 2: Redo Log Commit
trx_commit_commit (trx); // ← 标记Commit
// 释放ReadView, 清理Undo
read_view_close (trx->read_view);
}
// storage/innobase/read/read0read.cc — ReadView判断可见性
bool trx_read_view_sees (
const read_view_t *view, trx_id_t trx_id)
{
// trx_id < view->up_limit_id → 可见(事务已提交)
if (trx_id < view->up_limit_id) return true ;
// trx_id >= view->low_limit_id → 不可见(事务后开始)
if (trx_id >= view->low_limit_id) return false ;
// trx_id in view->m_ids → 不可见(活跃事务)
return !view->m_ids.contains(trx_id);
}
锁的运行流程 — 谁等谁,怎么等
行锁、间隙锁、临键锁各自锁定什么范围?死锁如何发生?InnoDB如何检测?
InnoDB锁层次结构
表锁 (Table Lock)
LOCK TABLE / DDL
行锁 (Record Lock)
锁定索引记录
← lock_rec_lock(LOCK_REC)
间隙锁 (Gap Lock)
锁定索引间隙(开区间)
← lock_rec_lock(LOCK_GAP)
临键锁 (Next-Key)
Record + Gap(左开右闭)
← lock_rec_lock(LOCK_ORDINARY)
RR隔离级别下默认加Next-Key Lock = Gap Lock + Record Lock,防止幻读
等值查询唯一索引:Next-Key退化为Record Lock;等值查询未命中:退化为Gap Lock
间隙锁之间不冲突(多个事务可同时持有同一间隙的Gap Lock),间隙锁只阻塞INSERT
图5-1:InnoDB锁层次 — 从表锁到临键锁
两个事务竞争同一行 — 行锁等待与释放
时间 →
T1
BEGIN
SELECT...FOR UPDATE
← lock_rec_lock() 加行锁
UPDATE SET...
COMMIT
← lock_rec_unlock() 释放锁
T2
BEGIN
UPDATE同一行 → 等待lock_wait...
← lock_wait 阻塞中
获得锁 → 执行UPDATE
COMMIT
T1释放锁后T2才能继续
行锁特性:只有通过索引条件检索数据才使用行锁,否则退化为表锁。InnoDB行锁锁的是索引,不是数据行本身。
lock_wait_timeout 默认50秒,超时返回ERROR 1205
图5-2:行锁竞争 — T2等待T1释放锁后才能继续执行
间隙锁场景 — 防止幻读
索引值:
5
10
15
20
25
Gap Lock (10, 20)
← lock_rec_lock(LOCK_GAP) 间隙锁
T1:
SELECT * FROM t WHERE id > 10 AND id < 20 FOR UPDATE
→ 锁住间隙(10,20),防止其他事务在此范围INSERT
T2:
INSERT INTO t VALUES (15) → BLOCKED! 间隙锁冲突
间隙锁要点
1. 仅在RR隔离级别下生效
2. 目的:防止幻读(范围内新插入行)
3. Gap Lock之间不互斥
4. Gap Lock只阻塞INSERT
5. RC级别下无Gap Lock
实际加锁 = Next-Key Lock(左开右闭)
= Record Lock + Gap Lock
图5-3:间隙锁防止幻读 — 锁住间隙阻止INSERT
死锁场景 — T1锁A等B,T2锁B等A
T1
LOCK row A ✓
等待 row B ... ⏳
T2
LOCK row B ✓
等待 row A ... ⏳
死锁!
死锁检测与处理
1. InnoDB自动检测: DeadlockChecker::check()
2. 回滚代价最小的事务
3. 返回 ERROR 1213: Deadlock found
← DeadlockChecker::check()
如何避免死锁?
1. 按固定顺序访问表和行 2. 保持事务短小 3. 降低隔离级别(RC无间隙锁) 4. 添加合理索引避免锁升级
图5-4:死锁场景 — 循环等待,InnoDB自动检测并回滚代价最小的事务
🔒 柜子走廊类比
行锁 = 锁柜子 (别人不能开这个柜子,但可以开其他柜子)
间隙锁 = 锁走廊 (别人不能进这段走廊放新柜子,防止"凭空多出柜子"=幻读)
临键锁 = 锁柜子+锁走廊 (既锁住当前柜子,又锁住相邻走廊)
死锁 = 你拿A钥匙等B,他拿B钥匙等A (两个人互相等,谁也动不了)
锁类型 锁定范围 与什么冲突 何时自动加 源码函数
Record Lock
索引上一条记录
另一个Record Lock(X)
UPDATE/DELETE/SELECT FOR UPDATE命中行
lock_rec_lock(LOCK_REC)
Gap Lock
索引记录之间的间隙(开区间)
INSERT(只阻塞插入)
RR级别下范围查询/等值未命中
lock_rec_lock(LOCK_GAP)
Next-Key Lock
Record + 前面Gap(左开右闭)
INSERT + Record Lock
RR级别下默认加锁方式
lock_rec_lock(LOCK_ORDINARY)
Insert Intention
插入位置间隙
Gap Lock
INSERT时自动请求
lock_rec_lock(LOCK_INSERT_INTENTION)
// storage/innobase/lock/lock0lock.cc — InnoDB锁核心
dberr_t lock_rec_lock (
bool impl, ulint mode,
const buf_block_t *block, ulint heap_no,
que_thr_t *thr)
{
// mode: LOCK_REC / LOCK_GAP / LOCK_ORDINARY
// LOCK_ORDINARY = Next-Key Lock (默认)
// 1. 检查是否与已有锁冲突
if (lock_rec_has_conflict (lock, mode)) {
// 2. 冲突 → 进入等待队列
lock_wait_suspend_thread (thr); // ← 阻塞等待
} else {
// 3. 无冲突 → 授予锁
lock_rec_add_to_queue (mode, block, heap_no, trx);
}
}
// 死锁检测 — 每1秒触发
void DeadlockChecker::check (trx_t *trx) {
// 构建等待图(Wait-for Graph)
// 检测是否存在环(循环等待)
if (has_cycle) {
// 选择代价最小的事务回滚
trx_rollback (victim_trx);
// 返回 ERROR 1213
}
}
// 锁释放 — 事务COMMIT时
void lock_rec_unlock (trx_t *trx) {
// 释放该事务持有的所有行锁
// 唤醒等待队列中的下一个事务
lock_wait_release_thread (waiting_thr);
}
面试速答
MySQL内部原理高频面试题,每题30秒速答版
Q1: 一条SELECT语句在MySQL中经历了哪些阶段?
答: 客户端发送SQL → 连接处理(权限校验) → 解析器(词法语法分析生成AST) → 优化器(选执行计划/选索引) → 执行器(调用存储引擎API) → InnoDB(Buffer Pool查找/磁盘IO) → 返回结果。对应源码:mysql_parse() → optimize_sql() → mysql_execute_command() → ha_innobase::index_read()。
Q2: 为什么InnoDB选择B+Tree而不是B-Tree或Hash?
答: B+Tree非叶节点只存key不存data,一个16KB页能放更多key,扇出更大,树更矮,磁盘IO更少。叶节点链表支持高效范围查询。B-Tree每个节点存key+data导致扇出小、树高。Hash不支持范围查询和排序。InnoDB用B+Tree做主结构,自适应哈希索引(AHI)做等值查询加速补充。
Q3: 什么是回表?覆盖索引如何避免回表?
答: 二级索引叶节点存的是主键值,不是完整行。通过二级索引找到主键后,还要回聚簇索引查一次完整行,这就是"回表"(row_sel_get_clust_rec())。覆盖索引指查询需要的列全部包含在索引中,无需回聚簇索引。EXPLAIN Extra列显示Using index即命中覆盖索引。
Q4: WAL两阶段提交是怎么回事?
答: 事务提交时先写Redo Log(Prepare) → 写Binlog → Redo Log(Commit)。两阶段保证Redo和Binlog一致:崩溃恢复时,Prepare+Binlog完整→提交;Binlog不完整→回滚。这样主从复制(Binlog)和崩溃恢复(Redo)不会矛盾。
Q5: MVCC的ReadView在RC和RR下有什么区别?
答: RC(READ COMMITTED):每次SELECT都创建新的ReadView,能看到其他事务已提交的最新数据。RR(REPEATABLE READ):事务内只有第一次SELECT创建ReadView,后续复用,所以同一事务内多次读同一行结果一致。核心函数:trx_read_view_sees()判断版本可见性。
Q6: 间隙锁什么时候加?和死锁有什么关系?
答: RR级别下默认加Next-Key Lock(Record+Gap)。等值查询未命中时退化为Gap Lock,命中时退化为Record Lock。间隙锁之间不冲突,只阻塞INSERT。死锁发生在:T1锁A等B,T2锁B等A,形成循环等待。InnoDB通过DeadlockChecker::check()自动检测,回滚代价最小的事务。避免方法:固定顺序访问、保持事务短小、降低隔离级别。
速记口诀
SQL旅程: 连接→解析→优化→执行→引擎
B+Tree: 非叶只存key,数据全在叶,叶间有链表,范围查询快
索引三路径: 二级索引要回表,覆盖索引不回表,没有索引全表扫
WAL: 先写日志再写数据,两阶段保一致
MVCC: RC每次新视图,RR复用旧视图,Undo链存历史
锁: 行锁锁记录,间隙锁锁区间,临键锁都锁,死锁自动检
核心三原则速记
MySQL三原则:① 索引设计优先于SQL优化 → ② 避免在WHERE条件中使用函数(索引失效)→ ③ 大查询用EXPLAIN验证执行计划