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

Nei complessi sistemi ERP molte entità hanno una natura gerarchica, quando oggetti omogenei si organizzano in un albero di relazioni "antenato - discendente" — si tratta sia della struttura organizzativa di un'azienda (tutti questi filiali, reparti e gruppi di lavoro), sia del catalogo dei prodotti, delle zone di lavoro e della geografia dei punti vendita,…

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

In effetti, non esiste un settore dell'automazione aziendale, in cui non ci sia una qualche gerarchia. Ma anche se non lavori "nel business", puoi comunque facilmente imbattersi in relazioni gerarchiche. Per esempio, il tuo albero genealogico o la pianta degli uffici in un centro commerciale hanno la stessa struttura.

Esistono molti modi per memorizzare questo albero in un DBMS, ma oggi ci soffermeremo solo su una opzione:

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

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

Mentre esplori la profondità della gerarchia, essa attende pazientemente di vedere quanto si rivelino [non] efficaci i tuoi approcci 'naïf' a questa struttura.

Antipattern di PostgreSQL: quanto profonda è la tana del coniglio? Esploriamo la gerarchia
Analizziamo i compiti tipici che sorgono, la loro implementazione in SQL e proviamo a migliorarne le prestazioni.

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

Per chiarezza, supponiamo che questa struttura rispecchi la subordinazione dei reparti nella struttura dell'organizzazione: dipartimenti, divisioni, settori, filiali, gruppi di lavoro,… — qualunque nome tu voglia darle.
Antipattern di PostgreSQL: quanto profonda è la tana del coniglio? Esploriamo la gerarchia

Cominciamo a generare il nostro 'albero' di 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 nei termini della gerarchia — trovare tutti i discendenti di un nodo.. E sarebbe utile ottenere anche la 'profondità' del discendente… Tutto ciò potrebbe essere necessario, ad esempio, per costruire una certa complessa selezione basata sull'elenco degli ID di questi dipendenti..

Sarebbe tutto a posto se ci fossero solo un paio di livelli di discendenti e una manciata di elementi, ma se i livelli superano 5 e i discendenti sono già decine, potrebbero sorgere problemi. Esaminiamo come vengono scritti (e funzionano) i tradizionali metodi di ricerca "verso il basso nell'albero". Ma prima, definiamo quali nodi saranno i più interessanti per le nostre indagini.

I più «profondi» sottotrees:

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ù «ampii» sottotrees:

...
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 utilizzato un tipico JOIN ricorsivo:
Antipattern di PostgreSQL: quanto profonda è la tana del coniglio? Esploriamo la gerarchia

È chiaro che con un modello di query del genere il numero di iterazioni corrisponderà al numero totale di discendenti (che sono decine), e questo può richiedere risorse significative e, di conseguenza, tempo.

Verifichiamo sul sottotree "più ampio":

CON RECURSIONE T AS (
  SELECT
    id
  FROM
    hier
  WHERE
    id = 5300
UNIONE TUTTO
  SELECT
    hier.id
  FROM
    T
  JOIN
    hier
      ON hier.pid = T.id
)
TABELLINA T;

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

Come previsto, abbiamo trovato tutte le 30 voci. Ma abbiamo impiegato il 60% del tempo totale — perché nel frattempo abbiamo effettuato 30 ricerche nell'indice. E meno — è possibile?

Lettura massiva tramite indice

È necessario fare una richiesta separata all'indice per ogni nodo? A quanto pare no — possiamo leggere dall'indice immediatamente per più chiavi in un'unica richiesta utilizzando = ANY(array).

E per ogni raggruppamento di identificatori possiamo prendere tutti gli ID trovati nel passaggio precedente per i "nodi". Cioè ad ogni passaggio successivo cercheremo tutti i discendenti di un certo livello.

Solo, c'è un problema, nella selezione ricorsiva non è possibile riferirsi a se stessi nella sottoquery, e noi dobbiamo in qualche modo selezionare solo ciò che è stato trovato nel livello precedente… A quanto pare, fare una sottoquery su tutta la selezione — non è possibile, ma su un suo campo specifico — sì. E quel campo può essere anche un array — proprio quello di cui abbiamo bisogno per utilizzarlo. ANY.

Sembra un po' strano, ma nella scheda — è tutto semplice.

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

CON RICORSIONE T AS (
  SELECT
    ARRAY[id] id$
  FROM
    hier
  WHERE
    id = 5300
UNIONE TUTTO
  SELECT
    ARRAY(
      SELECT
        id
      FROM
        hier
      WHERE
        pid = ANY(T.id$)
    ) id$
  FROM
    T
  WHERE
    coalesce(id$, '{}')  '{}' -- condizione di uscita dal ciclo - array vuoto
)
SELECT
  unnest(id$) id
FROM
  T;

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

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

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

Caratteristica del nodo

Una considerazione successiva che aiuterà a migliorare le prestazioni è che le «foglie» non possono avere figli, cioè per loro non c'è assolutamente bisogno di cercare «in basso». Nel contesto del nostro problema questo significa che se siamo andati lungo la catena dei reparti e siamo arrivati a un dipendente, non ha senso cercare più avanti su quel ramo.

Introduciamo nella nostra tabella un campo booleanoaggiuntivo, che ci dirà subito se questo specifico record nel nostro albero è un «nodo» — cioè se può avere realmente discendenti.

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
);
-- Query executed successfully: 3033 rows changed in 42 ms.

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

Ora applichiamo una meccanica un po' diversa — connessioni con la parte ricorsiva tramite LATERAL, che ci consentirà di accedere immediatamente ai campi della «tabella» ricorsiva, mentre la funzione aggregata con la condizione di filtraggio in base al criterio del nodo la utilizziamo per ridurre l'insieme delle chiavi:

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

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;

Antipattern di PostgreSQL: 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 due volte in volume di lettura.

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

Questo algoritmo sarà utile se è necessario raccogliere registrazioni per tutti gli elementi "salendo nell'albero", mantenendo al contempo informazioni sul quale foglio originale (e con quali indicatori) ha causato il suo inserimento nel campione — ad esempio, per la creazione di un report aggregato sui nodi.

Antipattern di PostgreSQL: quanto profonda è la tana del coniglio? Esploriamo la gerarchia
Il seguito va percepito esclusivamente come proof-of-concept, poiché la query diventa piuttosto ingombrante. Ma se domina nel tuo database, vale la pena considerare l'applicazione di tecniche simili.

Iniziamo con un paio di semplici affermazioni:

  • Una stessa registrazione nel database è meglio leggerla solo una volta.
  • Le registrazioni nel database si leggono più efficacemente "in blocco", piuttosto che singolarmente.

Ora proviamo a costruire la query di cui abbiamo bisogno.

Passo 1

È ovvio che all'inizio della ricorsione (dove altro poteva essere!) dovremo estrarre le registrazioni delle foglie stesse per un insieme di identificatori originali:

WITH RECURSIVE tree AS (
  SELECT
    rec -- questa è l'intera registrazione della tabella
  , id::text chld -- questo è il "set" dei fogli originali che hanno portato 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 un 'insieme' venga memorizzato come stringa anziché come array, c'è una spiegazione semplice. Esiste una funzione aggregante 'concatenatrice' incorporata per le stringhe string_agg, mentre per gli array non è così. Anche se non è complicato implementarla da soli. Ora dobbiamo ottenere un insieme di ID delle sezioni che dovranno essere letti successivamente. Quasi sempre saranno duplicati in diverse voci del set iniziale — quindi dobbiamo.

Passo 2

raggrupparli , mantenendo al contempo le informazioni sulle fonti-lettori.Ma qui ci aspettano tre problemi:

La parte 'sottorecursiva' della query non può contenere funzioni aggregate con

  1. L'accesso alla 'tabella' ricorsiva non può essere all'interno di una sottoquery. GROUP BY.
  2. La query nella parte ricorsiva non può contenere CTE.
  3. Fortunatamente, tutti questi problemi possono essere facilmente aggirati. Cominciamo dalla fine.

Il CTE nella parte ricorsiva

funziona:

Ecco come non WITH RECURSIVE tree AS ( ... UNION ALL WITH T (...) SELECT ... )

E così — funziona, le parentesi risolvono tutto!

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

Una query annidata alla 'tabella' ricorsiva

Вложенный запрос к рекурсивной «таблице»

Hmm... Un riferimento a un CTE ricorsivo non può essere in una sottoquery. Ma può essere all'interno di un CTE! E la sottoquery può già riferirsi a questo CTE!

GROUP BY all'interno della ricorsione

Scomodo, 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 è stato convertito in testo — per poterli unire separandoli con una virgola!

Passo 3

Per il finale ci resta solo poco:

  • estraiamo i record delle «sezioni» in base all'insieme di ID raggruppati
  • mettiamo in corrispondenza le sezioni estratte con i «set» di fogli di partenza
  • «sviluppiamo» la riga-insieme utilizzando unnest(string_to_array(chld, ',')::integer[])

CON ALGORITMI DI RICORSIONE tree COME (
  SELEZIONA
    rec
  , id::text chld
  DA
    gerarchia rec
  DOVE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNIONE TUTTO
  (
    CON prnt COME (
      SELEZIONA DISTINTI SU((rec).pid)
        (rec).pid id
      , string_agg(chld::text, ',') OVER(PARTIZIONE PER (rec).pid) chld
      DA
        tree
      DOVE
        (rec).pid È NON NULL
    )
    , nodi COME (
      SELEZIONA
        rec
      DA
        gerarchia rec
      DOVE
        id = ANY(ARRAY(
          SELEZIONA
            id
          DA
            prnt
        ))
    )
    SELEZIONA
      nodi.rec
    , prnt.chld
    DA
      prnt
    UNISCI
      nodi
        SU (nodi.rec).id = prnt.id
  )
)
SELEZIONA
  unnest(string_to_array(chld, ',')::integer[]) foglia
, (rec).* 
DA
  tree;

Antipattern di PostgreSQL: 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