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:
- Skalowanie sprzętowe ma sufit – wcześniej czy później pojawi się wąskie gardło I/O lub blokad.
- Koszt stały – mocniejsza maszyna lub wyższy tier w chmurze to abonament, który płacisz co miesiąc.
- 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:
- Włącz logi slow queries (np. powyżej 500 ms).
- Zgrupuj je po signaturze zapytania (bez konkretnych parametrów).
- Posortuj po: czas wykonania × liczba wywołań.
- 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:
- Czas trwania – średni, percentyle (p95, p99). Interesują: długie ogony, nie średnia.
- Liczba odczytanych wierszy – czy zapytanie faktycznie potrzebuje miliona wierszy do odpowiedzi?
- 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:
- Użytkownicy zgłaszają: lista zamówień w panelu działa bardzo wolno.
- APM pokazuje: endpoint GET /orders ma p95 = 3,5 s.
- Profilowanie requestu ujawnia: wykonuje się 80 zapytań do bazy.
- 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.
- 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.

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ć
ANALYZElub 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.
INz 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.

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
orderskorzystniejsze 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
emailw 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
endNa 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_bymapowane 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
endDużo bardziej przyjazne dla SQL-a i dla portfela (mniej czasu CPU, mniej blokad) są:
- wstawki batchowe (
INSERT ... VALUES (...), (...), ...), - średni czas i percentyle (p95, p99),
- liczbę wywołań na minutę/godzinę,
- łączny czas CPU / I/O dla danej signatury.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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”.
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:
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:
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:
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ę:
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:
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”:
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ę:
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.






