返回文章列表

Hologres 中 OR + IN 的性能陷阱:从 30 秒到 1 秒内

一条同时包含 OR 和 IN 子查询的 Hologres SQL,让千万级事实表失去 RowGroupFilter。本文通过 EXPLAIN 定位全表扫描,并用语义等价的 UNION ALL 恢复分支剪枝。

2026年8月23日5 分钟读完WhiteEnzuo

问题背景

一个社群查询需要同时返回两类数据:

  1. 当前用户自己的记录;
  2. 当前用户所管理成员的记录。

事实表在 user_idmanager_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 中的 ORIN 一定慢,而是这组表达式、数据分布与当前执行计划组合后无法有效剪枝。判断依据必须是实际 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_idmanager_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)

还没有评论,来抢沙发吧 ✦

WhiteEnzuo

已暂停

--:--
--:--
50%