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.

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.
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?
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 (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:
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.
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:
tenant_id, jeśli każde zapytanie jest ograniczone do tenant-a.= następne (często status, user_id).ORDER BY na końcu (często created_at lub id).INCLUDE, aby pokryć stronę list bez poszerzania klucza indeksu.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.
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ówNadindeksowanie 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.
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:
@>.-> / ->> (często porównywane =).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.
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).
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:
@> (tablica zawiera)&& (tablica dzieli dowolne elementy)= ANY(...), ale @> jest często bardziej przewidywalneTypowy 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.
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”.
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.
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:
tsvector dla tekstowego wyszukiwania opartego na trafności.Pułapki, na które warto uważać:
ILIKE '%abc') wymuszają skan.Jeśli szybko wypuszczasz ekrany wyszukiwania, traktuj indeks jako część funkcji: UX wyszukiwania i wybór indeksu trzeba zaprojektować razem.
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:
EXPLAIN (ANALYZE, BUFFERS) na tym samym zapytaniu.=, >=, LIKE, @>, @@), nie tylko na nazwach kolumn.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.
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ą:
tenant_id i created_at, a indeks zaczyna się od created_at, planer może go pominąć.status, is_active lub booleanu często niewiele daje, bo pasuje zbyt wielu wierszom. Sparuj go z selektywną kolumną (jak tenant_id lub created_at) albo zrezygnuj.ANALYZE nie 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.
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:
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ń:
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.
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:
(tenant_id, status, last_active_at DESC).tsvector z indeksem GIN.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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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ę.