Reported symptoms
The operations dashboard has loaded in under a second for two years. At 06:15 on Tuesday it starts timing out.
One query is responsible. It ran in 38 ms on Monday. On Tuesday it takes over eleven minutes. The query text is byte-identical and nothing has been deployed.
Database CPU is at 96% with a single backend consuming most of it. I/O is close to idle.
The table involved grew from 11 million rows to 51 million overnight, as part of a planned historical import.
The team adds an index on the filtered column. It takes twenty minutes to build and changes nothing. Somebody proposes restarting PostgreSQL because “the plan may be cached”.
Evidence provided
$ EXPLAIN ANALYZE on the dashboard query Nested Loop (cost=0.86..18.94 rows=1 width=48) (actual time=0.041..664218.552 rows=40000000.00 loops=1)
-> Index Scan using transactions_status_idx on transactions t (cost=0.43..8.45 rows=1 width=24) (actual time=0.019..21188.401 rows=40000000.00 loops=1)
Index Cond: (status = 'imported'::text)
Index Searches: 1
-> Index Scan using accounts_pkey on accounts a (cost=0.43..10.48 rows=1 width=32) (actual time=0.014..0.014 rows=1.00 loops=40000000)
Index Cond: (id = t.account_id)
Index Searches: 40000000
Execution Time: 668402.113 msIllustrative output
$ psql -c "SELECT attname, n_distinct, most_common_vals::text::text[] AS mcv FROM pg_stats WHERE tablename='transactions' AND attname='status';" attname | n_distinct | mcv
---------+------------+----------------------------------------
status | 4 | {settled,pending,reversed,disputed}
(1 row)Illustrative output
pg_stat_user_tables reports last_analyze and last_autoanalyze both
null, and n_mod_since_analyze of 40,000,000.
The load ran 02:10 to 05:40 and inserted 40 million rows all carrying
status = 'imported' — a value that did not previously exist.
The load pipeline is: COPY into staging, INSERT ... SELECT into the
target, notify. No ANALYZE.
Work the evidence before reading on
- The estimate is
rows=1and the actual is 40,000,000. Is that a planner bug? Index Searches: 40000000. What decision produced that number, and which node made it?- The table grew from 11 to 51 million rows. Why is that not the problem?
- An index was added on the filtered column and nothing changed. Why was that predictable from the plan?
Root cause
A value the statistics had never seen
The predicate is status = 'imported'. That value appears in no
most-common-values list and in no histogram bucket, because when the
statistics were gathered it did not exist in the table.
The planner concluded the value is essentially absent and produced its
smallest estimate: rows=1.
That is not a bug. It is the correct conclusion from the information available.
Growth would have been survivable; the distribution change was not
This distinction is the useful part of the incident.
pg_class.reltuples is not trusted literally. The planner takes the
density implied by the recorded tuples and pages, and applies it to the
table’s current physical size, which it measures. So a table that has
grown ten times since its last analyze still produces a reasonable
cardinality estimate.
What cannot be scaled is the distribution — n_distinct, the
most-common-values list, the histogram. Those describe the data as it
was, and no amount of measuring the file corrects them.
The window was left open
autovacuum_analyze_scale_factor of 0.1 against a starting 11 million
rows means an analyze was due, and it was scheduled. An analyze of a
51-million-row table takes time, and the 06:15 dashboard queries arrived
first.
The pipeline had no ANALYZE. That left a window between “the data is
visible to queries” and “the planner knows what the data looks like”, and
the dashboard fell into it.
Resolution
ANALYZE VERBOSE billing.transactions;
Confirm the statistics describe the data before re-testing:
SELECT attname, n_distinct, most_common_vals::text::text[] AS mcv, most_common_freqs
FROM pg_stats WHERE tablename = 'transactions' AND attname = 'status';
imported should now appear with a frequency near its true proportion.
Re-run EXPLAIN ANALYZE and read the estimates, not only the
runtime. A query that is fast because the data is cached is not the same
as a query that is fast because the plan is right.
Consider dropping the index added during the incident. It was built on a misdiagnosis, achieved nothing, and every index is maintained on every write and disables HOT updates for statements touching its columns.
Verification
EXPLAIN ANALYZE shows estimates within an order of magnitude of actual
on every node, and a plan suited to the real row counts — a hash join
rather than forty million nested-loop iterations.
Runtime is consistent with the work: 2.4 seconds against Monday’s 38 milliseconds is correct, because the table is five times larger and the query now matches forty million rows rather than none.
pg_stats contains the new value with a plausible frequency.
last_analyze is recent and n_mod_since_analyze is near zero.
The dashboard loads.
And, to confirm the mechanism rather than assume it: repeat the load into a staging copy and run the same query before analyzing. The bad plan should reproduce.
Prevention
ANALYZE at the end of every bulk load. One line, seconds of cost,
and it closes the entire window:
COPY staging FROM ...;
INSERT INTO target SELECT ... FROM staging;
ANALYZE target; -- this line
Analyze after every restore. pg_restore leaves a database with no
statistics at all.
Alert on estimate-to-actual ratio, not only duration. auto_explain
with auto_explain.log_min_duration captures the plan of the slow
execution, which is otherwise unavailable afterwards.
Watch n_mod_since_analyze. A large value on a large table is a
statistics gap in progress, visible before any query is affected.
Know which staleness matters. Growth degrades gracefully; distribution change does not.
Do not add an index during an incident on a hunch. Twenty minutes, no benefit, and permanent write overhead that will outlive everyone who remembers why it exists.