运维知识
悠悠
2026年5月4日

一条 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 连接。
典型流程:

  1. 客户端发起 TCP 三次握手
  2. MySQL 这边的 mysqld 进程接收连接,把它交给一个线程(或者线程池里的线程)
  3. 连接建立后,进入 MySQL 的认证阶段

1.2 身份认证 + 权限快照

握手完成,MySQL 要做两件事:

  • 校验账号密码(mysql.user 等系统表)
  • 把当前用户的权限读出来,在这个连接上缓存一份快照

这一步非常关键,有两个细节很多人没意识到:

  1. 之后你所有执行的 SQL 权限判断,都是基于这份快照
  2. 即使 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 继续往下走

听起来很美好,但有两个现实问题:

  1. 命中率极低
    生产里 SQL 条件各种拼,哪怕一个空格不一样、一个 LIMIT 不一样,都算完全不同的 key。
  2. 失效极其粗暴
    某个表被 INSERT/UPDATE/DELETE 一下,这个表相关的缓存统统失效,维护成本巨大。

所以真实情况就是:
几乎所有 SQL 都得进缓存查一圈,然后发现没有,用了一堆 CPU 做无用功。
这也是为啥 8.0 干脆直接把它删了。

2.2 没缓存,那就老老实实走解析器

我们的这条 SQL,在现在主流的 8.0 里,就不会走查询缓存,直接丢给解析器


解析器:SQL 文本被拆成“语法树”的全过程

很多人以为解析就是“看下有没有语法错误”。
MySQL 这里做得比你想象复杂不少。

3.1 词法分析:从字符到 Token

从左到右,一个字符一个字符扫过去,按规则切成一个个 Token:

  • 关键字:SELECTFROMWHERE
  • 标识符:useridname
  • 运算符:=AND
  • 常量:'13800001234'1

这一步就能抓出一部分低级错误,比如你写了个 SELEC id FROM user;
SELEC 不认,就直接在这一步报错了。

3.2 语法分析:给你拼成一棵树

Token 流出来之后,解析器会根据 MySQL 的语法规则,去组装一个语法树(Parse Tree / AST)

形象点说,你刚才那条 SQL,在解析器眼里,差不多就变成这样一种树状结构(概念上):

  • SELECT

    • fields: id, name, age
    • FROM

      • table: user
    • WHERE

      • AND

        • = (phone, '13800001234')
        • = (status, 1)

这一步会检查语法级别的错误,比如:

  • SELECT id FROM(缺表名)
  • SELECT FROM user(缺字段)
  • WHERE phone = AND status = 1(条件表达式错误)

但要注意:
表存在不存在、字段对不对、有没有权限,这些都还没检查,它只是从语言角度看你写得合不合法。

3.3 预处理:校验表、字段、别名、权限

解析完 AST 之后,就进入一个容易被忽略的阶段:预处理(preprocessor)

这里会干几件关键的事:

  1. 把表名解析成具体的表对象(比如 db_name.user 映射到真正的元数据)
  2. 检查列名是否存在(user 表到底有没有 phonestatus 这些列)
  3. 处理别名、子查询、视图等的字段绑定(有点类似编译器里的“符号绑定”)
  4. 校验权限:当前用户对这个表有没有 SELECT 权限

你平时遇到的很多错误,其实是在这一步报的,比如:

  • Unknown column 'xxx' in 'field list'
  • Table 'db.abc' doesn't exist
  • SELECT 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 + 一主键一二级索引的场景下,优化器至少要考虑这些问题:

  1. 用不用索引?

    • 直接全表扫描(不走索引)
    • 使用 idx_phone 二级索引
  2. 如果用索引,以什么顺序过滤?

    • 先用 phone 定位,再回表拿 status 判断
    • 或者(如果有联合索引)利用联合索引范围查询
  3. 多表 join 的话,该先驱动谁?
    这条 SQL 没 join,但多表场景下优化器会做关联重排,比如 A join B 还是 B join A。
  4. 有没有必要排序、建临时表?
    比如 SQL 里有 ORDER BYGROUP BYDISTINCT 等时,需要考虑排序方式、是否使用临时表。

4.2 基于代价的优化(Cost-Based)

MySQL 的优化器本质是一个基于代价(CBO)的系统。
每种可能的执行方案,都会估算一个“成本”(大体是:要扫描多少行、随机 IO 次数、CPU 运算量等),然后挑一个“看起来最便宜”的方案来用。1[4]

这就涉及几个输入:

  • 统计信息:索引基数(Cardinality)、数据分布、大概行数
  • 语句条件:WHEREORDER BYGROUP 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 = ref
  • key = idx_phone
  • rows = 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)

在这个过程中,执行器做几件事:

  1. 再次做权限校验(有些版本/配置会在执行前再兜一层)
  2. 构造行数据(包括函数计算,比如你 SELECT UPPER(name)
  3. 按协议格式写入网络 buffer,发给客户端

从客户端看,就是你在终端里一行一行看到结果返回。
对执行器来说,是一边从 InnoDB 拿、一边往 socket 里塞。


存储引擎:InnoDB 里面到底怎么“读”

很多人讲到这里就停了:“执行器调用存储引擎,存储引擎从磁盘读数据”。
但真正的性能瓶颈、锁问题、事务隔离,基本都藏在 InnoDB 这一层。

6.1 Buffer Pool:内存是第一现场

InnoDB 把数据页按块(一般 16KB)缓存到内存里,这块就是Buffer Pool。[1]

SQL 来了以后:

  1. 先看这次要读的索引页在不在 Buffer Pool 里

    • 在:内存直接读,很快
    • 不在:触发一次磁盘 IO,把那一页读进来,再从中取记录
  2. 读到的页,如果是热点,会长期呆在 Buffer Pool 里,后续读几乎不需要磁盘

所以:

  • 同一条 SQL,在数据全在 Buffer Pool 的情况下,耗时是一个数量级
  • 冷启 / 大量淘汰页之后,再跑一次,耗时可能翻好几倍

6.2 二级索引 + 聚簇索引:你那次“回表”其实在干嘛

idx_phone 是一个二级索引,B+ 树结构,叶子节点存的是:

  • phone 的值
  • 对应主键 id

执行的时候会这样走:

  1. idx_phone 树上,用 '13800001234' 做等值查找
  2. 得到一堆主键值,比如 id = 123456
  3. 用这个主键到聚簇索引(主键 B+ 树)上再查一次,把整行数据读出来
  4. 从行里取出 statusnameage 等字段

这一步就是常说的回表
如果你的 SQL 只需要的字段都在二级索引里,那就可以做到覆盖索引,连回表都免了,速度快一大截。

6.3 MVCC、可见性、快照读

我们的这个 SELECT 在默认隔离级别(InnoDB,REPEATABLE READ)下,是一个快照读
也就是说:

  • 你开始执行这条 SQL 时,会拿到一个一致性视图(Read View)
  • 之后即使别人更新了这行数据,你看到的还是事务开始时“那一刻”的数据

InnoDB 是通过 undo log + 隐藏列(trx_id 等) 实现 MVCC 的。
逻辑大概是:

  1. 行记录里有一个事务 ID(最后修改它的事务编号)
  2. 每次更新时,旧值写入 undo log,行记录更新为新值 + 新事务 ID
  3. 当你读一行时,InnoDB 看这行当前版本是否对你可见

    • 可见:直接返回
    • 不可见:沿着 undo 链往前翻,找到一个对你可见的版本返回

所以你看到的“结果集”,未必是当前磁盘上的“最新版本”,而是对你这个事务而言的一致版本

这个细节在排查数据不一致、幻读、锁等待时非常关键,不展开讲,不然篇幅直接爆炸。


更新语句比查询多干了哪些事(redo/binglog 两阶段)

虽然题目说的是“执行一条 SQL 的过程”,一般都会拿 SELECT 讲。
但很多人的痛苦,都是被 UPDATE / DELETE 折磨出来的,所以顺带把更新的执行路径也理一下。2

换一条 SQL:

UPDATE user
SET status = 2
WHERE phone = '13800001234';

大体流程前半段跟查询一模一样:

  • 连接器 / 解析器 / 优化器 / 执行器 / 存储引擎
  • 也是先用 idx_phone 找到主键,再回表

不一样的是:开始修改数据 + 写日志

7.1 InnoDB 内部更新顺序(逻辑)

从 InnoDB 的视角,大概是这样:

  1. 在 Buffer Pool 里找到那条记录所在的数据页(不在就从磁盘读入)
  2. 加行锁 / gap 锁,确保并发安全
  3. 记录一条 undo log(用于回滚,MVCC 读旧版本也靠它)
  4. 在内存页里把 status 改成 2
  5. redo log(重做日志),标记为 prepare
  6. 告诉 Server 层:“我这边 OK 了,可以提交”

接下来轮到 Server 层:

  1. binlog(归档日志)
  2. 再回去通知 InnoDB:“可以 commit 了”
  3. 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 里的整体过程:

  1. 客户端通过 TCP 和 MySQL 建立连接,连接器认证账号密码,加载权限快照
  2. 客户端把 SQL 文本丢过来,Server 层接收
  3. 在 5.7 之前可能会查一眼查询缓存(8.0 已没有)
  4. 解析器对 SQL 做词法、语法分析,生成语法树
  5. 预处理阶段绑定表、列、检查权限,生成更“语义完整”的内部结构
  6. 优化器基于统计信息,生成执行计划:决定用哪个索引、怎么回表、过滤顺序
  7. 执行器根据执行计划,逐步调用 InnoDB 的 handler 接口:打开表、定位索引、读记录
  8. InnoDB 在 Buffer Pool 里查找数据页,不在就触发磁盘 IO 读入
  9. InnoDB 按 MVCC 规则选出对当前事务可见的版本(快照读)
  10. 执行器对每条返回的记录做 WHERE 条件过滤、函数计算等
  11. 符合条件的行被封装进协议,顺序写入网络 buffer,回给客户端
  12. 客户端一点点从 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 的“生命周期”尽量从工程实践的角度摊开了一遍。
如果你觉得这篇对你有点帮助,可以先收藏起来,有空再配合 EXPLAINSHOW ENGINE INNODB STATUS 多练几次,
很多之前看不懂的现象都会慢慢串起来。

如果你对 MySQL 这一块还想看更多类似这种“偏实战、少废话”的拆解,可以关注我,后面会继续写:

  • 锁、MVCC、死锁排查
  • 磁盘 IO 抖动下 InnoDB 的表现
  • 慢查询从 EXPLAIN 到现场复盘的完整过程

也欢迎把这篇文章转发给身边正在被 MySQL 折腾的同学。


公众号:耕云躬行录
个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码