一条 SQL 从输入到返回,中间经历了什么

三年前我在一家做 SaaS 的公司,手里有一张 800 万行的订单表。

有天早上监控告警,后台的订单列表页从 200 毫秒涨到 4 秒。我先看了下 SQL,很普通:

sql 复制代码
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC
LIMIT 20;

索引有,(shop_id, status, created_at) 也在。SQL 没改过,代码没发版,为什么突然慢了。

后来发现是运营在前一天导了一批历史数据,shop_id = 10241 这个店铺的订单从 3 万条涨到 60 万条。索引没错,是数据分布变了------而这个"变",只有理解了 SQL 在 MySQL 内部到底走了哪几站,你才知道该去动哪一颗螺丝。

这篇就把这条路完整走一遍。

一、先看清全貌:一条 SQL 要穿过四层

很多人对 MySQL 的理解停在"它是个黑盒,我给它 SQL,它还我数据"。实际上中间有四层,每层职责非常清楚,而且跨层只通过约定好的接口。

连接层 负责"你是谁"。TCP 三次握手、用户名密码校验、加载这个用户的权限位图。一条连接对应一个线程,线程用完不销毁,放回 thread_cache 里等着下一次用。这也是为什么生产环境一定要用连接池------建立连接本身就要花 0.1 到 1 毫秒,而一条简单查询可能只需要 0.05 毫秒。

Server 层 负责"你想干什么"。解析、预处理、优化、执行,全在这一层。它最大的特点是与引擎无关:同一个 SQL 发给 InnoDB 和 MyISAM,前面这些步骤完全一样。

存储引擎层负责"数据在哪、怎么取"。InnoDB 在这里管 Buffer Pool、B+ 树索引、redo log、undo log。

文件系统 负责"掉电之后还在"。.ibd 存表数据和索引,redo log 和 binlog 各自落盘。

这里有个特别值得记住的细节:Server 层不知道 B+ 树长什么样 。它只会调用一组叫 handler 的接口,说"给我下一行"。至于这一行是从内存里的页拿的,还是从磁盘读的,Server 层不关心。

这个设计带来一个直接后果:换引擎不用改 SQL 。也带来一个隐蔽的坑:很多你以为引擎在做的过滤,其实是 Server 层一行行做的。后面讲索引下推会专门说这件事。

二、十站旅程:从左到右看一遍

把上面四层展开,一条 SELECT 大概要经过十站。

我给每一站配了大致的耗时量级,这张表建议存下来:

站点 做什么 典型耗时 出问题时的表现
1 建连接 TCP 握手 0.1~1 ms 连接数打满、Too many connections
2 认证 校验密码、载权限 微秒级 权限报错、连不上
3 收 SQL 读 socket 0.02 ms 超大 SQL 包、max_allowed_packet
4 解析 词法+语法 → 语法树 几微秒 1064 语法错误
5 预处理 查表、查列、查权限 几微秒 1146 / 1054
6 优化 选索引、定连接顺序 几十微秒 选错索引,慢 1000 倍
7 执行 打开表,调引擎 微秒级 锁等待
8 取页 Buffer Pool 或磁盘 0.1 微秒 ~ 10 ms 命中率低、IO 打满
9 过滤返回 Server 层再筛一遍 与行数成正比 扫描行数远大于返回行数
10 发送 打包写回 socket 与结果集大小成正比 大结果集撑爆内存

前十站里,真正决定快慢的是第 6 站和第 8 站。其余几站加起来,通常不到总耗时的 1%。

这解释了为什么调 SQL 的时候,"加个索引"往往比"优化写法"有效得多------你动的是第 6 站,而第 6 站能决定第 8 站要读多少页。

三、第 4、5 站:先把字符串变成树

在解析器眼里,你的 SQL 只是一串字节。它要做的第一件事是切词。

SELECT、FROM、WHERE 这些是关键字,name、users 是标识符,'杭州' 是字符串常量。切完之后按语法规则拼成一棵抽象语法树(AST),根节点是 SELECT_LEX,下面挂着投影列表、数据源、过滤条件、排序、限制。

这一站能拦住的问题很有限,但也很有代表性:

sql 复制代码
-- 1064:语法错误,解析器这一站就挂了
SELECT * FORM users;

-- 1054:列不存在,要等到预处理器
SELECT nmae FROM users;

-- 1146:表不存在,也是预处理器
SELECT * FROM userz;

很多人以为这三种错误是同一类,其实不是。FORM 是拼写错误,语法树根本建不起来;nmae 和 userz 语法完全合法,只是对象不存在,得进入预处理器去数据字典里核对。

预处理阶段还做三件事:

  1. 展开 select * ,把它变成具体的列清单。这也是为什么 select * 有额外开销------虽然很小,但它确实多做了一步。
  2. 检查权限。注意,连接建立时加载的是"用户级别"的权限,真正到表级、列级,还要在这里再核一次。
  3. 处理 having 和 where 的语义差异,确定哪些条件下推给引擎,哪些留在 Server 层。

这一步做完,MySQL 才算真正"听懂"了你要什么。

四、第 6 站:优化器其实在做算术

这是整条链路上最值得研究的一站,也是绝大多数慢 SQL 的源头。

优化器不看数据,只算账。它会枚举几种可能的执行路径,给每条路标一个"代价",然后挑最便宜的。

假设有这么一个查询:

sql 复制代码
SELECT * FROM orders WHERE user_id = 100 AND status = 1;

表里 100 万行,user_id 上有索引,status 上没有。两条路:

路 A:全表扫描。 50000 个数据页全读一遍,代价约 50000。

路 B:走 idx_user_id。 统计信息说 user_id = 100 大约匹配 200 行,那么二级索引读 3~4 页,回表 200 次,代价约 220。

优化器选 B。这个决策是基于估算的:它并没有真的去数那 200 行,而是查了索引的基数(cardinality)和数据分布,估出来的。

用 EXPLAIN 能看到它算完的结果:

sql 复制代码
mysql> EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
         type: ref
possible_keys: idx_user_id
          key: idx_user_id
      key_len: 8
          ref: const
         rows: 200
     filtered: 10.00
        Extra: Using where

逐列说一下,这几列是你日常最该看的:

  • type: ref 表示走的是非唯一索引的等值查找。这个字段从好到坏排是 system > const > eq_ref > ref > range > index > ALL,看到 ALL 基本就是全表扫。
  • key 是实际用的索引,possible_keys 是它考虑过的索引。这两个不一样的时候,说明它选了但不是唯一选择,值得追问一句为什么。
  • key_len: 8 表示用了索引的前 8 个字节,bigint 正好 8 字节。如果联合索引是 (a, b) 而你只看到 8,说明只用了 a。
  • rows: 200 是估算 的扫描行数,filtered: 10.00 表示估算这 200 行里只有 10% 满足剩余条件。两者相乘就是预估要回给 Server 层的行数。
  • Extra: Using where 说明 status = 1 这个条件没在索引里解决,是 Server 层逐行过滤的。

这个 Using where 很关键。它意味着 status = 1 这个条件,在二级索引上完全没用上,200 行全都回表了,回来才筛掉 180 行。

如果索引是 (user_id, status),那么 status 在索引里就能判断,Extra 会变成 Using index condition(索引下推),回表次数从 200 降到 20。这就是第三篇要展开的内容。

优化器为什么会算错? 三个常见原因:

第一,统计信息过期 。information_schema.tables 里的 data_length、index_length 和索引基数都是采样估的。刚批量导入几百万行,统计信息可能还是旧的,rows 估得离谱。

sql 复制代码
-- 手动更新统计信息
ANALYZE TABLE orders;

-- 看一看基数
SHOW INDEX FROM orders;

第二,多列之间强相关 。city = '杭州' 和 province = '浙江' 这两个条件,优化器会当成独立事件相乘,估出来的行数远小于实际。这时候 filtered 会明显偏低。

第三,代价模型对随机 IO 估得偏乐观。回表是随机读,模型里给它的权重往往低于真实情况,尤其是机械盘或者云盘的 IOPS 被限速时。

顺带说说:查询缓存为什么被删掉了

MySQL 5.7 之前有个 query cache,用 SQL 原文做 key、结果集做 value。命中就直接返回,连解析都跳过。

听起来很美,实际是个灾难。三个原因:

第一,失效太粗暴。 只要这张表有任何一条 DML,这张表的所有缓存全部失效。写入频繁的表,命中率接近 0。

第二,比对成本高。 每次查询都要拿 SQL 原文去逐个比对,全局一把锁。高并发下,这把锁本身就是瓶颈。

第三,命中率低但开销恒定。 相当于给所有查询加了一层税。

所以 MySQL 8.0 直接把它删了。这是个很好的反面教材:缓存只在"读多写少、内容稳定"的场景才有价值。业务层的缓存之所以有效,是因为你能精细控制失效粒度;数据库层的缓存做不到。

客户端和 MySQL 之间,还隔着几层

严格说,"一条 SQL 从输入到返回"的范围比 MySQL 进程本身更大。中间通常还有:

  • 连接池 (HikariCP、Druid):省掉建连接的开销,但池子太小会排队,太大又会把 MySQL 的 max_connections 打满。
  • 代理层(ProxySQL、MyCat):多一跳网络,但能做读写分离、限流、故障切换。
  • 读写分离 :主库写、从库读。代价是主从延迟------刚写完立刻读,可能读到旧数据。

这些不在 MySQL 内部,但它们决定了你看到的"慢"到底慢在哪一段。排查的时候,先确认是网络、是排队、还是数据库真的在算,比一上来就 EXPLAIN 有效。

五、第 7 站:执行器向引擎"要数据"

计划定了,执行器开始干活。它的逻辑简化下来大概是这样:

cpp 复制代码
// 伪代码,来自 sql/executor 的简化
ha_index_init(index_id);                        // 打开索引
while (ha_index_read_map(key) != HA_ERR_END_OF_RECORD) {
    if (engine_can_filter(row)) {               // 引擎层能过滤的
        if (server_filter(row)) {               // Server 层过滤
            send_to_client(row);                // 满足条件,发出去
        }
    }
}
ha_index_end();

这里有个很重要的分工,值得单独说:

引擎层能判断的条件 :索引列上的比较、order by 能不能用索引顺序、limit 能不能提前终止。

只能 Server 层判断的条件 :非索引列的过滤、函数运算、子查询、having、distinct。

limit 这件事经常被忽略。如果 order by 的列和索引顺序一致,引擎可以走索引顺序读,读到第 20 行就停;如果要对结果排序,就必须把所有匹配行都取出来排完再取前 20。这就是 Extra: Using filesort 出现时性能骤降的原因------它排序的是全部匹配行,不是 20 行。

六、第 8 站:真正慢的地方

InnoDB 拿到"给我下一行"的请求后,做的是:

  1. 在 Buffer Pool 里找这个页。命中就直接内存寻址,0.1 微秒。
  2. 没命中,从磁盘读 16KB 的页进内存,SSD 约 0.1~0.2 毫秒,机械盘 5~10 毫秒。
  3. 在页内用 Page Directory 做二分查找,定位到具体的行。

同一份数据,命中与不命中差的是十万倍 。所以 innodb_buffer_pool_size 这个参数,比大多数 SQL 写法优化都重要。

sql 复制代码
-- 看命中率,正常应该在 99% 以上
SHOW ENGINE INNODB STATUS\G
-- 关注 Buffer pool hit rate

一个常见的误区:以为"加了索引就快了"。索引只是把"要读的页"从 5 万个减少到 3 个,但这 3 个页如果每次都要从磁盘读,照样是毫秒级。索引和 Buffer Pool 是乘法关系,不是加法关系。

七、如果这是一条 UPDATE

上面讲的是 SELECT。UPDATE 要走的路更长,而且多了一个 SELECT 没有的问题:怎么保证 redo log 和 binlog 一致。

redo log 是 InnoDB 的,binlog 是 Server 层的,两个独立的东西要写成一个"原子操作",靠的是两阶段提交:

  1. InnoDB 写 undo、改 Buffer Pool 里的页(页变脏,但不急着落盘)
  2. redo log 写入磁盘,状态标记为 prepare
  3. Server 层写 binlog,fsync 落盘
  4. 通知 InnoDB,把 redo 状态改成 commit
  5. 返回客户端
  6. 后台线程慢慢把脏页刷回 .ibd

关键在于崩溃发生在这几步之间会怎样:

  • 在 prepare 之后、binlog 之前崩:重启恢复时 redo 里有 prepare 记录,但找不到对应的 binlog,判定事务未完成,回滚。主库没有这条数据,从库也没有(因为 binlog 没写),一致。
  • 在 binlog 之后、commit 之前崩:redo 里有 prepare,binlog 完整,判定事务已完成,补提交。从库能收到这条,也一致。

这个设计的精妙之处在于:用 binlog 是否完整,来反推事务该提交还是回滚。少了任何一方,都会出现主从不一致。

顺带说一个跟性能相关的点:第 4 步到第 5 步之所以能"不用等 redo 落盘就返回",是因为 redo 的 commit 标记可以延迟写。真正保证不丢的是第 2 步的 prepare 落盘。而 MySQL 5.7 之后有组提交(group commit),多个事务的 fsync 可以合并成一次,这也是高并发写入能撑住的重要原因。

八、把耗时摊开看

回到开头那个订单列表页的案例。

排查过程是这样的:

sql 复制代码
-- 先看执行计划
EXPLAIN SELECT id, order_no, amount, status, created_at
FROM orders WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC LIMIT 20;

-- 结果:key = idx_shop_status_time,rows = 600000,Extra = Using where; Using filesort

问题一目了然:rows 估了 60 万,还带了 filesort。索引是 (shop_id, status, created_at),shop_id 和 status 都是等值条件,created_at 是排序------理论上顺序完全匹配,不该有 filesort。

真正的原因是:status = 1 在这个店铺的 60 万订单里占了 58 万,优化器认为"走索引再回表"不如"全表扫",于是放弃了索引的排序能力。

最后的解决方式不是加索引,而是:

sql 复制代码
-- 用覆盖索引把回表彻底去掉
ALTER TABLE orders ADD INDEX idx_shop_status_time_cover
  (shop_id, status, created_at, order_no, amount);

-- 查询改成先取主键,再按主键批量取详情
SELECT id FROM orders
WHERE shop_id = 10241 AND status = 1
ORDER BY created_at DESC LIMIT 20;

响应时间从 4 秒回到 80 毫秒。

这个案例说明一件事:慢 SQL 的原因往往不在 SQL 里,而在"这一站到底发生了什么"的判断里。如果不清楚第 6 站会算账、第 8 站怕随机 IO、第 9 站会逐行过滤,你只能靠试。

九、能带走的三件事

第一,定位慢查询,按站排查。 先用 EXPLAIN 看第 6 站的决策(type、key、rows、Extra),再看第 8 站的命中率(Buffer Pool hit rate),最后才看 SQL 写法。顺序反了,容易做无用功。

第二,理解 Server 层和引擎层的边界。 知道哪些条件引擎能自己判断、哪些必须回 Server 层逐行做,你才能看懂 Using where、Using index、Using index condition 这三个 Extra 的真正含义。这三个词的区别,就是第三篇的全部内容。

第三,索引解决"读多少页",Buffer Pool 解决"页在哪"。 两件事都要管。只加索引不调缓冲池,在数据量超过内存时照样会慢;只调缓冲池不加索引,扫描量下不来,内存再多也会被冲穿。

下一篇我们把镜头推到最近,专门看 B+ 树:为什么是它,一页能装多少,三层到底能撑多少行------我会一行一行算给你看。

相关推荐
Oiiouui1 小时前
Godot-GridGame架构数据层篇
架构·godot·1024程序员节
java_nnnn1 小时前
Redis 核心特性与应用场景:从底层原理到分布式系统应用
数据库·redis·1024程序员节
宝绿特塑业再生科技1 小时前
板材挤出过程振动与尺寸波动:户外家具行业转向再生料后,工艺稳定性成为核心议题
科技·1024程序员节·户外家具·再生塑料·塑木桌椅·hdpe再生颗粒
comedate1 小时前
【SMLib】SMLib 五层架构深度解析:SMLib / SM_API / SM_API_USD / SmPyLib 各管什么
架构·cad·1024程序员节·openusd·smlib·nurbs
纪念 2291 小时前
C++ 算法(四)
1024程序员节
餐赢计餐饮策划设计1 小时前
如何判断国潮养生设计是否适合自己?
python·1024程序员节
lzx_0021 小时前
Linux 开发工具(六) 版本管理器git的简单使用
git·1024程序员节
讯捷蓝达1 小时前
华为 2288H V5 服务器不开机,显示 “888“ 是不是通病?
1024程序员节
Tsuki_tl1 小时前
【优选算法 — 双指针】经典例题题解+算法总结
算法·蓝桥杯·力扣·刷题·双指针·1024程序员节·优选算法