Kamil Owczarek
Opublikowano

Wyszukiwarka szybsza o 60-80%: optymalizacja CTE w PostgreSQL pod Node.js

Autorzy

Problem: wyszukiwarka robiła się coraz wolniejsza

Nasz endpoint wyszukiwarki w e-commerce działał za wolno. W miarę jak rósł katalog produktów, zapytania, które kiedyś śmigały w 200ms, zaczęły podchodzić pod 800ms. Dla wyszukiwarki to nie do przyjęcia — użytkownicy oczekują natychmiastowych wyników.

Endpoint korzysta z CTE (Common Table Expressions) w PostgreSQL, żeby zbudować złożony wynik wyszukiwania obejmujący produkty, powiązane aktualności, kolekcje i kategorie. Po sprofilowaniu znalazłem trzy wzorce, które zabijały wydajność.

Antywzorzec #1: podwójny JOIN na tej samej tabeli

Problem

ProductMetadata AS (
    SELECT DISTINCT
        fp_limited.id AS product_id,
        product."collectionId" AS collection_id,
        product."mainCategoryId" AS category_id
    FROM (
        SELECT fp.id
        FROM FinalProductsPool fp
        JOIN public."Product" AS p ON p."id" = fp.id
        ORDER BY ...
        LIMIT 100
    ) AS fp_limited
    JOIN public."Product" AS product ON product."id" = fp_limited.id
)

Widzisz problem? Joinujemy tabelę Product dwa razy:

  1. Raz wewnątrz podzapytania, żeby posortować
  2. Raz na zewnątrz, żeby wyciągnąć collectionId i mainCategoryId

Każdy join to pełne wyszukanie. Przy tabeli liczącej ponad 50 000 produktów to kosztowna zabawa.

Rozwiązanie

Przenieś wybór pól do podzapytania:

ProductMetadata AS (
    SELECT
        fp_limited.id AS product_id,
        fp_limited.collection_id,
        fp_limited.category_id
    FROM (
        SELECT
            fp.id,
            p."collectionId" AS collection_id,
            p."mainCategoryId" AS category_id
        FROM FinalProductsPool fp
        JOIN public."Product" AS p ON p."id" = fp.id
        ORDER BY ...
        LIMIT 100
    ) AS fp_limited
)

Jeden join zamiast dwóch. Te same dane, o połowę mniej roboty.

Antywzorzec #2: podzapytania z NOT IN

Problem

NewsFromCollections AS (
    SELECT DISTINCT n."id", 2 AS priority
    FROM ProductMetadata pm
    JOIN public."_newsCollections" nc ON nc."A" = pm.collection_id
    JOIN public."News" n ON n."id" = nc."B"
    WHERE
        pm.collection_id IS NOT NULL
        AND n."type" = 'Article'
        AND n."id" NOT IN (SELECT id FROM NewsFromProducts)  -- SLOW
),
NewsFromCategories AS (
    SELECT DISTINCT n."id", 3 AS priority
    FROM ProductMetadata pm
    JOIN public."_newsCategories" ncat ON ncat."A" = pm.category_id
    JOIN public."News" n ON n."id" = ncat."B"
    WHERE
        pm.category_id IS NOT NULL
        AND n."type" = 'Article'
        AND n."id" NOT IN (SELECT id FROM NewsFromProducts)    -- SLOW
        AND n."id" NOT IN (SELECT id FROM NewsFromCollections) -- SLOW
)

Podzapytania z NOT IN to wydajnościowy koszmar PostgreSQL. Dla każdego wiersza baza musi:

  1. Wykonać podzapytanie
  2. Sprawdzić, czy bieżące ID występuje w zbiorze wyników
  3. Obsłużyć przypadki brzegowe z NULL-ami (które całkowicie zmieniają zachowanie)

Przy zagnieżdżonych CTE to się kumuluje — PostgreSQL nie zawsze potrafi optymalizować w poprzek granic CTE.

Rozwiązanie

Zamień NOT IN na LEFT JOIN + IS NULL:

NewsFromCollections AS (
    SELECT DISTINCT n."id", 2 AS priority
    FROM ProductMetadata pm
    JOIN public."_newsCollections" nc ON nc."A" = pm.collection_id
    JOIN public."News" n ON n."id" = nc."B"
    LEFT JOIN NewsFromProducts nfp ON nfp."id" = n."id"
    WHERE
        pm.collection_id IS NOT NULL
        AND n."type" = 'Article'
        AND nfp."id" IS NULL  -- Exclude already-found news
),
NewsFromCategories AS (
    SELECT DISTINCT n."id", 3 AS priority
    FROM ProductMetadata pm
    JOIN public."_newsCategories" ncat ON ncat."A" = pm.category_id
    JOIN public."News" n ON n."id" = ncat."B"
    LEFT JOIN NewsFromProducts nfp ON nfp."id" = n."id"
    LEFT JOIN NewsFromCollections nfc ON nfc."id" = n."id"
    WHERE
        pm.category_id IS NOT NULL
        AND n."type" = 'Article'
        AND nfp."id" IS NULL
        AND nfc."id" IS NULL
)

Dlaczego to jest szybsze?

LEFT JOIN może korzystać z indeksów i wchodzi w skład planu zapytania. Baza z góry wie, co robi, i potrafi zoptymalizować całą sekwencję joinów. NOT IN wymusza sprawdzanie wiersz po wierszu, którego nie da się zaplanować równie efektywnie.

Antywzorzec #3: zbędne CTE z osobnym sortowaniem

Problem

UniqueCollectionsRaw AS (
    SELECT DISTINCT ON (col."id")
        col."id",
        col."name",
        asset."fullpath" AS "mainPhotoFullPath",
        col."priority"
    FROM ProductMetadata pm
    JOIN public."Collection" col ON col."id" = pm.collection_id
    LEFT JOIN public."Asset" asset ON col."mainPhotoId" = asset."id"
    WHERE col."isPublished" = true
    ORDER BY col."id"
),
UniqueCollections AS (
    SELECT id, name, "mainPhotoFullPath"
    FROM UniqueCollectionsRaw
    ORDER BY priority DESC
    LIMIT 5
)

Dwa CTE tam, gdzie wystarczyłoby jedno. Pierwsze deduplikuje po id, drugie sortuje na nowo po priority. PostgreSQL potrafi zrobić jedno i drugie w jednym przebiegu.

Rozwiązanie

Połącz to w jedno CTE z sensowną agregacją:

UniqueCollections AS (
    SELECT
        col."id",
        col."name",
        asset."fullpath" AS "mainPhotoFullPath"
    FROM (
        SELECT DISTINCT pm.collection_id
        FROM ProductMetadata pm
        WHERE pm.collection_id IS NOT NULL
    ) AS distinct_collections
    JOIN public."Collection" col ON col."id" = distinct_collections.collection_id
    LEFT JOIN public."Asset" asset ON col."mainPhotoId" = asset."id"
    WHERE col."isPublished" = true
    ORDER BY col."priority" DESC
    LIMIT 5
)

Jedno CTE zamiast dwóch. Najpierw deduplikacja (mniejszy zbiór danych), dopiero potem join i sortowanie.

Łączny efekt

Po wdrożeniu wszystkich trzech optymalizacji:

MetrykaPrzedPoPoprawa
Średni czas zapytania780ms180ms77% szybciej
Czas zapytania P951.2s320ms73% szybciej
CPU bazy na zapytanieWysokieNiskie~60% mniej

Przy szerokich wyszukiwaniach (pojedyncza litera albo popularne słowo) poprawa była jeszcze bardziej wyraźna — sięgała blisko 80%.

Jak znaleźć te wzorce u siebie w kodzie

1. Szukaj podwójnych joinów

Wyłap miejsca, w których joinujesz tabelę w podzapytaniu, a potem joinujesz ją ponownie na zewnątrz:

FROM (
    SELECT ... FROM table1 JOIN table2 ...
) AS subquery
JOIN table2 ...  -- Same table again!

2. Znajdź podzapytania z NOT IN

grep -r "NOT IN (SELECT" src/

Prawie każde NOT IN (SELECT ...) da się zastąpić przez LEFT JOIN ... IS NULL.

3. Sprawdź łańcuchy CTE

Jeśli masz CTE, które tylko przemielają poprzednie CTE bez żadnych nowych joinów:

CTE_A AS (SELECT ... ORDER BY x),
CTE_B AS (SELECT * FROM CTE_A ORDER BY y LIMIT n)

Rozważ ich połączenie.

Wskazówki do optymalizacji CTE w PostgreSQL

1. CTE to bariery optymalizacyjne (zazwyczaj)

PostgreSQL tradycyjnie traktuje CTE jak bariery optymalizacyjne — nie wstawia ich inline ani nie przepycha do nich predykatów. Zmieniło się to w PostgreSQL 12 wraz z NOT MATERIALIZED, ale wiele wzorców wciąż zyskuje na ręcznej optymalizacji.

2. Agreguj przed joinowaniem

Jeśli joinujesz dużą tabelę tylko po to, żeby dostać kilka unikalnych wartości, najpierw wyciągnij te wartości:

-- Slow: Join first, deduplicate later
SELECT DISTINCT col.* FROM big_table bt JOIN collection col ON ...

-- Fast: Deduplicate first, join smaller set
SELECT col.* FROM (
    SELECT DISTINCT collection_id FROM big_table
) AS ids
JOIN collection col ON col.id = ids.collection_id

3. LEFT JOIN IS NULL kontra NOT EXISTS

Oba są lepsze od NOT IN, ale przy sprawdzaniu istnienia NOT EXISTS bywa czasem szybszy:

-- Good
LEFT JOIN other_table ot ON ot.id = main.id
WHERE ot.id IS NULL

-- Sometimes better (especially with proper indexes)
WHERE NOT EXISTS (SELECT 1 FROM other_table ot WHERE ot.id = main.id)

Sprofiluj oba warianty w swoim konkretnym przypadku.

Mierzenie wydajności zapytań

EXPLAIN ANALYZE

Przy optymalizacji zawsze sięgaj po EXPLAIN ANALYZE:

EXPLAIN ANALYZE
WITH ...your CTEs...
SELECT ...

Szukaj:

  • Seq Scan na dużych tabelach (brakujący indeks?)
  • Nested Loop przy dużej liczbie wierszy (zła kolejność joinów?)
  • operacji Sort bez indeksu (dołożyć indeks czy przepisać zapytanie?)

W Prismie

Dla surowych zapytań w Prismie:

const results = await prisma.$queryRaw`
  EXPLAIN ANALYZE
  ${yourQuery}
`
console.log(results)

Wnioski

Trzy proste wzorce sprawiały, że nasza wyszukiwarka była 4x wolniejsza, niż musiała być:

  1. Podwójne JOIN-y: joinuj raz i od razu wybierz wszystko, czego potrzebujesz
  2. Podzapytania z NOT IN: użyj zamiast tego LEFT JOIN IS NULL
  3. Zbędne CTE: łącz operacje tam, gdzie się da

Poprawka to 23 zmienione linijki w zapytaniu liczącym ponad 1000 linii. Żadnych zmian w schemacie, żadnych nowych indeksów, żadnych podbić infrastruktury — po prostu mądrzejszy SQL.

Jeśli twoje zapytania w PostgreSQL są wolne, zacznij od poszukania tych wzorców. Rozwiązanie może być prostsze, niż myślisz.


Prawdziwa optymalizacja endpointu wyszukiwarki e-commerce obsługującego 11 rynków europejskich. Czas zapytania spadł z 780ms do 180ms.