PostgreSQL Recursive Descendant Depth


15

Ich muss die Tiefe eines Nachkommens von seinem Vorfahren berechnen. Wenn ein Datensatz vorhanden ist object_id = parent_id = ancestor_id, wird er als Stammknoten (der Vorfahr) betrachtet. Ich habe versucht, eine WITH RECURSIVEAbfrage mit PostgreSQL 9.4 zum Laufen zu bringen .

Ich kontrolliere weder die Daten noch die Spalten. Das Daten- und Tabellenschema stammt aus einer externen Quelle. Der Tisch wächst stetig . Momentan um ca. 30.000 Datensätze pro Tag. Jeder Knoten in der Baumstruktur kann fehlen und wird irgendwann von einer externen Quelle abgerufen. Sie werden normalerweise der created_at DESCReihe nach abgerufen , aber die Daten werden mit asynchronen Hintergrundjobs abgerufen.

Wir hatten ursprünglich eine Codelösung für dieses Problem, aber jetzt, da wir über 5 Millionen Zeilen verfügen, dauert die Ausführung fast 30 Minuten.

Beispiel Tabellendefinition und Testdaten:

CREATE TABLE objects (
  id          serial NOT NULL PRIMARY KEY,
  customer_id integer NOT NULL,
  object_id   integer NOT NULL,
  parent_id   integer,
  ancestor_id integer,
  generation  integer NOT NULL DEFAULT 0
);

INSERT INTO objects(id, customer_id , object_id, parent_id, ancestor_id, generation)
VALUES (2, 1, 2, 1, 1, -1), --no parent yet
       (3, 2, 3, 3, 3, -1), --root node
       (4, 2, 4, 3, 3, -1), --depth 1
       (5, 2, 5, 4, 3, -1), --depth 2
       (6, 2, 6, 5, 3, -1), --depth 3
       (7, 1, 7, 7, 7, -1), --root node
       (8, 1, 8, 7, 7, -1), --depth 1
       (9, 1, 9, 8, 7, -1); --depth 2

Beachten Sie, dass dies object_idnicht eindeutig ist, die Kombination (customer_id, object_id)jedoch eindeutig ist.
Ausführen einer Abfrage wie folgt:

WITH RECURSIVE descendants(id, customer_id, object_id, parent_id, ancestor_id, depth) AS (
  SELECT id, customer_id, object_id, parent_id, ancestor_id, 0
  FROM objects
  WHERE object_id = parent_id

  UNION

  SELECT o.id, o.customer_id, o.object_id, o.parent_id, o.ancestor_id, d.depth + 1
  FROM objects o
  INNER JOIN descendants d ON d.parent_id = o.object_id
  WHERE
    d.id <> o.id
  AND
    d.customer_id = o.customer_id
) SELECT * FROM descendants d;

Ich möchte, dass die generationSpalte als berechnete Tiefe festgelegt wird. Wenn ein neuer Datensatz hinzugefügt wird, wird die Generierungsspalte auf -1 gesetzt. Es gibt einige Fälle, in denen ein parent_idmöglicherweise noch nicht gezogen wurde. Wenn das parent_idnicht vorhanden ist, sollte die Generierungsspalte auf -1 gesetzt bleiben.

Die endgültigen Daten sollten wie folgt aussehen:

id | customer_id | object_id | parent_id | ancestor_id | generation
2    1             2           1           1            -1
3    2             3           3           3             0
4    2             4           3           3             1
5    2             5           4           3             2
6    2             6           5           3             3
7    1             7           7           7             0
8    1             8           7           7             1
9    1             9           8           7             2

Das Ergebnis der Abfrage sollte sein, die Generierungsspalte auf die richtige Tiefe zu aktualisieren.

Ich begann mit den Antworten auf diese verwandte Frage zu SO .


Sie wollen also updatemit dem Ergebnis Ihres rekursiven CTE zur Tabelle?
a_horse_with_no_name

Ja, ich möchte, dass die Generierungsspalte auf den neuesten Stand gebracht wird. Wenn es kein übergeordnetes Objekt gibt (objects.parent_id stimmt nicht mit objects.object_id überein), bleibt die Generierung bei -1.

ancestor_idIst das also schon eingestellt, braucht man nur die Generation von der CTE.depth zuzuordnen?

Ja, object_id, parent_id und ancestor_id sind bereits anhand der Daten festgelegt, die wir von der API erhalten. Ich möchte die Generierungsspalte auf eine beliebige Tiefe einstellen. Eine weitere Anmerkung: Die Objekt-ID ist nicht eindeutig, da Kunden-ID 1 Objekt-ID 1 und Kunden-ID 2 Objekt-ID 1 haben könnte. Die primäre ID in der Tabelle ist eindeutig.

Handelt es sich um ein einmaliges Update oder erweitern Sie Ihre Tabelle kontinuierlich? Scheint wie der letztere Fall. Macht einen großen Unterschied. Und können (noch) nur Wurzelknoten fehlen oder irgendein Knoten im Baum?
Erwin Brandstetter

Antworten:


14

Die Abfrage, die Sie haben, ist im Grunde richtig. Der einzige Fehler liegt im zweiten (rekursiven) Teil des CTE, in dem Sie Folgendes haben:

INNER JOIN descendants d ON d.parent_id = o.object_id

Es sollte umgekehrt sein:

INNER JOIN descendants d ON d.object_id = o.parent_id 

Sie möchten die Objekte mit ihren Eltern (die bereits gefunden wurden) verbinden.

So kann die Abfrage, die die Tiefe berechnet, geschrieben werden (nichts anderes geändert, nur die Formatierung):

-- calculate generation / depth, no updates
WITH RECURSIVE descendants
  (id, customer_id, object_id, parent_id, ancestor_id, depth) AS
 AS ( SELECT id, customer_id, object_id, parent_id, ancestor_id, 0
      FROM objects
      WHERE object_id = parent_id

      UNION ALL

      SELECT o.id, o.customer_id, o.object_id, o.parent_id, o.ancestor_id, d.depth + 1
      FROM objects o
      INNER JOIN descendants d ON  d.customer_id = o.customer_id
                               AND d.object_id = o.parent_id  
      WHERE d.id <> o.id
    ) 
SELECT * 
FROM descendants d
ORDER BY id ;

Für das Update ersetzen Sie einfach den letzten SELECTmit dem UPDATE, der das Ergebnis des CTE verbindet, zurück in die Tabelle:

-- update nodes
WITH RECURSIVE descendants
    -- nothing changes here except
    -- ancestor_id and parent_id 
    -- which can be omitted form the select lists
    ) 
UPDATE objects o 
SET generation = d.depth 
FROM descendants d
WHERE o.id = d.id 
  AND o.generation = -1 ;          -- skip unnecessary updates

Getestet mit SQLfiddle

Zusätzliche Kommentare:

  • Das ancestor_idund das müssen parent_idnicht in der Auswahlliste enthalten sein (Vorfahr ist offensichtlich, Eltern ein bisschen schwierig herauszufinden, warum), damit Sie sie in der SELECTAbfrage behalten können, wenn Sie möchten, aber Sie können sie sicher aus der Liste entfernen UPDATE.
  • Das (customer_id, object_id)scheint ein Kandidat für eine UNIQUEEinschränkung zu sein. Wenn Ihre Daten dies erfüllen, fügen Sie eine solche Einschränkung hinzu. Die im rekursiven CTE durchgeführten Verknüpfungen wären nicht sinnvoll, wenn sie nicht eindeutig wären (ein Knoten könnte andernfalls zwei Eltern haben).
  • Wenn Sie diese Einschränkung hinzufügen, ist der (customer_id, parent_id)ein Kandidat für eine FOREIGN KEYEinschränkung, REFERENCESdie der (eindeutige) ist (customer_id, object_id). Sie möchten diese FK-Einschränkung höchstwahrscheinlich nicht hinzufügen, da Sie Ihrer Beschreibung nach neue Zeilen hinzufügen und einige Zeilen auf andere verweisen können, die noch nicht hinzugefügt wurden.
  • Es gibt sicherlich Probleme mit der Effizienz der Abfrage, wenn sie in einer großen Tabelle ausgeführt wird. Nicht im ersten Durchgang, da sowieso fast die gesamte Tabelle aktualisiert wird. Beim zweiten Mal möchten Sie jedoch, dass nur neue Zeilen (und diejenigen, die beim ersten Durchlauf nicht berührt wurden) für die Aktualisierung berücksichtigt werden. Der CTE wird, wie er ist, ein großes Ergebnis erzielen müssen.
    Das AND o.generation = -1abschließende Update stellt sicher, dass die Zeilen, die im ersten Durchgang aktualisiert wurden, nicht erneut aktualisiert werden, der CTE jedoch immer noch ein teurer Teil ist.

Mit dem folgenden Befehl wird versucht, diese Probleme zu beheben: Verbessern Sie den CTE, um so wenig Zeilen wie möglich zu berücksichtigen, und verwenden Sie ihn (customer_id, obejct_id)statt (id)zum Identifizieren von Zeilen (wird also idvollständig aus der Abfrage entfernt. Er kann als erste Aktualisierung oder als nachfolgende verwendet werden:

WITH RECURSIVE descendants 
  (customer_id, object_id, depth) 
 AS ( SELECT customer_id, object_id, 0
      FROM objects
      WHERE object_id = parent_id
        AND generation = -1

      UNION ALL

      SELECT o.customer_id, o.object_id, p.generation + 1
      FROM objects o
        JOIN objects p ON  p.customer_id = o.customer_id
                       AND p.object_id = o.parent_id
                       AND p.generation > -1
      WHERE o.generation = -1

      UNION ALL

      SELECT o.customer_id, o.object_id, d.depth + 1
      FROM objects o
      INNER JOIN descendants d ON  o.customer_id = d.customer_id
                               AND o.parent_id = d.object_id
      WHERE o.parent_id <> o.object_id
        AND o.generation = -1
    )
UPDATE objects o 
SET generation = d.depth 
FROM descendants d
WHERE o.customer_id = d.customer_id
  AND o.object_id = d.object_id
  AND o.generation = -1        -- this is not really needed

Beachten Sie, wie der CTE 3 Teile hat. Die ersten beiden sind die stabilen Teile. Im ersten Teil werden die Stammknoten gesucht, die noch nicht aktualisiert wurden, und generation=-1müssen daher neu hinzugefügt werden. Der 2. Teil findet Kinder (mit generation=-1) von Elternknoten, die zuvor aktualisiert wurden.
Der dritte, rekursive Teil findet wie bisher alle Nachkommen der ersten beiden Teile.

Getestet mit SQLfiddle-2


3

@ypercube bietet bereits ausführliche Erklärungen, daher komme ich auf den Punkt , was ich hinzufügen muss.

Wenn das parent_idnicht vorhanden ist, sollte die Generierungsspalte auf -1 gesetzt bleiben.

Ich nehme an, dies soll rekursiv anzuwenden, dh der Rest des Baumes immer hat generation = -1nach jedem fehlenden Knoten.

Wenn ein Knoten im Baum (noch) fehlen kann, müssen wir Zeilen finden, bei generation = -1denen
es sich um Stammknoten handelt
oder bei denen es sich um einen übergeordneten Knoten handelt generation > -1.
Und von dort den Baum durchqueren. Untergeordnete Knoten dieser Auswahl müssen ebenfalls enthalten sein generation = -1.

Nehmen Sie das generationvon dem übergeordneten Knoten, das um eins erhöht wurde, oder fallen Sie für Stammknoten auf 0 zurück:

WITH RECURSIVE tree AS (
   SELECT c.customer_id, c.object_id, COALESCE(p.generation + 1, 0) AS depth
   FROM   objects      c
   LEFT   JOIN objects p ON c.customer_id = p.customer_id
                        AND c.parent_id   = p.object_id
                        AND p.generation > -1
   WHERE  c.generation = -1
   AND   (c.parent_id = c.object_id OR p.generation > -1)
       -- root node ... or parent with generation > -1

   UNION ALL
   SELECT customer_id, c.object_id, p.depth + 1
   FROM   objects c
   JOIN   tree    p USING (customer_id)
   WHERE  c.parent_id  = p.object_id
   AND    c.parent_id <> c.object_id  -- exclude root nodes
   AND    c.generation = -1           -- logically redundant, but see below!
   )
UPDATE objects o 
SET    generation = t.depth
FROM   tree t
WHERE  o.customer_id = t.customer_id
AND    o.object_id   = t.object_id;

Der nicht-rekursive Teil ist auf SELECTdiese Weise ein einzelner , entspricht jedoch logischerweise den beiden union'ed von @ ypercube SELECT. Nicht sicher, welche schneller ist, müssen Sie testen.
Der wesentlich wichtigere Punkt für die Leistung ist:

Index!

Wenn Sie einer großen Tabelle auf diese Weise wiederholt Zeilen hinzufügen, fügen Sie einen Teilindex hinzu :

CREATE INDEX objects_your_name_idx ON objects (customer_id, parent_id, object_id)
WHERE  generation = -1;

Dies führt zu mehr Leistung als alle anderen bisher diskutierten Verbesserungen - für wiederholte kleine Ergänzungen eines großen Tisches.

Ich habe die Indexbedingung zum rekursiven Teil des CTE hinzugefügt (obwohl logisch redundant), damit der Abfrageplaner versteht, dass der Teilindex anwendbar ist.

Außerdem sollten Sie wahrscheinlich auch die bereits erwähnte UNIQUEEinschränkung für (object_id, customer_id)@ypercube haben. Oder, wenn Sie aus irgendeinem Grund keine Eindeutigkeit festlegen können (warum?), Fügen Sie stattdessen einen einfachen Index hinzu. Die Reihenfolge der Indexspalten ist wichtig, übrigens:


1
Ich werde die von Ihnen und @ypercube vorgeschlagenen Indizes und Einschränkungen hinzufügen. Wenn ich mir die Daten ansehe, sehe ich keinen Grund, warum sie nicht auftreten konnten (außer dem Fremdschlüssel, da manchmal die parent_id noch nicht gesetzt ist). Ich werde auch festlegen, dass die Generierungsspalte nullwertfähig und der Standardwert NULL anstelle von -1 ist. Dann werde ich nicht viele "-1" -Filter haben und die Teilindizes können WHERE generation IS NULL usw. sein.
Diggity

@ Diggity: NULL sollte gut funktionieren, wenn Sie den Rest anpassen, ja.
Erwin Brandstetter

@ Erwin schön. Ich dachte ursprünglich ähnlich wie du. Ein Index ON objects (customer_id, parent_id, object_id) WHERE generation = -1;und vielleicht noch einer ON objects (customer_id, object_id) WHERE generation > -1;. Das Update muss auch alle aktualisierten Zeilen von einem Index auf einen anderen "umschalten". Sie sind sich also nicht sicher, ob dies für den ersten Start von UPDATE eine gute Idee ist.
ypercubeᵀᴹ

Die Indizierung für rekursive Abfragen kann sehr schwierig sein.
ypercubeᵀᴹ
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.