Optymalizacja zapytań SQL w dużych systemach: praktyczne techniki dla devów

0
118
3.4/5 - (7 votes)

Nawigacja:

Jak rozumieć „wydajne zapytanie” w dużym systemie

Co oznacza „duży system” z perspektywy bazy danych

„Duży system” to nie zawsze petabajty danych i setki serwerów. Z perspektywy bazy liczy się przede wszystkim:

  • ruch – liczba zapytań na sekundę/minutę, równoległe połączenia, bursty ruchu;
  • ilość danych – wielkość tabel, liczba indeksów, rozmiar pojedynczych rekordów;
  • złożoność logiki – JOIN-y na wielu tabelach, ciężkie agregacje, raporty „na żywo”.

Nawet system z kilkoma milionami rekordów może zabić bazę, jeśli każde zapytanie skanuje całe tabele lub robi kilkanaście JOIN-ów bez indeksów. Z drugiej strony – dobrze zaprojektowana baza poradzi sobie z dziesiątkami milionów wierszy, jeśli zapytania są proste i precyzyjne, a indeksy sensownie dobrane.

Z punktu widzenia developera liczy się nie to, jak „duży” jest system na papierze, ale to, jak często użytkownicy trafiają w te same, kosztowne fragmenty SQL. Często jeden nieoptymalny widok panelu admina potrafi obciążyć bazę bardziej niż cała reszta aplikacji.

Pojedynczy SELECT kontra optymalizacja całego przepływu

Optymalizowanie pojedynczego SELECT-a jest kuszące, bo łatwo zmierzyć wynik: było 800 ms, jest 60 ms. W dużym systemie ważniejszy jest jednak cały przepływ zapytań:

  • ile zapytań wykonuje jeden request HTTP / komenda / job crona;
  • czy te zapytania są powtarzalne (np. N+1 queries do tej samej tabeli);
  • czy da się zastąpić serię małych zapytań jednym, dobrze zaprojektowanym zapytaniem;
  • czy warstwa aplikacyjna nie dubluje pracy z bazy (np. liczenie sum po stronie kodu, choć baza mogłaby to zrobić jednym GROUP BY).

W praktyce często większy efekt daje:

  • redukcja liczby zapytań z 50 do 5 na request,
  • usunięcie N+1 queries z listy,
  • dodanie lekkiego cache’a w aplikacji,

niż wyciskanie dodatkowych 10% z jednego SELECT-a, który i tak odwoływany jest rzadko.

Rzeczywisty koszt: CPU, I/O, pamięć i blokady

Z punktu widzenia użytkownika liczy się czas odpowiedzi. Z punktu widzenia bazy – kombinacja czterech czynników:

  • I/O – odczyt z dysku, skany dużych tabel, brak indeksów, losowe odczyty;
  • CPU – sortowania, agregacje, funkcje na dużych zbiorach danych;
  • pamięć – buffor cache, hash joiny, sortowanie w pamięci vs na dysku;
  • blokady – konflikty między odczytem a zapisem, długie transakcje trzymające locki.

Biznes odczuwa te problemy jako:

  • spowolnienia kluczowych ekranów (checkout, panel sprzedażowy),
  • timeouty i błędy 500,
  • okresowe „zwieszki” systemu przy raportach lub batchach,
  • rosnące koszty utrzymania – mocniejsze maszyny, więcej instancji, droższa licencja.

Optymalizacja zapytań SQL ma sens wtedy, gdy pozwala ograniczyć I/O i blokady na krytycznych ścieżkach, dzięki czemu ta sama infrastruktura obsłuży większy ruch bez inwestycji w sprzęt.

Optymalizacja SQL vs dokładanie sprzętu

Dołożenie CPU czy RAM-u jest szybkie, ale ma trzy problemy:

  1. Skalowanie sprzętowe ma sufit – wcześniej czy później pojawi się wąskie gardło I/O lub blokad.
  2. Koszt stały – mocniejsza maszyna lub wyższy tier w chmurze to abonament, który płacisz co miesiąc.
  3. Skalujesz też błędy – nieoptymalne zapytania dalej są nieoptymalne, tylko trochę mniej bolą, aż ruch znów wzrośnie.

Refaktoryzacja całej warstwy danych jest droga. Natomiast:

  • dodanie kilku dobrych indeksów,
  • przepisać 3–5 kluczowych zapytań,
  • usunąć najgorszy N+1 problem,

to zwykle 1–2 dni pracy developera, a efekt bywa porównywalny z przesiadką na mocniejszy serwer. Rozsądne podejście to mix: najpierw tania optymalizacja SQL w newralgicznych miejscach, dopiero potem decyzja o skoku na wyższy poziom sprzętu.

Jak priorytetyzować zapytania do optymalizacji

Najlepsze rezultaty przy najmniejszym nakładzie pracy daje skupienie się na zapytaniach, które łączą w sobie:

  • długi czas wykonania lub duże zużycie zasobów,
  • częste wywołania – endpointy używane przez wszystkich użytkowników, joby odpalane co minutę,
  • krytyczność biznesową – sprzedaż, płatności, logowanie, główny panel operacyjny.

Praktyczna taktyka:

  1. Włącz logi slow queries (np. powyżej 500 ms).
  2. Zgrupuj je po signaturze zapytania (bez konkretnych parametrów).
  3. Posortuj po: czas wykonania × liczba wywołań.
  4. Wybierz top 10% najbardziej kosztownych i zajmij się tylko nimi.

Taka priorytetyzacja zwykle daje 80% efektu przy 20% wysiłku, zamiast grzebania w przypadkowych SELECT-ach, które odpalają się raz dziennie w panelu admina.

Jak namierzać problematyczne zapytania w produkcji

Logi slow queries – tani radar do bazy

Pierwszy krok bez większych inwestycji w narzędzia to włączenie logów wolnych zapytań.

  • MySQL/MariaDB: slow_query_log, long_query_time, log_queries_not_using_indexes;
  • PostgreSQL: log_min_duration_statement, log_statement, auto_explain;
  • SQL Server: Extended Events / Query Store.

Praktyczne ustawienia „na start”:

  • logować zapytania trwające dłużej niż 300–500 ms,
  • agregować logi na osobnym serwerze (syslog, ELK, Loki, CloudWatch),
  • usuwać z logów: krótkotrwałe, rzadkie raporty odpalane ręcznie, które nie są problemem dla biznesu.

Dobrze jest logować też:

  • liczbę odczytanych wierszy,
  • liczbę skanowanych stron/bloków,
  • plany zapytań dla najwolniejszych (np. co N minut dzięki auto_explain w PostgreSQL).

Proste metryki: czas, liczba wierszy, częstotliwość

Nie trzeba od razu wdrażać drogich APM-ów. Już trzy metryki bardzo dobrze wskazują, gdzie leży problem:

  1. Czas trwania – średni, percentyle (p95, p99). Interesują: długie ogony, nie średnia.
  2. Liczba odczytanych wierszy – czy zapytanie faktycznie potrzebuje miliona wierszy do odpowiedzi?
  3. Liczba wywołań na minutę – nawet średnio wolne zapytanie, jeśli odpalane jest tysiące razy, żyłuje bazę.

Jeśli nie ma dedykowanych narzędzi, można:

  • dodać prosty middleware logujący czas trwania zapytań według signatury,
  • uzupełnić kod ORM o logowanie zapytań powyżej progu,
  • co kilka minut zaciągać z bazy systemowe statystyki (np. PostgreSQL pg_stat_statements).

Kluczowe jest połączenie tych danych w jednym widoku: „zapytanie X – średni czas, p95, liczba wywołań, łączny czas CPU/I/O”. Wtedy łatwo widać, które zapytania są realnym problemem, a które tylko raz na jakiś czas trwają 5 sekund, ale nikt tego nie zauważa.

Tanie narzędzia: EXPLAIN, STATISTICS i lekkie APM

Zestaw „low-budget”, który wystarczy w większości projektów:

  • EXPLAIN / EXPLAIN ANALYZE – główne narzędzie do zrozumienia planu zapytania;
  • system views – np. pg_stat_statements (PostgreSQL), information_schema i performance_schema (MySQL), DMVs (SQL Server);
  • proste APM – tańsze lub open source: New Relic w minimalnym pakiecie, Elastic APM, Sentry Performance, OpenTelemetry + Prometheus/Grafana.

W praktyce wystarczy często:

  • zidentyfikować wolny endpoint w APM,
  • sprawdzić, które zapytania go dominują,
  • wziąć top 1–3 zapytań do EXPLAIN ANALYZE,
  • na tej podstawie zaplanować indeksy czy refaktoryzację SQL.

Od wolnego endpointu do konkretnego SQL-a – krok po kroku

Typowy scenariusz w projekcie:

  1. Użytkownicy zgłaszają: lista zamówień w panelu działa bardzo wolno.
  2. APM pokazuje: endpoint GET /orders ma p95 = 3,5 s.
  3. Profilowanie requestu ujawnia: wykonuje się 80 zapytań do bazy.
  4. Z logów SQL wynika, że:
    • jedno zapytanie SELECT … FROM orders … trwa ~900 ms,
    • 70 zapytań SELECT … FROM customers WHERE id = ? to typowe N+1.
  5. Priorytet: najpierw usunąć N+1 (zrobić JOIN/IN/batch load), potem zoptymalizować główne SELECT do orders.

Taki proces można ustandaryzować i przerobić na checklistę dla zespołu.

Szybkie oznaczanie kandydatów do optymalizacji

Najprostsza taktyka dla developera, który ma 1–2 dni na „sprzątanie” SQL:

  • Wylistować z logów slow queries top 20 zapytań wg łącznego czasu (czas × liczba wywołań).
  • Dla każdego policzyć, ile realnie da się urwać przy rozsądnym nakładzie pracy (np. potencjalnie ×10 vs ×1.2).
  • Oszacować liczbę godzin na:
    • analizę planu,
    • dodanie indeksów,
    • refaktoryzację logiki / ORM.
  • Wybrać 3–5 zapytań z najlepszym stosunkiem potencjalny zysk / szacowany czas.

Takie podejście jest pragmatyczne: skupiasz się na miejscach, gdzie naprawdę „płynie kasa”, a nie na akademickich optymalizacjach milisekund.

Zbliżenie kolorowego kodu programistycznego na ekranie monitora
Źródło: Pexels | Autor: Markus Spiske

Czytanie i rozumienie planu zapytania bez bycia DBA

Kluczowe elementy planu wykonania

Większość planów wykonania da się zrozumieć, jeśli zna się kilka podstawowych pojęć:

  • scan vs seek:
    • scan – skanowanie całej tabeli lub indeksu (Full Table Scan, Index Scan);
    • seek – celowane wyszukiwanie po indeksie (Index Seek, Index Only Scan).
  • typy JOIN:
    • Nested Loop – dobry dla małych zbiorów i dobrze dobranych indeksów;
    • Merge Join – wymaga posortowanych wejść, świetny dla dużych zakresów;
    • Hash Join – buduje hash w pamięci, dobry dla dużych nieposortowanych zbiorów.
  • Sort – sortowanie, które może zejść na dysk, jeśli brakuje pamięci lub indeksu z odpowiednim porządkiem.
  • Aggregate – operacje GROUP BY, COUNT, SUM itd.

Jako developer szukasz głównie:

  • słów „Seq Scan”, „Table Scan”, „Index Scan” na dużych tabelach,
  • drogich sortowań i hash joinów,
  • miejsc, gdzie liczba przetwarzanych wierszy idzie w miliony, choć wynik ma kilkadziesiąt rekordów.

Pełne skanowanie tabeli – kiedy jest problemem

Pełny skan tabeli nie jest złem samym w sobie. Problem pojawia się, gdy:

  • tabela ma setki tysięcy lub miliony wierszy,
  • zapytanie odpala się często,
  • logika faktycznie potrzebuje tylko niewielkiego podzbioru danych.

Przykład:

Przykład: kiedy Seq Scan jest OK, a kiedy boli

Załóżmy, że mamy tabelę currencies z kilkudziesięcioma wierszami, z której raz na godzinę odczytujesz wszystkie rekordy:

SELECT code, rate
FROM currencies;

Plan pokazuje Seq Scan on currencies. I dobrze – pełne przejście po małej tabeli jest tańsze niż jakiekolwiek kombinacje z indeksem. Próby „ratowania” tego zapytania indeksem nie mają sensu.

Inna sytuacja: tabela orders rośnie od lat, ma miliony wierszy, a aplikacja regularnie odpala:

SELECT *
FROM orders
WHERE status = 'PENDING';

Plan nadal pokazuje Seq Scan on orders, każdorazowo czytasz całą tabelę, żeby znaleźć ułamek rekordów. Zapytanie wisi, dysk mieli, CPU skacze. Tutaj pełny skan to realny problem, szczególnie jeśli endpoint jest w głównym flow użytkownika.

Prosty ruch:

CREATE INDEX idx_orders_status ON orders(status);

Plan zmienia się na Index Scan lub Index Only Scan, odczytanych stron jest o rząd wielkości mniej, a czas wykonania spada z sekund do dziesiątek milisekund.

Szacowana vs rzeczywista liczba wierszy

Kolejny element planu, który daje szybki sygnał o problemach, to porównanie wartości:

  • estimated rows – ile wierszy optymalizator spodziewa się przetworzyć,
  • actual rows – ile wierszy faktycznie przeszło przez daną część planu (EXPLAIN ANALYZE).

Jeśli widzisz, że optymalizator przewidział 100 wierszy, a rzeczywistość to 100 000, to znak, że:

  • statystyki są przestarzałe lub zbyt ogólne,
  • warunki WHERE są bardziej selektywne, niż „myśli” silnik,
  • optymalizator dobrał strategię (np. rodzaj JOIN-a) na błędnych założeniach.

Efekt w praktyce: wybór Nested Loop tam, gdzie lepiej sprawdziłby się Hash Join, albo skan ogromnego indeksu zamiast małego zakresu.

Niskokosztowe działania:

  • wykonać ANALYZE lub włączyć agresywniejsze autovacuum/analyze (PostgreSQL),
  • zwiększyć poziom próbkowania statystyk dla kluczowych kolumn (np. ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000;),
  • przeprojektować warunki (np. unikać funkcji na kolumnach w WHERE, które psują selektywność).

Sort, agregacje i operacje, które lubią I/O

W planie często pojawiają się operacje:

  • Sort,
  • GroupAggregate / HashAggregate,
  • Distinct.

Same w sobie nie są problemem, ale jeśli plan pokazuje wysoki koszt, dużo wierszy wejściowych i informację o „disk-based” sort/aggregate, zaczyna robić się drogo. Widać to szczególnie przy paginacji:

SELECT *
FROM events
ORDER BY created_at DESC
LIMIT 50 OFFSET 10000;

Silnik musi posortować dużą liczbę wierszy, często zapisując tymczasowe dane na dysk. Przy takich przypadkach lepsze są:

  • indeks wspierający sortowanie, np. CREATE INDEX idx_events_created_at ON events(created_at DESC);,
  • paginacja po „kursorem” (WHERE < ostatni_widoczny_created_at) zamiast OFFSET dla głębokich stron.

Indeksy – największa dźwignia przy rozsądnym koszcie

Jakie typy indeksów faktycznie się przydają

W typowym systemie biznesowym większość potrzeb pokryje kilka prostych typów indeksów:

  • B-tree – domyślny i najczęściej wystarczający: równość, zakresy, sortowanie;
  • indeksy złożone (wielokolumnowe) – pod typowe filtry w aplikacji;
  • indeksy częściowe (partial) – tylko na „żywą” część danych (np. aktywne rekordy);
  • indeksy pokrywające (covering) – z dodatkowymi kolumnami w INCLUDE (tam gdzie silnik to wspiera).

GiN/GiST, full-texty, indeksy kolumnowe – to już bardziej wyspecjalizowany temat. Zwykle wchodzą do gry dopiero, gdy prostsze opcje są wykorzystane.

Projektowanie indeksów pod konkretne zapytania

Najtańsza strategia to nie „indeks na każdą kolumnę”, ale kilka indeksów skrojonych pod realne zapytania z logów. Dla każdego z topowych SQL-i zadaj kilka pytań:

  • Jakie są warunki WHERE?
  • Po czym JOIN-ujesz?
  • Jakie są typowe ORDER BY i GROUP BY?

Na tej podstawie można dobrać odpowiednią kolejność kolumn w indeksie złożonym.

Przykład – raport zamówień:

SELECT id, customer_id, status, created_at, total
FROM orders
WHERE customer_id = :customer_id
  AND created_at >= :from
  AND created_at < :to
ORDER BY created_at DESC
LIMIT 50;

Tutaj logiczny wybór to:

CREATE INDEX idx_orders_customer_created_at
  ON orders(customer_id, created_at DESC);

Taki indeks:

  • filtruje po customer_id,
  • pozwala efektywnie zawęzić zakres po created_at,
  • pokrywa sortowanie po created_at DESC,
  • pozwala często wykonać Index Only Scan, jeśli kolumny id, status, total są w tym samym indeksie lub dodane jako INCLUDE (PostgreSQL, SQL Server).

Nadindeksowanie – cichy zabójca CPU przy zapisach

Tworzenie indeksu jest tanie, dopóki patrzysz wyłącznie na SELECT-y. Każdy dodatkowy indeks to jednak:

  • dodatkowy koszt INSERT/UPDATE/DELETE,
  • dłuższy czas utrzymywania blokad przy modyfikacjach,
  • więcej pracy dla autovacuum/maintenence,
  • większe zużycie dysku i cache.

Dobrym kompromisem jest regularny przegląd indeksów:

  • sprawdzenie, które indeksy są prawie nieużywane (statystyki użycia),
  • wyłapanie indeksów duplikujących się (np. (a,b) oraz osobno (a)),
  • usunięcie indeksów, które powstały „na wszelki wypadek”, a niczego już nie przyspieszają.

Kilka godzin pracy na kwartalny „cleanup indeksów” potrafi trwałe obniżyć obciążenie CPU na zapisach.

Indeksy częściowe: optymalizacja na „gorącej” części danych

W wielu systemach duża część danych to archiwum, które prawie nie jest dotykane, a logika aplikacji korzysta głównie z aktualnych rekordów. Dla takich scenariuszy świetnie sprawdzają się indeksy częściowe.

Przykład – tabela sessions:

SELECT *
FROM sessions
WHERE user_id = :user_id
  AND expires_at > NOW();

Zamiast pełnego indeksu na setki milionów sesji, można zrobić:

CREATE INDEX idx_sessions_active_user
ON sessions(user_id, expires_at)
WHERE expires_at > NOW();

Dzięki temu indeks jest dużo mniejszy, szybszy do przeszukiwania i tańszy w utrzymaniu. Archiwalne rekordy nie zaśmiecają „gorącego” indeksu.

Pisanie zapytań pod optymalizator: wzorce i antywzorce

Prosty WHERE zamiast logiki w funkcjach

Częsty, cichy antywzorzec to używanie funkcji po stronie kolumny w WHERE:

-- antywzorzec
SELECT *
FROM users
WHERE LOWER(email) = LOWER(:email);

Wiele silników w takiej sytuacji nie jest w stanie użyć indeksu po email (chyba że to indeks funkcyjny). Lepsza wersja to:

SELECT *
FROM users
WHERE email = :normalized_email;

Czyli normalizacja po stronie aplikacji lub triggera, a w bazie prosty warunek wspierany zwykłym indeksem B-tree.

Unikanie SELECT *

SELECT * kusi wygodą, ale kosztuje podwójnie:

  • czyta więcej danych z dysku niż potrzeba,
  • uniemożliwia wielu zapytaniom skorzystanie z Index Only Scan, bo trzeba sięgnąć do tabeli po kolumny spoza indeksu.

Dla zapytań w newralgicznych ścieżkach lepiej jawnie wymienić kolumny. To też dobra okazja, żeby uświadomić sobie, czego aplikacja naprawdę potrzebuje.

IN vs JOIN vs podzapytania

Wokół IN, EXISTS i JOIN-ów narosło sporo mitów. Optymalizatory często potrafią przekształcić jedno w drugie, jednak nie zawsze. Kilka praktycznych zasad:

  • Jeśli potrzebujesz tylko sprawdzić istnienie powiązania – użyj EXISTS:
    SELECT *
    FROM orders o
    WHERE EXISTS (
      SELECT 1 FROM payments p
      WHERE p.order_id = o.id
        AND p.status = 'CONFIRMED'
    );
  • Jeśli faktycznie potrzebujesz danych z obu tabel – użyj JOIN.
  • IN z dużą listą literaliów lepiej zamienić na tymczasową tabelę lub tabelę pomocniczą z indeksem.

Nie chodzi o „jedynie słuszną” formę, ale o to, co optymalizator może najłatwiej przełożyć na sensowny plan przy dostępnych indeksach.

LIMIT/OFFSET i „paginacja bieda-edition”

Klasyczny wzorzec:

SELECT *
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
LIMIT 50 OFFSET 5000;

Jest prosty w implementacji, ale źle skaluje się z rosnącym OFFSET. Baza musi policzyć pominięte wiersze i utrzymać sortowanie. Tańsze podejście przy często przeglądanych listach to paginacja oparta na „kursorem”:

-- pierwsza strona
SELECT *
FROM orders
WHERE customer_id = :cid
ORDER BY created_at DESC
LIMIT 50;

-- następna strona (załóżmy, że znamy najmniejszą wartość created_at z poprzedniej strony)
SELECT *
FROM orders
WHERE customer_id = :cid
  AND created_at < :last_created_at
ORDER BY created_at DESC
LIMIT 50;

Z punktu widzenia indeksu i planu, to duża różnica. OFFSET praktycznie znika z kosztu.

Okulary z odbiciem kodu SQL na ekranie monitora
Źródło: Pexels | Autor: Kevin Ku

JOIN-y, agregacje i paginacja – typowe źródła kłopotów

Plan JOIN-ów: w jakiej kolejności łączy baza

Dla zapytań z kilkoma JOIN-ami optymalizator wybiera kolejność łączenia tabel. Z perspektywy developera ważne są dwie rzeczy:

  • czy join keys (kolumny w ON) są zindeksowane po obu stronach,
  • czy nie robisz JOIN-a na dużo większym zbiorze niż potrzeba (np. filtrowanie po WHERE dopiero po kilku JOIN-ach).

Zamiast:

SELECT *
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE c.country = 'PL';

często lepiej, by optymalizator „widział” filtr wcześniej. Czasem pomaga przerobienie zapytania tak, by ograniczyć dane przed dużym JOIN-em, np. przez CTE lub podzapytanie, które realnie redukuje wiersze, a nie jest tylko „ładnym opakowaniem”.

Duże agregacje i raporty

Raporty typu „wszystkie zamówienia z ostatnich 12 miesięcy, zgrupowane po regionie, produkcie i kanale sprzedaży” potrafią zjeść bazę, jeśli lecą bez opamiętania po tabeli transakcyjnej.

Na start, zanim wjedzie osobny data warehouse, można:

  • przenieść ciężkie raporty na osobną replikę tylko do odczytu,
  • wprowadzić zrzuty dzienne (materialized views, tabele agregatów),
  • odpytywać nie „gołe” tabelki transakcyjne, ale przetworzone dane z agregatami.

Prosty materializowany widok z aktualizacją raz na godzinę często wystarcza, żeby raport z kilkunastu sekund skrócić do setek milisekund bez ruszania logiki produkcyjnej.

Paginacja raportów vs pełne exporty

Kolejny pragmatyczny kompromis: zamiast generować „pełen CSV wszystkich danych” on-line z jednego endpointu, który trzyma połączenie z bazą przez kilka minut, można:

  • w UI ograniczyć paginację do sensownych zakresów (np. max 1000 rekordów na widok),
  • dla pełnych exportów użyć joba w tle, który:
    • odczytuje dane w paczkach,
    • zapisuje wynik do pliku w storage,
    • odsyła użytkownikowi link po zakończeniu.
    • Redukowanie danych przed JOIN-em

      Wielokrotnie szybciej bywa zredukować dane wcześniej, a dopiero potem je łączyć. Zamiast przepychać przez JOIN całe tabele, lepiej zawęzić zakres w CTE lub podzapytaniu, które faktycznie odcina większość rekordów.

      -- wersja "wszystko naraz"
      SELECT o.id, o.customer_id, c.name, o.total
      FROM orders o
      JOIN customers c ON c.id = o.customer_id
      WHERE o.created_at >= :from
        AND o.created_at < :to
        AND c.segment = 'VIP';

      Przy bardzo dużym orders korzystniejsze bywa podejście dwustopniowe:

      WITH recent_orders AS (
        SELECT id, customer_id, total
        FROM orders
        WHERE created_at >= :from
          AND created_at < :to
      )
      SELECT ro.id, ro.customer_id, c.name, ro.total
      FROM recent_orders ro
      JOIN customers c ON c.id = ro.customer_id
      WHERE c.segment = 'VIP';

      Kluczowe jest to, żeby podzapytanie naprawdę przycinało liczbę wierszy, a nie było tylko kosmetyką. Jeśli w praktyce zawężasz dane do kilku procent tabeli, optymalizator ma dużo łatwiejsze zadanie.

      JOIN-y po funkcjach i wyrażeniach

      JOIN w stylu „funkcja po obu stronach” to klasyczny generator skanów sekwencyjnych:

      -- antywzorzec
      SELECT *
      FROM orders o
      JOIN customers c
        ON LOWER(c.email) = LOWER(o.customer_email);

      Indeksy po email w takim układzie często się nie przydają. Zamiast tego lepiej:

    • trzymać znormalizowane dane (np. maile w lowercase) w kolumnach,
    • łączyć po prostych kolumnach lub po dodatkowym kluczu technicznym.
    -- wersja przyjazna indeksom
    SELECT *
    FROM orders o
    JOIN customers c
      ON c.email_normalized = o.customer_email_normalized;

    Jeśli już nie ma wyjścia i musisz łączyć po przetworzonych wartościach, tańszą wersją niż funkcja wprost w ON bywa materializowanie „klucza łączącego” w dodatkowej kolumnie i zindeksowanie jej.

    JOIN vs podzapytania: aspekt kosztu

    Z punktu widzenia wydajności często nie chodzi o to, czy użyjesz JOIN, czy podzapytania, tylko jak duże zbiory każesz złączyć. Przykład z liczeniem liczby zamówień na klienta:

    -- wersja 1
    SELECT c.id, c.name,
           (SELECT COUNT(*)
            FROM orders o
            WHERE o.customer_id = c.id) AS orders_count
    FROM customers c;

    Przy tysiącach klientów i milionach zamówień taki wzorzec potrafi przeliczać ten sam zakres tabeli wiele razy. Często lepszy jest pojedynczy JOIN z agregacją:

    -- wersja 2
    SELECT c.id, c.name, COALESCE(o.orders_count, 0) AS orders_count
    FROM customers c
    LEFT JOIN (
      SELECT customer_id, COUNT(*) AS orders_count
      FROM orders
      GROUP BY customer_id
    ) o ON o.customer_id = c.id;

    Różnica w czasie wykonania rośnie wraz z rozmiarem danych. Z punktu widzenia nakładu pracy deweloperskiej – to wciąż prosty refaktor.

    GROUP BY a indeksy

    Agregacje często cierpią nie dlatego, że są „ciężkie z definicji”, tylko dlatego, że nie mają pod sobą sensownego indeksu. Jeśli raport grupuje po (country, status), a tabela ma indeks tylko po created_at, baza właściwie nie ma jak tego przyspieszyć.

    Przykład:

    SELECT country, status, COUNT(*) AS cnt
    FROM orders
    WHERE created_at >= :from
      AND created_at < :to
    GROUP BY country, status;

    Przy dużej tabeli prosty kompozytowy indeks potrafi zmienić wszystko:

    CREATE INDEX idx_orders_created_country_status
      ON orders(created_at, country, status);

    Plan może wtedy wykonać skan indeksu po zakresie dat i „przy okazji” wykorzystać już posortowane według country, status dane do grupowania. Koszt I/O dramatycznie spada, a implementacja sprowadza się do jednego polecenia DDL.

    AGGREGATE + JOIN: lepiej agregować wcześniej

    Gdy łączysz duże zbiory i jeszcze na końcu wykonujesz GROUP BY, niewielka zmiana kolejności operacji potrafi wyczyścić połowę kosztu. Zamiast:

    SELECT c.segment, SUM(o.total) AS revenue
    FROM orders o
    JOIN customers c ON c.id = o.customer_id
    WHERE o.created_at >= :from AND o.created_at < :to
    GROUP BY c.segment;

    często lepiej:

    WITH orders_agg AS (
      SELECT customer_id, SUM(total) AS customer_total
      FROM orders
      WHERE created_at >= :from AND created_at < :to
      GROUP BY customer_id
    )
    SELECT c.segment, SUM(oa.customer_total) AS revenue
    FROM orders_agg oa
    JOIN customers c ON c.id = oa.customer_id
    GROUP BY c.segment;

    Agregacja po stronie orders redukuje liczbę wierszy przed JOIN-em, co ma ogromne znaczenie przy bardzo aktywnych tabelach.

    Normalizacja, denormalizacja i inne kompromisy projektowe

    Normalizacja pod kątem zapytań krytycznych

    Model „3NF za wszelką cenę” bywa elegancki, ale w pewnym momencie rachunek się nie spina: każde pobranie danych to kaskada JOIN-ów, a indeksy muszą przykryć coraz więcej kluczy obcych. Dobrym punktem wyjścia jest:

    • trzymać model sensownie znormalizowany,
    • dla krytycznych ścieżek odczytu dodać dedykowane tabele lub kolumny zdenormalizowane.

    Przykład: ekran szczegółów zamówienia, który zawsze pokazuje także nazwę produktu i nazwę kategorii. Zamiast przy każdym SELECT-cie robić JOIN przez trzy tabele, można przy zapisie zamówienia zmaterializować nazwę kategorii w kolumnie order_category_name. Koszt: mały update przy zmianie kategorii; zysk: prostsze indeksy i tańsze SELECT-y w gorącej ścieżce.

    Denormalizacja punktowa vs „hurtownia w aplikacji”

    Pełna denormalizacja całego modelu zwykle kończy się chaosem. Dużo sensowniejsze są:

    • kolumny cache’ujące często używane atrybuty (np. status wyliczony, liczba elementów w koszyku),
    • małe tabele pod konkretne raporty lub widoki, synchro aktualizowane jobem w tle,
    • materializowane widoki z najczęściej używanymi agregatami.

    Koszt wdrożenia takich usprawnień jest niski, bo nie rozwala całego modelu domenowego. Wystarczy prosty ETL w cronie czy worker w kolejce zadań.

    Kolumny z wartościami wyliczanymi

    Gdy aplikacja często filtruje po wartości, którą trzeba policzyć na podstawie kilku kolumn (np. „czy subskrypcja jest aktywna”), tańsza długofalowo jest dodatkowa kolumna niż ciągłe wykonywanie złożonych warunków w WHERE.

    -- antywzorzec
    SELECT *
    FROM subscriptions
    WHERE canceled_at IS NULL
      AND (expires_at IS NULL OR expires_at > NOW())
      AND status = 'ACTIVE';

    Zamiast tego można utrzymywać kolumnę is_active aktualizowaną triggerem lub kodem aplikacji:

    SELECT *
    FROM subscriptions
    WHERE is_active = TRUE;

    Indeks po is_active jest prosty i skuteczny. Koszt: odrobina logiki przy zmianach rekordu, korzyść: prosty plan i szybkie filtry w każdym miejscu systemu.

    Partycjonowanie jako tańsza alternatywa „archiwum”

    Kiedy pojedyncza tabela rośnie do setek milionów rekordów, nawet dobre indeksy zaczynają przegrywać z rozmiarem. Zamiast przerabiać pół aplikacji na osobne archiwum, często wystarcza partycjonowanie po dacie lub innej osi czasu.

    -- przykład w PostgreSQL (schematycznie)
    CREATE TABLE orders (
      id BIGSERIAL PRIMARY KEY,
      created_at TIMESTAMP NOT NULL,
      ...
    ) PARTITION BY RANGE (created_at);

    Potem dokłada się partycje np. miesięczne czy kwartalne. Z punktu widzenia zapytań:

    • większość operacji „najnowsze zamówienia” dotyka tylko kilku ostatnich partycji,
    • stare dane można odcinać (DROP PARTITION) dużo taniej niż kasować wiersz po wierszu,
    • reindeksowanie czy vacuum na pojedynczej partycji trwa krócej.

    To nie jest darmowe – zmienia model DDL i wymaga trochę pracy operacyjnej – ale często tańsze niż rozbijanie logiki na dwie różne bazy (online + archiwum).

    Model a typowe zapytania: projektowanie „od końca”

    Przy nowych funkcjach najrozsądniej zacząć nie od schematu, ale od najbardziej realistycznych zapytań: jakie listy będzie oglądał użytkownik, jakie raporty będą generowane, po czym będzie filtrować UI. Mając takie 2–3 kluczowe SELECT-y, można ułożyć model tak, by:

    • JOIN-ów było mało i po prostych kluczach,
    • filtry mapowały się na pojedyncze, dobrze indeksowalne kolumny,
    • agregacje szły po polach, których kolejność w indeksie da się ułożyć rozsądnie.

    To tańsze niż późniejsza przebudowa schematu pod wymogi raportowania, kiedy w produkcji jest już kilka TB danych i setki zależności w kodzie.

    ORM a wydajność SQL: jak żyć z tym kompromisem

    N+1 queries – klasyczny przeciwnik

    ORM-y świetnie przyspieszają development, ale równie sprawnie generują wzorzec N+1: jedno zapytanie po listę encji, a potem osobne zapytanie po każdy powiązany rekord.

    -- pseudo-kod
    orders = Order.where(customer_id: cid).limit(100)
    orders.each do |o|
      puts o.customer.name        -- każde odwołanie odpala osobne SELECT
    end

    Na małej bazie działa, na produkcji potrafi wygenerować setki zapytań na żądanie. Tańsza w utrzymaniu niż ręczne SQL-e jest konfiguracja eager loadingu:

    orders = Order
      .includes(:customer)   -- lub prefetch/join zależnie od ORM
      .where(customer_id: cid)
      .limit(100)

    Cena: kilka dodatkowych parametrów w warstwie ORM; efekt: z setek zapytań robią się 2–3 dobrze dobrane SELECT-y.

    Filtry i sortowanie „z palca” vs możliwości ORM

    Część problemów z wydajnością wynika z przerzucania logiki filtrowania do kodu aplikacji, bo „w ORMach tak łatwiej”. Przykłady:

    • pobranie całej listy i filtrowanie po stronie aplikacji,
    • sortowanie w pamięci zamiast po stronie bazy,
    • ręczne łączenie kolekcji zamiast JOIN-a.

    Tego typu „ulepszenia” po cichu mnożą ilość danych przesyłanych z bazy i zużycie pamięci w aplikacji. W większości frameworków ORM da się wyrazić:

    • filtrowanie (where, filter),
    • sortowanie (order, sort_by mapowane na ORDER BY),
    • limit/offset lub paginację kursorem,
    • JOIN-y i agregacje.

    Dobrą praktyką jest pilnowanie, by to, co naturalnie robi SQL, dalej robił SQL, a nie aplikacja. Koszt: odrobina czasu na poznanie API ORM-u; zysk: mniej „magicznych” bottlenecków.

    Custom SQL jako „escape hatch” dla trudnych przypadków

    ORM-y mają sens jako domyślna ścieżka dla prostych CRUD-ów. Przy bardziej złożonych raportach lub krytycznych endpointach bardziej opłaca się:

    • napisać kilka ręcznych zapytań SQL,
    • opakować je w cienką warstwę w repozytorium / serwisie,
    • zostawić ORM tylko do mapowania wyników (jeśli w ogóle).

    Przykład: zaawansowany raport sprzedaży z kilkoma agregacjami i filtrowaniem po wielu kryteriach. Próba zapisania tego w DSL ORM-u kończy się zwykle mało czytelnym potworem, trudnym do debugowania pod kątem planu zapytania. Jedno czytelne SELECT ... FROM ... JOIN ... GROUP BY ... w pliku SQL jest tańsze w utrzymaniu i łatwiejsze do analizowania pod EXPLAIN.

    Kontrola generowanego SQL-a

    Duża część problemów z wydajnością w ORM-ach to niewiedza, co dokładnie trafia do bazy. Minimum higieny:

    • włączenie logowania zapytań w środowisku dev/stage,
    • profilowanie typowych ścieżek (np. za pomocą middleware rejestrującego liczbę i czas zapytań na żądanie),
    • traktowanie gwałtownego wzrostu liczby SELECT-ów jako sygnału do code review.

    To tanie narzędzia, a dają bardzo szybki feedback – zwłaszcza gdy na UI pojawia się nowa lista, tabelka albo raport, które nagle odpalają dziesiątki zapytań.

    Batchowanie zapisów i aktualizacji

    ORM-y kuszą prostym API do pojedynczych operacji INSERT/UPDATE w pętli. Dla bazy to jednak seria małych, stosunkowo drogich transakcji:

    records.each do |attrs|
      Model.create(attrs)   -- 1000 razy z osobna
    end

    Dużo bardziej przyjazne dla SQL-a i dla portfela (mniej czasu CPU, mniej blokad) są:

    • wstawki batchowe (INSERT ... VALUES (...), (...), ...),
    • Najczęściej zadawane pytania (FAQ)

      Jak sprawdzić, które zapytania SQL najbardziej spowalniają mój system?

      Najprostszy krok to włączenie logów wolnych zapytań w samej bazie (np. slow_query_log w MySQL, log_min_duration_statement i auto_explain w PostgreSQL). Ustaw próg na poziomie 300–500 ms i zbieraj logi na osobnym serwerze lub w systemie typu ELK, Loki, CloudWatch.

      Następnie zgrupuj zapytania po signaturze (bez konkretnych parametrów), zlicz:

      • średni czas i percentyle (p95, p99),
      • liczbę wywołań na minutę/godzinę,
      • łączny czas CPU / I/O dla danej signatury.

      Po posortowaniu po „czas × liczba wywołań” od razu widać, które 10–20 zapytań robi najwięcej szkody i to na nich opłaca się skupić w pierwszej kolejności.

      Co to znaczy „wydajne zapytanie SQL” w dużym systemie?

      Wydajne zapytanie to takie, które:

      • czyta jak najmniej danych z dysku (minimalny I/O),
      • zużywa niewiele CPU na sortowanie, agregacje i funkcje,
      • nie trzyma długo blokad i nie blokuje innych transakcji.

      W praktyce chodzi o to, żeby baza robiła dokładnie tyle pracy, ile jest niezbędne, zamiast skanować całe tabele lub wykonywać dziesiątki drobnych zapytań, które można zastąpić jednym dobrze zaprojektowanym SELECT-em.

      W dużym systemie liczy się też częstotliwość – zapytanie trwające 500 ms raz dziennie jest mniej groźne niż takie, które trwa 80 ms, ale odpala się tysiące razy na minutę przez popularny endpoint.

      Jak priorytetyzować, które zapytania SQL optymalizować w pierwszej kolejności?

      Najszybszy zwrot z inwestycji dają zapytania, które jednocześnie:

      • wykonują się długo lub zużywają dużo zasobów,
      • są odpalane bardzo często (główne endpointy, joby co minutę),
      • dotykają kluczowych funkcji biznesowych: sprzedaż, płatności, logowanie, główne panele operacyjne.

      Zacznij od wyciągnięcia logów wolnych zapytań, zgrupowania ich po signaturze i posortowania po „czas × liczba wywołań”. Top 10% najbardziej kosztownych zapytań zwykle daje 70–80% efektu, bez kopania się w losowych SELECT-ach z panelu admina, które prawie nikt nie uruchamia.

      Czy lepiej optymalizować pojedynczy SELECT, czy cały przepływ zapytań?

      W większości przypadków większy efekt daje optymalizacja całego przepływu niż wyciskanie ostatnich milisekund z jednego SELECT-a. Liczy się:

      • ile zapytań wykonuje pojedynczy request (HTTP, job crona, komenda),
      • czy masz problem N+1 (dziesiątki prawie identycznych SELECT-ów),
      • czy seria małych zapytań nie da się zastąpić jednym większym zapytaniem z JOIN/IN.

      Przykładowo: redukcja liczby zapytań z 80 do 8 dla popularnego endpointu często daje większy zysk niż optymalizacja jednego zapytania z 200 ms do 50 ms, które i tak odpalane jest rzadko.

      Kiedy optymalizować SQL, a kiedy po prostu dołożyć sprzętu?

      Dołożenie CPU i RAM-u jest szybkie, ale:

      • ma sufit – prędzej czy później i tak uderzysz w I/O lub blokady,
      • generuje stały, rosnący koszt abonamentu w chmurze lub mocniejszych maszyn,
      • skalujesz też błędy – nieoptymalne zapytania zostają nieoptymalne.

      Najbardziej opłacalny jest miks: najpierw 1–2 dni taniej optymalizacji (kilka indeksów, poprawa 3–5 najgorszych zapytań, usunięcie N+1 na kluczowych listach), a dopiero gdy to nie wystarczy – decyzja o mocniejszym serwerze.

      Jeśli system dusi się przy kluczowych operacjach mimo względnie mocnej maszyny, a w logach widać oczywiste „potworki” SQL, inwestowanie wyłącznie w sprzęt to zwykle przepalanie budżetu.

      Jak tanio namierzyć i przeanalizować wolne zapytania bez drogich APM-ów?

      Na start wystarczy zestaw „low-cost”:

      • EXPLAIN / EXPLAIN ANALYZE – do podglądu planu zapytania,
      • widoki systemowe: np. pg_stat_statements (PostgreSQL), performance_schema (MySQL),
      • prosty middleware w aplikacji, który loguje czas wykonania zapytań według signatury.

      Dane z bazy i aplikacji możesz wrzucić do tańszych lub otwartoźródłowych narzędzi: Elastic APM, Sentry Performance, Prometheus + Grafana. Kluczowe jest zebranie w jednym miejscu: signatura zapytania, średni czas, p95/p99, liczba wywołań i łączny czas – to w zupełności wystarcza, żeby wytypować priorytety bez drogiego enterprise APM.

      Jak rozpoznać, że system jest „duży” z perspektywy bazy danych?

      „Duży system” to nie tylko terabajty danych. Z punktu widzenia bazy liczy się:

      • ruch – liczba zapytań na sekundę, równoległe połączenia, nagłe piki,
      • ilość danych – wielkość tabel, liczba indeksów, rozmiar rekordów,
      • złożoność logiki – JOI N-y na wielu tabelach, ciężkie raporty i agregacje „na żywo”.

      Mała aplikacja z kilkoma milionami rekordów i kiepskimi zapytaniami (pełne skany tabel, brak indeksów, N+1) potrafi bardziej obciążyć bazę niż większy system z dziesiątkami milionów wierszy, ale prostymi, precyzyjnymi zapytaniami. Kluczowe jest więc realne obciążenie konkretnych fragmentów SQL, a nie „rozmiar na papierze.