PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

Komplekssetes ERP-süsteemides on paljusid üksusi iseloomustanud hierarhiline loomus, kus sarnased objektid on paigutatud suhete puusse "algataja - järeltulija" — see on nii ettevõtte organisatsiooniline struktuur (kõik need filiaalid, osakonnad ja töögrupid), tootekataloog, tööalad kui ka müügikohtade geograafia,…

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

Tegelikult pole ühtegi äritegevuse automatiseerimise valdkonda, kus mingit hierarhiat poleks. Kuid isegi kui te ei tööta „äri“ nimel, võite ikkagi kergesti kokku puutuda hierarhiliste suhetega. Lihtsalt, isegi teie genealoogia või kaubanduskeskuse korrusekava on sama struktuur.

Hierarhia salvestamiseks on palju viise, 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 tähenda indeksi automaatset loomist, erinevalt PK-st

Ja seni, kuni vaatate hierarhia sügavusi, ootab see rahulikult, kui efektiivsed on teie "naiivsed" lähenemised sellise struktuuriga töötamiseks.

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
Vaatame tavapäraseid tekkinud ülesandeid, nende rakendamist SQL-is ja proovime nende tulemuslikkust parandada.

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

Võtame selguse huvides, et see struktuur peegeldab meie organisatsiooni osakondade alluvust: osakonnad, divisjonid, sektorid, filiaalid, töögrupid,… — kuidas iganes neid nimetada.
PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

Alustame oma 'puu' genereerimist 10K elemendiga

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 - leida kõik töötajad, kes töötavad konkreetse sektori sees, või hierarhia terminiliselt - leida kõik sõlme järeltulijad. Samuti oleks hea saada järeltulija "sügavus"… Kõik see võib olla vajalik näiteks mingi keeruka ID-nimekirja valiku tegemiseks nende töötajate kohta.

Kõik oleks muidu hästi, kui neid järeltulijaid oleks seal vaid paar taset ja koguseliselt kümne ringis, kuid kui tasemeid on rohkem kui 5 ja järeltulijaid juba kümnete kaupa — võivad tekkida probleemid. Vaatame, kuidas kirjutatakse (ja töötavad) traditsioonilised variandid 'puu allapoole' otsimiseks. Kuid kõigepealt määratlegeme, 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 laiad 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 Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

Ilmselt vastab sellise päringu mudel iteratsioonide arv kogu järeltulijate arvule (ja neid on mitukümmend), ja see võib võtta üsna märkimisväärseid ressursse ning seega aega.

Kontrollime kõige 'laiema' alampuu peal:

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: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
[vaata explain.tensor.ru]

Nagu arvata võisime, leidsime kõik 30 kirjet. Kuid selleks kulus 60% kogu ajast — sest tegime sellega ka 30 otsingut indeksi järgi. Kas vähem ei saa?

Massiline lugemine indeksi kaudu

Kas iga sõlme jaoks on meil vaja teha eraldi päring indeksile? Tundub, et ei — saame indeksist lugeda koos mitme võtmega ühe taotlemisega kasutades = ANY(array).

Ja igasse sellesse identifikaatorite gruppi saame võtta kõik eelmisel sammul leitud ID-d 'sõlmedes'. See tähendab, et igal järgmistel sammudel otsime me otseselt kõiki teatud taseme järeltulijaid.

Kuid, siin on probleem, rekursiivses valikus ei saa me viidata endale sisemises päringus, aga me peame kuidagi valima just selle, mis eelmisel tasemel leiti... Selgub, et ei ole võimalik teha sisemist päringut kogu valikule — kuid selle konkreetsele väljadele — on võimalik. Ja see väli võib olla ka massiiv — mis ongi vajalik meie kasutamiseks. ALL.

Kõlab pisut veidralt, kuid skeemil on kõik lihtne.

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

REKURSIOONIGA T NAGU (
  VALI
    ARRAY[id] id$
  KUST
    id = 5300
UNION ALL
  VALI
    ARRAY(
      VALI
        id
      KUST
        pid = ANY(T.id$)
    ) id$
  KUST
    T
  KUS
    coalesce(id$, '{}') <> '{}' -- tsükli väljumise tingimus - tühi massiiv
)
VALI
  unnest(id$) id
KUST
  T;

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
[vaata explain.tensor.ru]

Siin on kõige olulisem isegi mitte 1,5 korda ajavõit, vaid see, et me lugesime vähem bufrite, sest indeksile pöördumisi on meil ainult 5, mitte 30!

Lisaboonuseks on see, et pärast lõplikku unnest jäävad identifikaatorid tasemete kaupa järjestatud.

Sõlme tunnus

Järgmine mõte, mis aitab parandada jõudlust — lehtedel ei saa olla lapsi, see tähendab, et nende jaoks ei ole mõtet otsida "alla". Meie ülesande püstituses tähendab see, et kui me käisime osakondade ahelas ja jõudsime töötajani, siis pole mõtet enam selles oksas otsida.

Vaatame, kas suudame meie tabelisse lisada boolean-veergu, mis ütleb meile kohe, kas see konkreetne kirje meie puus on "sõlm" — st kas sellel võivad üldse olla järeltulijad.

ALTER TABLE hier
  LISAGE VEERG branch boolean;

UUENDAGE
  hier T
SEADISTAGE
  branch = TRUE
KUST
  EXISITS(
    VALI
      NULL
    KUST
      hier
    KUST
      pid = T.id
    PIIRANG 1
);
-- Päring täideti edukalt: 3033 rida muudetud 42 ms jooksul.

Suurepärane! Selgub, et meil on vaid veidi üle 30% kõikidest puu elementidest, kellel on järeltulijad.

Nüüd rakendame veidi teistsuguse mehhanismi — liitmise rekursiivse osa kaudu LATERAL, mille abil saame kohe pöörduda rekursiivse "tabeli" väljade juurde, ja kasutame ühendamise tingimuse alusel sõlme tunnust aggregaatfunktsiooni jaoks, et vähendada võtmete hulka:

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid

REKURSIOONIGA T NAGU (
  VALI
    array_agg(id) id$
  , array_agg(id) FILTREERI(KUST branch) ns$
  KUST
    id = 5300
UNION ALL
  VALI
    X.*
  KUST
    T
  LIITU LATERAL (
    VALI
      array_agg(id) id$
    , array_agg(id) FILTREERI(KUST branch) ns$
    KUST
      pid = ANY(T.ns$)
  ) X
    KUS coalesce(T.ns$, '{}') <> '{}'
)
VALI
  unnest(id$) id
KUST
  T;

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
[vaata explain.tensor.ru]

Me suutsime veel ühe indeksi pöördumise vähendada ja võitsime rohkem kui 2 korda mahtudes loetavast.

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

See algoritm on kasulik, kui peate koguma kirjeid kõigi elementide „üles puu”, säilitades samal ajal teabe selle kohta, milline algne leht (ja milliste näitajatega) tõi selle valikusse — näiteks kokkuvõttearuande koostamiseks sõlmedel aggregatsiooniga.

PostgreSQL Antipatterns: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
Edasi tuleks võtta kui ainult tõestuseks kontseptsioonist, kuna päring muutub väga raskeks. Kuid kui see domineerib teie andmekogus, tasub kaaluda sarnaste meetodite rakendamist.

Alustame paari lihtsa väitega:

  • Sama kirje andmebaasist on parem lugeda ainult üks kord.
  • Andmed andmebaasist on efektiivsem lugeda 'kimbu' kaupa, kui ükshaaval.

Nüüd proovime konstrueerida vajalikku päringut.

Samm 1

Selgelt on, et rekursiooni alguses (kuhu muidu!) peame lugema lehtede kirjeid vastavalt algsetele identifikaatoritele:

WITH RECURSIVE tree AS (
  SELECT
    rec -- see on terviklik kirje tabelis
  , id::text chld -- see on "kogum" kergitavatest lehtedest
  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 tundub veider, et "kogum" on salvestatud stringina, mitte massiivina, siis sellele on lihtne selgitus. Stringide puhul on olemas sisseehitatud agrgeeriv "ühendav" funktsioon string_agg, aga massiivide jaoks ei ole. Kuigi selle rakendamine ei ole raske Nüüd peaksime saama sektsioonide ID-de kogumi, mida edasi lugeda. Peaaegu alati korduvad need erinevates algsete kirjete seas - seega peaksime.

Samm 2

need rühma panema , säilitades samas teavet allika lehtede kohta.Kuid siin ootavad meid ees kolm ebameeldivust:

"Alamrekursiivne" osa päringust ei tohi sisaldada agregaatfunktsioone

  1. Kutsumine rekursiivsesse "tabelisse" ei tohi olla sisestatud alam-päringus. on olemas, vaate määratlemine peab sisaldama.
  2. Päring rekursiivses osas ei tohi sisaldada CTE-d.
  3. Õnneks on need probleemid suhteliselt lihtsad ületada. Alustame lõpust.

CTE rekursiivses osas

töötab:

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

Ja nii — töötab, sulud lahendavad!

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

Sisenev päring rekursiivsesse "tabelisse"

Hm... Kutsumine rekursiivsele CTE-le ei tohi olla sisestatud päringus. Kuid see võib olla CTE sees! Ja sisendpäring võib juba seda CTE-t kutsuda!

GROUP BY rekursiooni sees

Ebameeldiv, kuid... Meil on lihtne viis, kuidas simuleerida GROUP BY koos

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

Aga nii — 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

VALI 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üüd näeme, miks numbriline ID muutus tekstiks — et neid saaks komadega kokku kleepida!

Samm 3

Finaali jaoks on meil alles vaid vähe:

  • me kontrollime «jaotuste» kirjeid rühmitatud ID-de seast
  • me seome loetud jaotused algsete lehtede «kogudega»
  • me «avame» komplekti stringi abil 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: kui sügavale küüliku väljakaevamine ulatub? vaatame hierarhiaid
[vaata explain.tensor.ru]

Allikas: habr.com

Osta usaldusväärne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid 🔥 Osta usaldusväärne hostimine veebilehtede jaoks DDoS-i kaitsega, VPS VDS serverid | ProHoster