SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”

Okresowo pojawia się potrzeba wyszukiwania powiązanych danych na podstawie zestawu kluczy, dopóki nie zgromadzimy potrzebnej sumy rekordów.

Najbardziej „życiowy” przykład — zestawić 20 najstarszych zadań, figuruje na liście pracowników (na przykład, w obrębie jednego działu). W różnych menedżerskich „kokpitach” z krótkimi podsumowaniami dotyczących obszarów pracy, podobny temat pojawia się dosyć często.

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”

W artykule rozważymy implementację w PostgreSQL „naiwnej” wersji rozwiązania takiego zadania, „sprytniejszy” i całkowicie złożony algorytm „pętli” w SQL z warunkiem wyjścia z danych znalezionych, który może być przydatny zarówno do ogólnej wiedzy, jak i do zastosowania w innych podobnych sytuacjach.

Weźmiemy testowy zestaw danych z poprzedniego artykułu. Aby wyświetlane rekordy nie „skakały” od razu przy powtszędzających się wartości sortowanych, rozszerzymy indeks przedmiotowy poprzez dodanie klucza głównego. To od razu nada mu unikalność i zapewni jednoznaczność kolejności sortowania:

UTWÓRZ INDEKS NA task(owner_id, task_date, id);
-- a stary - usuniemy
USUŃ INDEKS task_owner_id_task_date_idx;

Jak się słyszy, tak się pisze

Najpierw szkicujemy najprostszy wariant zapytania, przekazując ID wykonawców jako tablicę w roli parametru wejściowego:

SELECT
  *
FROM
  task
WHERE
  owner_id = ANY('{1,2,4,8,16,32,64,128,256,512}'::integer[])
ORDER BY
  task_date, id
LIMIT 20;

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”
[zobacz na explain.tensor.ru]

Trochę smutno — zamówiliśmy tylko 20 rekordów, a skanowanie indeksu zwróciło nam 960 wierszy, które jeszcze musieliśmy posortować… A spróbujmy czytać mniej.

unnest + TABLICA

Pierwsza myśl, która nam pomoże — jeśli potrzebujemy zaledwie 20 posortowanych rekordów, wystarczy czytać nie więcej niż 20 posortowanych w tej samej kolejności dla każdego klucza. Na szczęście, odpowiedni indeks (owner_id, task_date, id) już mamy.

Skorzystamy z tego samego mechanizmu wydobycia i 'rozkładania na kolumny' całościowego wpisu tabeli, co w poprzednim artykule. A także zastosujemy złożenie w tablicę z pomocą funkcji TABLICA():

Z T JAKO (
  SELECT
    unnest(TABLICA(
      SELECT
        t
      FROM
        task t
      WHERE
        owner_id = unnest
      ORDER BY
        task_date, id
      LIMIT 20 -- ograniczamy tutaj...
    )) r
  FROM
    unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
)
SELECT
  (r).* 
FROM
  T
ORDER BY
  (r).task_date, (r).id
LIMIT 20; -- ... i tutaj - również

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”
[zobacz na explain.tensor.ru]

O, już znacznie lepiej! O 40% szybciej i 4,5 razy mniej danych trzeba było przeczytać.

Materializacja rekordów tabeli za pomocą CTEZwracam uwagę, że w niektórych przypadkach próba pracy z polami rekordu zaraz po jego wyszukaniu w podzapytaniu, bez „opakowywania” w CTE, może prowadzić do „powielania” InitPlan proporcjonalnie do liczby tych właśnie pól:

SELECT
  ((
    SELECT
      t
    FROM
      task t
    WHERE
      owner_id = 1
    ORDER BY
      task_date, id
    LIMIT 1
  ).*);

Wynik  (koszt=4.77..4.78 wierszy=1 szerokość=16) (rzeczywisty czas=0.063..0.063 wierszy=1 pętli=1)
  Bufory: wspólny hit=16
  InitPlan 1 (zwraca $0)
    ->  Limit  (koszt=0.42..1.19 wierszy=1 szerokość=48) (rzeczywisty czas=0.031..0.032 wierszy=1 pętli=1)
          Bufory: wspólny hit=4
          ->  Skanowanie indeksu przy użyciu task_owner_id_task_date_id_idx w zadaniu t  (koszt=0.42..387.57 wierszy=500 szerokość=48) (rzeczywisty czas=0.030..0.030 wierszy=1 pętli=1)
                Warunek indeksu: (owner_id = 1)
                Bufory: wspólny hit=4
  InitPlan 2 (zwraca $1)
    ->  Limit  (koszt=0.42..1.19 wierszy=1 szerokość=48) (rzeczywisty czas=0.008..0.009 wierszy=1 pętli=1)
          Bufory: wspólny hit=4
          ->  Skanowanie indeksu przy użyciu task_owner_id_task_date_id_idx w zadaniu t_1  (koszt=0.42..387.57 wierszy=500 szerokość=48) (rzeczywisty czas=0.008..0.008 wierszy=1 pętli=1)
                Warunek indeksu: (owner_id = 1)
                Bufory: wspólny hit=4
  InitPlan 3 (zwraca $2)
    ->  Limit  (koszt=0.42..1.19 wierszy=1 szerokość=48) (rzeczywisty czas=0.008..0.008 wierszy=1 pętli=1)
          Bufory: wspólny hit=4
          ->  Skanowanie indeksu przy użyciu task_owner_id_task_date_id_idx w zadaniu t_2  (koszt=0.42..387.57 wierszy=500 szerokość=48) (rzeczywisty czas=0.008..0.008 wierszy=1 pętli=1)
                Warunek indeksu: (owner_id = 1)
                Bufory: wspólny hit=4"
  InitPlan 4 (zwraca $3)
    ->  Limit  (koszt=0.42..1.19 wierszy=1 szerokość=48) (rzeczywisty czas=0.009..0.009 wierszy=1 pętli=1)
          Bufory: wspólny hit=4
          ->  Skanowanie indeksu przy użyciu task_owner_id_task_date_id_idx w zadaniu t_3  (koszt=0.42..387.57 wierszy=500 szerokość=48) (rzeczywisty czas=0.009..0.009 wierszy=1 pętli=1)
                Warunek indeksu: (owner_id = 1)
                Bufory: wspólny hit=4

Ta sama rekord «znajdowała się» 4 razy... Do PostgreSQL 11 to zachowanie występuje regularnie, a rozwiązaniem jest „opakowanie” w CTE, co jest niewątpliwą granicą dla optymalizatora w tych wersjach.

Rekurencyjny akumulator

W poprzedniej wersji łącznie przeczytaliśmy 200 wierszy aby uzyskać potrzebne 20. Już nie 960, ale nadal mniej — czy to możliwe?

Spróbujmy skorzystać z wiedzy, że potrzebujemy zaledwie 20 rekordów. Oznacza to, że będziemy iterować pobieranie danych tylko do osiągnięcia potrzebnej liczby.

Krok 1: lista początkowa

Oczywiście nasza „docelowa” lista 20 rekordów powinna zaczynać się od „pierwszych” rekordów według jednego z naszych kluczy owner_id. Dlatego najpierw znajdźmy takie „najwcześniejsze” według każdego z kluczy i dodajmy je do listy, sortując ją w porządku, który chcemy — (task_date, id).

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”

Krok 2: znajdujemy „następne” rekordy

Teraz, jeśli weźmiemy pierwszą pozycję z naszej listy i zaczniemy „kroczyć” dalej po indeksie z zachowaniem klucza owner_id, to wszystkie znalezione rekordy będą właśnie kolejnymi w wynikowym zbiorze. Oczywiście tylko dopóki nie przekroczyliśmy klucza aplikacji drugiego rekordu na liście.

Jeśli okazało się, że „przecięliśmy” drugi zapis, to ostatni przeczytany zapis powinien zostać dodany do listy zamiast pierwszego (z tym samym owner_id), po czym lista jest ponownie sortowana.

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”

Oznacza to, że zawsze będziemy mieć w liście nie więcej niż jeden zapis dla każdego z kluczy (jeśli zapisy się skończą, a my nie „przecięliśmy”, to pierwszy zapis po prostu zniknie z listy i nic nie zostanie dodane), przy czym są zawsze posortowane w porządku rosnącym klucza aplikacji (task_date, id).

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”

Krok 3: filtrujemy i „rozwijamy” zapisy

W niektórych linijkach naszego rekurencyjnego wyboru niektóre zapisy rv się dublują — najpierw znajdujemy te, które „przecinają granicę 2. zapisu na liście”, a potem wstawiamy jako 1. na liście. Tak więc pierwsze pojawienie się należy odfiltrować.

Straszne końcowe zapytanie

Z REKURSJĄ T JAKO (
  -- #1 : zbieramy "pierwsze" zapisy dla każdego z kluczy zbioru
  Z wrap JAKO ( -- "materializujemy" rekordy, aby odwołanie do pól nie powodowało mnożenia InitPlan/SubPlan
    Z T JAKO (
      WYBIERZ
        (
          WYBIERZ
            r
          Z
            zadanie r
          GDZIE
            owner_id = unnest
          PORZĄDEK Z
            task_date, id
          LIMIT 1
        ) r
      Z
        unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
    )
    WYBIERZ
      array_agg(r PORZĄDEK Z (r).task_date, (r).id) lista -- sortujemy listę w odpowiedniej kolejności
    Z
      T
  )
  WYBIERZ
    lista
  , lista[1] rv
  , FAŁSZ not_cross
  , 0 rozmiar
  Z
    wrap
UNIA WSZYSTKIE
  -- #2 : odczytujemy zapisy 1-wszego klucza, aż nie przeskoczymy przez zapis 2-giego
  WYBIERZ
    PRZYPADEK
      -- jeśli nic nie znaleziono dla klucza 1-wszego zapisu
      KIEDY X._r JEST NIE ODRĘBNE OD NULL TO
        T.lista[2:] -- usuwamy go z listy
      -- jeśli NIE przeszliśmy przez klucz 2-giego zapisu
      KIEDY X.not_cross TO
        T.lista -- po prostu kontynuujemy tę samą listę bez modyfikacji
      -- jeśli lista już nie zawiera 2-giego zapisu
      KIEDY T.lista[2] JEST NULL TO
        -- po prostu zwracamy pustą listę
        '{}'
      -- przestawiamy słownik, usuwając 1-szy zapis i dodając ostatni znaleziony
      INACZEJ (
        WYBIERZ
          coalesce(T.lista[2] || array_agg(r PORZĄDEK Z (r).task_date, (r).id), '{}')
        Z
          unnest(T.lista[3:] || X._r) r
      )
    KONIEC
  , X._r
  , X.not_cross
  , T.rozmiar + X.not_cross::integer
  Z
    T
  , LATERAL(
      Z wrap JAKO ( -- "materializujemy" rekord
        WYBIERZ
          PRZYPADEK
            -- jeśli rzeczywiście "przeszliśmy" przez 2-gi zapis
            KIEDY NIE T.not_cross
              -- to potrzebny zapis - pierwszy z listy
              WTEDY T.lista[1]
            INACZEJ ( -- jeśli nie przeszliśmy, to klucz pozostał jak w poprzednim zapisie - od niego się odwołujemy
              WYBIERZ
                _r
              Z
                zadanie _r
              GDZIE
                owner_id = (rv).owner_id I
                (task_date, id) > ((rv).task_date, (rv).id)
              PORZĄDEK Z
                task_date, id
              LIMIT 1
            )
          KONIEC _r
      )
      WYBIERZ
        _r
      , PRZYPADKU
          -- jeśli 2-gi zapis już nie istnieje na liście, ale coś znaleźliśmy
          KIEDY lista[2] JEST NULL I _r JEST ODRĘBNE OD NULL TO
            PRAWDA
          INACZEJ -- nic nie znaleziono lub "przeszliśmy"
            coalesce(((_r).task_date, (_r).id) < ((lista[2]).task_date, (lista[2]).id), FAŁSZ)
        KONIEC not_cross
      Z
        wrap
    ) X
  GDZIE
    T.rozmiar < 20 I -- ograniczamy ilość
    T.lista JEST NIE ODRĘBNE OD '{}' -- lub dopóki lista się nie skończyła
)
-- #3 : "rozwijamy" zapisy - kolejność gwarantowana przez konstrukcję
WYBIERZ
  (rv).*
Z
  T
GDZIE
  not_cross; -- bierzemy tylko "nieprzeszłe" zapisy

SQL JakTo: piszemy pętlę while bezpośrednio w zapytaniu, albo „Elementarna pętla trójkowa”
[zobacz na explain.tensor.ru]

W ten sposób wymieniliśmy 50% odczytów danych na 20% czasu wykonania . Oznacza to, że jeśli istnieją powody, aby sądzić, że odczyt może być długi (na przykład, gdy dane często nie są w pamięci podręcznej i trzeba je pobierać z dysku), to w ten sposób można zmniejszyć zależność od odczytów.W każdym razie czas wykonania okazał się lepszy niż w «naiwnym» pierwszym wariancie. Ale który z tych 3 wariantów używać — to zależy od Ciebie.

Wydanie środowiska programistycznego KDevelop 5.5

Źródło: habr.com

Kup niezawodny hosting dla stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup niezawodny hosting dla stron z ochroną przed DDoS, serwery VPS VDS | ProHoster