一条 SQL 从敲下回车,到在 MySQL 里“跑完一生”,中间到底经历了啥?
很多人写 SQL 写了好几年,EXPLAIN 也看过,慢查询也调过,但真要问一句:
“我在客户端敲下 SELECT ... 回车后,MySQL 里面具体一步一步干了啥?”
大概率只能说个连接 → 解析 → 优化 → 执行这种级别的回答。
看个面试还能糊弄一下,上了生产就完全不够用了。
我这篇就干一件事:
以一条 SQL 为线,把它从网卡一直送到磁盘,路上遇到的每一个“角色”,具体做了什么、怎么做、有什么坑,都掰开揉碎说清楚。
不讲历史故事,不聊产品背景,纯过程、纯细节、纯生产视角。
为了方便,你脑子里先立个场景:
SELECT id, name, age
FROM user
WHERE phone = '13800001234'
AND status = 1;user 表用 InnoDB,引擎里有这些东西:
PRIMARY KEY (id)- 二级索引
idx_phone (phone) status上没有单独索引
下面就按它的真实“生命轨迹”往下走。
从客户端到 MySQL:连接那一瞬间其实已经有坑了
SQL 想进 MySQL,先得有连接,这个在 Server 层里是连接器干的事。
1.1 TCP 建链 + 握手
不管你用的是 Navicat、JDBC、Go 客户端还是命令行,底层都是 TCP 连接。
典型流程:
- 客户端发起 TCP 三次握手
- MySQL 这边的
mysqld进程接收连接,把它交给一个线程(或者线程池里的线程) - 连接建立后,进入 MySQL 的认证阶段
1.2 身份认证 + 权限快照
握手完成,MySQL 要做两件事:
- 校验账号密码(
mysql.user等系统表) - 把当前用户的权限读出来,在这个连接上缓存一份快照
这一步非常关键,有两个细节很多人没意识到:
- 之后你所有执行的 SQL 权限判断,都是基于这份快照
- 即使 DBA 后面改了你这个账号的权限,只要连接不断开,你的权限依然按老的来
生产上见过好几次:
DBA 改了权限说“已经禁止这个账号 DROP 了”,结果业务连接一直没断,照样能删表,删完大家面面相觑。
1.3 连接数和连接复用
这又跟 SQL 执行没那么直接,但会决定你能不能发出那条 SQL。
max_connections控制最大连接数- 连接池(比如 Druid、HikariCP)会复用连接,不会每次都重新握手
连接器这块看似简单,其实夯住了整个 SQL 生命周期的入口:
有连接,才有后面的故事。
SQL 到达 Server 层:老版本还有查询缓存这一关
连接建立后,客户端把 SQL 文本发给 MySQL。
这时候 SQL 命令进了 Server 层,没开头说的那些架构图,我们直接走流程。
说明一下:MySQL 8.0 之后已经没查询缓存了,如果你还在用 5.6/5.7,可以顺便带着看这个坑。
2.1 查询缓存的“鸡肋人生”(老版本)
在老版本里(5.6/5.7 部分版本没关):
SELECT 语句进来后,Server 层会先用 SQL 文本(+当前数据库、某些环境)作为 key,到查询缓存里查一把。
- 命中:直接把结果集从内存拿出来返回给客户端
- 没命中:SQL 继续往下走
听起来很美好,但有两个现实问题:
- 命中率极低
生产里 SQL 条件各种拼,哪怕一个空格不一样、一个LIMIT不一样,都算完全不同的 key。 - 失效极其粗暴
某个表被INSERT/UPDATE/DELETE一下,这个表相关的缓存统统失效,维护成本巨大。
所以真实情况就是:
几乎所有 SQL 都得进缓存查一圈,然后发现没有,用了一堆 CPU 做无用功。
这也是为啥 8.0 干脆直接把它删了。
2.2 没缓存,那就老老实实走解析器
我们的这条 SQL,在现在主流的 8.0 里,就不会走查询缓存,直接丢给解析器。
解析器:SQL 文本被拆成“语法树”的全过程
很多人以为解析就是“看下有没有语法错误”。
MySQL 这里做得比你想象复杂不少。
3.1 词法分析:从字符到 Token
从左到右,一个字符一个字符扫过去,按规则切成一个个 Token:
- 关键字:
SELECT、FROM、WHERE… - 标识符:
user、id、name - 运算符:
=、AND - 常量:
'13800001234'、1
这一步就能抓出一部分低级错误,比如你写了个 SELEC id FROM user;,SELEC 不认,就直接在这一步报错了。
3.2 语法分析:给你拼成一棵树
Token 流出来之后,解析器会根据 MySQL 的语法规则,去组装一个语法树(Parse Tree / AST)。
形象点说,你刚才那条 SQL,在解析器眼里,差不多就变成这样一种树状结构(概念上):
SELECTfields:id,name,ageFROMtable:user
WHEREAND=(phone,'13800001234')=(status,1)
这一步会检查语法级别的错误,比如:
SELECT id FROM(缺表名)SELECT FROM user(缺字段)WHERE phone = AND status = 1(条件表达式错误)
但要注意:
表存在不存在、字段对不对、有没有权限,这些都还没检查,它只是从语言角度看你写得合不合法。
3.3 预处理:校验表、字段、别名、权限
解析完 AST 之后,就进入一个容易被忽略的阶段:预处理(preprocessor)。
这里会干几件关键的事:
- 把表名解析成具体的表对象(比如
db_name.user映射到真正的元数据) - 检查列名是否存在(
user表到底有没有phone、status这些列) - 处理别名、子查询、视图等的字段绑定(有点类似编译器里的“符号绑定”)
- 校验权限:当前用户对这个表有没有
SELECT权限
你平时遇到的很多错误,其实是在这一步报的,比如:
Unknown column 'xxx' in 'field list'Table 'db.abc' doesn't existSELECT command denied to user 'xxx' for table 'user'
还有一个经常被用来提升性能的点:预处理 + 预编译语句(Prepared Statement)。
大家都知道预编译能省解析的成本,这个就是背后那部分逻辑:1
准备语句的时候:
PREPARE stmt FROM
'SELECT id, name, age FROM user
WHERE phone = ? AND status = ?';- 解析、预处理、权限校验在 PREPARE 时做
- 真正执行时只换参数,提高了重复执行场景下的性能
优化器:SQL 怎么“走”,在这里被决定
语法树通过预处理之后,就算是“合法”了。
但怎么从磁盘把数据高效搜出来,是优化器(Optimizer)的工作。
你可以理解为:
这一步把“你写的 SQL”翻译成“数据库要跑的一套步骤”,也就是执行计划(Execution Plan)。
4.1 这条查询大概要面临哪些选择?
我们的示例 SQL:
SELECT id, name, age
FROM user
WHERE phone = '13800001234'
AND status = 1;在 InnoDB + 一主键一二级索引的场景下,优化器至少要考虑这些问题:
用不用索引?
- 直接全表扫描(不走索引)
- 使用
idx_phone二级索引
如果用索引,以什么顺序过滤?
- 先用
phone定位,再回表拿status判断 - 或者(如果有联合索引)利用联合索引范围查询
- 先用
- 多表 join 的话,该先驱动谁?
这条 SQL 没 join,但多表场景下优化器会做关联重排,比如 A join B 还是 B join A。 - 有没有必要排序、建临时表?
比如 SQL 里有ORDER BY、GROUP BY、DISTINCT等时,需要考虑排序方式、是否使用临时表。
4.2 基于代价的优化(Cost-Based)
MySQL 的优化器本质是一个基于代价(CBO)的系统。
每种可能的执行方案,都会估算一个“成本”(大体是:要扫描多少行、随机 IO 次数、CPU 运算量等),然后挑一个“看起来最便宜”的方案来用。1[4]
这就涉及几个输入:
- 统计信息:索引基数(Cardinality)、数据分布、大概行数
- 语句条件:
WHERE、ORDER BY、GROUP BY等
举个简单例子:
- 如果
phone列基数非常高(几乎唯一),
那WHERE phone = '13800001234'预估结果就是极少量行,走idx_phone几乎一定划算。 - 如果
status = 1这个条件能匹配大部分行,而phone用的是LIKE '%xxx'这种后缀匹配,
优化器层面可能宁可全表扫描(不过这条 SQL 里phone是等值,情况很好)。
当然,这个“估计”可能不准,因为统计信息不新、采样不够精细,
这也是为什么有时候你会发现:EXPLAIN 出来的计划明显不是你想要的,但它就坚决要走那个索引。
4.3 执行计划长啥样?
执行计划在内部是个树,Server 层会按这棵树和存储引擎交互。
从我们这条 SQL 的维度,你用 EXPLAIN 看大概是这个样子:
EXPLAIN
SELECT id, name, age
FROM user
WHERE phone = '13800001234'
AND status = 1\G可能会看到:
type = refkey = idx_phonerows = 1(估算)Extra = Using where
大概可以翻译为:
先用idx_phone找到匹配的记录指针
再回表到聚簇索引里取出整行数据
然后再用status = 1过滤一次
注意:执行计划不会生成类似 Java 字节码那种东西,
它是一棵“指令树”,执行器会按照这棵树一步步调用存储引擎。1[4]
执行器:一边叫存储引擎干活,一边把结果往外推
执行计划准备好了,轮到执行器(Executor)上场。
你可以把它理解成一个调度器:
- 向存储引擎发起“读某个索引的下一行”“按主键读这一行”这种调用
- 把返回的行数据套上 Server 层的各种逻辑(权限、触发器、函数等)
- 最后封装协议,发回给客户端
5.1 执行过程的“伪代码感”
按我们的执行计划,大概会是这样的流程(逻辑上):
open_table(user)
use index idx_phone
for each record where phone = '13800001234':
row = read_row_from_clustered_index(pk)
if row.status == 1:
send_to_client(row.id, row.name, row.age)
close_table(user)在这个过程中,执行器做几件事:
- 再次做权限校验(有些版本/配置会在执行前再兜一层)
- 构造行数据(包括函数计算,比如你
SELECT UPPER(name)) - 按协议格式写入网络 buffer,发给客户端
从客户端看,就是你在终端里一行一行看到结果返回。
对执行器来说,是一边从 InnoDB 拿、一边往 socket 里塞。
存储引擎:InnoDB 里面到底怎么“读”
很多人讲到这里就停了:“执行器调用存储引擎,存储引擎从磁盘读数据”。
但真正的性能瓶颈、锁问题、事务隔离,基本都藏在 InnoDB 这一层。
6.1 Buffer Pool:内存是第一现场
InnoDB 把数据页按块(一般 16KB)缓存到内存里,这块就是Buffer Pool。[1]
SQL 来了以后:
先看这次要读的索引页在不在 Buffer Pool 里
- 在:内存直接读,很快
- 不在:触发一次磁盘 IO,把那一页读进来,再从中取记录
- 读到的页,如果是热点,会长期呆在 Buffer Pool 里,后续读几乎不需要磁盘
所以:
- 同一条 SQL,在数据全在 Buffer Pool 的情况下,耗时是一个数量级
- 冷启 / 大量淘汰页之后,再跑一次,耗时可能翻好几倍
6.2 二级索引 + 聚簇索引:你那次“回表”其实在干嘛
idx_phone 是一个二级索引,B+ 树结构,叶子节点存的是:
phone的值- 对应主键
id
执行的时候会这样走:
- 在
idx_phone树上,用'13800001234'做等值查找 - 得到一堆主键值,比如
id = 123456 - 用这个主键到聚簇索引(主键 B+ 树)上再查一次,把整行数据读出来
- 从行里取出
status、name、age等字段
这一步就是常说的回表。
如果你的 SQL 只需要的字段都在二级索引里,那就可以做到覆盖索引,连回表都免了,速度快一大截。
6.3 MVCC、可见性、快照读
我们的这个 SELECT 在默认隔离级别(InnoDB,REPEATABLE READ)下,是一个快照读。
也就是说:
- 你开始执行这条 SQL 时,会拿到一个一致性视图(Read View)
- 之后即使别人更新了这行数据,你看到的还是事务开始时“那一刻”的数据
InnoDB 是通过 undo log + 隐藏列(trx_id 等) 实现 MVCC 的。
逻辑大概是:
- 行记录里有一个事务 ID(最后修改它的事务编号)
- 每次更新时,旧值写入 undo log,行记录更新为新值 + 新事务 ID
当你读一行时,InnoDB 看这行当前版本是否对你可见
- 可见:直接返回
- 不可见:沿着 undo 链往前翻,找到一个对你可见的版本返回
所以你看到的“结果集”,未必是当前磁盘上的“最新版本”,而是对你这个事务而言的一致版本。
这个细节在排查数据不一致、幻读、锁等待时非常关键,不展开讲,不然篇幅直接爆炸。
更新语句比查询多干了哪些事(redo/binglog 两阶段)
虽然题目说的是“执行一条 SQL 的过程”,一般都会拿 SELECT 讲。
但很多人的痛苦,都是被 UPDATE / DELETE 折磨出来的,所以顺带把更新的执行路径也理一下。2
换一条 SQL:
UPDATE user
SET status = 2
WHERE phone = '13800001234';大体流程前半段跟查询一模一样:
- 连接器 / 解析器 / 优化器 / 执行器 / 存储引擎
- 也是先用
idx_phone找到主键,再回表
不一样的是:开始修改数据 + 写日志。
7.1 InnoDB 内部更新顺序(逻辑)
从 InnoDB 的视角,大概是这样:
- 在 Buffer Pool 里找到那条记录所在的数据页(不在就从磁盘读入)
- 加行锁 / gap 锁,确保并发安全
- 记录一条 undo log(用于回滚,MVCC 读旧版本也靠它)
- 在内存页里把
status改成2 - 写 redo log(重做日志),标记为
prepare - 告诉 Server 层:“我这边 OK 了,可以提交”
接下来轮到 Server 层:
- 写 binlog(归档日志)
- 再回去通知 InnoDB:“可以 commit 了”
- InnoDB 把 redo log 标记为
commit
这就是常说的:InnoDB redo log + binlog 两阶段提交。1[4]
7.2 为什么要搞这么复杂的两阶段?
原因非常现实:要保证崩溃恢复后,本地数据和 binlog 里的数据一致。
简单想象几种错误顺序:
- 如果先写 redo 并直接 commit,再写 binlog
那写完 redo commit 后宕机、binlog 还没写,这条记录本地已经算提交,
但 binlog 里啥都没有,后面做主从复制、基于 binlog 的增量备份,全都少这条变更。 - 如果先写 binlog,再写 redo commit
写完 binlog 崩了,本地 redo 没 commit,这条数据恢复后回滚,
但 binlog 里面却有这条语句,从库和备份都会应用,数据就彻底不一致。
两阶段提交的大致意思就是:
- redo prepare:我先记一下这事要做
- 写 binlog:把变更记录发到归档/复制系统
- redo commit:我确认这事已经和外界(binlog)对齐了
崩溃恢复时:
- redo 是 commit:认为事务已完成
redo 是 prepare:再去检查 binlog 里有没有对应事务
- 有:补上 commit
- 没:回滚
这就是二阶段提交的那套逻辑,靠这个保证了事务的 crash-safe + 主从一致。
再把这条 SQL 的“一生”串一下
把所有细节都过了一遍,我们回头再用一段话,串起这条查询 SQL 在 MySQL 里的整体过程:
- 客户端通过 TCP 和 MySQL 建立连接,连接器认证账号密码,加载权限快照
- 客户端把 SQL 文本丢过来,Server 层接收
- 在 5.7 之前可能会查一眼查询缓存(8.0 已没有)
- 解析器对 SQL 做词法、语法分析,生成语法树
- 预处理阶段绑定表、列、检查权限,生成更“语义完整”的内部结构
- 优化器基于统计信息,生成执行计划:决定用哪个索引、怎么回表、过滤顺序
- 执行器根据执行计划,逐步调用 InnoDB 的 handler 接口:打开表、定位索引、读记录
- InnoDB 在 Buffer Pool 里查找数据页,不在就触发磁盘 IO 读入
- InnoDB 按 MVCC 规则选出对当前事务可见的版本(快照读)
- 执行器对每条返回的记录做
WHERE条件过滤、函数计算等 - 符合条件的行被封装进协议,顺序写入网络 buffer,回给客户端
- 客户端一点点从 socket 里收到结果集,打印在控制台/返回给业务代码
如果是 UPDATE:
- 前半程一样
- 在引擎层多了:写 undo + 修改内存 + redo(prepare)
- 在 Server 层多了:写 binlog + 通知 redo(commit)
- 崩溃恢复时靠 redo + binlog 做最终判决
这堆细节在生产里到底有什么用?
讲这么多,看似“底层原理”,但这些对线上问题排查、SQL 优化真的很直接。
随手举几个非常接地气的用法:
- 为什么单条 SELECT 有时突然变很慢?
你就可以按“是否命中 Buffer Pool”、“优化器是否选错索引”、“执行计划里 rows 预估错误”等路径一步步排查。 - 为什么权限改了,老连接还按老权限来?
知道权限在连接建立时就缓存,你就不会一改权限就指望马上生效,而是会安排连接重建。 - 慢 UPDATE/DELETE 到底是在写磁盘慢,还是锁冲突?
你知道 InnoDB 是“先改内存+写日志,再刷盘”,你会优先看锁等待、redo 写入情况,而不是一上来就怀疑磁盘坏了。 - 主从数据偶尔不一致怎么起手?
搞清楚二阶段提交和 redo/binlog 的协同逻辑,你排查时会顺着事务 ID 去看 redo 状态 + binlog 记录。
说白了:
你对“一条 SQL 在 MySQL 里跑过的每一步”越熟,生产里的各种奇怪现象越不容易把你整懵。
就写到这,算是把一条 SQL 的“生命周期”尽量从工程实践的角度摊开了一遍。
如果你觉得这篇对你有点帮助,可以先收藏起来,有空再配合 EXPLAIN、SHOW ENGINE INNODB STATUS 多练几次,
很多之前看不懂的现象都会慢慢串起来。
如果你对 MySQL 这一块还想看更多类似这种“偏实战、少废话”的拆解,可以关注我,后面会继续写:
- 锁、MVCC、死锁排查
- 磁盘 IO 抖动下 InnoDB 的表现
- 慢查询从 EXPLAIN 到现场复盘的完整过程
也欢迎把这篇文章转发给身边正在被 MySQL 折腾的同学。
公众号:耕云躬行录
个人博客:躬行笔记