Pooling is not one thing. What you should do differs by engine, and a toolkit that shipped a uniform "pooler" for all of them would be selling you three quarters of a lie.
| Engine | What to use | Required? | Shipped |
|---|---|---|---|
| PostgreSQL | pgBouncer | Yes, in production | Yes — PROFILES=pg,pooler |
| MySQL / MariaDB | ProxySQL | Optional | Yes — PROFILES=mysql,pooler |
| MongoDB | The driver | Never use a proxy | n/a |
| Cassandra | The driver | Never use a proxy | n/a |
| FerretDB | The driver | Never use a proxy | n/a |
PostgreSQL forks one operating system process per connection, and each costs roughly 10 MB before it does any work. The arithmetic is unforgiving:
| Direct connections | RAM spent before a single query |
|---|---|
| 100 | ~1 GB |
| 300 | ~3 GB |
| 500 | ~5 GB |
That is memory gone to existing, not to serving. Past a few hundred connections the machine also spends real time context-switching between processes that are mostly idle.
So pgBouncer is mandatory under the prod profile, and check-env refuses to
start a prod stack without it. Your application opens a thousand connections to
pgBouncer; pgBouncer keeps twenty-five to PostgreSQL.
make up PROFILES=pg,poolerThen connect to 6432 instead of 5432 — that is the only application change.
MDB_PGBOUNCER_POOL_MODE=transaction
MDB_PGBOUNCER_DEFAULT_POOL_SIZE=25
MDB_PGBOUNCER_MAX_CLIENT_CONN=1000
A pooler has to authenticate clients, and the obvious way — give it a list of users and passwords — makes it a second place every credential lives. This one does not do that. It has a login role of its own, and resolves everyone else's verifier on demand:
SELECT username, password FROM pgbouncer.get_auth($1)pgbouncer.get_auth is a SECURITY DEFINER function that returns exactly one
row for the name it is given and excludes superusers, so compromising the
pooler yields one credential rather than all of them. Its search_path is
pinned to pg_catalog, because a SECURITY DEFINER function that resolves
unqualified names through the caller's path is a privilege-escalation bug.
The function is created by a convergence stage that runs on every start, not only at first init, so you can enable the pooler on a volume created months ago without destroying data to get it.
Check 33 asserts all of this behaviourally: that a user authenticates through the pooler, that the pooler's userlist does not contain that user's password, that eight clients collapse onto a pool of two, and that a wrong password is still refused.
Transaction pooling has rules. A server connection is returned to the pool at the end of every transaction, so anything that lives on a session breaks:
- session-level
SET(useSET LOCAL, inside the transaction) LISTEN/NOTIFY- advisory locks held across transactions
- prepared statements outside a transaction
- cursors declared
WITHOUT HOLD
This matters for the RLS kit: SET LOCAL app.tenant_id is correct precisely
because it is discarded at commit, so a pooled connection cannot carry one
tenant's identity into the next request. See the RLS kit.
If you need session state, use session pool mode and accept a much lower
multiplexing ratio.
The MySQL family uses one thread per connection, not a process. A thread is far cheaper, so a few hundred connections is unremarkable and pooling is a scaling aid rather than a rescue.
ProxySQL is offered because it brings more than pooling — query routing,
read/write splitting, query rules — and those are the reasons to adopt it. If
all you want is fewer connections, raise max_connections first and measure.
It is wired, and the interesting part is how it authenticates.
ProxySQL has no equivalent of auth_query: it cannot look a credential up
on demand, it has to hold one. That is why this pooler was left unwired at
first — holding every application password would have given up the exact
property the PostgreSQL side goes to some trouble to preserve.
What it holds is a verifier, not a password. On start it copies each user's
caching_sha2_password hash straight out of mysql.user.authentication_string,
so ProxySQL knows no more than the server already does, and a compromised
pooler yields what a read of that table would — not a set of usable passwords.
proxysql: mirrored 7 user verifier(s); no cleartext password was read
Two consequences worth knowing:
It mirrors on start. A user created after the pooler booted is not known to
it until it restarts. make new-project followed by
docker compose restart mysql-pooler is the sequence.
ProxySQL gets its own narrow account, not root — root stays restricted to
the local socket. The account has exactly two privileges: SELECT on
mysql.user to read verifiers, and REPLICATION CLIENT for the monitor. Its
admin interface on 6032 is never published, because that interface can read
every verifier it holds.
Check 35 asserts all of it: a user connects through the pooler, what is stored
begins with the $A$ verifier prefix and does not contain the password, the
pooler's account holds no SUPER, and a wrong password is still refused.
Do not put a connection proxy in front of these. Their drivers pool natively, and a proxy actively breaks things:
MongoDB drivers implement pooling as a specified part of the driver (Connection Monitoring and Pooling). The driver also monitors topology, selects servers by latency and read preference, and retries writes on failover. A proxy in the middle hides the topology, so all of that stops working.
Cassandra drivers hold connections to each node under a load-balancing policy and multiplex thousands of concurrent requests per connection. The driver routes each query to a replica that owns the data — token awareness — which is where much of Cassandra's performance comes from. A proxy defeats it by making every request appear to come from one place.
For these engines the fix is one line of application code, not infrastructure:
// Correct: one client for the process lifetime. The driver pools behind it.
const client = new MongoClient(uri);
// Wrong: a new client per request. This defeats the pool entirely and is the
// single most common cause of "MongoDB is slow".
async function handler() {
const client = new MongoClient(uri); // don't
}The same rule applies to Cassandra's Session and FerretDB, which is reached by
MongoDB drivers.
Every engine's descriptor declares MDB_ENGINE_POOLING=external or =driver,
and check 31 requires a driver declaration to state why. An engine cannot
quietly acquire a pooler nobody thought about, and it cannot quietly go without
one either.