我们前面讲 redo log、binlog 的时候,反复提到 MySQL 分成 Server 层和存储引擎层,但一直没把这条链路本身展开。这篇我们换个角度看同一件事:一条 SQL 从客户端发出去,到结果返回,中间走了哪些环节。
我们先记住一个大框架:一条语句要穿过两层。
| 层 | 管什么 | 有哪些东西 |
|---|---|---|
| Server 层 | SQL 的语法语义、权限、决定怎么执行 | 连接器、分析器、优化器、执行器,还有 binlog |
| 存储引擎层 | 数据存在哪、怎么读写 | InnoDB(默认)、MyISAM、Memory,redo log 和 undo log 在这一层 |
真正执行一条语句,是这两层配合的结果。下面从客户端连上来开始走。
连接器
客户端连 MySQL,第一步是连接器,它管三件事:
text
1. TCP 握手,建立起连接
2. 认证身份:客户端发来用户名和一段密码摘要
3. 认证通过后,读取这个账号的权限
密码不是明文传的,客户端发的是密码的加密摘要,连接器拿去和存储的密码比对。权限在这里一次性读出来,之后这条连接上的操作都用这份权限判断------所以一个已经连上的会话,中途改了它的权限不会立刻生效,要重连。
认证通过后连接就保持住了,空闲时状态是 Sleep,用 show processlist 能看到。连接不是用完就断,默认会挂着(wait_timeout 默认 8 小时)。这里有两种用法:
- 短连接:每次操作新建连接,用完断开
- 长连接:连一次一直用
长连接省了建连开销,但有个坑:MySQL 执行过程中用的临时内存是挂在连接对象上的,要到连接断开才释放。一条长连接如果跑过几个大查询,内存会一直占着不还。8.0 的解法是执行
mysql_reset_connection把连接状态重置掉,或者定期主动断开重连。
查询缓存
8.0 之前的 MySQL 在连接器和分析器之间还有一层查询缓存:把 SQL 语句当 key,结果当 value 存起来,下次来一模一样的语句直接返回。
听起来很美,但它的失效粒度太粗------只要表上有一条记录被更新,这张表的所有查询缓存全部清空。写多读少的库里,缓存刚存进去就被清掉,维护它反而成了负担。所以 8.0 直接把查询缓存整个删掉了。知道这层曾经存在、以及为什么被删就够了,现在不用管它。
分析器
到了分析器,MySQL 才真正"读"这条 SQL,分两步:
- 词法分析 :把一串字符拆开,认出
select是查询关键字、T是表名、ID是列名 - 语法分析:按 MySQL 的语法规则,判断这句话是否合法
语法不对,报错就出在这一步,报错信息里会指出它在哪儿卡住:
text
You have an error in your SQL syntax; check the manual that corresponds to
your MySQL server version for the right syntax to use near 'where id = 1' at line 1
分析器的产物是"这句话说得通,而且说的是对表 T 按 ID 查",此时它还不知道该怎么执行。比如表上到底用哪个索引、多表 join 谁先谁后,是下一步的事。
优化器
同一条 SQL 往往有好几种执行方式,优化器负责挑一条代价最小的。它主要决定两件事:
- 单表查询:走哪个索引,还是干脆全表扫描
- 多表 join:以哪张表为驱动表,连接的顺序怎么定
我们拿一个最直观的例子看它的作用。假设要查两张表:
sql
select * from t1 join t2 on t1.a = t2.b where t1.c = 1;
可以先扫 t1 用 c=1 过滤,再拿结果去 t2 上匹配;也可以反过来先扫 t2。两条路的代价可能差很远------如果 t1 的 c=1 只剩几行,先扫 t1 就快得多。优化器就是算这个账的。
选完以后,它输出一份执行计划 ,用 EXPLAIN 可以看:
sql
explain select * from t1 join t2 on t1.a = t2.b where t1.c = 1;
计划里会写明用不用索引(key 列)、扫描方式(type 列)、谁做驱动表。优化器的判断不一定永远最优------它靠的是统计信息和代价估算,估错了就可能选一条差的。判断"索引为什么没走"这件事,看的就是 EXPLAIN 的结果,可以接着看 MySQL 索引失效的场景总结。
执行器
优化器给完计划,执行器开始照着执行。顺序是:
text
1. 先判断当前连接对要操作的表有没有权限,没有就报错返回
2. 有权限就打开表,调用存储引擎提供的接口,一行一行把数据取出来
3. 把满足条件的结果集返回给客户端
关键在于第 2 步"怎么取"。有没有索引,执行器调用引擎的方式是不一样的。
没有索引
以 select * from T where k = 5 为例,假设 k 上没有索引:
text
执行器调用引擎接口「取表的第一行」
引擎返回第一行 → 执行器判断 k 是不是 5
不是 → 跳过,再调「取下一行」
是 → 放进结果集,再调「取下一行」
重复到整张表扫完
每一行都要取出来、由执行器判断一遍,这就是全表扫描。判断"k 是不是 5"这个动作是执行器做的,引擎只负责把行给它。
有索引
如果 k 上建了索引,执行器换一个接口:
text
执行器调用引擎接口「取满足 k = 5 的第一行」
引擎用 k 索引直接定位到第一条 k = 5 的记录,返回
执行器调用「取满足 k = 5 的下一行」
引擎沿着索引往下找,返回第二条......
直到没有满足条件的记录
这次引擎返回的每一行都已经满足 k = 5,执行器不用再逐行判断,而且要读的行数也从"整张表"缩到了"命中的那几行"。这就是索引的价值所在,也是为什么"索引失效了"会那么让人头疼。
存储引擎层
执行器往下调的接口,进到的是存储引擎层。数据真正待的地方在这一层,以 InnoDB 为例:
- 数据在磁盘上按页 组织,一页 16KB。读一行,实际上是把它所在的整页读进内存(
buffer pool) - 页不在
buffer pool里,就从磁盘读,这是一次随机 IO,比顺序读慢一两个数量级 - 有索引时走 B+ 树定位到目标页,没索引时只能顺着页一页页扫
第 1 步里执行器拿到的行,也不是磁盘上的原始数据,而是 InnoDB 按 MVCC 规则判断出来、对当前事务可见的那个版本,可以回顾一下 MVCC 的 ReadView。
写入路径同样落在这层:修改要先写 undo log、再改内存里的页、同时写 redo log,提交时配合 Server 层的 binlog 做两阶段提交。这部分在MySQL 日志全景图和 Redo Log 和 Binlog 为什么需要两阶段提交?里展开,这里不重复。
再补充一个和"取行"相关的概念:回表 。普通索引的叶子节点只存了索引列和主键值,如果要查的列不在索引里,InnoDB 得先拿到主键,再回主键索引里查一次完整行。所以
select *常常比只查需要的列更慢------查的列多了,索引覆盖不住,每一行都要回表。这个也是索引失效那篇里反复出现的一个成本点。
为什么要分两层
讲到这里,我们可以回答一个"为什么不合成一层"的问题。
分成 Server 层和引擎层,最大的好处是存储引擎可以换 。Server 层把 SQL 的语法、语义、权限、执行计划都处理完,再通过一套统一的接口(handler API)去调引擎;InnoDB、MyISAM、Memory 只要实现这套接口,就能被 Server 层调用。所以改存储引擎是改配置的事,SQL 不用动。
另一层意义在日志的归属上:binlog 属于 Server 层,redo log / undo log 属于引擎层。两个日志在不同的层、由不同的代码写,没法用一次原子操作保证同时成功------这正是它们之间需要两阶段提交的根本原因。这也是分清这两层之后,能顺手解释掉的一个问题。