Wydajność bazy danych rzadko psuje się z jednego powodu. Najczęściej winny jest zły plan wykonania, brak odpowiedniego indeksu, nieoptymalny warunek w `WHERE` albo zapytanie, które pobiera zbyt dużo danych na raz. Ten tekst pokazuje, jak podejść do optymalizacji zapytań SQL bez zgadywania: od diagnozy, przez indeksy i składnię, aż po sytuacje, w których problem leży już nie w samym SQL, lecz w aplikacji albo modelu danych.
Najpierw diagnoza, potem indeksy i dopiero później refaktoryzacja zapytania
- Najwolniejsze zapytanie nie zawsze potrzebuje nowego indeksu, czasem wystarczy poprawić warunek, kolejność joinów albo paginację.
- Plan wykonania pokazuje, czy silnik skanuje całą tabelę, czy naprawdę korzysta z indeksu.
- Największy efekt zwykle dają indeksy na kolumnach filtrowania, łączenia i sortowania, ale tylko wtedy, gdy są dobrane do realnego wzorca odczytu.
- Funkcje na kolumnach, `SELECT *`, `OFFSET` przy dużych stronach i niepotrzebne `OR` potrafią zabić wydajność nawet dobrze zaprojektowanej bazy.
- W aplikacjach Pythonowych bardzo często problemem jest nie jedno wolne zapytanie, lecz cały łańcuch małych zapytań, czyli klasyczny efekt N+1.
Najpierw sprawdź plan wykonania, a nie tylko czas odpowiedzi
Ja zawsze zaczynam od planu wykonania. Sam czas odpowiedzi mówi, że coś jest wolne, ale nie mówi dlaczego. Dopiero plan pokazuje, czy silnik idzie po indeksie, robi pełny skan tabeli, wykonuje kosztowne sortowanie, czy źle szacuje liczbę wierszy.
To ważne rozróżnienie, bo dwa zapytania mogą zwracać ten sam wynik, a jedno będzie działało w milisekundach, a drugie w sekundach. Różnica zwykle nie leży w składni jako takiej, tylko w tym, jak optimizer interpretuje warunki i ile danych musi faktycznie przeczytać.
Co w planie zwykle zdradza problem
W praktyce szukam kilku sygnałów. Sequential scan na dużej tabeli bywa ostrzeżeniem, ale nie zawsze błędem, bo czasem pełny odczyt naprawdę jest tańszy niż skakanie po indeksie. Bardziej podejrzane są sytuacje, w których plan pokazuje ogromną różnicę między liczbą wierszy estymowanych a rzeczywistych, bo to zwykle oznacza złe statystyki albo niepasujący indeks.
- duża rozbieżność między `estimated rows` i `actual rows`,
- kosztowne sortowanie po dużym zbiorze,
- nested loop na bardzo szerokim zbiorze danych,
- powtarzające się odczyty tych samych bloków,
- skan całej tabeli mimo selektywnego warunku w `WHERE`.
Jak czytać różnicę między estymacją a rzeczywistością
Jeśli optimizer zakłada, że warunek zwróci 50 wierszy, a w praktyce zwraca 500 tysięcy, wybór planu może być kompletnie nietrafiony. Wtedy problemem nie jest tylko wydajność jednego zapytania, lecz jakość danych statystycznych, na których opiera się decyzja silnika. W PostgreSQL, MySQL i SQL Server cel jest ten sam: silnik ma podjąć decyzję na podstawie aktualnego obrazu danych, a nie dawno nieodświeżonych założeń.
Właśnie dlatego plan wykonania warto oglądać przed każdą większą zmianą, a potem jeszcze raz po zmianie. To najprostszy sposób, żeby nie optymalizować „na wyczucie”. Następny krok to indeksy, ale tylko te, które pasują do realnego sposobu odczytu danych.

Indeksy, które naprawdę przyspieszają
Indeks nie jest magicznym przyspieszaczem wszystkiego. Dobrze zaprojektowany indeks skraca drogę do danych, ale jednocześnie zwiększa koszt zapisu, zajmuje miejsce i może zostać całkiem pominięty, jeśli zapytanie jest zbyt mało selektywne. Dlatego w praktyce lepiej mieć kilka trafnych indeksów niż wiele przypadkowych.
Od czego zacząć dobór indeksu
Najpierw patrzę na kolumny używane w `WHERE`, `JOIN`, `ORDER BY` i czasem w `GROUP BY`. Jeśli zapytanie filtruje po jednej kolumnie o wysokiej selektywności, indeks B-tree zwykle jest naturalnym punktem startu. Jeśli łączysz kilka warunków, ważna staje się kolejność kolumn w indeksie.
Prosta reguła praktyczna jest taka: najpierw kolumny równościowe, potem zakresowe, potem sortowanie. To nie jest dogmat, bo każdy silnik ma własne szczegóły, ale jako punkt startowy działa zaskakująco dobrze.
Najczęstsze typy indeksów i kiedy mają sens
| Typ indeksu | Kiedy pomaga | Ograniczenie |
|---|---|---|
| B-tree | Równość, zakres, sortowanie, większość klasycznych zapytań OLTP | Nie zawsze opłaca się przy bardzo szerokich odczytach |
| Indeks złożony | Filtrowanie po kilku kolumnach i wspólne `ORDER BY` | Kolejność kolumn ma krytyczne znaczenie |
| Covering index | Gdy zapytanie może zostać obsłużone bez sięgania do tabeli | Rośnie rozmiar indeksu i koszt zapisu |
| Indeks filtrowany / częściowy | Gdy interesuje Cię tylko podzbiór rekordów, np. aktywne zamówienia | Silnik i składnia zależą od systemu bazodanowego |
| Columnstore | Raporty, agregacje, analityka na dużych zbiorach | Słabszy wybór dla częstych, małych aktualizacji |
Dlaczego covering index bywa tak skuteczny
Covering index, czyli indeks pokrywający, pozwala odpowiedzieć na zapytanie bez dodatkowego sięgania do tabeli. To ważne, bo w klasycznym indeksie silnik często musi odczytać najpierw sam indeks, a potem jeszcze właściwy wiersz. Jeśli zapytanie potrzebuje tylko kilku kolumn, dobrze dobrany indeks może ograniczyć liczbę losowych odczytów i wyraźnie skrócić czas odpowiedzi.
Jest tu jednak haczyk: taki indeks ma sens tylko wtedy, gdy naprawdę obsługuje często wykonywane zapytanie. Jeśli nie jest używany, zostaje wyłącznie kosztem dla zapisów. Ja traktuję go jak narzędzie precyzyjne, nie jak domyślny wybór do wszystkiego. Z indeksów płynnie przechodzimy do samego zapytania, bo nawet najlepszy indeks nie pomoże, jeśli składnia blokuje jego użycie.
Jak pisać zapytania, które silnik może zoptymalizować
To tutaj najczęściej widać szybki zysk. Czasem jedna drobna zmiana w warunku sprawia, że optimizer może użyć indeksu, a czasem trzeba przebudować cały fragment logiki. Najlepiej myśleć nie o „ładnym SQL-u”, tylko o SQL-u, który jest dla silnika prosty do zinterpretowania.
Nie chowaj kolumny w funkcji
Jeżeli w warunku filtrujesz po kolumnie opakowanej w funkcję, silnik często nie może użyć zwykłego indeksu. Przykład jest prosty:
-- Wolniej i trudniej o użycie indeksu
SELECT id, created_at
FROM orders
WHERE DATE(created_at) = '2026-06-09';
-- Lepiej: zakres na surowej kolumnie
SELECT id, created_at
FROM orders
WHERE created_at >= '2026-06-09'
AND created_at < '2026-06-10';Ta sama zasada dotyczy zbyt agresywnego rzutowania typów, obliczania `LOWER(column)` w warunku czy porównywania tekstu z różnymi typami pośrednimi. Jeśli kolumna ma być filtrowana często, zostaw ją możliwie „czystą” w predykacie.
Unikaj `SELECT *`, jeśli pobierasz tylko część danych
`SELECT *` nie jest z definicji zły, ale bardzo często jest niepotrzebny. Pobiera więcej kolumn niż trzeba, zwiększa transfer, utrudnia zastosowanie indeksu pokrywającego i potrafi maskować fakt, że aplikacja tak naprawdę używa tylko dwóch lub trzech pól. W projektach, które rosną przez lata, to właśnie takie drobiazgi kumulują się w realny problem wydajnościowy.
Paginuj po kluczu, nie po dużym `OFFSET`
Offset-based pagination wygląda wygodnie, ale przy dużych stronach robi się coraz droższa. Silnik musi „przejść” przez wszystkie pominięte rekordy, zanim odda bieżącą stronę. Przy pierwszych kilku stronach to nie boli, przy setnej albo tysięcznej już tak.
-- Coraz wolniejsze przy dużych wartościach OFFSET
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
-- Zwykle lepsze przy dużych zbiorach
SELECT id, title, created_at
FROM posts
WHERE created_at < '2026-06-01 12:00:00'
ORDER BY created_at DESC
LIMIT 20;To rozwiązanie bywa nazywane keyset pagination. Dla aplikacji produktowych jest zazwyczaj stabilniejsze i szybsze, zwłaszcza tam, gdzie użytkownik przewija listę w jedną stronę. Po stronie łączeń i agregacji problem bywa podobny, tylko objawia się w większej skali.
Joiny, agregacje i paginacja bez niepotrzebnego kosztu
Najdroższe zapytania to często nie pojedynczy filtr, lecz kombinacja joinów, grupowania i sortowania na dużym zbiorze. Wtedy każda zbędna kolumna i każdy zbędny rekord zaczynają kosztować wielokrotnie więcej. Ja zwykle staram się najpierw zmniejszyć zbiór danych, a dopiero potem łączyć i agregować.
Łącz tabele po kluczach, które da się szybko przeszukać
Jeśli join opiera się na kolumnach bez indeksu, baza może wykonać bardzo kosztowne operacje pośrednie. Przy klasycznych relacjach 1:N zwykle warto zadbać, żeby kolumny łączenia były indeksowane po obu stronach tam, gdzie to ma sens. Dzięki temu optimizer ma większą swobodę wyboru między nested loop, hash join i merge join.
Przefiltruj wcześniej, agreguj później
To prosta zasada, ale często ignorowana. Jeśli raport ma pokazać sprzedaż z ostatnich 30 dni, nie ma sensu najpierw agregować całej historii, a dopiero potem filtrować wynik. Lepiej zawęzić zbiór jak najwcześniej, nawet jeśli wymaga to podzielenia zapytania na czytelniejsze kroki.
- najpierw ogranicz dane w `WHERE`,
- potem dołącz tylko potrzebne tabele,
- na końcu wykonaj `GROUP BY` i `ORDER BY`,
- jeśli raport jest powtarzalny, rozważ widok materializowany albo preagregację,
- jeśli zestaw danych jest bardzo duży, sprawdź, czy nie lepiej liczyć wyniki okresowo zamiast przy każdym odczycie.
Uważaj na zapytania, które mnożą wiersze
Klasyczny błąd to join 1:N, a potem agregacja na poziomie, którego nie planowano. Nagle jeden rekord zamówienia ma po 12 wierszy pośrednich, bo dołączono pozycje, tagi i historię statusów, a potem ktoś dopiero na końcu próbuje to „posprzątać” `DISTINCT`. To kosztowny wzorzec, który często da się uprościć przez wcześniejsze zawężenie danych albo użycie `EXISTS` zamiast pełnego joinu, jeśli interesuje Cię tylko samo istnienie rekordu.
W tym miejscu widać, że optymalizacja to nie tylko indeksy. Równie ważne są statystyki i aktualność wiedzy, którą ma optimizer o danych, bo bez tego nawet dobrze napisane zapytanie może dostać zły plan.

Plan wykonania i statystyki decydują o jakości planu
Silnik nie zgaduje w ciemno. Opiera decyzje na statystykach, które mówią mu, jak rozkładają się dane w tabelach, ile jest wartości unikalnych i jak selektywne są predykaty. Jeśli te statystyki są nieaktualne, optimizer może wybrać zły indeks, zły typ joinu albo zupełnie niepotrzebny skan tabeli.
Co warto odświeżać i kiedy
W praktyce aktualizuję statystyki po dużych zmianach danych, po masowych importach i po przebudowie krytycznych indeksów. W PostgreSQL robi to często automatyka, ale przy zmianach o dużej skali ręczny `ANALYZE` nadal bywa dobrym ruchem. W MySQL sens ma `ANALYZE TABLE`, a w SQL Server trzeba pilnować statystyk i ewentualnie użyć `UPDATE STATISTICS`.
| Silnik | Co sprawdzam | Co zwykle odświeżam |
|---|---|---|
| PostgreSQL | `EXPLAIN (ANALYZE, BUFFERS)`, różnice między estymacją a realnym przebiegiem | `ANALYZE`, automatyczny autovacuum i statystyki kolumn |
| MySQL 8.4 | `EXPLAIN ANALYZE`, drzewo planu i realny czas iteratorów | `ANALYZE TABLE`, ewentualnie histogramy dla trudniejszych rozkładów |
| SQL Server | Actual execution plan, ponowne użycie planów, obciążenie operatorów | `UPDATE STATISTICS` i automatyczne statystyki tam, gdzie wystarczają |
Dlaczego statystyki tak mocno wpływają na wydajność
Jeśli tabela ma milion wierszy, ale tylko 200 z nich spełnia warunek, optimizer powinien to wiedzieć. Jeśli nie wie, może wybrać plan, który zakłada zupełnie inną selektywność, a potem trzeba płacić za odczyt tysięcy niepotrzebnych rekordów. W dokumentacji PostgreSQL wprost widać, że `ANALYZE` zbiera statystyki po to, by planner mógł wybrać efektywniejszy plan. To właśnie ten mechanizm decyduje, czy baza „myśli” dobrze o Twoim zapytaniu.
Gdy plan i statystyki są w porządku, a zapytanie nadal jest wolne, czas spojrzeć szerzej. Często problem nie siedzi już w samym SQL, tylko w warstwie aplikacyjnej albo w modelu pracy całego systemu.
Kiedy problem nie leży w samym SQL
W aplikacjach Pythonowych widzę to wyjątkowo często: pojedyncze zapytanie nie jest dramatyczne, ale warstwa ORM generuje ich kilkadziesiąt albo kilkaset. Wtedy żadna pojedyncza optymalizacja nie da takiego efektu jak ograniczenie liczby round-tripów do bazy. To klasyczny efekt N+1, szczególnie przy relacjach ładowanych leniwie.
Sprawdź, czy nie płacisz za N+1
Jeśli pobierasz listę 100 rekordów i dla każdego z nich dociągasz dodatkowe dane osobnym zapytaniem, problemem nie jest już SQL jako taki, tylko sposób użycia ORM-u. W takim scenariuszu lepiej użyć eager loading, `JOIN` tam, gdzie to bezpieczne, albo pobrać dane w jednej, dobrze ułożonej operacji. W praktyce to bywa większy zysk niż dłubanie przy jednym indeksie.
Zwróć uwagę na blokady, I/O i sieć
- zbyt długie transakcje potrafią blokować odczyt lub zapis,
- wolny dysk i brak pamięci podręcznej zmieniają koszt tego samego planu,
- aplikacja działająca przez sieć między regionami zwiększa latency,
- cache zapytań albo warstwa aplikacyjna mogą dać większy efekt niż dalsze strojenie pojedynczego selecta.
Przeczytaj również: GROUP BY w SQL - Jak grupować dane i unikać błędów?
Nie każda optymalizacja musi być w samym bazodanowym planie
Jeśli dashboard jest odświeżany co 30 sekund, preagregacja albo materializowany widok często ma więcej sensu niż ciągłe liczenie wszystkiego od nowa. Jeśli tabela rośnie bardzo szybko, czasem trzeba dołożyć partycjonowanie. A jeśli ten sam zestaw wyników jest pobierany dziesiątki razy na minutę, cache po stronie aplikacji może być zwyczajnie tańszy niż każde kolejne odpytanie bazy.
Najważniejsze jest to, żeby nie leczyć objawów na ślepo. Dlatego domykam temat krótką listą decyzji, które realnie porządkują pracę nad wydajnością i pomagają nie wracać do tego samego problemu po kilku tygodniach.
Najkrótsza droga do trwałej poprawy wydajności zapytań
Jeżeli miałbym sprowadzić cały proces do kilku decyzji, wyglądałoby to tak: najpierw mierzę, potem czytam plan, następnie poprawiam zapytanie, dopiero później dokładam lub koryguję indeksy. To kolejność, która oszczędza najwięcej czasu, bo nie każe zgadywać, co naprawdę boli.
- Najpierw uruchom `EXPLAIN` albo plan rzeczywisty i zobacz, gdzie uciekają zasoby.
- Sprawdź, czy warunek w `WHERE` jest sargowalny i czy nie blokujesz indeksu funkcją lub rzutowaniem.
- Zweryfikuj kolejność kolumn w indeksach złożonych i usuń te, które nie są używane.
- Przy dużych zbiorach zamień `OFFSET` na paginację po kluczu.
- Jeśli optymalizator myli się w szacunkach, odśwież statystyki i porównaj plan jeszcze raz.
W praktyce najlepsze efekty daje nie jedna „sztuczka”, tylko konsekwentne łączenie diagnozy, dobrego indeksu, prostszego zapytania i zdrowego podejścia do modelu odczytu. Gdy te elementy zaczynają ze sobą współpracować, baza przestaje być wąskim gardłem, a cały system działa przewidywalnie.
