PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

Nei complessi sistemi ERP molte entità hanno una natura gerarchica, quando oggetti omogenei si dispongono in un albero di relazioni "genitore - figlio" — sia come struttura organizzativa dell'impresa (tutti quegli uffici, dipartimenti e gruppi di lavoro), sia come catalogo di prodotti, come aree di lavoro e come geografia dei punti vendita,…

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

Infatti, non esiste alcuna area di automazione aziendale, in cui non sia presente qualche forma di gerarchia. Ma anche se non lavori "nel business", potresti comunque imbattersi facilmente in relazioni gerarchiche. Banale, anche il tuo albero genealogico o la pianta dei piani in un centro commerciale — costituiscono la stessa struttura.

Esistono molti modi per memorizzare tale albero in un DBMS, ma oggi ci concentreremo solo su una variante:

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

CREATE INDEX ON hier(pid); -- non dimentichiamoci che l'FK non implica la creazione automatica dell'indice, a differenza dell'PK

E mentre ti immergi nella profondità della gerarchia, essa attende pazientemente quanto saranno [non]efficaci i tuoi approcci "naïf" per gestire tale struttura.

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
Analizziamo i problemi tipici che si presentano, la loro implementazione in SQL e cerchiamo di migliorare le loro prestazioni.

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

Per chiarezza, supponiamo che questa struttura rifletta la subordinazione dei reparti nella struttura organizzativa: dipartimenti, divisioni, settori, filiali, gruppi di lavoro,… — come vuoi chiamarli.
PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

Iniziamo generando il nostro 'albero' con 10K elementi

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;

Iniziamo con il compito più semplice: trovare tutti i dipendenti che lavorano all'interno di un settore specifico, o in termini gerarchici — trovare tutti i discendenti di un nodo. E sarebbe utile ottenere anche la "profondità" di un discendente... Tutto ciò può essere necessario, ad esempio, per costruire una selezione complessa basata sulla lista degli ID di questi dipendenti.

Tutto sarebbe a posto se ci fossero solo un paio di livelli di discendenti, in un numero che si aggira intorno a una decina, ma se ci sono più di 5 livelli e decine di discendenti, potrebbero sorgere dei problemi. Esaminiamo come funzionano (e come si scrivono) le tradizionali opzioni di ricerca "verso il basso nell'albero". Ma prima definiamo quali nodi saranno i più interessanti per le nostre ricerche.

I più «profondi» sottodomini:

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

I più «ampî» sottodomini:

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

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

Per queste query abbiamo usato un tipico JOIN ricorsivo:
PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

È evidente che con un tale modello di query il numero di iterazioni coinciderà con il totale dei discendenti (che sono diversi decine), e questo potrebbe richiedere risorse notevoli e, di conseguenza, tempo.

Controlliamo sul sottodominio "più ampio":

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;

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
[guarda su explain.tensor.ru]

Come prevedevamo, abbiamo trovato tutte e 30 le voci. Ma abbiamo impiegato il 60% del tempo totale — perché abbiamo effettuato anche 30 ricerche sull'indice. E si può fare di meno?

Lettura massiva dall'indice

E per ogni nodo abbiamo bisogno di eseguire una query separata sull'indice? A quanto pare no — possiamo leggere dall'indice immediatamente su più chiavi in un'unica chiamata utilizzando = ANY(array).

E in ogni gruppo di identificatori possiamo prendere tutti gli ID trovati nel passo precedente per i "nodi". Cioè a ogni passo successivo cercheremo tutti i discendenti di un determinato livello.

Ma ecco il problema, nella selezione ricorsiva non si può riferire a se stessa in una subquery, e dobbiamo comunque selezionare solo quanto trovato nel livello precedente... Si scopre che è impossibile fare una subquery su tutta la selezione — ma su un suo campo specifico — sì. E questo campo può essere un array — cosa di cui abbiamo bisogno per usare ANY.

Sembra un po' strano, ma nello schema — è tutto semplice.

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

CON RECORSO T COME (
  SELEZIONA
    ARRAY[id] id$
  DA
    hier
  DOVE
    id = 5300
UNIONE TUTTO
  SELEZIONA
    ARRAY(
      SELEZIONA
        id
      DA
        hier
      DOVE
        pid = ANY(T.id$)
    ) id$
  DA
    T
  DOVE
    coalesce(id$, '{}') >> '{}' -- condizione di uscita dal ciclo - array vuoto
)
SELEZIONA
  unnest(id$) id
DA
  T;

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
[guarda su explain.tensor.ru]

E qui la cosa più importante non è neanche il guadagno di 1,5 volte in tempo, ma il fatto che abbiamo ridotto il numero di buffer, poiché le chiamate all'indice sono solo 5 invece di 30!

Un ulteriore vantaggio è il fatto che dopo il unnest finale, gli identificatori rimarranno ordinati per "livelli".

Caratteristica del nodo

Il pensiero successivo che aiuterà a migliorare le prestazioni è che le "foglie" non possono avere figli, cioè non c'è bisogno di cercare "in giù" per loro. Nella formulazione del nostro problema, questo significa che se abbiamo seguito una catena di reparti e siamo arrivati a un dipendente, non c'è motivo di cercare ulteriormente in quel ramo.

Introduciamo nella nostra tabella un campo-campo, che ci dirà immediatamente se questo specifico record nel nostro albero è un "nodo" — cioè se possono esistere discendenti.

ALTER TABLE hier
  AGGIUNGI COLONNA branch boolean;

AGGIORNA
  hier T
SETTA
  branch = TRUE
DOVE
  ESISTE(
    SELEZIONA
      NULL
    DA
      hier
    DOVE
      pid = T.id
    LIMIT 1
);
-- Richiesta eseguita con successo: 3033 righe modificate in 42 ms.

Ottimo! Si scopre che solo poco più del 30% di tutti gli elementi dell'albero hanno discendenti.

Ora applichiamo una meccanica leggermente diversa — join con la parte ricorsiva tramite LATERALE, il che ci permetterà di accedere immediatamente ai campi della "tabella" ricorsiva, e utilizziamo una funzione aggregata con condizione di filtraggio per la caratteristica del nodo per ridurre il set di chiavi:

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia

CON RECORSO T COME (
  SELEZIONA
    array_agg(id) id$
  , array_agg(id) FILTRATO(DOVE branch) ns$
  DA
    hier
  DOVE
    id = 5300
UNIONE TUTTO
  SELEZIONA
    X.*
  DA
    T
  JOIN LATERALE (
    SELEZIONA
      array_agg(id) id$
    , array_agg(id) FILTRATO(DOVE branch) ns$
    DA
      hier
    DOVE
      pid = ANY(T.ns$)
  ) X
    ON coalesce(T.ns$, '{}') >> '{}'
)
SELEZIONA
  unnest(id$) id
DA
  T;

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
[guarda su explain.tensor.ru]

Siamo riusciti a ridurre un'altra chiamata all'indice e abbiamo guadagnato più di 2 volte in volume lettura.

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

Questo algoritmo sarà utile se hai bisogno di raccogliere registrazioni per tutti gli elementi "verso l'alto nell'albero", mantenendo comunque l'informazione su quale foglia originale (e con quali indicatori) ha causato il suo inserimento nel campionamento — ad esempio, per formare un rapporto aggregato sui nodi.

PostgreSQL Antipatterns: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
Il prosieguo va considerato esclusivamente come proof-of-concept, poiché la richiesta risulta piuttosto ingombrante. Ma se domina nel tuo database, vale la pena riflettere sull'applicazione di metodologie simili.

Iniziamo con un paio di affermazioni semplici:

  • La stessa registrazione dal database è meglio leggerla solo una volta.
  • Le registrazioni dal database sono più efficaci da leggere in "gruppo", piuttosto che singolarmente.

Ora proveremo a costruire la richiesta necessaria.

Passo 1

È ovvio che all'inizio della ricorsione (dove andremmo senza di essa!) dovremo estrarre le registrazioni delle foglie utilizzando un insieme di identificatori di partenza:

WITH RECURSIVE tree AS (
  SELECT
    rec -- questa è la registrazione intera della tabella
  , id::text chld -- questo è il "set" delle foglie di partenza che portano qui
  FROM
    hier rec
  WHERE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNION ALL
  ...

Se a qualcuno è sembrato strano che il "set" sia memorizzato come stringa e non come array, c'è una spiegazione semplice. Per le stringhe esiste una funzione aggregante "concatenante" incorporata string_agg, mentre per gli array non esiste. Anche se è facile da implementare autonomamente.

Passo 2

Ora dobbiamo ottenere un insieme di ID delle sezioni che dobbiamo estrarre ulteriormente. Quasi sempre saranno duplicati in diverse registrazioni del set di partenza, quindi dovremmo raggrupparli, mantenendo nel contempo le informazioni sulle foglie di origine.

Ma qui ci aspettano tre spiacevoli sorprese:

  1. La parte "pre-ricorsiva" della richiesta non può contenere funzioni aggregate con GROUP BY..
  2. L'accesso alla "tabella" ricorsiva non può trovarsi in una sottora richiesta.
  3. La richiesta nella parte ricorsiva non può contenere CTE.

Fortunatamente, tutti questi problemi possono essere aggirati abbastanza facilmente. Iniziamo dalla fine.

CTE nella parte ricorsiva

Ecco come non funziona:

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

E così — funziona, le parentesi fanno la differenza!

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

Richiesta nidificata alla "tabella" ricorsiva

Hmm… L'accesso alla CTE ricorsiva non può essere in una sottora richiesta. Ma può essere all'interno della CTE! E una richiesta nidificata può già riferirsi a questa CTE!

GROUP BY all'interno della ricorsione

Sgradevole, ma… Abbiamo un modo semplice per simulare GROUP BY utilizzando DISTINCT ON e funzioni di finestra!

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

Ma in questo modo — funziona!

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

Ora vediamo perché l'ID numerico si è trasformato in testo: così da poterli unire tramite una virgola!

Passo 3

Per il finale ci rimane soltanto un piccolo passaggio:

  • rivediamo le registrazioni delle "sezioni" per un insieme di ID raggruppati
  • associamo le sezioni estratte con i "set" dei fogli di origine
  • "espandiamo" la stringa-set con l'aiuto di 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: quanto è profonda la tana del coniglio? Esploriamo la gerarchia
[guarda su explain.tensor.ru]

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster