Reported symptoms
Disk usage on the primary reached 88 percent, and the orders table
accounted for most of the growth.
An engineer ran VACUUM FULL orders at 11:20. Within seconds every query
touching orders stopped returning — including simple primary-key
lookups.
The connection pool filled, and requests began timing out across services
that do not use the orders table.
The operation completed after eleven minutes and everything resumed. Disk usage fell from 88 percent to 61 percent, so some regard it as a success.
A plain VACUUM had been run the previous week and freed no disk space at
all, which is why VACUUM FULL was chosen.
Evidence provided
$ psql -c "SELECT * FROM pgstattuple('orders');" table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space | free_percent
-----------+-------------+-----------+---------------+------------------+----------------+--------------------+------------+--------------
77766656 | 200000 | 11261952 | 14.48 | 200057 | 11265091 | 14.49 | 48262032 | 62.06$ psql -c "VACUUM orders;" -c "SELECT * FROM pgstattuple('orders');" table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space | free_percent
-----------+-------------+-----------+---------------+------------------+----------------+--------------------+------------+--------------
77766656 | 200000 | 11261952 | 14.48 | 0 | 0 | 0 | 64438200 | 82.86And the eleven minutes:
$ psql -c "SELECT pid, state, wait_event_type, wait_event, query FROM pg_stat_activity ..." pid | state | wait_event_type | wait_event | query
-------+---------------------+-----------------+------------+------------------------------
11253 | idle in transaction | Client | ClientRead | SELECT count(*) FROM orders;
11269 | active | Lock | relation | VACUUM FULL orders;
11287 | active | Lock | relation | SELECT count(*) FROM orders;
11293 | active | Lock | relation | SELECT count(*) FROM orders;
pid | mode | granted
-------+---------------------+---------
11253 | AccessShareLock | t
11269 | AccessExclusiveLock | f
11287 | AccessShareLock | f
11293 | AccessShareLock | fThe rewrite itself, measured on a comparable table, took 159 milliseconds.
Work the evidence before reading on
- The plain
VACUUMmovedfree_percentfrom 62.06 to 82.86 and lefttable_lenunchanged. Was it a failure? VACUUM FULLran for eleven minutes. How much of that was rewriting?- Why did services that never touch
orderstime out? - What would you have run instead, and how would you have known it was the right choice?
Root cause
Two faults, and the team was right about the first
The bloat was real: 62 percent free space in a heap holding 11 MB of live tuples. Sequential scans were reading four pages for every useful one. Something needed doing.
The remedy was the fault
VACUUM FULL rewrites the table into a new file and holds ACCESS EXCLUSIVE for the whole operation. That conflicts with everything,
including the AccessShareLock that every plain SELECT takes.
Unrelated services timed out because the connection pool filled with blocked sessions. A lock on one table became a connection shortage for everything sharing the pool.
Why VACUUM FULL looked like the answer
The plain VACUUM the previous week did free the space — 62.06 to
82.86 percent — and did not shrink the file, which is the only thing
anybody could see.
Resolution
Stop the bleeding. Cancelling drains the queue immediately:
SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE query LIKE 'VACUUM FULL%';
Then find what it was waiting for — that is what turned 159 milliseconds into eleven minutes:
SELECT a.pid, a.state, a.wait_event_type, a.wait_event,
now() - a.state_change AS in_state_for,
left(a.query, 60) AS query
FROM pg_stat_activity a
WHERE a.pid IN (SELECT pid FROM pg_locks WHERE relation = 'orders'::regclass)
ORDER BY a.state_change;
Measure before deciding. df is not a measurement of bloat:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders');
SELECT * FROM pgstatindex('orders_pkey');
pgstattuple scans the whole table; use pgstattuple_approx on a large
one.
Then choose the instrument by what you need:
- The space will be reused by ongoing writes. A plain
VACUUM, plus autovacuum settings that stop the table reaching 62 percent free again. This is the right answer far more often than it is chosen. - The index is the problem.
REINDEX INDEX CONCURRENTLY— measured at 23 seconds against an active reader, without blocking it. - The disk must genuinely be returned and the table will not regrow.
A rewrite is warranted, and
pg_repackperforms one without the long exclusive lock.
If VACUUM FULL really is right — a permanently shrunken table, in a
window, where a full stop on that table is acceptable — bound it:
SET lock_timeout = '5s';
VACUUM FULL orders;
Failing fast beats blocking, because a blocked ACCESS EXCLUSIVE request
blocks everybody behind it.
Verification
Nothing is waiting on a lock:
SELECT count(*) FROM pg_stat_activity
WHERE wait_event_type = 'Lock' AND state = 'active';
Latency and connection count return to normal, including for the services that do not use this table.
pgstattuple reports the state you intended. After a rewrite on the
measured table: tuple_percent 85.65 and free_percent 0.55, against
14.48 and 62.06 before.
Index density is restored, if that was the goal — 89.95 percent density and 0 fragmentation after a rebuild, against 36.17 and 39.97 before:
SELECT avg_leaf_density, leaf_fragmentation, index_size FROM pgstatindex('orders_pkey');
Prevention
Never run VACUUM FULL on a live table during business hours. Put
that in the runbook in those words; it is an obvious-looking answer with
a non-obvious cost.
Understand what plain VACUUM does, so its result does not look like
a failure.
Measure bloat with pgstattuple, not df, and read free_percent
rather than dead_tuple_percent.
Prefer REINDEX ... CONCURRENTLY for index bloat — the majority of
visible bloat on many workloads. It does not block, though it does wait
for older transactions, so a long-running transaction will delay it.
Prefer pg_repack for heap rewrites where space must genuinely be
returned.
Set lock_timeout before any statement needing a heavy lock.
Alert on idle in transaction. One of those turned 159 milliseconds
into eleven minutes, and it will do the same to your next migration.
Fix the autovacuum settings rather than repeating the rewrite. A table reaching 62 percent free space is not being vacuumed often enough for its churn rate.