Postgres: EXPLAIN ANALYZE, VACUUM, and Why BI Must Leave the Primary
A BI scan on the orders primary froze autovacuum, the replication slot kept WAL, and the disk hit 91%. Read the plan, manage bloat, and keep CDC from filling the volume.
By Dinesh Chandra
Table of contents
Disk on the orders primary hit 91% on a Tuesday
afternoon. Table sizes looked normal. pg_wal was
240 GB. pg_stat_activity showed a Looker extract
that had been seq-scanning orders for 70 minutes
in a still-open transaction.
Autovacuum could not clean the dead tuples that transaction still needed. The Debezium slot was healthy but the WAL volume from the stuck snapshot plus ordinary writes outran the disk we had sized for “OLTP plus a little CDC.” We killed the BI session, the vacuum caught up, and I spent the evening explaining why a dashboard is not a reason to scan the primary.
Wrong number, then disk. Same week a different
extract used SELECT * through a foreign table
and double-counted refunds. The plan would have
said seq scan. Nobody had opened it.
EXPLAIN ANALYZE first
I do not tune a query I have not timed. EXPLAIN
is a guess. EXPLAIN ANALYZE is what ran, with
actual rows and time. If the estimate and the
actual differ by an order of magnitude, your
statistics are stale or your predicate hides a
function on the column.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT order_id, status, amount_cents
FROM orders
WHERE created_at >= now() - interval '7 days'
AND status = 'shipped';
A seq scan on a 200 million row table for a week
of data means created_at is not doing the job —
wrong index, wrapping the column in date_trunc
so the index cannot be used, or a stats target
that still thinks the table is small. Fix the
predicate and the index before you buy a larger
instance.
VACUUM (ANALYZE) orders; after a bulk load is
not folklore. The planner is only as honest as
the last analyze.
flowchart TD
q["Query or extract"] --> plan["EXPLAIN ANALYZE"]
plan --> idx["Index + tight predicate"]
plan --> seq["Seq scan on primary"]
seq --> lock["Long snapshot"]
lock --> bloat["VACUUM cannot reclaim"]
bloat --> disk["Disk and WAL grow"]
idx --> replica["Run it on a replica"]
A long read on the primary is a vacuum problem. Put BI on a replica before it becomes a disk problem.
Bloat and the slot share the disk
Dead tuples stay until no snapshot still needs
them. A BI session, a forgotten BEGIN, or a
CDC snapshot held too long will pin that horizon.
The table file grows. You pay for it in cache
miss and in backup size.
SELECT
relname,
n_dead_tup,
last_autovacuum,
pg_size_pretty(pg_relation_size(relid)) AS rel
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
SELECT
slot_name,
active,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
) AS retained_wal
FROM pg_replication_slots;
I page on retained WAL and on active = false,
the same alarms as in the
Debezium on Postgres guide.
max_slot_wal_keep_size is the circuit breaker
so a dead connector cannot take writes with it.
VACUUM is not optional maintenance. It is how
Postgres stays the same size as your live data.
If you need to be loud, pg_repack or a
controlled rewrite, but only after you fix the
session that prevented the regular vacuum.
Never BI-scan the primary
Warehouse extracts, Looker PDTs, “just one
export,” and ad-hoc SELECT * FROM orders belong
on a replica or on CDC into the warehouse. The
primary’s job is the application write path. A
seq scan steals I/O, holds a snapshot, and
competes with the slot for disk.
CDC is the extract I want for tables that change and delete. Batch dumps are fine for small reference data. Both should leave the primary out of the scan. If you are already running Connect, the apply side of that pipe is in Kafka Connect in production.
Pitfalls
Tuning work_mem before reading the plan.
You will hide a seq scan behind a larger sort
and call it a win.
VACUUM FULL as a habit. It locks the table. Fix the bloat source. Use FULL as a last resort on a quiet window.
A slot left after a deleted connector. Postgres keeps the WAL. Drop the slot in the teardown.
Logical decoding on the primary with no
max_slot_wal_keep_size. The first failed
connector becomes a disk incident.
Pointing Metabase at the writer because the replica was “a few seconds behind.” That lag is cheaper than a locked vacuum.
What this means for your pipelines
Postgres is the source of truth until someone
treats it like a warehouse. I start with
EXPLAIN ANALYZE, I watch dead tuples and
slot retained WAL on the same dashboard, and
I refuse extracts on the primary.
CDC earns its keep when you need deletes and low lag. It also makes the primary a participant in your pipeline. Respect that: heartbeats, slot caps, and no BI session that can pin a snapshot for an hour. The 91% disk was not a storage mis-order. It was a read that should never have landed on the writer.
Enjoyed this post?
Get the next one in your inbox — one email a week, no spam.
Newsletter signup is not live yet. Use the contact form if you want to be notified.