it-swarm.com.de

Optimieren Sie die GROUP BY-Abfrage, um die letzte Zeile pro Benutzer abzurufen

Ich habe die folgende Protokolltabelle für Benutzermeldungen (vereinfachte Form) in Postgres 9.2:

CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);

Es enthält bis zu einen Datensatz pro Benutzer und Tag. 300 Tage lang werden ungefähr 500.000 Datensätze pro Tag gespeichert. Die Nutzlast nimmt für jeden Benutzer zu (falls dies wichtig ist).

Ich möchte den neuesten Datensatz für jeden Benutzer vor einem bestimmten Datum effizient abrufen. Meine Anfrage lautet:

SELECT user_id, max(log_date), max(payload) 
FROM log 
WHERE log_date <= :mydate 
GROUP BY user_id

das ist extrem langsam. Ich habe auch versucht:

SELECT DISTINCT ON(user_id), log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC;

das hat den gleichen Plan und ist ebenso langsam.

Bisher habe ich einen einzelnen Index für log(log_date), aber es hilft nicht viel.

Und ich habe eine users Tabelle mit allen Benutzern. Ich möchte auch das Ergebnis für einige Benutzer abrufen (die mit payload > :value).

Gibt es einen anderen Index, den ich verwenden sollte, um dies zu beschleunigen, oder einen anderen Weg, um das zu erreichen, was ich will?

42
xpapad

Für eine optimale Leseleistung benötigen Sie einen mehrspaltigen Index :

CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST)

Fügen Sie die ansonsten nicht benötigte Spalte payload hinzu, um index only scans zu ermöglichen:

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload)

Warum DESC NULLS LAST?

Für wenige Zeilen pro user_id Oder kleine Tabellen DISTINCT ON Ist in der Regel am schnellsten und einfachsten:

Für viele Zeilen pro user_id Und Index-Skip-Scan (oder Loose Index Scan) ist (viel) effizienter. Dies ist bis zu Postgres 12 nicht implementiert - für Postgres 13 wird derzeit gearbeitet. Es gibt jedoch Möglichkeiten, es effizient zu emulieren.

Gängige Tabellenausdrücke erfordern Postgres 8.4 +.
LATERAL benötigt Postgres 9.3 +.
Die folgenden Lösungen gehen über das hinaus, was im Postgres Wiki behandelt wird.

1. Keine separate Tabelle mit eindeutigen Benutzern

Mit einer separaten users Tabelle sind Lösungen in 2. unten normalerweise einfacher und schneller. Überspringen.

1a. Rekursiver CTE mit dem Join LATERAL

WITH RECURSIVE cte AS (
   (                                -- parentheses required
   SELECT user_id, log_date, payload
   FROM   log
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT l.*
   FROM   cte c
   CROSS  JOIN LATERAL (
      SELECT l.user_id, l.log_date, l.payload
      FROM   log l
      WHERE  l.user_id > c.user_id  -- lateral reference
      AND    log_date <= :mydate    -- repeat condition
      ORDER  BY l.user_id, l.log_date DESC NULLS LAST
      LIMIT  1
      ) l
   )
TABLE  cte
ORDER  BY user_id;

Dies ist einfach, um beliebige Spalten abzurufen und wahrscheinlich am besten in aktuellen Postgres. Weitere Erläuterungen in Kapitel 2a. weiter unten.

1b. Rekursiver CTE mit korrelierter Unterabfrage

WITH RECURSIVE cte AS (
   (                                           -- parentheses required
   SELECT l AS my_row                          -- whole row
   FROM   log l
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT (SELECT l                            -- whole row
           FROM   log l
           WHERE  l.user_id > (c.my_row).user_id
           AND    l.log_date <= :mydate        -- repeat condition
           ORDER  BY l.user_id, l.log_date DESC NULLS LAST
           LIMIT  1)
   FROM   cte c
   WHERE  (c.my_row).user_id IS NOT NULL       -- note parentheses
   )
SELECT (my_row).*                              -- decompose row
FROM   cte
WHERE  (my_row).user_id IS NOT NULL
ORDER  BY (my_row).user_id;

Praktisch, um eine einzelne Spalte oder die ganze Zeile abzurufen. Im Beispiel wird der gesamte Zeilentyp der Tabelle verwendet. Andere Varianten sind möglich.

Testen Sie eine einzelne NOT NULL-Spalte (wie den Primärschlüssel), um zu bestätigen, dass in der vorherigen Iteration eine Zeile gefunden wurde.

Weitere Erläuterungen zu dieser Abfrage in Kapitel 2b. unten.

Verbunden:

2. Mit separater users Tabelle

Das Tabellenlayout spielt kaum eine Rolle, solange genau eine Zeile pro relevantem user_id Garantiert ist. Beispiel:

CREATE TABLE users (
   user_id  serial PRIMARY KEY
 , username text NOT NULL
);

Idealerweise wird die Tabelle physisch synchron mit der Tabelle log sortiert. Sehen:

Oder es ist klein genug (geringe Kardinalität), dass es kaum darauf ankommt. Anderenfalls kann das Sortieren von Zeilen in der Abfrage dazu beitragen, die Leistung weiter zu optimieren. Siehe den Zusatz von Gang Liang. Wenn die physikalische Sortierreihenfolge der Tabelle users mit dem Index für log übereinstimmt, ist dies möglicherweise irrelevant.

2a. LATERAL beitreten

SELECT u.user_id, l.log_date, l.payload
FROM   users u
CROSS  JOIN LATERAL (
   SELECT l.log_date, l.payload
   FROM   log l
   WHERE  l.user_id = u.user_id         -- lateral reference
   AND    l.log_date <= :mydate
   ORDER  BY l.log_date DESC NULLS LAST
   LIMIT  1
   ) l;

JOIN LATERAL ermöglicht es, auf vorherige FROM Elemente auf derselben Abfrageebene zu verweisen. Sehen:

Ergebnisse in einer Indexsuche (-nur) pro Benutzer.

Gibt keine Zeile für Benutzer zurück, die in der Tabelle users fehlen. Normalerweise würde eine Fremdschlüssel Einschränkung, die referenzielle Integrität erzwingt, dies ausschließen.

Auch keine Zeile für Benutzer ohne übereinstimmenden Eintrag in log - entsprechend der ursprünglichen Frage. Um diese Benutzer im Ergebnis zu behalten, verwenden Sie LEFT JOIN LATERAL ... ON true anstelle von CROSS JOIN LATERAL:

Verwenden Sie LIMIT n anstelle von LIMIT 1, Um mehr als eine Zeile (aber nicht alle) pro Benutzer abzurufen.

Tatsächlich machen alle das Gleiche:

JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...

Der letzte hat jedoch eine niedrigere Priorität. Explizites JOIN wird vor dem Komma gebunden. Dieser subtile Unterschied kann bei mehr Join-Tabellen von Bedeutung sein. Sehen:

2b. Korrelierte Unterabfrage

Gute Wahl, um einzelne Spalte aus einzelne Zeile abzurufen. Codebeispiel:

Dasselbe ist für mehrere Spalten möglich, aber Sie benötigen mehr Smarts:

CREATE TEMP TABLE combo (log_date date, payload int);

SELECT user_id, (combo1).*              -- note parentheses
FROM (
   SELECT u.user_id
        , (SELECT (l.log_date, l.payload)::combo
           FROM   log l
           WHERE  l.user_id = u.user_id
           AND    l.log_date <= :mydate
           ORDER  BY l.log_date DESC NULLS LAST
           LIMIT  1) AS combo1
   FROM   users u
   ) sub;
  • Wie oben LEFT JOIN LATERAL Enthält diese Variante all Benutzer, auch ohne Einträge in log. Sie erhalten NULL für combo1, Das Sie bei Bedarf einfach mit einer WHERE -Klausel in der äußeren Abfrage filtern können.
    Nitpick: In der äußeren Abfrage können Sie nicht unterscheiden, ob die Unterabfrage keine Zeile gefunden hat oder ob alle Spaltenwerte NULL sind - dasselbe Ergebnis. Sie benötigen eine NOT NULL - Spalte in der Unterabfrage, um diese Mehrdeutigkeit zu vermeiden.

  • Eine korrelierte Unterabfrage kann nur einen Einzelwert ​​zurückgeben. Sie können mehrere Spalten in einen zusammengesetzten Typ einschließen. Um es später zu zersetzen, benötigt Postgres einen bekannten Composite-Typ. Anonyme Datensätze können nur mithilfe einer Spaltendefinitionsliste zerlegt werden.
    Verwenden Sie einen registrierten Typ wie den Zeilentyp einer vorhandenen Tabelle. Oder registrieren Sie einen zusammengesetzten Typ explizit (und dauerhaft) mit CREATE TYPE. Oder erstellen Sie eine temporäre Tabelle (die am Ende der Sitzung automatisch gelöscht wird), um den Zeilentyp vorübergehend zu registrieren. Cast-Syntax: (log_date, payload)::combo

  • Schließlich möchten wir nicht combo1 Auf derselben Abfrageebene zerlegen. Aufgrund einer Schwachstelle im Abfrageplaner wird die Unterabfrage für jede Spalte einmal ausgewertet (in Postgres 12 immer noch wahr). Machen Sie es stattdessen zu einer Unterabfrage und zerlegen Sie es in der äußeren Abfrage.

Verbunden:

Demonstration aller 4 Abfragen mit 100.000 Protokolleinträgen und 1.000 Benutzern:
db <> fiddle here - pg 11
Altes sqlfiddle - S. 9.6

100

Dies ist keine eigenständige Antwort, sondern ein Kommentar zu @ Erwins Antwort . Für 2a, das Beispiel für laterale Verknüpfungen, kann die Abfrage verbessert werden, indem die Tabelle users sortiert wird, um die Lokalität des Index für log auszunutzen.

SELECT u.user_id, l.log_date, l.payload
  FROM (SELECT user_id FROM users ORDER BY user_id) u,
       LATERAL (SELECT log_date, payload
                  FROM log
                 WHERE user_id = u.user_id -- lateral reference
                   AND log_date <= :mydate
              ORDER BY log_date DESC NULLS LAST
                 LIMIT 1) l;

Der Grund dafür ist, dass die Indexsuche teuer ist, wenn die user_id - Werte zufällig sind. Wenn Sie zuerst user_id Aussortieren, sieht die nachfolgende laterale Verknüpfung wie ein einfacher Scan des Index von log aus. Obwohl beide Abfragepläne gleich aussehen, würde sich die Laufzeit insbesondere bei großen Tabellen stark unterscheiden.

Die Kosten für die Sortierung sind minimal, insbesondere wenn das Feld user_id Einen Index enthält.

5
Gang Liang

Vielleicht würde ein anderer Index auf dem Tisch helfen. Versuchen Sie Folgendes: log(user_id, log_date). Ich bin mir nicht sicher, ob Postgres mit distinct on.

Also, ich würde mich an diesen Index halten und diese Version ausprobieren:

select *
from log l
where not exists (select 1
                  from log l2
                  where l2.user_id = l.user_id and
                        l2.log_date <= :mydate and
                        l2.log_date > l.log_date
                 );

Dies sollte das Sortieren/Gruppieren durch Indexsuchen ersetzen. Es könnte schneller sein.

4
Gordon Linoff