Profondeur récursive descendante de PostgreSQL

15

J'ai besoin de calculer la profondeur d'un descendant de son ancêtre. Lorsqu'un enregistrement a object_id = parent_id = ancestor_id, il est considéré comme un nœud racine (l'ancêtre). J'ai essayé de lancer une WITH RECURSIVErequête avec PostgreSQL 9.4 .

Je ne contrôle pas les données ou les colonnes. Le schéma de données et de table provient d'une source externe. La table ne cesse de croître . À l'heure actuelle, environ 30 000 enregistrements par jour. Tous les nœuds de l'arborescence peuvent être manquants et ils seront extraits d'une source externe à un moment donné. Ils sont généralement extraits dans l' created_at DESCordre, mais les données sont extraites avec des tâches d'arrière-plan asynchrones.

Nous avions initialement une solution de code à ce problème, mais ayant maintenant 5M + lignes, cela prend presque 30 minutes pour terminer.

Exemple de définition de table et de données de 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

Notez que ce object_idn'est pas unique, mais la combinaison (customer_id, object_id)est unique.
Exécuter une requête comme celle-ci:

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;

Je voudrais que la generationcolonne soit définie comme la profondeur qui a été calculée. Lorsqu'un nouvel enregistrement est ajouté, la colonne de génération est définie sur -1. Il y a des cas où un parent_idn'a peut - être pas encore été retiré. Si le parent_idn'existe pas, il doit laisser la colonne de génération définie sur -1.

Les données finales devraient ressembler à:

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

Le résultat de la requête doit être de mettre à jour la colonne de génération à la profondeur correcte.

J'ai commencé à travailler à partir des réponses à cette question connexe sur SO .

Diggity
la source
Vous voulez donc updatele tableau avec le résultat de votre CTE récursif?
a_horse_with_no_name
Oui, je voudrais que la colonne de génération soit MISE À JOUR de sa profondeur. S'il n'y a pas de parent (objects.parent_id ne correspond à aucun objects.object_id) la génération resterait comme -1.
Donc, le ancestor_idest déjà défini, vous n'avez donc qu'à affecter la génération à partir de CTE.depth?
Oui, object_id, parent_id et ancestor_id sont déjà définis à partir des données que nous obtenons de l'API. Je voudrais définir la colonne de génération quelle que soit la profondeur. Une autre remarque, object_id n'est pas unique, car customer_id 1 pourrait avoir object_id 1 et customer_id 2 pourrait avoir object_id 1. L'ID principal sur la table est unique.
S'agit-il d'une mise à jour ponctuelle ou ajoutez-vous continuellement à une table en pleine croissance? On dirait le dernier cas. Fait une grande différence. Et ne peut-il pas (encore) manquer de nœuds racine ou n'importe quel nœud de l'arbre?
Erwin Brandstetter

Réponses:

14

La requête que vous avez est fondamentalement correcte. La seule erreur se trouve dans la deuxième partie (récursive) du CTE où vous avez:

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

Il devrait être l'inverse:

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

Vous souhaitez joindre les objets à leurs parents (qui ont déjà été trouvés).

Ainsi, la requête qui calcule la profondeur peut être écrite (rien d'autre n'a changé, seulement le formatage):

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

Pour la mise à jour, vous remplacez simplement le dernier SELECT, par le UPDATE, en joignant le résultat du cte, de retour au tableau:

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

Testé sur SQLfiddle

Commentaires supplémentaires:

  • ancestor_idIl parent_idn'est pas nécessaire que le et le soient dans la liste de sélection (l'ancêtre est évident, le parent est un peu difficile à comprendre pourquoi), vous pouvez donc les conserver dans la SELECTrequête si vous le souhaitez mais vous pouvez les supprimer en toute sécurité du UPDATE.
  • le (customer_id, object_id)semble être un candidat pour une UNIQUEcontrainte. Si vos données sont conformes à cela, ajoutez une telle contrainte. Les jointures effectuées dans le CTE récursif n'auraient aucun sens si elles n'étaient pas uniques (un nœud pourrait avoir 2 parents sinon).
  • si vous ajoutez cette contrainte, la (customer_id, parent_id)serait candidate à une FOREIGN KEYcontrainte qui REFERENCESla (unique) (customer_id, object_id). Cependant, vous ne voulez probablement pas ajouter cette contrainte FK, car d'après votre description, vous ajoutez de nouvelles lignes et certaines lignes peuvent en référencer d'autres qui n'ont pas encore été ajoutées.
  • Il y a certainement des problèmes avec l'efficacité de la requête, si elle doit être effectuée dans une grande table. Pas lors de la première exécution, car la quasi-totalité du tableau sera de toute façon mise à jour. Mais la deuxième fois, vous souhaiterez que seules les nouvelles lignes (et celles qui n'ont pas été touchées par la première exécution) soient prises en compte pour la mise à jour. Le CTE tel qu'il devra construire un gros résultat.
    La mise AND o.generation = -1à jour finale s'assurera que les lignes qui ont été mises à jour lors de la première exécution ne seront pas mises à jour à nouveau, mais le CTE est toujours une partie coûteuse.

Ce qui suit est une tentative pour résoudre ces problèmes: améliorer le CTE de manière à prendre en compte le moins de lignes possible et à utiliser (customer_id, obejct_id)au lieu d' (id)identifier les lignes (il idest donc complètement supprimé de la requête. Il peut être utilisé comme première mise à jour ou ultérieure:

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

Notez comment le CTE comprend 3 parties. Les deux premiers sont les parties stables. La 1ère partie trouve les nœuds racine qui n'ont pas été mis à jour auparavant et qui le sont encore generation=-1donc ils doivent être des nœuds nouvellement ajoutés. La 2e partie trouve les enfants (avec generation=-1) des nœuds parents qui ont été précédemment mis à jour.
La 3ème partie, récursive, retrouve tous les descendants des deux premières parties, comme précédemment.

Testé sur SQLfiddle-2

ypercubeᵀᴹ
la source
3

@ypercube fournit déjà de nombreuses explications, je vais donc aller au bout de ce que je dois ajouter.

Si la parent_id n'existe pas, il doit laisser la colonne de génération définie sur -1.

Je suppose que cela est censé appliquer récursive, à savoir le reste de l'arbre toujours ageneration = -1 après tout noeud manquant.

Si un nœud de l'arborescence peut (encore) être manquant, nous devons trouver des lignes avec generation = -1ce ...
... sont des nœuds racine
... ou avoir un parent avec generation > -1.
Et traversez l'arbre à partir de là. Les nœuds enfants de cette sélection doivent avoirgeneration = -1 .

Prenez le generationparent incrémenté de un ou retombez à 0 pour les nœuds racine:

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 partie non récursive est unique de SELECTcette façon, mais logiquement équivalente aux deux unions de @ ypercubeSELECT . Vous ne savez pas lequel est le plus rapide, vous devrez le tester.
Le point beaucoup plus important pour la performance est:

Indice!

Si vous ajoutez à plusieurs reprises des lignes à une grande table de cette façon, ajoutez un index partiel :

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

Cela permettra d'obtenir plus de performances que toutes les autres améliorations discutées jusqu'à présent - pour de petits ajouts répétés à une grande table.

J'ai ajouté la condition d'index à la partie récursive du CTE (même si elle est logiquement redondante) pour aider le planificateur de requêtes à comprendre que l'index partiel est applicable.

De plus, vous devriez probablement également avoir la UNIQUEcontrainte sur (object_id, customer_id)ce @ypercube déjà mentionné. Ou, si vous ne pouvez pas imposer l'unicité pour une raison (pourquoi?), Ajoutez plutôt un index simple. L'ordre des colonnes d'index est important, entre autres:

Erwin Brandstetter
la source
1
J'ajouterai les index et contraintes suggérés par vous et @ypercube. En parcourant les données, je ne vois aucune raison pour laquelle elles ne pourraient pas se produire (à part la clé étrangère car parfois le parent_id n'est pas encore défini). Je définirai également la colonne de génération comme nullable et la valeur par défaut définie sur NULL au lieu de -1. Ensuite, je n'aurai pas beaucoup de filtres "-1" et les index partiels peuvent être OERE la génération est nulle, etc.
Diggity
@Diggity: NULL devrait très bien fonctionner si vous adaptez le reste, oui.
Erwin Brandstetter
@Erwin nice. Je pensais à l'origine semblable à vous. Un index ON objects (customer_id, parent_id, object_id) WHERE generation = -1;et peut-être un autre ON objects (customer_id, object_id) WHERE generation > -1;. La mise à jour devra également «basculer» toutes les lignes mises à jour d'un index à un autre, donc vous ne savez pas si c'est une bonne idée pour l'exécution initiale de la MISE À JOUR.
ypercubeᵀᴹ
L'indexation des requêtes récursives peut être très difficile.
ypercubeᵀᴹ