为什么搜索词的微小变化会大大降低查询速度?

Why does a slight change in the search term slow down the query so much?

我在 PostgreSQL (9.5.1) 中有以下查询:

select e.id, (select count(id) from imgitem ii where ii.tabid = e.id and ii.tab = 'esp') as imgs,
 e.ano, e.mes, e.dia, cast(cast(e.ano as varchar(4))||'-'||right('0'||cast(e.mes as varchar(2)),2)||'-'|| right('0'||cast(e.dia as varchar(2)),2) as varchar(10)) as data,
 pl.pltag, e.inpa, e.det, d.ano anodet, coalesce(p.abrev,'')||' ('||coalesce(p.prenome,'')||')' determinador, d.tax, coalesce(v.val,v.valf)||' '||vu.unit as altura,
 coalesce(v1.val,v1.valf)||' '||vu1.unit as DAP, d.fam, tf.nome família, d.gen, tg.nome gênero, d.sp, ts.nome espécie, d.inf, e.loc, l.nome localidade, e.lat, e.lon
from esp e
left join det d on e.det = d.id
left join tax tf on d.fam = tf.oldfam
left join tax tg on d.gen = tg.oldgen
left join tax ts on d.sp = ts.oldsp
left join tax ti on d.inf = ti.oldinf
left join loc l on e.loc = l.id
left join pess p on p.id = d.detby
left join var v on v.esp = e.id and v.key = 265
left join varunit vu on vu.id = v.unit
left join var v1 on v1.esp = e.id and v1.key = 264
left join varunit vu1 on vu1.id = v1.unit
left join pl on pl.id = e.pl
WHERE unaccent(TEXT(coalesce(p.abrev,'')||' ('||coalesce(p.prenome,'')||')')) ilike unaccent('%vicen%')

esp table.

中的总共 9250 行中检索 1129 行需要 430 毫秒

如果我将搜索词从 %vicen% 更改为 %vicent%(添加 't'),检索相同的 1129 行需要 431 毫秒。

按搜索列排序,升序和降序,我看到所有 1129 行在这两种情况下都具有完全相同的名称。

现在很奇怪:如果我将搜索词从 %vicent% 更改为 %vicenti%(添加 'i'),现在需要 24.4 秒 检索相同的 1129 行!

搜索的词总是在第一个coalesce,即coalesce(p.abrev,'')。我希望查询 运行 变慢或变快,具体取决于搜索字符串的大小,但不会太大!有人知道发生了什么事吗?

EXPLAIN ANALYZE 的结果(此处将超过 30k 个字符的限制):

对于%vicen%http://explain.depesz.com/s/2XF

对于%vicenti%http://explain.depesz.com/s/dEc6

为什么?

原因是这样的:

快速查询:

->  Hash Left Join  (cost=1378.60..2467.48 rows=15 width=79) (actual time=41.759..85.037 rows=1129 loops=1)
      ...
      Filter: (unaccent(((((COALESCE(p.abrev, ''::character varying))::text || ' ('::text) || (COALESCE(p.prenome, ''::character varying))::text) || ')'::text)) ~~* (...)

慢查询:

->  Hash Left Join  (cost=1378.60..2467.48 rows=1 width=79) (actual time=35.084..80.209 rows=1129 loops=1)
      ...
      Filter: (unaccent(((((COALESCE(p.abrev, ''::character varying))::text || ' ('::text) || (COALESCE(p.prenome, ''::character varying))::text) || ')'::text)) ~~* unacc (...)

用另一个字符扩展搜索模式会导致 Postgres 假定更少的命中。 (通常,这是一个合理的估计。)Postgres 显然没有足够精确的统计数据(none,实际上,请继续阅读)来期望与实际获得的命中数相同。

这会导致切换到不同的查询计划,这对于 实际 的命中数来说更不理想 rows=1129.

解决方案

假设当前的 Postgres 9.5 尚未声明。

改善这种情况的一种方法是在谓词中的表达式上创建一个表达式索引。这使得 Postgres 收集实际表达式的统计信息,这可以帮助查询 即使索引本身不用于查询 。没有索引,没有表达式的统计数据。如果做得好,索引可以用于查询,那就更好了。但是您当前的表达式存在多个问题

unaccent(TEXT(coalesce(p.abrev,'')||' ('||coalesce(p.prenome,'')||')')) ilike unaccent('%vicen%')

根据关于您未公开的 table 定义的一些假设,考虑这个更新的查询:

SELECT e.id
     , (SELECT count(*) FROM imgitem
        WHERE tabid = e.id AND tab = 'esp') AS imgs -- count(*) is faster
     , e.ano, e.mes, e.dia
     , e.ano::text || to_char(e.mes2, 'FM"-"00')
                   || to_char(e.dia,  'FM"-"00') AS data    
     , pl.pltag, e.inpa, e.det, d.ano anodet
     , format('%s (%s)', p.abrev, p.prenome) AS determinador
     , d.tax
     , coalesce(v.val,v.valf)   || ' ' || vu.unit  AS altura
     , coalesce(v1.val,v1.valf) || ' ' || vu1.unit AS dap
     , d.fam, tf.nome família, d.gen, tg.nome AS gênero, d.sp
     , ts.nome AS espécie, d.inf, e.loc, l.nome localidade, e.lat, e.lon
FROM      pess    p                        -- reorder!
JOIN      det     d   ON d.detby   = p.id  -- INNER JOIN !
LEFT JOIN tax     tf  ON tf.oldfam = d.fam
LEFT JOIN tax     tg  ON tg.oldgen = d.gen
LEFT JOIN tax     ts  ON ts.oldsp  = d.sp
<strike>LEFT JOIN tax     ti  ON ti.oldinf = d.inf</strike>  -- unused, see @joop's comment
LEFT JOIN esp     e   ON e.det     = d.id
LEFT JOIN loc     l   ON l.id      = e.loc
LEFT JOIN var     v   ON v.esp     = e.id AND v.key  = 265
LEFT JOIN varunit vu  ON vu.id     = v.unit
LEFT JOIN var     v1  ON v1.esp    = e.id AND v1.key = 264
LEFT JOIN varunit vu1 ON vu1.id    = v1.unit
LEFT JOIN pl          ON pl.id     = e.pl
WHERE f_unaccent(p.abrev)   ILIKE f_unaccent('%' || 'vicenti' || '%') OR
      f_unaccent(p.prenome) ILIKE f_unaccent('%' || 'vicenti' || '%');

要点

为什么f_unaccent()?因为 unaccent() 不能被索引。读这个:

  • Does PostgreSQL support "accent insensitive" collations?

我使用那里概述的函数允许以下(推荐!)多列功能三元组 GIN index:

CREATE INDEX pess_unaccent_nome_trgm_idx ON pess
USING gin (f_unaccent(pess) gin_trgm_ops, f_unaccent(prenome) gin_trgm_ops);

如果您不熟悉三元组索引,请先阅读此内容:

  • PostgreSQL LIKE query performance variations

并且可能:

确保 运行 最新版本的 Postgres(当前为 9.5)。 GIN 索引有了实质性的改进。您会对 pg_trgm 1.2 的改进感兴趣,计划与即将发布的 Postgres 9.6 一起发布:


准备好的语句 是使用参数(尤其是用户输入的文本)执行查询的常用方法。 Postgres 必须找到一个最适合任何给定参数的计划。将 通配符作为常量 添加到搜索词中,如下所示:

f_unaccent(p.abrev) ILIKE f_unaccent(<b>'%' || 'vicenti' || '%'</b>)

'vicenti' 将被替换为参数。)所以 Postgres 知道我们正在处理一个既不锚定左也不锚定右的模式——这将允许不同的策略。更详细的相关答案:

  • Performance impact of empty LIKE in a prepared statement

或者重新规划每个搜索词的查询(可能在函数中使用动态 SQL)。但要确保计划时间不会影响任何可能的性能提升。


pess 中列的 WHERE 条件与 LEFT JOIN 相矛盾。 Postgres 被迫将其转换为 INNER JOIN。更糟糕的是,连接在连接树中出现得很晚。由于 Postgres 无法重新排序您的连接(见下文),这可能会变得非常昂贵。将 table 移动到 FROM 子句中的 first 位置以提前删除行。根据定义,接下来的 LEFT JOIN 不会删除任何行。但是对于那么多 table,将可能 乘以 行的连接移动到末尾是很重要的。


您将加入 13 个 table,其中 12 个具有 LEFT JOIN,剩下 12! 个可能的组合 - 或者 11! * 2! 如果我们采用一个 LEFT JOIN 考虑到这确实是一个 INNER JOIN。对于 Postgres 评估最佳查询计划的所有可能排列来说,太多。了解 join_collapse_limit:

  • Sample Query to show Cardinality estimation error in PostgreSQL
  • SQL INNER JOIN over multiple tables equal to WHERE syntax

join_collapse_limit的默认设置是8,这意味着Postgres不会尝试重新排序tables 在你的 FROM 子句中 tables 的顺序是 relevant.

解决此问题的一种方法是将性能关键部分拆分为 CTE like 。不要将 join_collapse_limit 设置得更高,否则涉及许多已连接 table 的查询计划的时间会恶化。


关于你的 串联日期 命名为 data:

cast(cast(e.ano as varchar(4))||'-'||right('0'||cast(e.mes as varchar(2)),2)||'-'|| right('0'||cast(e.dia as varchar(2)),2) as varchar(10)) as data

假设 您从定义为 NOT NULL 的年、月和日三个数字列构建,请改为使用:

e.ano::text || to_char(e.mes2, 'FM"-"00')
            || to_char(e.dia,  'FM"-"00') AS data

关于 FM 模板模式修饰符:

但实际上,您首先应该将日期存储为 date 数据类型。


也简化了:

format('%s (%s)', p.abrev, p.prenome) AS determinador

不会使查询更快,但更简洁。参见 format()


首先,性能优化的所有常用建议均适用:

  • Keep PostgreSQL from sometimes choosing a bad query plan

如果您做对了所有这些,您应该会看到对 所有 模式的更快查询。

减小范围 table 大小的一种方法是将查询的一小部分压缩到 CTE 中,例如:

WITH zzz AS (
        SELECT l.id, l.nome
        , coalesce(v.val,v.valf)||' '||vu.unit as altura
        , coalesce(v1.val,v1.valf)||' '||vu1.unit as DAP
        FROM loc l 
         left join var v on v.esp = l.id and v.key = 265
         left join varunit vu on vu.id = v.unit
         left join var v1 on v1.esp = l.id and v1.key = 264
         left join varunit vu1 on vu1.id = v1.unit
        )
select e.id, (select count(id) from imgitem ii
                where ii.tabid = e.id and ii.tab = 'esp'
                ) as imgs
        , e.ano, e.mes, e.dia
        , cast(cast(e.ano as varchar(4))||'-'||right('0'||cast(e.mes as varchar(2)),2)||'-'|| right('0'||cast(e.dia as varchar(2)),2) as varchar(10)) as data
        , pl.pltag, e.inpa, e.det, d.ano anodet
        , coalesce(p.abrev,'')||' ('||coalesce(p.prenome,'')||')' determinador
        , d.tax

        , zzz.altura as altura
        , zzz.DAP as DAP

        , d.fam, tf.nome família
        , d.gen, tg.nome gênero
        , d.sp , ts.nome espécie
        , d.inf, e.loc
        , zzz.nome AS localidade
        , e.lat, e.lon
from esp e
left join det d on e.det = d.id         -- these could possibly be
left join pess p on p.id = d.detby      -- plain joins
        -- 
left join tax tf on d.fam = tf.oldfam
left join tax tg on d.gen = tg.oldgen
left join tax ts on d.sp = ts.oldsp
 -- ### commented out, since it is never referred
 -- ### left join tax ti on d.inf = ti.oldinf
left join pl on pl.id = e.pl
left JOIN zzz ON zzz.id = e.loc
        -- 
WHERE unaccent(TEXT(coalesce(p.abrev,'')||' ('||coalesce(p.prenome,'')||')')) ilike unaccent('%vicen%')
        ;

[未经测试,因为我没有 table 定义]