špatný první krok
Spousta Django týmů narazí na FATAL: remaining connection slots are reserved for non-replication superuser connections, zpanikaří, přidá dvě další app instance, sleduje, jak CPU zůstává skoro stejné, zatímco databáze je ještě víc přetížená, a nazve to scaling. To je přesně opačně. Každý další Gunicorn worker, každý další Kubernetes pod, každý další Celery proces jen zvětšuje dopad problému, pokud vaše aplikace otevírá přímé Postgres sessions a drží je déle, než musí. Nepřidali jste throughput, jen jste rozmnožili idle sockety a backend procesy.
Postgres bere connections jako reálnou práci. Každý backend proces žere paměť, čas scheduleru, interní bookkeeping a překvapivě hodně provozní pozornosti ve chvíli, kdy se dostanete za rozumný rozsah pro daný server. Na obyčejném db.t4g.large s 8 GB RAM je max_connections = 500 jen proto, že někdo chtěl rezervu, spolehlivý recept na horší cache hit rate a hlučnější latenci. Většina startup workloadů nepotřebuje 500 aktivních databázových sessions. Potřebuje 20 až 50 aktivních query, při traffic spike možná 80, a rozumnou frontu před nimi.
Tou frontou je PgBouncer. Konkrétně transaction pooling. Ne session pooling, který si nechává moc starých problémů, a ne další vrstvu aplikační magie. PgBouncer sedí mezi Django a Postgres, levně přijme velké množství client connections a pak je multiplexuje na mnohem menší počet skutečných server connections. Tahle jediná změna vám obvykle dá větší rezervu než zdvojnásobení app fleet, protože bottleneck nebyl v Python request handlingu, ale ve správě connections.
Ve Steezr tenhle pattern vidíme pořád dokola, hlavně u customer portálů a ERP-style Django systémů: aplikační vrstva je očividně nevyužitá, request latency skáče nahoru, RDS hlásí saturaci connections a někdo si myslí, že řešení je horizontal scaling. Většinou není. Nejdřív opravte connections, potom řešte pomalé query, potom kapacitu aplikace. Na tom pořadí záleží.
proč vyhrává transaction pooling
PgBouncer má několik režimů a pro většinu Django aplikací řeší problém čistě jen jeden z nich. Použijte pool_mode = transaction. Session pooling dává každému klientovi dedikovanou server connection po celou dobu session, takže si necháváte velkou část plýtvání, kterého se chcete zbavit. Statement pooling je na běžné použití Django příliš restriktivní. Transaction pooling přidělí server connection jen na dobu transakce a hned potom ji vrátí do poolu. O to jde.
Django s tím většinou funguje bez problémů, pokud přestanete spoléhat na databázový stav navázaný na konkrétní session. Cokoli předpokládá, že connection zůstane stejná navždy, vás dřív nebo později kousne, většinou dost nenápadně. Klasický příklad jsou server-side cursors. Jestli je používáte přes .iterator() nad velkými querysets, nastavte v Django DISABLE_SERVER_SIDE_CURSORS = True, pokud jedete přes transaction pooler. Prepared statements se taky můžou chovat divně podle driveru a konfigurace, protože PgBouncer může další transakci poslat přes jinou backend connection. S moderním psycopg setupem se to dá normálně zvládnout, jen musíte vědět, které feature závisejí na tom, že držíte stejnou connection.
Minimální konfigurace PgBounceru 1.22 může vypadat takhle:
1[databases]2app = host=postgres.internal port=5432 dbname=app34[pgbouncer]5listen_addr = 0.0.0.06listen_port = 64327auth_type = scram-sha-2568auth_file = /etc/pgbouncer/userlist.txt9pool_mode = transaction10max_client_conn = 100011default_pool_size = 4012reserve_pool_size = 1013reserve_pool_timeout = 314server_reset_query = DISCARD ALL15server_check_delay = 3016ignore_startup_parameters = extra_float_digits,options17admin_users = pgbouncer18stats_users = pgbouncer
default_pool_size = 40 neznamená 40 uživatelů celkem. Znamená to až 40 server connections na každou kombinaci databáze a uživatele, což je důležité ve chvíli, kdy dělíte traffic mezi více rolí. Přínos je ale obrovský: 300 Django client connections můžete bezpečně stáhnout do 40 nebo 50 reálných Postgres backendů a databáze přestane trávit polovinu života context switchingem.
opravte django a gunicorn
PgBouncer pomůže jen tehdy, když se s ním zbytek stacku nepere. Django defaulty a náhodná Gunicorn nastavení posbíraná z blogpostů jsou často přesně ten důvod, proč si týmy myslí, že potřebují víc infrastruktury. Nepotřebují. Potřebují méně dlouho žijících procesů, které drží resources skoro bez užitku.
Začněte u Django. Nasměrujte DATABASES na PgBouncer, ne přímo na Postgres, vypněte persistent connections na straně Django a vypněte server-side cursors, pokud používáte transaction pooling:
1DATABASES = {2 "default": {3 "ENGINE": "django.db.backends.postgresql",4 "NAME": "app",5 "USER": "app",6 "PASSWORD": os.environ["DB_PASSWORD"],7 "HOST": os.environ.get("DB_HOST", "pgbouncer"),8 "PORT": int(os.environ.get("DB_PORT", "6432")),9 "CONN_MAX_AGE": 0,10 "DISABLE_SERVER_SIDE_CURSORS": True,11 "OPTIONS": {12 "connect_timeout": 5,13 "application_name": "web",14 },15 }16}
CONN_MAX_AGE = 0 lidi překvapí, protože roky poslouchali, že persistent connections jsou dobře. Při direct-to-Postgres možná. Přes PgBouncer vám persistence na straně Django skoro nic nepřinese a jen zamlží, co se vlastně děje. Pooling ať si řídí PgBouncer.
Teď Gunicorn. Typická špatná konfigurace vypadá třeba jako workers = 8 na boxu se 2 vCPU, protože někdo bez přemýšlení převzal (2 x cores) + 1, k tomu přihodil threads = 4 a pak to nasadil do čtyř podů. Gratulace, právě jste si vyrobili 128 potenciálních request handlerů, které se můžou sesypat na databázi. Pokud každý request udělá dvě ORM volání a jedno z nich čeká na I/O, nepostavili jste kapacitu, ale problém s frontami.
Počet nastavte podle CPU a reálného request profilu. Pro běžnou Django aplikaci na 2 vCPU bývá workers = 3 nebo 4 a threads = 2 mnohem lepší výchozí bod než nafukování workerů. Třeba takhle:
1bind = "0.0.0.0:8000"2workers = 43threads = 24worker_class = "gthread"5timeout = 606graceful_timeout = 307keepalive = 58max_requests = 20009max_requests_jitter = 200
Pak si spočítejte všechno napříč procesy: web, Celery workery, management joby, async consumery. Jestli váš cluster umí vygenerovat 180 souběžných execution slotů, které můžou sahat do databáze, PgBouncer i Postgres tomu musí odpovídat, nebo musíte snížit concurrency. Většina týmů si tohle nikdy nespočítá. Měla by.
pool sizing, který přežije provoz
Sizing poolu nepotřebuje žádnou mystiku. Potřebuje tužku a papír. Začněte od databáze, ne od aplikace. Řekněme, že Postgres má max_connections = 200. Odečtěte rezervu pro admin přístup, migrace, read replicas, maintenance tasky a cokoli, co obchází PgBouncer. Já si obvykle hned rezervuju 30 až 40. Zůstane vám zhruba 160 použitelných connections.
Pak se ptejte, kolik aktivních query vaše databáze opravdu zvládne, než začne růst latence. Na menším production serveru s běžným OLTP trafficem často zjistíte, že sweet spot je někde mezi 32 a 48 aktivními transakcemi, zatímco při 100 už se jen víc čeká. Nastavte PgBouncer podle toho reálného čísla, ne podle teoretického maxima. Třeba takhle, jedna Django aplikace, jedna primární databáze, nějaký Celery traffic:
1default_pool_size = 402reserve_pool_size = 103max_db_connections = 604max_client_conn = 800
max_client_conn může být výrazně větší, protože client connections v PgBounceru jsou levné. Drahé jsou server connections. Chcete, aby se fronta tvořila v PgBounceru, kde je viditelná a pod kontrolou, ne v Postgresu, kde každý další backend pálí paměť.
Pomůže i hrubá kontrola reality. Řekněme, že běží 3 web pody, každý se 4 Gunicorn workery a 2 thready, plus 2 Celery pody s concurrency 6. To je 3 x 4 x 2 = 24 potenciálních web execution slotů a 2 x 6 = 12 task slotů, dohromady 36 jednotek, které můžou sahat do databáze. Pokud jich najednou aktivně queryuje jen polovina, pool o velikosti 20 může stačit. Pokud máte v request patternu pomalé reporty nebo těžké admin obrazovky, bude 20 už těsné a 40 je bezpečnější. Ověříte to přes SHOW POOLS; a SHOW STATS; v PgBouncer admin konzoli.
Sledujte cl_waiting, sv_active, sv_idle a průměrnou dobu transakce. Pokud je cl_waiting při normální zátěži stabilně nad nulou a Postgres má pořád rezervu na CPU i I/O, pool size opatrně zvedněte. Pokud je sv_active dlouhodobě nalepené vysoko a query latency s tím roste, řešení je tuning query nebo úprava workloadu, ne dalších 30 server connections. Víc concurrency špatný query plan nezachrání. Jen zviditelní ten průšvih.
migrace bez dramatu
Na nasazení PgBounceru nepotřebujete kvartální platform projekt. Když víte, na co si dát pozor, často stačí jedno poctivé odpoledne. Můžete ho provozovat jako sidecar, samostatný deployment nebo použít managed variantu, pokud ji váš cloud nabízí. Já preferuju dedikovanou službu, protože jsou díky tomu failure domains čitelné a změny konfigurace zůstávají pod kontrolou.
Nejdřív ho nainstalujte a správně nastavte autentizaci. U SCRAM musí sedět to, co používá Postgres. Než sáhnete na Django, otestujte psql přes port 6432. Pak na něj přepněte jedno nekritické prostředí, staging, pokud staging odpovídá reálným datům, nebo low-traffic worker, pokud ne. Potom vypněte v Django persistent connections a server-side cursors a projeďte test suite i pár skutečných flow. Počítejte s tím, že se objeví pár podivných chyb, pokud někdo někde schoval SET search_path, temp tabulky, advisory locky držené přes více requestů nebo driver-level předpoklady o sticky connections.
Migrace si zaslouží speciální režim. Django migrace sice v mnoha případech přes PgBouncer normálně fungují, ale já je stejně radši posílám přímo do Postgresu, případně přes samostatný PgBouncer database entry s pool_mode = session pro administrativní operace. Totéž platí pro manage.py dbshell. Provozní cesty udržujte co nejnudnější. Když migrace drží locky 40 sekund, opravdu nechcete navíc debugovat chování pooleru.
Failover vyžaduje ještě jedno explicitní rozhodnutí. Pokud jste na RDS, Cloud SQL nebo managed HA Postgresu, namiřte PgBouncer na writer endpoint, ne na adresu konkrétní instance, pokud si nechcete užívat ruční cutovery. DNS cache kolem toho nastavte konzervativně. Pak si to nacvičte. Restartujte PgBouncer v klidném okně, povyšte repliku ve stagingu, sledujte, jak se chová aplikace, a ověřte, že client retry dávají smysl. Chyba, které chcete rozumět ještě před produkcí, je psycopg.OperationalError: server closed the connection unexpectedly, protože nějakou její variantu při failoveru a restartech uvidíte.
Rollout plán je v praxi jednoduchý: nasaďte PgBouncer, přepněte jednu službu, porovnejte počty connections a p95 latenci, potom přesuňte zbytek. Přímé připojení na Postgres si nechte k dispozici pro rollback ještě den nebo dva. Nejspíš ho nebudete potřebovat.
monitorujte správnou vrstvu
Pooler řeší tlak na connections. Neodpouští ale špatné SQL. Týmy často nainstalují PgBouncer, sledují, jak se connection graf srovnal, prohlásí vítězství a přehlédnou SELECT, který skenuje 8 milionů řádků, protože někdo ve request path filtruje podle neindexovaného JSONB klíče.
Monitorujte všechny tři vrstvy: aplikaci, pooler i databázi. V Django zapněte query logging selektivně nebo použijte třeba OpenTelemetry s request spany a SQL timings. V PgBounceru tahajte SHOW STATS, SHOW POOLS a SHOW SERVERS do Promethea. Na Postgresu mějte zapnuté pg_stat_statements a opravdu se do nich dívejte. Pokud neřadíte query podle celkového času, průměrného času a počtu volání, jen hádáte.
Základní query, které se vyplatí mít po ruce:
1SELECT2 query,3 calls,4 total_exec_time,5 mean_exec_time,6 rows7FROM pg_stat_statements8ORDER BY total_exec_time DESC9LIMIT 20;
Pak to korelujte s wait metrikami z PgBounceru. Pokud wait time vyskočí proto, že jeden reporting endpoint otevře transakci a pak 12 sekund renderuje CSV, zatímco drží locky, žádné škálování aplikace nepomůže. Přesuňte report do async zpracování, rozdělte ho na chunky nebo přepište query. Pokud Celery při backfillu zaplaví pool, rozdělte fronty a dejte worker fleet vlastní PgBouncer user s nižším limitem poolu. Izolace funguje líp než naděje.
Ještě jedna věc: nastavte application_name podle typu procesu. web, celery, beat, migration. Tohle malé minimum hygieny dělá z pg_stat_activity čitelný nástroj při incidentech a čitelné systémy se opravují rychleji. Týmy, které pod zátěží zůstávají v klidu, nebývají chytřejší. Jen si včas vytvořily dostatečnou viditelnost, aby nemusely hádat.
PgBouncer nevyřeší každý scaling problém v Django startupu. Vyřeší ale velmi častý a velmi zbytečný problém: chováte se k Postgresu, jako by bez následků zvládl nekonečno ukecaných klientů. Nasaďte pooler, nastavte ho záměrně, zkroťte počty workerů, sledujte query a teprve potom řešte, jestli opravdu potřebujete další servery. Většina týmů ne.
