【问题标题】:Can a LEFT JOIN be deferred to only apply to matching rows?可以将 LEFT JOIN 推迟到仅适用于匹配的行吗?
【发布时间】:2021-07-16 11:10:22
【问题描述】:

当加入一个表然后过滤(例如 LIMIT 30)时,Postgres 将对所有行应用一个 JOIN 操作,即使这些行中的列仅用于返回的列,而不是作为过滤谓词。

这对于 INNER JOIN(PG 必须知道是否返回该行)或没有唯一约束的 LEFT JOIN(PG 必须知道是否返回多行)是可以理解的,但是对于 UNIQUE 列上的 LEFT JOIN,这似乎很浪费:如果查询匹配 10k 行,则将执行 10k 连接,然后只返回 30。

尽可能“延迟”或推迟连接似乎更有效,这是我在其他一些查询中看到的情况。

将其拆分为子查询 (SELECT * FROM (SELECT * FROM main WHERE x LIMIT 30) LEFT JOIN secondary) 可以通过确保在加入主表之前只返回 30 个项目,但感觉就像我遗漏了一些东西,以及查询的“标准”形式也应该应用相同的优化。

但是,查看 EXPLAIN 计划,我可以看到加入的行数始终是总行数,而不是“提前退出”,例如,在运行带有 LIMIT 的 Seq Scan 时5.

具有main 表和secondary 1 的示例架构:只会返回辅助列,从不过滤。

drop table if exists secondary;
drop table if exists main;

create table main(id int primary key not null, main_column int);
create index main_column on main(main_column);
insert into main(id, main_column) SELECT i, i % 3000 from generate_series( 1, 1000000, 1) i;
create table secondary(id serial primary key not null, main_id int references main(id) not null, secondary_column int);
create unique index secondary_main_id on secondary(main_id);
insert into secondary(main_id, secondary_column) SELECT i, (i + 17) % 113 from generate_series( 1, 1000000, 1) i;

analyze main;
analyze secondary;

查询示例:

explain analyze verbose select main.id, main_column, secondary_column
from main
left join secondary on main.id = secondary.main_id
where main_column = 5
order by main.id
limit 50;

这是编写查询最“明显”的方式,在我的计算机上平均需要大约 5 毫秒。

解释:

Limit  (cost=3742.93..3743.05 rows=50 width=12) (actual time=5.010..5.322 rows=50 loops=1)
  Output: main.id, main.main_column, secondary.secondary_column
  ->  Sort  (cost=3742.93..3743.76 rows=332 width=12) (actual time=5.006..5.094 rows=50 loops=1)
        Output: main.id, main.main_column, secondary.secondary_column
        Sort Key: main.id
        Sort Method: top-N heapsort  Memory: 27kB
        ->  Nested Loop Left Join  (cost=11.42..3731.90 rows=332 width=12) (actual time=0.123..4.446 rows=334 loops=1)
              Output: main.id, main.main_column, secondary.secondary_column
              Inner Unique: true
              ->  Bitmap Heap Scan on public.main  (cost=11.00..1036.99 rows=332 width=8) (actual time=0.106..1.021 rows=334 loops=1)
                    Output: main.id, main.main_column
                    Recheck Cond: (main.main_column = 5)
                    Heap Blocks: exact=334
                    ->  Bitmap Index Scan on main_column  (cost=0.00..10.92 rows=332 width=0) (actual time=0.056..0.057 rows=334 loops=1)
                          Index Cond: (main.main_column = 5)
              ->  Index Scan using secondary_main_id on public.secondary  (cost=0.42..8.12 rows=1 width=8) (actual time=0.006..0.006 rows=1 loops=334)
                    Output: secondary.id, secondary.main_id, secondary.secondary_column
                    Index Cond: (secondary.main_id = main.id)
Planning Time: 0.761 ms
Execution Time: 5.423 ms
explain analyze verbose select m.id, main_column, secondary_column
from (
    select main.id, main_column
    from main
    where main_column = 5
    order by main.id
    limit 50
) m
left join secondary on m.id = secondary.main_id
where main_column = 5
order by m.id
limit 50

这会在 2 毫秒内返回相同的结果。 总 EXPLAIN 成本也高出三倍,与我们看到的性能提升一致。

Limit  (cost=1048.44..1057.21 rows=1 width=12) (actual time=1.219..2.027 rows=50 loops=1)
  Output: m.id, m.main_column, secondary.secondary_column
  ->  Nested Loop Left Join  (cost=1048.44..1057.21 rows=1 width=12) (actual time=1.216..1.900 rows=50 loops=1)
        Output: m.id, m.main_column, secondary.secondary_column
        Inner Unique: true
        ->  Subquery Scan on m  (cost=1048.02..1048.77 rows=1 width=8) (actual time=1.201..1.515 rows=50 loops=1)
              Output: m.id, m.main_column
              Filter: (m.main_column = 5)
              ->  Limit  (cost=1048.02..1048.14 rows=50 width=8) (actual time=1.196..1.384 rows=50 loops=1)
                    Output: main.id, main.main_column
                    ->  Sort  (cost=1048.02..1048.85 rows=332 width=8) (actual time=1.194..1.260 rows=50 loops=1)
                          Output: main.id, main.main_column
                          Sort Key: main.id
                          Sort Method: top-N heapsort  Memory: 27kB
                          ->  Bitmap Heap Scan on public.main  (cost=11.00..1036.99 rows=332 width=8) (actual time=0.054..0.753 rows=334 loops=1)
                                Output: main.id, main.main_column
                                Recheck Cond: (main.main_column = 5)
                                Heap Blocks: exact=334
                                ->  Bitmap Index Scan on main_column  (cost=0.00..10.92 rows=332 width=0) (actual time=0.029..0.030 rows=334 loops=1)
                                      Index Cond: (main.main_column = 5)
        ->  Index Scan using secondary_main_id on public.secondary  (cost=0.42..8.44 rows=1 width=8) (actual time=0.004..0.004 rows=1 loops=50)
              Output: secondary.id, secondary.main_id, secondary.secondary_column
              Index Cond: (secondary.main_id = m.id)
Planning Time: 0.161 ms
Execution Time: 2.115 ms

这里是一个玩具数据集,但是在真实的 DB 上,IO 差异很大(30 行就足够了,不需要取 1000 行),而且时序差异也很快加起来(慢了一个数量级) )。

所以我的问题是:有什么方法可以让规划人员了解 JOIN 可以在流程的后期应用吗? 它似乎可以自动应用来获得相当大的性能提升。

【问题讨论】:

  • 您确实已经自己回答了标题中的问题:是的,使用子查询。
  • 旁白:两个查询不等价。您必须在外部查询中重复 order by main.id 以保证排序顺序。添加的LEFT JOIN 可能会改变行的顺序。
  • @ErwinBrandstetter 我的问题更多:为什么 PG 不自动执行此操作,而模式中的所有信息都可以有效地自动执行此子查询?
  • 子查询中的LIMIT 50与外部查询中的LIMIT 50不同。
  • @wildplasser 它是,因为它是 LEFT JOIN(所以 1+ 行)并且是唯一的(所以最多 1 行);所以具有唯一左连接的 50 行将始终为 50 行(可能带有 NULL)

标签: postgresql left-join query-optimization


【解决方案1】:

延迟连接很好。对仅产生 id 值的子查询运行限制操作通常很有帮助order by....limit 操作必须对较少的数据进行排序才能丢弃它。

select main.id, main.main_column, secondary.secondary_column
from main
join (
       select id
         from main
        where main_column = 5
        order by id
        limit 50
     ) selection on main.id = selection.id
left join secondary on main.id = secondary.main_id
order by main.id
limit 50

也可以将id 添加到您的 main_column 索引中会有所帮助。使用 BTREE 索引,查询规划器知道它可以从索引中按升序获取 id 值,因此它可以完全跳过排序步骤,只扫描前 50 个值。

create index main_column on main(main_column, id); 

编辑 在一个大表中,查询的繁重工作将是选择要处理的 50 个 main.id 值。为了尽可能便宜地获得这 50 个 id 值,您可以使用我提议的子查询的覆盖索引扫描。获得 50 个 id 值后,通过 main.idsecondary.main_id 从各种表中查找 50 行的详细信息是微不足道的;您有正确的索引,并且行数有限。因为它的行数有限,所以不会花费太多时间。

不过,您的表大小似乎太小,无法进行各种优化以产生太大效果。当表更大时,查询计划会发生很大变化。

【讨论】:

  • 谢谢,这确实是一个玩具数据集。我不能使用 IndexOnlyScan,因为实际上,我需要 8-10 列来自 main 和相同的辅助列,并且索引的大小将大于 500gb(遗憾的是已经测试过)。
  • (关于仅加载 id 列:正确,但我想检查稍后重新访问索引以获取 main.main_column 的成本是否值得我将获得的速度提升在排序中。在您的示例中,未检索 main.main_column。但我明白了一般的想法,并同意它)
  • 请看我的编辑。 main.main_column 实际上是由我提出的查询检索到的,而不是由子查询检索到的。
  • @Neamar (main_column, id) 上的索引的重点不是它提供仅索引扫描,而是它允许避免排序。您可以轻松地向表中添加一个额外的列并查询以击败 IOS 并查看此操作。计划者愿意在它不愿意通过嵌套循环向下推动排序的情况下利用排序索引。您可以要求改进规划器,但最好将其发送到 pgsql-hackers 邮件列表,而不是 SO。
  • @O.Jones 我认为在特定大小下唯一的优化是 JIT 编译。其他优化可能仅在特定大小时才有价值,但这是成本估算中的一部分。因此,它们会根据成本被考虑和拒绝,而不仅仅是不被考虑。计划者不会考虑所有可能的方式来运行计划,但如果它不考虑某些可能性,它就不会仅仅因为规模增加而开始考虑它。
【解决方案2】:

替代查询,使用 row_number() 而不是 LIMIT(我认为您甚至可以在这里省略 LIMIT):


-- prepare q3 AS
select m.id, main_column, secondary_column
from (
    select id, main_column
        , row_number() OVER (ORDER BY id, main_column) AS rn
    from main
    where main_column = 5
) m
left join secondary on m.id = secondary.main_id
WHERE m.rn <= 50
ORDER BY m.id
LIMIT 50
        ;

将子集放入 CTE 可以避免它被合并到主查询中:


PREPARE q6 AS
WITH
-- MATERIALIZED -- not needed before version 12
xxx AS (
        SELECT DISTINCT x.id
        FROM main x
        WHERE x.main_column = 5
        ORDER BY x.id
        LIMIT 50
        )
select m.id, m.main_column, s.secondary_column
from main m
left join secondary s on m.id = s.main_id
WHERE EXISTS (
        SELECT *
        FROM xxx x WHERE x.id = m.id
        )
order by m.id
-- limit 50
        ;

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-11-18
    • 2015-03-07
    • 1970-01-01
    • 1970-01-01
    • 2013-03-15
    • 2012-04-05
    相关资源
    最近更新 更多