Hologres 中 OR + IN 的性能陷阱:从 30 秒到 1 秒内
一条同时包含 OR 和 IN 子查询的 Hologres SQL,让千万级事实表失去 RowGroupFilter。本文通过 EXPLAIN 定位全表扫描,并用语义等价的 UNION ALL 恢复分支剪枝。
问题背景
一个社群查询需要同时返回两类数据:
- 当前用户自己的记录;
- 当前用户所管理成员的记录。
事实表在 user_id 和 manager_id 上都建立了适合当前数据分布的索引或分区结构。早期查询单个团队时,下面的 SQL 可以在 1 秒内返回:
select *
from social_cart_fact
where user_id = :current_user_id
or manager_id = :current_user_id;后来管理范围扩大,manager_id 需要匹配一个关系子查询:
select *
from social_cart_fact
where user_id = :current_user_id
or manager_id in (
select member_id
from social_relation
where root_id = :current_user_id
);小范围用户仍然很快,但管理成员较多时,查询耗时上升到约 30 秒。
表名、字段名和参数均为脱敏示例。
EXPLAIN 暴露的问题
对原 SQL 执行 EXPLAIN 后,关键计划可以简化成:
Gather
-> ExecuteExternalSQL
Filter: (subquery_match OR user_id = :current_user_id)
-> Seq Scan on social_cart_fact主事实表约有 2800 万行。IN (subquery) 与另一个字段上的 OR 被组合成单一过滤条件后,优化器没有把两个条件分别下推到适合的扫描分支,user_id 的 RowGroupFilter 也没有生效,最终读取了大范围数据。
这不是说 Hologres 中的 OR 或 IN 一定慢,而是这组表达式、数据分布与当前执行计划组合后无法有效剪枝。判断依据必须是实际 EXPLAIN,而不是只看 SQL 语法。
集合关系示意
原 SQL 的查询逻辑本质上是两个条件集合的并集:
原查询将两个条件通过 OR 合并,优化器无法分别利用各自的访问路径,导致剪枝失效。改写后每个分支独立优化,各自走最优路径。
用 UNION ALL 拆开两个访问路径
两个条件本来就对应两种独立访问路径,可以拆成两个查询分支:
select *
from social_cart_fact
where user_id = :current_user_id
union all
select *
from social_cart_fact
where manager_id in (
select member_id
from social_relation
where root_id = :current_user_id
)
and user_id is distinct from :current_user_id;第二个分支使用 is distinct from 排除第一分支已经返回的数据,同时正确处理 user_id is null。如果字段有 not null 约束,也可以使用 user_id <> :current_user_id。
不要直接删掉排重条件。原来的 A OR B 对同一行只返回一次,而 UNION ALL 会保留两个分支的重复行。
改写后的执行计划
改写后,计划的关键部分变成:
Append
-> Scan social_cart_fact
Filter: user_id = :current_user_id
RowGroupFilter: user_id = :current_user_id
-> Hash Semi Join
Hash Cond: social_cart_fact.manager_id = social_relation.member_id
-> Scan social_cart_fact
-> Filter social_relation.root_id = :current_user_id两个分支获得了独立计划:
user_id分支重新出现RowGroupFilter;IN子查询被转换为 Hash Semi Join;- 优化器不再需要用一个复杂
OR同时覆盖两条访问路径。
实测中,不同身份的查询都回到 1 秒内。
UNION 还是 UNION ALL
UNION 会对结果做全局去重,通常需要额外排序或 Hash:
query_a
union
query_b;如果两个分支可以通过条件保证互斥,优先使用 UNION ALL,避免额外去重:
query_a
union all
query_b where not overlaps_with_a;无法证明互斥时,应先保证正确性,再考虑是否值得优化去重成本。
正确性验证
改写逻辑运算时,不能只对比 count(*)。建议同时检查双向差集:
with before_result as (
-- 原 OR 查询
),
after_result as (
-- UNION ALL 查询
)
select * from before_result
except
select * from after_result;再反向检查一次:
select * from after_result
except
select * from before_result;还需要覆盖以下边界:
- 当前用户同时出现在管理关系中;
user_id或manager_id存在空值;- 子查询返回空集或重复 ID;
- 参数类型与字段类型不一致,触发隐式转换;
- 小团队与大团队的数据分布差异。
什么时候值得尝试这个模式
当一条查询同时满足以下特征时,可以用 UNION ALL 做对照实验:
OR两侧是不同字段或不同访问路径;- 每个条件单独执行都能有效剪枝;
- 合并后出现全表扫描、外部执行或估算行数异常;
- 两个分支可以证明互斥,或可以显式排重。
通用改写模式如下:
-- 原始形式
select columns
from fact
where condition_a or condition_b;
-- 分支形式
select columns
from fact
where condition_a
union all
select columns
from fact
where condition_b
and not condition_a;not condition_a 需要根据 NULL 语义谨慎实现,不能机械照搬。
进一步优化
完成表达式改写后,还可以继续检查:
- 关系子查询是否只选择必要列并提前去重;
- JOIN 字段是否类型一致,避免运行时 Cast;
- 表统计信息是否最新;
- 分布键、聚簇键是否匹配高频过滤路径;
- 是否真的需要
select *; - 大范围关系能否预计算为临时表或结果表。
例如:
with managed_user as (
select distinct member_id
from social_relation
where root_id = :current_user_id
)
select f.required_column_a, f.required_column_b
from social_cart_fact f
join managed_user m on m.member_id = f.manager_id;总结
这次问题的根因不是"索引不存在",而是组合条件让执行计划无法使用原有的数据剪枝能力。把 OR 拆成两个 UNION ALL 分支后,每条路径都能独立优化,查询从约 30 秒恢复到 1 秒内。
面对类似问题,最可靠的步骤仍然是:单独执行每个条件、对比执行计划、改写访问路径、验证结果等价,最后再用真实数据压测。
评论(0)
还没有评论,来抢沙发吧 ✦