Reported symptoms
Disk usage on db-prod-03 goes from 58% to 74% over four days. No row
count anywhere in the cluster has changed materially.
The growth is not concentrated in one place. About a hundred tables across six schemas are all larger, including several that take fewer than a thousand writes a day and one that is written to weekly.
Latency on the two busiest tables has roughly doubled. Plans that used to
show index-only scans now report large Heap Fetches values.
pg_stat_user_tables shows autovacuum_count increasing and
last_autovacuum recent on every affected table. A manual
VACUUM VERBOSE on the largest table runs for ninety seconds and frees
nothing.
The team raises autovacuum_max_workers from 3 to 8 and drops
autovacuum_vacuum_scale_factor to 0.02 cluster-wide. The growth
continues at exactly the same rate. Somebody proposes VACUUM FULL on
the six largest tables in the next window.
Evidence provided
$ psql -c "VACUUM (VERBOSE) orders;"INFO: vacuuming "app.public.orders"
INFO: finished vacuuming "app.public.orders": index scans: 0
pages: 0 removed, 512044 remain, 512044 scanned (100.00% of total)
tuples: 0 removed, 4188122 remain, 3947551 are dead but not yet removable
removable cutoff: 118904221, which was 11284993 XIDs old when operation ended
avg read rate: 88.412 MB/s, avg write rate: 0.004 MB/s
system usage: CPU: user: 3.11 s, system: 1.02 s, elapsed: 89.44 sIllustrative output
$ psql -c "SELECT pid, usename, state, now()-xact_start AS xact_age, age(backend_xmin) AS xmin_age, left(query,45) AS last_query FROM pg_stat_activity WHERE state LIKE 'idle in transaction%' ORDER BY xact_start;" pid | usename | state | xact_age | xmin_age | last_query
-------+------------+---------------------+-----------------+----------+--------------------------------------------
41182 | j.okonkwo | idle in transaction | 4 days 02:17:51 | 11284993 | SELECT count(*) FROM orders WHERE status = 4;Illustrative output
pg_replication_slots is empty. pg_prepared_xacts is empty.
idle_in_transaction_session_timeout is 0.
pgstattuple on the largest table reports tuple_percent of 11.4 and
free_percent of 4.2.
Work the evidence before reading on
autovacuum_countis rising andlast_autovacuumis recent. Is autovacuum failing?- The vacuum reports
0 removedand3947551 are dead but not yet removable. Which of those two numbers is the diagnosis? - A hundred tables across six schemas are affected. What kind of cause has that reach?
free_percentis 4.2 andtuple_percentis 11.4. Where is the rest of the file?
Root cause
One snapshot, one cluster-wide horizon
Vacuum may only remove a dead row version if no running transaction could still need to see it. It establishes that boundary from the oldest snapshot held anywhere on the cluster and refuses to remove anything newer.
That boundary is one number. It is not per table, per database or per schema, because vacuum cannot know which tables an open transaction might go on to read.
Session 41182 had held a snapshot for four days. Every dead row version created in those four days, in every table, in every database, was protected by it.
The slow queries have the same cause
An index-only scan avoids reading the heap by consulting the visibility map — one bit per page saying “every tuple here is visible to everyone”. Vacuum is what sets those bits, and it cannot set a page all-visible while it contains dead tuples it is not allowed to remove.
So the bits went stale, index-only scans degraded into heap fetches, and
Heap Fetches rose. The latency regression and the disk growth are the
same incident.
The space is dead rows, not free space
pgstattuple separates them, and the distinction points at the fix:
tuple_percent = 11.4— live rows.free_percent = 4.2— reusable space.- The remaining ~84% is dead tuples that vacuum could not reclaim.
If this were ordinary bloat, free_percent would be high — the space
would have been reclaimed and would be waiting for reuse. It is not.
Nothing has been reclaimed at all.
Resolution
Terminate the session. pg_cancel_backend will return true and change
nothing, because there is no statement running to cancel — the session is
idle, holding a transaction.
SELECT pg_terminate_backend(41182);
Confirm the horizon actually moved before doing anything else:
SELECT max(age(backend_xmin)) AS oldest_snapshot FROM pg_stat_activity;
Then vacuum the largest tables explicitly and read the output:
VACUUM (VERBOSE) orders;
The line to see is a non-zero “removed” and 0 are dead but not yet removable. Anything else means something is still holding the horizon.
Revert the autovacuum changes. They were made against a misdiagnosis, they raise load on every table in the cluster, and leaving them in place distorts the next capacity discussion.
Verification
No session has been idle in transaction for more than a few minutes.
VACUUM (VERBOSE) on a previously affected table reports a non-zero
removed count, 0 are dead but not yet removable, and a removable cutoff
close to the current transaction id.
n_dead_tup falls on the affected tables and stops rising.
EXPLAIN ANALYZE of a regressed query shows Heap Fetches: 0 and the
previous execution time.
Table sizes do not fall, and that is correct.
Prevention
Set idle_in_transaction_session_timeout. Minutes, cluster-wide.
This single setting closes the class, and it logs the reason when it
acts:
FATAL: terminating connection due to idle-in-transaction timeout
Alert on transaction age, not disk usage. max(now() - xact_start)
and max(age(backend_xmin)) fire in minutes. Disk fires in days.
Put “dead but not yet removable” on the dashboard. It is the line that distinguishes “autovacuum cannot keep up” from “autovacuum is not allowed to act”, and the two have opposite remedies.
Watch all three horizon holders on one panel, so that nobody diagnoses the wrong one.
Give interactive roles a short timeout. The access is legitimate; the weekend-long transaction is not, and a per-role setting costs nothing.