半连接转内连接的策略,TO_INNER+LIMIT 1

【 使用环境 】生产环境
【 OB or 其他组件 】OB
【 使用版本 】4.5.1.0
【问题描述】
在半连接转内连接的源码实现中,有两种条件下的转换:
1、右表最多输出一行,转TO_INNER。比如:
SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM (SELECT * FROM t2 LIMIT 1) v WHERE t1.a = v.c1);
被转换为:
SELECT * FROM t1 INNER JOIN (SELECT * FROM t2 LIMIT 1) v ON t1.a = v.c1;
如果子查询查询项是聚合函数,这样的转换看上去就不等价(参考ORACLE,聚合函数在表无数据时会返回一行数据,该行数据值为NULL,EXISTS检查会通过)。比如:
SELECT * FROM t1 WHERE EXISTS (SELECT COUNT(*) FROM (SELECT * FROM t2) v WHERE t1.a = v.c1);
若(SELECT * FROM t2)无数据,若转换成第二条SQL就不等价。
2、所有半连接全是左表filter则会使用TO_INNER+LIMIT 1形式。比如:
SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t1.a > 10);
被转换为:
SELECT * FROM t1 WHERE t1.a > 10;
这是否考虑了t2表为空的情况?

【复现路径】无
【附件及日志】推荐使用OceanBase敏捷诊断工具obdiag收集诊断信息,详情参见链接(右键跳转查看):

【SOP系列 22 】——故障诊断第一步(自助诊断和诊断信息收集)

【备注】基于 LLM 和开源文档 RAG 的论坛小助手已开放测试,在发帖时输入 [@论坛小助手] 即可召唤小助手,欢迎试用!

3 个赞

:+1: :+1: :+1:

问题 1:聚合子查询 vs LIMIT 1

两条不同的判定路径

路径 判定函数 适用场景 空表行为
is_unique check_right_exprs_uniquecheck_stmt_unique 标量聚合(scalar group by)、唯一键等 聚合仍输出 1 行
is_one_row check_right_table_output_one_row 右表 ref_query 有 LIMIT 1 扫描无数据时输出 0 行

check_stmt_unique 对标量聚合有专门分支:

ob_transform_utils.cppLines 4437-4439

} else if (!ignore_group && stmt->is_scala_group_by() //scalar group by

&& ObOptimizerUtil::subset_exprs(select_exprs, exprs)) {

is_unique = true;

is_scala_group_by() 即无 GROUP BY 但有聚合项(如 COUNT(*))。

你的 COUNT 例子

SELECT * FROM t1

WHERE EXISTS (SELECT COUNT(*) FROM (SELECT * FROM t2) v WHERE t1.a = v.c1);

  • 右表 ref_query 是 SELECT COUNT(*) ...,没有 LIMIT 1 → is_one_row = false
  • 但有标量聚合 → is_unique = true → 走第一条路径,不是 LIMIT 1 那条

等价改写语义上是:

SELECT * FROM t1

INNER JOIN (SELECT COUNT(*) c1 FROM (SELECT * FROM t2) sub) v ON t1.a = v.c1

t2 为空时:

  • EXISTS:子查询仍返回 1 行(COUNT=0),再按 t1.a = v.c1 过滤
  • INNER JOIN:右表同样 1 行,连接条件相同

因此 COUNT 场景在现有实现下是等价的,不会误用“LIMIT 1 空表 0 行”的逻辑。

你担心的不等价,发生在把 聚合的“恒 1 行” 和 LIMIT 1 扫描的“空则 0 行” 当成同一条件时;源码里它们是两条路径。

LIMIT 1 例子

SELECT * FROM t1

WHERE EXISTS (SELECT 1 FROM (SELECT * FROM t2 LIMIT 1) v WHERE t1.a = v.c1);

右表 ref_query 为 (SELECT * FROM t2 LIMIT 1),带 LIMIT 1 → is_one_row = truet2 空时 EXISTS 与子查询均为 0 行,也等价。

注释与实现的差距

ob_transform_semi_to_inner.cppLines 973-976

/**

  • 如果右表有limit 1或者unique_key = const表达式

  • 说明右边输出至多一行

*/

check_right_table_output_one_row 只检查了 LIMIT 1,注释里的 unique_key = const 未实现:

ob_transform_semi_to_inner.cppLines 977-1001

int ObTransformSemiToInner::check_right_table_output_one_row(…)

{

if (right_table.is_generated_table()) {

… check_limit_value(…, 1, is_one_row, …) …

}

return ret;

}

潜在边界(HAVING 等)

标量聚合 + HAVING 过滤掉聚合行(如 HAVING COUNT(*) > 0 且表空 → 0 行)时,is_scala_group_by() 仍为 true,check_stmt_unique 仍可能判 is_unique = true,存在理论上的不等价风险。你举的纯 COUNT(*) 无 HAVING 不在此列。

问题 2:全是左表 filter + LIMIT 1,空表 t2 是否考虑?

判定条件

is_all_left_filter 表示 semi condition 只引用左表(如 t1.a > 10),不引用右表:

ob_transform_semi_to_inner.cppLines 405-409

} else if (left_table_set.is_superset(expr->get_relation_ids())) {

if (OB_FAIL(filter_conds.push_back(expr))) {

}

} else if (OB_FALSE_IT(is_all_left_filter = false)) {

实际改写(不是只推 filter)

do_transform 不会去掉右表,而是:

  1. 把 semi condition 下推到 WHERE
  2. 把右表加入 FROM(等价 INNER JOIN / 笛卡尔积)
  3. 对右表加 LIMIT 1

ob_transform_semi_to_inner.cppLines 1115-1136

} else if (!need_add_distinct) {

} else if (OB_FAIL(stmt.add_from_item(semi_info->right_table_id_, false))) {

if (OB_FAIL(ret) || !right_table_need_add_limit) {

} else if (OB_FAIL(ObTransformUtils::add_limit_to_semi_right_table(&stmt, ctx_, semi_info))) {

对你举的例子,semi-to-inner 阶段更接近:

SELECT * FROM t1, (SELECT 1 FROM t2 LIMIT 1) AS v

WHERE t1.a > 10;

不是 SELECT * FROM t1 WHERE t1.a > 10。若最终计划里看不到 t2,应是后续规则(如 join elimination)再做的,不是 semi-to-inner 本身。

空表 t2 的语义

场景 EXISTS 原语义 改写后(INNER JOIN + LIMIT 1)
t2 有数据 右表非空 → EXISTS 为真(若 t1.a > 10 右表 LIMIT 1 得 1 行,与 t1 连接 → 等价
t2 无数据 EXISTS 为假 LIMIT 1 得 0 行,INNER JOIN 无结果 → 等价

add_limit_to_semi_right_table 对基础表会先包一层 inline view 再设 LIMIT 1,目的就是让“右表是否为空”决定连接是否有行,从而对齐 EXISTS 的“右表非空”语义。

学会了

聚合函数为啥在无数据时会返回一行NULL数据呢,应该不是吧