Vyčerpanie Connection Poolu: Tichý Spúšťač Výpadkov
Uz som pozeral na zelene dashboardy, kym requesty timeoutovali, a bol to pool. Toto je jeden z najčastejších dôvodov, prečo appka „visí“, aj keď databáza vyzerá úplne v pohode: vyčerpaný connection pool.
Keď sú všetky spojenia obsadené—buď čakajú na pomalý dotaz, alebo ich drží zabudnutá/otvorená transakcia—nové requesty sa k databáze vôbec nedostanú. Len stoja v rade, kým nenarazia na timeout, a výpadok je na svete.
Mne sa to raz tvárilo ako „mystery outage“: databáza mala nízke CPU a grafy vyzerali zdravo. Nakoniec za to mohol jeden pomalý dotaz, ktorý držal spojenie ~45 sekúnd—a jediná metrika, ktorá to prezradila, bola tá, ktorú sme ani nesledovali: pending požiadavky na spojenie v poole.
Testované na: PostgreSQL 15, HikariCP 5.0, pgbouncer 1.21
Problém
Ako Connection Pooly Fungujú
Skôr než sa pustím do riešení, oplatí sa rozumieť, prečo pooly vôbec existujú. Nadviazanie databázového spojenia je drahé – typicky 50–100 ms (handshake, autentifikácia, nastavenie session). Pre request, ktorý má byť hotový za ~50 ms, je to úplne mimo.
Pool to rieši tak, že udržiava sadu vopred otvorených spojení. Keď aplikácia potrebuje databázu, „požičia“ si spojenie z poolu, vykoná dotaz a spojenie vráti späť. Drahé otváranie spojenia sa deje raz pri štarte poolu, nie pri každom requeste.
Bez poolu:
┌─────────┐ ┌──────────┐
│ Request │ ──vytvor conn──→ │ Database │
│ │ ←───odpoveď──── │ │
│ │ ──zatvor conn──→ │ │
└─────────┘ └──────────┘
Náklad: ~50ms za nadviazanie spojenia
S poolom:
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Request │ ──→ │ Pool │ ─→ │ Database │
│ │ ←── │ (reuse) │ ←─ │ │
└─────────┘ └──────────┘ └──────────┘
Náklad: ~0ms (spojenie už otvorené)
Pool drží fixný počet spojení – napríklad 10. Keď príde request, zoberie si jedno spojenie, spraví dotaz, a potom ho vráti. O pár milisekúnd ho môže použiť ďalší request. Táto recyklácia je dôvod, prečo pooly zvyšujú výkon aj stabilitu.
Keď sa Pool Vyčerpá
Fixná veľkosť poolu je zároveň jeho sila aj slabina. S 10 spojeniami vieš mať maximálne 10 paralelných databázových operácií. Kým dotazy trvajú krátko, je to v poriadku. Problém začne, keď sa dotazy spomalia.
Ak dotazy bežne trvajú 10 ms, ale niečo ich spomalí na 500 ms (napríklad chýbajúci index na často volanej ceste), priepustnosť spadne z ~1000 dotazov/s na ~20 dotazov/s. Všetko ostatné sa začne hromadiť v rade – čaká sa už nie na databázu, ale na voľné spojenie v poole.
Scenár: Veľkosť poolu = 10, Request rate = 100/sec
Normálna prevádzka (10ms dotazy):
10 spojení × 100 dotazov/sec každé = 1000 dotazov/sec ✓
Objaví sa pomalý dotaz (500ms):
10 spojení × 2 dotazy/sec každé = 20 dotazov/sec
80 requestov/sec čaká na spojenie... ✗
Kaskáda vyčerpania poolu:
┌─────────────────────────────────────────────────────────────┐
│ T+0s: Pomalý dotaz začne na spojení 1 │
│ T+0.5s: Spojenia 2-10 tiež trafia pomalé dotazy │
│ T+1s: VŠETKY spojenia sú obsadené │
│ T+1s+: Nové requesty ČAKAJÚ na spojenie │
│ T+30s: Timeout na získanie spojenia → requesty zlyhajú │
│ Výsledok: Totálny výpadok napriek "funkčnej" databáze │
└─────────────────────────────────────────────────────────────┘
Toto je dôvod, prečo je tento typ výpadku tak zradný. Jeden pomalý dotaz nemusí byť problém – ostatné spojenia ešte stíhajú. Ale keď sa pomalý vzor dotkne väčšiny trafficu, postupne sa „zasekne“ celý pool. A keď je pool plný, už sa len čaká… až kým nezačnú padať timeouty.
Úniky Spojení: Pomalý Jed
Ešte horšie ako pomalé dotazy sú úniky spojení (connection leaks). Únik znamená, že kód si požičia spojenie z poolu, ale nikdy ho nevráti. Niekde padne výnimka a close() sa nevolá. Niekto zabudne zavrieť transakciu. Alebo ostane otvorená transakcia bez COMMIT/ROLLBACK.
Úniky sú zákerné, lebo degradácia je pomalá. Ak „stratíš“ jedno spojenie za minútu, prvé minúty si nič nevšimneš. Po 10 minútach je pool prázdny a aplikácia visí. Keď začneš debugovať, únik už často neprebieha – vidíš len vyčerpaný pool a nevieš prečo.
Kľúčová diagnostika je sledovať pool v čase. Ak počet idle spojení postupne klesá aj mimo špičky, veľmi pravdepodobne máš leak.
Detekcia Vyčerpania Poolu
Metriky Aplikácie
Najdôležitejšia metrika je pending – počet požiadaviek, ktoré čakajú na spojenie. V zdravom stave má byť takmer vždy nula. Ak je pending nenulové, si na kapacite. Ak je nenulové dlhšie, pool sa vyčerpáva.
// HikariCP exponuje tieto metriky
hikaricp_connections_active # Momentálne používané
hikaricp_connections_idle # Dostupné
hikaricp_connections_pending # Čakajúce na spojenie
// Alert keď:
// pending > 0 dlhší čas = vyčerpanie
// active == max dlhší čas = na kapacite
Ja typicky alertujem na pending > 0 dlhšie než 30 sekúnd. Krátke špičky sa môžu stať, ale ak pending drží, je to problém. Zároveň je dobré sledovať, či active nie je dlhodobo na maxime – to znamená, že stačí malý slowdown a spadneš cez hranu.
Strana PostgreSQL
Z pohľadu databázy chceš vidieť, čo tie spojenia robia:
-- Spojenia per aplikácia
SELECT
application_name,
state,
count(*) as connections,
avg(EXTRACT(EPOCH FROM (now() - query_start))) as avg_query_time
FROM pg_stat_activity
WHERE datname = 'mydb'
GROUP BY application_name, state;
-- Dlho-bežiace dotazy držiace spojenia
SELECT
pid,
now() - query_start as duration,
state,
query
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start < now() - interval '30 seconds'
ORDER BY duration DESC;
Druhý dotaz je počas incidentu zlatý. Hneď uvidíš, čo drží spojenia „najdlhšie“.
Sleduj aj state. active znamená, že dotaz beží. idle in transaction je červená vlajka – transakcia je otvorená, ale nič nerobí, a aj tak blokuje spojenie. Veľmi často je to bug v aplikácii.
Incident Runbook (15 minút)
Keď mám incident a podozrenie na vyčerpaný pool, idem podľa tohto:
- Potvrď, že je to pool.
pending > 0, prípadneactive == max. - Nájdi „žrúta“ spojení. Pozri dlhé dotazy v
pg_stat_activity. - Skontroluj úniky. Hľadaj
idle in transactiona trend (idle klesá v čase). - Rýchla mitigácia. Najprv
pg_cancel_backend, až potompg_terminate_backend. - Zastav retry búrku. Keď je pool plný, nech requesty padajú rýchlo (fail-fast), nie po desiatkach sekúnd čakania.
-- Najprv ruš dlhé dotazy (bezpečnejšie)
SELECT pg_cancel_backend(pid)
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start < now() - interval '30 seconds';
-- Ak to nepomôže, ukonči spojenie (tvrdší zásah)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < now() - interval '5 minutes';
Kontraintuitívne: „pridám viac app inštancií“ môže situáciu zhoršiť, ak každá inštancia má vlastný pool. V incidente radšej znižujem concurrency (alebo zhadzujem load) než slepo násobím počet spojení do databázy.
Riešenia
1. Správne Dimenzovanie Poolu
Klasická heuristika (nie dogma) je z PostgreSQL wiki:
Vzorec: connections = (počet_jadier * 2) + effective_spindle_count
Pre SSD:
connections ≈ počet_jadier * 2
Pre cloud databázu (4 vCPU):
connections = 4 * 2 = 8 na inštanciu
S 10 app inštanciami:
Celkom = 8 * 10 = 80 spojení
PostgreSQL max_connections = 100 (s rezervou)
Ber to ako štartovací bod a potvrď si to záťažovým testom. Dôležitý insight je, že viac spojení často výkon nezlepší – skôr zhorší (pamäť na serveri, context switching, overhead).
# HikariCP konfigurácia
spring:
datasource:
hikari:
maximum-pool-size: 8
minimum-idle: 2
connection-timeout: 10000 # 10s max čakanie
idle-timeout: 300000 # 5min idle pred zatvorením
max-lifetime: 1800000 # 30min max vek spojenia
leak-detection-threshold: 30000 # 30s leak warning
leak-detection-threshold je pre mňa must-have. Keď sa spojenie nevráti do 30 sekúnd, HikariCP zaloguje warning so stack traceom, odkiaľ sa spojenie požičalo. Toto vie leak nájsť prekvapivo rýchlo.
2. Detekcia Únikov Spojení
Leak detekciu sa oplatí mať zapnutú všade, nie len v produkcii. Chyby sa tak chytia už v dev/test prostredí.
// HikariCP leak detekcia
@Configuration
public class DataSourceConfig {
@Bean
public HikariDataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setLeakDetectionThreshold(30000); // 30 sekúnd
// Ak spojenie nie je vrátené do 30s, log stack trace
return new HikariDataSource(config);
}
}
// Typické leak patterny:
// 1. Chýbajúci try-with-resources
// 2. Výnimka pred close()
// 3. Zabudnutý commit/rollback pri @Transactional
Try-with-resources je najjednoduchšia obrana:
// ZLE - leak pri výnimke
Connection conn = dataSource.getConnection();
// ... kód ktorý môže hodiť exception ...
conn.close();
// DOBRE - vždy sa zavrie, aj pri výnimke
try (Connection conn = dataSource.getConnection()) {
// ...
}
V Pythone (SQLAlchemy) sa dá chytiť „dlho požičané spojenie“ cez eventy:
from sqlalchemy import event
from sqlalchemy.pool import Pool
import traceback
import time
checkout_times = {}
@event.listens_for(Pool, "checkout")
def on_checkout(dbapi_conn, connection_record, connection_proxy):
checkout_times[id(dbapi_conn)] = {
"time": time.time(),
"stack": traceback.format_stack(),
}
@event.listens_for(Pool, "checkin")
def on_checkin(dbapi_conn, connection_record):
checkout_info = checkout_times.pop(id(dbapi_conn), None)
if checkout_info and time.time() - checkout_info["time"] > 30:
print(f"Long checkout: {checkout_info['stack']}")
Stack trace ti povie presnú cestu kódom, ktorá drží spojenie príliš dlho.
3. Query/Statement Timeouty
Timeouty bránia tomu, aby dotazy bežali „navždy“ a držali spojenie donekonečna. Je dobré mať ich na viacerých vrstvách (defense in depth).
-- PostgreSQL: statement timeout
SET statement_timeout = '30s';
-- Alebo per rola (dobrá poistka)
ALTER ROLE app_user SET statement_timeout = '30s';
Role-level nastavenie funguje aj vtedy, keď to niekto zabudne nastaviť v aplikácii.
# HikariCP s timeoutmi (driver-specific)
spring:
datasource:
hikari:
data-source-properties:
socketTimeout: 30000
connectTimeout: 10000
socketTimeout je posledná poistka – ak databáza nereaguje, driver spojenie ukončí.
4. PgBouncer pre Connection Multiplexing
Ak máš veľa app inštancií, ale databáza má rozumný limit na počet spojení, pomôže PgBouncer:
# pgbouncer.ini
[databases]
mydb = host=localhost port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
reserve_pool_size = 5
# transaction mode: spojenie vrátené po každej transakcii
# Umožní 1000 app spojení s len 20 DB spojeniami
PgBouncer je medzi aplikáciou a databázou. Aplikácia sa pripája na PgBouncer (napr. 1000 klientov), ale PgBouncer drží len malý počet reálnych spojení do databázy (napr. 20).
Transaction mode je najagresívnejšie poolovanie, ale má obmedzenia (prepared statements a session-level nastavenia nemusia fungovať, lebo každú transakciu môžeš dostať iné server-side spojenie). Ak to potrebuješ, použi session mode.
5. Circuit Breaker na Získanie Spojenia
Keď je pool vyčerpaný, je často lepšie fail-fast než čakať desiatky sekúnd v rade. Circuit breaker vie zabrániť kaskáde timeoutov:
@Bean
public CircuitBreaker connectionCircuitBreaker() {
return CircuitBreaker.of("db-pool", CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.build());
}
public Connection getConnection() {
return circuitBreaker.executeSupplier(() -> dataSource.getConnection());
}
Ak získanie spojenia zlyháva napr. viac než 50% času, breaker sa otvorí a ďalšie pokusy zlyhajú hneď. Tým sa zastaví reťaz: timeouty na spojení → timeouty requestov → retry búrky → ešte viac loadu.
Monitoring
# Prometheus alerty
- alert: ConnectionPoolExhausted
expr: |
hikaricp_connections_pending > 0
for: 1m
annotations:
summary: "Connection pool má čakajúce requesty"
- alert: ConnectionPoolAtCapacity
expr: |
hikaricp_connections_active / hikaricp_connections_max > 0.9
for: 5m
annotations:
summary: "Connection pool je na 90%+ kapacite"
- alert: LongRunningTransactions
expr: |
pg_stat_activity_max_tx_duration{state!="idle"} > 30
for: 5m
annotations:
summary: "PostgreSQL má ne-idle transakcie bežiace > 30s"
Prvý alert chytá aktívne vyčerpanie (requesty čakajú). Druhý chytá „tesne pred pádom“. Tretí je pomocný – dlhé transakcie často držia spojenia, ktoré potom chýbajú v poole. (Názvy metrík/labelov závisia od exporteru – prispôsob si to podľa toho, čo reálne exportuješ.)
Checklist
## Connection Pool Konfigurácia
### Dimenzovanie
- [ ] Vypočítaj veľkosť poolu: (jadrá × 2) + spindles
- [ ] Nastav connection-timeout (10-30s)
- [ ] Povoľ leak detekciu (30s threshold)
### Timeouty
- [ ] Nastav statement_timeout v PostgreSQL
- [ ] Konfiguruj socket timeout v poole
- [ ] Pridaj circuit breaker na získanie spojenia
### Monitoring
- [ ] Exportuj pool metriky (active, idle, pending)
- [ ] Alert na pending > 0
- [ ] Sleduj connection checkout duration
### Architektúra
- [ ] Zváž PgBouncer pri vysokom počte spojení
- [ ] Použi transaction pool mode, keď to dáva zmysel
- [ ] Pridaj timeouty aj na aplikačnej vrstve
Záver
Vyčerpanie connection poolu spôsobuje tiché výpadky, lebo klasické databázové metriky vyzerajú „v pohode“. Problém je často v rozhraní medzi appkou a databázou – v poole.
Čo si z toho beriem:
- Dimenzuj pooly rozumne – viac nie je lepšie, začni heuristikou a over load testom
- Chyť úniky spojení – leak detekcia a stack traces šetria hodiny
- Nastav timeouty – nech spojenia nie sú držané donekonečna
- Použi PgBouncer keď potrebuješ veľa app spojení, ale málo DB spojení
- Sleduj
pending– to je tvoj kanárik v bani
Ak máš pending niekedy nenulové, si bližšie k hrane, než si myslíš.
Súvisiace články
- PostgreSQL Idle in Transaction Playbook - Jeden z najčastejších „leak“ patternov
- Redis Memory Fragmentácia - Problémy databázových zdrojov
- gRPC Deadline Propagácia - Timeout patterns
Referencie
- PostgreSQL Wiki – Number of Database Connections: https://wiki.postgresql.org/wiki/Number_Of_Database_Connections
- PostgreSQL Docs –
pg_stat_activity: https://www.postgresql.org/docs/current/monitoring-stats.html - HikariCP: https://github.com/brettwooldridge/HikariCP
- PgBouncer: https://www.pgbouncer.org/
- SQLAlchemy – Pool Events: https://docs.sqlalchemy.org/en/20/core/pooling.html#pool-events
- Resilience4j – CircuitBreaker: https://resilience4j.readme.io/docs/circuitbreaker
Súvisiace články
PostgreSQL checkpoint špičky: prečo p99 exploduje každých N minút
Reprodukovateľný postup na diagnostiku a odstránenie checkpoint-induced latency špičiek pomocou pgbench, pg_stat_bgwriter a WAL/IO budgetu.
Funguje v psql, nestabilne v produkcii: Ticha vrazda LISTEN/NOTIFY cez PgBouncer
PostgreSQL LISTEN/NOTIFY funguje perfektne v lokalnom testovani ale notifikacie nahodne prestanu prichodit v produkcii. Vinik: transaction pooling ticho prideluje vase spojenie niekomu inemu.
EXPLAIN vám klamal: PostgreSQL Prepared Statement Plan Cliff
Váš EXPLAIN vyzerá perfektne ale produkcia horí. Vinník: PostgreSQL ticho prepol z custom plánu na generic plán po dostatočnom počte vykonaní, a generic plán je katastrofálne zlý.
GIN Index Pending List Overflow: Rýchle Zápisy, Pomalé Vyhľadávanie
Full-text search bol rýchly, teraz je pomalý. Príčina: GIN index pending list narástol obrovský počas bulk insertov a každé vyhľadávanie musí teraz skenovať nezoradené pending záznamy.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.