Indeksy PostgreSQL dla aplikacji SaaS: btree vs GIN vs GiST
Indeksy PostgreSQL dla aplikacji SaaS: wybierz między btree, GIN i GiST, używając rzeczywistych wzorców zapytań jak filtry, wyszukiwanie, JSONB i tablice.

Jakie problemy rozwiązują indeksy na rzeczywistych ekranach SaaS
Indeks zmienia sposób, w jaki PostgreSQL znajduje wiersze. Bez indeksu baza danych często musi odczytać dużą część tabeli (sekwencyjne skanowanie) i potem odrzucić większość danych. Z odpowiednim indeksem może od razu przeskoczyć do pasujących wierszy (wyszukiwanie po indeksie), a następnie pobrać tylko to, co potrzebne.
Zauważysz to szybko w SaaS, ponieważ codzienne ekrany są ciężkie od zapytań. Jedno kliknięcie może wywołać kilka odczytów: stronę listy, łączną liczbę, kilka kafelków dashboardu i pole wyszukiwania. Gdy tabela rośnie od tysięcy do milionów wierszy, to samo zapytanie, które kiedyś było błyskawiczne, zaczyna zwalniać.
Typowy przykład to strona Zamówień filtrowana po statusie i dacie, sortowana od najnowszych, z paginacją. Jeśli PostgreSQL musi przeskanować całą tabelę zamówień, aby znaleźć zapłacone zamówienia z ostatnich 30 dni, każde ładowanie strony wykonuje dużo dodatkowej pracy. Dobry indeks zamienia to w szybkie przeskoczenie do właściwego wycinka danych.
Indeksy nie są darmowe. Każdy przyspiesza odczyty dla konkretnych zapytań, ale też spowalnia zapisy (INSERT/UPDATE/DELETE muszą aktualizować indeksy) i zajmuje dodatkowo miejsce na dysku (oraz zwiększa presję na cache). Ten kompromis jest powodem, dla którego powinieneś zaczynać od rzeczywistych wzorców zapytań, a nie od typów indeksów.
Prosta zasada, która zapobiega nadmiernej pracy: dodawaj indeks tylko wtedy, gdy możesz wskazać konkretne, częste zapytanie, które on przyspieszy. Jeśli budujesz ekrany przy pomocy kreatora sterowanego czatem takiego jak Koder.ai, warto zapisać SQL za stronami list i dashboardami i użyć tego jako listy życzeń indeksów.
B-tree vs GIN vs GiST prostymi słowami
Większość zamieszania z indeksami znika, gdy przestaniesz myśleć o funkcjach (JSON, wyszukiwanie, tablice) i zaczniesz myśleć o kształcie zapytania: co robi klauzula WHERE i jak oczekujesz sortowania wyników?
B-tree: szybki do sortowania i porównań
Używaj B-tree, gdy twoje zapytanie przypomina normalne porównania i zależy ci na porządku. To podstawowy typ dla równości, zakresów i złączeń.
Przykładowe kształty: filtrowanie po tenant_id = ?, status = 'active', created_at >= ?, złączenie users.id = orders.user_id, albo pokazanie „najpierw najnowsze” przez ORDER BY created_at DESC.
GIN i GiST: kiedy w wierszu jest wiele wartości do przeszukania
GIN (Generalized Inverted Index) pasuje, gdy jedna kolumna zawiera wiele elementów i pytasz „czy zawiera X?” — to typowe dla kluczy JSONB, elementów tablic i wektorów pełnotekstowych.
Przykładowe kształty: metadata @> {'plan':'pro'} na JSONB, tags @> ARRAY['urgent'], lub to_tsvector(body) @@ plainto_tsquery('reset password').
GiST (Generalized Search Tree) sprawdza się przy pytaniach o odległość lub nakładanie się, gdzie wartości zachowują się jak zakresy lub kształty. Często używa się go dla typów zakresowych, danych geometrycznych i niektórych „dopasowań najbliższych”.
Przykładowe kształty: nakładające się okna czasowe z kolumnami zakresowymi, wyszukiwania stylu podobieństwa (np. z operatorami trigramów), lub zapytania przestrzenne (jeśli używasz PostGIS).
Praktyczny sposób wyboru:
- Jeśli filtrujesz lub sortujesz po zwykłych kolumnach — zacznij od B-tree.
- Jeśli sprawdzasz zawieranie lub przynależność — spójrz na GIN.
- Jeśli pytasz „jak blisko” lub „czy się nakłada” — GiST często będzie właściwy.
- Jeśli zapytanie jest rzadkie albo tabela jest malutka — może nie potrzebujesz nowego indeksu.
- Jeśli nie potrafisz opisać kształtu zapytania, najpierw zmierz (EXPLAIN) zanim cokolwiek dodasz.
Indeksy przyspieszają odczyty, ale kosztują czas zapisu i miejsce na dysku. W SaaS ten kompromis ma największe znaczenie przy gorących tabelach takich jak events, sessions czy activity logs.
Wzorce B-tree dla filtrów, sortowania i paginacji
Większość ekranów list w SaaS ma podobny kształt: granica tenant-a, kilka filtrów i przewidywalne sortowanie. Indeksy B-tree są domyślnym wyborem tutaj i zwykle najtańszym w utrzymaniu.
Częsty wzorzec to WHERE tenant_id = ? plus filtry jak status = ?, user_id = ? i zakres czasu typu created_at >= ?. Dla złożonych indeksów B-tree umieść filtry równościowe najpierw (kolumny dopasowywane przez =), a potem dodaj kolumnę, po której sortujesz.
Zasady, które dobrze działają w większości aplikacji:
- Zaczynaj od
tenant_id, jeśli każde zapytanie jest ograniczone do tenant-a. - Wstaw filtry
=następne (częstostatus,user_id). - Umieść kolumnę
ORDER BYna końcu (częstocreated_atlubid). - Używaj
INCLUDE, aby pokryć stronę list bez poszerzania klucza indeksu. - Preferuj paginację przez seek (keyset) zamiast offset, gdy strony robią się głębokie.
Realistyczny przykład: strona Zgłoszeń pokazująca najnowsze elementy najpierw, filtrowana po statusie.
-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC
LIMIT 50;
-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);
Ten indeks obsługuje zarówno filtr, jak i sortowanie, więc Postgres może uniknąć sortowania dużego zestawu wyników. Część INCLUDE (title) pomaga stronie listy dotykać mniej stron tabeli, zachowując klucz indeksu skoncentrowany na filtrowaniu i porządkowaniu.
Dla zakresów czasowych ta sama zasada:
SELECT id, created_at
FROM events
WHERE tenant_id = $1
AND created_at >= $2
AND created_at < $3
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);
Paginacja to miejsce, gdzie wiele aplikacji SaaS zwalnia. Paginacja przez OFFSET (OFFSET 50000) zmusza bazę do przejścia przez wiele wierszy. Paginacja przez seek pozostaje szybka, używając ostatniego widzianego klucza sortowania:
SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
AND created_at < $2
ORDER BY created_at DESC
LIMIT 50;
Z odpowiednim indeksem B-tree to pozostaje szybkie nawet przy rosnącej tabeli.
Indeksowanie przyjazne tenant-om bez nadmiernej liczby indeksów
Większość aplikacji SaaS jest multi-tenant: każde zapytanie musi pozostać w granicach jednego tenant-a. Jeśli twoje indeksy nie zawierają tenant_id, Postgres nadal może szybko znaleźć wiersze, ale często przeszuka znacznie więcej wpisów indeksu niż potrzeba. Indeksy uwzględniające tenant-a utrzymują dane każdego tenant-a pogrupowane w indeksie, dzięki czemu typowe ekrany pozostają szybkie i przewidywalne.
Prosta zasada: umieść tenant_id pierwszy w indeksie, gdy zapytanie zawsze filtruje po tenant.
Wysoko-wpływowe, nudne indeksy często wyglądają tak:
(tenant_id, created_at)dla list najnowszych elementów i paginacji kursorem(tenant_id, status)dla filtrów statusu (Open, Paid, Failed)(tenant_id, user_id)dla ekranów „elementy należące do użytkownika”(tenant_id, updated_at)dla widoków administracyjnych „ostatnio zmienione”(tenant_id, external_id)dla wyszukań z webhooków lub importów
Nadindeksowanie zdarza się, gdy dodajesz nowy indeks dla każdego nieznacznie innego ekranu. Zanim utworzysz kolejny, sprawdź, czy istniejący indeks złożony już nie pokrywa najbardziej lewostronnych kolumn, których potrzebujesz. Na przykład, jeśli masz (tenant_id, created_at), zwykle nie potrzebujesz też (tenant_id, created_at, id), chyba że naprawdę filtrujesz po id po tych kolumnach.
Indeksy częściowe mogą zmniejszyć rozmiar i koszt zapisu, gdy większość wierszy nie jest istotna. Dobrze działają przy soft-delete i „tylko aktywne” danych, np. indeksuj tylko tam, gdzie deleted_at IS NULL, lub tylko tam, gdzie status = 'active'.
Każdy dodatkowy indeks obciąża zapisy. Insert musi aktualizować każdy indeks, a aktualizacje mogą dotykać wielu indeksów nawet gdy zmieniasz jedną kolumnę. Jeśli twoja aplikacja przyjmuje dużo zdarzeń (w tym aplikacje szybko budowane z Koder.ai), trzymaj indeksy skupione na kilku wzorcach zapytań, których użytkownicy używają codziennie.
Indeksowanie JSONB: GIN i precyzyjne indeksy wyrażeń
JSONB jest przydatny, gdy twoja aplikacja potrzebuje elastycznych pól dodatkowych, jak feature flagi, atrybuty użytkownika czy ustawienia per-tenant. Wadą jest to, że różne operatory JSONB zachowują się inaczej, więc najlepszy indeks zależy od tego, jak zapytujesz.
Dwa kształty mają największe znaczenie:
- Zawieranie: „czy ten JSON zawiera te pary klucz-wartość?” używając
@>. - Ekstrakcja ścieżki: „jaka jest wartość tego konkretnego pola?” używając
->/->>(często porównywane=).
Kiedy indeks GIN jest właściwy
Jeśli często filtrujesz z @>, indeks GIN na kolumnie JSONB zwykle się opłaca.
-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata @> '{"region":"eu","plan":"pro"}';
-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);
Jeśli struktura JSON jest przewidywalna i głównie używasz @> na kluczach najwyższego poziomu, jsonb_path_ops może być mniejsze i szybsze, ale wspiera mniej typów operatorów.
Kiedy indeks wyrażeń jest lepszy
Jeśli UI wielokrotnie filtruje po jednym polu (np. plan), wydobycie tego pola i zindeksowanie go często jest szybsze i tańsze niż szeroki GIN.
SELECT id
FROM accounts
WHERE tenant_id = $1
AND metadata->> 'plan' = 'pro';
CREATE INDEX accounts_plan_expr
ON accounts ((metadata->> 'plan'));
Praktyczna zasada: trzymaj JSONB dla elastycznych, rzadko filtrowanych atrybutów, ale promuj stabilne, często używane pola (plan, status, created_at) do prawdziwych kolumn. Jeśli szybko iterujesz nad generowaną aplikacją, często to łatwa poprawka schematu, gdy zobaczysz, które filtry pojawiają się na każdej stronie.
Przykład: jeśli przechowujesz {"tags":["beta","finance"],"region":"us"} w JSONB, użyj GIN, gdy filtrujesz po zestawach atrybutów (@>), i dodaj indeksy wyrażeń dla kilku kluczy, które napędzają większość widoków list (plan, region).
Indeksowanie tablic: kiedy GIN błyszczy
Tablice kuszą, bo łatwo je przechowywać i czytać. Kolumna users.roles text[] lub projects.labels text[] dobrze działa, gdy w większości pytasz jedno: czy wiersz zawiera wartość (lub zestaw wartości)? To dokładnie miejsce, gdzie pomaga GIN.
GIN jest domyślnym wyborem dla zapytań członkostwa na tablicach. Rozbija tablicę na indywidualne elementy i buduje szybkie wyszukiwanie wierszy, które je zawierają.
Kształty zapytań na tablicach, które często korzystają z indeksu:
- Zawiera wartość lub zestaw:
@>(tablica zawiera) - Nakładanie się z zestawem:
&&(tablica dzieli dowolne elementy) - Czasem:
= ANY(...), ale@>jest często bardziej przewidywalne
Typowy przykład filtrowania użytkowników po roli:
-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @> ARRAY['admin'];
CREATE INDEX users_roles_gin ON users USING GIN (roles);
I filtrowanie projektów po zestawie etykiet (musi zawierać obie etykiety):
SELECT id, name
FROM projects
WHERE labels @> ARRAY['billing', 'urgent'];
CREATE INDEX projects_labels_gin ON projects USING GIN (labels);
Gdzie ludzie się dziwią: niektóre wzorce nie używają indeksu tak, jak oczekujesz. Jeśli zamienisz tablicę na string (array_to_string(labels, ',')) i potem użyjesz LIKE, indeks GIN nie pomoże. Również jeśli potrzebujesz „rozpoczyna się od” lub dopasowań przybliżonych w obrębie etykiet, to już terytorium wyszukiwania tekstowego, a nie członkostwa w tablicy.
Tablice też mogą stać się trudne w utrzymaniu, gdy zamieniają się w mini-bazę: częste aktualizacje, potrzeba metadanych per elementu (kto dodał etykietę, kiedy, dlaczego) lub potrzeby analityczne per etykieta. W takim wypadku tabela relacyjna typu project_labels(project_id, label) jest zwykle łatwiejsza do walidacji, zapytań i dalszego rozwoju.
Indeksowanie wyszukiwania: pełnotekstowe i przybliżone dopasowanie (GIN i GiST)
Dla pól wyszukiwania powtarzają się dwa wzorce: pełnotekstowe wyszukiwanie (znajdź rekordy na dany temat) i dopasowanie przybliżone (radzenie sobie z literówkami, częściowymi nazwami i wzorcami ILIKE). Właściwy indeks to różnica między „natychmiastowym” a „timeout przy 10k użytkowników”.
Pełnotekstowe wyszukiwanie: tsvector + GIN
Używaj pełnotekstowego wyszukiwania, gdy użytkownicy wpisują prawdziwe słowa i chcesz wyniki uporządkować wg trafności, np. wyszukiwanie zgłoszeń po temacie i opisie. Zwyczajowe podejście to przechowywać tsvector (często w kolumnie generowanej) i indeksować go GIN. Szukasz przez @@ i tsquery.
-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);
-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');
-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);
SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';
Co przechowywać w wektorze: tylko pola, po których faktycznie wyszukujesz. Jeśli włączysz wszystko (notatki, logi wewnętrzne), zapłacisz większym rozmiarem indeksu i kosztem zapisu.
Dopasowanie przybliżone: trigramy z GIN lub GiST
Użyj podobieństwa trigramowego, gdy użytkownicy wyszukują imiona, emaile lub krótkie frazy i potrzebujesz dopasowań częściowych lub tolerancji literówek. Trigramy pomagają z ILIKE '%term%' i operatorami podobieństwa. GIN zwykle jest szybszy dla pytań „czy to pasuje?”; GiST może być lepszy, gdy zależy ci również na sortowaniu po podobieństwie.
Zasady praktyczne:
- Używaj GIN +
tsvectordla tekstowego wyszukiwania opartego na trafności. - Używaj trigramów dla ILIKE i przyjaznych błędom literowym wyszukiwań nazw.
Pułapki, na które warto uważać:
- Dziedziczne wildcardy bez trigramów (
ILIKE '%abc') wymuszają skan. - Bardzo krótkie zapytania (1–2 znaki) często nie używają dobrze trigramów.
- Stop-words i stemming mogą zaskoczyć użytkowników w wynikach pełnotekstowych, więc dobierz konfigurację odpowiadającą językowi produktu.
Jeśli szybko wypuszczasz ekrany wyszukiwania, traktuj indeks jako część funkcji: UX wyszukiwania i wybór indeksu trzeba zaprojektować razem.
Krok po kroku: od wolnego zapytania do właściwego indeksu
Zacznij od dokładnego zapytania, które uruchamia twoja aplikacja, a nie od domysłów. „Wolny ekran” to zwykle jedno polecenie SQL z bardzo konkretnym WHERE i ORDER BY. Skopiuj je z logów, debug output ORM-a lub z narzędzia, które już używasz do przechwytywania zapytań.
Workflow, który sprawdza się w praktyce:
- Zapisz pełne SQL, włącznie z WHERE, ORDER BY i LIMIT.
- Uruchom
EXPLAIN (ANALYZE, BUFFERS)na tym samym zapytaniu. - Skoncentruj się na operatorach robiących robotę (
=,>=,LIKE,@>,@@), nie tylko na nazwach kolumn. - Dodaj najmniejszy indeks, który pasuje do tych operatorów.
- Ponownie uruchom
EXPLAIN (ANALYZE, BUFFERS)z realistycznym wolumenem danych.
Oto konkretny przykład. Strona Klientów filtruje po tenant i statusie, sortuje po newest i paginuje:
SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Jeśli EXPLAIN pokazuje sekwencyjne skanowanie i sortowanie, indeks B-tree pasujący do filtra i sortowania często to naprawia:
CREATE INDEX ON customers (tenant_id, status, created_at DESC);
Jeśli wolne miejsce to filtrowanie JSONB jak metadata @> '{"plan":"pro"}', to wskazuje na GIN. Jeśli to jest pełnotekstowe wyszukiwanie to_tsvector(...) @@ plainto_tsquery(...), to także wskazuje na indeks GIN. Jeśli to jest „najbliższe dopasowanie” lub operatory nakładania się, to GiST może być właściwy.
Po dodaniu indeksu zmierz kompromis. Sprawdź rozmiar indeksu, czas insercji i aktualizacji oraz czy pomaga on w najważniejszych wolnych zapytaniach, czy tylko w jednym krawędziowym przypadku. W szybko zmieniających się projektach (w tym tych budowanych na Koder.ai) ta ponowna weryfikacja pomaga unikać nagromadzenia nieużywanych indeksów.
Częste błędy indeksowania, które marnują czas i pieniądze
Większość problemów z indeksami nie polega na wyborze B-tree vs GIN vs GiST. Chodzi o tworzenie indeksu, który wygląda poprawnie, ale nie pasuje do sposobu, w jaki aplikacja zapytuje tabelę.
Błędy, które najczęściej szkodzą:
- Indeksy, które nigdy nie są używane. Zapytanie korzysta z innego operatora niż ten, który wspiera indeks, albo indeks złożony ma złą kolejność kolumn. Jeśli w WHERE zaczynasz od
tenant_idicreated_at, a indeks zaczyna się odcreated_at, planer może go pominąć. - Indeksowanie kolumn o niskiej krotności wartości. Samodzielny indeks na
status,is_activelub booleanu często niewiele daje, bo pasuje zbyt wielu wierszom. Sparuj go z selektywną kolumną (jaktenant_idlubcreated_at) albo zrezygnuj. - Nakładające się indeksy, które puchną i spowalniają zapisy. Podobne indeksy na tej samej tabeli mogą podwoić zajętość i spowolnić inserty i aktualizacje.
- Pułapki paginacji. Indeksowanie wyłącznie pod OFFSET jest powszechną pułapką. Jeśli używasz paginacji keyset, potrzebujesz indeksu, który pasuje do sortu i filtru ostatnio-widzianego klucza.
- Zastarzałe statystyki i bałagan w tabeli. Jeśli autovacuum nie nadąża, albo
ANALYZEnie był uruchamiany, planer może wybrać złe plany nawet gdy właściwy indeks istnieje.
Konkretne przykłady: ekran Faktur filtruje po tenant_id i status, a potem sortuje po created_at DESC. Indeks tylko na status niewiele pomoże. Lepszym dopasowaniem jest indeks złożony zaczynający się od tenant_id, potem status, na końcu created_at (filtr najpierw, sort na końcu). Ta jedna zmiana często bije dodawanie trzech oddzielnych indeksów.
Traktuj każdy indeks jak koszt. Musi się spłacić w rzeczywistych zapytaniach, nie tylko w teorii.
Krótka lista kontrolna przed wypuszczeniem zmian indeksów
Zmiany indeksów łatwo wprowadzić, a trudno cofnąć, jeśli dodadzą kosztów zapisu lub zablokują gorącą tabelę. Przed merge’em traktuj to jak małe wydanie.
Zacznij od zdecydowania, co optymalizujesz. Wyciągnij dwa krótkie rankingi z logów lub monitoringu: zapytania uruchamiane najczęściej i zapytania o największej latencji. Dla każdego zapisz dokładny kształt: kolumny filtra, kolejność sortowania, złączenia i używane operatory (equals, range, IN, ILIKE, operatory JSONB, contains tablicy). To zapobiega zgadywaniu i pomaga wybrać właściwy typ indeksu.
Checklist przed push:
- Potwierdź, że zapytanie jest ograniczone tenant-em tam, gdzie powinno.
- Dopasuj indeks do operatora: B-tree dla równości/rang/sortu, GIN dla członkostwa (tablice, JSONB, pełny tekst), GiST dla przypadków nakładania się lub odległości.
- Preferuj jeden indeks złożony, który pasuje do typowego filtra + sortu, zamiast kilku indeksów jedno-kolumnowych.
- Utrzymaj indeks wąski: tylko kolumny, których zapytanie naprawdę używa.
- Zaplanuj rollout: czy tworzenie indeksu zablokuje zapisy i czy trzeba to zrobić poza godzinami szczytu?
Po dodaniu indeksu sprawdź, czy pomógł w rzeczywistym planie. Uruchom EXPLAIN (ANALYZE, BUFFERS) na dokładnym zapytaniu i porównaj przed i po. Potem obserwuj zachowanie produkcji przez dzień:
- Czy latencja odczytów spadła dla docelowych ekranów?
- Czy latencja zapisów wzrosła (insert/update)?
- Czy CPU lub przestrzeń dyskowa wzrosły przez nowy indeks?
- Czy indeks jest faktycznie używany, czy tylko zajmuje miejsce?
Jeśli budujesz z Koder.ai, warto trzymać wygenerowany SQL dla jednego lub dwóch wolnych ekranów obok zmiany, aby indeks pasował do tego, co aplikacja rzeczywiście uruchamia.
Przykład: indeksowanie typowego workflow w aplikacji SaaS + następne kroki
Wyobraź sobie typowy ekran administracyjny: lista Użytkowników z ograniczeniem tenant-a, kilkoma filtrami, sortem po "ostatnio aktywny" i polem wyszukiwania. To moment, gdy indeksy przestają być teorią i zaczynają oszczędzać rzeczywisty czas.
Trzy kształty zapytań, które zwykle zobaczysz:
-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;
-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;
-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
AND metadata @> '{"plan":"pro"}'::jsonb;
Niewielki, ale przemyślany zestaw indeksów dla tego ekranu:
- Złożony B-tree dla widoku listy:
(tenant_id, status, last_active_at DESC). - GIN dla wyszukiwania: generowana kolumna
tsvectorz indeksem GIN. - Indeksowanie JSONB zależnie od użycia:
GIN (metadata)gdy często używasz@>, albo indeks wyrażenia B-tree jak((metadata->> 'plan'))gdy najczęściej filtrujesz po jednym kluczu.
Mieszane potrzeby są normalne. Jeśli jedna strona robi filtry + wyszukiwanie + JSON, unikaj upychania wszystkiego w jeden mega indeks. Trzymaj B-tree dla sortowania/paginacji, a potem dodaj jeden specjalizowany indeks (często GIN) dla kosztownej części.
Następne kroki: wybierz jeden wolny ekran, zapisz jego 2–3 główne kształty zapytań i przejrzyj każdy indeks według celu (filtr, sort, wyszukiwanie, JSON). Jeśli indeks nie pasuje jasno do rzeczywistego zapytania, usuń go z planu. Jeśli szybko iterujesz na Koder.ai, przeprowadzaj taką przeglądówkę w miarę dodawania nowych ekranów, aby zapobiec rozrostowi indeksów, gdy schemat wciąż się zmienia.
Często zadawane pytania
Co konkretnie naprawia indeks w prawdziwej aplikacji SaaS?
Indeks pozwala PostgreSQL znaleźć pasujące wiersze bez czytania większej części tabeli. Dla typowych ekranów SaaS — list, pulpitów i wyszukiwania — odpowiedni indeks może zamienić wolne sekwencyjne skanowanie w szybkie wyszukiwanie, które lepiej skaluje się wraz ze wzrostem tabeli.
Jak wybrać między B-tree, GIN i GiST bez zbędnego analizowania?
Zacznij od B-tree dla większości zapytań aplikacji — jest najlepszy dla filtrów =, zakresów, złączeń i ORDER BY. Jeśli zapytanie dotyczy głównie zawierania (JSONB, tablice) lub pełnotekstowego wyszukiwania, rozważ GIN; GiST lepiej pasuje do zapytań o nakładanie się lub „najbliższy/podobny” typ dopasowania.
Jaka jest najprostsza zasada kolejności kolumn w złożonym indeksie B-tree?
Umieść najpierw kolumny, które filtrowane są z =, a na końcu kolumnę, po której sortujesz. Taki układ pasuje do tego, jak planer może przeglądać indeks, więc jednocześnie filtruje i zwraca wiersze w odpowiedniej kolejności bez dodatkowego sortowania.
Czy naprawdę muszę uwzględniać tenant_id w indeksach dla wielodostępnego SaaS?
Jeśli każde zapytanie jest ograniczone do tenant_id, wstawienie tenant_id jako pierwszej kolumny powoduje, że wiersze danego tenant-a są pogrupowane w indeksie. To zazwyczaj zmniejsza ilość wpisów indeksu i stron tabeli, które PostgreSQL musi przeszukać dla codziennych widoków list.
Kiedy warto użyć INCLUDE w indeksie B-tree?
INCLUDE pozwala dodać dodatkowe kolumny, aby obsłużyć odczyty tylko z indeksu na stronach list bez zwiększania szerokości klucza indeksu. Przydatne, gdy filtrujesz i sortujesz po kilku kolumnach, a jednocześnie wyświetlasz kilka dodatkowych pól.
Kiedy indeks częściowy jest lepszy niż indeksowanie całej tabeli?
Indeks częściowy jest lepszy, gdy interesuje cię tylko podzbiór wierszy, np. „nieusunięte” lub tylko „aktywne”. Pozwala to utrzymać indeks mniejszym i tańszym w utrzymaniu, co ma znaczenie dla gorących tabel z dużą liczbą wstawek i aktualizacji.
Czy indeksować JSONB przez GIN, czy przez indeksy wyrażeń?
Użyj indeksu GIN na kolumnie JSONB, gdy często filtrujesz przez zawieranie, np. metadata @> '{"plan":"pro"}'. Jeśli zaś najczęściej filtrujesz po jednym konkretnym kluczu, indeks wyrażenia B-tree na (metadata->> 'plan') będzie zwykle mniejszy i szybszy.
Kiedy tablice zasługują na indeks GIN, a kiedy lepiej je znormalizować?
GIN sprawdza się, gdy głównie pytasz „czy ta tablica zawiera X?” używając operatorów takich jak @> lub &&. Jeśli potrzebujesz metadanych per element, częstych edycji lub analiz dla każdego elementu, lepsza jest normalizacja do tabeli z relacją (join table).
Jaki jest właściwy sposób indeksowania pola wyszukiwania?
Dla pełnotekstowego wyszukiwania przechowuj tsvector (np. w kolumnie generowanej) i indeksuj go GIN, potem wyszukuj przez @@ dla wyników sortowanych po trafności. Dla dopasowań typu fuzzy (ILIKE '%name%') i tolerancji literówek używaj trigramów (często GIN z gin_trgm_ops).
Jak przejść od wolnego zapytania do właściwego indeksu w powtarzalny sposób?
Skopiuj dokładne SQL, które twoja aplikacja uruchamia i uruchom EXPLAIN (ANALYZE, BUFFERS), aby zobaczyć, gdzie spędza czas — czy skanuje, sortuje, czy stosuje kosztowne filtry. Dodaj najmniejszy indeks odpowiadający operatorom i porządkowi sortowania zapytania, a potem ponownie uruchom EXPLAIN, aby sprawdzić poprawę.