PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

Komplekssetes ERP-süsteemides on paljusid entiteete, millel on hierarhiline loomus., kui homogeensed objektid järjestatakse suhete «eelkäija — järeltulija» puusse. See hõlmab nii ettevõtte organisatsioonilist struktuuri (kõik need filiaalid, osakonnad ja töögrupid), tootekatalooge, tööalasid kui ka müügikohtade geograafiat,...

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

Tegelikult ei ole ühtegi äritegevuse automatiseerimise valdkonda,, kus mingit hierarhiat ei oleks. Aga isegi kui te ei tööta „äri“ jaoks, võite siiski kergesti kokku puutuda hierarhiliste suhetega. Näiteks teie genealoogiline puu või korruste skeem kaubanduskeskuses — sama struktuur.

On palju viise sellise puu salvestamiseks andmebaasis, kuid täna peatume ainult ühel variandil:

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

CREATE INDEX ON hier(pid); -- ärge unustage, et FK ei eelda automaatset indeksi loomist, erinevalt PK-st.

Ja samal ajal, kui te uurite hierarhia sügavust, ootab see kannatlikult, kui efektiivsed osutuvad teie "naiivsed" töömeetodid sellise struktuuriga.

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
Vaadakem levinud tekkivaid ülesandeid, nende rakendamist SQL-is ja proovime parandada nende jõudlust.

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

Kuna me soovime selgust, oletame, et see struktuur peegeldab osakondade alluvust organisatsiooni struktuuris: osakonnad, divisjonid, sektorid, filiaalid, töörühmad… kuidas iganes neid nimetada.
PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

Alustame meie 'puu' genereerimisega 10K elemendist

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;

Alustame kõige lihtsama ülesandega — leidke kõik töötajad, kes töötavad konkreetses sektoris, või hierarhia mõistes — leida kõik sõlme järeltulijad. Ja oleks hea saada ka järglase "sügavust"... Kõik see võib olla vajalik näiteks mingi kompleksse valiku koostamiseks nende töötajate ID-de loendist..

Kõik oleks hästi, kui neid järeltulijaid oleks seal vaid paar taset ja koguselt kümne ringis, kuid kui tasemeid on rohkem kui 5 ja järeltulijaid juba tosin, võivad probleemid tekkida. Vaatame, kuidas kirjutatakse (ja töötavad) traditsioonilised variandid otsinguks „puu allapoole“. Aga kõigepealt määratleme, millised sõlmed on meie uuringute jaoks kõige huvitavamad.

Kõige „sügavamad“ alampuud:

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

Kõige „laiemad“ alampuud:

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

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

Nende päringute jaoks kasutasime tüüpilist rekursiivset JOIN:
PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

Ilmselt sellise päringumudeli korral iteratsioonide arv langeb kokku järeltulijate koguarvuga (nende leidub paar tosin), ja see võib nõuda märkimisväärseid ressursse ning seetõttu aega.

Kontrollime kõige „laiemas“ alampuus:

KUIDAS REKURSIOONILINE T NÄEB VÄLJA (
  VALI
    id
  KUST
    hier
  KUS
    id = 5300
UNION ALL
  VALI
    hier.id
  KUST
    T
  LIITU
    hier
      ON hier.pid = T.id
)
TABLE T;

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
[vaata explain.tensor.ru]

Nagu arvasime, leidsime kõik 30 kirjet. Kuid selleks kulus 60% kogu ajast — kuna tegime samal ajal ka 30 indeksi otsingut. Aga kas vähem on võimalik?

Massiline lugemine indeksi kaudu

Ja kas peame iga sõlme jaoks tegema eraldi päringu indeksi juurde? Selgub, et ei — me saame lugeda indekse samuti mitme võtme kaudu ühe päringu abil kasutades = ANY(array).

Ja igasse sellisesse identifikaatorite rühma saame panna kõik eelmisel sammul leitud ID-d "sõlmedes". See tähendab, et igal järgmisel sammul otsime kohe kõiki järglasi kindlast tasemest.

Ainult et, kahjuks, rekursiivse valiku korral ei saa viidata sellele ise enda sisse MGUTSIS päringus, kuid meil on vaja kuidagi eristada just need, mis leiti eelmisel tasemel... Selgub, et ei saa teha sisemist päringut kogu valiku kohta — aga konkreetse välja kohta saab küll. Ja see väli võib olla ka massiiv — just see ongi vajalik meie kasutamiseks. ANY.

Kostab veidi ulmeliselt, kuid diagrammil — kõik on lihtne.

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

REKURSIOONIGA T KUI (
  VALI
    ARRAY[id] id$
  KUST
    id = 5300
ÜHENDUS KÕIK
  VALI
    ARRAY(
      VALI
        id
      KUST
        hier
      KUS
        pid = ANY(T.id$)
    ) id$
  FROM
    T
  KUS
    coalesce(id$, '{}') <> '{}' -- väljumise tingimus tsüklist - tühi massiiv
)
VALI
  unnest(id$) id
FROM
  T;

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
[vaata explain.tensor.ru]

Ja siin on kõige olulisem mitte aegade 1.5-kordne võit, vaid see, et me lugesime vähem buffers, kuna indeksi kutsumisi on meil kokku 5, mitte 30!

Lisa boonuseks on see, et pärast lõplikku unnest jäävad identifikaatorid «taseme» järgi järjestatud.

Sõlme märgis

Järgmine aspekt, mis aitab parandada jõudlust — «lehtedel» ei saa olla lapsi, see tähendab, et nende jaoks ei ole vaja «alla» otsida üldse. Meie ülesande määratlemise kohaselt tähendab see, et kui oleme käinud osakondade ahelas ja jõudnud töötajani, siis ei pea me selle haru peale enam midagi otsima.

Viieme meie tabelisse täiendava boolean-välja, mis ütleb meile kohe, kas see konkreetne kirje meie puud on «sõlm» — see tähendab, kas tal võivad üldse olla järeltulijad.

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
);
-- Päring on edukalt täidetud: 3033 rida muudeti 42 ms jooksul.

Suurepärane! Selgub, et meil on vaid veidi üle 30% kõigist puu elementidest, millel on järglased.

Nüüd rakendame veidi teistsugust mehaanikat — ühendusi rekursiivse osa kaudu LATERAL, mis lubab meil otse pöörduda rekursiivse „tabeli“ väli poole, ning agregaatfunktsiooni filtritingimusega sõlme tunnuse alusel kasutame võtmete hulga vähendamiseks:

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.

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;

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
[vaata explain.tensor.ru]

Suutsime vähendada veel üht pöördumist indeksi poole ja saime rohkem kui 2 korda mahus lugemiseks.

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

See algoritm on kasulik, kui peate koguma kirjeid kõigi „üles puu“ elementide jaoks, säilitades teavet selle kohta, millise algse lehe (ja milliste näitajatega) selle lisamine valikusse toimus — näiteks, töötlusaruande koostamiseks, mis sisaldab koondatud andmeid sõlmedel.

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
Edasi on mõistlik käsitleda ainult proof-of-concept'ina, kuna päring osutub liiga koormavaks. Kuid kui see domineerib teie andmebaasis — tasub kaaluda sarnaste meetodite rakendamist.

Alustame paarist lihtsast väitest:

  • Ühte ja sama kirjet andmebaasist on parem lugeda ainult üks kord.
  • Andmed andmebaasist on efektiivsem lugeda „pundina“, kui üksikult.

Nüüd proovime koostada meie vajadustele vastava päringu.

Samm 1

On ilmne, et rekursiooni algatamisel (kus see ikka ilma selleta!) tuleb meil lugeda lehtede enda kirjeid algsete identifikaatorite komplekti põhjal:

WITH RECURSIVE tree AS (
  SELECT
    rec -- see on terve tabeli kirje
  , id::text chld -- see on "komplekt" algsetest lehtedest, mis siia tõid
  FROM
    hier rec
  WHERE
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
UNION ALL
  ...

Kui kellelegi tundus kummaline, et 'kogum' on salvestatud stringina, mitte massiivina, siis sellel on lihtne selgitus. Stringide jaoks on olemas sisseehitatud agreggeeriv 'liitmis' funktsioon string_agg, aga massiivide jaoks ei ole. Kuigi selle rakendamine iseseisvalt ei ole keeruline.

Samm 2

Nüüd peame saama komplekti sektsioonide ID-dest, mida tuleb hiljem lugeda. Peaaegu alati dubleeruvad need erinevates algsete komplekti kirjetes — seetõttu peame need rühmitama, säilitades samas teabe allikate lehtede kohta.

Aga siin ootavad meid kolm ebameeldivust:

  1. 'Alurekursiivne' osa päringust ei tohi sisaldada agregaatfunktsioone koos GROUP BY.
  2. Juurdepääs rekursiivsele 'tabelile' ei saa olla sisemises alampäringus.
  3. Päring rekursiivses osas ei tohi sisaldada CTE-d.

Kuidas iganes, need probleemid on piisavalt kergesti ületatavad. Alustame lõpust.

CTE rekursiivses osas

Nii see ei töötleb:

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

Ja nii — see töötab, sulud lahendavad!

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

Sisemine päring rekursiivsele 'tabelile'

Hmm... Rekursiivne CTE ei saa olla sisemises päringus. Kuid see võib olla CTE sees! Ja sisemine päring võib sellele CTE-le viidata!

GROUP BY rekursiooni sees

Kahjuks... Meil on siiski lihtne viis, kuidas GROUP BY-d simuleerida kasutades DISTINCT ON ja aknafunktsioone!

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

Aga jällegi — see töötab!

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

Näete nüüd, miks numbriline ID muutus tekstiks — et neid saaks kokku liita komaga!

Samm 3

Lõpuks jääb meil vähe teha:

  • loeme «jaotiste» kirjed rühmitatud ID-de kogumi järgi
  • seostame loetud jaotised «kogumitega» algsetest lehtedest
  • «avatud» rida-kogum kasutades unnest(string_to_array(chld, ',')::integer[])

REKURSIVE puu KUIDAS (
  VALI
    rec
  , id::text chld
  KUST
    id = ANY('{1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192}'::integer[])
ÜHENDUS KÕIK
  (
    KUIDAS prnt KUIDAS (
      Vali ERINEV ON((rec).pid)
        (rec).pid id
      , string_agg(chld::text, ',') OVER(PARTITION BY (rec).pid) chld
      KUST
        puu
      KUST
        (rec).pid EI OLE NULL
    )
    , sõlmed KUIDAS (
      Vali
        rec
      KUST
        hier rec
      KUST
        id = ANY(ARRAY(
          Vali
            id
          KUST
            prnt
        ))
    )
    Vali
      sõlmed.rec
    , prnt.chld
    KUST
      prnt
    ÜHENDUS
      sõlmed
        ON (sõlmed.rec).id = prnt.id
  )
)
Vali
  unnest(string_to_array(chld, ',')::integer[]) leht
, (rec).* 
KUST
  puu;

PostgreSQL'i antipatternid: kui sügavale jäneseauku me läheme? Vaatame hierarhiat.
[vaata explain.tensor.ru]

Allikas: habr.com

Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne veebihosting DDoS kaitsega, VPS VDS serverid | ProHoster