实验 14:连接顺序枚举(DPsize / DPsub / DPccp)
为什么做这个实验
一条涉及 n 张表的连接查询,执行的先后顺序(连接顺序,join order)可以有非常多种。
n = 6 时丛生树(bushy tree)形状的计划就有 6! × C(5) = 720 × 42 = 30 240 种(C(5) = 42 是第 5 个卡塔兰数)。
靠"试一遍"是不现实的,优化器必须用**动态规划**:把"哪几张表连成一个中间结果"当作子问题,
用一张 plan table 记住每个子问题的最优计划,再一层层拼上去。
但"拼"的时候要枚举哪些 (S1, S2) 对,大有讲究。枚举得太粗(先枚举再过滤)会白算一大堆
笛卡尔积、重叠、不连通的无效对;枚举得太细又要额外的簿记开销。本实验对比三种经典做法:
| 算法 | 枚举方式 | 是否利用连接图 |
| --- | --- | --- |
| **DPsize** | 按结果集大小 s = 2…n 分层枚举 | 否,先枚举后过滤 |
| **DPsub** | 对每个子集枚举它的所有非空真子集 | 否,但不会枚举重叠对 |
| **DPccp** | 只枚举连通互补对(csg-cmp pair) | 是,一个无效对都不枚举 |
出处:Moerkotte & Neumann,《Analysis of Two Existing and One New Dynamic Programming
Algorithm for the Enumerations of Join Orders》,VLDB 2006。
读本 notebook 时请先记住一句:**DPsize / DPsub 是"先枚举后过滤",DPccp 是"只枚举有用的"。**
计数口径(务必先看清,否则数字对不上)
下面所有枚举量都是**直接运行本 notebook 的三个枚举器**得到的。该实现有一个统一约定:
**三个算法都对对称对做了去重,每个 (S₁, S₂) 连接对只计一次**(S₁、S₂ 互换视为同一个对)。
若按有序对计数,每个数字都会翻倍。
另一个容易踩的坑:教科书上说"DPsize 与 DPsub 的枚举量只由 n 决定、与图形状无关",
那是对**原始定义**而言(原始定义的 DP 表里放着全部 2ⁿ 个子集)。
**本仓库的实现只在 DP 表里放连通的子问题**,所以连 DPsize 也随图形状变化。
本 notebook 末尾的对比表会给出实测值,讲课与做题请以那些数字为准,不要拿教材公式去核对。
运行前准备
• Python 3.10 及以上(大量用到位运算)。
• 依赖:`numpy`、`matplotlib`(画枚举耗时曲线);可选 `jupyter`。
• 缺包时安装:
```bash
python -m pip install numpy matplotlib
```
(本机已配置环境可用:`python -m pip install numpy matplotlib`)
• 打开方式:在仓库根目录执行 `jupyter notebook notebooks/PlanEnumeration.ipynb`。
第一个代码 cell 会把仓库根目录加进 `sys.path`,这样 `from system...` 无论从哪打开都能用。
• 仓库相关代码:`system/query_optimization/` 下的 `join_graph.py`(四种图形状的工厂类)、
`problems.py`(用位序列表示关系子集的 `Problem`)、`plan_table.py`、`cost_function.py`(`C_Out`)、
`cardinality_table.py`。
import sys, pathlib
# Put the repo root (the directory containing `system/`) on sys.path so
# `from system...` works whether this notebook is opened from its own
# folder or from the repo root.
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
if (_p / "system").is_dir():
if str(_p) not in sys.path:
sys.path.insert(0, str(_p))
breakfrom typing import Type, Iterator
import numpy as np
import matplotlib.pyplot as plt
from abc import ABC, abstractmethodMatplotlib is building the font cache; this may take a moment.
实验设定:枚举器的统一接口
三种算法实现同一个抽象类 `PlanEnumerator`,都只有一个方法 `enumerate(...)`,返回三元组:
| 返回值 | 含义 |
| --- | --- |
| 第 1 项 | 最优计划的代价(按给定代价函数) |
| 第 2 项 | 最优计划本身(嵌套元组形式的连接树) |
| 第 3 项 | **枚举过程中产生的 (S₁, S₂) 对数量** ← 本实验的观测对象 |
第三个返回值正是我们比较三种算法的抓手:`print_results(results)` 会把它打印出来。
搜索空间限定为**不含笛卡尔积的丛生树**(bushy plans without cross products)。
所谓丛生树,就是连接的左右两支都可以是中间结果(而不要求右孩子必须是基表);
所谓不含笛卡尔积,就是每个连接都必须对应连接图里的一条边。
from system.interfaces.cost_functions import CostFunction
from system.query_optimization.cardinality_table import CardinalityTable
from system.query_optimization.join_graph import JoinGraph
class PlanEnumerator(ABC):
"""
Abstract enumerator class to enumerate the join plans for a given join graph.
"""
@abstractmethod
def enumerate(
self,
join_graph: JoinGraph,
cardinality_table: CardinalityTable,
cost_function: CostFunction,
print_info: bool = False,
) -> tuple[int, tuple, int]:
"""
Computes and returns the best join order for the given problem sizes.
# TODO: join order vs join plan
:param join_graph: The join graph.
:param cardinality_table: The cardinality table to be used.
:param cost_function: A cost function determining the cost of different operations.
:param print_info: A flag indicating whether additional info should be printed during enumeration.
:return: A tuple, consisting of the costs of the best plan, the best plan as a tuple and the number of pairs that were enumerated.
"""公共基础:plan table 与动态规划
三个算法共用一张 **plan table**(计划表),它记录"每个子问题到目前为止找到的最优计划及其代价"。
有了它,n 张表的查询不必枚举 n! 棵树,只需枚举所有子集的划分——这就是**动态规划**(dynamic programming)。
动态规划能成立靠的是**最优子结构**:大问题 S 的最优解,一定由它的某个划分 (S₁, S₂) 的**最优解**拼成。
因此两件事必须同时满足:
1. 每个子问题只算一次,结果存进 plan table 反复查用;
2. 生成 (S₁, S₂) 时,S₁ 与 S₂ 的最优计划**必须已经算好**。后面会看到,DPccp 的枚举顺序天生保证了这一点,
而 DPsize / DPsub 必须靠"按大小/按编号分层"来间接保证。
本仓库的代价函数是 `C_Out`(`system/query_optimization/cost_function.py`),定义极简:
C_out(S₁ ⋈ S₂) = |S₁ ∪ S₂| 的估计基数 + C_out(S₁) + C_out(S₂)
C_out(单表 R) = |R| 的估计基数
它只统计"流过的行数",是最朴素也最好手算的代价模型。真实系统还要再乘代价常数,
例如 PostgreSQL 的默认值 `seq_page_cost = 1.0`、`random_page_cost = 4.0`(官方文档 19.7 Query Planning)。
注意:本仓库的 plan table **只收录连通的子问题**(不连通的子集根本不是合法的连接中间结果)。
这一条取舍是后面"DPsize 也受图形状影响"的直接原因。
DPsize:按结果集大小分层枚举
思路:按结果集大小 s = 2, 3, …, n 分层。对每一层,枚举所有满足 |S₁| + |S₂| = s 的组合,
并检查一下:
• S₁ 与 S₂ 是否**不相交**(`s1.intersects(s2)` 为假);
• S₁ 与 S₂ 在连接图里是否**有边相连**(`join_graph.are_connected(s1, s2)`)。
只有两条都满足,才拿去更新 plan table;否则就白枚举了一次。
为什么会有那么多无效对?因为 DPsize **完全不看连接图**去生成候选,它只是机械地按大小配对:
• 重叠对:S₁ ∩ S₂ ≠ ∅,例如 S₁ = {R0, R1}、S₂ = {R1, R2};
• 笛卡尔积对:S₁ 与 S₂ 之间没有连接谓词;
• 不连通对:S₁ 或 S₂ 自身在连接图上不连通(本仓库的 plan table 里根本没有它)。
代码里的对称去重:当 |S₁| = |S₂| 时,内层从下标 `i + 1` 开始枚举,
这样 (S₁, S₂) 与 (S₂, S₁) 只会出现一次。**这就是"每个对只计一次"这条口径的来源。**
from system.query_optimization.problems import Problem
from system.query_optimization.plan_table import SizeBasedPlanTable
class DP_Size(PlanEnumerator):
"""
DPsize uses a size-based problem enumeration, i.e. it enumerates problems of increasing sizes. During the enumeration, it does
not consider the actual join graph, which means that Cartesian Products, as well as disconnected join pairs will be enumerated. In other words,
we also enumerate cross products in the algorithm (but then discard them).
"""
def enumerate(
self,
join_graph: JoinGraph,
cardinality_table: CardinalityTable,
cost_function: CostFunction,
print_info: bool = False,
) -> tuple[int, tuple, int]:
"""
Computes and returns the best join order for the given problem sizes.
:param join_graph: The join graph.
:param cardinality_table: The cardinality table to be used.
:param cost_function: A cost function determining the cost of different operations.
:param print_info: A flag indicating whether additional info should be printed during enumeration.
:return: A tuple consisting of: the costs of the best plan, the best plan as a tuple and the number of pairs that were enumerated.
"""
# Initialize plan table with the join graph
# A size plan table stores entries according to their problem size, which
# allows iterating through existing problems based on their size
plan_table: SizeBasedPlanTable = SizeBasedPlanTable(
join_graph, cost_function, cardinality_table
)
# Count number of pairs that were enumerated
enumerated_pairs: int = 0
# Count number of valid pairs
valid_pairs: int = 0
# Go through each pair generated by the enumerator below
# It still needs to make sure that both problems are actually
# connected and that they are disjoint.
for s1, s2 in self.__generate_pairs_size_based(join_graph, plan_table):
# Both s1 and s2 will already be in the plan table
enumerated_pairs += 1
# Check whether s1 and s2 are disjoint. Pairs which are not disjoint
# are no valid pairs. They also need to be connected in the join graph.
# this condition could be removed to also enumerate disconnected problems, i.e. cross products
if not s1.intersects(s2) and join_graph.are_connected(s1, s2):
valid_pairs += 1
# Update the table with the newly generated pair
plan_table.update_table(s1, s2, cardinality_table, cost_function)
if print_info:
print(f"Enumerated valid pair {s1.as_set()}, {s2.as_set()}.")
else:
if print_info:
print(f"Enumerated invalid pair {s1.as_set()}, {s2.as_set()}.")
problem_with_all_relations: Problem = Problem.get_problem_with_all_relations(
len(join_graph)
)
if print_info:
print(
f"{enumerated_pairs} pairs were enumerated, out of which {valid_pairs} were valid!"
)
return (
plan_table.get_costs_for_problem(problem_with_all_relations),
plan_table.get_plan_for_problem(problem_with_all_relations),
enumerated_pairs,
)
def __generate_pairs_size_based(
self, join_graph: JoinGraph, plan_table: SizeBasedPlanTable
) -> Iterator[tuple[Problem, Problem]]:
"""
Enumerate through the search space by utilizing the size of problems.
:param join_graph: The join graph.
:param plan_table: The underlying plan table.
"""
num_nodes: int = len(join_graph.adjacency_matrix)
# Enumerate all possible problem sizes starting from 2
for main_size in range(2, num_nodes + 1):
# Enumerate all sizes that are smaller than size_1 // 2
# to avoid duplicate work.
for s1_size in range(1, (main_size // 2) + 1):
s2_size: int = main_size - s1_size
# If both sizes are equal, you need to evaluate symmetric pairs only once
if s1_size == s2_size:
# Enumerate all problems of size s1_size
for i, s1 in enumerate(plan_table.entries_with_size(s1_size)):
# Evaluated entries can be addressed by an index
# Enumerate all problems of size s2_size, and start with index i + 1
# to avoid having to re-enumerate symmetric pairs
for s2 in plan_table.entries_with_size(s2_size, i + 1):
# New pair was generated
yield s1, s2
else:
# Enumerate all problems of size s1_size
for s1 in plan_table.entries_with_size(s1_size):
# Enumerate all problems of size s2_size
for s2 in plan_table.entries_with_size(s2_size):
# New pair was generated
yield s1, s2看一遍 DPsize 的枚举过程
下面构造一个 **4 节点环形查询**(`CycleQueryFactory.construct_join_graph(4)`):
R0 —— R1
| |
R2 —— R3
即边集 {R0–R1, R0–R2, R1–R3, R2–R3},首尾相连成环。
基数表用 `randomize_cardinalities(cardinality_table, 4, 42)` 随机生成(种子 42),
**它不代表任何真实数据**,只是让代价有数可算;本实验关心的是枚举量,不是这个代价具体是多少。
`print_info=True` 会把每个枚举出的对打印出来,并标注 valid / invalid。
请重点观察:**valid 的行数远少于总行数**——这些 invalid 就是 DPsize 白做的功。
from system.query_optimization.cost_function import C_Out
from system.query_optimization.utils import randomize_cardinalities, print_results
from system.query_optimization.join_graph import CycleQueryFactory
# Construct a cycle query with 4 nodes. Therefore, this query has the following structure:
# 0 -- 1
# | |
# 2 -- 3
cycle_query = CycleQueryFactory.construct_join_graph(4)
# Define a Cardinality Table, i.e. a table estimating the sizes of problems.
# For this demonstration, we just randomize them, i.e. they do not represent realistic data by any means
cardinality_table: CardinalityTable = CardinalityTable()
randomize_cardinalities(cardinality_table, 4, 42)
# Start the enumeration, use C_out as the underlying cost function
results = DP_Size().enumerate(
join_graph=cycle_query,
cardinality_table=cardinality_table,
cost_function=C_Out(),
print_info=True,
)
print_results(results)Enumerated valid pair {0}, {1}.
Enumerated invalid pair {0}, {2}.
Enumerated valid pair {0}, {3}.
Enumerated valid pair {1}, {2}.
Enumerated invalid pair {1}, {3}.
Enumerated valid pair {2}, {3}.
Enumerated invalid pair {0}, {0, 1}.
Enumerated invalid pair {0}, {0, 3}.
Enumerated valid pair {0}, {1, 2}.
Enumerated valid pair {0}, {2, 3}.
Enumerated invalid pair {1}, {0, 1}.
Enumerated valid pair {1}, {0, 3}.
Enumerated invalid pair {1}, {1, 2}.
Enumerated valid pair {1}, {2, 3}.
Enumerated valid pair {2}, {0, 1}.
Enumerated valid pair {2}, {0, 3}.
Enumerated invalid pair {2}, {1, 2}.
Enumerated invalid pair {2}, {2, 3}.
Enumerated valid pair {3}, {0, 1}.
Enumerated invalid pair {3}, {0, 3}.
Enumerated valid pair {3}, {1, 2}.
Enumerated invalid pair {3}, {2, 3}.
Enumerated invalid pair {0}, {0, 1, 2}.
Enumerated invalid pair {0}, {0, 2, 3}.
Enumerated invalid pair {0}, {0, 1, 3}.
Enumerated valid pair {0}, {1, 2, 3}.
Enumerated invalid pair {1}, {0, 1, 2}.
Enumerated valid pair {1}, {0, 2, 3}.
Enumerated invalid pair {1}, {0, 1, 3}.
Enumerated invalid pair {1}, {1, 2, 3}.
Enumerated invalid pair {2}, {0, 1, 2}.
Enumerated invalid pair {2}, {0, 2, 3}.
Enumerated valid pair {2}, {0, 1, 3}.
Enumerated invalid pair {2}, {1, 2, 3}.
Enumerated valid pair {3}, {0, 1, 2}.
Enumerated invalid pair {3}, {0, 2, 3}.
Enumerated invalid pair {3}, {0, 1, 3}.
Enumerated invalid pair {3}, {1, 2, 3}.
Enumerated invalid pair {0, 1}, {0, 3}.
Enumerated invalid pair {0, 1}, {1, 2}.
Enumerated valid pair {0, 1}, {2, 3}.
Enumerated valid pair {0, 3}, {1, 2}.
Enumerated invalid pair {0, 3}, {2, 3}.
Enumerated invalid pair {1, 2}, {2, 3}.
44 pairs were enumerated, out of which 18 were valid!
The best join plan is (3⋈((1⋈0)⋈2)) with costs 2011! Overall, we needed to enumerate 44 pairs.
观察:大部分枚举是白做的
从上面的输出可以看到,枚举出的对里绝大多数是 invalid(重叠、笛卡尔积、或不连通)。
原因上一节已经分析过:DPsize 按大小机械配对,连接图只在"验证"阶段起作用,没有参与"生成"。
一个自然的想法:如果直接枚举**子集**而不是按大小配对,能不能少一些无效对?这就是下一个算法 DPsub。
衡量标准不是"少枚举几个"这么简单,而是三个条件能不能同时满足:
1. 所有合法(不相交、有边相连)的对都要枚举到,不能漏;
2. 一个无效对都不要枚举;
3. 生成 (S₁, S₂) 时,S₁ 和 S₂ 的最优计划**必须已经算好**(动态规划的前提)。
DPsub 解决了第 1 条、改善了第 2 条,但仍然做不到第 2 条。
DPsub:直接枚举子集划分
思路:对每个子集 S,枚举它的所有非空真子集作为左孩子 S₁,右孩子就是 S₂ = S − S₁。
与 DPsize 相比的进步:**不会再枚举出重叠对**,因为 S₁ ⊆ S 且 S₂ = S − S₁,两者必然不相交。
仍然存在的问题:S₁ 或 S₂ **自身可能不连通**。例如环形查询里 S₁ = {R0, R2} 是连通的,
但如果连接图是链形 R0–R1–R2,那么 {R0, R2} 就不连通,它根本不在 plan table 里,
这次枚举就作废了。代码用 `if s1 in plan_table and s2 in plan_table` 来判定。
实现细节(也是对称去重的来源):代码并没有枚举 S 的全部真子集,而是用
`stop_problem = main_problem.get_most_significant_bit()` 把枚举停在一半处,
这样 (S₁, S₂) 与 (S₂, S₁) 只产生一次。所以本仓库 DPsub 的计数同样是"每个无序对只计一次"。
另外一个与本仓库实现密切相关的点:只有 `join_graph.is_connected(main_problem)` 为真时才会继续枚举。
图形状不同,连通子集的个数就不同——**这就是 DPsub 的计数为什么会随图形状变化**。
from system.query_optimization.plan_table import StandardPlanTable
from system.interfaces.query_optimization.planning import PlanTable
class DP_Sub(PlanEnumerator):
"""
DPsub generates all possible pairs for each connected problem. It still generates Cartesian Products, that need to be filtered out.
"""
def enumerate(
self,
join_graph: JoinGraph,
cardinality_table: CardinalityTable,
cost_function: CostFunction,
print_info: bool = False,
) -> tuple[int, tuple, int]:
"""
Computes and returns the best join order for the given problem sizes.
:param join_graph: The join graph.
:param cardinality_table: The cardinality table to be used.
:param cost_function: A cost function determining the cost of different operations.
:param print_info: A flag indicating whether additional info should be printed during enumeration.
:return: A tuple, consisting of the costs of the best plan, the best plan as a tuple and the number of pairs that were enumerated.
"""
# Initialize plan table with the join graph
plan_table: PlanTable = StandardPlanTable(
join_graph, cost_function, cardinality_table
)
# Count number of pairs that were enumerated
enumerated_pairs: int = 0
# Count number of valid pairs
valid_pairs: int = 0
# Go through each generated pair and make sure that both problems s1 and s2
# are connected
for s1, s2 in self.__generate_pairs_sub_based(join_graph):
enumerated_pairs += 1
# s1 | s2 is guaranteed to be connected and disjoint as this point
# We still need to check whether s1 and s2 themselves are connected
# s1 and s2 are connected if they are contained in the plan table
# meaning they were enumerated as a valid problem at an earlier point
if s1 in plan_table and s2 in plan_table:
valid_pairs += 1
# Update the table with the newly generated pair
plan_table.update_table(s1, s2, cardinality_table, cost_function)
if print_info:
print(f"Enumerated valid pair {s1.as_set()}, {s2.as_set()}")
else:
if print_info:
print(f"Enumerated invalid pair {s1.as_set()}, {s2.as_set()}")
problem_with_all_relations: Problem = Problem.get_problem_with_all_relations(
len(join_graph)
)
if print_info:
print(
f"{enumerated_pairs} pairs were enumerated, out of which {valid_pairs} were valid"
)
return (
plan_table.get_costs_for_problem(problem_with_all_relations),
plan_table.get_plan_for_problem(problem_with_all_relations),
enumerated_pairs,
)
def __generate_pairs_sub_based(
self, join_graph: JoinGraph
) -> Iterator[tuple[Problem, Problem]]:
"""
Generate the required pairs by enumerating all possible problems.
:param join_graph: The join graph.
"""
num_nodes: int = len(join_graph.adjacency_matrix)
# Enumerate all problems by increasing the underlying problem number
for i in range(1, 1 << num_nodes):
# Find the best plan for this problem
main_problem: Problem = Problem(i)
# Singletons do not have to be considered
if len(main_problem) == 1:
continue
# The problem also needs to be connected, otherwise
# it would not be a valid problem
if not join_graph.is_connected(main_problem):
continue
# Again, there is no need to enumerate symmetric pair twice, which can be achieved
# by stopping the enumeration after the first half is done.
# Setting the stop problem exactly to the singleton pointing to the
# position of the MSB stops the enumeration exactly at this point
stop_problem: Problem = main_problem.get_most_significant_bit()
# Start the enumeration with the smallest singleton of the main problem, stop when the stop problem has been reached
for s1 in Problem.enumerate_all_problems(
main_problem, stop_problem=stop_problem
):
# Generate the corresponding partner subset
# Given that s1 | s2 == main_problem, we know s1 and s2 are connected via at least one join edge
s2: Problem = main_problem - s1
# New pair was generated
yield s1, s2再看一遍:DPsub 的枚举过程
同样的 4 节点环形查询、同样的随机基数表。请把这次输出的 invalid 行数与 DPsize 那次对比:
• 无效对的数量明显下降(重叠对已经消失);
• 但仍然不为零(S₁ 或 S₂ 自身不连通的情况还在)。
也就是说 DPsub 只是"少浪费一点",没有根治问题。
results = DP_Sub().enumerate(
join_graph=cycle_query,
cardinality_table=cardinality_table,
cost_function=C_Out(),
print_info=True,
)
print_results(results)Enumerated valid pair {0}, {1}
Enumerated valid pair {1}, {2}
Enumerated valid pair {0}, {1, 2}
Enumerated invalid pair {1}, {0, 2}
Enumerated valid pair {0, 1}, {2}
Enumerated valid pair {0}, {3}
Enumerated invalid pair {0}, {1, 3}
Enumerated valid pair {1}, {0, 3}
Enumerated valid pair {0, 1}, {3}
Enumerated valid pair {2}, {3}
Enumerated valid pair {0}, {2, 3}
Enumerated valid pair {2}, {0, 3}
Enumerated invalid pair {0, 2}, {3}
Enumerated valid pair {1}, {2, 3}
Enumerated invalid pair {2}, {1, 3}
Enumerated valid pair {1, 2}, {3}
Enumerated valid pair {0}, {1, 2, 3}
Enumerated valid pair {1}, {0, 2, 3}
Enumerated valid pair {0, 1}, {2, 3}
Enumerated valid pair {2}, {0, 1, 3}
Enumerated invalid pair {0, 2}, {1, 3}
Enumerated valid pair {1, 2}, {0, 3}
Enumerated valid pair {0, 1, 2}, {3}
23 pairs were enumerated, out of which 18 were valid
The best join plan is (3⋈((1⋈0)⋈2)) with costs 2011! Overall, we needed to enumerate 23 pairs.
问题的根源:连接图只被用来"验证",没被用来"生成"
DPsize 与 DPsub 的共同毛病:**先按集合论的方式把候选生成出来,再拿连接图去判断合不合法**。
生成阶段不看图,就一定会生成大量图里根本不存在的组合。
正确的做法是反过来——**让连接图直接驱动生成**。这就是 DPccp 的出发点,也是本 notebook 后半段的核心。
思路分两步:
1. 先解决"怎么不重复、不遗漏地枚举所有**连通子图**(csg)";
2. 再解决"对每个 csg,怎么枚举它的**互补连通子图**(cmp),并保证动态规划的顺序正确"。
第一步就是下一节的内容。
第一步:不重复地枚举所有连通子图(csg)
朴素做法会从每个单点出发做 BFS/DFS 扩展,但那样同一个集合会被很多条路径重复生成
(例如 {R0, R1, R2} 可以由 R0→R1→R2 生成,也可以由 R1→R0→R2 生成)。
解决办法是给每个节点定一个**编号**(这里就用关系的 id),然后约定一条规则:
> 扩展集合 S 时,只允许加入编号**大于** S 中最小编号的邻居。
配套一个**排除集 X**(exclude set):X 始终包含"编号小于等于当前集合最小编号"的关系,
扩展时直接从邻居里把 X 减掉即可。
举个例子(链形 R0–R1–R2–R3):
• 从 {R2} 出发,可以扩展到 {R2, R3}(R3 编号更大),不能扩展到 {R1, R2}(R1 编号更小);
• {R1, R2} 会由从 {R1} 出发的那条路径生成,所以不会漏;
• 于是每个连通子集**恰好**被生成一次。
代码里还有一处细节:递归调用子集合 S′ 时,会把"当前层用过的邻居"也并进 X′
(`exclude | neighbors`),这样更深一层的扩展也不会绕回已经处理过的分支。
文字描述对应代码里的 `generate_recursively(join_graph, start | neighbor_problem, exclude | neighbors)`。
这样一来,递归**不再需要显式的终止条件**:当没有可加的邻居时,迭代自然结束。
def generate_connected_problems(join_graph: JoinGraph) -> Iterator[Problem]:
"""
Generate all connected problems of the join graph, according to the description given above.
:param join_graph: The join graph.
"""
problem_with_all_relations: Problem = Problem.get_problem_with_all_relations(
len(join_graph)
)
# Top-level exclude set, always ignore neighbors whose order is <= than the current singleton. See the cell above for more details.
exclude: Problem = problem_with_all_relations
# Traverse all singleton sets (sets containing only one relation) as starting point. Go from the highest bit to the lowest bit.
for singleton in reversed(problem_with_all_relations):
yield singleton
# Recursively extend the singleton with all its neighbors, as long as the order of the neighbor is not lower/equal than the singleton
yield from generate_recursively(join_graph, singleton, exclude)
# Current problem can now be considered
exclude -= singleton
def generate_recursively(
join_graph: JoinGraph,
start: Problem,
exclude: Problem,
) -> Iterator[Problem]:
"""
Recursively extends the given start problem with its neighbors. Ignores relations contained in the exclude problem.
:param join_graph: The join graph.
:param start: The start problem.
:param exclude: The exclude problem.
"""
# Get direct neighbors of the problem, ignore those from the exclude set
neighbors: Problem = join_graph.get_neighbors(start) - exclude
# Get all problems contained in the neighbor set
neighbor_problems: list[Problem] = Problem.get_all_problems(neighbors)
# Combine all neighbor problems with the start problem
for neighbor_problem in neighbor_problems:
yield start | neighbor_problem
for neighbor_problem in neighbor_problems:
# Proceed recursively, extend the exclude set with the current neighbors
yield from generate_recursively(
join_graph,
start | neighbor_problem,
exclude | neighbors,
)验证:一个重复都没有
跑一下 `generate_connected_problems(cycle_query)`,把 4 节点环形查询的所有连通子集打印出来。
请核对两件事:
1. **没有重复**:同一个集合只出现一次;
2. **没有不连通的集合**:例如环形图里 {R0, R3} 没有直接边,它不会出现在输出里。
还可以自己数一下总数,与"环形 n = 4 的连通子集个数"对上。
for problem in generate_connected_problems(cycle_query):
print(f"{problem}"){3}
{2}
{2, 3}
{1}
{1, 2}
{1, 2, 3}
{0}
{0, 1}
{0, 3}
{0, 1, 3}
{0, 1, 2}
{0, 2, 3}
{0, 1, 2, 3}
第二步:从 csg 到 ccp(连通互补对)
现在我们能不重复地枚举连通子图了,但动态规划要的是**成对**的东西。目标是枚举所有
**(S₁, S₂)**,同时满足三条:
1. **完整性**:所有合法(不相交、有边相连)的对都要枚举到,不能漏;
2. **无浪费**:一个无效对都不要枚举;
3. **顺序正确**:生成 (S₁, S₂) 时,S₁ 与 S₂ 的最优计划必须**已经算好**——这是动态规划的前提。
术语:
| 缩写 | 全称 | 含义 |
| --- | --- | --- |
| csg | connected subgraph | 在连接图上连通的关系子集 |
| cmp | complement(这里指 complementing subgraph) | 与某个 csg 不相交、且与之有边相连的连通子集 |
| ccp | connected complementing pair | 一对 (S₁, S₂),两者都连通、互不相交、且之间有边 |
只有 ccp 才对应一个合法的、非笛卡尔积的连接。
难点在第 3 条:如果拿到一个 csg 就立刻枚举它所有的 cmp,那么生成某个 (S₁, S₂) 时,
S₁ 的最优计划可能还没算出来。
解决办法仍然是"编号"这个抓手。回顾 csg 的生成规则:扩展时只加编号更大的邻居,
于是**集合被生成的顺序本身就带有编号信息**。利用这一点:枚举 S₁ 的 cmp 时,
排除掉任何含有"编号小于等于 S₁ 中最小编号"的关系的集合,就能保证顺序正确。
代码里对应两行:
• `always_exclude_set = csg | csg.get_smaller_or_equal_relations()`:cmp 里绝不允许出现编号更小的关系;
• 从 csg 的直接邻居出发,复用 `generate_recursively` 逐层扩展 cmp。
def generate_ccp_pairs(join_graph: JoinGraph) -> Iterator[tuple[Problem, Problem]]:
"""
Enumerate all ccp pairs of the given join graph.
:param join_graph: The join graph.
"""
for csg in generate_connected_problems(join_graph):
# We do not want the csg_problem to be contained in any cmp problem at any problem
# We also always want to ignore cmp problems that contain relations whose number is smaller than the smallest in the csg
always_exclude_set: Problem = csg | csg.get_smaller_or_equal_relations()
# Start the enumeration of the cmp problems with the direct neighbors of the csg problem
csg_neighbors: Problem = join_graph.get_neighbors(csg) - always_exclude_set
# Define an additional top-level ignore set, we still want to ignore all problems with smaller/equal numbers than the current problem
# This time, however, we only enumerate the direct neighbors of the csg, thus we need to gradually reduce the neighbor set, instead of the whole problem.
exclude_set: Problem = csg_neighbors
# Again traverse all singleton problems in reversed order
for singleton in reversed(csg_neighbors):
yield csg, singleton
# The exclude_set is gradually reduced, we still need to make sure that we never include a relation from the always_exclude_set
# Make use of the already defined recursive function, as the required behavior here is exactly the same.
for cmp_problem in generate_recursively(
join_graph,
singleton,
exclude_set | always_exclude_set,
):
yield csg, cmp_problem
# Current problem can now be considered
exclude_set -= singleton试跑一下 ccp 枚举
跑 `generate_ccp_pairs(cycle_query)`,把 4 节点环形查询的所有 ccp 打印出来。
对照上一节的三条要求检查输出:
• 每一对 (S₁, S₂) 两侧都连通、不相交、之间有边——**没有无效对**;
• (S₁, S₂) 与 (S₂, S₁) 只出现一次——**没有对称重复**;
• 观察输出的先后顺序:S₁ 从编号大的单点开始,S₂ 逐步长大——**子问题先于父问题**。
这个数就是 DPccp 在该图上的枚举量(环形 n = 4 时为 18)。
for ccp_pair in generate_ccp_pairs(cycle_query):
print(f"Enumerated ccp pair ({ccp_pair[0].as_set()}, {ccp_pair[1].as_set()})")Enumerated ccp pair ({2}, {3})
Enumerated ccp pair ({1}, {2})
Enumerated ccp pair ({1}, {2, 3})
Enumerated ccp pair ({1, 2}, {3})
Enumerated ccp pair ({0}, {3})
Enumerated ccp pair ({0}, {2, 3})
Enumerated ccp pair ({0}, {1})
Enumerated ccp pair ({0}, {1, 2})
Enumerated ccp pair ({0}, {1, 2, 3})
Enumerated ccp pair ({0, 1}, {3})
Enumerated ccp pair ({0, 1}, {2})
Enumerated ccp pair ({0, 1}, {2, 3})
Enumerated ccp pair ({0, 3}, {2})
Enumerated ccp pair ({0, 3}, {1})
Enumerated ccp pair ({0, 3}, {1, 2})
Enumerated ccp pair ({0, 1, 3}, {2})
Enumerated ccp pair ({0, 1, 2}, {3})
Enumerated ccp pair ({0, 2, 3}, {1})
ccp 枚举到位了
上面的函数已经同时满足三条要求:不漏、不浪费、顺序正确。
剩下的工作只是把它接到 plan table 上——这就是 DPccp。
对比一下三种算法的"过滤"发生在哪里:
| 算法 | 无效对 | 生成时是否参考连接图 |
| --- | --- | --- |
| DPsize | 重叠对 + 笛卡尔积对 + 不连通对,数量最多 | 否 |
| DPsub | 只剩不连通对 | 否 |
| DPccp | **零** | 是 |
DPccp:只枚举有用的
DPccp 的 `enumerate` 极其干净:直接用上一节的 `generate_ccp_pairs` 迭代,
**不做任何有效性检查**(`if s1 in plan_table` 之类的判断全部省掉),
因为生成器已经保证产出的每一个对都是合法 ccp,而且子问题都已算好。
这就是"把判断从运行时挪到生成时"的价值:省下的不只是那几次 `if`,
更重要的是枚举总量本身变小了。
class DP_CCP(PlanEnumerator):
"""
Uses the join graph to enumerate all ccp pairs, without enumerating any invalid pair.
"""
def enumerate(
self,
join_graph: JoinGraph,
cardinality_table: CardinalityTable,
cost_function: CostFunction,
print_info: bool = False,
) -> tuple[int, tuple, int]:
"""
Computes and returns the best join order for the given problem sizes.
:param join_graph: The join graph.
:param cardinality_table: The cardinality table to be used.
:param cost_function: A cost function determining the cost of different operations.
:param print_info: A flag indicating whether additional info should be printed during enumeration.
:return: A tuple, consisting of the costs of the best plan, the best plan as a tuple and the number of pairs that were enumerated.
"""
# Initialize plan table with the join graph
plan_table: PlanTable = StandardPlanTable(
join_graph, cost_function, cardinality_table
)
# Count number of pairs that were enumerated
enumerated_pairs: int = 0
# Use previously defined generator to iterator through each ccp
# Note that no checks are required here anymore, as the ccp is guaranteed to be valid
for s1, s2 in generate_ccp_pairs(join_graph):
enumerated_pairs += 1
if print_info:
print(f"Enumerated valid pair {s1.as_set()}, {s2.as_set()}")
# Update the table with the newly generated pair
plan_table.update_table(s1, s2, cardinality_table, cost_function)
problem_with_all_relations: Problem = Problem.get_problem_with_all_relations(
len(join_graph)
)
if print_info:
print(f"Enumerated {enumerated_pairs} valid pairs")
return (
plan_table.get_costs_for_problem(problem_with_all_relations),
plan_table.get_plan_for_problem(problem_with_all_relations),
enumerated_pairs,
)最后跑一遍同一个例子
还是那个 4 节点环形查询,还是那张随机基数表。
请把这次输出与 DPsize、DPsub 的两次输出放在一起看:
• 得到的**最优计划相同**、**代价相同**——三种算法只是枚举顺序不同,最终答案是同一个
(这也说明"省枚举量"换来的是速度,不是不同的计划);
• 枚举出的对的数量就是合法 ccp 的数量(环形 n = 4 时为 18);
• **一行 invalid 都没有**。
results = DP_CCP().enumerate(
join_graph=cycle_query,
cardinality_table=cardinality_table,
cost_function=C_Out(),
print_info=True,
)
print_results(results)Enumerated valid pair {2}, {3}
Enumerated valid pair {1}, {2}
Enumerated valid pair {1}, {2, 3}
Enumerated valid pair {1, 2}, {3}
Enumerated valid pair {0}, {3}
Enumerated valid pair {0}, {2, 3}
Enumerated valid pair {0}, {1}
Enumerated valid pair {0}, {1, 2}
Enumerated valid pair {0}, {1, 2, 3}
Enumerated valid pair {0, 1}, {3}
Enumerated valid pair {0, 1}, {2}
Enumerated valid pair {0, 1}, {2, 3}
Enumerated valid pair {0, 3}, {2}
Enumerated valid pair {0, 3}, {1}
Enumerated valid pair {0, 3}, {1, 2}
Enumerated valid pair {0, 1, 3}, {2}
Enumerated valid pair {0, 1, 2}, {3}
Enumerated valid pair {0, 2, 3}, {1}
Enumerated 18 valid pairs
The best join plan is (3⋈((1⋈0)⋈2)) with costs 2011! Overall, we needed to enumerate 18 pairs.
小结:同样的答案,少做了多少功
三种算法在 4 节点环形查询上给出了同一个最优计划与同一个代价,差别只在枚举了多少个对。
DPsize 白枚举了绝大多数,DPsub 少一些,DPccp 一个都不浪费。
但要回答"到底省了多少",必须上规模、换图形状。下一节做这个基准测试。
在四种图形状上比较三种算法
下面这个基准用四种典型的连接图形状(n 从 5 到 9):
• **链形 chain**:R0–R1–R2–…,多表主外键串联,最常见的形状;
• **星形 star**:中心一张事实表、其余是维度表,数据仓库的典型形状;
• **环形 cycle**:链形再把首尾连起来;
• **团形 clique**:任意两表之间都有连接谓词,连接图最密,也最不现实。
**请注意这张图画的是"枚举耗时"(秒),不是枚举量。** 耗时里包含了 Python 解释器开销,
所以它是"趋势"而不是"精确计数"。
**要看枚举量,请读 `enumerate()` 的第三个返回值**:把 `randomize_cardinalities_and_enumerate_join_graph(...)`
返回的第三个元素取出来即可(本 notebook 的单元测试里就是这么断言的)。
枚举量实测对照表(n = 6,仓库真实输出)
| 图形状 | DPsize | DPsub | DPccp |
| --- | --- | --- | --- |
| 链形 | 150 | 99 | **35** |
| 星形 | 350 | 211 | **80** |
| 环形 | 261 | 187 | **75** |
| 团形 | 1171 | 301 | **301** |
其他规模(只列 DPccp,顺序为 链形 / 星形 / 环形 / 团形):
| n | 链形 | 星形 | 环形 | 团形 |
| --- | --- | --- | --- | --- |
| 4 | 10 | 12 | 18 | 25 |
| 5 | 20 | 32 | 40 | 90 |
| 7 | 56 | 192 | 126 | 966 |
| 8 | 84 | 448 | 196 | 3025 |
**计数口径:三个算法都做对称去重,每个 (S₁, S₂) 对只计一次;若按有序对计数,每个数字翻倍。**
从表里能读出三条规律:
1. **DPccp 永远不比另两个多**——四种形状、多个规模,无一例外;
2. **图越稀疏,DPccp 的优势越大**:链形 n = 6 时 35 对 vs DPsub 的 99 对,省掉约 65%;
3. **团形是最坏情况**:团形 n = 6 时 DPccp 与 DPsub 都是 301,DPccp 一点都没省。
原因是团形里任意子集都连通、任意两个不相交子集之间都有边,ccp 的条件自动成立,
剪枝无从下手。好在真实查询几乎不会是团形。
两条通项公式(可代入上表验证)
• 链形:DPccp = C(n+1, 3)。链形里连通子集一定是连续区间,一个 ccp 就是两个相邻区间,
确定它等价于从 n+1 个"间隙"里选 3 个边界。n = 4…8 依次得 10、20、35、56、84。
• 团形:DPccp = (3ⁿ − 2^(n+1) + 1) / 2,恰好等于本仓库团形下的 DPsub。
n = 6 得 301,n = 7 得 966,n = 8 得 3025。
一个常见误区
教科书(Moerkotte & Neumann, VLDB 2006)给出 DPsize / DPsub 的枚举量公式时,
是按"DP 表里放着全部 2ⁿ 个子集、且按有序对计数"推的,因此**只由 n 决定、与图形状无关**;
其中 DPsub 的闭式是 3ⁿ − 2^(n+1) + 1。
**那不是本仓库的实测值。** 本仓库的 DP 表只收录连通子问题,还做了对称去重,
所以连 DPsize 都随图形状变化——上面的表里,n = 6 时 DPsize 这一列四种形状各不相同
(链形 150、星形 350、环形 261、团形 1171)。
结论:**公式刻画的是算法的思想,实测值反映的是这一份具体实现的取舍。**
讲课和做题都不要拿教材公式去核对实测值,也不要反过来。
from system.query_optimization.utils import (
randomize_cardinalities_and_enumerate_join_graph,
)
from system.query_optimization.join_graph import JoinGraphFactory
import time
# Number of join-graph nodes (relations) the benchmark below runs for. The
# enumeration cost grows exponentially in the number of relations -- for the
# clique query in particular DP_Size/DP_Sub explode -- so the largest value
# dominates the total run time. We cap it at 9 to keep CI fast (~2s instead of
# ~90s). For the lecture, uncomment the range(5, 15) line to show the full
# exponential blow-up in the plots.
num_nodes: list[int] = list(range(5, 10))
# num_nodes: list[int] = list(range(5, 15))
def evaluate_algorithms_on_graphs(graph_factory: Type[JoinGraphFactory]):
"""
Evaluate all enumeration algorithms on different graph sizes.
:param graph_factory: A static class type used to define methods to create certain graphs.
"""
# Store number of enumerated pairs and the required time
result_dict: dict[str, list[int]] = {
algorithm: [] for algorithm in ["DP_CCP", "DP_Sub", "DP_Size"]
}
method_dict: dict[str, PlanEnumerator] = {
"DP_CCP": DP_CCP(),
"DP_Sub": DP_Sub(),
"DP_Size": DP_Size(),
}
# Do an experiment for each node size
for num in num_nodes:
# Perform the experiment for each algorithm and store the result in the dictionary from above
for algorithm, algorithm_result_lists in result_dict.items():
start_time = time.time()
_, _, _ = randomize_cardinalities_and_enumerate_join_graph(
graph_factory.construct_join_graph(num), method_dict[algorithm]
)
end_time = time.time()
algorithm_result_lists.append(end_time - start_time)
# Plot data
x = np.arange(len(num_nodes))
fig, ax = plt.subplots(layout="constrained", figsize=(4, 3))
for shape, shape_list in result_dict.items():
ax.plot(x, shape_list, label=shape)
ax.set_ylabel("Required Enumeration time in s")
ax.set_xlabel("# of Nodes")
ax.set_xticks(x, num_nodes)
fig.legend(bbox_to_anchor=(0.2, 0.99), loc="upper left")链形查询
预期:链形是最稀疏的形状,DPccp 在这里优势最大(n = 6 时 35 对,而 DPsub 要 99 对)。
三条曲线的间距应当比较明显,且 DPccp 最低。
from system.query_optimization.join_graph import ChainQueryFactory
evaluate_algorithms_on_graphs(ChainQueryFactory)星形查询
预期:星形比链形密一些(中心表与所有维度表相连),DPccp 与 DPsize/DPsub 的差距会缩小,
但 DPccp 仍然不劣于另两者。
from system.query_optimization.join_graph import StarQueryFactory
evaluate_algorithms_on_graphs(StarQueryFactory)环形查询
预期:环形只比链形多一条边,规律与链形接近,DPccp 依然领先。
evaluate_algorithms_on_graphs(CycleQueryFactory)团形查询
预期:这是最坏情况。团形下任意子集都连通、任意两个不相交子集之间都有边,
DPccp 的剪枝完全用不上,因此它与 DPsub 的枚举量相同(n = 6 时都是 301),
而 DPsize 会明显更差(1171)。
图上若看到 DP_Size 在团形上"爆炸",那是正常的:它的枚举量随 n 增长最快。
这也解释了为什么代码注释里把 `num_nodes` 上限设成 9——再往上跑会很慢。
from system.query_optimization.join_graph import CliqueQueryFactory
evaluate_algorithms_on_graphs(CliqueQueryFactory)单元测试
枚举器这种"看起来对、跑起来差一个"的代码,一定要用测试钉住。这里有两个测试:
1. `test_plan_enumerators`:链形 n = 5,手工给定基数表,断言三种算法给出**同一个**最优计划
`(((3, 2), 1), 0), 4`(即 `((((R3 ⋈ R2) ⋈ R1) ⋈ R0) ⋈ R4)`)与**同一个**最优代价 10770,
同时断言各自的枚举量:`DP_Size = 73`、`DP_Sub = 42`、`DP_CCP = 20`。
这三个数字与本 notebook 的计数口径一致(对称去重、只收录连通子问题)。
注意最优计划并不是"按 R0→R1→R2 的自然顺序",而是先连小而便宜的 R3 ⋈ R2——这正是优化器的价值。
2. `test_csg_enumeration` 与 `test_ccp_helper_enumeration`:把链形 n = 5 的全部 csg 与全部 ccp
**逐个列举**出来做精确比对,确保"不重复、不遗漏"这条性质没有被改坏。
跑完应看到 `OK`。
import unittest
class EnumeratorTests(unittest.TestCase):
def test_plan_enumerators(self):
join_graph: JoinGraph = ChainQueryFactory.construct_join_graph(5)
cardinality_table: CardinalityTable = CardinalityTable()
# Set cardinalities for important problems
# Cardinalities for Size 1
cardinality_table.update_cardinality_estimation(Problem(0b1, 5), 5000)
cardinality_table.update_cardinality_estimation(Problem(0b10, 5), 4000)
cardinality_table.update_cardinality_estimation(Problem(0b100, 5), 500)
cardinality_table.update_cardinality_estimation(Problem(0b1000, 5), 100)
cardinality_table.update_cardinality_estimation(Problem(0b10000, 5), 1000)
# Cardinalities for Size 2
cardinality_table.update_cardinality_estimation(Problem(0b11, 5), 100)
cardinality_table.update_cardinality_estimation(Problem(0b110, 5), 500)
cardinality_table.update_cardinality_estimation(Problem(0b1100, 5), 100)
cardinality_table.update_cardinality_estimation(Problem(0b11000, 5), 100)
# Cardinalities for Size 3
cardinality_table.update_cardinality_estimation(Problem(0b111, 5), 500)
cardinality_table.update_cardinality_estimation(Problem(0b1110, 5), 50)
cardinality_table.update_cardinality_estimation(Problem(0b11100, 5), 100)
# Cardinalities for Size 4
cardinality_table.update_cardinality_estimation(Problem(0b1111, 5), 10)
cardinality_table.update_cardinality_estimation(Problem(0b11110, 5), 100)
# Cardinalities for Size 5
cardinality_table.update_cardinality_estimation(Problem(0b11111, 5), 10)
best_plan: tuple = (
(((3, 2), 1), 0),
4,
)
best_costs: int = 10770
algorithm_pairs: dict[str, int] = {
"DP_Sub": 42,
"DP_Size": 73,
"DP_CCP": 20,
}
def _test_plan_costs(enumeration_algorithm: PlanEnumerator):
costs, plan, enumerated_pairs = enumeration_algorithm.enumerate(
join_graph, cardinality_table, C_Out(), False
)
self.assertEqual(plan, best_plan)
self.assertEqual(costs, best_costs)
self.assertEqual(
enumerated_pairs,
algorithm_pairs[enumeration_algorithm.__class__.__name__],
)
_test_plan_costs(DP_Size())
_test_plan_costs(DP_Sub())
_test_plan_costs(DP_CCP())
def test_csg_enumeration(self):
chain_query: JoinGraph = ChainQueryFactory.construct_join_graph(5)
correct_csgs: list[Problem] = [
Problem(0b10000, 5),
Problem(0b1000, 5),
Problem(0b11000, 5),
Problem(0b100, 5),
Problem(0b1100, 5),
Problem(0b11100, 5),
Problem(0b10, 5),
Problem(0b110, 5),
Problem(0b1110, 5),
Problem(0b11110, 5),
Problem(0b1, 5),
Problem(0b11, 5),
Problem(0b111, 5),
Problem(0b1111, 5),
Problem(0b11111, 5),
]
csgs_without_duplicates: list[Problem] = list(
generate_connected_problems(chain_query)
)
self.assertListEqual(csgs_without_duplicates, correct_csgs)
def test_ccp_helper_enumeration(self):
chain_query: JoinGraph = ChainQueryFactory.construct_join_graph(5)
correct_ccps: list[tuple[Problem, Problem]] = [
(Problem(0b1000, 5), Problem(0b10000, 5)),
(Problem(0b100, 5), Problem(0b1000, 5)),
(Problem(0b100, 5), Problem(0b11000, 5)),
(Problem(0b1100, 5), Problem(0b10000, 5)),
(Problem(0b10, 5), Problem(0b100, 5)),
(Problem(0b10, 5), Problem(0b1100, 5)),
(Problem(0b10, 5), Problem(0b11100, 5)),
(Problem(0b110, 5), Problem(0b1000, 5)),
(Problem(0b110, 5), Problem(0b11000, 5)),
(Problem(0b1110, 5), Problem(0b10000, 5)),
(Problem(0b1, 5), Problem(0b10, 5)),
(Problem(0b1, 5), Problem(0b110, 5)),
(Problem(0b1, 5), Problem(0b1110, 5)),
(Problem(0b1, 5), Problem(0b11110, 5)),
(Problem(0b11, 5), Problem(0b100, 5)),
(Problem(0b11, 5), Problem(0b1100, 5)),
(Problem(0b11, 5), Problem(0b11100, 5)),
(Problem(0b111, 5), Problem(0b1000, 5)),
(Problem(0b111, 5), Problem(0b11000, 5)),
(Problem(0b1111, 5), Problem(0b10000, 5)),
]
generated_ccps = list(generate_ccp_pairs(chain_query))
self.assertListEqual(generated_ccps, correct_ccps)
unittest.main(
argv=["ignored", "-v"], defaultTest="EnumeratorTests", verbosity=2, exit=False
)test_ccp_helper_enumeration (__main__.EnumeratorTests.test_ccp_helper_enumeration) ... ok
test_csg_enumeration (__main__.EnumeratorTests.test_csg_enumeration) ... ok
test_plan_enumerators (__main__.EnumeratorTests.test_plan_enumerators) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.002s
OK
<unittest.main.TestProgram at 0x10d93a750>真实系统的兜底:到多少张表就不穷举了
枚举量随 n 指数增长,任何穷举方案都有撑不住的一天。真实系统的做法是设阈值,超过就换成随机搜索:
| 系统 / 参数 | 默认值 | 含义 |
| --- | --- | --- |
| PostgreSQL `geqo_threshold` | 12 | 参与连接的关系数 ≥ 12 时不再穷举,改用 GEQO |
| PostgreSQL `join_collapse_limit` | 8 | 超过 8 个表项就不强行把 JOIN 重排成最优顺序 |
| PostgreSQL `from_collapse_limit` | 8 | 同理,针对 FROM 里的子查询 |
| PostgreSQL `geqo_effort` | 5 | GEQO 搜索的用力程度,越大搜索越久、计划通常越好 |
(出处:PostgreSQL 官方文档 19.7 Query Planning。不同大版本默认值可能调整,使用前请核对所用版本。)
换来的是**优化时间可控**——不会因为表多而把 planning 时间拖到比执行还久;
牺牲的是**不再保证找到代价最小的计划**,只保证找到一个"足够好"的计划。
这正是本实验的现实落点:DPccp 把可穷举的边界往上推了一截,但推不到无穷远。
一个必须知道的旁注:枚举得再完整,也可能选错
动态规划比较的是**估计代价**,而估计代价依赖基数估计(cardinality estimation)。
基数估计一旦有系统性偏差,误差会沿连接的层数**指数放大**。
模型:每张基表 10 000 行,过滤选择率 0.3,每次连接真实选择率 0.02;
但优化器每层都系统性高估 f = 1.6 倍。实测得到的"估计 / 真实"倍数:
| 连接层数 | 1 | 2 | 3 | 4 | 5 | 6 |
| --- | --- | --- | --- | --- | --- | --- |
| 估计 / 真实 | 1.00× | 1.60× | 2.56× | 4.10× | 6.55× | 10.49× |
即第 k 层的误差约为 f^(k−1)(1.6⁰ = 1.0,1.6¹ = 1.6,1.6² = 2.56,1.6³ = 4.096,1.6⁴ = 6.5536,1.6⁵ = 10.4858)。
与 Ioannidis & Christodoulakis(SIGMOD 1991)"多路自然连接的基数估计误差随路数指数增长"的结论一致。
含义:**枚举算法的正确性和代价估计的准确性是两件事。** DPccp 保证"该比的都比了",
但不保证"比出来的赢家是真的赢家"。这也是为什么真实系统要投入大量精力做多列统计、采样与反馈校正。
思考题与详解
**1. n = 6 且不考虑连接图约束(任意两表都能连)时,左深树、右深树、丛生树各有多少个计划?写出计算过程。**
<details><summary>答案要点</summary>
• 左深树:**n!** = 6! = 720
• 右深树:**n!** = 720(与左深树对称,只是左右镜像)
• 丛生树:**n! · C(n−1)** = 6! · C(5) = 720 × 42 = **30 240**
其中 C(5) 是第 5 个卡塔兰数(Catalan number):C(k) = 1/(k+1) · C(2k, k),
C(5) = 1/6 · C(10, 5) = 1/6 × 252 = 42。卡塔兰数在这里数的是"n 个叶子能组成多少种不同的二叉树形状"。
出处:MPI-INF 查询优化课程讲义 / Moerkotte《Building a Query Compiler》。
</details>
**2. 有人说"DPsize 与 DPsub 的枚举量只由 n 决定、与图形状无关"。这句话对教科书的原始定义成立吗?对本仓库的实现成立吗?请用实测数字佐证。**
<details><summary>答案要点</summary>
**对教科书原始定义:成立。** 因为按原始定义,两种算法枚举时都不看连接图,
只按子集的大小或组成机械枚举:
• DPsize(n) = Σ_{s=2}^{n} Σ_{a=1}^{s−1} C(n,a) · C(n, s−a)
• DPsub(n) = Σ_{k=1}^{n} C(n,k) · (2^k − 2) = 3ⁿ − 2^(n+1) + 1
两式里只有 n,没有图,所以计数与图形状无关。
**对本仓库实现:不成立。** 差别在三点:
| | 教科书原始定义 | 本仓库实现 |
| --- | --- | --- |
| DP 表里放什么 | 全部 2ⁿ 个子集 | 只放**连通**的子问题 |
| 对称对 | 按有序对计两次 | **去重**,每对只计一次 |
| 结果 | 只由 n 决定 | **受图形状影响** |
实测(n = 6):链形 150 / 99 / 35,星形 350 / 211 / 80,环形 261 / 187 / 75,团形 1171 / 301 / 301。
**DPsize 这一列四种形状各不相同**,与教科书公式算出的数(且不是同一口径)当然对不上。
这个差异本身就是一堂课:公式刻画算法的**思想**,实测值反映**这一份实现的取舍**。
不要拿教材公式去核对实测值。
</details>
**3. 链形的 DPccp 计数:n = 4 时 10、n = 5 时 20、n = 6 时 35、n = 7 时 56、n = 8 时 84。请找规律、推导通项并代入验证。**
<details><summary>答案要点</summary>
链形图中,连通子集一定是**连续区间**。一个 ccp 要求两侧都连通、不相交、之间有边,
因此两侧必然是两个**相邻**的区间:S₁ = [i..j]、S₂ = [j+1..k]。
确定这样一对区间,等价于从 n 个关系之间的 **n+1 个间隙位置**中选出 3 个边界
(i 的左端、j 与 j+1 的分界、k 的右端):
DPccp(chain, n) = C(n+1, 3)
代入验证:
• n = 4 → C(5,3) = 10 ✓
• n = 5 → C(6,3) = 20 ✓
• n = 6 → C(7,3) = 35 ✓
• n = 7 → C(8,3) = 56 ✓
• n = 8 → C(9,3) = 84 ✓
(若按有序对计数,上式要乘 2。本仓库做对称去重,所以用不带 2 的版本。)
</details>
**4. 团形 n = 6 时 DPccp = 301,恰好等于本仓库的 DPsub。n = 7、n = 8 时是否仍然相等?这是巧合吗?**
<details><summary>答案要点</summary>
仍然相等:n = 7 时两者都是 966,n = 8 时都是 3025。**不是巧合。**
团形中任意子集都连通,且任意两个非空不相交子集之间一定有边,所以 ccp 的三个条件
(都连通、不相交、有边)自动满足。于是 DPccp 的"跳过不连通子集"这一步**省不掉任何东西**,
退化成"枚举所有无序的不重叠非空子集对":
DPccp(clique, n) = (3ⁿ − 2^(n+1) + 1) / 2
代入:n = 6 → (729 − 128 + 1)/2 = 301 ✓;n = 7 → (2187 − 256 + 1)/2 = 966 ✓;
n = 8 → (6561 − 512 + 1)/2 = 3025 ✓。
而本仓库的 DPsub 因为做了对称去重,在团形下恰好也是同一个数,所以两者相等。
**这正好说明团形是 DPccp 的最坏情况**:它全部的优势(利用连接图剪枝)在这里一点都用不上。
幸运的是真实查询几乎不会是团形。
</details>
**5. 为什么 DPccp 必须保证"生成 ccp (S₁, S₂) 时 S₁ 与 S₂ 的最优计划已经算出"?顺序错了会怎样?**
<details><summary>答案要点</summary>
动态规划的前提是**最优子结构**:大问题的最优解由子问题的最优解拼成。
生成 (S₁, S₂) 时若 S₁ 的最优计划还没算出来(plan table 里没有,或只有次优解),
那么用 `C_out(S₁ ⋈ S₂) = |S₁ ∪ S₂| + C_out(S₁) + C_out(S₂)` 算出的代价就是错的,
而且这个错误会沿着连接树**向上传递**,最终可能选出一个明显更差的计划。
更隐蔽的是:这类错误不会报错,只会得到一个"次优但看起来合理"的计划。
DPccp 靠两件事天然保证顺序正确:
① csg 的扩展只允许加入编号更大的邻居,于是集合按编号单调地"长大";
② 枚举 cmp 时用 `always_exclude_set` 排掉编号更小的关系。
结果就是子问题一定先于父问题被枚举到,不需要额外的排序或拓扑检查。
</details>
**6. 本仓库的 DPsize 也随图形状变化(链形 150、星形 350、环形 261、团形 1171),可 DPsize 明明"不看连接图"。为什么?**
<details><summary>答案要点</summary>
因为"不看连接图"指的是**生成 (S₁, S₂) 的方式**不看图(按大小机械配对),
但**配对所用的候选池**来自 plan table,而本仓库的 plan table **只收录连通的子问题**。
图形状不同 ⇒ 连通子集的个数不同 ⇒ `plan_table.entries_with_size(k)` 每次能取出的条目数不同
⇒ 按大小配对产生的组合数就不同。团形里所有 2ⁿ 个子集都连通,候选池最大,DPsize 的枚举量也就最大(1171);
链形里连通子集少得多(只有连续区间),候选池小,枚举量也就小(150)。
再叠加"对称去重"(|S₁| = |S₂| 时内层从 i+1 开始)与"跳过重叠对",
最终数字既不等于教科书公式,也确实随图形状变化。
检验办法:把 plan table 改成"收录全部子集"再数一次,计数就会回到只由 n 决定的版本。
</details>
**7. 基数估计误差为什么会指数放大?用"每层高估 f 倍"的模型写出第 k 层的误差表达式;若每层高估 1.2 倍,6 层之后误差是多少?**
<details><summary>答案要点</summary>
因为每一层的估计结果会成为上一层的**输入**:若每层都高估 f 倍,这个 f 会沿着连接树连乘。
误差(k) = f^(k−1)
("第 1 层 = 单表过滤后",还没有经过任何连接,误差为 f⁰ = 1。)
f = 1.2、k = 6:**1.2⁵ ≈ 2.49 倍**。即使每层只高估 20%,6 张表的连接也已经把中间结果估大了近 1.5 倍。
含义:优化器在表多时容易"选崩",不是因为枚举算法不够好,而是因为**输入不准**。
这也解释了为什么真实系统要在多列统计、采样、反馈校正上投入大量工程。
出处:Ioannidis & Christodoulakis(SIGMOD 1991)证明 N 路自然连接的基数估计误差随 N 指数增长。
</details>
**8. PostgreSQL 在多少张表以上放弃穷举?用了什么替代方法?换来的是什么、牺牲的是什么?**
<details><summary>答案要点</summary>
`geqo_threshold` 默认 **12**:参与连接的关系数达到 12 就不再穷举,改用 **GEQO**
(Genetic Query Optimizer,遗传算法)做随机搜索(PostgreSQL 官方文档 19.7 Query Planning)。
相关默认值还有 `join_collapse_limit` = 8、`from_collapse_limit` = 8、`geqo_effort` = 5。
• **换来的是**:优化时间可控。不会因为表多而把 planning 时间拖到比执行还久。
• **牺牲的是**:不再保证找到代价最小的计划,只保证找到一个"足够好"的计划。
`geqo_effort` 越大,搜索越久、计划通常越好,这是在"优化时间"与"计划质量"之间做的二次权衡。
这也说明 DPccp 的现实意义:它把"还能穷举"的边界往上推了一截,但推不到无穷远,
所以阈值 + 随机搜索是所有真实优化器都必须有的兜底。
</details>