PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

In komplexen ERP-Systemen haben viele Entitäten eine hierarchische Natur, wenn homogene Objekte sich zu einem Baum von Beziehungen „Vorfahr - Nachkomme“ anordnen — das ist sowohl die organisatorische Struktur des Unternehmens (all diese Filialen, Abteilungen und Arbeitsgruppen) als auch ein Produktkatalog, Tätigkeitsbereiche und die Geografie der Verkaufsstellen,…

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

Tatsächlich gibt es keinen einzigen Bereich der Automatisierung von Geschäften, in dem nicht eine gewisse Hierarchie entstanden wäre. Aber selbst wenn Sie nicht „im Geschäft“ arbeiten, können Sie dennoch leicht mit hierarchischen Beziehungen konfrontiert werden. Plakativ gesagt, selbst Ihr Stammbaum oder der Grundriss eines Einkaufszentrums sind dieselbe Struktur.

Es gibt viele Möglichkeiten, einen solchen Baum in einer Datenbank zu speichern, aber heute konzentrieren wir uns nur auf eine Variante:

CREATE TABLE hier(
  id
    integer
      PRIMARY KEY
, pid
    integer
      REFERENCES hier
, data
    json
);

CREATE INDEX ON hier(pid); -- Vergessen Sie nicht, dass FK nicht das automatische Erstellen eines Indexes impliziert, im Gegensatz zu PK

Und während Sie in die Tiefe der Hierarchie blicken, wartet sie geduldig darauf, wie [un]effektiv Ihre „naiven“ Ansätze zur Arbeit mit einer solchen Struktur sein werden.

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
Lassen Sie uns typische auftretende Aufgaben durchgehen, ihre Implementierung in SQL und versuchen, ihre Leistung zu verbessern.

#1. Насколько глубока кроличья нора?

Lassen Sie uns zur Klarheit annehmen, dass diese Struktur die Unterordnung der Abteilungen in der Organisationsstruktur widerspiegeln wird: Abteilungen, Divisionen, Sektoren, Filialen, Arbeitsgruppen,… egal wie Sie sie nennen wollen.
PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

Lassen Sie uns zuerst unser „Baum“-Daten aus 10.000 Elementen generieren

INSERT INTO hier
WITH RECURSIVE T AS (
  SELECT
    1::integer id
  , '{1}'::integer[] pids
UNION ALL
  SELECT
    id + 1
  , pids[1:(random() * array_length(pids, 1))::integer] || (id + 1)
  FROM
    T
  WHERE
    id < 10000
)
SELECT
  pids[array_length(pids, 1)] id
, pids[array_length(pids, 1) - 1] pid
FROM
  T;

Beginnen wir mit der einfachsten Aufgabe – alle Mitarbeiter zu finden, die innerhalb eines bestimmten Sektors arbeiten, oder in den Begriffen der Hierarchie – alle Nachkommen eines Knotens zu finden. Und es wäre auch gut, die „Tiefe“ des Nachkommens zu ermitteln… All dies könnte notwendig sein, zum Beispiel für den Aufbau einer komplexen Abfrage auf der Liste der IDs dieser Mitarbeiter.

Es wäre alles gut, wenn es dort nur ein paar Nachkommen auf wenigen Ebenen gäbe und die Anzahl im zweistelligen Bereich bliebe. Aber wenn es mehr als 5 Ebenen sind und die Nachkommen bereits im Dutzend sind, kann es Probleme geben. Lassen Sie uns anschauen, wie traditionelle Suchvarianten "nach unten im Baum" geschrieben (und funktionieren) werden. Aber zuerst definieren wir, welche Knoten für unsere Untersuchungen am interessantesten sein werden.

Die tiefsten Teilbäume: WITH RECURSIVE T AS ( SELECT id , pid , ARRAY[id] path FROM hier WHERE pid IS NULL UNION ALL SELECT hier.id , hier.pid , T.path || hier.id FROM T JOIN hier ON hier.pid = T.id ) TABLE T ORDER BY array_length(path, 1) DESC;

id  | pid  | path
---------------------------------------------
7624 | 7623 | {7615,7620,7621,7622,7623,7624}
4995 | 4994 | {4983,4985,4988,4993,4994,4995}
4991 | 4990 | {4983,4985,4988,4989,4990,4991}
...

 Die weiten

Die tiefsten ... SELECT path[1] id , count(*) FROM T GROUP BY 1 ORDER BY 2 DESC; WITH RECURSIVE T AS ( SELECT id , pid , ARRAY[id] path FROM hier WHERE pid IS NULL UNION ALL SELECT hier.id , hier.pid , T.path || hier.id FROM T JOIN hier ON hier.pid = T.id ) TABLE T ORDER BY array_length(path, 1) DESC;

id   | count
------------
5300 |   30
 450 |   28
1239 |   27
1573 |   25

Für diese Abfragen haben wir einen typischen

rekursiven JOIN verwendet. Offensichtlich wird bei diesem Abfragemodell:
PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

die Anzahl der Iterationen mit der Gesamtanzahl der Nachkommen übereinstimmen (und es sind schließlich mehrere Dutzend), und dies kann eine beträchtliche Menge an Ressourcen und folglich Zeit in Anspruch nehmen. Überprüfen wir es im "weitesten" Teilbaum:

WITH RECURSIVE T AS ( SELECT id FROM hier WHERE id = 5300 UNION ALL SELECT hier.id FROM T JOIN hier ON hier.pid = T.id ) TABLE T;

Wie vermutet, haben wir alle 30 Datensätze gefunden. Aber wir haben dafür 60 % der gesamten Zeit aufgewendet – weil wir dabei auch 30 Suchen im Index durchgeführt haben. Geht es auch schneller?

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
[auf explain.tensor.ru anschauen]

Massenlesungen über den Index.

Brauchen wir für jeden Knoten einen separaten Indexabfrage? Es stellt sich heraus, dass das nicht nötig ist – wir können

schon bei mehreren Schlüsseln in einem einzigen Zugriff aus dem Index lesen. = ANY(array) mit In jede solche Gruppe von Identifikatoren können wir alle auf dem vorherigen Schritt gefundenen IDs nach „Knoten“ aufnehmen. Das heißt, bei jedem folgenden Schritt werden wir.

alle Nachkommen einer bestimmten Ebene gleichzeitig suchen. Nur gibt es da ein Problem:.

bei der rekursiven Abfrage kann man sich nicht im inneren Abfrage selbst ansprechen, und wir müssen doch irgendwie nur das gefundenen auf der vorherigen Ebene filtern... Es stellt sich heraus, dass man eine verschachtelte Abfrage für die gesamte Auswahl nicht machen kann; aber für ein bestimmtes Feld geht es. Und dieses Feld kann auch ein Array sein – was wir zur Nutzung benötigen.ANY ANY.

Klingt etwas seltsam, aber in der Darstellung ist es ganz einfach.

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

MIT REKURSIVER CTE T ALS (
  SELECT
    ARRAY[id] id$
  FROM
    hier
  WHERE
    id = 5300
UNION ALL
  SELECT
    ARRAY(
      SELECT
        id
      FROM
        hier
      WHERE
        pid = ANY(T.id$)
    ) id$
  FROM
    T
  WHERE
    coalesce(id$, '{}')  '{}' -- Austrittsbedingung für die Schleife - leeres Array
)
SELECT
  unnest(id$) id
FROM
  T;

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
[auf explain.tensor.ru anschauen]

Und hier ist das Wichtigste nicht einmal der Gewinn von 1,5-fach an Zeit, sondern dass wir weniger Puffer abgezogen haben, da wir nur 5 statt 30 Zugriffe auf den Index haben!

Ein zusätzlicher Vorteil ist, dass nach dem letzten unnest die Identifikatoren in „Levels“ sortiert bleiben.

Knotenmerkmal

Der nächste Punkt, der zur Verbesserung der Leistung beitragen kann, ist dass „Blätter“ keine Kinder haben können, das heißt, dass wir für sie „nach unten“ überhaupt nichts suchen müssen. In der Formulierung unserer Aufgabe bedeutet das, dass wenn wir der Kette von Abteilungen gefolgt sind und zu einem Mitarbeiter gelangen, es keinen Sinn macht, weiter in diesem Zweig zu suchen.

Lassen Sie uns in unsere Tabelle ein zusätzliches boolean-Feld, das uns sofort sagt, ob dieser spezielle Eintrag in unserem Baum ein „Knoten“ ist – das heißt, ob er überhaupt Nachkommen haben kann.

ALTER TABLE hier
  ADD COLUMN branch boolean;

UPDATE
  hier T
SET
  branch = TRUE
WHERE
  EXISTS(
    SELECT
      NULL
    FROM
      hier
    WHERE
      pid = T.id
    LIMIT 1
);
-- Anfrage erfolgreich ausgeführt: 3033 Zeilen in 42 ms geändert.

Ausgezeichnet! Es stellt sich heraus, dass nur etwas über 30 % aller Elemente des Baums Nachkommen haben.

Jetzt wenden wir eine etwas andere Mechanik an – Verbindungen mit dem rekursiven Teil durch LATERAL, was es uns ermöglicht, sofort auf die Felder der rekursiven „Tabelle“ zuzugreifen, während wir die Aggregatfunktion mit Filterbedingungen nach Knotenmerkmal verwenden, um die Schlüsselauswahl zu reduzieren:

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.

MIT REKURSIVER CTE T ALS (
  SELECT
    array_agg(id) id$
  , array_agg(id) FILTER(WHERE branch) ns$
  FROM
    hier
  WHERE
    id = 5300
UNION ALL
  SELECT
    X.*
  FROM
    T
  JOIN LATERAL (
    SELECT
      array_agg(id) id$
    , array_agg(id) FILTER(WHERE branch) ns$
    FROM
      hier
    WHERE
      pid = ANY(T.ns$)
  ) X
    ON coalesce(T.ns$, '{}')  '{}'
)
SELECT
  unnest(id$) id
FROM
  T;

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
[auf explain.tensor.ru anschauen]

Wir konnten einen weiteren Zugriff auf den Index einsparen und haben mehr als das Zwei- bis Dreifache an Volumen gewonnen das abgezogen wird.

#2. Вернемся к корням

Dieser Algorithmus wird nützlich sein, wenn Sie Datensätze für alle Elemente „aufwärts im Baum“ sammeln müssen, während Sie Informationen darüber bewahren, mit welchem Ursprungsblatt (und welchen Werten) es in die Auswahl gelangt ist – beispielsweise zur Erstellung eines konsolidierten Berichts mit Aggregation an Knoten.

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
Die folgenden Informationen sind als Proof-of-Concept zu verstehen, da die Abfrage sehr umfangreich ist. Wenn sie jedoch in Ihrer Datenbank dominiert, sollten Sie überlegen, ähnliche Methoden anzuwenden.

Lass uns mit ein paar einfachen Aussagen beginnen:

  • Einen Eintrag aus der Datenbank sollte man besser nur einmal lesen.
  • Einträge aus der Datenbank lässt sich effizienter als "Batch" lesen, als einzeln.

Nun versuchen wir, die benötigte Abfrage zu konstruieren.

Schritt 1

Offensichtlich müssen wir bei der Initialisierung der Rekursion (wohin auch immer sie führt!) die Einträge der Blätter anhand einer Menge von Ausgangsidentifikatoren auslesen:

WITH RECURSIVE tree AS (
  SELECT
    rec -- das ist der vollständige Eintrag der Tabelle
  , id::text chld -- das ist das "Set" der Eingangsblätter, die hierher geführt haben
  FROM
    hier rec
  WHERE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNION ALL
  ...

Falls es jemand seltsam fand, dass das "Set" als Zeichenfolge und nicht als Array gespeichert wird, gibt es dafür eine einfache Erklärung. Für Zeichenfolgen gibt es eine integrierte aggregierende "verknüpfende" Funktion string_agg, aber für Arrays gibt es keine. Obwohl es auch nicht schwer ist, selbst zu implementieren..

Schritt 2

Nun möchten wir eine Menge von ID der Abschnitte erhalten, die weiter ausgelesen werden müssen. Sie werden fast immer in verschiedenen Einträgen des Ausgangssatzes dupliziert, daher sollten wir sie gruppieren, wobei wir die Informationen über die Quellblätter beibehalten.

Aber hier warten drei unangenehme Probleme auf uns:

  1. Der "Unterrekursive" Teil der Abfrage darf keine aggregierenden Funktionen mit GROUP BY.
  2. enthalten. Der Zugriff auf die rekursive "Tabelle" darf sich nicht in einer verschachtelten Unterabfrage befinden.
  3. Die Abfrage im rekursiven Teil darf keine CTE enthalten.

Glücklicherweise lassen sich all diese Probleme recht einfach umgehen. Lassen Sie uns mit dem Ende beginnen.

CTE im rekursiven Teil

So sollte nicht funktioniert:

WITH RECURSIVE tree AS (
  ...
UNION ALL
  WITH T (...)
  SELECT ...
)

Und so – funktioniert es, die Klammern lösen das Problem!

WITH RECURSIVE tree AS (
  ...
UNION ALL
  (
    WITH T (...)
    SELECT ...
  )
)

Verschachtelte Abfrage zur rekursiven "Tabelle"

Hmm… Der Zugriff auf die rekursive CTE darf sich nicht in einer verschachtelten Abfrage befinden. Aber es kann innerhalb der CTE sein! Und die verschachtelte Abfrage kann bereits auf diese CTE zugreifen!

GROUP BY innerhalb der Rekursion

Unangenehm, aber… Wir haben einen einfachen Weg, wie wir GROUP BY mit Hilfe von DISTINCT ON und Fensterfunktionen simulieren können!

SELECT
  (rec).pid id
, string_agg(chld::text, ',') chld
FROM
  tree
WHERE
  (rec).pid IS NOT NULL
GROUP BY 1 -- funktioniert nicht!

Aber so – funktioniert es!

SELECT DISTINCT ON((rec).pid)
  (rec).pid id
, string_agg(chld::text, ',') OVER(PARTITION BY (rec).pid) chld
FROM
  tree
WHERE
  (rec).pid IS NOT NULL

Jetzt sehen wir, warum die numerische ID in Text umgewandelt wurde — damit sie durch Kommas verbunden werden können!

Schritt 3

Für das Finale bleibt uns nur noch wenig übrig:

  • wir lesen die Einträge der „Abschnitte“ nach einer Gruppe von IDs aus
  • wir verknüpfen die ausgelesenen Abschnitte mit den „Sätzen“ der Ausgangsblätter
  • wir „entfalten“ die Satz-Reihe mit Hilfe von unnest(string_to_array(chld, ',')::integer[])

WITH RECURSIVE tree AS (
  SELECT
    rec
  , id::text chld
  FROM
    hier rec
  WHERE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNION ALL
  (
    WITH prnt AS (
      SELECT DISTINCT ON((rec).pid)
        (rec).pid id
      , string_agg(chld::text, ',') OVER(PARTITION BY (rec).pid) chld
      FROM
        tree
      WHERE
        (rec).pid IS NOT NULL
    )
    , nodes AS (
      SELECT
        rec
      FROM
        hier rec
      WHERE
        id = ANY(ARRAY(
          SELECT
            id
          FROM
            prnt
        ))
    )
    SELECT
      nodes.rec
    , prnt.chld
    FROM
      prnt
    JOIN
      nodes
        ON (nodes.rec).id = prnt.id
  )
)
SELECT
  unnest(string_to_array(chld, ',')::integer[]) leaf
, (rec).* 
FROM
  tree;

PostgreSQL Antipatterns: Wie tief geht der Kaninchenbau? Lassen Sie uns die Hierarchie durchgehen.
[auf explain.tensor.ru anschauen]

Quelle: habr.com

60GB SSD 8Gb DDR4