Skip to content

Commit 956e4f9

Browse files
committed
fix(query): prefer proven-empty outer join build side
1 parent 91809b4 commit 956e4f9

7 files changed

Lines changed: 406 additions & 35 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Outer Join 空基数规划问题备忘录
2+
3+
## 范围与脱敏说明
4+
5+
本文记录一个位于 `UNION ALL` 与顶层 `LIMIT` 下方的 Outer Join 优化器回归。文中不包含原始
6+
SQL、Schema 名称、业务字段、数据规模和性能测量数据。
7+
8+
## 问题现象
9+
10+
两个版本生成了语义等价、执行成本却不同的 Hash Join:
11+
12+
- 快计划把 `LEFT OUTER JOIN` 交换成 `RIGHT OUTER JOIN`,将能够低成本确定为空的输入放在
13+
Hash Build 侧。
14+
- 慢计划保留 `LEFT OUTER JOIN`,将昂贵输入放在 Hash Build 侧;执行器完成 Build 后,才发现
15+
Preserve/Probe 输入为空。
16+
17+
Hash Build 是阻塞阶段,因此外层 `LIMIT` 无法提前终止这部分工作。
18+
19+
## 证据与根因
20+
21+
两个版本的 Join 交换规则本身没有实质差异。规则按左右子树的输出基数决定 Build 方向;回归发生
22+
在两侧基数同时坍缩为零时。
23+
24+
新版选择率估算器在内部已经区分:
25+
26+
- 由常量、布尔规则或列域矛盾确定的空集;
27+
- 普通数值估算得到的零。
28+
29+
但生成算子统计信息时,这个区别丢失了,两者都只剩 `cardinality = 0.0``CommuteJoin` 因而看到
30+
无法区分的平局,并保留了不利的 Build 方向。
31+
32+
诊断还发现,缺少频率统计时,严重倾斜的等值谓词可能被当作近似唯一值。这会进一步恶化基数,
33+
但不能通过特殊处理某个业务值或假设统一选择率来安全修复。
34+
35+
## 修复设计
36+
37+
使用 `precise_cardinality = Some(0)` 保留“确定为空”的信息:
38+
39+
1. `SelectivityEstimator` 暴露零是否来自确定性规则,例如精确空输入、常量 False 或列域矛盾。
40+
2. `Filter``Scan` 将确定空集保存为精确零;普通估算零继续保持非精确。
41+
3. `Join` 按 Join 语义传播精确零。
42+
4. 普通 `LEFT/RIGHT OUTER JOIN` 两侧同为零、且仅一侧为精确零时,`CommuteJoin` 将精确空侧放到
43+
Build 侧。
44+
45+
非零估算、两个非精确零和两个精确零仍保持原行为。
46+
47+
## 未采用的方案
48+
49+
-`<` 改为 `<=`:会翻转所有零基数平局,可能让镜像场景从 Build 空小侧退化为 Build 大侧。
50+
- 比较底表总行数:无法反映过滤和裁剪后的成本,可能选择相反方向。
51+
- 特殊处理哨兵值:依赖业务数据,无法泛化。
52+
- 要求立即分析整张大表:操作成本高,而且不能保证缺失或陈旧统计下的健壮性。
53+
54+
## 测试覆盖
55+
56+
- 确定性 False 谓词会标记精确空集。
57+
- 数值估算零不会被提升为精确空集。
58+
- Outer Join 只从 Preserve 侧传播精确空集。
59+
- 零基数平局时,精确空侧被放到 Build。
60+
- 精确空侧本来就在 Build 时不交换。
61+
- 原有零值 canonicalization 保持不变。
62+
63+
验证结果:
64+
65+
- `databend-common-sql` 单元测试全部通过。
66+
- SQL crate 轻量 planner/integration 测试全部通过。
67+
- SQL crate `clippy -D warnings` 通过。
68+
- 本次修改涉及的 Rust 文件通过格式和 diff 检查。
69+
70+
## 后续方向
71+
72+
- 为推导 NDV 保留置信度和来源。
73+
- 为倾斜列维护 Top-N/频率统计。
74+
- 将裁剪后的扫描工作量纳入 Join Cost,而非只依赖输出基数。
75+
- 研究 Preserve 输入可能低成本为空时的自适应 Outer Join 执行。

src/query/sql/src/planner/optimizer/ir/stats/selectivity.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub struct SelectivityEstimator {
6565
top_n: TopNSet,
6666
count_min_sketch: CountMinSketchSet,
6767
overrides: ColumnStatSet,
68+
proven_empty: bool,
6869
}
6970

7071
impl SelectivityEstimator {
@@ -75,6 +76,7 @@ impl SelectivityEstimator {
7576
top_n: TopNSet::new(),
7677
count_min_sketch: CountMinSketchSet::new(),
7778
overrides: ColumnStatSet::new(),
79+
proven_empty: cardinality == StatCardinality::Exact(0),
7880
}
7981
}
8082

@@ -88,6 +90,13 @@ impl SelectivityEstimator {
8890
self
8991
}
9092

93+
/// Returns true when the predicates deterministically produce no rows.
94+
///
95+
/// This is deliberately stronger than an estimated cardinality of zero.
96+
pub fn is_proven_empty(&self) -> bool {
97+
self.proven_empty
98+
}
99+
91100
fn merged_column_stats(&self) -> ColumnStatSet {
92101
let mut merged = self.column_stats.clone();
93102
merged.extend(self.overrides.clone());
@@ -139,6 +148,7 @@ impl SelectivityEstimator {
139148
return match constant_filter_truthiness(&constant.scalar) {
140149
Some(true) => Ok(self.cardinality.value()),
141150
Some(false) => {
151+
self.proven_empty = true;
142152
self.clear_column_stats_for_empty_result();
143153
Ok(0.0)
144154
}
@@ -159,6 +169,7 @@ impl SelectivityEstimator {
159169
}) => !domain.has_true,
160170
_ => false,
161171
}) {
172+
self.proven_empty = true;
162173
self.clear_column_stats_for_empty_result();
163174
return Ok(0.0);
164175
}
@@ -263,6 +274,7 @@ impl SelectivityEstimator {
263274
Selectivity::Unknown => DEFAULT_SELECTIVITY,
264275
Selectivity::LowerBound => UNKNOWN_COL_STATS_FILTER_SEL_LOWER_BOUND,
265276
Selectivity::Zero => {
277+
self.proven_empty = true;
266278
self.clear_column_stats_for_empty_result();
267279
return 0.0;
268280
}

src/query/sql/src/planner/optimizer/optimizers/rule/join_rules/rule_commute_join.rs

Lines changed: 163 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use databend_common_exception::Result;
1919
use crate::optimizer::ir::Matcher;
2020
use crate::optimizer::ir::RelExpr;
2121
use crate::optimizer::ir::SExpr;
22+
use crate::optimizer::ir::StatInfo;
2223
use crate::optimizer::optimizers::rule::Rule;
2324
use crate::optimizer::optimizers::rule::RuleID;
2425
use crate::optimizer::optimizers::rule::TransformResult;
@@ -32,6 +33,45 @@ fn contains_recursive_cte(expr: &SExpr) -> bool {
3233
|| expr.children().any(contains_recursive_cte)
3334
}
3435

36+
fn should_commute(join_type: JoinType, left: &StatInfo, right: &StatInfo) -> bool {
37+
if left.cardinality < right.cardinality {
38+
return matches!(
39+
join_type,
40+
JoinType::Inner
41+
| JoinType::Cross
42+
| JoinType::Left
43+
| JoinType::Right
44+
| JoinType::LeftSingle
45+
| JoinType::RightSingle
46+
| JoinType::LeftSemi
47+
| JoinType::RightSemi
48+
| JoinType::LeftAnti
49+
| JoinType::RightAnti
50+
| JoinType::LeftMark
51+
| JoinType::RightMark
52+
);
53+
}
54+
55+
if left.cardinality != right.cardinality {
56+
return false;
57+
}
58+
59+
if left.cardinality == 0.0 && matches!(join_type, JoinType::Left | JoinType::Right) {
60+
let left_proven_empty = left.statistics.precise_cardinality == Some(0);
61+
let right_proven_empty = right.statistics.precise_cardinality == Some(0);
62+
if left_proven_empty != right_proven_empty {
63+
// The right child is the hash-build side. Prefer the input that is
64+
// known to be empty over one whose zero cardinality is only estimated.
65+
return left_proven_empty;
66+
}
67+
}
68+
69+
matches!(
70+
join_type,
71+
JoinType::Right | JoinType::RightSingle | JoinType::RightSemi | JoinType::RightAnti
72+
)
73+
}
74+
3575
/// Rule to apply commutativity of join operator.
3676
/// Since we will always use the right child as build side, this
3777
/// rule will help us measure which child is the better one.
@@ -79,33 +119,9 @@ impl Rule for RuleCommuteJoin {
79119

80120
let left_rel_expr = RelExpr::with_s_expr(left_child);
81121
let right_rel_expr = RelExpr::with_s_expr(right_child);
82-
let left_card = left_rel_expr.derive_cardinality()?.cardinality;
83-
let right_card = right_rel_expr.derive_cardinality()?.cardinality;
84-
85-
let need_commute = if left_card < right_card {
86-
matches!(
87-
join.join_type,
88-
JoinType::Inner
89-
| JoinType::Cross
90-
| JoinType::Left
91-
| JoinType::Right
92-
| JoinType::LeftSingle
93-
| JoinType::RightSingle
94-
| JoinType::LeftSemi
95-
| JoinType::RightSemi
96-
| JoinType::LeftAnti
97-
| JoinType::RightAnti
98-
| JoinType::LeftMark
99-
| JoinType::RightMark
100-
)
101-
} else if left_card == right_card {
102-
matches!(
103-
join.join_type,
104-
JoinType::Right | JoinType::RightSingle | JoinType::RightSemi | JoinType::RightAnti
105-
)
106-
} else {
107-
false
108-
};
122+
let left_stat = left_rel_expr.derive_cardinality()?;
123+
let right_stat = right_rel_expr.derive_cardinality()?;
124+
let need_commute = should_commute(join.join_type, &left_stat, &right_stat);
109125
if need_commute {
110126
// Swap the join conditions side
111127
for condition in join.equi_conditions.iter_mut() {
@@ -135,3 +151,123 @@ impl Default for RuleCommuteJoin {
135151
Self::new()
136152
}
137153
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use databend_common_expression::Scalar;
158+
159+
use super::*;
160+
use crate::optimizer::ir::Statistics;
161+
use crate::plans::ConstantExpr;
162+
use crate::plans::DummyTableScan;
163+
use crate::plans::Filter;
164+
use crate::plans::MutationSource;
165+
use crate::plans::ScalarExpr;
166+
167+
fn empty_stat(precise: bool) -> StatInfo {
168+
StatInfo {
169+
cardinality: 0.0,
170+
statistics: Statistics {
171+
precise_cardinality: precise.then_some(0),
172+
column_stats: Default::default(),
173+
top_n: Default::default(),
174+
count_min_sketch: Default::default(),
175+
},
176+
}
177+
}
178+
179+
fn proven_empty_expr() -> SExpr {
180+
SExpr::create_unary(
181+
Filter {
182+
predicates: vec![ScalarExpr::ConstantExpr(ConstantExpr {
183+
span: None,
184+
value: Scalar::Boolean(false),
185+
})],
186+
},
187+
SExpr::create_leaf(DummyTableScan::default()),
188+
)
189+
}
190+
191+
#[test]
192+
fn test_outer_join_zero_tie_prefers_proven_empty_build_side() {
193+
let proven_empty = empty_stat(true);
194+
let estimated_empty = empty_stat(false);
195+
196+
assert!(should_commute(
197+
JoinType::Left,
198+
&proven_empty,
199+
&estimated_empty
200+
));
201+
assert!(!should_commute(
202+
JoinType::Left,
203+
&estimated_empty,
204+
&proven_empty
205+
));
206+
assert!(should_commute(
207+
JoinType::Right,
208+
&proven_empty,
209+
&estimated_empty
210+
));
211+
assert!(!should_commute(
212+
JoinType::Right,
213+
&estimated_empty,
214+
&proven_empty
215+
));
216+
}
217+
218+
#[test]
219+
fn test_outer_join_zero_tie_preserves_existing_canonicalization() {
220+
let left = empty_stat(false);
221+
let right = empty_stat(false);
222+
223+
assert!(!should_commute(JoinType::Left, &left, &right));
224+
assert!(should_commute(JoinType::Right, &left, &right));
225+
}
226+
227+
#[test]
228+
fn test_commute_join_builds_proven_empty_input() -> Result<()> {
229+
let join = Join {
230+
join_type: JoinType::Left,
231+
..Default::default()
232+
};
233+
let expr = SExpr::create_binary(
234+
join,
235+
proven_empty_expr(),
236+
SExpr::create_leaf(MutationSource::default()),
237+
);
238+
let mut state = TransformResult::new();
239+
240+
RuleCommuteJoin::new().apply(&expr, &mut state)?;
241+
242+
assert_eq!(state.results().len(), 1);
243+
let result_join: Join = state.results()[0].plan().clone().try_into()?;
244+
assert_eq!(result_join.join_type, JoinType::Right);
245+
assert_eq!(
246+
RelExpr::with_s_expr(state.results()[0].child(1)?)
247+
.derive_cardinality()?
248+
.statistics
249+
.precise_cardinality,
250+
Some(0)
251+
);
252+
Ok(())
253+
}
254+
255+
#[test]
256+
fn test_commute_join_keeps_proven_empty_build_input() -> Result<()> {
257+
let join = Join {
258+
join_type: JoinType::Left,
259+
..Default::default()
260+
};
261+
let expr = SExpr::create_binary(
262+
join,
263+
SExpr::create_leaf(MutationSource::default()),
264+
proven_empty_expr(),
265+
);
266+
let mut state = TransformResult::new();
267+
268+
RuleCommuteJoin::new().apply(&expr, &mut state)?;
269+
270+
assert!(state.results().is_empty());
271+
Ok(())
272+
}
273+
}

0 commit comments

Comments
 (0)