W tabeli z milionem wierszy to samo zapytanie SELECT może trwać pięćdziesiąt milisekund albo mniej niż jedną. Jedyna różnica: baza danych albo czyta każdy wiersz, żeby znaleźć te, których szukasz, albo skacze od razu do nich. Właśnie to umożliwia indeks — i płacisz za niego przy każdym zapisie.
Sam indeks to druga, posortowana kopia jednej lub więcej kolumn, przechowywana obok tabeli i aktualizowana automatycznie przy każdej zmianie tabeli. Baza danych używa jej, żeby dotrzeć do pasujących wierszy bez dotykania reszty.
Dlaczego zapytanie bez indeksu czyta całą tabelę
Bez indeksu WHERE customer_id = 42 zmusza Postgresa do przeczytania każdego wiersza i sprawdzenia warunku na każdym z nich. To jest skanowanie sekwencyjne (Seq Scan). Przy tysiącu wierszy nawet tego nie zauważysz. Przy milionie wierszy, gdzie prawie nic nie pasuje, czytasz i odrzucasz 999 989 wierszy, żeby dostać 11.
Możesz to sprawdzić sam. Uruchom kontener Postgresa, skonfigurowany tak jak w Co To Jest Docker Compose i Jak Go Używać:
| |
| |
Potem zbuduj tabelę na tyle dużą, żeby było widać różnicę:
| |
Milion wierszy, brak indeksu na customer_id. Poproś o zamówienia jednego klienta. Poniższe przykłady najpierw wyłączają workery równoległe (SET max_parallel_workers_per_gather = 0), żeby plan pozostał w jednym węźle — na maszynie wielordzeniowej Postgres mógłby inaczej podzielić to samo skanowanie między Gather a kilka workerów Parallel Seq Scan, co jest tym samym pomysłem, tylko z większą liczbą procesów działających naraz:
| |
| |
Rows Removed by Filter: 999989 to jest rachunek do zapłacenia. Postgres przeczytał całą tabelę, żeby zwrócić 11 wierszy.
Co naprawdę przechowuje indeks B-tree
Tabela bez indeksu to pudełko z paragonami, wrzucanymi jeden na drugi w kolejności, w jakiej przyszły. Znalezienie wszystkich paragonów jednego klienta oznacza opróżnienie pudełka.
Indeks B-tree to te same paragony, ułożone w szafce, posortowane według klienta. Najniższy poziom, leaf pages, przechowuje każdą zindeksowaną wartość razem ze wskaźnikiem do jej wiersza na dysku. Nad liśćmi znajduje się kilka stron wewnętrznych, każda z najniższą wartością zakresu i wskaźnikiem do niego. Strony Postgresa mają domyślnie 8 KB, a jedna strona wewnętrzna może wskazywać na setki dzieci, więc trzy lub cztery poziomy pokrywają dziesiątki milionów wierszy. Wyszukiwanie czyta jedną stronę na poziom i trafia na właściwy liść. Kilka odczytów strony zamiast przejścia przez całą tabelę.
Dodaj indeks i uruchom to samo zapytanie:
| |
| |
| |
Czas spada z 53 ms do 0,45 ms, około 120 razy szybciej, a szacowany koszt z 20834 do 86. Seq Scan zniknął, i nie ma już linii Rows Removed by Filter: nie było nic do odrzucenia.
Postgres wybrał Bitmap Heap Scan zamiast zwykłego Index Scan. Przy około 20 pasujących wierszach rozrzuconych po tabeli taniej jest najpierw wyciągnąć wszystkie pasujące wpisy z indeksu (linia Bitmap Index Scan), posortować je według strony fizycznej, a potem odwiedzić każdą stronę heapu tylko raz (to pokazuje Heap Blocks: exact=11), niż skakać do heapu osobno dla każdego wiersza. Odfiltruj do jednego wiersza (porównanie równości na unikalnej kolumnie, na przykład id = 42) i zamiast tego zobaczysz zwykły Index Scan. W obu przypadkach indeks robi to samo: dociera do pasujących wierszy bez czytania tych, które nie pasują.
Jak działa kolejność kolumn w indeksie złożonym
Indeks może obejmować więcej niż jedną kolumnę, a kolejność, w jakiej je wymienisz, decyduje, jakie zapytania może obsłużyć. CREATE INDEX ON orders (customer_id, status) sortuje najpierw według customer_id, potem według status wewnątrz każdego customer_id. Może więc odpowiedzieć na:
WHERE customer_id = 42, używając pierwszej kolumny.WHERE customer_id = 42 AND status = 'paid', używając obu.
Nie może wydajnie odpowiedzieć samo na WHERE status = 'paid'. status jest posortowany tylko wewnątrz grupy każdego klienta, więc nie istnieje jeden posortowany ciąg wierszy 'paid', do którego drzewo mogłoby skoczyć. To jest reguła najbardziej lewego prefiksu (leftmost prefix rule): indeks złożony pomaga zapytaniu tylko wtedy, gdy ogranicza ono swoje kolumny od lewej, po kolei, bez przerwy.
Dwie zasady wyboru kolejności. Po pierwsze, umieść z przodu kolumnę, po której twoje zapytania filtrują samodzielnie, bo to jedyna, którą indeks obsłuży sam. Po drugie, umieść kolumny porównywane przez = przed kolumną porównywaną przez zakres (>, <, BETWEEN), bo wszystko po kolumnie zakresowej przestaje zawężać wyszukiwanie. Dla tej tabeli customer_id (około 50 000 różnych wartości) idzie przed status (cztery wartości): szukasz klientów cały czas, i nigdy nie chcesz przeglądać wszystkich zamówień 'paid' bez rozróżniania klienta.
Jeśli potrzebujesz też szybkiego wyszukiwania tylko po status, zbuduj drugi indeks. Nie zmieniaj kolejności pierwszego.
Kiedy index-only scan pomija tabelę
Powyższy Bitmap Heap Scan nadal dotyka tabeli — to właśnie robi Heap Blocks: exact=11. Indeks zawiera customer_id i wskaźnik do każdego wiersza; SELECT * chce też status, total_cents i created_at, a te istnieją tylko w tabeli. Klauzula INCLUDE wkłada dodatkowe kolumny do samego indeksu:
| |
status i total_cents jadą jako payload. Nie są częścią klucza sortowania, więc WHERE status = ... nie może ich użyć do wyszukiwania. Ale poproś tylko o kolumny, które indeks teraz ze sobą niesie:
| |
| |
Index Only Scan i Heap Fetches: 0: każdy wiersz przychodzi prosto z indeksu, bez żadnej wizyty w tabeli. To jest index-only scan.
Heap Fetches: 0 zależy od visibility map tabeli, która zaznacza strony, których wiersze są widoczne dla każdej transakcji — skanowanie pomija heap tylko dla oznaczonych stron. VACUUM ustawia te oznaczenia (zwykle autovacuum, który działa według własnego harmonogramu, nie czegoś, co uruchamiasz ręcznie), a każdy zapis na stronie znowu je czyści. W tabeli z dużą liczbą niedawnych zapisów wiele stron nie jest oznaczonych, Heap Fetches rośnie powyżej zera, a Postgres i tak odwiedza heap dla tych wierszy. Indeksy pokrywające zwracają się najbardziej w tabelach czytanych dużo częściej niż zapisywanych.
Ile kosztuje indeks i dlaczego Postgres go ignoruje
Każdy INSERT zapisuje wpis w każdym indeksie tabeli, a UPDATE, który zmienia zindeksowaną kolumnę, też zapisuje nowe wpisy. Pięć indeksów oznacza pięć dodatkowych struktur do utrzymania przy każdym z tych zapisów. Indeksy zajmują też miejsce na dysku. Porównaj oba rozmiary bezpośrednio:
| |
A czasem budujesz indeks, a planner go nie używa. Rzadko jest to bug Postgresa. Zwykle chodzi o jedno z tych:
- Funkcja opakowuje kolumnę.
WHERE lower(email) = '[email protected]'nie może użyć zwykłego indeksu naemail, bo indeks przechowuje surowe wartości, nielower(email). Zindeksuj wyrażenie (CREATE INDEX ON users (lower(email))) albo filtruj po surowej kolumnie. - Wiodący wildcard.
WHERE name LIKE '%smith'nie daje drzewu żadnego prefiksu do wyszukania.LIKE 'smith%'daje. - Niska selektywność. Indeks na wartości logicznej podzielonej 50/50 nic nie oszczędza. Postgres i tak przeczytałby połowę indeksu i większość stron tabeli, więc planner słusznie wybiera skanowanie sekwencyjne.
- Nieaktualne statystyki. Planner wybiera między typami skanowania na podstawie statystyk, które
ANALYZEzbiera wpg_statistic. Po masowym ładowaniu bezANALYZEjego szacunki liczby wierszy mogą być mocno chybione. Uruchamiaj go po każdym dużym ładowaniu, tak jak w konfiguracji powyżej.
Kiedy widzisz Seq Scan tam, gdzie spodziewałeś się Index Scan, sprawdź zapytanie względem tej listy, zanim tkniesz indeks.
Kiedy dodać indeks, a kiedy sobie odpuścić
Dodaj go, gdy kolumna pojawia się w WHERE, JOIN albo ORDER BY na tabeli na tyle dużej, że skanowanie kosztuje realny czas, a filtr jest selektywny: zawęża wynik do małego wycinka tabeli, nie do połowy. Odpuść sobie, gdy tabela jest na tyle mała, że skanowanie jest już szybkie, gdy tabela ma dużo zapisów i mało odczytów, albo gdy kolumna niczego nie zawęża.
Nie dodawaj go na wyczucie. Uruchom najpierw EXPLAIN ANALYZE na wolnym zapytaniu. Jeśli już pokazuje Index Scan i nadal jest wolne, indeks nie jest twoim problemem: przepisz zapytanie albo zcachuj wynik w Redis. Jeśli pokazuje Seq Scan na dużej tabeli z selektywnym filtrem, zbuduj indeks dokładnie na kolumnach, których używa WHERE, w kolejności wymaganej przez regułę najbardziej lewego prefiksu, a potem uruchom EXPLAIN ANALYZE jeszcze raz, żeby potwierdzić, że plan się zmienił.