MySQL 单点详解(01):一条 SQL 的一生,从连接器到存储引擎过了几道手
写在前面
面试官问这题,通常有两句:
- 一条查询语句在 MySQL 里是怎么执行的?
- 那 8.0 还有查询缓存吗?
第二句是坑。很多人第一句答"先查缓存",正好撞上。
8.0 已经把查询缓存整个删了,5.7 里默认也是关的。
所以这条链路的第一步不是查缓存,是验身份。
整条链路只需要抓住一条分界线:
server 层管"这条 SQL 该怎么干",引擎层管"数据在哪、怎么搬"。
五道手里只有优化器在做选择,其余四道都在照做。慢查询、选错索引,追到根上几乎都出在优化器那一步。
一、五道手总览
| 环节 | 干什么 | 所在层 |
|---|---|---|
| 连接器 | 验身份、分配线程、管连接数 | server |
| 解析器 | 词法 + 语法分析,产出语法树 | server |
| 预处理器 | 查表、查列、展开 select * |
server |
| 优化器 | 选索引、定 join 顺序 | server |
| 执行器 | 按计划向引擎要行、逐行判断条件 | server |
| 存储引擎 | 读页、改页、写日志 | 引擎 |
前五站全在 server 层,碰磁盘是最后一站的事。
面试时先甩这张表,再挑重点展开,比一口气背流程更稳。
二、连接器:先验身份
客户端先和 MySQL 建 TCP 连接,连上之后第一件事不是执行 SQL,是验身份。
用户名密码对不上,后面的活一点都干不了。
验证通过,服务端分一个线程给这条连接,之后这条连接上的语句都归它管。
一个连接一个线程,所以连接数一多就顶不住,默认最多 151 个。
空闲连接也不会一直留着,默认八小时没动静就被断开。
这里有个常被说反的点:权限不是连接时一次性读进来的。
| 改权限的方式 | 是否立即生效 | 要不要 flush |
|---|---|---|
GRANT / REVOKE |
立即生效,含已连接会话 | 不需要 |
直接 UPDATE mysql.user |
不生效 | 需要 FLUSH PRIVILEGES |
权限是每条语句执行时现查的,所以用 GRANT 改完,已经连着的会话下一条语句就生效。
只有绕过 GRANT 直接改表,内存里的权限缓存不知道,才需要手动刷。
这两件事说混,很容易被判成背的。
三、解析器和预处理器:先看语法,再看表和列
解析器只干一件事:把 SQL 切成词,再按语法拼成一棵树。
它只认语法,不认表。
关键字写错才归它管,报错 1064,还会指出 near 哪个词。
表不存在不是它报的。
表在不在、列在不在,是紧接着的预处理器干的。
列名写错报 1054 Unknown column,就是这一站。
select * 也有代价,星号要先展开成具体列,表多的时候这一步不免费。
两站合起来只做一件事:把人话翻译成机器能读的树。
翻译完,语法对、语义也对,但还不知道怎么取数据。
四、优化器:全链路唯一做选择的地方
同一条 SQL,可以有很多种取法:走哪个索引、两张表先读谁、子查询要不要先算出来。
选哪种,是优化器的事,也是整条链路上唯一做选择的一站。
它按成本选,成本大致等于"要读多少页"加"要比多少行"。
走索引读得少,但可能要多回几次表;全表扫读得多,但一遍就过。
关键问题:这个成本是估的,不是算的。
估的依据是统计信息,而统计信息是抽样出来的。
一张几千万行的表,可能只看了二十来个页。
样本这么少,偏差自然大,这就是"明明有索引,它偏要全表扫"最常见的原因。
统计信息还会过期,数据翻了几倍,它记的还是上一版的样子。
所以线上突然变慢,先看执行计划变没变,再看统计信息多久没更新。
打个比方:优化器像导航。
路线按历史路况估出来,真上路堵不堵,它并不知道。
导航估错不代表没用,但得知道它会估错。
救急可以强制指定索引,不过那是止血,不是治病。
五、执行器:要一行,判一行
优化器给出一份计划,执行器照着做。
有索引,就顺着索引往下找;没索引,就一行一行扫。
每拿到一行,它自己判一次 where,符合就留下,不符合丢掉,接着要下一行。
注意方向:是执行器主动去要,不是引擎把结果推上来。
于是慢日志里那两个数字有了明确含义:
| 指标 | 含义 | 异常时的判断 |
|---|---|---|
| 扫描行数 | 执行器一共向引擎要了多少行 | 远大于返回行数,说明索引没帮上忙 |
| 返回行数 | 最终发给客户端的行数 | 与扫描行数接近,说明过滤靠的是索引 |
扫描一千行、只返回十行,说明索引基本白建。
还有一点:结果不是攒齐了才发,是边取边发。
所以 limit 10 的时候,引擎可能只读了几十行就停了。
反过来,客户端收得慢,服务端就卡在发送上。
这种"慢"不是查得慢,是等得慢。
顺带一句:select * 除了多传数据,还容易让覆盖索引失效。
六、存储引擎:真正碰磁盘的地方
到这一站才碰磁盘。
InnoDB 不是按行读,是按页读,一页 16KB。
要一行,先看这行所在的页在不在内存里。
在,直接取;不在,从磁盘把这一页读进内存,再取。
所以查一行的真实开销,往往不是"读一行",是"读一页"。
同一条数据,第一次查要跑一趟磁盘,第二次就快了,差别全在缓存。
InnoDB 还有一点要记:数据本身就挂在主键索引的叶子节点上。
按主键查,走到叶子就拿到整行,一次到位。
按普通索引查,叶子节点上只有主键值。
要拿主键再回主键索引找一遍,这就是回表。
所以"尽量用主键查""尽量走覆盖索引",根子都在这里。
七、update 多出来的两步
select 到这儿结束,update 还没完。
它先按同样方式找到那一行,然后改内存里的页,不直接改磁盘。
改之前先把旧值记到 undo log,回滚要用。
改完这页成了脏页,同时这次修改记进 redo log。
三种日志各管一摊:
| 日志 | 记什么 | 谁写的 | 用来干什么 |
|---|---|---|---|
| undo log | 修改前的旧值 | InnoDB | 回滚、MVCC |
| redo log | 页改成了什么样 | InnoDB | 崩溃恢复 |
| binlog | 执行了什么语句 | server 层 | 主从同步、备份 |
到这儿内存里的事做完了,但事务还没提交。
提交时要保证 redo 和 binlog 对得上,靠的是两阶段提交:先写 redo 并标成准备,再写 binlog,最后把 redo 标成完成。
顺序不能反:
| 写入顺序 | 中途断电的后果 |
|---|---|
| 先 binlog 后 redo | 从库按 binlog 重放拿到了修改,主库却回滚了,主从不一致 |
| 先 redo(准备)后 binlog | 主库恢复时去 binlog 核对:有就补提交,没有就回滚 |
所以"提交成功"和"数据已经落盘"是两件事,靠两个刷盘参数把它们绑在一起。
八、面试官会往下问的三层
第一层:优化器凭什么觉得走索引更便宜?
按成本算,比的是读多少页、比多少行。
它估错,是因为统计信息是抽样的,样本少,还会过期。
第二层:扫描行数比返回行数大很多,说明什么?
索引没帮上忙。
常见四种:条件上套了函数、类型对不上发生隐式转换、用了 like '%x'、联合索引没用上第一列。
第三层:update 什么时候返回给客户端?
两阶段提交走完、redo 标成完成之后才返回。
返回成功就代表这次修改已经能恢复,崩了也不会丢。
还可能追问一句:binlog 能不能不要?
不能。从库靠它重放,备份、闪回也都靠它,redo 出了这台机器就没用了。
收尾
五道手里只有优化器在猜,其余都在照做。它猜错,多半不是不会算,是看到的信息不准。
下一期讲它猜得最频繁的那件事:为什么用 B+ 树,不用 B 树、哈希、跳表。