Langsame Volltextsuche aufgrund extrem ungenauer Zeilenschätzungen


10

Die Ausführung von Volltextabfragen für diese Datenbank (Speichern von RT- Tickets ( Request Tracker )) scheint sehr lange zu dauern. Die Anhangstabelle (die die Volltextdaten enthält) umfasst ca. 15 GB.

Das Datenbankschema lautet wie folgt: Es sind ungefähr 2 Millionen Zeilen:

rt4 = # \ d + Anhänge
                                                    Tabelle "public.attachments"
     Spalte | Geben Sie | ein Modifikatoren | Lagerung | Beschreibung
----------------- + ----------------------------- + - -------------------------------------------------- ------ + ---------- + -------------
 id | Ganzzahl | nicht null default nextval ('attachments_id_seq' :: regclass) | schlicht |
 Transaktions-ID | Ganzzahl | nicht null | schlicht |
 Eltern | Ganzzahl | nicht null default 0 | schlicht |
 messageid | Zeichen variierend (160) | | erweitert |
 Betreff | Zeichen variierend (255) | | erweitert |
 Dateiname | Zeichen variierend (255) | | erweitert |
 Inhaltstyp | Zeichen variierend (80) | | erweitert |
 contentencoding | Zeichen variierend (80) | | erweitert |
 Inhalt | Text | | erweitert |
 Überschriften | Text | | erweitert |
 Schöpfer | Ganzzahl | nicht null default 0 | schlicht |
 erstellt | Zeitstempel ohne Zeitzone | | schlicht |
 Inhaltsindex | tsvector | | erweitert |
Indizes:
    "attachments_pkey" PRIMARY KEY, btree (id)
    "Anhang1" btree (Elternteil)
    "Anhang2" btree (Transaktions-ID)
    "attachments3" btree (Eltern, Transaktions-ID)
    "contentindex_idx" gin (contentindex)
Hat OIDs: nein

Ich kann die Datenbank sehr schnell (<1s) selbst abfragen, mit einer Abfrage wie:

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

Wenn RT jedoch eine Abfrage ausführt, die eine Volltextindexsuche für dieselbe Tabelle durchführen soll, dauert der Abschluss normalerweise Hunderte von Sekunden. Die Ausgabe der Abfrageanalyse lautet wie folgt:

Abfrage

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 Ausgabe

                                                                             Abfrageplan 
-------------------------------------------------- -------------------------------------------------- -------------------------------------------------- --------------
 Aggregat (Kosten = 51210.60..51210.61 Zeilen = 1 Breite = 4) (tatsächliche Zeit = 477778.806..477778.806 Zeilen = 1 Schleifen = 1)
   -> Verschachtelte Schleife (Kosten = 0,00..51210,57 Zeilen = 15 Breite = 4) (tatsächliche Zeit = 17943,986..477775,174 Zeilen = 4197 Schleifen = 1)
         -> Verschachtelte Schleife (Kosten = 0,00..40643.08 Zeilen = 6507 Breite = 8) (tatsächliche Zeit = 8.526..20610.380 Zeilen = 1714818 Schleifen = 1)
               -> Seq Scan auf Tickets main (Kosten = 0,00..9818,37 Zeilen = 598 Breite = 8) (tatsächliche Zeit = 0,008..256,042 Zeilen = 96990 Schleifen = 1)
                     Filter: (((Status) :: Text 'gelöscht' :: Text) UND (id = effektive ID) UND ((Typ) :: Text = 'Ticket' :: Text))
               -> Index-Scan mit Transaktionen1 für Transaktionen Transaktionen_1 (Kosten = 0,00..51,36 Zeilen = 15 Breite = 8) (tatsächliche Zeit = 0,102..0,202 Zeilen = 18 Schleifen = 96990)
                     Index Cond: (((Objekttyp) :: text = 'RT :: Ticket' :: Text) AND (objectid = main.id))
         -> Index-Scan mit Anhängen2 für Anhänge Anhang_2 (Kosten = 0,00..1,61 Zeilen = 1 Breite = 4) (tatsächliche Zeit = 0,266..0,266 Zeilen = 0 Schleifen = 1714818)
               Index Cond: (transactionid = transaction_1.id)
               Filter: (contentindex @@ plago_tsquery ('frobnicate' :: text))
 Gesamtlaufzeit: 477778,883 ms

Soweit ich das beurteilen kann, scheint das Problem darin zu bestehen, dass nicht der für contentindexfield ( contentindex_idx) erstellte Index verwendet wird , sondern ein Filter für eine große Anzahl übereinstimmender Zeilen in der Anhangstabelle ausgeführt wird. Die ANALYZEZeilenanzahl in der EXPLAIN- Ausgabe scheint auch nach einer kürzlichen Zeit sehr ungenau zu sein : geschätzte Zeilen = 6507 tatsächliche Zeilen = 1714818.

Ich bin mir nicht sicher, wohin ich als nächstes gehen soll.


Ein Upgrade würde zusätzliche Vorteile bringen. Neben vielen allgemeinen Verbesserungen, insbesondere: 9.2 ermöglicht Nur-Index-Scans und Verbesserungen der Skalierbarkeit. Die kommende Version 9.4 wird wesentliche Verbesserungen für GIN-Indizes bringen.
Erwin Brandstetter

Antworten:


5

Dies kann auf tausend und eine Weise verbessert werden, dann sollte es eine Frage von Millisekunden sein .

Bessere Fragen

Dies ist nur Ihre Abfrage, die mit Aliasnamen neu formatiert und etwas Rauschen entfernt wurde, um den Nebel zu beseitigen:

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');

Das meiste Problem mit Ihrer Abfrage liegt in den ersten beiden Tabellen ticketsund transactions, die in der Frage fehlen. Ich fülle mit fundierten Vermutungen.

  • t.status, t.objecttypeund tr.objecttypesollte wahrscheinlich nicht sein text, aber enumoder möglicherweise ein sehr kleiner Wert, der auf eine Nachschlagetabelle verweist.

EXISTS Semi-Join

Vorausgesetzt, es tickets.idist der Primärschlüssel, sollte dieses umgeschriebene Formular viel billiger sein:

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')
   );

Anstatt Zeilen mit zwei 1: n -Verknüpfungen zu multiplizieren count(DISTINCT id), verwenden Sie eine EXISTSSemi-Verknüpfung, um am Ende mehrere Übereinstimmungen zu reduzieren. Sobald die erste Übereinstimmung gefunden wurde und der letzte DISTINCTSchritt veraltet ist, kann die Suche unterbrochen werden. Pro Dokumentation:

Die Unterabfrage wird im Allgemeinen nur lange genug ausgeführt, um festzustellen, ob mindestens eine Zeile zurückgegeben wird, und nicht bis zur Fertigstellung.

Die Wirksamkeit hängt davon ab, wie viele Transaktionen pro Ticket und Anhänge pro Transaktion vorhanden sind.

Bestimmen Sie die Reihenfolge der Verknüpfungen mit join_collapse_limit

Wenn Sie wissen , dass Ihr Suchbegriff für attachments.contentindexist sehr selektiv - selektive als andere Bedingungen in der Abfrage (was wahrscheinlich der Fall für ‚frobnicate‘ ist, aber nicht für ‚Problem‘), können Sie die Reihenfolge des Joins erzwingen. Der Abfrageplaner kann die Selektivität bestimmter Wörter mit Ausnahme der häufigsten kaum beurteilen. Pro Dokumentation:

join_collapse_limit( integer)

[...]
Da der Abfrageplaner nicht immer die optimale Verknüpfungsreihenfolge auswählt, können fortgeschrittene Benutzer diese Variable vorübergehend auf 1 setzen und dann die gewünschte Verknüpfungsreihenfolge explizit angeben.

Verwenden Sie diese SET LOCALOption, um sie nur für die aktuelle Transaktion festzulegen.

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;

Die Reihenfolge der WHEREBedingungen ist immer irrelevant. Hier ist nur die Reihenfolge der Verknüpfungen relevant.

Oder verwenden Sie einen CTE, wie @jjanes in "Option 2" erklärt. für einen ähnlichen Effekt.

Indizes

B-Baum-Indizes

Nehmen Sie alle Bedingungen an tickets, die bei den meisten Abfragen identisch verwendet werden, und erstellen Sie einen Teilindex für tickets:

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

Wenn eine der Bedingungen variabel ist, löschen Sie sie aus der WHEREBedingung und stellen Sie stattdessen die Spalte als Indexspalte voran.

Ein weiterer auf transactions:

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

Die dritte Spalte dient nur zum Aktivieren von Nur-Index-Scans.

Da Sie diesen zusammengesetzten Index mit zwei ganzzahligen Spalten haben attachments:

"attachments3" btree (parent, transactionid)

Dieser zusätzliche Index ist eine völlige Verschwendung . Löschen Sie ihn:

"attachments1" btree (parent)

Einzelheiten:

GIN-Index

Fügen Sie transactionidIhrem GIN-Index hinzu, um ihn viel effektiver zu machen. Dies kann eine weitere Silberkugel sein , da möglicherweise nur Index-Scans möglich sind, wodurch Besuche des großen Tisches vollständig vermieden werden.
Sie benötigen zusätzliche Operatorklassen, die vom zusätzlichen Modul bereitgestellt werden btree_gin. Detaillierte Anleitung:

"contentindex_idx" gin (transactionid, contentindex)

4 Bytes aus einer integerSpalte machen den Index nicht viel größer. Zum Glück unterscheiden sich GIN-Indizes in einem entscheidenden Aspekt von B-Tree-Indizes. Pro Dokumentation:

Ein mehrspaltiger GIN-Index kann mit Abfragebedingungen verwendet werden, die eine beliebige Teilmenge der Indexspalten umfassen . Im Gegensatz zu B-Tree oder GiST ist die Effektivität der Indexsuche unabhängig von den von den Abfragebedingungen verwendeten Indexspalten gleich .

Meine kühne Betonung. Sie brauchen also nur den einen (großen und etwas teuren) GIN-Index.

Tabellendefinition

Bewegen Sie die integer not null columnsnach vorne. Dies hat einige geringfügige positive Auswirkungen auf Speicher und Leistung. Spart in diesem Fall 4 - 8 Bytes pro Zeile.

                      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                    |

3

Option 1

Der Planer hat keinen Einblick in die wahre Natur der Beziehung zwischen EffectiveId und id und denkt daher wahrscheinlich an die Klausel:

main.EffectiveId = main.id

wird viel selektiver sein als es tatsächlich ist. Wenn dies meiner Meinung nach so ist, ist EffectiveID fast immer gleich main.id, aber der Planer weiß das nicht.

Eine möglicherweise bessere Möglichkeit, diese Art von Beziehung zu speichern, besteht normalerweise darin, den NULL-Wert von EffectiveID so zu definieren, dass er "effektiv dasselbe wie id" bedeutet, und nur dann etwas darin zu speichern, wenn es einen Unterschied gibt.

Angenommen, Sie möchten Ihr Schema nicht neu organisieren, können Sie versuchen, es zu umgehen, indem Sie diese Klausel wie folgt umschreiben:

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

Der Planer könnte annehmen, dass das betweenweniger selektiv ist als eine Gleichheit, und das könnte ausreichen, um es aus seiner aktuellen Falle herauszuholen.

Option 2

Ein anderer Ansatz ist die Verwendung eines CTE:

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

Dies zwingt den Planer, ContentIndex als Selektivitätsquelle zu verwenden. Sobald dies erzwungen wird, sehen die irreführenden Spaltenkorrelationen auf dem Tickets-Tisch nicht mehr so ​​attraktiv aus. Wenn jemand nach "Problem" und nicht nach "Frobnikat" sucht, kann dies natürlich nach hinten losgehen.

Option 3

Um die Schätzungen für fehlerhafte Zeilen weiter zu untersuchen, sollten Sie die folgende Abfrage in allen 2 ^ 3 = 8 Permutationen der verschiedenen AND-Klauseln ausführen, die auskommentiert werden. Dies hilft herauszufinden, woher die schlechte Schätzung kommt.

explain analyze
SELECT * FROM Tickets main WHERE 
   main.Status != 'deleted' AND 
   main.Type = 'ticket' AND 
   main.EffectiveId = main.id;
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.