惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tailwind CSS Blog
The GitHub Blog
The GitHub Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog
Microsoft Security Blog
Microsoft Security Blog
Stack Overflow Blog
Stack Overflow Blog
量子位
Martin Fowler
Martin Fowler
月光博客
月光博客
P
Proofpoint News Feed
博客园_首页
Y
Y Combinator Blog
I
InfoQ
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
V
Visual Studio Blog
H
Help Net Security
U
Unit 42
GbyAI
GbyAI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 司徒正美
MongoDB | Blog
MongoDB | Blog
F
Fortinet All Blogs
罗磊的独立博客
酷 壳 – CoolShell
酷 壳 – CoolShell

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution
Connection Pooling Without the Cargo Cult: pgbouncer in 1...
The Practica · 2022-08-19 · via The Practical Developer

The Postgres dashboard shows 200 connections used out of 200 max. The application is throwing remaining connection slots are reserved for non-replication superuser connections. Somebody in the chat says “we need to bump max_connections” and somebody else says “we need pgbouncer.” Both are right and both are wrong, and the order matters.

Postgres does not handle thousands of connections gracefully. Each one is a backend process that consumes ~10MB of RAM and a slot in the shared lock table. Past a few hundred concurrent connections, throughput goes down. Context-switching dominates, lock contention spikes, and the cure (more connections) is the disease. The actual fix is connection pooling: a layer between your app and Postgres that maintains a small number of real connections and multiplexes thousands of clients across them. pgbouncer is the standard. It is also the most miscopied config in the industry.

The mental model

A pgbouncer process sits between your application and Postgres. Your app opens a TCP connection to pgbouncer (cheap; pgbouncer holds it indefinitely). pgbouncer maintains a pool of real Postgres connections behind the scenes and routes your queries through whichever one is free. The two sides are decoupled.

[ app instance × 50 ]                  [ pgbouncer ]                [ Postgres ]
   2000 client conns  <───────────>   pool_size=20  <────────────>   max 25

Fifty app instances × 40 client connections each = 2000 client-side connections, but pgbouncer holds only 20 real connections to Postgres. As long as no single query stalls everything, throughput is dictated by query latency, not connection count.

The three pool modes, ranked by what they break

This is the setting most config copy-pasters get wrong. pgbouncer has three pool modes, and they trade off compatibility against efficiency.

Session pooling: a client gets a server connection and keeps it for the entire session. Functionally identical to no pooler at all from the application’s point of view, except pgbouncer is the queue when you exceed pool_size. Almost no efficiency gain at high concurrency. Use this only if you genuinely need session-scoped state (e.g., temp tables that survive across statements, advisory session locks, prepared statements pinned to a connection).

Transaction pooling: a client gets a server connection only for the duration of a transaction. After COMMIT or ROLLBACK, the connection goes back to the pool and another client can use it. This is the mode 95% of web apps want. The trade-off: anything that requires connection-level state breaks.

Statement pooling: a client gets a server connection only for the duration of one statement. Multi-statement transactions are not allowed. Use this only for single-shot read-heavy workloads (analytics dashboards, public APIs that always do one SELECT). It is rare in practice.

For a typical Node.js / Rails / Django app: transaction pooling. Anything else is wasting the pooler.

What transaction pooling breaks

This is the part teams skip when they “just turn on pgbouncer” and then debug for a week.

Session-level state is gone. SET search_path = ..., SET timezone = ..., SET application_name = ...: these set a property on a connection. In transaction pooling mode, your next transaction probably runs on a different backend, and the setting is gone. Use SET LOCAL (which is transaction-scoped) or set it via the connection string parameters.

Server-side prepared statements break. When your driver runs PREPARE stmt AS ... once and then EXECUTE stmt later, the EXECUTE may land on a backend that has never seen the PREPARE. Modern drivers handle this. Node pg with pg-pool is fine, libpq with prepareThreshold=0 is fine, but ORMs that aggressively cache prepared statements (some configurations of Sequelize, older versions of Hibernate-style ORMs) need a config tweak. Test it.

LISTEN / NOTIFY does not work. LISTEN is a property of a connection. The next time the client sends NOTIFY, it goes through a different backend and your LISTEN-er never hears it. Connect directly to Postgres for LISTEN-ers (skip pgbouncer for that one connection).

Advisory locks across statements are unsafe. pg_advisory_lock is session-scoped; in transaction mode you must use pg_advisory_xact_lock (transaction-scoped) instead.

Cursors do not survive past COMMIT. Use FETCH inside the same transaction, or rewrite to keyset pagination.

If your app respects these constraints (most do), transaction pooling is invisible.

A pgbouncer config that actually works

Drop this into /etc/pgbouncer/pgbouncer.ini and adjust the host/auth lines. Each block is annotated with the why.

[databases]
; Multiple logical databases routed to one Postgres host.
appdb = host=10.0.0.4 port=5432 dbname=appdb pool_size=25
analytics = host=10.0.0.4 port=5432 dbname=analytics pool_size=10

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432

; Auth.
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgbouncer_admin

; The single most important setting. See the next section for sizing.
pool_mode = transaction

; Maximum total Postgres connections per pgbouncer (sum across pools).
; This must be < Postgres max_connections - reserved superuser slots.
max_db_connections = 100

; Maximum client-side connections to pgbouncer. Set generously; these are cheap.
max_client_conn = 5000

; Reserve some pool slots so urgent queries do not get stuck behind big ones.
reserve_pool_size = 5
reserve_pool_timeout = 3

; A client that has not done anything for this long gets disconnected.
client_idle_timeout = 600

; A pooled server connection that has been idle for this long is closed.
server_idle_timeout = 600

; Defensive: a query that runs longer than this is killed at the pool.
; Should comfortably exceed your slowest legitimate query.
query_timeout = 60
query_wait_timeout = 30

; Logs.
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60

userlist.txt holds the SCRAM credentials pgbouncer will accept from clients:

"appuser" "SCRAM-SHA-256$..."

You generate the SCRAM hash with psql’s \password and copy the resulting hash. Avoid storing plain passwords; pgbouncer supports it, but you do not need to.

The pool_size formula that actually holds

The most common pool-size mistake is “set it to a big number.” pgbouncer at pool_size=200 against a Postgres at max_connections=500 does not buy you anything except extra ways to overload the database. The right way to size:

pool_size = number of CPU cores on the Postgres host × 2 to 4

That is it. It is a hardware bound, not an application bound. The database can usefully execute ~2x cores worth of concurrent queries before throughput plateaus and starts dropping. Past that, you are queuing, and queuing should happen at the pgbouncer layer (where it is cheap) rather than at the Postgres backend (where each waiting connection costs RAM).

For an 8-core Postgres host: pool_size between 16 and 32. For a 32-core host: 64 to 128. Most “we need a bigger pool” requests are actually “we have one slow query holding connections too long,” not “we are short on parallelism.”

If your queries are short (single-digit ms), you can run with smaller pools, and pgbouncer will multiplex effectively. If your queries are long (hundreds of ms), even a small number of slow queries will starve the pool. The metric to watch is cl_waiting (clients waiting for a server connection) from SHOW POOLS:

psql -h pgbouncer-host -p 6432 -U pgbouncer_admin pgbouncer -c "SHOW POOLS;"

If cl_waiting is consistently > 0, you are pool-starved. Either pool_size is too small or queries are too slow. Usually queries are too slow.

What to put in your application’s connection string

The application also has a connection pool (Node pg’s Pool, Rails’ ActiveRecord::ConnectionPool, Django’s CONN_MAX_AGE). With pgbouncer in front, the application pool is now mostly a worker-side cache; sizing rules change:

  • Application pool per process: small (4–10). The app does not need its own deep queue; that is pgbouncer’s job.
  • Total application connections = (instances) × (pool per process). This is what hits pgbouncer’s max_client_conn. If you have 50 instances × 10 = 500, set max_client_conn comfortably above that (e.g., 1000–5000).
  • Query timeout in the driver should be lower than pgbouncer’s query_timeout so the driver kills slow queries first and pgbouncer is the safety net.

For Node:

import { Pool } from 'pg';
export const pool = new Pool({
  host: 'pgbouncer-host',
  port: 6432,
  database: 'appdb',
  user: 'appuser',
  password: process.env.DB_PASSWORD,
  max: 8,                              // small per-process pool
  idleTimeoutMillis: 10_000,
  connectionTimeoutMillis: 5_000,
  statement_timeout: 30_000,           // < pgbouncer query_timeout
});

Monitoring you actually need

Two queries you should hit every minute via your monitoring system. Both run against pgbouncer’s admin DB.

-- 1. Are we pool-starved?
SHOW POOLS;
-- Watch cl_waiting and maxwait_us. If maxwait grows past a few seconds,
-- somebody is being held up.

-- 2. Are queries running long enough to matter?
SHOW STATS;
-- avg_query_time and avg_xact_time give you per-pool latency at the pooler.

Pipe these into Prometheus via pgbouncer_exporter and alert on cl_waiting > 5 for 5 minutes. That single alert catches almost every “the database is slow” incident before users notice.

Also keep an eye on the Postgres side. Even with pgbouncer, you want pg_stat_activity showing roughly pool_size active backends. If you see ~1000 active backends, somebody bypassed pgbouncer (often: a one-off worker process pointed at the Postgres host directly).

Two scenarios.

Per-connection state your app cannot give up. If you genuinely need session-scoped temp tables, you cannot use transaction pooling. Use session pooling and accept that you have not gained much. Or look at PgCat, which adds richer routing.

Sharded reads/writes. pgbouncer routes everything to one Postgres host. If you have a primary + replicas and want SELECTs to go to the replicas, you need application-level routing or a smarter pooler (PgCat, Odyssey).

For 95% of teams: pgbouncer in transaction mode, in front of one Postgres primary, with the config above and a sensible pool_size. The migration from “no pooler” to “pgbouncer” is one of the highest-leverage two-hour tasks in backend infra, the kind of change that takes a Saturday morning and makes the next year of scaling 5× easier.

The takeaway

Connection pooling is not an optimization, it is a precondition for any Postgres database with more than a handful of app instances. The default pool_size formula is cores × 2 to 4. The default pool_mode is transaction, with the four constraints above respected. The default monitoring is cl_waiting and maxwait_us. Anything fancier than that is a refinement, not a starting point.

Most “Postgres is slow” incidents that turn out to be connection-related are solved by these five settings. The next thirty minutes you spend wiring pgbouncer up is worth more than the next thirty hours you spend tuning queries that only get slow when the pool is starving.


A note from Yojji

The kind of infrastructure work that turns a “we are out of database connections” incident into a non-event (pooler config, slow-query budget, the right alert thresholds) does not show up in a sprint demo and shows up loudly in an outage. It is the unglamorous backend work Yojji’s teams build into the production systems they ship.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their dedicated teams ship across the JavaScript ecosystem (React, Node.js, TypeScript) and the major cloud platforms (AWS, Azure, GCP), including the database and infra plumbing that decides whether a product still feels good at the next traffic milestone.