Optimieren Sie eine LATERAL JOIN-Abfrage für eine große Tabelle

8

Ich benutze Postgres 9.5. Ich habe eine Tabelle, in der Seitentreffer von mehreren Websites aufgezeichnet werden. Diese Tabelle enthält ungefähr 32 Millionen Zeilen vom 1. Januar 2016 bis 30. Juni 2016.

CREATE TABLE event_pg (
   timestamp_        timestamp without time zone NOT NULL,
   person_id         character(24),
   location_host     varchar(256),
   location_path     varchar(256),
   location_query    varchar(256),
   location_fragment varchar(256)
);

Ich versuche, eine Abfrage zu optimieren, die die Anzahl der Personen zählt, die eine bestimmte Folge von Seitentreffern ausgeführt haben. Die Abfrage soll Fragen beantworten wie "Wie viele Personen haben die Startseite angesehen und sind dann zur Hilfeseite gegangen und haben dann die Dankesseite angesehen"? Das Ergebnis sieht so aus

╔════════════╦════════════╦═════════════╗
  home-page  help site   thankyou    
╠════════════╬════════════╬═════════════╣
 10000       9800       1500         
╚════════════╩════════════╩═════════════╝

Beachten Sie, dass die Anzahl abnimmt, was sinnvoll ist, da die 10000, die die Homepage 9800 angesehen haben, auf die Hilfeseite gingen und die 1500 auf die Dankesseite gingen.

Das SQL für eine 3-Schritt-Sequenz verwendet laterale Verknüpfungen wie folgt:

SELECT 
  sum(view_homepage) AS view_homepage,
  sum(use_help) AS use_help,
  sum(thank_you) AS thank_you
FROM (
  -- Get the first time each user viewed the homepage.
  SELECT X.person_id,
    1 AS view_homepage,
    min(timestamp_) AS view_homepage_time
  FROM event_pg X 
  WHERE X.timestamp_ between '2016-04-23 00:00:00.0' and timestamp '2016-04-30 23:59:59.999'
  AND X.location_host like '2015.testonline.ca'
  GROUP BY X.person_id
) e1 
LEFT JOIN LATERAL (
  SELECT
    Y.person_id,
    1 AS use_help,
    timestamp_ AS use_help_time
  FROM event_pg Y 
  WHERE 
    Y.person_id = e1.person_id AND
    location_host = 'helpcentre.testonline.ca' AND
    timestamp_ BETWEEN view_homepage_time AND timestamp '2016-04-30 23:59:59.999'
  ORDER BY timestamp_
  LIMIT 1
) e2 ON true 
LEFT JOIN LATERAL (
  SELECT
    1 AS thank_you,
    timestamp_ AS thank_you_time
  FROM event_pg Z 
  WHERE Z.person_id = e2.person_id AND
    location_fragment =  '/file/thank-you' AND
    timestamp_ BETWEEN use_help_time AND timestamp '2016-04-30 23:59:59.999'
  ORDER BY timestamp_
  LIMIT 1
) e3 ON true;

Ich habe einen Index auf timestamp_, person_idund die locationSpalten. Abfragen zu Datumsbereichen von einigen Tagen oder Wochen sind sehr schnell (1s bis 10s). Es wird langsam, wenn ich versuche, die Abfrage für alles zwischen dem 1. Januar und dem 30. Juli auszuführen. Es dauert über eine Minute. Wenn Sie die beiden folgenden Erklärungen vergleichen, können Sie sehen, dass der Index timestamp_ nicht mehr verwendet wird und stattdessen ein Seq-Scan durchgeführt wird, da der Index uns nichts kaufen würde, da wir "all time" abfragen, also so ziemlich alle Datensätze in der Tabelle .

Jetzt ist mir klar, dass die verschachtelte Schleifennatur des lateralen Joins umso langsamer wird, je mehr Datensätze durchlaufen werden müssen. Gibt es jedoch eine Möglichkeit, diese Abfrage für große Datumsbereiche zu beschleunigen, damit sie besser skaliert werden kann?

maxTrialfire
quelle

Antworten:

10

Vorbemerkungen

  • Sie verwenden ungerade Datentypen. character(24)? char(n)ist ein veralteter Typ und fast immer die falsche Wahl. Sie haben Indizes aktiviert person_idund nehmen wiederholt daran teil. integerwäre aus mehreren Gründen viel effizienter. (Oder bigintwenn Sie vorhaben, über die Lebensdauer der Tabelle mehr als 2 Milliarden Zeilen zu brennen.) Verwandte Themen:

  • LIKEist ohne Platzhalter sinnlos. Verwenden Sie =stattdessen. Schneller.
    x.location_host LIKE '2015.testonline.ca'
    x.location_host = '2015.testonline.ca'

  • Verwenden Sie count(e1.*)oder count(*)anstatt eine Dummy-Spalte mit dem Wert 1für jede Unterabfrage hinzuzufügen. (Mit Ausnahme von last ( e3), wo Sie keine tatsächlichen Daten benötigen.)

  • Sie sind inkonsistent, wenn Sie das String-Literal timestampmanchmal in und manchmal nicht ( timestamp '2016-04-30 23:59:59.999') umwandeln . Entweder macht es Sinn, dann mach es die ganze Zeit oder nicht, dann mach es nicht.
    Das tut es nicht. Im Vergleich zu einer timestampSpalte wird timestampohnehin ein String-Literal erzwungen . Sie brauchen also keine explizite Besetzung.

  • Der Postgres-Datentyp timestamphat bis zu 6 Bruchstellen. Ihre BETWEENAusdrücke hinterlassen Eckfälle. Ich habe sie durch weniger fehleranfällige Ausdrücke ersetzt.

Indizes

Wichtig: Um die Leistung zu optimieren, erstellen Sie mehrspaltige Indizes .
Für die erste Unterabfrage hp:

CREATE INDEX event_pg_location_host_timestamp__idx
ON event_pg (location_host, timestamp_);

Oder fügen Sie person_iddem Index Folgendes hinzu, wenn Sie nur Index-Scans erhalten können :

CREATE INDEX event_pg_location_host_timestamp__person_id_idx
ON event_pg (location_host, timestamp_, person_id);

Für sehr großehlp Zeitbereiche, die sich über den größten Teil oder die gesamte Tabelle erstrecken, sollte dieser Index vorzuziehen sein. Er unterstützt auch die Unterabfrage. Erstellen Sie ihn also so oder so:

CREATE INDEX event_pg_location_host_person_id_timestamp__idx
ON event_pg (location_host, person_id, timestamp_);

Für tnk:

CREATE INDEX event_pg_location_fragment_timestamp__idx
ON event_pg (location_fragment, person_id, timestamp_);

Optimiert mit Teilindizes

Wenn Ihre Prädikate auf location_hostund location_fragmentKonstanten sind, können wir stattdessen viel billigere Teilindizes verwenden , zumal Ihre location_*Spalten groß erscheinen:

CREATE INDEX event_pg_hp_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_host = '2015.testonline.ca';

CREATE INDEX event_pg_hlp_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_host = 'helpcentre.testonline.ca';

CREATE INDEX event_pg_tnk_person_id_ts_idx ON event_pg (person_id, timestamp_)
WHERE  location_fragment = '/file/thank-you';

Erwägen:

Auch hier sind alle diese Indizes mit integeroder bigintfür wesentlich kleiner und schneller person_id.

Im Allgemeinen müssen Sie nach ANALYZEdem Erstellen eines neuen Index die Tabelle aufrufen - oder warten, bis das Autovakuum einsetzt, um dies für Sie zu tun.

Um nur Index-Scans zu erhalten , muss Ihre Tabelle VACUUMausreichend bearbeitet werden. Sofort danach VACUUMals Proof of Concept testen . Lesen Sie die verlinkte Postgres-Wiki-Seite, um weitere Informationen zu erhalten, wenn Sie mit Nur-Index-Scans nicht vertraut sind .

Grundlegende Abfrage

Umsetzung dessen, was ich besprochen habe. Abfrage für kleine Bereiche ( wenige Zeilen pro person_id):

SELECT count(*)::int           AS view_homepage
     , count(hlp.hlp_ts)::int AS use_help
     , count(tnk.yes)::int     AS thank_you
FROM  (
   SELECT DISTINCT ON (person_id)
          person_id, timestamp_ AS hp_ts
   FROM   event_pg
   WHERE  timestamp_ >= '2016-04-23'
   AND    timestamp_ <  '2016-05-01'
   AND    location_host = '2015.testonline.ca'
   ORDER  BY person_id, timestamp_
   ) hp
LEFT JOIN LATERAL (
   SELECT timestamp_ AS hlp_ts
   FROM   event_pg y 
   WHERE  y.person_id = hp.person_id
   AND    timestamp_ >= hp.hp_ts
   AND    timestamp_ <  '2016-05-01'
   AND    location_host = 'helpcentre.testonline.ca'
   ORDER  BY timestamp_
   LIMIT  1
   ) hlp ON true 
LEFT JOIN LATERAL (
   SELECT true AS yes                   -- we only need existence
   FROM   event_pg z
   WHERE  z.person_id = hp.person_id    -- we can use hp here
   AND    location_fragment = '/file/thank-you'
   AND    timestamp_ >= hlp.hlp_ts      -- this introduces dependency on hlp anyways.
   AND    timestamp_ <  '2016-05-01'
   ORDER  BY timestamp_
   LIMIT  1
   ) tnk ON true;

DISTINCT ONist oft billiger für wenige Reihen pro person_id. Ausführliche Erklärung:

Wenn Sie viele Zeilen pro habenperson_id(wahrscheinlicher für größere Zeitbereiche), kann der in dieser Antwort in Kapitel 1a beschriebene rekursive CTE(viel) schneller sein:

Siehe es unten integriert.

Optimieren und automatisieren Sie die beste Abfrage

Es ist das alte Rätsel: Eine Abfragetechnik eignet sich am besten für einen kleineren Satz, eine andere für einen größeren Satz. In Ihrem speziellen Fall haben wir von Anfang an einen sehr guten Indikator - die Länge des angegebenen Zeitraums - anhand dessen wir entscheiden können.

Wir verpacken alles in eine PL / pgSQL-Funktion. Meine Implementierung wechselt von DISTINCT ONrCTE, wenn der angegebene Zeitraum länger als ein festgelegter Schwellenwert ist:

CREATE OR REPLACE FUNCTION f_my_counts(_ts_low_inc timestamp, _ts_hi_excl timestamp)
  RETURNS TABLE (view_homepage int, use_help int, thank_you int) AS
$func$
BEGIN

CASE
WHEN _ts_hi_excl <= _ts_low_inc THEN
   RAISE EXCEPTION 'Timestamp _ts_hi_excl (1st param) must be later than _ts_low_inc!';

WHEN _ts_hi_excl - _ts_low_inc < interval '10 days' THEN  -- example value !!!
-- DISTINCT ON for few rows per person_id
   RETURN QUERY
   WITH hp AS (
      SELECT DISTINCT ON (person_id)
             person_id, timestamp_ AS hp_ts
      FROM   event_pg
      WHERE  timestamp_ >= _ts_low_inc
      AND    timestamp_ <  _ts_hi_excl
      AND    location_host = '2015.testonline.ca'
      ORDER  BY person_id, timestamp_
      )
    , hlp AS (
      SELECT hp.person_id, hlp.hlp_ts
      FROM   hp
      CROSS  JOIN LATERAL (
         SELECT timestamp_ AS hlp_ts
         FROM   event_pg
         WHERE  person_id = hp.person_id
         AND    timestamp_ >= hp.hp_ts
         AND    timestamp_ < _ts_hi_excl
         AND    location_host = 'helpcentre.testonline.ca'  -- match partial idx
         ORDER  BY timestamp_
         LIMIT  1
         ) hlp
      )
   SELECT (SELECT count(*)::int FROM hp)   -- AS view_homepage
        , (SELECT count(*)::int FROM hlp)  -- AS use_help
        , (SELECT count(*)::int            -- AS thank_you
           FROM   hlp
           CROSS  JOIN LATERAL (
              SELECT 1                     -- we only care for existence
              FROM   event_pg
              WHERE  person_id = hlp.person_id
              AND    location_fragment = '/file/thank-you'
              AND    timestamp_ >= hlp.hlp_ts
              AND    timestamp_ < _ts_hi_excl
              ORDER  BY timestamp_
              LIMIT  1
              ) tnk
           );

ELSE
-- rCTE for many rows per person_id
   RETURN QUERY
   WITH RECURSIVE hp AS (
      (  -- parentheses required
      SELECT person_id, timestamp_ AS hp_ts
      FROM   event_pg
      WHERE  timestamp_ >= _ts_low_inc
      AND    timestamp_ <  _ts_hi_excl
      AND    location_host = '2015.testonline.ca'  -- match partial idx
      ORDER  BY person_id, timestamp_
      LIMIT  1
      )
      UNION ALL
      SELECT x.*
      FROM   hp, LATERAL (
         SELECT person_id, timestamp_ AS hp_ts
         FROM   event_pg
         WHERE  person_id  > hp.person_id  -- lateral reference
         AND    timestamp_ >= _ts_low_inc  -- repeat conditions
         AND    timestamp_ <  _ts_hi_excl
         AND    location_host = '2015.testonline.ca'  -- match partial idx
         ORDER  BY person_id, timestamp_
         LIMIT  1
         ) x
      )
    , hlp AS (
      SELECT hp.person_id, hlp.hlp_ts
      FROM   hp
      CROSS  JOIN LATERAL (
         SELECT timestamp_ AS hlp_ts
         FROM   event_pg y 
         WHERE  y.person_id = hp.person_id
         AND    location_host = 'helpcentre.testonline.ca'  -- match partial idx
         AND    timestamp_ >= hp.hp_ts
         AND    timestamp_ < _ts_hi_excl
         ORDER  BY timestamp_
         LIMIT  1
         ) hlp
      )
   SELECT (SELECT count(*)::int FROM hp)   -- AS view_homepage
        , (SELECT count(*)::int FROM hlp)  -- AS use_help
        , (SELECT count(*)::int            -- AS thank_you
           FROM   hlp
           CROSS  JOIN LATERAL (
              SELECT 1                     -- we only care for existence
              FROM   event_pg
              WHERE  person_id = hlp.person_id
              AND    location_fragment = '/file/thank-you'
              AND    timestamp_ >= hlp.hlp_ts
              AND    timestamp_ < _ts_hi_excl
              ORDER  BY timestamp_
              LIMIT  1
              ) tnk
           );
END CASE;

END
$func$  LANGUAGE plpgsql STABLE STRICT;

Anruf:

SELECT * FROM f_my_counts('2016-01-23', '2016-05-01');

Der rCTE arbeitet per Definition mit einem CTE. Ich habe auch CTEs für die DISTINCT ONAbfrage eingegeben (wie ich in den Kommentaren mit @Lennart besprochen habe ), wodurch wir den Satz bei jedem Schritt verwenden können, CROSS JOINanstatt ihn LEFT JOINzu reduzieren, da wir jeden CTE separat zählen können. Dies hat Auswirkungen in entgegengesetzte Richtungen:

  • Zum einen mussten wir die Anzahl der Zeilen reduzieren, was den dritten Join billiger machen sollte.
  • Auf der anderen Seite führen wir Overhead für die CTEs ein und benötigen erheblich mehr RAM, was besonders für große Abfragen wie Ihre wichtig sein kann.

Sie müssen testen, welche die anderen überwiegt.

Erwin Brandstetter
quelle
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Paul White 9