lec15 Query Optimization Pt1
HOW do we go from SQL to a query plan?
parser->binder->optimizer
today's Agenda
bg, Transformations, Heuristic/Ruled-based optimization , cost-based optimization
BG
优化器职责: 逻辑计划->物理计划(必须说明具体怎么做)
三大挑战:
- 搜索空间巨大 (Large search space):
对于一个多表连接查询,可能的组合顺序和算法组合成千上万。优化器不能傻傻地每个都试一遍,得有策略地寻找。 - 准确判断优劣 (Accurately determine):
它怎么知道 37 次 I/O 的计划一定比 3,151 次的好?这就涉及到了代价模型 (Cost Model)。它会根据表的大小(10,000 条记录)、索引的情况、磁盘 I/O 速度等估算一个"分数"。 - 高效搜索 (Efficiently search):
它必须在极短的时间内(通常是毫秒级)找到那个最优(或接近最优)的方案,不能因为优化 SQL 本身就花了半天时间。
- 物理算子(Physical Operators)的特性
图中提到了三个非常重要的实战细节:
- 定义具体的执行策略(Specific execution strategy):
逻辑上只是一个JOIN,但物理上必须决定是Nested-Loop、Sort-Merge还是Hash Join。这就是决定**访问路径(Access Path)**的过程。 - 依赖数据的物理格式(Physical format):
物理算子会根据数据是否已经排好序(sorting)、是否压缩(compression)来选择最快的路。- 例子: 如果两张表在磁盘上本来就是按
did排好序的,优化器会瞬间决定用 Sort-Merge Join,因为"排序"这一大块开销直接省掉了。
- 例子: 如果两张表在磁盘上本来就是按
- 不是 1:1 的映射(Not always a 1:1 mapping):
这是一个高级考点。一个逻辑算子可能会变成多个物理算子的组合,或者多个逻辑算子被合并成一个物理操作。- 例子: 逻辑上的
SELECT+JOIN可能会被合并成一个物理上的 Index Nested-Loop Join(利用索引一次性完成过滤和关联)。
- 例子: 逻辑上的
Optimization Granularity(粒度)
选择 # 1:单查询优化 (Single Query) ------ 主流做法
- 优点: 搜寻最优方案的范围(Search space)小,优化过程非常快。
- 缺点:
- 各管各的: 即使你先后运行了两个几乎一样的 SQL,数据库通常也不会复用上一次的结果,而是重新算一遍。
- 资源竞争: 优化器得考虑当前系统的负载(比如 CPU 此时是不是很忙),来决定要不要给这个查询分配更多资源。
选择 # 2:多查询优化 (Multiple Queries) ------ 高级/数仓做法
- 优点: 共享经济。如果 10 个人都在查同一张大表的不同维度,优化器可以只扫描一次表,把数据同时分发给这 10 个查询。这就大大节省了 I/O。
- 缺点:
- 太复杂: 搜索空间呈指数级爆炸。要同时计算多个 SQL 的最优解,优化器本身可能就会跑很久。
- 延迟: 有时为了等其他类似的查询一起"拼车"处理,第一个查询的响应时间反而变长了。
observation
它指出了决定一个查询优化器"聪明程度"的三大支柱
- Transformations / Enumeration(变换与枚举)------ "解题思路多不多"
- Search Algorithm(搜索算法)------ "找答案快不快"
- Cost Model(代价模型)------ "判断标准准不准"
Transformations
核心任务:语义等价 (Semantically Equivalent)
logical to logical
目标:
- 降低执行成本 (Lower cost): 显而易见,比如把
Join之后再Filter变成先Filter再Join(谓词下推),直接砍掉 90% 的计算量。 - 解锁后续变换 (Unlock additional transformations): 这是一个"连招"逻辑。有些变换本身可能不怎么省时间,但它能改变查询的结构 ,从而让另一个威力巨大的变换变得可行。
- 打个比方: 你把嵌套子查询改写成
Join,这一步可能提升一般,但变成Join后,优化器就能使用 Index Nested-Loop Join 了,这才是真正的性能杀手锏。
- 打个比方: 你把嵌套子查询改写成
一些技巧
1.Split Conjunctive Predicates (拆分连词谓词)
- 做法: 把
WHERE A=1 AND B=2拆成两个独立的过滤条件:Filter(A=1)和Filter(B=2)。 - 目的: 拆开后,优化器就可以灵活地把其中一个条件先"推"下去。比如 A 字段有索引,那就先处理
A=1。
2.Predicate Pushdown (谓词下推) ------ 最常用、威力最大 - 做法: 也就是我们之前反复提到的,把
WHERE条件尽可能地往数据源头(Scan)推。 - 意义: 与其把 100 万行数据连表后再过滤掉 99 万行,不如先过滤掉 99 万行再连表 。这能瞬间把 Join 的压力降低几个数量级。
3.Replace Cartesian Products with Joins (将笛卡尔积替换为连接) - 做法: 识别出那些没有连接条件的
CROSS JOIN,并尝试结合后面的WHERE子句将它们转化成真正的INNER JOIN。 - 意义: 还记得你发的第一张 2,000,000 I/O 的图吗?那就是笛卡尔积。这一步变换能直接阻止这种"自杀式"查询计划的产生。
4.Projection Pushdown (投影下推) - 做法: 尽早只保留需要的列。如果你一张表有 100 个字段,但你最后只要
name,那么在读取数据时就只读name。 - 意义: 减少数据在内存/网络中的体积。尤其是对于列式存储数据库(如 ClickHouse),这一步能极大减少硬盘读取量。
Search algorithms
即便优化器掌握了所有"变换神技",在实际搜索时,它其实是在 "半盲"状态 下工作的。
图中提到了搜索时面临的两个核心挑战:
1.信息的缺失 (Missing Information)
优化器在制定计划时,往往并不知道查询执行时的真实情况
-
带输入变量的预编译语句 (Prepared Statements):
如果你写的是
WHERE id = ?,在生成计划时,优化器并不知道这个?是1还是1,000,000。- 为什么这很重要? 如果
?是唯一主键,用 Index Join 最好;如果?范围很大,可能 Sort-Merge Join 更快。优化器在这里必须做抉择(或先生成一个通用计划)。
- 为什么这很重要? 如果
-
缺失统计信息 (Missing statistical information):
数据库需要知道表里大概有多少行、数据分布是否均匀。如果统计信息过期了(比如表里刚导进了 1 亿行数据但没更新统计),优化器就会"看走眼",选出一个极慢的计划。
2.搜索的本质
既然信息不全,搜索算法的目标就从"寻找绝对最优解"变成了**"寻找一个足够好的物理计划 (Search for a good physical plan)" 。
heuristics /Rules
这就像是给优化器写了一本"避坑指南"。
- 核心逻辑: 凭经验办事。比如:"先把能过滤的都过滤了(Selections first)"、"能下推的谓词一定下推"。
尽早执行限制性最强的选择(Most restrictive selection early): 如果一个条件能一下子过滤掉 99% 的数据,那就第一步做它。
先选择再连接(Selections before joins): 也就是"减肥后再相亲",减少 Join 操作的负担。
各种下推(Pushdowns): 包括谓词(WHERE)、限制(LIMIT)和投影(SELECT 字段)。越早下推,后续算子的内存压力就越小。
简单的连接(join) 谁先写谁先/简单统计 - 为什么叫它"盲目":
- 不看数据(Do not need to examine data): 它只看表结构(Catalog),不关心表里有 10 行还是 10 亿行。
- 死理: 哪怕下推一个过滤条件反而会让查询变慢(极少数极端情况),它也会照做,因为它觉得这是"政治正确"。
- 现状: 现代优化器通常把这作为第一步预处理,用来快速剪枝,扔掉那些明显傻掉的方案。
优点: 实现调试简单(就是一堆if-else),简单查询跑得快
缺点:依赖魔法数字,处理不了复杂的相互依赖
Cost-based Search
这是现代数据库的大脑,也是之前那张 37 I/Os 图背后的功臣。
-
核心逻辑: 枚举出各种等价计划,给每个计划"算一卦",算出一个代表开销的数字(Cost)。
-
怎么选: 谁分低选谁。
-
为什么它更聪明: 它会看统计信息 。如果它发现
dname='Toy'的部门在表里占了 90%,它可能就不会选择用索引,而是直接全表扫描(因为此时索引回表反而更慢)。 -
步骤 :
计划枚举
- 单表 (Single relation): 是全表扫描还是走索引?单表只需考虑路径访问方法?
- 多表 (Multiple relations): 先连 A 和 B,还是先连 B 和 C?用 Hash Join 还是 Index Join?
- 嵌套子查询 (Nested sub-queries): 是直接执行子查询,还是把它"拉平"变成一个大 Join?
代价估算(IO CPU 内存/网络)
搜索终止条件
search termination
1.Wall-clock Time (墙上时钟时间)
- 做法: 定个闹钟,比如 100 毫秒。闹钟一响,不管找到哪一步,都从目前已知的计划里选一个最好的。
- 适用场景: 对响应时间极其敏感的在线业务(OLTP)。
2.Cost Threshold (代价阈值) - 做法: 设定一个"及格线"。只要找到一个计划,它的成本(Cost)低于这个阈值,优化器就觉得"够用了,收工"。
- 直白点说: "不要最好,只要够好"。
3.Exhaustion (穷尽搜索) - 做法: 把所有能变的花样全部试一遍,直到无路可走。
- 适用场景: 通常只在查询非常简单 (比如只有一两张表)或者**子计划/小组(sub-plan/group)**内部使用。
- 注意: 如果表多了,这种方法会导致计算量爆炸。
4.Transformation Count (变换次数限制) - 做法: 限制优化器尝试"规则"和"变换"的总次数。比如只允许你尝试 500 次等价改写。
- 逻辑: 尝试的变换越多,搜索树就越深。限制次数就是限制了搜索的深度和广度。
Access Path Transformation(对应single-relation)
这步是决定:为了拿到数据,我是该"走路"去(全表扫描),还是"骑车"去(利用索引)? '
下面这些参数都不重要,可跳过
优化器会权衡以下几个关键因素来计算每种路径的"成本":
1.Selectivity of predicate (谓词选择率) ------ 最重要的参数
- 意思: 你的过滤条件能过滤掉多少数据?
- 成本逻辑:
- 如果你查
id = 1(选择率极高,只有一行),索引绝对最快。 - 如果你查
gender = 'Male'(选择率很低,占了一半人),数据库可能会觉得走索引还得不断"回表",反而不如直接全表扫描 来得痛快。
2.Data structures (数据结构)
- 如果你查
- B+Tree vs. Hash Table:
- 如果你是范围查询(
age > 20),B+树索引才管用,哈希索引直接废掉。 - 如果是等值查询(
id = 100),哈希索引可能比 B+树更快。
3.Sort order (排序顺序)
- 如果你是范围查询(
- 伏笔: 如果你后续有个
ORDER BY操作,而某个索引本身就是排好序的,优化器会给这个索引"加分",因为它能帮你省掉后面专门排序的开销。
4.Data accoutrements (数据"配饰"/附加属性) - INCLUDE: 这是指覆盖索引(Covering Index)。如果你要查的字段全在索引里,不需要回表看原始行,成本会大幅下降。
- Zone maps: 常见于列式存储,记录了每一块数据的最大最小值。如果查询条件不在这个范围内,直接整块跳过,这叫"谓词下推"的极致物理实现。
5.Compression / Encoding (压缩与编码) - 如果数据被高度压缩,读取它会省 I/O,但解压会费 CPU。优化器需要计算这个权衡。
single-relation query planning(对于单表查询,最重要的就是选最好的accessing method,也就是做好access path transformation)
sargable: search argument able 可搜索参数化
如果一个查询条件是sargable, 这意味着优化器可以直接利用索引来快速定位数据,而不是扫全表
Multi-relation query planning(单表基础上,重点在表与表间)
选择1 : bottom-up / forward chaining 自底向上(理论上优于自顶向下)
选择2 : top-down / backward chaining 自顶向下
Bottom-up
使用广度优先搜索(BFS)
讲到这里结束了,只是介绍了system R的实现
Top-down
使用深度优先搜索(DFS)