Strojenie wydajności PostgreSQL: konfiguracja serwera, optymalizacja zapytań i connection pooling
Poniższe opracowanie to kompendium strojenia wydajności PostgreSQL – od konfiguracji pamięci i buforów, przez optymalizację WAL i checkpointów, strojenie autovacuum, strategie indeksowania, analizę planów zapytań, po connection pooling z PgBouncer. Omawiamy również zmiany wydajnościowe w PostgreSQL 17.
Wprowadzenie
Domyślna konfiguracja PostgreSQL jest celowo zachowawcza – zaprojektowana tak, aby baza uruchomiła się poprawnie na dowolnym sprzęcie, od Raspberry Pi po serwer z 512 GB RAM. W środowisku produkcyjnym oznacza to, że serwer wykorzystuje ułamek dostępnych zasobów. Parametr shared_buffers ustawiony domyślnie na 128 MB na maszynie z 32 GB RAM to jak silnik sportowy pracujący na biegu jałowym.
Strojenie PostgreSQL to proces systematyczny: zaczynamy od alokacji pamięci, przechodzimy do konfiguracji zapisu (WAL), zapewniamy skuteczne czyszczenie martwych wierszy (autovacuum), budujemy odpowiednie indeksy, identyfikujemy wolne zapytania i na końcu wdrażamy connection pooling. Pomijanie któregokolwiek z tych etapów tworzy wąskie gardło, które niweluje korzyści z pozostałych optymalizacji.
Pamięć i bufory
shared_buffers i effective_cache_size
Gdy PostgreSQL odczytuje wiersz z tabeli, nie sięga bezpośrednio na dysk – najpierw sprawdza, czy potrzebny blok danych (strona o rozmiarze 8 KB) znajduje się już w pamięci operacyjnej. Parametr shared_buffers definiuje rozmiar tej pamięci współdzielonej, w której PostgreSQL przechowuje najczęściej odczytywane bloki. Im więcej danych mieści się w pamięci, tym rzadziej baza sięga na dysk – a każdy odczyt z RAM jest tysiące razy szybszy niż odczyt z dysku SSD.
Standardowa rekomendacja to 25% dostępnego RAM-u. Na serwerze z 16 GB RAM ustawiamy shared_buffers = 4GB. Dlaczego nie więcej? PostgreSQL nie jest jedynym konsumentem pamięci na serwerze. System operacyjny Linux posiada własny mechanizm buforowania – tzw. page cache – który automatycznie przechowuje w wolnej pamięci RAM dane z dysku, do których ostatnio sięgano. PostgreSQL korzysta z obu warstw: najpierw sprawdza shared_buffers, a jeśli nie znajdzie danych, system operacyjny może je mieć w swoim page cache. Zwiększanie shared_buffers powyżej 40% RAM zabiera pamięć page cache i przynosi malejące korzyści.
Parametr effective_cache_size nie alokuje żadnej pamięci – jest wskazówką dla planisty zapytań (query planner). Planista to komponent PostgreSQL, który przed wykonaniem każdego zapytania SQL analizuje dostępne indeksy i statystyki tabel, a następnie wybiera najszybszą strategię wykonania. effective_cache_size informuje planistę, ile łącznie pamięci jest dostępne na buforowanie (shared_buffers + page cache systemu operacyjnego). Na dedykowanym serwerze bazodanowym ustawiamy go na 75% RAM.
Wartość ta wpływa na kluczowe decyzje planisty. Wysoki effective_cache_size sprawia, że planer częściej wybiera Index Scan (odczyt przez indeks – szybki, ale wymaga, by indeks zmieścił się w pamięci) zamiast Sequential Scan (odczyt całej tabeli wiersz po wierszu – wolniejszy, ale nie wymaga pamięci na indeks). Zbyt niska wartość powoduje, że planista pesymistycznie zakłada, iż dane nie zmieszczą się w cache, i wybiera gorsze plany.
work_mem i maintenance_work_mem
Parametr work_mem kontroluje ilość pamięci przydzielanej na każdą operację sortowania, haszowania lub łączenia (join) w ramach pojedynczego zapytania. Aby zrozumieć jego znaczenie, wyobraź sobie zapytanie z klauzulą ORDER BY na tabeli z milionem wierszy. PostgreSQL musi posortować wyniki – jeśli zmieszczą się w pamięci przydzielonej przez work_mem, sortowanie odbywa się błyskawicznie w RAM. Jeśli nie – dane muszą zostać tymczasowo zapisane na dysku i posortowane w częściach (operacja spill-to-disk), co jest wielokrotnie wolniejsze.
Domyślne 4 MB wymusza zrzucanie na dysk przy każdym większym sortowaniu. Zbyt wysoka wartość jest jednak ryzykowna – jedno złożone zapytanie może użyć work_mem wielokrotnie (raz na każdy węzeł Sort lub Hash w planie wykonania). Zapytanie łączące trzy tabele z dwoma sortowaniami zużyje work_mem pięciokrotnie. Przy setkach jednoczesnych zapytań łatwo wyczerpać cały RAM.
Bezpieczna strategia to ustawienie work_mem na umiarkowanym poziomie globalnie (np. 16–64 MB w pliku postgresql.conf) i podnoszenie go na poziomie sesji dla konkretnych zapytań analitycznych. Przed uruchomieniem ciężkiego raportu administrator może wykonać SET work_mem = '256MB' – ustawienie obowiązuje tylko w tej sesji i nie wpływa na pozostałe połączenia.
Parametr maintenance_work_mem kontroluje pamięć operacji administracyjnych: VACUUM (czyszczenie martwych wierszy), CREATE INDEX (budowanie indeksu) i ALTER TABLE ADD FOREIGN KEY. Operacje te wykonywane są sporadycznie, ale na dużych tabelach potrzebują znacznie więcej pamięci niż zwykłe zapytania. Domyślne 64 MB spowalnia budowanie indeksów na tabelach z milionami wierszy. Rekomendowana wartość to 512 MB–2 GB – ponieważ te operacje zazwyczaj nie działają jednocześnie w dużych ilościach, wyższy limit jest bezpieczny.
Interakcja z ZFS ARC
Na serwerach z systemem plików ZFS dochodzi dodatkowa warstwa buforowania – pamięć podręczna ARC. Zarówno shared_buffers PostgreSQL, jak i ARC konkurują o ten sam fizyczny RAM. Łączna suma shared_buffers + zfs_arc_max nie powinna przekraczać 75% RAM-u, aby pozostawić zapas dla work_mem, procesów systemowych i jądra. Szczegółowe omówienie tej interakcji znajdziesz w naszej publikacji Optymalizacja ZFS dla baz danych PostgreSQL i MySQL.
WAL i strojenie checkpointów
Wyobraź sobie scenariusz: aplikacja zapisuje zamówienie w bazie danych. PostgreSQL potwierdza zapis, ale sekundę później serwer traci zasilanie. Czy zamówienie przetrwa? Tak – dzięki mechanizmowi WAL (Write-Ahead Log, dziennik zapisu wyprzedzającego).
Zasada działania jest prosta: zanim PostgreSQL zmodyfikuje główne pliki tabel na dysku, zapisuje opis planowanej zmiany do sekwencyjnego dziennika WAL. Zapis do WAL jest bardzo szybki (sekwencyjny zapis na dysk), a po jego zakończeniu PostgreSQL potwierdza transakcję klientowi. Faktyczna modyfikacja plików tabel następuje później, w tle. Jeśli serwer ulegnie awarii, przy ponownym uruchomieniu PostgreSQL odczyta dziennik WAL i odtworzy wszystkie potwierdzone, ale jeszcze niezapisane zmiany – ten proces nazywa się crash recovery.
Checkpoint to operacja, podczas której silnik przenosi wszystkie zbuforowane zmiany (tzw. dirty pages – strony zmodyfikowane w pamięci, ale jeszcze niezapisane na dysku) z pamięci do głównych plików danych. Po zakończeniu checkpointu PostgreSQL może usunąć stare pliki WAL, bo zmiany w nich zapisane są już bezpiecznie na dysku.
Zbyt częste checkpointy (domyślny max_wal_size = 1GB) generują intensywne operacje I/O – serwer musi zapisać na dysk wszystkie zmodyfikowane strony naraz, co powoduje skoki opóźnień odczuwalne przez użytkowników. Zbyt rzadkie checkpointy oznaczają, że po awarii PostgreSQL musi odtworzyć więcej zmian z WAL, co wydłuża czas odzyskiwania (crash recovery). Dla serwerów produkcyjnych rekomendowane jest zwiększenie max_wal_size do 4–16 GB – checkpointy będą rzadsze, a obciążenie I/O rozłożone bardziej równomiernie.
Parametr checkpoint_completion_target (domyślnie 0.9) kontroluje, jak równomiernie rozkładany jest zapis danych między checkpointami. Wartość 0.9 oznacza, że PostgreSQL stara się rozłożyć zapis na 90% czasu między checkpointami, zamiast wykonywać go jednorazowo na końcu. To zapobiega nagłym skokom obciążenia dysku. Ustawienie to powinno pozostać na domyślnej wartości.
Bufor WAL (wal_buffers) to pamięć, w której PostgreSQL tymczasowo gromadzi wpisy do dziennika przed zapisaniem ich na dysk. Domyślnie ustawia się automatycznie na 1/32 shared_buffers (max 64 MB). Dla serwerów z dużym ruchem transakcyjnym warto wymusić wal_buffers = 64MB, aby uniknąć sytuacji, w której wiele jednoczesnych transakcji konkuruje o zbyt mały bufor.
Autovacuum, MVCC i problem bloatu
Mechanizm MVCC i martwe wiersze
PostgreSQL implementuje kontrolę współbieżności w modelu MVCC (Multi-Version Concurrency Control – kontrola współbieżności przez wiele wersji). Aby zrozumieć, dlaczego bloat jest problemem, trzeba wiedzieć, co dzieje się na poziomie dysku podczas zwykłych operacji.
Gdy wykonujesz UPDATE users SET email = 'nowy@email.pl' WHERE id = 1, PostgreSQL nie nadpisuje istniejącego wiersza. Zamiast tego tworzy nową kopię wiersza ze zmienionym adresem e-mail, a starą wersję oznacza jako martwą (dead tuple). Stary wiersz fizycznie pozostaje na dysku – nie jest usuwany, ponieważ inne transakcje, które rozpoczęły się przed tym UPDATE, mogą jeszcze potrzebować widzieć poprzednią wersję danych (to właśnie gwarantuje izolację transakcji w MVCC).
To samo dotyczy operacji DELETE – wiersz jest jedynie oznaczany jako martwy, ale fizycznie zajmuje miejsce w tabeli i we wszystkich powiązanych indeksach.
Narastanie martwych wierszy nazywa się bloatem. Skala problemu rośnie z intensywnością aktualizacji: tabela z milionem wierszy, w której każdy wiersz jest aktualizowany raz dziennie, po miesiącu bez czyszczenia będzie zajmować miejsce na 31 milionów wierszy – z czego 30 milionów to martwe kopie. Indeksy puchną proporcjonalnie, ponieważ każda martwa wersja wiersza wciąż posiada swoje wpisy w indeksie. Zapytania stają się wolniejsze, bo PostgreSQL musi przeskakiwać martwe wiersze podczas skanowania tabeli.
Strojenie autovacuum
Autovacuum to proces działający w tle, który automatycznie czyści martwe wiersze i aktualizuje statystyki planisty (polecenie ANALYZE – zbiera informacje o rozkładzie danych w tabelach, dzięki czemu planista może podejmować trafne decyzje o strategii wykonania zapytań). Bez aktualnych statystyk planista może np. wybrać Sequential Scan na tabeli z milionem wierszy, mimo że istnieje doskonały indeks – po prostu dlatego, że nie wie, ile wierszy zwróci zapytanie.
Domyślne parametry autovacuum są zachowawcze. Czyszczenie uruchamia się, gdy liczba martwych wierszy przekroczy próg obliczany wzorem: autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × liczba wierszy w tabeli. Przy domyślnych wartościach (threshold = 50, scale_factor = 0.2) dla tabeli z 10 milionami wierszy oznacza to czekanie, aż nagromadzi się 2 000 050 martwych wierszy – ponad 2 miliony.
Dla tabel z intensywnym ruchem (np. sesje użytkowników, koszyki w WooCommerce, logi aktywności) rekomendowane jest obniżenie progu. Parametry można ustawić globalnie w postgresql.conf lub – co jest lepszą praktyką – indywidualnie dla konkretnej tabeli za pomocą ALTER TABLE ... SET:
autovacuum_vacuum_scale_factor = 0.05– uruchomienie czyszczenia przy 5% martwych wierszy zamiast 20%. Dla tabeli z 10 milionami wierszy próg spada do 500 000.autovacuum_vacuum_cost_limit = 1000(domyślnie 200) – pozwala autovacuum pracować szybciej, zużywając więcej I/O. Autovacuum celowo „hamuje" się, by nie spowalniać zapytań użytkowników – ten parametr poluzowuje hamulec.autovacuum_vacuum_cost_delay = 1ms(domyślnie 2 ms) – skraca pauzy między porcjami pracy. Autovacuum po wykonaniu określonej ilości pracy (mierzonej kosztem I/O) robi przerwę na tyle milisekund – mniejsza wartość oznacza szybsze czyszczenie.
Diagnostyka bloatu opiera się na widoku systemowym pg_stat_user_tables. Najważniejsze kolumny to: n_dead_tup (liczba martwych wierszy), n_live_tup (liczba żywych wierszy – stosunek tych dwóch wartości pokazuje skalę bloatu), last_autovacuum (kiedy ostatnio uruchomiono czyszczenie) i last_autoanalyze (kiedy ostatnio zaktualizowano statystyki).
Wysoka wartość n_dead_tup przy niedawnym last_autovacuum oznacza, że autovacuum uruchamia się, ale nie nadąża z czyszczeniem – wymaga agresywniejszego strojenia parametrów opisanych powyżej. Brak last_autovacuum (wartość NULL) przy dużym n_dead_tup może oznaczać, że próg uruchomienia jest zbyt wysoki lub autovacuum jest całkowicie wyłączony dla tej tabeli.
Strategie indeksowania
B-tree, GIN, GiST i BRIN
Indeks w bazie danych pełni tę samą rolę co spis treści w książce – zamiast czytać całą książkę (skanować całą tabelę), sprawdzasz spis i od razu przeskakujesz na właściwą stronę. PostgreSQL oferuje kilka typów indeksów, z których każdy jest zoptymalizowany pod inny rodzaj zapytań.
B-tree – domyślny typ indeksu, tworzony automatycznie gdy nie podasz innego typu. Optymalny dla operacji porównania: =, <, >, BETWEEN, ORDER BY. Jeśli szukasz użytkownika po adresie e-mail (WHERE email = 'jan@example.com') lub produktów w przedziale cenowym (WHERE price BETWEEN 100 AND 500), B-tree jest właściwym wyborem. Sprawdza się w większości scenariuszy OLTP (przetwarzania transakcyjnego).
GIN (Generalized Inverted Index – uogólniony indeks odwrócony) – zaprojektowany do przeszukiwania struktur, które zawierają wiele wartości w jednym polu. Trzy główne zastosowania to: wyszukiwanie pełnotekstowe (kolumny typu tsvector – np. wyszukiwanie artykułów zawierających słowa „PostgreSQL" i „wydajność"), zapytania na kolumnach JSONB z operatorami @> (zawiera) i ? (czy istnieje klucz), oraz przeszukiwanie tablic (ARRAY). GIN jest wolniejszy w budowaniu niż B-tree (bo musi zanalizować i zindeksować wiele wartości z każdego wiersza), ale znacznie szybszy w wyszukiwaniu złożonych struktur.
GiST (Generalized Search Tree) – stosowany w danych geometrycznych i przestrzennych (np. rozszerzenie PostGIS – „znajdź wszystkie restauracje w promieniu 5 km"), zakresach czasowych i numerycznych (tsrange, int4range – np. „znajdź wszystkie rezerwacje pokrywające się z datą 15 marca") oraz jako alternatywa dla GIN przy wyszukiwaniu pełnotekstowym (szybsza w budowaniu, ale wolniejsza w wyszukiwaniu).
BRIN (Block Range Index) – ekstremalnie kompaktowy indeks, przechowujący jedynie podsumowania zakresów wartości w blokach fizycznych na dysku. Idealny dla tabel, w których dane są fizycznie posortowane w kolejności wstawiania – typowo logi, metryki, zdarzenia czy historia zamówień posortowana chronologicznie. BRIN na tabeli z miliardem wierszy może zajmować zaledwie kilka megabajtów, podczas gdy B-tree zająłby gigabajty. Warunek: dane muszą mieć naturalny porządek fizyczny (nowe wiersze dopisywane na końcu tabeli). Jeśli tabela jest intensywnie aktualizowana i fizyczny porządek wierszy nie odpowiada wartościom kolumny, BRIN będzie nieefektywny.
Indeksy częściowe i pokrywające
Indeksy częściowe (partial indexes) zawierają jedynie wiersze spełniające określony warunek WHERE. Rozważmy tabelę zamówień z 10 milionami wierszy, w której 95% zamówień ma status „completed", a tylko 5% – „pending". Aplikacja najczęściej szuka zamówień oczekujących. Pełny indeks na kolumnie status zindeksowałby wszystkie 10 milionów wierszy, ale indeks częściowy CREATE INDEX ON orders (status) WHERE status = 'pending' zawiera jedynie 500 000 wierszy – jest 20 razy mniejszy, szybszy w przeszukiwaniu i szybszy w aktualizacji.
Indeksy pokrywające (covering indexes, klauzula INCLUDE, PostgreSQL 11+) umożliwiają tzw. index-only scan. Normalnie, gdy PostgreSQL znajduje wiersz w indeksie, musi jeszcze sięgnąć do tabeli (heap), aby pobrać pozostałe kolumny potrzebne w zapytaniu – to dodatkowy losowy odczyt I/O. Indeks pokrywający zawiera dodatkowe kolumny „na dokładkę", dzięki czemu zapytanie pobiera wszystkie potrzebne dane bezpośrednio z indeksu, bez sięgania do tabeli. Na przykład: CREATE INDEX ON orders (user_id) INCLUDE (total, created_at) pozwala na zapytanie SELECT total, created_at FROM orders WHERE user_id = 42 w trybie index-only scan. Eliminuje to losowe odczyty I/O i może przyspieszyć zapytania nawet kilkukrotnie.
Diagnostyka nieużywanych indeksów
Każdy indeks spowalnia operacje zapisu (INSERT/UPDATE/DELETE) i zajmuje miejsce na dysku. Widok pg_stat_user_indexes z kolumną idx_scan pozwala zidentyfikować indeksy, które nigdy lub rzadko są używane. Indeksy z idx_scan = 0 przez dłuższy okres powinny zostać usunięte – zmniejsza to narzut zapisu i przyspiesza autovacuum.
Analiza zapytań
EXPLAIN ANALYZE i odczytywanie planów
Polecenie EXPLAIN pokazuje plan, jaki planista wybrał dla zapytania – bez jego wykonywania. Polecenie EXPLAIN (ANALYZE, BUFFERS) idzie dalej: faktycznie wykonuje zapytanie i zwraca rzeczywisty plan z zmierzonymi czasami i liczbą odczytanych bloków. To podstawowe narzędzie diagnostyczne – odpowiada na pytanie „dlaczego to zapytanie jest wolne?".
Plan wykonania to drzewo operacji. PostgreSQL czyta je od dołu do góry – najgłębsze węzły wykonują się pierwsze. Kluczowe elementy do identyfikacji:
- Seq Scan (Sequential Scan) na dużej tabeli – oznacza, że PostgreSQL czyta całą tabelę wiersz po wierszu, zamiast użyć indeksu. Zazwyczaj wskazuje na brak odpowiedniego indeksu. Wyjątek: planista może świadomie wybrać Seq Scan, jeśli zapytanie zwraca więcej niż ~10% wierszy tabeli – w takim przypadku sekwencyjny odczyt jest szybszy niż skakanie po indeksie.
- Nested Loop z dużą liczbą iteracji (rows) – PostgreSQL dla każdego wiersza z jednej tabeli wykonuje wyszukiwanie w drugiej. Przy braku indeksu na kolumnie łączenia (join) każda iteracja to pełny skan wewnętrznej tabeli. 1000 wierszy × 100 000 wierszy = 100 milionów porównań.
- Sort Method: external merge – dane nie zmieściły się w pamięci
work_memi zostały zrzucone na dysk. Rozwiązanie: zwiększeniework_memdla tej sesji lub dodanie indeksu, który eliminuje konieczność sortowania. - Buffers: read (duża wartość) przy niskim Buffers: hit – dane odczytywane są z dysku zamiast z pamięci. Może wskazywać na zbyt mały
shared_bufferslub zbyt dużą tabelę.
Częstym błędem, który uniemożliwia użycie indeksu, jest stosowanie funkcji na kolumnie indeksowanej. Zapytanie WHERE EXTRACT(YEAR FROM created_at) = 2026 wymusza Seq Scan, ponieważ PostgreSQL nie potrafi dopasować wyniku funkcji do indeksu B-tree. Przepisanie na WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' pozwala na wykorzystanie indeksu – zapytanie porównuje wartości kolumny bezpośrednio, bez przetwarzania funkcją.
pg_stat_statements – top-N najwolniejszych zapytań
Rozszerzenie pg_stat_statements agreguje statystyki wykonania wszystkich zapytań SQL. Sortując po total_exec_time, identyfikujemy zapytania zużywające łącznie najwięcej czasu CPU. Sortując po mean_exec_time – pojedyncze najwolniejsze zapytania. Kolumna calls pozwala odróżnić zapytanie wolne-ale-rzadkie od szybkiego-ale-wykonywanego-milion-razy.
Rozszerzenie należy włączyć w postgresql.conf (shared_preload_libraries = 'pg_stat_statements') i utworzyć (CREATE EXTENSION pg_stat_statements). Statystyki resetuje się okresowo poleceniem pg_stat_statements_reset(), aby dane odzwierciedlały bieżący profil obciążenia.
Connection pooling: PgBouncer
Problem skalowalności połączeń
PostgreSQL używa modelu process-per-connection – każde połączenie od aplikacji tworzy osobny proces systemowy (tzw. backend). To fundamentalna różnica w stosunku do np. MySQL, który używa wątków (threads). Proces jest cięższy od wątku: pojedynczy backend PostgreSQL zużywa 5–10 MB pamięci na stos, bufory i metadane.
W praktyce oznacza to, że aplikacja webowa z 1000 jednoczesnych użytkowników, z których każdy utrzymuje otwarte połączenie z bazą, generuje 1000 procesów PostgreSQL zajmujących 5–10 GB RAM – zanim baza wykona jakiekolwiek zapytanie. Dodatkowo system operacyjny musi przełączać kontekst między setkami procesów (context switching), co samo w sobie degraduje wydajność CPU.
Testy wykazują, że PostgreSQL z 200 bezpośrednimi połączeniami i prostym zapytaniem 10 ms osiąga ~8000 req/s. Ten sam serwer z PgBouncer w trybie transaction pooling i 20 rzeczywistymi połączeniami do bazy obsługuje ~50 000 req/s – ponad sześciokrotny wzrost. Różnica wynika z tego, że PgBouncer utrzymuje jedynie małą pulę „prawdziwych" połączeń z bazą, a tysiące połączeń od aplikacji obsługuje jako lekkie, prawie bezkosztowe gniazda sieciowe.
Tryby PgBouncer: transaction vs session
Transaction pooling (zalecany dla większości aplikacji webowych) – połączenie z bazą jest przypisane klientowi wyłącznie na czas trwania transakcji. Gdy klient wysyła BEGIN, PgBouncer pobiera wolne połączenie z puli i przypisuje je klientowi. Po COMMIT lub ROLLBACK połączenie wraca do puli i może być natychmiast użyte przez innego klienta. Zapewnia to największą efektywność – tysiące klientów mogą współdzielić kilkadziesiąt połączeń z bazą, ponieważ w danym momencie większość z nich nie wykonuje aktywnej transakcji (czeka na dane od użytkownika, przetwarza logikę aplikacji itp.).
Ograniczenie trybu transaction: nie wspiera funkcji PostgreSQL wymagających utrzymania stanu między transakcjami. Dotyczy to prepared statements tworzonych przez protokół binarny (nie przez PREPARE SQL), poleceń SET (np. SET timezone – ustawienie obowiązuje tylko do końca bieżącej transakcji) oraz advisory locks. Jeśli aplikacja korzysta z tych funkcji, potrzebne są bezpośrednie połączenia z bazą obok PgBouncer.
Session pooling – połączenie jest przypisane klientowi na całą sesję (do rozłączenia). Pełna kompatybilność z wszystkimi funkcjami PostgreSQL, ale minimalne korzyści ze współdzielenia – użyteczne głównie do zarządzania reconnektami i ochrony bazy przed nagłymi skokami nowych połączeń.
Konfiguracja produkcyjna
Kluczowe parametry pgbouncer.ini:
pool_mode = transaction– tryb poolingu.max_client_conn = 2000– maksymalna liczba połączeń klientów do PgBouncer (mogą być w tysiącach, ponieważ połączenia w PgBouncer są lekkie).default_pool_size = 50– liczba rzeczywistych połączeń do PostgreSQL per para (użytkownik, baza). Musi być mniejsza niżmax_connectionsw PostgreSQL.reserve_pool_size = 10– pula awaryjna na nagłe skoki.server_lifetime = 3600– czas życia połączenia z bazą (recykling co godzinę zapobiega wyciekom pamięci na backendach PostgreSQL).
Zasada: max_connections w PostgreSQL powinno być nieco większe (o 10–20%) niż suma default_pool_size × liczba pul, aby zostawić zapas na bezpośrednie połączenia administracyjne. Relacja ta pokrywa się z zasadami kaskady połączeń opisanymi w publikacji Architektura wydajnego stosu LEMP.
Nowości wydajnościowe w PostgreSQL 17
PostgreSQL 17 wprowadził szereg ulepszeń wydajnościowych, które bezpośrednio wpływają na strojenie produkcyjne:
- Przebudowane zarządzanie pamięcią dla VACUUM – nowy mechanizm TidStore zastąpił tablicę martwych wierszy, redukując zużycie pamięci podczas czyszczenia i przyspieszając operację na dużych tabelach.
- Read Stream API – nowy interfejs odczytu strumieniowego przyspiesza sekwencyjne skany, szczególnie na dużych tabelach. W testach wydajność odczytu wzrosła o 30–50% w porównaniu z PostgreSQL 16.
- Rozszerzony paralelizm – zapytania z
FULL OUTER JOINi agregatami mogą teraz korzystać z równoległego wykonania, co wcześniej było ograniczone do prostszych planów. - Ulepszone sortowanie inkrementalne – planer lepiej wykorzystuje częściowe uporządkowanie danych, zmniejszając liczbę operacji sortowania.
- Synchronizacja slotów replikacji logicznej – sloty logiczne są teraz automatycznie synchronizowane na repliki fizyczne, co zapewnia ciągłość potoków danych po failoverze. Szczegóły tego mechanizmu omawiamy w publikacji Replikacja i wysoka dostępność PostgreSQL.
Uwaga przy migracji: planer zapytań w PostgreSQL 17 jest bardziej agresywny w wykorzystaniu paralelizmu. Niektóre zapytania, które wcześniej korzystały z Index Scan, mogą przejść na Parallel Sequential Scan. Po migracji konieczne jest przetestowanie planów krytycznych zapytań za pomocą EXPLAIN ANALYZE i ewentualne dostrojenie parallel_tuple_cost oraz max_parallel_workers_per_gather.
Zakończenie
Strojenie wydajności PostgreSQL to proces wielowarstwowy. Zaczyna się od prawidłowej alokacji pamięci (shared_buffers, work_mem), przechodzi przez optymalizację zapisu (WAL, checkpointy), zapewnienie skutecznego czyszczenia martwych wierszy (autovacuum) i budowę odpowiednich indeksów (B-tree, GIN, BRIN, partial, covering). Analiza zapytań za pomocą EXPLAIN ANALYZE i pg_stat_statements identyfikuje rzeczywiste wąskie gardła, a PgBouncer w trybie transaction pooling rozwiązuje problem skalowalności połączeń.
Każdy z tych elementów wpływa na pozostałe – agresywny autovacuum wymaga odpowiedniego maintenance_work_mem, connection pooling zmienia wzorce obciążenia, a migracja na PostgreSQL 17 wymaga ponownej walidacji planów zapytań. Systematyczne podejście, oparte na pomiarach i diagnostyce, zawsze daje lepsze rezultaty niż ślepe kopiowanie parametrów z internetu.
W WebOptimo specjalizujemy się w administracji i strojeniu wydajności PostgreSQL na serwerach produkcyjnych. Jeśli potrzebujesz audytu bazy danych, optymalizacji zapytań lub wdrożenia connection pooling – skontaktuj się z nami. Sprawdź również nasze usługi administracji PostgreSQL, administracji serwerem oraz optymalizacji WordPress. Zobacz też nasze pozostałe publikacje: Optymalizacja ZFS, Architektura stosu LEMP, PostgreSQL HA, Konteneryzacja oraz Architektura DNS.
FAQ – Strojenie PostgreSQL
Standardowa rekomendacja to 25% dostępnego RAM-u. Na serwerze z 16 GB ustawiamy shared_buffers = 4GB. PostgreSQL korzysta również z systemowego page cache, więc zwiększanie powyżej 40% RAM przynosi malejące korzyści. Na serwerach z ZFS należy uwzględnić pamięć ARC – łączna suma shared_buffers i zfs_arc_max nie powinna przekraczać 75% RAM.
Bloat to nadmiarowe miejsce zajmowane przez martwe wiersze powstające w wyniku mechanizmu MVCC. Autovacuum czyści je w tle, ale przy domyślnych ustawieniach może nie nadążać. Kluczowe jest obniżenie autovacuum_vacuum_scale_factor (z 0.2 do 0.05) dla tabel z dużym ruchem i zwiększenie autovacuum_vacuum_cost_limit dla szybszego czyszczenia.
GIN jest optymalny dla przeszukiwania struktur wielowartościowych: wyszukiwanie pełnotekstowe (tsvector), kolumny JSONB z operatorami @> i ?, tablice. B-tree sprawdza się przy operacjach porównania (=, <, >, BETWEEN) na skalarnych wartościach.
PostgreSQL używa modelu process-per-connection – każde połączenie to osobny proces zużywający 5–10 MB pamięci. Przy 1000 połączeniach to 5–10 GB narzutu. Przełączanie kontekstu między setkami procesów degraduje CPU. Rozwiązaniem jest PgBouncer w trybie transaction pooling, pozwalający tysiącom klientów współdzielić kilkadziesiąt rzeczywistych połączeń z bazą.
Rozszerzenie pg_stat_statements zbiera statystyki wykonania wszystkich zapytań SQL. Sortując po total_exec_time lub mean_exec_time, identyfikujesz zapytania zużywające najwięcej czasu. Dla szczegółowej analizy konkretnego zapytania używa się EXPLAIN (ANALYZE, BUFFERS), które pokazuje rzeczywisty plan, liczbę bloków i czas każdej operacji.
PgBouncer to lekki proxy connection pooler. Tryb transaction pooling (zalecany) przypisuje połączenie z bazą tylko na czas transakcji, potem oddaje je do puli. Pozwala obsłużyć tysiące klientów za pomocą kilkudziesięciu połączeń z bazą. Ograniczenie: brak wsparcia dla prepared statements między transakcjami i poleceń SET.
PostgreSQL 17 wprowadził przebudowany VACUUM (mechanizm TidStore), Read Stream API przyspieszający sekwencyjne odczyty o 30–50%, rozszerzony paralelizm dla FULL OUTER JOIN i agregatów, ulepszone sortowanie inkrementalne oraz synchronizację slotów replikacji logicznej na repliki fizyczne.


