添加订单子句时不使用 GIN 索引

pan*_*ari 6 postgresql index full-text-search

我正在尝试加速执行三个ILIKE查询的查询并使用 or 减少这些查询(返回总计数和 10 个条目)

SELECT  *, count(*) OVER() as filtered_count FROM "users" 
WHERE (
   (f_unaccent("users"."first_name") ILIKE f_unaccent('%foo%') OR 
    f_unaccent("users"."last_name") ILIKE f_unaccent('%foo%')) OR 
    f_unaccent("users"."club_or_hometown") ILIKE f_unaccent('%foo%')
) LIMIT 10 OFFSET 0
Run Code Online (Sandbox Code Playgroud)

在为所有查询的属性添加 gin-indexes 后,这工作得相当快(这里仅适用于 first_name):

CREATE INDEX users_first_name_gin
ON users
USING gin
(f_unaccent(first_name::text) COLLATE pg_catalog."default" gin_trgm_ops);
Run Code Online (Sandbox Code Playgroud)

然而,如果我添加一个额外的 order 子句,例如ORDER BY users.first_name ASC,postgresql 不使用 gin 索引,而是使用正常的 b-tree 索引 on first_name,然后过滤结果。这在我的应用程序中需要更长的时间。即使对于有序查询,我如何调整查询/索引以继续使用 gin 索引?

编辑:我使用的是 postgresql 9.4

无序查询的解释:

"Limit  (cost=125.98..139.61 rows=10 width=58) (actual time=17.828..17.833 rows=10 loops=1)"
"  ->  WindowAgg  (cost=125.98..2972.72 rows=2088 width=58) (actual time=17.826..17.831 rows=10 loops=1)"
"        ->  Bitmap Heap Scan on users (cost=125.98..2946.62 rows=2088 width=58) (actual time=0.915..16.816 rows=1755 loops=1)"
"              Recheck Cond: ((f_unaccent((first_name)::text) ~~* '%foo%'::text) OR (f_unaccent((last_name)::text) ~~* '%foo%'::text) OR (f_unaccent((club_or_hometown)::text) ~~* '%foo%'::text))"
"              Heap Blocks: exact=891"
"              ->  BitmapOr  (cost=125.98..125.98 rows=2088 width=0) (actual time=0.742..0.742 rows=0 loops=1)"
"                    ->  Bitmap Index Scan on users_first_name_gin  (cost=0.00..51.80 rows=2074 width=0) (actual time=0.600..0.600 rows=1735 loops=1)"
"                          Index Cond: (f_unaccent((first_name)::text) ~~* '%foo%'::text)"
"                    ->  Bitmap Index Scan on users_last_name_gin  (cost=0.00..36.31 rows=8 width=0) (actual time=0.069..0.069 rows=20 loops=1)"
"                          Index Cond: (f_unaccent((last_name)::text) ~~* '%foo%'::text)"
"                    ->  Bitmap Index Scan on users_club_or_hometown_gin  (cost=0.00..36.29 rows=6 width=0) (actual time=0.072..0.072 rows=0 loops=1)"
"                          Index Cond: (f_unaccent((club_or_hometown)::text) ~~* '%foo%'::text)"
"Planning time: 0.791 ms"
"Execution time: 17.909 ms"
Run Code Online (Sandbox Code Playgroud)

有序查询说明:

"Limit  (cost=0.42..404.22 rows=10 width=58) (actual time=2363.902..2363.908 rows=10 loops=1)"
"  ->  WindowAgg  (cost=0.42..84314.74 rows=2088 width=58) (actual time=2363.900..2363.904 rows=10 loops=1)"
"        ->  Index Scan using index_users_on_first_name on users  (cost=0.42..84288.64 rows=2088 width=58) (actual time=132.873..2362.996 rows=1755 loops=1)"
"              Filter: ((f_unaccent((first_name)::text) ~~* '%foo%'::text) OR (f_unaccent((last_name)::text) ~~* '%foo%'::text) OR (f_unaccent((club_or_hometown)::text) ~~* '%foo%'::text))"
"              Rows Removed by Filter: 99646"
"Planning time: 0.937 ms"
"Execution time: 2363.989 ms"
Run Code Online (Sandbox Code Playgroud)

索引来自 \d users

"users_pkey" PRIMARY KEY, btree (id)
"index_users_on_club_or_hometown" btree (club_or_hometown)
"index_users_on_first_name" btree (first_name)
"index_users_on_last_name" btree (last_name)
"users_club_or_hometown_gin" gin (f_unaccent(club_or_hometown::text) gin_trgm_ops)
"users_first_name_gin" gin (f_unaccent(first_name::text) gin_trgm_ops)
"users_last_name_gin" gin (f_unaccent(last_name::text) gin_trgm_ops)
Run Code Online (Sandbox Code Playgroud)

编辑2:

当使用 禁用索引扫描时set enable_indexscan = off;,postgres 再次使用正确的索引:

"Limit  (cost=3273.20..3273.22 rows=10 width=58) (actual time=32.231..32.231 rows=10 loops=1)"
"  ->  Sort  (cost=3273.20..3279.30 rows=2442 width=58) (actual time=32.229..32.229 rows=10 loops=1)"
"        Sort Key: first_name"
"        Sort Method: top-N heapsort  Memory: 26kB"
"        ->  WindowAgg  (cost=128.90..3220.43 rows=2442 width=58) (actual time=29.982..30.735 rows=2655 loops=1)"
"              ->  Bitmap Heap Scan on users  (cost=128.90..3189.90 rows=2442 width=58) (actual time=1.323..28.260 rows=2655 loops=1)"
"                    Recheck Cond: ((f_unaccent((first_name)::text) ~~* '%foo%'::text) OR (f_unaccent((last_name)::text) ~~* '%foo%'::text) OR (f_unaccent((club_or_hometown)::text) ~~* '%foo%'::text))"
"                    Heap Blocks: exact=1057"
"                    ->  BitmapOr  (cost=128.90..128.90 rows=2443 width=0) (actual time=1.099..1.099 rows=0 loops=1)"
"                          ->  Bitmap Index Scan on users_first_name_gin  (cost=0.00..54.46 rows=2428 width=0) (actual time=0.961..0.961 rows=2647 loops=1)"
"                                Index Cond: (f_unaccent((first_name)::text) ~~* '%foo%'::text)"
"                          ->  Bitmap Index Scan on users_last_name_gin  (cost=0.00..36.31 rows=8 width=0) (actual time=0.066..0.066 rows=7 loops=1)"
"                                Index Cond: (f_unaccent((last_name)::text) ~~* '%foo%'::text)"
"                          ->  Bitmap Index Scan on users_club_or_hometown_gin  (cost=0.00..36.29 rows=6 width=0) (actual time=0.071..0.071 rows=1 loops=1)"
"                                Index Cond: (f_unaccent((club_or_hometown)::text) ~~* '%foo%'::text)"
"Planning time: 0.803 ms"
"Execution time: 32.292 ms"
Run Code Online (Sandbox Code Playgroud)

Erw*_*ter 6

您添加的评论已经在正确的轨道上:

我进一步发现,只有当搜索查询(在我的示例中pg_stats.most_common_vals 为foo)出现在该列中时,才会使用低效索引。所以我认为这会使估计的成本向错误的方向倾斜。任何想法如何解决这一问题?

如果 'foo' 很常见,Postgres 期望它更快地从 b 树索引中读取并跳过不匹配的行。该估计还基于成本设置和谓词的预期选择性。偏斜估计有多个切入点。

  • 统计信息中的行数已关闭。
  • 谓词的选择性估计已关闭。
  • 多个谓词组合的效果判断错误。
  • 成本设置已关闭。

谓词的选择性及其组合

你最重要的问题似乎在这里:

(cost=0.42..84,314.74 rows=2,088 width=58) (实际时间=2,363.900..2,363.904 rows=10 loops=1)

Postgres 期望返回的行数是其209 倍。Explain.depesz.com 可以帮助审核查询计划:http : //explain.depesz.com/s/53E

您可以通过增加索引列的统计目标来获得更准确的估计。Postgres 不仅收集表列的统计信息,它还为索引表达式收集统计信息(不适用于已提供统计信息的普通索引列)。细节:

您可以通过以下方式检查:

SELECT * FROM pg_stats WHERE tablename = 'users_first_name_gin';
Run Code Online (Sandbox Code Playgroud)

基本行数和设置在pg_class和 中pg_attribute

SELECT *
FROM   pg_attribute
WHERE  attrelid = 'users_last_name_gin'::regclass;

SELECT *
FROM   pg_class
WHERE  oid = 'users_last_name_gin'::regclass;
Run Code Online (Sandbox Code Playgroud)

索引在内部被视为特殊表。这应该使您可以ALTER TABLE在索引上使用不那么令人惊讶:

ALTER TABLE users_last_name_gin ALTER COLUMN f_unaccent SET STATISTICS 1000;
Run Code Online (Sandbox Code Playgroud)

使用它可以增加用于计算索引列统计信息的样本大小。然后运行ANALYZE users以更新统计信息。

您不能为索引条目提供明确的列名,名称是自动选择的。您可以使用pg_attribute上面的查询来查找它。列名f_unaccent源自使用的函数名。

默认统计目标为 100,允许范围为 0 - 10000。对于非常大的表,100 通常不足以获得合理的估计。将索引表达式设置为 1000(示例)以获得更好的估计。

解决方法

就像dezso 评论的那样,您可以通过将原始表单封装在 CTE(在 Postgres 中充当优化屏障)中来获得替代查询计划 - 在外部之前ORDER BYLIMIT之中SELECT

WITH cte AS (
   SELECT *, count(*) OVER() AS filtered_count
   FROM   users 
   WHERE (f_unaccent("users"."first_name")       ILIKE f_unaccent('%foo%') OR 
          f_unaccent("users"."last_name")        ILIKE f_unaccent('%foo%') OR 
          f_unaccent("users"."club_or_hometown") ILIKE f_unaccent('%foo%'))
   )
SELECT *
FROM   cte
ORDER  BY first_name
LIMIT  10;
Run Code Online (Sandbox Code Playgroud)

替代索引

既然你评论了:

我一直在搜索所有三列

所有三个的单一索引会更便宜。GIN 索引可以是多列索引。文档:

目前只有 B-tree、GiST 和 GIN 索引类型支持多列索引。

和:

多列 GIN 索引可用于涉及索引列的任何子集的查询条件。与 B-tree 或 GiST 不同,无论查询条件使用哪个索引列,索引搜索的有效性都是相同的。

所以:

CREATE INDEX big_unaccent_big_gin_idx ON users USING gin (
     f_unaccent(first_name)       gin_trgm_ops
   , f_unaccent(last_name)        gin_trgm_ops
   , f_unaccent(club_or_hometown) gin_trgm_ops);
Run Code Online (Sandbox Code Playgroud)

这会将每个索引条目的三重开销减少到只有一个。整体应该更快。或者,更快地将所有三列连接成一个字符串。我正在添加一个空格作为分隔符以避免误报。使用任何不会出现在搜索表达式中的字符作为分隔符:

CREATE INDEX big_unaccent_big_gin_idx ON users USING gin (
   f_unaccent(concat_ws(' ', first_name, last_name, club_or_hometown)) gin_trgm_ops);
Run Code Online (Sandbox Code Playgroud)

如果所有列都是NOT NULL,则可以改用普通串联:

first_name || ' ' || last_name || ' ' || club_or_hometown
Run Code Online (Sandbox Code Playgroud)

确保在查询中使用相同的表达式

WHERE f_unaccent(concat_ws(' ', first_name, last_name, club_or_hometown)) ILIKE '%foo%'
Run Code Online (Sandbox Code Playgroud)

在再次测试之前设置STATISTICS为 1000 或更多,如上所示ANALYZE。确保多次运行查询以比较热缓存和热缓存。

除了较小的索引和更快的计算之外,您的案例的主要好处可能是单个谓词不太容易受到成本估计错误的影响。组合多个谓词会增加计算错误。

强制查询计划

如果所有其他方法都失败了,您可以通过暂时禁用索引扫描来强制执行我在评论中建议的首选查询计划。请记住,这是一个邪恶的 hack,如果底层数据分布发生变化或升级到下一个 Postgres 版本,它可能会反击:

使用SET LOCAL来限制对交易的影响,并包装在一个明确的事务整个事情。

BEGIN;
SET LOCAL enable_indexscan = off;
SELECT  ...  -- your query here
COMMIT;
Run Code Online (Sandbox Code Playgroud)