Pula połączeń: dlaczego 100 połączeń jest wolniejsze niż 20 — HikariCP, PgBouncer i czas oczekiwania
Gdy aplikacja zwalnia pod obciążeniem, pierwszym odruchem jest podniesienie maximumPoolSize z 20 do 100. Zwykle robi się gorzej, nie lepiej. Pula połączeń to nie jest zapas mocy, tylko kolejka przed bazą, która ma tyle samo rdzeni co przed zmianą. Kolejka istnieje tak czy inaczej. Rozmiar puli decyduje tylko o tym, gdzie ją widać: w puli, gdzie da się ją zmierzyć, czy w schedulerze systemu i w lock managerze Postgresa, gdzie jej nie widać. Poniżej arytmetyka rozmiaru puli, PgBouncer w trybie transaction z tym, co się w nim psuje, pułapka dwóch pul i metryka, która to wszystko pokazuje.
To kontynuacja wpisu Batching: jedno zapytanie zamiast pięciuset. Tam pula pojawiła się na marginesie: każde z 501 zapytań N+1 trzyma połączenie, więc przy kilkunastu równoległych żądaniach problem jednego endpointu staje się problemem całej aplikacji. Tutaj ten margines jest tematem.
Pula to kolejka, nie zapas
Ile połączeń faktycznie potrzebujesz, wynika z prawa Little'a: średnia liczba zajętych połączeń to przepustowość pomnożona przez czas trzymania połączenia.
zajęte połączenia ≈ żądania na sekundę × czas trzymania połączenia na żądanie
Nie znajdziesz tego wzoru w dokumentacji HikariCP ani na wiki PostgreSQL — to zwykła teoria kolejek, ale świetnie porządkuje myślenie. Prosta arytmetyka, nie benchmark:
| ruch | czas trzymania połączenia | średnio zajętych |
|---|---|---|
| 400 req/s | 5 ms (2 zapytania po kluczu) | 2 |
| 400 req/s | 25 ms (N+1 na 50 wierszach) | 10 |
| 400 req/s | 250 ms (wywołanie HTTP wewnątrz transakcji) | 100 |
Rozmiar puli rośnie liniowo z czasem trzymania, nie z ruchem. Trzeci wiersz to ten sam ruch co pierwszy, tylko ktoś w środku transakcji czeka na zewnętrzne API. Podniesienie puli do 100 nie naprawia tej sytuacji, tylko ją utrwala: baza dostaje sto sesji, z których większość nic nie robi i trzyma blokady.
Dlatego pierwsza zmienna do strojenia to czas trzymania połączenia, a nie liczba połączeń. Batching z poprzedniego wpisu jest w tym sensie optymalizacją puli — skraca czas trzymania z 25 ms do kilku.
Dlaczego 100 jest wolniejsze niż 20
Najczęściej cytowany przykład pochodzi z prezentacji zespołu Oracle Real World Performance, opisanej na wiki HikariCP (About Pool Sizing): zmniejszenie puli z 2048 do 96 połączeń skróciło czas odpowiedzi z około 100 ms do około 2 ms. Przy tym samym sprzęcie i tym samym obciążeniu.
Mechanizm jest prozaiczny. Serwer z ośmioma rdzeniami wykonuje naraz osiem rzeczy. Sto aktywnych zapytań nie dostaje stu rdzeni — dostaje przełączanie kontekstu, walkę o te same blokady (buffer mapping, WAL insert, blokady wierszy) i wypieranie się nawzajem z cache procesora. Przepustowość stoi w miejscu albo spada, a czas pojedynczego zapytania rośnie. Przy puli 20 nadmiarowe żądania czekają w aplikacji, gdzie czekanie jest tanie i widoczne. Przy puli 100 czekają wewnątrz bazy, gdzie czekanie kosztuje CPU i nie ma go w żadnej metryce aplikacji.
Postgres dokłada do tego swoją specyfikę:
- Proces na połączenie. Każde połączenie to osobny proces backendu z własną pamięcią.
work_memprzydziela się na każdy węzeł sortowania i haszowania w każdym zapytaniu, więc sto sesji po kilka takich węzłów przywork_mem = 64MBto scenariusz, w którym w szczycie robi się ciasno nawet na dużej maszynie. - Koszt snapshotów. Do PostgreSQL 13 liczenie migawki MVCC rosło z liczbą sesji, łącznie z bezczynnymi. Wersja 14 przepisała to (praca Andresa Freunda nad
GetSnapshotData()), więc bezczynne połączenia są dziś znacznie tańsze. Tańsze nie znaczy darmowe, a aktywne połączenia dalej konkurują o rdzenie. max_connectionsdomyślnie wynosi 100, z czego 3 zarezerwowane są dla superużytkownika (superuser_reserved_connections), a od PG 16 można dodatkowo odłożyć pulę dla ról zpg_use_reserved_connections(reserved_connections).
Punkt startowy z wiki PostgreSQL (Number Of Database Connections) i HikariCP jest ten sam:
połączenia = (liczba_rdzeni × 2) + efektywna_liczba_dysków
Rdzenie fizyczne, bez hyper-threadingu; „dyski" to zero, gdy dane mieszczą się w cache. Dla ośmiu rdzeni wychodzi około 16–20. Obie strony uczciwie zaznaczają, że to punkt wyjścia, a wiki PostgreSQL wprost pisze, że nikt nie zbadał, jak ten wzór sprawdza się na SSD. Traktuj go jako rząd wielkości, nie jako wynik.
Mnożenie, którego nikt nie robi
Wzór dotyczy sumy połączeń do bazy, a nie jednej instancji aplikacji. Domyślne maximumPoolSize w HikariCP to 10, w node-postgres max to 10. Dwanaście podów po 10 połączeń to 120, czyli więcej niż domyślne 97 dostępnych w Postgresie. Autoskaler, który w szczycie dokłada pody, dokłada też połączenia — dokładnie w momencie, w którym baza ma ich najmniej do oddania. Deployment typu rolling na chwilę podwaja liczbę instancji.
instancje × maximumPoolSize (w szczycie, z rolling update) ≤ max_connections − rezerwy
Jest też dolna granica. Jeśli jedno żądanie potrzebuje naraz kilku połączeń (np. osobna transakcja do logu audytu otwierana w trakcie głównej), za mała pula zakleszcza się sama ze sobą: każdy wątek trzyma jedno połączenie i czeka na drugie. Wiki HikariCP podaje minimum Tn × (Cm − 1) + 1, gdzie Tn to liczba wątków, a Cm to maksymalna liczba połączeń trzymanych naraz przez jeden wątek. Lepiej jednak usunąć przyczynę — dwa połączenia na żądanie to zwykle błąd w granicach transakcji, nie wymaganie.
Ustawienia, które warto zmienić od razu
spring.datasource.hikari:
maximum-pool-size: 16
minimum-idle: 16 # pula o stałym rozmiarze, bez dobierania pod obciążeniem
connection-timeout: 2000 # domyślnie 30 s — żądanie czeka pół minuty na połączenie
max-lifetime: 1500000 # krócej niż każdy timeout ucinający połączenia po drodze
Domyślny connectionTimeout w HikariCP to 30 sekund. Żaden użytkownik nie czeka 30 sekund, więc w praktyce oznacza to, że przeciążona aplikacja trzyma wątki, pamięć i otwarte gniazda dla żądań, które klient dawno porzucił. Krótki timeout na pobranie połączenia zamienia przeciążenie w szybki błąd, który da się obsłużyć (503, retry z backoffem), zamiast w lawinę czekających wątków.
W node-postgres jest gorzej: connectionTimeoutMillis ma domyślnie wartość 0, czyli brak limitu — żądanie czeka na połączenie w nieskończoność. Prisma 7 z adapterem @prisma/adapter-pg dziedziczy te domyślne wartości (max: 10, bez timeoutu), w odróżnieniu od Prismy 6, która miała pool_timeout 10 s i limit liczba_rdzeni × 2 + 1. Kto przeszedł na v7 bez czytania notatek do migracji, ma dziś pulę, która nigdy nie zgłasza błędu, tylko wisi.
PgBouncer w trybie transaction
Gdy instancji aplikacji jest dużo, a połączeń do bazy ma być mało, między nimi staje PgBouncer. W trybie transaction przydziela połączenie z serwerem tylko na czas transakcji, więc tysiąc klienckich połączeń może obsłużyć dwadzieścia połączeń z Postgresem.
Cena jest jedna, ale ma wiele objawów: kolejne transakcje tego samego klienta trafiają na różne procesy backendu. Wszystko, co żyje w sesji, a nie w transakcji, przestaje działać albo — co gorsze — działa, tylko u kogoś innego. Według tabeli zgodności PgBouncera w trybie transaction nigdy nie działają:
| funkcja | co się dzieje | zamiennik |
|---|---|---|
SET / RESET | ustawienie zostaje na backendzie i dostaje je następny klient | SET LOCAL w transakcji, parametry startowe |
LISTEN | rejestracja zostaje na backendzie, powiadomienia trafiają do przypadkowego klienta albo do nikogo | osobne, bezpośrednie połączenie |
kursory WITH HOLD | kursor przeżywa commit na backendzie, którego klient już nie ma | keyset pagination, kursor w obrębie transakcji |
SQL-owe PREPARE / DEALLOCATE | instrukcja przygotowana na innym backendzie niż wykonanie | prepared statements na poziomie protokołu |
tabele tymczasowe z PRESERVE/DELETE ROWS | tabela zostaje na backendzie | ON COMMIT DROP |
| sesyjne advisory locki | pg_advisory_lock przeżywa commit, a unlock trafia na inny backend i po cichu nic nie zwalnia | pg_advisory_xact_lock |
LOAD | moduł ładowany w sesji | konfiguracja serwera |
NOTIFY, zwykłe kursory (WITHOUT HOLD), tabele ON COMMIT DROP i prepared statements na poziomie protokołu działają.
Prepared statements: od 1.21 tak, ale nie wszędzie
Przez lata reguła brzmiała „wyłącz prepared statements przy PgBouncerze", bo sterownik przygotowywał instrukcję na jednym backendzie, a wykonywał na innym. Objaw to znany z praktyki błąd prepared statement "S_1" already exists albo "S_2" does not exist.
PgBouncer 1.21 (październik 2023) dodał obsługę prepared statements na poziomie protokołu (komunikaty Parse/Bind/Close) — śledzi je i odtwarza na backendzie, który akurat dostał klient. Steruje tym max_prepared_statements, czyli rozmiar cache LRU na każde połączenie z serwerem. Do wersji 1.23 włącznie domyślna wartość to 0, czyli funkcja wyłączona; od 1.24 (styczeń 2025) jest to 200. Instalacje z pakietów dystrybucji potrafią być kilka wersji do tyłu, więc zanim wyłączysz obejście, sprawdź SHOW VERSION i SHOW CONFIG.
SQL-owe PREPARE ... ; EXECUTE ... dalej nie działa. Obsługiwana jest tylko ścieżka protokołu, której używają sterowniki.
Co to znaczy dla sterowników:
| sterownik | ustawienie domyślne | z PgBouncerem starszym niż 1.21 | z 1.21+ i max_prepared_statements > 0 |
|---|---|---|---|
| pgjdbc | prepareThreshold=5 | prepareThreshold=0 | domyślne działa |
| asyncpg | statement_cache_size=100 | statement_cache_size=0 | domyślne działa |
| psycopg 3 | prepare_threshold=5 | prepare_threshold=None | od 3.2, z PgBouncerem 1.22+ i libpq 17+; ze starszym libpq prepared_max=None |
| Prisma | — | ?pgbouncer=true | parametr pominąć |
Prisma ma jeszcze jedną pułapkę: migracje muszą iść bezpośrednio do Postgresa, nie przez PgBouncera. W Prismie 7 bezpośredni URL ustawia się w datasource.url w prisma.config.ts, a URL przez PgBouncera trafia do adaptera. Pomylenie ich kończy się błędem prepared statement "s0" already exists przy migrate.
SET search_path i przeciekanie między klientami
To jest pułapka, w której wydajność zamienia się w bezpieczeństwo. Popularny wzorzec wielodostępności — schemat na najemcę i SET search_path TO tenant_42 na początku żądania — w trybie transaction przestaje izolować najemców.
PgBouncer śledzi parametry sesji i odtwarza je klientowi na każdym backendzie, ale tylko te, o których Postgres go informuje. search_path Postgres raportuje dopiero od wersji 18, a PgBouncer zaczął to wykorzystywać w wersji 1.26 (wrzesień 2026). Na starszych wersjach PgBouncer zna tylko search_path z pakietu startowego. SET search_path wykonany po połączeniu zostaje na backendzie i dostaje go następny klient, który na ten backend trafi.
Konsekwencja: żądanie najemcy A wykonuje zapytanie bez kwalifikowanej nazwy schematu, a backend ma jeszcze search_path najemcy B. Nic się nie wywraca, zapytanie zwraca poprawnie wyglądające dane — tylko cudze. To ten sam rodzaj błędu co globalny DataLoader z poprzedniego wpisu: cache albo stan współdzielony między żądaniami bez klucza najemcy.
server_reset_query (domyślnie DISCARD ALL) tego nie ratuje, bo w trybie transaction w ogóle nie jest wykonywany. Można go wymusić przez server_reset_query_always = 1, ale to dodatkowy round-trip na każdą transakcję. Rozwiązania, które działają niezależnie od wersji:
BEGIN;
SET LOCAL search_path TO tenant_42; -- znika przy COMMIT/ROLLBACK
-- albo, gdy wartość przychodzi jako parametr:
SELECT set_config('search_path', $1, true); -- true = lokalnie dla transakcji
...
COMMIT;
Lepiej jeszcze w ogóle nie opierać izolacji na stanie sesji: kwalifikowane nazwy tabel albo Row-Level Security z ustawieniem kontekstu przez SET LOCAL. To samo dotyczy SET statement_timeout, SET role i każdego SET bez LOCAL — ten ostatni jest najgroźniejszy, bo zmienia uprawnienia następnego klienta.
LISTEN potrzebuje osobnego połączenia
Proces, który nasłuchuje powiadomień (LISTEN orders_changed), potrzebuje jednego, długo żyjącego połączenia z sesją. Przez PgBouncera w trybie transaction rejestracja zostaje na backendzie, który po commicie wraca do puli. Powiadomienie dostanie przypadkowy klient albo nikt — i nie ma żadnego błędu, po prostu zdarzenia przestają przychodzić.
Typowe rozwiązanie to dwa connection stringi: aplikacja przez PgBouncera, nasłuch i migracje bezpośrednio do Postgresa (albo przez osobną pulę PgBouncera w trybie session). NOTIFY może zostać na zwykłej ścieżce, bo jest wysyłany przy commicie transakcji.
Pułapka: dwie pule to dwie kolejki
Typowa architektura po dodaniu PgBouncera wygląda tak:
pod 1: HikariCP (10) ─┐
pod 2: HikariCP (10) ─┼─▶ PgBouncer (default_pool_size = 20) ─▶ Postgres
... │
pod 12: HikariCP (10) ┘
Sto dwadzieścia połączeń aplikacyjnych, dwadzieścia serwerowych. To nie jest pula 120 plus pula 20. To dwie kolejki jedna za drugą, a przepustowość wyznacza ta węższa.
W tym układzie dzieją się trzy nieintuicyjne rzeczy.
Oczekiwanie przenosi się w niewidoczne miejsce. HikariCP trzyma otwarte połączenia do PgBouncera, więc pobranie połączenia z puli aplikacji jest natychmiastowe. Wąskim gardłem jest teraz pula PgBouncera, a czekanie na serwer zaczyna się dopiero po wysłaniu zapytania. Z perspektywy aplikacji wygląda to jak wolne zapytanie: metryka hikaricp.connections.acquire jest płaska, a czas zapytań rośnie. Zespół zaczyna optymalizować SQL, który na samym Postgresie wykonuje się w 2 ms.
Timeouty się nie składają. HikariCP rezygnuje z pobrania połączenia po 30 sekundach, ale to dotyczy tylko jego własnej kolejki. PgBouncer ma query_wait_timeout domyślnie 120 sekund — klient wisi w jego kolejce dwie minuty, zanim zostanie rozłączony. Do tego dochodzi timeout żądania HTTP na load balancerze, zwykle krótszy od obu. Efekt: użytkownik dawno dostał 504, a zapytanie wciąż czeka w PgBouncerze, a potem i tak się wykonuje, zajmując miejsce żądaniom, które jeszcze kogoś obchodzą.
Pula aplikacji przestaje chronić bazę, ale dalej kosztuje. Skoro i tak limituje PgBouncer, duża pula w aplikacji tylko wpuszcza więcej żądań do kolejki, której nie widać.
Jak to poukładać:
- Jedno wąskie gardło, świadomie wybrane. Kolejka powinna być w jednym miejscu, monitorowana i z sensownym timeoutem. Przy PgBouncerze tym miejscem jest PgBouncer, a pula aplikacji służy głównie do ograniczenia liczby wątków, które jedna instancja wpuszcza do bazy.
- Timeouty od środka na zewnątrz rosną.
query_wait_timeoutw PgBouncerze i timeout pobrania połączenia w aplikacji krótsze od timeoutu żądania HTTP. Od wersji 1.26 PgBouncer pozwala ustawićquery_wait_timeoutper użytkownik i baza, więc ruch interaktywny może mieć 2 sekundy, a raporty dłużej. - Ruch z różnymi wymaganiami w osobnych pulach. Nocny raport trzymający połączenie przez minutę i endpoint API odpowiadający w 20 ms nie powinny stać w tej samej kolejce. Osobny użytkownik bazy z własnym
pool_sizew PgBouncerze (per użytkownik od 1.23) rozdziela je bez dokładania infrastruktury. max_lifetimew aplikacji krótszy od każdego mechanizmu, który ucina połączenia po drodze (timeouty bezczynności PgBouncera, firewalla, load balancera). Inaczej aplikacja dostaje z puli połączenie, które ktoś już zamknął.
Metryka: czas oczekiwania na połączenie, nie czas zapytania
Czas zapytania w APM nie mówi, czy pula jest za mała, bo zawiera w sobie czekanie, które dzieje się w różnych miejscach. Mierzysz trzy rzeczy osobno: ile czekano na połączenie, jak długo je trzymano i ile żądań stoi w kolejce teraz.
| warstwa | czekanie na połączenie | długość kolejki | czas trzymania |
|---|---|---|---|
| HikariCP (Micrometer) | hikaricp.connections.acquire | hikaricp.connections.pending | hikaricp.connections.usage |
| node-postgres | własny pomiar wokół pool.connect() | pool.waitingCount | własny pomiar do release() |
PgBouncer SHOW STATS | avg_wait_time, total_wait_time (µs) | — | avg_xact_time (µs) |
PgBouncer SHOW POOLS | maxwait (s) + maxwait_us | cl_waiting | — |
Dodatkowo licznik hikaricp.connections.timeout: każdy jego przyrost to żądanie, które dostało błąd, bo nie doczekało się połączenia. W Prometheusie metryki czasowe dostają przyrostek _seconds, np. hikaricp_connections_acquire_seconds_max.
hikaricp.connections.usage to czas trzymania z prawa Little'a i najważniejsza z tych liczb. Gdy rośnie, a czas zapytań na serwerze nie, połączenie jest trzymane przez coś, co nie jest bazą: wywołanie HTTP, serializację, logikę w pętli. Po stronie Postgresa to samo widać jako sesje w stanie idle in transaction:
SELECT state, count(*), max(now() - state_change) AS najdluzej
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state;
Sesja idle in transaction trzyma połączenie, blokady i horyzont xmin, nie wykonując żadnej pracy. Bezpiecznik po stronie bazy to idle_in_transaction_session_timeout, a po stronie PgBouncera transaction_timeout (od 1.25) — oba zamykają sesję, która zapomniała zakończyć transakcję, zamiast pozwolić jej zatkać pulę.
Od wersji 1.25 PgBouncer wysyła klientowi komunikat NOTICE, gdy ten czeka w kolejce dłużej niż 5 sekund (query_wait_notify). Jeśli sterownik loguje NOTICE, to najtańszy możliwy alarm, że wąskie gardło jest w PgBouncerze, a nie w zapytaniu.
Jak dobrać rozmiar, zamiast zgadywać
- Zacznij od małej puli — rzędu
rdzenie × 2po stronie bazy, podzielone na instancje aplikacji z zapasem na rolling update. - Obciąż i patrz na dwie rzeczy naraz: czas oczekiwania na połączenie i wykorzystanie CPU bazy.
- Zwiększaj tylko wtedy, gdy rośnie oczekiwanie, a baza ma zapas CPU i I/O. Jeśli baza jest już nasycona, większa pula przeniesie kolejkę z aplikacji do bazy i pogorszy czasy.
- Gdy oczekiwanie rośnie, sprawdź najpierw
connections.usage. Długi czas trzymania to problem kodu (N+1, I/O w transakcji), który większa pula tylko ukryje. - Powtórz pomiar po każdej zmianie. Wartość dobra dziś przestaje taką być, gdy zmieni się mix zapytań.
Co z tego zabrać
- Pula to kolejka. Kolejka istnieje zawsze, rozmiar puli decyduje tylko o tym, czy jest w miejscu, które widzisz.
- Potrzebne połączenia ≈ ruch × czas trzymania. Skracaj czas trzymania, zanim zaczniesz dokładać połączenia.
- Licz połączenia sumarycznie: instancje × pula, w szczycie i w trakcie rolling update, wobec
max_connectionsminus rezerwy. - Ustaw krótki timeout pobrania połączenia. Domyślne 30 s w HikariCP i brak limitu w node-postgres (a więc i w Prismie 7 z
adapter-pg) zamieniają przeciążenie w zawieszenie. - W PgBouncerze w trybie transaction nic, co żyje w sesji, nie działa:
SET,LISTEN, kursoryWITH HOLD, sesyjne advisory locki, SQL-owePREPARE. - Prepared statements przez PgBouncera wymagają 1.21+ i
max_prepared_statements> 0 (domyślnie od 1.24). Sprawdź wersję, zanim usunieszprepareThreshold=0czystatement_cache_size=0. SET search_pathbezLOCALza PgBouncerem to przeciek między najemcami, nie błąd wydajności. UżywajSET LOCALalboset_config(..., true).- Dwie pule to dwie kolejki. Wybierz jedno wąskie gardło, a timeouty ustaw tak, żeby rosły od bazy w stronę klienta.
- Mierz czas oczekiwania na połączenie i czas trzymania, nie czas zapytania. Płaski
acquireprzy rosnących czasach zapytań to znak, że kolejka przesunęła się do PgBouncera.