Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

Dans les systèmes ERP complexes de nombreuses entités ont une nature hiérarchique, lorsque des objets homogènes sont organisés en un arbre de relations « parent - enfant » — cela concerne à la fois la structure organisationnelle de l'entreprise (toutes ces filiales, départements et groupes de travail), le catalogue de produits, les domaines de travail, et la géographie des points de vente,…

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

En réalité, il n'existe aucune domaine d'automatisation des affaires, où il n'y a pas eu un minimum de hiérarchie. Mais même si vous ne travaillez pas « dans les affaires », vous pouvez néanmoins rencontrer facilement des relations hiérarchiques. Simplement, même votre arbre généalogique ou le plan d'étage d'un centre commercial représente une telle structure.

Il existe de nombreuses manières de stocker cet arbre dans une SGBD, mais aujourd'hui nous allons nous concentrer uniquement sur une option :

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

CREATE INDEX ON hier(pid); -- n'oublions pas que la clé étrangère ne sous-entend pas la création automatique d'un index, contrairement à la clé primaire.

Et pendant que vous scrutez la profondeur de l'arborescence, elle attend patiemment de voir à quel point vos méthodes de travail « naïves » avec cette structure s'avéreront [non] efficaces.

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
Examinons les tâches typiques qui se présentent, leur mise en œuvre en SQL et essayons d'améliorer leurs performances.

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

Pour être précis, considérons que cette structure reflétera la subordination des départements dans la structure de l'organisation : départements, divisions, secteurs, filiales, groupes de travail,… peu importe comment vous les appelez.
Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

Commençons par générer notre 'arbre' de 10K éléments

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;

Commençons par la tâche la plus simple : trouver tous les employés qui travaillent dans un secteur particulier, ou en termes hiérarchiques — trouver tous les descendants d'un nœud. Et il serait également bon d'obtenir la « profondeur » du descendant… Tout cela peut être nécessaire, par exemple, pour construire un échantillonnage complexe basé sur la liste des ID de ces employés..

Tout irait bien si ces descendants n'avaient que quelques niveaux et étaient au nombre d'une dizaine. Mais si le nombre de niveaux dépasse 5 et que les descendants sont déjà des dizaines, cela peut poser des problèmes. Voyons comment s'écrivent (et fonctionnent) les variantes traditionnelles de la recherche "vers le bas dans l'arbre". Mais d'abord, déterminons quels nœuds seront les plus intéressants pour nos recherches.

Les plus «profonds» sous-arbres :

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

Les plus «larges» sous-arbres :

...
SELECT
  path[1] id
, count(*)
FROM
  T
GROUP BY
  1
ORDER BY
  2 DESC;

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

Pour ces requêtes, nous avons utilisé un typique JOIN récursif:
Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

Évidemment, avec un tel modèle de requête le nombre d'itérations correspondra au nombre total de descendants (et ils sont plusieurs dizaines), et cela peut nécessiter des ressources assez substantielles, et par conséquent, du temps.

Vérifions sur le sous-arbre le plus "large" :

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;

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
[voir sur explain.tensor.ru]

Comme nous l'avions prévu, nous avons trouvé toutes les 30 entrées. Mais cela nous a pris 60 % du temps total — parce que nous avons effectué 30 recherches dans l'index. Serait-il possible de faire moins ?

Lecture massifiée par l'index

Et avons-nous vraiment besoin de faire une requête distincte à l'index pour chaque nœud ? Apparemment, non — nous pouvons lire dans l'index tout de suite avec plusieurs clés en une seule requête à l'aide de = ANY(array).

Et pour chaque groupe d'identifiants, nous pouvons prendre tous les ID trouvés à l'étape précédente pour les «nœuds». Cela signifie qu'à chaque étape suivante, nous allons chercher tous les descendants d'un certain niveau en même temps..

Seulement voilà, dans une sélection récursive, il n'est pas possible de se référer à lui-même dans une requête imbriquée, et nous devons bien récupérer uniquement ceux trouvés au niveau précédent... Il s'avère qu'une requête imbriquée sur l'ensemble de la sélection n'est pas possible, mais sur son champ spécifique, cela l'est. Et ce champ peut être un tableau — ce qui est exactement ce dont nous avons besoin pour l'utilisation. ALL.

Cela peut sembler un peu étrange, mais sur le schéma, c'est tout simple.

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

AVEC RECURSIVE T AS (
  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$, '{}')  '{}' -- condition de sortie de la boucle - tableau vide
)
SELECT
  unnest(id$) id
FROM
  T;

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
[voir sur explain.tensor.ru]

Et ici, le plus important n'est même pas gagner 1,5 fois en temps, mais le fait que nous avons moins extrait de buffers, puisque nous n'avons eu que 5 accès à l'index au lieu de 30 !

Un bonus supplémentaire est que, après le dénuement final, les identifiants resteront ordonnés par « niveaux ».

Indicateur de nœud

Une autre considération qui aidera à améliorer les performances — les « feuilles » ne peuvent pas avoir d'enfants, c'est-à-dire qu'il n'est pas nécessaire de chercher « en bas » pour elles. Dans notre tâche, cela signifie que si nous avons traversé la chaîne des départements et atteint un employé, il n'est plus nécessaire de chercher plus loin dans cette branche.

Introduisons dans notre tableau un boolean-champ, qui nous indiquera immédiatement si cet enregistrement spécifique dans notre arbre est un « nœud » — c'est-à-dire s'il peut avoir des descendants.

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
);
-- La requête a été exécutée avec succès : 3033 lignes modifiées en 42 ms.

Super ! Il s'avère que seulement un peu plus de 30 % de tous les éléments de l'arbre ont des descendants.

Appliquons maintenant une mécanique légèrement différente — des jointures avec la partie récursive via LATERAL, ce qui nous permettra de nous adresser immédiatement aux champs de la « table » récursive, et nous utiliserons la fonction agrégative avec une condition de filtrage sur l'indicateur de nœud pour réduire l'ensemble des clés :

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie

WITH RECURSIVE T AS (
  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;

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
[voir sur explain.tensor.ru]

Nous avons pu réduire encore un accès à l'index et gagné plus de 2 fois en volume extrait.

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

Cet algorithme sera utile si vous devez rassembler des enregistrements pour tous les éléments « en remontant dans l'arbre », tout en conservant les informations sur quel feuille d'origine (et avec quels indicateurs) a conduit à son inclusion dans l'échantillon — par exemple, pour élaborer un rapport récapitulatif avec une agrégation sur les nœuds.

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
La suite doit être considérée uniquement comme un proof-of-concept, car la requête devient vraiment encombrante. Mais si elle domine votre base, il vaut la peine de réfléchir à l'utilisation de telles méthodes.

Commençons par quelques déclarations simples :

  • Un même enregistrement dans la base vaut mieux être lu une seule fois.
  • Les enregistrements de la base sont plus efficaces à lire en « lot », plutôt qu'un par un.

Essayons maintenant de construire la requête dont nous avons besoin.

Étape 1

Il est évident qu'à l'initialisation de la récursion (sans elle, ça ne fonctionne pas !), nous devrons lire les enregistrements des feuilles selon un ensemble d'identifiants source :

WITH RECURSIVE tree AS (
  SELECT
    rec -- c'est un enregistrement complet de la table
  , id::text chld -- c'est "l'ensemble" des feuilles sources qui ont conduit ici
  FROM
    hier rec
  WHERE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNION ALL
  ...

Si quelqu'un a trouvé étrange que « l'ensemble » soit stocké sous forme de chaîne, et non de tableau, il y a une explication simple. Pour les chaînes, il existe une fonction d'agrégation intégrée appelée string_agg, mais il n'en existe pas pour les tableaux. Bien qu'il soit facile de l'implémenter soi-même Nous aimerions maintenant obtenir un ensemble d'ID de sections à lire ensuite. Ils seront presque toujours dupliqués dans différents enregistrements de l'ensemble source — c'est pourquoi nous devrions.

Étape 2

les regrouper , tout en préservant l'information sur les feuilles sources.Mais ici, trois problèmes nous attendent :

La partie « sous-récursive » de la requête ne peut pas contenir de fonctions d'agrégation avec

  1. L'appel à la « table » récursive ne peut pas se trouver dans une sous-requête imbriquée. et ne doit pas contenir.
  2. La requête dans la partie récursive ne peut pas contenir de CTE.
  3. Heureusement, tous ces problèmes sont assez faciles à contourner. Commençons par la fin.

Le CTE dans la partie récursive

fonctionne :

Voilà à quoi ne WITH RECURSIVE tree AS ( ... UNION ALL WITH T (...) SELECT ... )

Et de cette façon, ça marche — les parenthèses résolvent le problème !

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

Une requête imbriquée à la « table » récursive

Hmm… L'appel à la CTE récursive ne peut pas être dans une requête imbriquée. Mais il peut être à l'intérieur d'une CTE ! Et la requête imbriquée peut déjà faire appel à cette CTE !

GROUP BY à l'intérieur de la récursion

C'est désagréable, mais… Nous avons un moyen simple de simuler GROUP BY avec

DISTINCT ON et des fonctions de fenêtre ! SELECT (rec).pid id , string_agg(chld::text, ',') chld FROM tree WHERE (rec).pid IS NOT NULL GROUP BY 1 -- ne fonctionne pas !

Mais de cette façon, ça marche !

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

SÉLECTIONNER DISTINCT SUR((rec).pid)
  (rec).pid id
, string_agg(chld::text, ',') SUR(PARTITION PAR (rec).pid) chld
DE
  arbre
OÙ
  (rec).pid N'EST PAS NULL

Maintenant, nous voyons pourquoi l'ID numérique était transformé en texte — pour pouvoir les concaténer avec une virgule !

Étape 3

Il ne nous reste plus qu'à finaliser :

  • nous examinons les enregistrements des « sections » selon l'ensemble des ID regroupés
  • nous associons les sections extraites aux « ensembles » de feuilles sources
  • nous « déployons » la ligne-ensemble à l'aide de 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;

Antipatterns PostgreSQL : jusqu'où va le terrier du lapin ? Explorons la hiérarchie
[voir sur explain.tensor.ru]

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster