数据库查询优化器<2>物理计划搜索和代价估计
一、数据库通常维护哪些统计信息1. 表级统计信息表级统计信息描述一张表的整体规模。统计信息含义用途表行数表中大约有多少行估算扫描代价、JOIN 输入规模数据页数表占用多少磁盘页 / 块估算全表扫描 I/O 成本平均行宽每行平均占多少字节估算内存、网络、排序、Hash 代价表大小表的总存储大小判断扫描成本空闲空间 / 膨胀信息表中是否有大量空洞或过期版本影响扫描效率和维护策略例如orders 表 - 行数10,000,000 - 数据页数500,000 - 平均行宽120 bytes优化器看到这些信息后就能粗略判断全表扫描 orders 的代价很高。2. 列级统计信息列级统计信息是优化器估算WHERE条件选择率的核心依据。统计信息含义用途NDVNumber of Distinct Values不同值个数估算等值查询选择率NULL 比例该列中 NULL 所占比例估算IS NULL、IS NOT NULL最大值 / 最小值列值范围估算范围查询平均列宽该列平均占多少字节估算行宽、排序和网络传输高频值 MCVMost Common Values最常见值处理数据倾斜直方图 Histogram列值分布情况估算范围查询和非均匀分布相关系数 / 聚簇度列值和物理存储顺序的相关程度判断索引范围扫描是否高效例如列status的统计信息可能是status: - NDV 5 - NULL 比例 0 - 高频值 - paid60% - cancelled5% - pending20%如果没有高频值统计优化器可能简单认为status paid 的选择率 ≈ 1 / 5 20%但真实情况是status paid 的选择率 60%这就是为什么高频值统计很重要。3. 直方图统计信息直方图用来描述列值的分布尤其适合范围查询。例如WHERE amount BETWEEN 100 AND 500如果优化器只知道amount 最小值 0 amount 最大值 10000它可能假设数据均匀分布从而估计选择率为(500 - 100) / (10000 - 0) 4%但如果大多数订单金额都集中在 100 到 500 之间这个估计就会严重偏低。直方图可以帮助优化器知道amount 的值主要集中在哪些区间。常见直方图类型包括类型说明等宽直方图每个桶的值域宽度相同等高直方图每个桶中的行数大致相同高频值直方图单独记录特别常见的值混合直方图同时描述高频值和区间分布4. 索引统计信息索引统计信息描述索引本身是否适合访问数据。统计信息含义用途索引页数索引占多少页估算索引扫描 I/O 成本索引高度B 树有几层估算索引查找成本索引列 NDV索引键不同值个数估算索引选择性聚簇因子 / 聚簇度索引顺序和表数据物理顺序是否接近判断索引回表成本唯一性是否唯一索引判断最多返回多少行叶子页数量索引叶子层规模估算范围扫描成本例如WHERE customer_id 10086如果customer_id上有唯一索引优化器可以知道最多匹配 1 行。如果customer_id上是普通索引并且 NDV 很高优化器也可能认为索引扫描很划算。但是如果某个索引选择性很差例如gender 只有 2 个不同值那么WHERE gender F可能返回大量行优化器就不一定会选择索引扫描。5. 多列统计信息单列统计信息无法准确描述列之间的相关性因此一些数据库会维护多列统计信息。统计信息含义用途多列 NDV多列组合有多少不同值估算组合条件选择率函数依赖一列是否决定另一列修正独立性假设多列高频值常见列组合处理组合条件数据倾斜相关性统计多个列之间是否相关改善AND条件估计例如WHERE city Tokyo AND postal_code LIKE 100-%如果优化器把city和postal_code当作独立条件可能估算错误。因为postal_code 和 city 高度相关。多列统计信息可以帮助优化器知道city Tokyo 时postal_code 不是随机分布的。6. 分区统计信息对于分区表数据库通常会维护统计信息含义全局统计信息整张分区表的总体统计分区级统计信息每个分区自己的统计分区行数每个分区大约有多少行分区最大值 / 最小值分区列或普通列的范围分区 NDV每个分区内的不同值数量例如orders_20245 亿行 orders_20258 亿行 orders_20262 亿行对于查询WHERE order_date 2026-01-01优化器可以通过分区统计和分区裁剪只访问 2026 年相关分区。7. 运行时统计信息除了静态统计信息有些数据库还会记录或利用运行时信息。信息含义用途实际返回行数某次执行中每个算子实际产生多少行用于反馈优化实际执行时间算子耗时判断代价模型是否准确缓存命中率数据是否在 buffer cache 中影响 I/O 成本临时文件大小排序或 Hash 是否溢写磁盘调整后续计划执行计划历史过去某类 SQL 的表现自适应优化、计划管理这类信息不一定所有数据库都用于普通优化但现代数据库越来越重视运行时反馈。二、统计信息什么时候会更新统计信息的更新方式通常有以下几类。1. 手动更新数据库通常提供显式命令让用户或 DBA 主动收集统计信息。常见命令形式包括ANALYZE table_name;或UPDATE STATISTICS table_name;或DBMS_STATS.GATHER_TABLE_STATS(...);不同数据库命令不同但目的相同重新扫描或采样数据生成新的统计信息。适合在这些场景手动更新场景原因大量导入数据后原有统计信息已经不准确大量删除数据后表行数、分布都变了大量更新列值后列分布发生变化新建索引后优化器需要知道索引统计查询计划突然变差可能统计信息过期分区装载 / 交换后新分区统计信息缺失或不准2. 自动更新很多数据库会在表发生足够多的数据变化后自动触发统计信息更新。典型触发条件是表中插入、更新、删除的数据量超过某个阈值。例如表有 100 万行 如果修改了其中较大比例的数据 数据库可能认为统计信息过期 然后自动重新收集统计信息自动更新的策略通常基于判断依据说明修改行数自上次统计以来修改了多少行修改比例修改行数占表总行数的比例表大小大表和小表阈值可能不同统计信息年龄距离上次收集已经多久查询需求某些数据库在优化时发现缺统计信息会触发收集3. 定期更新生产系统中经常会安排定时任务更新统计信息。例如每天凌晨 每周末 每次 ETL 之后 每次批量装载之后这样做的原因是白天业务繁忙时收集统计信息可能影响性能 夜间低峰期收集更安全。定期更新适合系统类型更新策略OLTP 系统自动更新 低峰期定期维护数据仓库每次批处理 / ETL 后更新分区表新分区加载后更新新分区统计报表系统数据刷新后更新统计4. 采样更新统计信息不一定通过全表扫描获得。对于大表数据库经常使用采样。例如从 10 亿行中抽取 1% 的样本 根据样本估算整体分布采样的优点是速度快开销低。缺点是对数据倾斜、高频值、极端值的估计可能不够准确。对于特别重要的表或列可以提高采样比例甚至做全量统计。5. 增量更新对于分区表或超大表重新统计整张表代价很高因此数据库可能支持增量统计。例如orders 表按月份分区 新装载 orders_2026_06 分区不必重新统计所有历史分区只需要统计新分区 再合并生成全局统计信息增量统计适合大规模分区表 数据仓库 按时间追加数据的表6. 创建对象时更新某些操作会生成或刷新统计信息例如操作可能产生的统计信息创建索引收集索引键分布、索引页数、高度等重建索引更新索引统计信息创建表初始统计信息为空或很少批量导入数据可能触发统计信息收集分区交换可能需要同步分区统计表重组 / VACUUM / OPTIMIZE可能影响页数、膨胀、物理布局统计三、统计信息不更新会有什么问题如果统计信息过期优化器可能会做出错误判断。例如原来表中只有orders10 万行后来导入大量数据变成orders1 亿行但统计信息仍然显示orders10 万行优化器可能会错误地认为表很小从而选择Nested Loop Join而不是更适合大表的Hash Join结果可能导致查询性能急剧下降。四、一个典型例子假设有表orders(order_id, customer_id, status, amount, order_date)数据库可能维护如下统计信息对象统计信息orders 表行数、页数、平均行宽、表大小order_id 列NDV、唯一性、最小值、最大值customer_id 列NDV、高频客户、NULL 比例status 列NDV、高频值例如paid、cancelledamount 列最小值、最大值、直方图order_date 列最小值、最大值、直方图、分区范围customer_id 索引索引高度、叶子页数、聚簇因子分区 orders_2026_06分区行数、分区页数、局部分布如果执行SELECT * FROM orders WHERE status paid AND amount 100 AND order_date 2026-01-01;优化器会使用status 的高频值统计 amount 的直方图 order_date 的分区统计 表行数和页数 相关索引统计来估算过滤后大约剩多少行 是否可以裁剪分区 是否使用 status 索引 是否使用 order_date 索引 是索引扫描还是全表扫描五、统计信息更新时机总结表更新方式触发时机特点手动更新DBA 或用户执行ANALYZE/UPDATE STATISTICS等命令可控适合批量变更后自动更新表数据变化超过阈值方便但可能有延迟定期更新每天、每周、低峰期任务稳定适合生产系统采样更新大表统计时抽样收集开销低但可能不够精确全量更新扫描完整数据精确但开销高增量更新分区表新增或修改部分分区适合大表和数据仓库创建 / 重建索引建索引或重建索引时刷新索引相关统计批量导入后大量 INSERT / LOAD 后防止优化器低估数据量大量删除后大批量 DELETE 后防止优化器高估数据量大量更新后UPDATE 改变列分布后防止选择率估计错误六、总结数据库通常维护的统计信息包括表行数、页数、平均行宽 列的 NDV、NULL 比例、最大最小值、高频值、直方图 索引高度、索引页数、聚簇度、唯一性 多列相关性、分区级统计、运行时反馈信息。这些统计信息通常在手动 ANALYZE、 自动阈值触发、 定期维护任务、 批量导入 / 删除 / 更新后、 创建或重建索引后、 分区数据变化后进行更新。它们的作用可以概括为一句话统计信息是优化器估算行数和代价的依据统计信息越准确优化器越可能选择正确的执行计划。下面用教科书风格说明逻辑优化之后数据库如何进行物理计划搜索与代价估计。回到顶部一、逻辑优化之后得到了什么逻辑优化之后数据库得到的是一个经过等价重写的逻辑查询计划。例如 SQLSELECT c.name, o.amount FROM customers c JOIN orders o ON c.customer_id o.customer_id WHERE c.region Asia AND o.amount 100;逻辑优化后可能得到π_c.name,o.amount( σ_c.regionAsia(customers) ⋈_c.customer_ido.customer_id σ_o.amount100(orders) )这个计划说明1. 先过滤 customers.region Asia 2. 先过滤 orders.amount 100 3. 再按 customer_id 连接 4. 最后输出 c.name 和 o.amount但它还没有说明customers 怎么扫描 orders 怎么扫描 JOIN 用 Hash Join 还是 Nested Loop Join JOIN 谁作为外表谁作为内表 是否使用索引 是否并行这些问题属于物理计划搜索与代价估计阶段。回到顶部二、物理计划搜索与代价估计的核心任务逻辑计划回答查询在逻辑上要做什么物理计划搜索回答这些逻辑操作具体用什么物理算法执行代价估计回答每种执行方式大概要花多少成本因此逻辑优化之后的工作可以概括为逻辑计划 → 生成候选物理计划 → 对候选计划估算代价 → 比较并剪枝 → 继续搜索 → 选择估计代价最低的最终物理计划更准确地说物理计划搜索和代价估计是交织进行的不是完全分开的两个阶段。回到顶部三、从逻辑算子到物理算子逻辑算子只有语义不规定实现方式。物理计划生成的第一步就是把逻辑算子映射为具体物理算子。1. 表访问算子逻辑上Scan(customers)物理上可能有多种实现物理访问方式含义适用场景Seq Scan顺序扫描整张表返回大量数据Index Scan使用索引扫描谓词选择率较高Index Lookup通过索引查少量行主键、唯一键查询Bitmap Scan先合并索引结果再访问表多个低选择性条件Covering Index Scan只读索引不回表查询列都在索引中Partition Scan只扫描相关分区分区表查询例如WHERE c.region Asia如果customers.region上有索引可以生成Index Scan[customers_region_idx]如果没有合适索引则生成Seq Scan[customers] Filter[region Asia]2. 连接算子逻辑上customers ⋈ orders物理上可能实现为物理连接算法特点适用场景Nested Loop Join双层循环匹配外表很小Index Nested Loop Join外表每行通过索引查内表内表连接列有索引Hash Join一边建 Hash 表一边探测大表等值连接Sort-Merge Join两边排序后归并输入已有序或需要保序Broadcast Join分布式中广播小表小表连接大表Shuffle Join分布式中按 key 重分布大表连接大表同一个逻辑连接R ⋈_R.aS.b S可能生成HashJoin(R, S) NestedLoopJoin(R, S) IndexNestedLoopJoin(R, S) MergeJoin(R, S)每种连接算法的代价公式不同所以必须分别估计。3. 聚合算子逻辑上γ_customer_id, COUNT(*)(orders)物理上可能实现为物理聚合方式特点Hash Aggregate用 Hash 表维护分组状态Sort Aggregate先排序再按组聚合Streaming Aggregate输入已有序时边读边聚合Partial Aggregate并行或分布式环境中先局部聚合如果输入已经按customer_id有序Streaming Aggregate可能比Hash Aggregate更便宜。4. 排序算子逻辑需求ORDER BY amount DESC物理上可能有实现方式说明Sort对全部输入排序Top-N Sort只维护前 N 个结果Index Order Scan利用索引天然顺序Merge Sort分布式或并行归并例如ORDER BY amount DESC LIMIT 10如果有amount索引优化器可能选择Index Scan[amount_idx DESC] Limit[10]而不是Seq Scan Sort Limit回到顶部四、代价估计估什么代价估计通常分成两个层次1. 基数估计每个算子输出多少行 2. 成本估计执行这个物理算子要花多少资源1. 基数估计基数估计估算每一步的输出规模。例如σ_regionAsia(customers)如果customers 有 1,000,000 行 region Asia 的选择率约为 0.1则估计输出1,000,000 × 0.1 100,000 行对于连接R ⋈_R.aS.b S常见估计公式是|R ⋈ S| ≈ |R| × |S| / max(NDV(R.a), NDV(S.b))其中NDV表示某列不同值的数量。2. 成本估计成本估计估算物理执行需要消耗多少资源。常见成本包括成本类型含义I/O 成本读写磁盘页、随机 I/O、顺序 I/OCPU 成本比较、过滤、表达式计算、Hash 计算内存成本Hash 表、排序缓冲区、聚合状态网络成本分布式数据库中的数据传输并行成本线程调度、数据交换、同步开销例如 Hash Join 的粗略成本可以表示为Cost(HashJoin(R, S)) ≈ Cost(R) Cost(S) BuildHashCost(R) ProbeHashCost(S)Index Nested Loop Join 的粗略成本可以表示为Cost(IndexNestedLoop(R, S)) ≈ Cost(R) |R| × Cost(IndexLookup(S))如果外表R很小索引嵌套循环可能很便宜如果R很大它可能非常昂贵。回到顶部五、访问路径搜索对于每张表优化器首先会生成候选访问路径。例如SELECT * FROM orders WHERE order_date 2026-01-01 AND status paid;假设有这些索引orders(order_date) orders(status) orders(status, order_date)优化器可能生成候选 1Seq Scan orders 候选 2Index Scan orders_order_date_idx 候选 3Index Scan orders_status_idx 候选 4Index Scan orders_status_order_date_idx 候选 5BitmapAnd(status_idx, order_date_idx)然后分别估算每种访问路径会读多少页 会返回多少行 是否需要回表 是否能利用索引顺序 是否能覆盖查询所需列选择若干较优访问路径进入后续计划搜索。回到顶部六、连接顺序搜索多表连接是物理计划搜索中最复杂的部分。例如SELECT * FROM A JOIN B ON A.x B.x JOIN C ON B.y C.y JOIN D ON C.z D.z;逻辑上是A ⋈ B ⋈ C ⋈ D但执行顺序可以有很多种((A ⋈ B) ⋈ C) ⋈ D ((B ⋈ C) ⋈ D) ⋈ A (A ⋈ (B ⋈ C)) ⋈ D (A ⋈ B) ⋈ (C ⋈ D)不同顺序的中间结果可能差别巨大。例如A ⋈ B 产生 10,000,000 行 B ⋈ C 产生 1,000 行那么通常应优先考虑B ⋈ C因为它能先产生较小的中间结果。回到顶部七、动态规划式计划搜索经典优化器常用动态规划搜索连接顺序。基本思想是先找单表最优计划再找两表最优计划再找三表最优计划逐步构造更大的计划。以三张表A, B, C为例。1. 单表阶段BestPlan(A) BestPlan(B) BestPlan(C)每个单表计划可能包括Seq Scan Index Scan Bitmap Scan优化器估算它们的代价保留较优者。2. 两表阶段枚举两表连接BestPlan(A, B) BestPlan(A, C) BestPlan(B, C)例如BestPlan(A, B) min { HashJoin(BestPlan(A), BestPlan(B)), NestedLoopJoin(BestPlan(A), BestPlan(B)), MergeJoin(BestPlan(A), BestPlan(B)) }优化器会估算每个候选连接计划的代价然后保留较优者。3. 三表阶段构造三表连接BestPlan(A, B, C)可以由以下方式产生BestPlan(A, B) ⋈ C BestPlan(A, C) ⋈ B BestPlan(B, C) ⋈ A每一种又可以选择不同连接算法Hash Join Nested Loop Join Merge Join最终保留估计代价最低的计划。回到顶部八、左深树、右深树和灌木树连接计划通常可以表示为树。1. 左深树⋈ / \ ⋈ D / \ ⋈ C / \ A B对应(((A ⋈ B) ⋈ C) ⋈ D)特点搜索空间较小 实现简单 传统优化器常优先搜索 适合嵌套循环连接2. 右深树⋈ / \ A ⋈ / \ B ⋈ / \ C D对应A ⋈ (B ⋈ (C ⋈ D))特点在某些 Hash Join 或流水线执行中有优势3. 灌木树⋈ / \ ⋈ ⋈ / \ / \ A B C D对应(A ⋈ B) ⋈ (C ⋈ D)特点搜索空间最大 可能找到更优计划 适合复杂查询和并行执行回到顶部九、物理属性与 interesting properties优化器比较计划时不只看当前代价还会看计划输出的物理属性。常见物理属性包括物理属性含义排序属性输出是否已经按某列有序分区属性数据是否按某个 key 分布并行属性是否并行输出唯一性输出是否已经唯一覆盖性是否不需要回表物化状态中间结果是否已物化例如SELECT * FROM orders JOIN customers ON orders.customer_id customers.customer_id ORDER BY orders.order_date;计划 AHash Join 输出无序 当前代价低 后面还需要 Sort计划 BMerge Join 输出已有序 当前代价略高 后面可以省掉 Sort如果后面有ORDER BY计划 B 可能整体更优。这种“对后续算子有用的物理属性”称为interesting properties因此优化器有时不会只保留当前代价最低的计划还会保留一些具有有用属性的候选计划。回到顶部十、剪枝为什么不能枚举所有计划多表连接的候选计划数量增长非常快。表越多可能的连接顺序和物理实现越多。因此优化器必须进行剪枝。常见剪枝策略包括剪枝方式含义代价剪枝如果一个计划明显比另一个计划贵且无额外属性就丢弃物理属性剪枝保留不同排序、分区属性的少数计划搜索空间限制只搜索左深树或限制 join reorder 范围启发式规则优先连接小表、优先下推过滤超时停止优化时间达到阈值后停止搜索Top-K 保留每个子问题只保留若干较优计划例如对于同一个关系集合{A, B}Plan 1: Cost 100 Output order none Plan 2: Cost 300 Output order none如果二者输出属性相同Plan 2 可以被剪掉。但如果Plan 1: Cost 100 Output order none Plan 2: Cost 130 Output order A.xPlan 2 可能被保留因为后续ORDER BY A.x或 Merge Join 可能用得上。回到顶部十一、Volcano / Cascades 风格优化器现代数据库优化器常使用 Volcano 或 Cascades 风格的搜索框架。它们的基本思想是1. 用等价规则生成逻辑等价表达式 2. 用实现规则生成物理表达式 3. 将等价表达式组织在 memo 结构中 4. 对候选计划估算代价 5. 剪枝并选择最低代价计划例如逻辑规则R ⋈ S ≡ S ⋈ R实现规则LogicalJoin → HashJoin LogicalJoin → NestedLoopJoin LogicalJoin → MergeJoin在这种框架中优化器不是只处理一棵固定的计划树而是在一个由等价表达式组成的搜索空间中寻找最低代价实现。回到顶部十二、一个完整例子考虑 SQLSELECT c.name, o.amount FROM customers c JOIN orders o ON c.customer_id o.customer_id WHERE c.region Asia AND o.amount 100 ORDER BY o.amount DESC LIMIT 10;1. 逻辑优化后的计划Limit[10] └── Sort[o.amount DESC] └── Projection[c.name, o.amount] └── Join[c.customer_id o.customer_id] ├── Selection[c.region Asia] │ └── customers └── Selection[o.amount 100] └── orders2. 生成候选访问路径对customersC1: Seq Scan customers Filter region Asia C2: Index Scan customers_region_idx对ordersO1: Seq Scan orders Filter amount 100 O2: Index Scan orders_amount_idx O3: Index Scan orders_customer_id_idx3. 估算单表访问代价假设统计信息显示customers 有 1,000,000 行 region Asia 选择率 10% orders 有 10,000,000 行 amount 100 选择率 20%则估计customers 过滤后约 100,000 行 orders 过滤后约 2,000,000 行然后比较C1 和 C2 哪个更便宜 O1 和 O2 哪个更便宜4. 生成候选连接计划候选计划可能包括P1: HashJoin(C2, O1) P2: HashJoin(O1, C2) P3: NestedLoop(C2, IndexLookup orders_customer_id_idx) P4: MergeJoin(Sort C2 by customer_id, Sort O1 by customer_id)5. 对连接计划估算代价例如Cost(P1) Cost(C2) Cost(O1) BuildHashCost(C2) ProbeHashCost(O1) Cost(P3) Cost(C2) rows(C2) × Cost(IndexLookup orders_customer_id_idx)如果C2输出 100,000 行索引查找要执行 100,000 次代价可能较高。但如果C2只输出 100 行P3可能非常便宜。6. 考虑 ORDER BY 和 LIMIT查询有ORDER BY o.amount DESC LIMIT 10因此优化器还会考虑能不能利用 orders_amount_idx 的顺序 能不能避免全量排序 能不能使用 Top-N Sort一个特殊计划可能是P5: Limit[10] └── NestedLoop ├── Index Scan orders_amount_idx DESC, condition amount 100 └── Index Lookup customers_pk Filter region Asia这个计划的思想是按 amount 从大到小扫描 orders 每找到一个订单就查对应 customer 如果 customer.region Asia则输出 找到 10 条后停止它不一定适合返回全部结果但对ORDER BY LIMIT 10可能非常高效。这说明物理计划搜索必须考虑整个查询的上下文而不能只看局部算子。回到顶部十三、最终物理计划选择优化器会为候选计划估算总代价Total Cost 子计划成本 当前物理算子成本然后选择估计代价最低的计划。最终计划可能是Limit[10] └── Top-N Sort[o.amount DESC] └── Hash Join[c.customer_id o.customer_id] ├── Index Scan[customers_region_idx] └── Seq Scan[orders] Filter[o.amount 100]也可能是Limit[10] └── Nested Loop Join ├── Index Scan[orders_amount_idx DESC] │ Filter[o.amount 100] └── Index Lookup[customers_pk] Filter[c.region Asia]最终选择哪个取决于统计信息和代价模型。回到顶部十四、为什么优化器可能选错优化器选择的是估计代价最低的计划但不一定是真实运行最快的计划。原因包括原因说明统计信息过期表行数、列分布不准确数据倾斜某些值特别常见列相关性独立性假设不成立参数未知预编译 SQL 中参数值未知缓存状态变化实际 I/O 成本不同UDF 成本难估函数执行代价不确定分布式数据倾斜某些节点数据过多代价模型简化真实硬件行为更复杂因此优化器的目标通常不是找到理论绝对最优计划而是在有限优化时间内根据当前统计信息和代价模型找到一个估计代价较低的计划。