一条SQL的完整旅程 — MySQL 查询执行原理图解

从客户端发送SQL到InnoDB返回数据,每一个环节都有源码级映射

MySQL 8.0 InnoDB B+Tree MVCC WAL 锁机制
一条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=磁盘)

MySQL 8.0 Source
📁 sql/
sql_parse.cc
sql_executor.cc
sql_optimizer.cc
📁 handler/
ha_innodb.cc
📁 storage/innobase/
row/row0sel.cc
trx/trx0trx.cc
buf/buf0lru.cc
// 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
📁 buf/
buf0lru.cc
📁 page/
page0cur.cc
// 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+TreeB-TreeHash索引
结构 非叶节点只存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说明命中覆盖索引,无需回表。设计索引时,尽量让查询列都包含在索引中。
EXPLAIN Output
📁 Case 1
二级索引+回表
📁 Case 2
覆盖索引
📁 Case 3
全表扫描
-- 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
trx0roll.cc
📁 log/
log0log.cc
📁 row/
row0sel.cc
📁 read/
read0read.cc
// 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
lock0wait.cc
📁 handler/
ha_innodb.cc
// 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验证执行计划