Profondità discendente ricorsiva PostgreSQL


15

Devo calcolare la profondità di un discendente dal suo antenato. Quando un record ha object_id = parent_id = ancestor_id, è considerato un nodo radice (l'antenato). Ho cercato di far funzionare una WITH RECURSIVEquery con PostgreSQL 9.4 .

Non controllo i dati o le colonne. Lo schema di dati e tabelle proviene da un'origine esterna. La tabella è in continua crescita . In questo momento da circa 30k record al giorno. Qualunque nodo nella struttura può mancare e verranno estratti da una fonte esterna ad un certo punto. Di solito vengono estratti in created_at DESCordine, ma i dati vengono estratti con processi in background asincroni.

Inizialmente avevamo una soluzione di codice per questo problema, ma ora con più di 5 milioni di righe, il completamento richiede quasi 30 minuti.

Definizione della tabella di esempio e dati di test:

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

Nota che object_idnon è unico, ma la combinazione (customer_id, object_id)è unica.
Esecuzione di una query come questa:

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;

Vorrei che la generationcolonna fosse impostata come profondità calcolata. Quando viene aggiunto un nuovo record, la colonna di generazione viene impostata come -1. Ci sono alcuni casi in cui a parent_idpotrebbe non essere stato ancora estratto. Se il parent_idnon esiste, dovrebbe lasciare la colonna di generazione impostata su -1.

I dati finali dovrebbero apparire come:

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

Il risultato della query dovrebbe essere quello di aggiornare la colonna di generazione alla profondità corretta.

Ho iniziato a lavorare dalle risposte a questa domanda correlata su SO .


Quindi vuoi andare updateal tavolo con il risultato del tuo CTE ricorsivo?
a_horse_with_no_name

Sì, vorrei che la colonna della generazione fosse AGGIORNATA su quale sia la sua profondità. Se non è presente alcun elemento padre (objects.parent_id non corrisponde a nessun object.object_id) la generazione rimarrà come -1.

Quindi ancestor_idè già impostato, quindi devi solo assegnare la generazione dal CTE.depth?

Sì, object_id, parent_id e ancestor_id sono già impostati dai dati che otteniamo dall'API. Vorrei impostare la colonna di generazione su qualunque sia la profondità. Un'altra nota, object_id non è univoco, poiché customer_id 1 potrebbe avere object_id 1 e customer_id 2 potrebbe avere object_id 1. L'ID principale nella tabella è unico.

Si tratta di un aggiornamento singolo o stai aggiungendo continuamente a una tabella in crescita? Sembra quest'ultimo caso. Fa una grande differenza. E possono mancare (ancora) solo i nodi radice o qualsiasi nodo nella struttura?
Erwin Brandstetter,

Risposte:


14

La query che hai è sostanzialmente corretta. L'unico errore è nella seconda parte (ricorsiva) del CTE in cui hai:

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

Dovrebbe essere il contrario:

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

Vuoi unire gli oggetti con i loro genitori (che sono già stati trovati).

Quindi la query che calcola la profondità può essere scritta (nient'altro è cambiato, solo formattazione):

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

Per l'aggiornamento, è sufficiente sostituire l'ultimo SELECT, con il UPDATE, unendo il risultato del cte, alla tabella:

-- 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

Testato su SQLfiddle

Commenti aggiuntivi:

  • il ancestor_ide il parent_idnon sono necessari per essere nell'elenco di selezione (l'antenato è ovvio, genitore un po 'difficile da capire perché), quindi puoi tenerli nella SELECTquery se vuoi ma puoi rimuoverli in modo sicuro dal file UPDATE.
  • l' (customer_id, object_id)sembra un candidato per un UNIQUEvincolo. Se i tuoi dati sono conformi a questo, aggiungi un tale vincolo. I join eseguiti nel CTE ricorsivo non avrebbero senso se non fosse univoco (altrimenti un nodo potrebbe avere 2 genitori).
  • se aggiungi quel vincolo, (customer_id, parent_id)sarebbe un candidato per un FOREIGN KEYvincolo che REFERENCES(unico) (customer_id, object_id). Molto probabilmente non vorrai aggiungere quel vincolo FK, dato che dalla tua descrizione stai aggiungendo nuove righe e alcune righe possono fare riferimento ad altre che non sono state ancora aggiunte.
  • Ci sono certamente problemi con l'efficienza della query, se verrà eseguita in una tabella di grandi dimensioni. Non nella prima esecuzione, poiché quasi l'intera tabella verrà comunque aggiornata. Ma la seconda volta, ti consigliamo di considerare solo le nuove righe (e quelle che non sono state toccate dalla prima esecuzione) per l'aggiornamento. Il CTE così com'è dovrà creare un grande risultato.
    L' AND o.generation = -1aggiornamento finale farà in modo che le righe che sono state aggiornate nella prima esecuzione non vengano nuovamente aggiornate, ma il CTE è ancora una parte costosa.

Di seguito è un tentativo di affrontare questi problemi: migliorare il CTE in modo da considerare il minor numero di righe possibile e utilizzare (customer_id, obejct_id)invece di (id)identificare le righe (quindi idviene completamente rimosso dalla query. Può essere utilizzato come primo aggiornamento o successivo:

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

Nota come il CTE ha 3 parti. I primi due sono le parti stabili. La prima parte trova i nodi radice che non sono stati aggiornati in precedenza e che hanno ancora generation=-1quindi devono essere nuovi nodi aggiunti. La seconda parte trova i figli (con generation=-1) dei nodi padre che sono stati precedentemente aggiornati.
La terza parte ricorsiva trova tutti i discendenti delle prime due parti, come prima.

Testato su SQLfiddle-2


3

@ypercube fornisce già un'ampia spiegazione, quindi taglierò al sodo ciò che devo aggiungere.

Se il parent_idnon esiste, dovrebbe lasciare la colonna di generazione impostata su -1.

Suppongo che questo dovrebbe applicarsi in modo ricorsivo, cioè il resto dell'albero ha sempregeneration = -1 dopo ogni nodo mancante.

Se qualche nodo nell'albero può mancare (ancora) dobbiamo trovare le righe con generation = -1quello ...
... sono nodi root
... o avere un genitore con generation > -1.
E attraversa l'albero da lì. Anche i nodi figlio di questa selezione devono avere generation = -1.

Prendi il generationgenitore incrementato di uno o torna a 0 per i nodi radice:

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;

La parte non ricorsiva è una sola in SELECTquesto modo, ma logicamente equivalente alle due unione di @ ypercube SELECT. Non sei sicuro di quale sia il più veloce, dovrai testarlo.
Il punto molto più importante per le prestazioni è:

Indice!

Se aggiungi ripetutamente righe a una tabella grande in questo modo, aggiungi un indice parziale :

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

Ciò consentirà di ottenere di più per le prestazioni rispetto a tutti gli altri miglioramenti discussi finora - per piccole aggiunte ripetute a un grande tavolo.

Ho aggiunto la condizione dell'indice alla parte ricorsiva del CTE (anche se logicamente ridondante) per aiutare il pianificatore di query a capire che l'indice parziale è applicabile.

Inoltre dovresti probabilmente avere anche il UNIQUEvincolo su (object_id, customer_id)quel @ypercube già menzionato. Oppure, se non puoi imporre l'unicità per qualche motivo (perché?) Aggiungi invece un indice semplice. L'ordine delle colonne dell'indice è importante, a proposito:


1
Aggiungerò gli indici e i vincoli suggeriti da te e da @ypercube. Guardando attraverso i dati, non vedo alcun motivo per cui non potrebbero accadere (tranne la chiave esterna poiché a volte parent_id non è ancora impostato). Inoltre, imposterò la colonna di generazione su nullable e l'impostazione predefinita sarà NULL anziché -1. Quindi non avrò molti filtri "-1" e gli indici parziali possono essere DOVE la generazione È NULL, ecc.
Diggity

@Diggity: NULL dovrebbe funzionare bene se si adatta il resto, sì.
Erwin Brandstetter,

@Erwin carino. Inizialmente pensavo simile a te. Un indice ON objects (customer_id, parent_id, object_id) WHERE generation = -1;e forse un altro ON objects (customer_id, object_id) WHERE generation > -1;. L'aggiornamento dovrà anche "cambiare" tutte le righe aggiornate da un indice a un altro, quindi non è sicuro che questa sia una buona idea per l'esecuzione iniziale di UPDATE.
ypercubeᵀᴹ

L'indicizzazione per query ricorsive può essere davvero difficile.
ypercubeᵀᴹ
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.