如何使 postgres 避免对此搜索分页查询进行双重顺序扫描?

Posted

技术标签:

【中文标题】如何使 postgres 避免对此搜索分页查询进行双重顺序扫描?【英文标题】:How do I make postgres avoid doing a double sequential scan for this seek pagination query? 【发布时间】:2020-06-20 14:11:30 【问题描述】:

架构

我有一堆帖子存储在一个表中 (feed_items) 我有一个表格,其中包含喜欢/不喜欢哪个用户 ID 的 feed_item_id (feed_item_likes_dislikes) 我有另一个表,其中包含哪个用户 id 喜欢/激怒了哪个 feed_item_id (feed_item_love_anger) 我有第四个表,其中包含哪个 feed_item_id 有哪些标签,其中 tags 是 varchar 的 ARRAY (feed_item_tags) 每个帖子的喜欢/不喜欢的总数存储在具体化视图中 (feed_item_likes_dislikes_aggregate) 爱/愤怒的总数存储在另一个物化视图中 (feed_item_love_anger_agregate) 喜欢/不喜欢/喜欢/生气可以分开存储,因为可以同时喜欢/不喜欢和喜欢/生气(不幸的是业务需求) 我在 feed_items 中有 2 列名为 TSVECTOR 的 title_vector 和 summary_vector 列,这有助于按搜索关键字查找帖子(postgres 中的全文搜索)

问题

我想按照发布日期和 feed_item_id 的降序查找所有帖子 部分帖子同时发布,我想分页使用(pubdate, feed_item_id) HERE

我的第 1 页查询

查找喜欢 > 0 且标题或摘要中带有“骗局”一词的帖子

SELECT
  fi.feed_item_id,
  pubdate,
  link,
  title,
  summary,
  author,
  feed_id,
  likes,
  dislikes,
  love,
  anger,
  tags 
FROM
  feed_items fi 
  LEFT JOIN
    feed_item_tags t 
    ON fi.feed_item_id = t.feed_item_id 
  LEFT JOIN
    feed_item_love_anger_aggregate bba 
    ON fi.feed_item_id = bba.feed_item_id 
  LEFT JOIN
    feed_item_likes_dislikes_aggregate lda 
    ON fi.feed_item_id = lda.feed_item_id 
WHERE
  (
    title_vector @@ to_tsquery('scam') 
    OR summary_vector @@ to_tsquery('scam')
  )
  AND 'for' = ANY(tags) 
  AND likes > 0 
ORDER BY
  pubdate DESC,
  feed_item_id DESC LIMIT 3;

解释分析第 1 页

 Limit  (cost=2.83..16.88 rows=3 width=233) (actual time=0.075..0.158 rows=3 loops=1)
   ->  Nested Loop Left Join  (cost=2.83..124.53 rows=26 width=233) (actual time=0.074..0.157 rows=3 loops=1)
         ->  Nested Loop  (cost=2.69..116.00 rows=26 width=217) (actual time=0.067..0.146 rows=3 loops=1)
               Join Filter: (t.feed_item_id = fi.feed_item_id)
               Rows Removed by Join Filter: 73
               ->  Index Scan using idx_feed_items_pubdate_feed_item_id_desc on feed_items fi  (cost=0.14..68.77 rows=76 width=62) (actual time=0.016..0.023 rows=3 loops=1)
                     Filter: ((title_vector @@ to_tsquery('scam'::text)) OR (summary_vector @@ to_tsquery('scam'::text)))
                     Rows Removed by Filter: 1
               ->  Materialize  (cost=2.55..8.56 rows=34 width=187) (actual time=0.016..0.037 rows=25 loops=3)
                     ->  Hash Join  (cost=2.55..8.39 rows=34 width=187) (actual time=0.044..0.091 rows=36 loops=1)
                           Hash Cond: (t.feed_item_id = lda.feed_item_id)
                           ->  Seq Scan on feed_item_tags t  (cost=0.00..5.25 rows=67 width=155) (actual time=0.009..0.043 rows=67 loops=1)
                                 Filter: ('for'::text = ANY ((tags)::text[]))
                                 Rows Removed by Filter: 33
                           ->  Hash  (cost=1.93..1.93 rows=50 width=32) (actual time=0.029..0.029 rows=50 loops=1)
                                 Buckets: 1024  Batches: 1  Memory Usage: 12kB
                                 ->  Seq Scan on feed_item_likes_dislikes_aggregate lda  (cost=0.00..1.93 rows=50 width=32) (actual time=0.004..0.013 rows=50 loops=1)
                                       Filter: (likes > 0)
                                       Rows Removed by Filter: 24
         ->  Index Scan using idx_feed_item_love_anger_aggregate on feed_item_love_anger_aggregate bba  (cost=0.14..0.32 rows=1 width=32) (actual time=0.002..0.003 rows=0 loops=3)
               Index Cond: (feed_item_id = fi.feed_item_id)
 Planning Time: 0.601 ms
 Execution Time: 0.195 ms
(23 rows)

尽管所有表都有适当的索引,但它正在执行 2 次顺序扫描

我的页面 N 查询

从上述查询中获取第三个结果的发布日期和 feed_item_id 并加载接下来的 3 个结果

SELECT
  fi.feed_item_id,
  pubdate,
  link,
  title,
  summary,
  author,
  feed_id,
  likes,
  dislikes,
  love,
  anger,
  tags 
FROM
  feed_items fi 
  LEFT JOIN
    feed_item_tags t 
    ON fi.feed_item_id = t.feed_item_id 
  LEFT JOIN
    feed_item_love_anger_aggregate bba 
    ON fi.feed_item_id = bba.feed_item_id 
  LEFT JOIN
    feed_item_likes_dislikes_aggregate lda 
    ON fi.feed_item_id = lda.feed_item_id 
WHERE
  (
    pubdate,
    fi.feed_item_id
  )
  < ('2020-06-19 19:50:00+05:30', 'bc5c8dfe-13a9-d97a-a328-0e5b8990c500') 
  AND 
  (
    title_vector @@ to_tsquery('scam') 
    OR summary_vector @@ to_tsquery('scam')
  )
  AND 'for' = ANY(tags) 
  AND likes > 0 
ORDER BY
  pubdate DESC,
  feed_item_id DESC LIMIT 3;

解释第 N 页查询 尽管进行了过滤,但它正在执行 2 次顺序扫描

 Limit  (cost=2.83..17.13 rows=3 width=233) (actual time=0.082..0.199 rows=3 loops=1)
   ->  Nested Loop Left Join  (cost=2.83..121.97 rows=25 width=233) (actual time=0.081..0.198 rows=3 loops=1)
         ->  Nested Loop  (cost=2.69..113.67 rows=25 width=217) (actual time=0.073..0.185 rows=3 loops=1)
               Join Filter: (t.feed_item_id = fi.feed_item_id)
               Rows Removed by Join Filter: 183
               ->  Index Scan using idx_feed_items_pubdate_feed_item_id_desc on feed_items fi  (cost=0.14..67.45 rows=74 width=62) (actual time=0.014..0.034 rows=6 loops=1)
                     Index Cond: (ROW(pubdate, feed_item_id) < ROW('2020-06-19 19:50:00+05:30'::timestamp with time zone, 'bc5c8dfe-13a9-d97a-a328-0e5b8990c500'::uuid))
                     Filter: ((title_vector @@ to_tsquery('scam'::text)) OR (summary_vector @@ to_tsquery('scam'::text)))
                     Rows Removed by Filter: 2
               ->  Materialize  (cost=2.55..8.56 rows=34 width=187) (actual time=0.009..0.022 rows=31 loops=6)
                     ->  Hash Join  (cost=2.55..8.39 rows=34 width=187) (actual time=0.050..0.098 rows=36 loops=1)
                           Hash Cond: (t.feed_item_id = lda.feed_item_id)
                           ->  Seq Scan on feed_item_tags t  (cost=0.00..5.25 rows=67 width=155) (actual time=0.009..0.044 rows=67 loops=1)
                                 Filter: ('for'::text = ANY ((tags)::text[]))
                                 Rows Removed by Filter: 33
                           ->  Hash  (cost=1.93..1.93 rows=50 width=32) (actual time=0.028..0.029 rows=50 loops=1)
                                 Buckets: 1024  Batches: 1  Memory Usage: 12kB
                                 ->  Seq Scan on feed_item_likes_dislikes_aggregate lda  (cost=0.00..1.93 rows=50 width=32) (actual time=0.005..0.014 rows=50 loops=1)
                                       Filter: (likes > 0)
                                       Rows Removed by Filter: 24
         ->  Index Scan using idx_feed_item_love_anger_aggregate on feed_item_love_anger_aggregate bba  (cost=0.14..0.32 rows=1 width=32) (actual time=0.003..0.003 rows=1 loops=3)
               Index Cond: (feed_item_id = fi.feed_item_id)
 Planning Time: 0.596 ms
 Execution Time: 0.236 ms
(24 rows)

LINK TO THE FIDDLE

我已经设置了所需的表和索引,有人可以告诉我如何修复查询以最多使用索引扫描或将顺序扫描的数量减少到 1?

【问题讨论】:

如果您无论如何要返回表的 2/3,则通常使用索引是有意义的。创建实际大小的数据集进行测试。 @jjanes 我只想返回 3 行以防您看到查询 无论你想返回多少行给客户端,重要的是需要将表的多少行返回给更高级别的节点。 但是如果您删除搜索或标签,它不再进行顺序扫描,它只是不进行索引连接,只有当搜索和标签都存在时,它才会变得混乱 不知道是什么意思,如果去掉标签条件,它仍然会对标签表进行seq扫描。它还能在未索引的表上做什么?如果您完全删除对标记表的连接,当然它不会对查询中不再提及的表进行 seq 扫描。而且我不会将在不到 1 毫秒内完成的查询称为失控。 【参考方案1】:

除了 GIN 索引之外,您目前在 tags 表上没有其他索引。在你的小提琴中,如果我 create index on feed_item_tags (feed_item_id) 并执行 ANALYZE,那么两个 seq 扫描都会消失。这样做可能比重新制定它可以使用 GIN 索引更好,就像我的其他答案一样,因为这种方式可以更有效地利用 LIMIT 提前停止的前景。

但实际上,“feed_item_tags”表的意义何在?如果您将有一个子表来列出标签,通常每行有一个标签/父 ID 组合。如果你想要一个标签数组而不是它们的一列,为什么不直接将数组粘贴到父表中呢?有时有理由让两个表之间具有 1:1 关系的表,但不是很常见。

【讨论】:

非常感谢!在生产数据库上测试,没有任何顺序扫描,就像一个魅力,希望我能不止一次地投票【参考方案2】:

构造 'for' = ANY(tags) 不能使用 GIN 索引。为了能够使用它,您需要将其重新表述为 'for' &lt;@ tags 之类的内容。

但是,它会选择不使用索引,因为表太小而且条件太无选择性。如果你想强制使用索引,以证明它有能力这样做,你可以先set enable_seqscan=off

【讨论】:

赞那个!这实际上减少了有关标签的表中的顺序扫描

以上是关于如何使 postgres 避免对此搜索分页查询进行双重顺序扫描?的主要内容,如果未能解决你的问题,请参考以下文章

Oracle分页查询语句的写法(转)

如何使 postgres 快速搜索

如何对搜索结果进行分页?

Oracle中分页查询语句

分页查询如何避免幻读

Django Admin 搜索查询未命中 Postgres 索引