每天用导航出门时,你输入目的地,导航会给出几条路线:走高速快但绕远,走小路近但堵车,走环线平稳但多等几个红绿灯。导航最终推荐的那条,是它结合实时路况、历史数据和红绿灯时长,算出的“预计时间最短”路线。数据库里的 SQL 优化器,干的就是完全一样的事——只不过它导航的是一座由数据表组成的城市,目标是把你的一条 SQL 查询,翻译成一条执行起来最省时省力的“数据搬运路线”,也就是执行计划。 这个“智能导航”厉害在哪?你写的 SQL 只是说“我要什么结果”,至于先查哪张表、走索引还是全表扫、两张大表怎么连,全由优化器自己拍板。它拍板的依据,是一堆“路况数据”——统计信息、直方图、代价模型。懂它,你写的 SQL 才真正算“被它温柔以待”;不懂它,你写完就祈祷,祈祷它别选那条看上去绕死人的烂路。

岗位关联

对 AI 从业者而言,理解优化器意味着你能解释“为什么同一条 SQL 换个环境就慢 100 倍”,能带着 trace 去定位问题、用 hint 精准止血,这是数据工程和算法落地中极稀缺的调优能力。

目录
  1. 可视化演示
  2. 执行计划
  3. 代价模型
  4. 统计信息
  5. 连接顺序
  6. Hint调优
  7. 直方图统计
  8. 优化器开关
  9. Trace排查
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示全表扫描/索引扫描/Hint强制三种执行计划代价对比、连接顺序与驱动表选择,以及直方图统计对基数估算的影响。

本节配有交互动效(进入视口后自动加载,约 3KB)。

执行计划代价对比 全表扫描代价850 vs 索引扫描代价120 优化器选择代价最低的索引扫描计划 Cost = IO_cost + CPU_cost 基于代价优化器从多个计划中选择成本最低的执行方案

执行计划

你在导航里输入“公司”,导航先做的不是踩油门,而是规划:出小区左转、上高架、第三个出口下、右转进园区。这一串“先走哪、再走哪”的路线描述,就是“执行计划”。数据库收到一条 SQL 时也一样,它会先给数据画出搬运路线,再真正开始读表。这条路线的形状,直接决定了这条 SQL 是 10 毫秒还是 10 秒。

从 SQL 变成执行计划,要经历四步:语法分析先检查句子有没有拼写错误,语义分析确认表和列真的存在,逻辑优化把 SQL 改写成更聪明的等价形式(比如把子查询提前去重),物理优化才真正决定扫描方式、连接顺序。物理优化这一步的产出,就是我们眼里看到的执行计划。在 MySQL 里用 EXPLAIN、在 PostgreSQL 里用 EXPLAIN ANALYZE,就能把这条“导航路线图”打印出来。

执行计划里最基础的分叉是“怎么读一张表”。全表扫描就像一栋没有住户名单的大楼,快递员只能一层层敲门确认谁在家,读多少块就付出多少 IO;索引扫描则像楼下的物业名单,快递员先查名单锁定目标楼层,再去指定房间敲门。一般情况下,过滤比例越小,索引越划算;过滤比例接近 100% 时,全表扫描反而更省事。

执行计划虽是“纸上谈兵”,但它决定了真刀真枪时的路径。就像导航画出的路线图,路没走,胜负已分。

Restimated=Rrows×selectivityR_{estimated} = R_{rows} \times \mathrm{selectivity}
优化器估算“这条过滤条件大概会命中多少行”,用总行数乘以一个过滤选择性得到。
  1. $R_{rows}$ 总行数统计信息里记录的表总行数,是估算的地基。
    Rrows=500000R_{rows} = 500000
  2. $\mathrm{selectivity}$ 过滤选择性WHERE 条件筛掉多少数据,取值在 0 到 1 之间,1 表示全命中。
    selectivity=1NDV=0.01\mathrm{selectivity} = \frac{1}{NDV} = 0.01
  3. $R_{estimated}$ 估算命中行数两者相乘,得到这条过滤条件下预计要访问的行数,它是后续代价计算的第一块砖。
    Restimated=500000×0.01=5000R_{estimated} = 500000 \times 0.01 = 5000

代价模型

导航 APP 给你报“预计 25 分钟”,这 25 分钟不是简单量距离,而是红绿灯数量、限速、拥堵系数、收费站的综合加权。数据库优化器同样会给每条候选路线打一个“预计用时”分——这个分数叫代价。代价越低,计划越优。

代价从哪来?主要三块:磁盘 IO 代价(读多少数据块)、CPU 代价(处理多少行)、内存/网络代价(排序与传输)。数据库为每类操作预设权重因子:在绝大多数数据库里,一次磁盘 IO 的权重远高于处理一行的 CPU 成本。优化器把所有候选计划的代价都算出来,然后挑数值最小的那个,这就是大名鼎鼎的“基于代价的优化”(CBO)。

这个模型还解释了一个反直觉现象:SQL 写得很“短”不代表执行快。优化器不看你的句子长短,只看代价数字。一条写得很长的 SQL,如果统计信息准确、命中索引,可能比一条“看上去精炼”但触发全表扫描的 SQL 快几十倍。就像绕远的高速可能比穿城的小路更快,导航关心的是时间,不是里程。

优化器就像一个精算师,把每条路的所有成本项摊开、加权、求和,然后淡定地选数字最小的那条。

C=wio×IO_cost+wcpu×CPU_costC = w_{io} \times \mathrm{IO\_cost} + w_{cpu} \times \mathrm{CPU\_cost}
总代价是 IO 代价与 CPU 代价的加权和,权重取决于数据库对硬件瓶颈的敏感度。
  1. $\mathrm{IO\_cost}$ 磁盘 IO 代价按需读取的数据块数量,块越多代价越高。
    IO_cost=数据块数×每块读取代价\mathrm{IO\_cost} = \text{数据块数} \times \text{每块读取代价}
  2. $\mathrm{CPU\_cost}$ 处理代价对读出来的行做过滤、比较、连接运算的开销。
    CPU_cost=处理行数×每行运算代价\mathrm{CPU\_cost} = \text{处理行数} \times \text{每行运算代价}
  3. 权重因子 $w_{io}$ 与 $w_{cpu}$数据库根据硬件特征设定的调节系数,通常 IO 权重远大于 CPU,体现“读盘是最贵的动作”。
    wio≫wcpuw_{io} \gg w_{cpu}

统计信息

导航的预估时间准不准,取决于路况数据新不新——三个月前的地图,永远不知道你家门口新修的单行道。优化器做代价估算所依赖的“地图数据”,就是统计信息。数据库定期用 ANALYZE 命令给表做“体检”,记录下三件大事:表里有多少行(行数统计)、每列有多少个不同值(列基数 NDV)、数据块分布情况。

列基数是估算的神器。假设 orders 表有 50 万行,status 列有 3 种取值,那么平均每个值大概占 16.7 万行。索引回表成本、连接顺序选择,全都建立在这些估算之上。为了保持新鲜,需要定期收集:MySQL 的 ANALYZE TABLE、PostgreSQL 的 ANALYZE、Oracle 的 DBMS_STATS 都是干这活的。

统计信息一旦过期,就是“拿着三个月前的地图跑今天的路”。最常见的情景:表从 10 万行暴涨到 1000 万行,统计信息没刷新,优化器还按 10 万行估算,认为全表扫描很便宜,结果一条点查被扫了千万行,慢得离谱。这也是 SQL 调优里最常被忽略的第一嫌疑犯。

路况数据越新鲜,导航越值得信赖;统计信息越准确,优化器的每次判断才站得住脚。

r^=Rrows×1NDV\hat{r} = R_{rows} \times \frac{1}{NDV}
等值条件 WHERE col = 'x' 的估算命中行数:假设每种取值平均出现,用总行数除以不同值个数。
  1. NDV 列不同值个数这一列究竟有多少种取值,统计信息的核心指标。
    NDV=3NDV = 3
  2. $\frac{1}{NDV}$ 平均选择率没有任何额外证据时,优化器假设每个值出现的概率相等。
    1NDV=13≈0.33\frac{1}{NDV} = \frac{1}{3} \approx 0.33
  3. $\hat{r}$ 估算行数总行数与选择率相乘,得到这个等值条件预计命中的行数。
    r^=500000×0.33≈166667\hat{r} = 500000 \times 0.33 \approx 166667

连接顺序

两栋楼都要送货,先到 A 栋还是先到 B 栋?这个先后顺序,在 SQL 里就是连接顺序。被导航先带到的那张表,叫驱动表(外层表)。顺序不同,搬运的数据量天差地别:先开车去一栋只有 10 户的小楼,再逐户去另一栋 1000 户的大楼匹配,和反过来做是完全不同的工作量。

最常见的连接方式是嵌套循环(Nested Loop)。它像快递员拿着 A 栋每个住户的名单,跑去 B 栋逐户核对:外层每一行,都要到内层找匹配。所以它的代价大约是“外层扫描一次 + 外层返回行数 × 每行去内层查找的代价”。内层越小,这个乘法越划算。另一种是哈希连接(Hash Join),先给一张表的连接列建一本地毯式的“索引簿”(哈希表),另一张表的每一行只需翻簿子匹配,适合两张大表等值连接。

那么谁来当驱动表?不是 SQL 里写在 FROM 后面的第一张表,而是优化器看统计信息后钦定的“过滤后行数最少”的表。小表驱动大表能让内层索引被反复命中缓存,减少随机 IO。别自己脑补顺序,交给优化器——除非你亲眼看到它选错了。

导航先到哪个地标,从来不看你先提哪个地名,只看怎么走总时间最短。连接顺序是优化器最得意的算术题之一。

CNLJ=Couter+Nouter×Cinner_per_rowC_{NLJ} = C_{outer} + N_{outer} \times C_{inner\_per\_row}
嵌套循环连接总代价:外层扫描一次的花费,加上外层返回行数乘以每行在内层匹配的代价。
  1. $C_{outer}$ 外层扫描代价读驱动表并且做完过滤的总代价。
    Couter=100C_{outer} = 100
  2. $N_{outer}$ 外层返回行数外层表经过 WHERE 条件后真正留下的行数,它放大内层开销。
    Nouter=1000N_{outer} = 1000
  3. $C_{inner\_per\_row}$ 内层单次匹配代价每一行去内层表查一次匹配的成本,走索引会显著变小。
    Cinner_per_row=5C_{inner\_per\_row} = 5
  4. 总代价合成外层扫一遍,内层被访问 N 次,总代价随之放大。
    CNLJ=100+1000×5=5100C_{NLJ} = 100 + 1000 \times 5 = 5100

Hint调优

导航推荐走高架,但老司机一拍大腿:“走滨江路,这个点我知道不堵!”SQL 里的 Hint(提示)就是这条“老司机指令”,它写在注释里,强制优化器按指定的路线走。调用格式像是给 SQL 贴了张便条:/*+ INDEX(orders idx_status) */ 表示强制走某个索引,/*+ LEADING(a b) */ 表示强制 a 先作为驱动表。

Hint 适合什么时候用?统计信息怎么刷新都不见效、优化器在某个版本里反复抽风、线上正在火急火燎等不到下一次 ANALYZE 的时候。它是“急救止血带”,不是日常保健品。大部分数据库(Oracle / MySQL 8.0 的部分写法 / PostgreSQL 的 pg_hint_plan 插件)都给了你这条“手动接管方向盘”的通路。

但强制路线有个大坑:数据是会成长的。今天这条滨江路畅通是因为车少,半年后车流翻倍,固定走它就会堵死在桥上。Hint 把计划固定死,等于在数据变化面前失去了自适应能力。而且 Hint 越多,系统越难维护——每个 DBA 都要猜你当年为什么这么写。所以业界共识是:能用统计信息解决的,别上 Hint。

老司机也有失手的时候:强制走滨江路一旦遇上临时管制,连换路的余地都没有。Hint 可以是杀手锏,但别让它成为默认姿势。

P∗=arg⁡min⁡P∈ΠhintCost(P)P^* = \arg\min_{P \in \Pi_{\text{hint}}} \mathrm{Cost}(P)
加上 Hint 后,优化器只能在“满足提示要求的计划集合”里挑最小代价计划,自由空间被收窄了。
  1. $\Pi_{\text{hint}}$ 受约束的计划集合被 Hint 过滤后的合法计划子集,只有走指定索引/顺序的计划才在里面。
    Πhint⊆Π\Pi_{\text{hint}} \subseteq \Pi
  2. $\arg\min$ 最小化运算在给定集合里找出让代价函数取值最小的那个计划。
    arg⁡min⁡P∈ΠhintCost(P)\arg\min_{P \in \Pi_{\text{hint}}} \mathrm{Cost}(P)
  3. $P^*$ 最终选定计划最优计划的记号,它是优化器在约束下能交出的最好答卷。
    P∗=满足 Hint 且代价最小者P^* = \text{满足 Hint 且代价最小者}

直方图统计

路口流量统计说“平均每小时 100 辆车”,可实际上早高峰 800 辆、深夜 0 辆。平均数骗了所有人。列的数据分布也一样,如果没有直方图,优化器只知道“有 3 种取值”,它会天真地认为每种各占三分之一。可现实中 99% 的行都是“已完成”状态,只有 1% 是“待支付”。于是执行 WHERE status = '待支付' 时,优化器按 16.7 万行估算,实际只有 5000 行——基数估算翻了几十倍,计划自然选歪。

直方图就是数据库给数据分布画的“流量热力图”。它把列的值域切成一个个桶(bucket),记录每个桶里有多少行。两种主流画法:频率直方图,每个频繁值独占一个桶,适合不同值个数(NDV)较小的列;高度均衡直方图,让每个桶装下大致相同的行数,适合 NDV 很大、数据分散的列。

有了直方图,优化器就不再迷信“平均值”了。它会看目标值落在哪几个桶里:落在高桶,说明这个值很常见,估算大;落在矮桶,说明罕见,估算小。这就是为什么大厂调优规范里总会有一条:对大表里倾斜严重的查询列,强制收集直方图。

流量热力图让导航知道哪条路高峰会堵,直方图让优化器知道哪些值是数据里的“早晚高峰”。

selHBH=kB\mathrm{sel}_{HBH} = \frac{k}{B}
高度均衡直方图的选择率:目标值跨越的桶数除以总桶数,用来估算该值的行数占比。
  1. $B$ 总桶数直方图把值域切分成多少个桶,桶越多粒度越细。
    B=100B = 100
  2. $k$ 跨越桶数目标值在直方图里覆盖的桶的数量,覆盖越多说明该值出现越频繁。
    k=1k = 1
  3. $\mathrm{sel}_{HBH}$ 修正后的选择率桶数占比代替了"1/NDV"的平均分配,从而修正倾斜数据下的估算偏差。
    selHBH=1100=0.01\mathrm{sel}_{HBH} = \frac{1}{100} = 0.01

优化器开关

导航 APP 的偏好设置里,你可以勾选“不走高速”“避免拥堵”“优先快速路”。数据库优化器同样有一排“偏好开关”,它们是影响选路策略的系统参数。最常见的两个方向:ALL_ROWS 与 FIRST_ROWS。

ALL_ROWS 模式追求的是“跑完全部结果总的代价最小”,适合报表、批处理;FIRST_ROWS 模式追求“先返回前几行要快”,适合网页分页——用户等不了整张报表,但能接受开头几行秒出。另外还有 optimizer_dynamic_sampling 动态采样:当表没有统计信息或信息太旧时,数据库现场抽几页数据“临时测路况”,用样本估算基数。自适应计划则允许执行到一半发现路况不对,临时换个连接方式,像一个会根据实时车流变道的导航。

开关不是越大越好。动态采样虽然有奇效,但它本身也要花时间;采样页数开得太大,每次查询都先付一笔“测路费”,得不偿失。调这些开关的正确姿势是:先搞清楚业务要的是“首屏快”还是“总量稳”,再去翻参数文档,最后用 A/B 对比验证,而不是听别人说哪个参数好就无脑开。

导航偏好设错,明明想躲拥堵,却总被拉上高架。优化器开关没有银弹,只有跟业务目标对齐,才算真正“对味”。

r^dyn=rsample×PtotalPsampled\hat{r}_{dyn} = r_{sample} \times \frac{P_{total}}{P_{sampled}}
动态采样估算:从采样结果的行数,按“采的页占总数页的比例”反推全表行数。
  1. $r_{sample}$ 采样命中行数随机抽的少数数据页里,满足条件的行数。
    rsample=120r_{sample} = 120
  2. $\frac{P_{total}}{P_{sampled}}$ 页数放大比例全表总数据页数除以采样页数,是把局部结果外推到全局的倍数。
    PtotalPsampled=100010=100\frac{P_{total}}{P_{sampled}} = \frac{1000}{10} = 100
  3. $\hat{r}_{dyn}$ 动态采样估算行数采样结果乘以放大比例,得到对全表命中行数的估计。
    r^dyn=120×100=12000\hat{r}_{dyn} = 120 \times 100 = 12000

Trace排查

老司机走错路后最值钱的动作是复盘:打开导航记录,看它当时说滨江路 20 分钟,为什么实际开了 45?数据库的 Trace 就是导航的“行程回放”,它把优化器选路时看过的每一个数字、每一次比较、每一个候选计划的代价都记在日志里。

不同数据库叫法不同:MySQL 有 optimizer_trace 专用跟踪,PostgreSQL 用 EXPLAIN (ANALYZE, BUFFERS) 看真实执行细节,Oracle 有 10053 事件和 DBMS_XPLAN。读 trace 时要抓三件事:第一,统计信息对不对(比如行数是不是差了两个数量级);第二,基数估了多少(是不是因为没直方图把倾斜数据当均匀分布);第三,哪一项权重把计划带偏了(IO 代价还是 CPU 代价)。

调优是一条闭环:发现慢 SQL → 开 trace 看代价明细 → 定位基数偏差 → 刷新统计信息 / 加直方图 / 必要时上 Hint → 复测并对比替代计划的代价差。每一次复盘都在给下一次“选路”积累经验。这也是 AI 时代做数据库自治优化的基础——机器读 trace、找规律、自动调参。

导航回放是司机的复盘神器,Trace 是 DBA 的破案现场。把每次“选错路”的原因记进日志、汇成规律,数据库调优从此不再靠玄学。

ΔC=CP2−CP1\Delta C = C_{P_2} - C_{P_1}
两个计划的代价差:若差值为正,说明 P1 更便宜;差值越大,换路带来的收益越明显。
  1. $C_{P_1}$ 当前计划代价优化器现在选的计划的估算代价。
    CP1=1200C_{P_1} = 1200
  2. $C_{P_2}$ 替代计划代价另一个连接顺序或扫描方式下的估算代价。
    CP2=300C_{P_2} = 300
  3. $\Delta C$ 代价差二者相减,判断是否值得换路,差越大,越值得改。
    ΔC=300−1200=−900<0⇒应选 P2\Delta C = 300 - 1200 = -900 < 0 \Rightarrow \text{应选 } P_2

AI 应用场景

慢 SQL 智能诊断平台

阶段:异常检测与根因分析

解决的问题:传统慢 SQL 排查靠资深 DBA 手工看执行计划、猜统计信息是否过期,平均一次定位要半小时以上。AI 平台自动收集所有慢查询的 trace 与统计信息快照,用规则模型加机器学习识别“基数偏差过大”“驱动表选反”等典型病灶,把定位时间压缩到秒级。

具体案例:某电商大促期间,SELECT ... WHERE order_status='待支付' 从 50ms 飙升到 8 秒,AI 诊断平台对比直方图后,发现该列数据从 1% 倾斜恶化到 0.1%,统计信息已过期一周,自动给出“立即刷新 + 构建高度均衡直方图”的建议,执行后查询回落到 80ms。

数据库自治调优助手

阶段:自动化决策与闭环优化

解决的问题:传统 DBA 调参靠经验试错,参数组合爆炸式增长。AI 助手把优化器开关、统计信息收集策略、动态采样阈值建模为多臂老虎机问题,根据线上查询延迟反馈自动调节开关组合,形成“感知—决策—验证—复盘”的闭环。

具体案例:一家金融公司把 2000 条批处理 SQL 接入自治调优助手,系统通过 Trace 数据分析发现 40% 的慢查询卡在 FIRST_ROWS 模式下,自动将批处理会话切换为 ALL_ROWS,并将 15 个关键连接列的采样频率提高,整体批处理时长下降 57%。

云数据仓库查询改写引擎

阶段:机器学习推理与成本预测

解决的问题:云厂商的数据仓库每天接收海量分析 SQL,人工优化不现实。利用学习型优化器,把“统计信息 + 历史执行代价”喂给模型,预测哪些计划会超时,自动改写连接顺序或注入 hint,让查询更聪明地利用资源。

具体案例:某数据分析平台使用学习型优化器后,对 1 万条历史慢查询建模,模型识别出“大表等值连接未走哈希连接”的常见病,自动重写 SQL 并注入 HASH_JOIN 提示,P95 查询延迟从 12 秒降至 3.5 秒。

Python 演示

python59 行
# -*- coding: utf-8 -*-"""MiniCBO: 用统计信息估算代价并挑选连接计划"""import math  class TableStat:    def __init__(self, name, rows, blocks, ndv):        self.name = name        self.rows = rows        self.blocks = blocks        self.ndv = ndv  def cost_full_scan(t):    """全表扫描代价: 读所有块 + 逐行过滤的CPU开销"""    return t.blocks * 1.2 + t.rows * 0.05  def cost_index_filter(t):    """索引过滤代价: 树深常数 + 回表行数 + 日志因子"""    return 3.0 + (t.rows / t.ndv) * 0.3 + math.log(t.rows + 1)  def cost_nlj(outer, inner):    """嵌套循环连接代价 = 外层扫描 + 外层行数 x 内层单次代价"""    return cost_full_scan(outer) + outer.rows * cost_index_filter(inner)  def cost_hash_join(outer, inner):    """哈希连接代价: 构建哈希表 + 两表各扫一遍"""    return outer.blocks + inner.blocks + outer.rows * 0.08 + inner.rows * 0.08  def pick_plan(t1, t2):    """枚举两种连接顺序和两种连接方式, 返回代价最小计划"""    best = None    for outer, inner in ((t1, t2), (t2, t1)):        candidates = [            ("NESTED LOOP 驱动=" + outer.name, cost_nlj(outer, inner)),            ("HASH JOIN 驱动=" + outer.name, cost_hash_join(outer, inner)),        ]        for pname, pcost in candidates:            if best is None or pcost < best[1]:                best = (pname, pcost)    return best  def main():    users = TableStat("users", 10000, 400, 2000)    orders = TableStat("orders", 500000, 15000, 30000)    name, cost = pick_plan(users, orders)    print("最优计划:", name)    print("估算代价:", round(cost, 2))    print("orders 索引过滤代价:", round(cost_index_filter(orders), 2))    print("orders 全表扫描代价:", round(cost_full_scan(orders), 2))  if __name__ == "__main__":    main()
目的:用 Python 模拟一个极简的基于代价优化器,给定两张表的行数、数据块数和列基数,自动枚举连接顺序与连接方式,输出代价最低的执行计划。
  1. class TableStat:定义一张表的统计信息:行数、数据块数、列基数 NDV,相当于数据库表上的“体检报告”。
  2. def cost_full_scan(t):全表扫描代价:读所有块乘以 IO 系数,加上逐行过滤的 CPU 开销。
  3. def cost_index_filter(t):索引过滤代价:把“回表行数 = 总行数 / NDV”带入公式,体现选择性越好越便宜。
  4. def cost_nlj(outer, inner):嵌套循环代价:外层扫一次,但外层行数会“放大”内层的单次查找成本。
  5. def pick_plan(t1, t2):枚举两表两种连接顺序、两种连接方式共 4 个候选,挑最小代价,模拟优化器的核心过程。
  6. if __name__ == "__main__":程序入口,用小表 users 和大表 orders 跑一遍,输出最优计划。

常见误区

误区“我只要把所有常用查询列都加上索引,数据库读数据就永远快。索引反正不碍事,多加几个保险。”

正解索引像导航里的“快捷方式图层”,每多一个不是免费的——写入时每个索引都要同步更新,占用磁盘和内存,还会让优化器在选路时徒增无数候选计划。更关键的是,如果列的选择性很差(比如状态列 99% 都是同一个值),走索引反而要回表几万次,不如全表扫描快。索引是手术刀,不是防弹衣。

为何常见:初学者看到“索引加速查询”的结论后,容易线性外推成“越多越好”,忽略了写入成本和选择性这两个关键变量。

误区“同样是查订单,别人写 3 行,我写 8 行,我的肯定更慢。把子查询删掉、把 JOIN 改紧凑点,速度应该能翻倍。”

正解执行速度由优化器选出的执行计划决定,而计划由统计信息、代价模型、连接顺序决定,跟 SQL 文本的篇幅没有直接关系。一个用词简洁但触发全表扫描的 SQL,通常打不过“啰嗦但命中索引”的写法。真正影响性能的是数据分布、索引设计、统计信息新鲜度。

为何常见:人用“最短路径”的直觉理解查询,把代码的文本长度和执行消耗画了等号,这是把“可读性”和“执行代价”两个维度搞混了。

误区“这张表虽然只有 200 行,但既然建了索引,就该强制让它用索引,否则索引不就白建了吗?”

正解小表走全表扫描往往更快,因为全表扫描是顺序读,一次预读就能带走几百行;而走索引要先查 B+ 树再回表,多了两三次随机 IO,反而更慢。优化器不选索引不是“傻”,是代价模型算得明明白白。强行用 Hint 逼它走索引,常常把 0.1ms 的查询拖成 1ms。

为何常见:人们把“索引”神化成万能加速器,忽略了顺序读和随机读的硬件差异,也低估了优化器对微小代价差的敏感度。

误区“上周刚跑过 ANALYZE,统计信息肯定准得很,查询还慢肯定是 SQL 写得有问题,跟数据没关系。”

正解统计信息是某一时刻的快照。表数据如果持续增删改,尤其是大表出现戏剧性增长或倾斜加剧,这个快照会迅速过期。过期统计信息让优化器用错误的行数估算代价,选出的计划常常荒谬到令人咋舌。每次排查慢 SQL,第一步都该先确认统计信息的新鲜度,而不是急着改 SQL。

为何常见:收集统计信息给人“一劳永逸”的安全感,忽略了数据库是动态系统;加上调优时人们更爱改可见的 SQL,而不是去检查隐藏的元数据。

误区“我在 SQL 里把用户表写在最前面,那驱动表一定是它。我只要调整 FROM 的顺序,就能控制连接顺序。”

正解除非你使用 Hint 强制,否则驱动表由优化器根据统计信息和代价模型决定,跟 SQL 书写的表顺序没有必然关系。你就算把大表写在最前面,优化器发现小表过滤后行数更少,照样让小表做驱动。想确认真实的驱动表,用 EXPLAIN 看执行计划,而不是盯着 SQL 原文猜。

为何常见:人们习惯用“先出现的先执行”的直觉理解声明式语言 SQL,而 SQL 恰恰是“声明要什么、不声明怎么做”,执行细节全在优化器手里。

面试题

Q1. 优化器选择执行计划的首要依据是什么?

单选题 · easy

  • A.计划书写的篇幅最短
  • B.估算出的代价最低← 正确答案
  • C.FROM 表出现顺序
  • D.索引数量的总和

答案:B。优化器的核心目标是最小化代价模型算出的总代价,而不是计划文本的长短、表出现的顺序或索引数量。就像导航选预计时间最短的路线,而不是距离最短或路名最好听的路线。

Q2. 一张表只有几百行,查询没有过滤条件,通常最优的执行方式是?

单选题 · easy

  • A.全表扫描省去索引开销← 正确答案
  • B.索引扫描一定最快
  • C.只看行数做决策
  • D.关闭优化器防误判

答案:A。小表全表扫描是顺序读,代价极低;走索引反而增加随机 IO。优化器算过账后通常选全表扫描,强行用 Hint 走索引只会更慢。这体现了“代价模型说了算”而非“索引等于快”。

Q3. 嵌套循环连接中,驱动表的选择原则是?

单选题 · mid

  • A.过滤后行数少者驱动← 正确答案
  • B.过滤后行数多者驱动
  • C.总行数最多者驱动
  • D.由开发者随意指定

答案:A。驱动表的外层行数会放大内层的查找代价(C = 外层 + N_outer × 内层单次代价),所以应该让过滤后返回行数最少的表做驱动。总行数多但过滤后剩很少的表,反而是优质驱动表。

Q4. 以下哪项属于数据库优化器依赖的统计信息?

单选题 · mid

  • A.表行数与列基数← 正确答案
  • B.表上的注释文本
  • C.应用内存大小
  • D.浏览器类型分布

答案:A。统计信息记录的是表行数、数据块数、列 NDV 等数据分布特征。注释文本、应用内存、浏览器信息都不在数据库的视野内。行数和 NDV 是基数估算的两大支柱。

Q5. 某列数据严重倾斜但未收集直方图,等值条件估算行数时优化器默认怎么做?

单选题 · mid

  • A.按 1/NDV 平均分摊← 正确答案
  • B.按最大值整列命中
  • C.全部行都被命中
  • D.固定只返回一行

答案:A。没有直方图时,优化器只知道 NDV,默认每个取值平均出现,用总行数除以 NDV。这在倾斜数据下会严重失真,直方图就是用来修正这种“平均主义幻觉”的。

Q6. 以下哪些手段能够影响优化器的决策?

多选题 · mid

  • A.刷新统计信息← 正确答案
  • B.添加索引提示← 正确答案
  • C.调整优化模式← 正确答案
  • D.插入空行缩进

答案:ABC。统计信息改变基数估算,Hint 强制缩小计划集合,优化器开关改变代价偏好,三者都能实质改变选路结果。而 SQL 里的空行、缩进、多余空格只影响可读性,不影响执行计划。

Q7. 嵌套循环连接中,外层返回 1000 行,内层每次匹配代价 5,外层扫描代价 100,则总代价最接近?

单选题 · hard

  • A.5100← 正确答案
  • B.10100
  • C.1500
  • D.6100

答案:A。代入公式 C = C_outer + N_outer × C_inner = 100 + 1000 × 5 = 5100。要让总代价下降,最有效的方法是减少外层返回行数,或降低内层单次匹配代价(比如加索引)。

Q8. 关于 Hint 的说法正确的是?

多选题 · hard

  • A.数据变化后固定计划可能变差← 正确答案
  • B.Hint 可在各数据库通用
  • C.它是紧急情况的后手← 正确答案
  • D.Hint 让统计信息失效

答案:AC。Hint 把计划固定后,数据增长会让原本的最优变成灾难,所以它适合急救而非日常。Hint 语法是数据库私有的,并不通用;且加 Hint 后优化器仍会使用统计信息计算代价,只是把候选集合缩小了,因此 D 错误。

Q9. 高度均衡直方图中,目标值跨越 k 个桶、总桶数为 B,它的选择率是?

单选题 · hard

  • A.k 除以 B← 正确答案
  • B.B 除以 k
  • C.k 乘以 B
  • D.k 加上 B

答案:A。高度均衡直方图把行数大致均匀装进 B 个桶,目标值覆盖的桶数 k 占总桶数的比例就是选择率 k/B。覆盖桶越多,说明该值越常见,估算命中行数越多。

Q10. 通过 Trace 排查慢 SQL 时,通常能定位到哪些问题?

多选题 · extreme

  • A.统计信息过期导致的行数偏差← 正确答案
  • B.基数误判引发的连接顺序错误← 正确答案
  • C.动态采样调参方向的提示← 正确答案
  • D.SQL 中多余空格的位置

答案:ABC。Trace 完整记录优化器的输入与计算过程,能看到它读取的行数、估算的基数、最终选择的代价。由此可定位统计信息过期、基数误差导致驱动表选反,以及动态采样力度是否不足。而空格属于词法层信息,不进入优化器的决策日志。