Connection pooling PostgreSQL su un VPS da 4 GB
Ogni connessione PostgreSQL è un processo con memoria propria: su un VPS da 4 GB la RAM finisce prima di max_connections. Cosa risolve un pooler e cosa rompe.
Perché un piccolo VPS esaurisce la RAM prima di raggiungere max_connections
Il connection pooling di Postgres su un VPS non serve ad aumentare la velocità. Serve a mantenere operativo un server con 4 GB di RAM, perché ogni connessione PostgreSQL è un processo separato del sistema operativo e dispone di memoria privata propria. Un pooler mantiene un numero ridotto e fisso di processi backend reali dietro un numero elevato ed economico di connessioni client.
Il valore predefinito di max_connections è 100. Si tratta di un limite, non di una disponibilità di memoria riservata. PostgreSQL non verifica se il computer è effettivamente in grado di mantenere 100 backend che eseguono query reali, quindi il sistema esaurisce le risorse prima. Il killer out of memory (OOM) del kernel seleziona un processo e, quando seleziona un backend, PostgreSQL riavvia l'intero cluster per mantenere coerente la memoria condivisa. Il log mostra server process (PID 1234) was terminated by signal 9: Killed, quindi terminating any other active server processes. Tutte le connessioni aperte vengono interrotte, comprese quelle ancora funzionanti.
Il server esaurisce la memoria perché ogni connessione è un processo e perché work_mem viene allocato per ogni operazione di ordinamento o hash, non per ogni connessione. Entrambi questi fattori si moltiplicano.
Ogni connessione è un processo e ogni processo consuma memoria
PostgreSQL usa un processo per ogni connessione. Il postmaster crea un processo backend quando un client si connette e quel backend resta attivo fino alla disconnessione del client. Non è un thread. Ha le proprie tabelle delle pagine, le proprie cache del catalogo e i propri piani delle query memorizzati nella cache. Queste cache crescono quando la connessione accede a più tabelle ed esegue query diverse, quindi una connessione di lunga durata in un'applicazione ORM molto utilizzata consuma più memoria di una connessione appena creata.
La memoria condivisa è realmente condivisa. shared_buffers è un'unica allocazione per l'intero cluster, mappata in ogni backend. La memoria privata non è condivisa. Per questo top può trarre in inganno: il resident set size (RSS) di un backend include le pagine condivise che quel backend ha utilizzato. Sommando l'RSS di 50 backend si contano quindi shared_buffers 50 volte.
Misurare invece la parte privata. Il PSS (proportional set size) divide ogni pagina condivisa per il numero di processi che la mappano, mentre l'USS (unique set size) conta soltanto le pagine appartenenti esclusivamente a quel processo.
sudo apt update
sudo apt install -y smem
sudo smem -k -P '^postgres'La colonna USS indica la memoria che verrebbe liberata se quel backend terminasse. Questo è il costo effettivo della singola connessione. I valori pubblicati indicano in genere pochi megabyte per un backend inattivo e valori diverse volte superiori per un backend che ha eseguito query ORM estese. Considerali valori pubblicati tipici, non i valori del tuo sistema. L'unico valore utile per la pianificazione è quello misurato sul tuo server con il tuo carico di lavoro.
È facile non considerare un'allocazione per sessione. temp_buffers ha un valore predefinito di 8MB e viene allocata per sessione la prima volta che la sessione accede a una tabella temporanea. Non viene restituita fino al termine della sessione.
work_mem viene assegnato per operazione, non per connessione
È qui che i calcoli traggono in inganno. work_mem ha un valore predefinito di 4MB e la documentazione di PostgreSQL è chiara sul significato: "una query complessa può eseguire contemporaneamente diverse operazioni di ordinamento e hashing; in genere ogni operazione può usare una quantità di memoria pari a questo valore prima di iniziare a scrivere i dati in file temporanei". Un piano con tre nodi di ordinamento può usare tre volte work_mem all'interno dello stesso backend, nello stesso momento.
Le operazioni di hashing possono usare una quantità ancora maggiore. hash_mem_multiplier ha un valore predefinito di 2.0, quindi un hash join o un hash aggregate può usare work_mem moltiplicato per due, cioè 8MB con le impostazioni predefinite. Le query parallele moltiplicano ulteriormente il consumo, perché ogni parallel worker è un altro processo con la propria disponibilità di memoria.
Facciamo i calcoli per una VPS da 4 GB. Imposta shared_buffers su 1 GB, lascia work_mem a 4MB e consenti a 100 connessioni di eseguire ciascuna una query con due nodi di hashing. Il calcolo è 100 moltiplicato per 16MB, quindi 1.6 GB di memoria privata oltre a 1 GB di shared buffers, prima della page cache e di qualsiasi altro consumo del server. Ora aumenta work_mem a 64MB perché il server ha RAM disponibile: le stesse 100 connessioni arrivano a 100 moltiplicato per 256MB. Non viene visualizzato alcun avviso. Te ne accorgi quando interviene l'OOM killer.
Puoi verificare se work_mem è troppo basso invece di procedere per tentativi. Imposta log_temp_files = 0 in postgresql.conf e ricarica la configurazione. Ogni spill su disco scrive quindi una riga che indica il nome e la dimensione del file, come temporary file: path "base/pgsql_tmp/pgsql_tmp1234.0", size 20971520. Gli spill frequenti indicano che un valore maggiore di work_mem potrebbe essere utile. Se non si verificano spill, aumentare il valore non offre vantaggi e consuma memoria che non hai.
L’aritmetica dei pool che crea davvero problemi
Nessuno configura intenzionalmente 240 connessioni. Si configura un pool da 20 connessioni, quindi si esegue l’applicazione in più punti.
The data behind this chart
[
{
"config": "1 worker",
"backends": 20
},
{
"config": "4 web workers",
"backends": 80
},
{
"config": "4 web + 2 background",
"backends": 120
},
{
"config": "2 hosts x 4 workers",
"backends": 160
},
{
"config": "3 hosts x 4 workers",
"backends": 240
}
]Quattro worker Gunicorn, ciascuno con un pool da 20 connessioni, richiedono 80 backend. Aggiungendo due worker per i job in background, il totale diventa 120. Passando a 3 hosts x 4 workers, l’applicazione richiede 240 backend su un max_connections di 100. Nessuna di queste 5 configurazioni è errata se considerata singolarmente. Il pool è specifico per processo e nessuna parte dell’applicazione può vedere il totale.
Anche i valori predefiniti delle librerie spingono nella stessa direzione. In SQLAlchemy, QueuePool ha come valore predefinito pool_size=5 con max_overflow=10, quindi 15 connessioni per processo. HikariCP usa 10 come valore predefinito. Prima della versione 5.1, Django non aveva un pool integrato e usava una connessione per processo worker. Per questo le applicazioni Django incontrano il problema più tardi, ma poi lo affrontano tutto in una volta quando qualcuno imposta CONN_MAX_AGE o abilita la nuova opzione "pool": True. Se esegui un’app Django dietro Gunicorn e nginx, il numero da moltiplicare è il numero di worker Gunicorn, non il numero di server.
Cosa cambia davvero il connection pooling di Postgres su un VPS
Un pooler è un processo che comunica con l'applicazione usando il wire protocol di PostgreSQL e mantiene dall'altra parte un numero ridotto di connessioni reali al server. Non rende più veloce alcuna query. Cambia chi sostiene il costo di una connessione e quanti backend reali sono presenti.
Migliorano due aspetti. L'apertura della connessione non richiede più un fork e le ricerche nel catalogo necessarie per riempire la cache vuota del backend, perché è il pooler a rispondere direttamente alla connessione del client. Soprattutto, il numero di backend reali non segue più il numero di connessioni dell'applicazione: 500 client possono condividere 20 backend.
L'attesa è la funzionalità principale, ed è l'aspetto che molti faticano ad accettare. Senza un pooler, 500 query concorrenti ricevono tutte un backend ed eseguono contemporaneamente su due core CPU. Di conseguenza, ogni query è lenta e tutta la memoria viene utilizzata nello stesso momento. Con un pooler, 20 query vengono eseguite e le altre attendono alcuni millisecondi. Ogni query in esecuzione riceve così una quota effettiva della CPU e termina prima. Una coda davanti a un pool ridotto è più efficace dell'assenza di coda davanti a un pool grande.
Un pooler non limita nessun'altra risorsa della macchina. Se Postgres condivide il VPS con un application server o con un database vettoriale sullo stesso VPS, il pooler protegge Postgres dall'applicazione, ma non offre altre protezioni. Imposta un limite rigido anche per i servizi vicini: puoi limitare la memoria e la CPU che un servizio può utilizzare con systemd, così un processo fuori controllo non può causare anche l'arresto del database. La posizione del database determina il modo in cui imposti questi limiti. Questa è la differenza pratica tra eseguire Postgres in Docker o direttamente sull'host.
Pooling delle sessioni e pooling delle transazioni
Un'unica impostazione determina tutto il resto: pool_mode.
Con il pooling delle sessioni, una connessione al server viene assegnata a un client per tutta la durata della connessione del client e viene rilasciata quando il client si disconnette. Tutto funziona perché il pooler è un semplice proxy. Si riduce il costo della connessione, ma non cambia altro. Se l'applicazione apre 200 connessioni, servono comunque 200 connessioni backend.
Con il pooling delle transazioni, una connessione al server viene assegnata a un client soltanto per la durata di una transazione. In corrispondenza di COMMIT o ROLLBACK torna nel pool e viene assegnata al client in attesa successivo. È questo che consente di gestire 500 client con 20 connessioni backend. È anche ciò che causa le incompatibilità, per progettazione: l'istruzione successiva può essere eseguita su un backend diverso da quello usato per l'ultima istruzione.
L'impostazione predefinita di PgBouncer è pool_mode = session. Installandolo senza modificare nulla si ottiene solo il vantaggio economico, senza il beneficio principale. Una terza modalità, statement, restituisce la connessione dopo ogni singola istruzione e rifiuta le transazioni composte da più istruzioni. Lasciarla invariata, a meno che non si sappia esattamente perché la si vuole usare.
Quale modalità di transazione si rompe e perché
Tutto ciò che segue fallisce per lo stesso motivo. Si tratta di stato che risiede in un singolo backend, mentre il transaction pooling non garantisce l'uso dello stesso backend due volte.
SETeRESETa livello di sessione.SET search_path,SET statement_timeout,SET TIME ZONEeSET ROLEvengono eseguiti sul backend che ha gestito quella istruzione e scompaiono prima della transazione successiva. UsaSET LOCALall'interno di una transazione esplicita: è associato a quella transazione e quindi è sicuro.LISTEN. La consegna delle notifiche dipende dal backend che ha eseguitoLISTEN, ma quel backend viene assegnato a un altro client non appena termina la transazione.NOTIFYcontinua a funzionare in modalità transaction, creando un errore difficile da diagnosticare: l'invio riesce, ma la ricezione non avviene mai. Se ti serveLISTEN, apri una connessione aggiuntiva direttamente sulla porta 5432, ignorando il pooler.- I lock advisory a livello di sessione.
pg_advisory_lock()viene mantenuto dalla sessione e rilasciato quando la sessione termina. Con il transaction pooling, la chiamata di sblocco viene eseguita su un backend diverso, quindi il lock resta attivo finché PgBouncer non ritira quella connessione al server; per impostazione predefinita ciò avviene doposerver_lifetime, cioè un'ora. Usapg_advisory_xact_lock(), che viene rilasciato al termine della transazione dallo stesso backend che lo ha acquisito. PREPAREeDEALLOCATE, le istruzioni SQL. Non sono mai disponibili in modalità transaction.- I cursori
WITH HOLDe qualsiasi cursore lato server che dovrebbe restare disponibile oltre la transazione. - Le tabelle temporanee che devono sopravvivere a un commit.
CREATE TEMP TABLE ... ON COMMIT PRESERVE ROWSinserisce la tabella nello schema temporaneo di un backend, ma la transazione successiva potrebbe essere eseguita su un backend diverso. LOAD.
Le istruzioni preparate a livello di protocollo sono l'unico elemento che ha subito modifiche. PgBouncer 1.21.0 ha aggiunto il supporto per queste istruzioni in modalità transaction e la versione 1.24.0 lo ha attivato per impostazione predefinita impostando max_prepared_statements su 200. Le versioni precedenti lo lasciano su 0, cioè disattivato. Ubuntu 24.04 include PgBouncer 1.22.0, quindi la funzionalità è disponibile, ma devi impostare manualmente max_prepared_statements. Se non sai con certezza come si comporta la tua build, l'impostazione sicura è sul client: psycopg 3 smette di usare le istruzioni preparate lato server quando imposti prepare_threshold su None.
Django definisce una propria variante di questo comportamento. La documentazione specifica che "l'uso di un pooler di connessioni in modalità transaction (ad esempio PgBouncer) richiede la disattivazione dei cursori lato server per quella connessione", perché "i cursori lato server sono accessibili solo dalla connessione in cui sono stati creati". Imposta DISABLE_SERVER_SIDE_CURSORS su True nella voce relativa a quel database, altrimenti ogni chiamata .iterator() diventa un errore intermittente che compare solo sotto carico.
La modalità transaction è una scelta valida, ma comporta un contratto. Leggi l'elenco, verifica la compatibilità del tuo ORM e della libreria per i job in background, quindi attivala.
Installare PgBouncer e configurare l’applicazione per usarlo
La configurazione seguente deve essere eseguita sul proprio server: Ubuntu 24.04, con PostgreSQL già in ascolto su 127.0.0.1, porta 5432.
sudo apt update
sudo apt install -y pgbouncer
pgbouncer --versionUbuntu 24.04 include il pacchetto PgBouncer 1.22.0. A monte, la versione disponibile ad agosto 2026 è la 1.25.2. Verificare quale versione è installata, perché il comportamento delle prepared statement descritto sopra dipende da questo.
Creare un ruolo utilizzato esclusivamente per accedere alla console di amministrazione di PgBouncer, quindi creare il file delle password. PgBouncer deve leggere da pg_authid i secret SCRAM (salted challenge response authentication mechanism) e solo un superuser può leggere questa tabella.
sudo -u postgres psql -c "CREATE ROLE pgb_admin LOGIN PASSWORD 'change-this'"
sudo -u postgres psql -At -c \
'SELECT format($$"%s" "%s"$$, rolname, rolpassword) FROM pg_authid WHERE rolpassword IS NOT NULL' \
> /tmp/userlist.txt
sudo install -o postgres -g postgres -m 640 /tmp/userlist.txt /etc/pgbouncer/userlist.txt
rm /tmp/userlist.txtLa copia dei secret, invece di riscrivere le password, è ciò che rende possibile questa configurazione. PgBouncer può riutilizzare un secret SCRAM per autenticarsi a PostgreSQL solo quando il client si è autenticato a sua volta con SCRAM, quando il secret nel file corrisponde byte per byte a quello in pg_authid (stesso salt e stesso numero di iterazioni, non soltanto la stessa password) e quando la riga [databases] non imposta un user=. Aggiungendo user=appuser a quella riga, PgBouncer richiede invece una password in chiaro. Verificare che il proprietario del file corrisponda all’account con cui viene eseguito il servizio, usando systemctl show pgbouncer -p User. La rotazione di una password in PostgreSQL richiede la rigenerazione di questo file; in caso contrario, la connessione successiva restituisce password authentication failed.
Ora scrivere /etc/pgbouncer/pgbouncer.ini.
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgb_admin
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20
min_pool_size = 5
max_db_connections = 80
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digitslisten_addr = 127.0.0.1 mantiene il connection pooler fuori da Internet, aspetto importante perché un pooler raggiungibile dall’esterno è un endpoint di autenticazione che non si intendeva pubblicare. max_client_conn indica quante connessioni applicative PgBouncer accetta, e il relativo costo è ridotto, quindi il valore può essere elevato. default_pool_size indica quante connessioni ai backend reali può mantenere una singola coppia database e utente, ed è il valore più oneroso. max_db_connections limita l’intero database a 80 connessioni, lasciando margine al di sotto di max_connections per psql, backup e monitoraggio. ignore_startup_parameters = extra_float_digits impedisce a PgBouncer di rifiutare i driver, incluso il driver JDBC, che inviano quel parametro al momento della connessione.
sudo systemctl restart pgbouncer
sudo systemctl status pgbouncer --no-pager
sudo journalctl -u pgbouncer -n 20 --no-pagerUn avvio corretto registra una riga che indica che PgBouncer è in ascolto su 127.0.0.1:6432. Se l’avvio fallisce a causa del file delle password, il log indica il percorso che non è stato possibile leggere. Nella maggior parte dei casi il problema riguarda modalità o proprietario del file, non la sintassi. Modificare quindi la stringa di connessione dell’applicazione sostituendo la porta 5432 con la porta 6432 e riavviare l’applicazione. Non è necessario modificare altro nell’applicazione.
Come verificare che il pool stia svolgendo il proprio compito
PgBouncer dispone di una console di amministrazione accessibile tramite un database virtuale chiamato pgbouncer.
psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncerSHOW POOLS;
SHOW STATS;
SHOW CLIENTS;SHOW POOLS è il valore da monitorare. cl_active indica i client attualmente associati a una connessione al server, cl_waiting i client in coda in attesa di una connessione, sv_active e sv_idle i backend effettivamente in uso e liberi, mentre maxwait indica da quanti secondi il client in testa alla coda è in attesa. In condizioni normali, un pool in buona salute ha cl_waiting a 0 e maxwait a 0. Un valore di maxwait che supera uno o due secondi indica che il pool è troppo piccolo oppure che le query sono troppo lente. Questi due casi richiedono interventi diversi.
Verificare quale dei due casi si presenta prima di aumentare default_pool_size.
SELECT state, count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend' GROUP BY state;Se la maggior parte dei backend rimane in idle in transaction, il problema non è la dimensione del pool. L'applicazione apre una transazione e poi esegue al suo interno un'operazione lenta, ad esempio una chiamata HTTP. Di conseguenza, ogni backend rimane occupato senza eseguire una query. idle_in_transaction_session_timeout interromperà queste transazioni, ma la correzione effettiva deve essere apportata al codice dell'applicazione. Se invece tutti i backend sono in active, il pool è realmente saturo. Prima di assegnare altre connessioni, è necessario analizzare le query con EXPLAIN (ANALYZE, BUFFERS).
Per il dimensionamento, il punto di partenza più citato è l'euristica di HikariCP: circa il doppio del numero di core più uno. Su una VPS con 2 core, il valore è 5. Usare questo numero come riferimento iniziale documentato, impostare default_pool_size su un valore vicino e modificarlo in base a maxwait. I pool piccoli possono sembrare insufficienti, ma spesso producono risultati migliori nelle misurazioni. Un backend in coda non consuma risorse, mentre un backend in esecuzione consuma CPU e memoria e aumenta la contesa sui lock.
Scelta tra PgBouncer, PgDog e Pgpool-II
PgBouncer è la scelta per il caso ordinario: un server PostgreSQL, un VPS e un'applicazione che apre più connessioni di quante il sistema possa gestire. Svolge un solo compito, la configurazione è contenuta in un unico file ini ed è disponibile nei pacchetti Debian e Ubuntu. Gestisce le connessioni in un singolo thread, una capacità sufficiente per un carico delle dimensioni di un VPS. Questo diventa un limite soltanto su macchine molto più grandi.
PgDog è da valutare quando la decisione di instradamento deve avvenire nello stesso passaggio di rete del pooling. Si presenta come un proxy per la scalabilità di PostgreSQL, è scritto in Rust e supporta il pooling per transazioni e sessioni, oltre alla separazione delle letture e delle scritture tramite analisi delle query. Supporta anche lo sharding con instradamento su più shard e il commit a due fasi. Sceglilo quando hai un primary e una o più repliche e vuoi inviare le letture alle repliche senza modificare l'applicazione per renderla consapevole della loro presenza. Sono necessarie due cautele. È distribuito con licenza AGPLv3, quindi la clausola relativa all'uso tramite rete è una questione di licenza da chiarire con il responsabile di questa decisione nella tua azienda prima dell'uso in produzione; secondo il progetto, l'uso interno e le modifiche private non creano un obbligo di pubblicazione del codice sorgente. È inoltre un progetto giovane, con release settimanali e numeri di versione 0.x, quindi fissa un release tag invece di seguire main.
git clone https://github.com/pgdogdev/pgdog
cd pgdog
cargo build --release
./target/release/pgdog --config pgdog.toml --users users.tomlLa compilazione dal codice sorgente richiede una toolchain Rust stable aggiornata, CMake e un compilatore C/C++. Nella pagina delle release sono disponibili anche binari Linux precompilati e pacchetti Debian, oltre a un'immagine container all'indirizzo ghcr.io/pgdogdev/pgdog. La configurazione è suddivisa in due file. Il primo contiene le impostazioni generali e una voce per ogni database, qui scritta come array TOML di tabelle inline, così le due forme restano facilmente distinguibili.
databases = [
{ name = "appdb", host = "127.0.0.1" },
]
[general]
port = 6432
default_pool_size = 10Il secondo contiene una voce per ogni utente, nello stesso formato array.
users = [
{ name = "appuser", database = "appdb", password = "change-this" },
]PgDog resta in ascolto sulla porta 6432 per impostazione predefinita, la stessa porta usata da PgBouncer. Per questo, sullo stesso host, i due servizi non possono usare contemporaneamente la configurazione predefinita.
Pgpool-II, alla versione 4.7.2 a giugno 2026, offre pooling e bilanciamento del carico, con un watchdog per il failover automatico. Le funzionalità aggiuntive introducono ulteriori modalità di errore, e prima di sceglierlo è necessario comprendere il suo modello di pooling. Pgpool-II crea in anticipo num_init_children processi figli e ogni processo memorizza nella cache fino a max_pool connessioni ai server. Il limite sui backend è quindi num_init_children moltiplicato per max_pool. Ogni processo figlio gestisce un solo client alla volta, quindi il numero di client accettabili è num_init_children ed è fissato all'avvio. Anche un client inattivo occupa comunque un processo figlio. Impostando num_init_children su 100 e max_pool su 4, autorizzi 400 backend: è esattamente il problema che hai installato un pooler per risolvere. Scegli Pgpool-II quando ti servono il suo failover e il suo instradamento delle query, quindi esegui con attenzione questa moltiplicazione. Se ti serve soltanto ridurre il numero di backend, offre una complessità superiore a quella necessaria.
La questione del proxy gestito e l'equivalente self-hosted
Le piattaforme gestite vendono questa funzione come prodotto separato. AWS mette RDS Proxy davanti a RDS, mentre Supabase mette il proprio pooler, Supavisor, davanti a Supabase Postgres. Entrambi svolgono il compito descritto qui: mantengono le connessioni dei client con un costo contenuto e assegnano un numero inferiore di backend reali. Supavisor è open source e può essere eseguito in modalità self-hosted, quindi la scelta non contrappone una soluzione proprietaria a una gratuita.
L'equivalente self-hosted di un proxy gestito non è un concetto diverso. È lo stesso concetto con il file di configurazione sotto il tuo controllo: PgBouncer in modalità transaction, sullo stesso VPS del database, in ascolto su 127.0.0.1. Le differenze effettive sono due. Un proxy gestito si trova a un hop di rete di distanza, quindi aggiunge latenza e continua a mantenere le connessioni dei client mentre il database sottostante viene riavviato. PgBouncer sul server del database aggiunge un hop di loopback, con un costo quasi nullo, e si arresta quando si arresta quel server. Se vuoi un comportamento che resista al riavvio, ti serve anche un'infrastruttura di failover. È qui che il watchdog di Pgpool-II o gli health check di PgDog iniziano a giustificare la loro complessità.
C'è un'altra opzione da considerare. Se il numero di connessioni è il principale fattore di complessità della distribuzione, un database embedded non ha un modello di connessioni da gestire con un pool, perché è una libreria all'interno del processo e non un server in ascolto su una porta. Per un singolo application server con un volume di scrittura contenuto, eseguire SQLite in produzione su un VPS elimina completamente il problema invece di richiedere la gestione di un pool. Quando ti serve un server reale, dimensiona il pool prima della macchina.
FAQ
Do I still need PgBouncer if my application already has a connection pool?
Usually yes, because an application pool is per process and cannot see the others. Four Gunicorn workers each holding a pool of 20 request 80 backends, and adding two background workers makes it 120. PgBouncer is the only component that sees the total and can cap it. The good arrangement is both: a small pool inside each worker so requests do not pay for a TCP connect, and PgBouncer in transaction mode bounding the real backends behind them.
What exactly breaks when I switch PgBouncer to transaction mode?
Anything that keeps state in one backend across transactions. Session-level SET and RESET, LISTEN, WITH HOLD cursors, the SQL PREPARE and DEALLOCATE statements, session-level advisory locks, temporary tables that must outlive a commit, and LOAD. NOTIFY keeps working, which makes broken LISTEN look like a delivery bug rather than a pooling one. On Django, set DISABLE_SERVER_SIDE_CURSORS to True. On psycopg 3, either set prepare_threshold to None or run PgBouncer 1.22 or newer with max_prepared_statements above 0. Replace pg_advisory_lock() with pg_advisory_xact_lock().
How large should default_pool_size be on a 2 core VPS?
Smaller than feels right. The widely published HikariCP heuristic is about twice the core count plus one, so roughly 5 on two cores, and that is a starting point rather than an answer. Set it, then read maxwait and cl_waiting in SHOW POOLS under real load. Zero on both means the pool is big enough. A rising maxwait means clients are queueing, and before raising the number, check pg_stat_activity: backends stuck in idle in transaction are an application bug that more connections will only hide.
PgBouncer or PgDog?
PgBouncer for one PostgreSQL server on one VPS, which is most deployments. It is packaged in Ubuntu, its behaviour is well documented, and its whole configuration is one ini file. PgDog when read/write splitting across replicas or sharding belongs in the same hop as the pooling, so the application does not have to know the topology. Before committing to PgDog, settle the AGPLv3 question with whoever owns licensing where you work, and pin a specific release, because the project is still on 0.x version numbers and ships weekly.