Búsqueda lenta de texto completo debido a estimaciones de filas extremadamente inexactas

10

Las consultas de texto completo en esta base de datos (almacenamiento de tickets RT ( Request Tracker )) parecen tardar mucho tiempo en ejecutarse. La tabla de archivos adjuntos (que contiene los datos de texto completo) es de aproximadamente 15 GB.

El esquema de la base de datos es el siguiente, es de aproximadamente 2 millones de filas:

rt4 = # \ d + archivos adjuntos
                                                    Tabla "archivos adjuntos públicos"
     Columna | Tipo | Modificadores | Almacenamiento | Descripción
----------------- + ----------------------------- + - -------------------------------------------------- ------ + ---------- + -------------
 id | entero | nextval no nulo predeterminado ('attachments_id_seq' :: regclass) | llano |
 transaccion | entero | no nulo | llano |
 padre | entero | no nulo predeterminado 0 | llano |
 messageid | carácter variable (160) | El | extendido |
 sujeto | carácter variable (255) | El | extendido |
 nombre de archivo | carácter variable (255) | El | extendido |
 contenttype | carácter variable (80) | El | extendido |
 contentencoding | carácter variable (80) | El | extendido |
 contenido | texto | El | extendido |
 encabezados | texto | El | extendido |
 creador | entero | no nulo predeterminado 0 | llano |
 creado | marca de tiempo sin zona horaria | El | llano |
 contentindex | tsvector | El | extendido |
Índices:
    "attachments_pkey" CLAVE PRIMARIA, btree (id)
    "adjuntos1" btree (padre)
    btree "adjuntos2" (transacciónid)
    btree "adjuntos3" (padre, transacciónid)
    Ginebra "contentindex_idx" (contentindex)
Tiene OID: no

Puedo consultar la base de datos por sí sola muy rápidamente (<1s) con una consulta como:

select objectid
from attachments
join transactions on attachments.transactionid = transactions.id
where contentindex @@ to_tsquery('frobnicate');

Sin embargo, cuando RT ejecuta una consulta que se supone que realiza una búsqueda de índice de texto completo en la misma tabla, generalmente tarda cientos de segundos en completarse. La salida de análisis de consulta es la siguiente:

Consulta

SELECT COUNT(DISTINCT main.id)
FROM Tickets main
JOIN Transactions Transactions_1 ON ( Transactions_1.ObjectType = 'RT::Ticket' )
                                AND ( Transactions_1.ObjectId = main.id )
JOIN Attachments Attachments_2 ON ( Attachments_2.TransactionId = Transactions_1.id )
WHERE (main.Status != 'deleted')
AND ( ( ( Attachments_2.ContentIndex @@ plainto_tsquery('frobnicate') ) ) )
AND (main.Type = 'ticket')
AND (main.EffectiveId = main.id);

EXPLAIN ANALYZE salida

                                                                             PLAN DE CONSULTA 
-------------------------------------------------- -------------------------------------------------- -------------------------------------------------- --------------
 Agregado (costo = 51210.60..51210.61 filas = 1 ancho = 4) (tiempo real = 477778.806..477778.806 filas = 1 bucles = 1)
   -> Bucle anidado (costo = 0.00..51210.57 filas = 15 ancho = 4) (tiempo real = 17943.986..477775.174 filas = 4197 bucles = 1)
         -> Bucle anidado (costo = 0.00..40643.08 filas = 6507 ancho = 8) (tiempo real = 8.526..20610.380 filas = 1714818 bucles = 1)
               -> Seq Scan en tickets principales (costo = 0.00..9818.37 filas = 598 ancho = 8) (tiempo real = 0.008..256.042 filas = 96990 bucles = 1)
                     Filtro: (((estado) :: texto 'eliminado' :: texto) Y (id = efectivo) Y ((tipo) :: texto = 'ticket' :: texto))
               -> Escaneo de índice usando transacciones1 en transacciones transacciones_1 (costo = 0.00..51.36 filas = 15 ancho = 8) (tiempo real = 0.102..0.202 filas = 18 bucles = 96990)
                     Índice Cond: (((objecttype) :: text = 'RT :: Ticket' :: text) AND (objectid = main.id))
         -> Escaneo de índice usando archivos adjuntos2 en archivos adjuntos archivos adjuntos_2 (costo = 0.00..1.61 filas = 1 ancho = 4) (tiempo real = 0.266..0.266 filas = 0 bucles = 1714818)
               Índice Cond: (transaccion = transacciones_1.id)
               Filtro: (contentindex @@ plainto_tsquery ('frobnicate' :: text))
 Tiempo de ejecución total: 477778.883 ms

Por lo que puedo decir, el problema parece ser que no está usando el índice creado en el contentindexcampo ( contentindex_idx), sino que está haciendo un filtro en una gran cantidad de filas coincidentes en la tabla de archivos adjuntos. Los recuentos de filas en la salida de explicación también parecen ser muy inexactos, incluso después de un reciente ANALYZE: filas estimadas = 6507 filas reales = 1714818.

No estoy realmente seguro de a dónde ir después con esto.

JamesHannah
fuente
La actualización produciría beneficios adicionales. Además de muchas mejoras generales, en particular: 9.2 permite escaneos de solo índice y mejoras en la escalabilidad. El próximo 9.4 traerá importantes mejoras para los índices GIN.
Erwin Brandstetter

Respuestas:

5

Esto se puede mejorar de mil y una maneras, entonces debería ser cuestión de milisegundos .

Mejores consultas

Esta es solo su consulta reformateada con alias y algo de ruido eliminado para despejar la niebla:

SELECT count(DISTINCT t.id)
FROM   tickets      t
JOIN   transactions tr ON tr.objectid = t.id
JOIN   attachments  a  ON a.transactionid = tr.id
WHERE  t.status <> 'deleted'
AND    t.type = 'ticket'
AND    t.effectiveid = t.id
AND    tr.objecttype = 'RT::Ticket'
AND    a.contentindex @@ plainto_tsquery('frobnicate');

La mayor parte del problema con su consulta radica en las dos primeras tablas ticketsy transactions, que faltan en la pregunta. Estoy llenando de conjeturas educadas.

  • t.status, t.objecttypey tr.objecttypeprobablemente no debería serlo text, enumo posiblemente algún valor muy pequeño que haga referencia a una tabla de búsqueda.

EXISTS semi-unirse

Suponiendo que tickets.ides la clave principal, este formulario reescrito debería ser mucho más barato:

SELECT count(*)
FROM   tickets t
WHERE  status <> 'deleted'
AND    type = 'ticket'
AND    effectiveid = id
AND    EXISTS (
   SELECT 1
   FROM   transactions tr
   JOIN   attachments  a ON a.transactionid = tr.id
   WHERE  tr.objectid = t.id
   AND    tr.objecttype = 'RT::Ticket'
   AND    a.contentindex @@ plainto_tsquery('frobnicate')
   );

En lugar de multiplicar filas con dos uniones 1: n, solo para colapsar múltiples coincidencias al final count(DISTINCT id), use una EXISTSsemiunión, que puede dejar de mirar más allá tan pronto como se encuentre la primera coincidencia y al mismo tiempo obsoleto el DISTINCTpaso final . Por documentación:

La subconsulta generalmente solo se ejecutará el tiempo suficiente para determinar si se devuelve al menos una fila, no hasta el final.

La efectividad depende de cuántas transacciones por boleto y archivos adjuntos por transacción haya.

Determinar el orden de las uniones con join_collapse_limit

Si sabe que su término de búsqueda attachments.contentindexes muy selectivo , más selectivo que otras condiciones en la consulta (que probablemente sea el caso de 'frobnicate', pero no para 'problema'), puede forzar la secuencia de uniones. El planificador de consultas apenas puede juzgar la selectividad de palabras particulares, excepto las más comunes. Por documentación:

join_collapse_limit( integer)

[...]
Debido a que el planificador de consultas no siempre elige el orden de unión óptimo, los usuarios avanzados pueden optar por establecer temporalmente esta variable en 1, y luego especificar el orden de unión que desean explícitamente.

Use SET LOCALcon el propósito de configurarlo solo para la transacción actual.

BEGIN;
SET LOCAL join_collapse_limit = 1;

SELECT count(DISTINCT t.id)
FROM   attachments  a                              -- 1st
JOIN   transactions tr ON tr.id = a.transactionid  -- 2nd
JOIN   tickets      t  ON t.id = tr.objectid       -- 3rd
WHERE  t.status <> 'deleted'
AND    t.type = 'ticket'
AND    t.effectiveid = t.id
AND    tr.objecttype = 'RT::Ticket'
AND    a.contentindex @@ plainto_tsquery('frobnicate');

ROLLBACK; -- or COMMIT;

El orden de las WHEREcondiciones es siempre irrelevante. Solo el orden de las combinaciones es relevante aquí.

O use un CTE como explica @jjanes en la "Opción 2". para un efecto similar

Índices

Índices de árbol B

Tome todas las condiciones ticketsque se usan de manera idéntica con la mayoría de las consultas y cree un índice parcial sobre tickets:

CREATE INDEX tickets_partial_idx
ON tickets(id)
WHERE  status <> 'deleted'
AND    type = 'ticket'
AND    effectiveid = id;

Si una de las condiciones es variable, suéltela WHEREy añada la columna como columna de índice.

Otro sobre transactions:

CREATE INDEX transactions_partial_idx
ON transactions(objecttype, objectid, id)

La tercera columna es solo para habilitar escaneos de solo índice.

Además, dado que tiene este índice compuesto con dos columnas enteras en attachments:

"attachments3" btree (parent, transactionid)

Este índice adicional es un desperdicio completo , elimínelo:

"attachments1" btree (parent)

Detalles:

Índice de GIN

Agregue transactionida su índice GIN para que sea mucho más efectivo. Esta puede ser otra bala de plata , porque potencialmente permite escaneos de solo índice, eliminando por completo las visitas a la gran mesa.
Necesita clases de operador adicionales proporcionadas por el módulo adicional btree_gin. Instrucciones detalladas:

"contentindex_idx" gin (transactionid, contentindex)

4 bytes de una integercolumna no hacen que el índice sea mucho más grande. Además, afortunadamente para usted, los índices GIN son diferentes de los índices del árbol B en un aspecto crucial. Por documentación:

Se puede usar un índice GIN de varias columnas con condiciones de consulta que involucren cualquier subconjunto de las columnas del índice . A diferencia de B-tree o GiST, la efectividad de la búsqueda de índice es la misma independientemente de las columnas de índice que utilicen las condiciones de consulta.

El énfasis audaz es mío. Por lo que sólo necesita el uno índice GIN (grande y algo costoso).

Definición de tabla

Mueve el integer not null columnsal frente. Esto tiene un par de efectos positivos menores en el almacenamiento y el rendimiento. Ahorra de 4 a 8 bytes por fila en este caso.

                      Table "public.attachments"
         Column      |            Type             |         Modifiers
    -----------------+-----------------------------+------------------------------
     id              | integer                     | not null default nextval('...
     transactionid   | integer                     | not null
     parent          | integer                     | not null default 0
     creator         | integer                     | not null default 0  -- !
     created         | timestamp                   |                     -- !
     messageid       | character varying(160)      |
     subject         | character varying(255)      |
     filename        | character varying(255)      |
     contenttype     | character varying(80)       |
     contentencoding | character varying(80)       |
     content         | text                        |
     headers         | text                        |
     contentindex    | tsvector                    |
Erwin Brandstetter
fuente
3

Opción 1

El planificador no tiene una idea de la verdadera naturaleza de la relación entre EffectiveId e id, por lo que probablemente piense en la cláusula:

main.EffectiveId = main.id

va a ser mucho más selectivo de lo que realmente es Si esto es lo que creo que es, EffectiveID casi siempre es igual a main.id, pero el planificador no lo sabe.

Una forma posiblemente mejor de almacenar este tipo de relación suele ser definir el valor NULL de EffectiveID para que signifique "efectivamente igual que id", y almacenar algo en él solo si hay una diferencia.

Suponiendo que no desea reorganizar su esquema, puede intentar solucionarlo reescribiendo esa cláusula como algo así como:

main.EffectiveId+0 between main.id+0 and main.id+0

El planificador podría asumir que betweenes menos selectivo que una igualdad, y eso podría ser suficiente para sacarlo de su trampa actual.

opcion 2

Otro enfoque es usar un CTE:

WITH attach as (
    SELECT * from Attachments 
        where ContentIndex @@ plainto_tsquery('frobnicate') 
)
<rest of query goes here, with 'attach' used in place of 'Attachments'>

Esto obliga al planificador a usar ContentIndex como fuente de selectividad. Una vez que se ve obligado a hacerlo, las correlaciones engañosas de la columna en la tabla Tickets ya no serán tan atractivas. Por supuesto, si alguien busca 'problema' en lugar de 'frobnicate', eso podría ser contraproducente.

Opción 3

Para investigar más las estimaciones de filas incorrectas, debe ejecutar la consulta a continuación en todas las permutaciones 2 ^ 3 = 8 de las diferentes cláusulas AND que se comentan. Esto ayudará a determinar de dónde proviene la mala estimación.

explain analyze
SELECT * FROM Tickets main WHERE 
   main.Status != 'deleted' AND 
   main.Type = 'ticket' AND 
   main.EffectiveId = main.id;
jjanes
fuente