Dev & EngARTICLE

PostgreSQL: why max_wal_senders blocks replication and backup even with spare connections

A post in the All Your GUCs in a Row series, by consultant Christophe Pettus, shows how the parameter that controls how many processes can read the WAL has had a life of its own since PostgreSQL 12, and why underestimating it brings down backup, replica, and logical replication all at once.

Christophe Pettus, a consultant specializing in PostgreSQL, published on Planet PostgreSQL another chapter of the All Your GUCs in a Row series, this time about max_wal_senders. The post opens by correcting the author himself: the previous post, about max_connections, recommended sizing that parameter by accounting for "replication connections." Pettus is direct in saying that guidance is wrong as of PostgreSQL 12: since then, WAL sender processes have their own pool, and no longer take a seat from max_connections.

The parameter defines how many processes can read the server's WAL on behalf of something else. And that list of consumers is longer than most people assume. According to the post, each standard pg_basebackup uses two simultaneous connections (because since version 10 it streams WAL over a separate connection, with -X stream as the default), each logical replication subscription takes up one seat for the apply worker plus one per table during initial copy, pg_receivewal and everything built on top of it (Barman's streaming mode, for example) also count toward the total, and every client that dropped off the network without closing the socket keeps occupying a seat until wal_sender_timeout expires.

The pool stopped being borrowed in 2019

Before PostgreSQL 12, a WAL sender took a seat from max_connections, which created two problems: an application with many open connections could prevent a standby from reconnecting, and the sum of superuser_reserved_connections plus max_wal_senders had to fit within max_connections, or the server wouldn't even start. Pettus credits Alexander Kukushkin with the fix that gave WAL senders their own pool of seats.

The post demonstrates the practical effect by running PostgreSQL 18.6 with max_connections = 5 and all seats taken by idle sessions. A regular psql gets FATAL: sorry, too many clients already, but two pg_receivewal processes connect without complaint, because they never asked for a regular seat. No rule for regular connections applies to a replication connection:

Terminal shows psql refused due to max_connections exhaustion while two pg_receivewal processes connect normally, using the dedicated max_wal_senders pool
Terminal shows psql refused due to max_connections exhaustion while two pg_receivewal processes connect normally, using the dedicated max_wal_senders pool. Reprodução: postgr.es.

None of the rules for regular seats apply to a replication connection: not max_connections, not the reserved seats, not a role's or a database's CONNECTION LIMIT.

Christophe Pettus, PostgreSQL consultant

The advantage has a symmetrical cost: nothing lends a seat in the opposite direction. An exhausted max_wal_senders stays exhausted even with ninety idle application connections sitting right next to it, and the error shows up in the backup job or the standby, not in the application, usually at three in the morning.

The basebackup fails and deletes what it already copied

The post's example is illustrative: with only one free seat, pg_basebackup -D bb -X stream connects, starts the copy, fails to open the second connection needed for WAL streaming, and removes everything it had already written on exit:

FATAL: number of requested standby connections exceeds "max_wal_senders" (currently 2)

The same command with -X fetch needs only one seat and completes without error on the same server, moments later. A logical replication subscription follows the opposite logic: it consumes one seat for the apply worker plus one per max_sync_workers_per_subscription during the initial sync of tables, all counted as WAL senders on the publisher side.

Even wal_level is part of the check: it must be set to replica or logical, and that verification happens in the postmaster. A wal_level = minimal configured for a batch workload, with max_wal_senders at its default, fails to start with:

FATAL: WAL streaming ("max_wal_senders" > 0) requires "wal_level" to be "replica" or "logical"

Interestingly, zeroing out the parameter disables less than the documentation suggests: Pettus tested, with max_wal_senders = 0 and wal_level = logical on version 18.6, creating a logical slot with pg_create_logical_replication_slot() and reading changes with pg_logical_slot_get_changes(), because those functions run in a regular backend. What zero turns off is specifically the WAL sender process: physical standby, logical subscription, and pg_basebackup.

The ghost that still occupies the seat

The documentation recommends setting the value "a little above the maximum expected number of clients," and the post explains why with an experiment. Pettus spins up two pg_receivewal clients against a server with only two seats and wal_sender_timeout set to 15 seconds, then sends SIGSTOP to both processes: they stay alive, the sockets stay open, and the server has no way to know the client isn't coming back.

A pg_basebackup attempted at this point fails immediately with the same full-pool error. A query against pg_stat_replication shows both rows in streaming state, with reply_time growing progressively older, until the log records terminating walsender process due to replication timeout for both. Only after that does the repeated pg_basebackup finish successfully, about thirteen seconds after the test began.

The difference matters: a client process that dies closes the socket and frees the seat right away. The ghost is the one that goes silent without closing anything, whether it's a pulled cable, a frozen VM, or an expired NAT mapping. With the default wal_sender_timeout of one minute, a standby that lost network access and came back has to wait for its own ghost to be killed before reconnecting, and if the pool was sized at its limit, everyone waits together.

Cascading standby and the mandatory mirror

Two additional details from the post deserve attention from anyone operating a topology with more than one level of replication. A cascading standby serves its own downstream nodes out of its own max_wal_senders, so it needs seats for them, not just for itself.

And since PostgreSQL 12, a hot standby must have max_wal_senders at least as high as the primary's, for the same reason that applies to max_connections and max_prepared_transactions: the value gets recorded in pg_control and in the WAL itself. A standby that receives a higher value from the primary than its own pauses recovery (from version 14 onward; on 12 and 13 it simply shuts down) until it's restarted with a compatible value. The simplest way out, according to the post, is to copy the primary's configuration to the standby and never let that divergence arise.

How much it costs to raise it, and to what

A WAL sender seat costs, in shared memory, the same as a regular connection: on version 18.6, a thousand seats (of either kind) add up to 51 MB, and raising the default 10 to 60 cost 3.8 MB when the author measured it. The cap of 262143 exists because a WAL sender counts, for shared-memory purposes, as a backend: the server's four backend groups need to fit together under that limit (MAX_BACKENDS).

Given the low cost and the fact that fixing the value requires restarting the server, the post's recommendation is direct: set 50 on every node, in the same restart used to adjust max_replication_slots, and then do the real math by adding up standbys, two seats per simultaneous backup that would run, one seat per logical subscription plus its sync workers, one per streaming archiver, and a margin for the ghosts of a wal_sender_timeout. If that number gets close to 50, in Pettus's view, the installation was never small to begin with.

In summary: for anyone already running high availability, the risk isn't the volume of application traffic, but the number of replication streams open at the same time. Sizing max_wal_senders with headroom costs a few megabytes of memory and keeps a routine backup or a standby reconnection from turning into a middle-of-the-night incident.

Translated from the Brazilian Portuguese original · Read the original