| # Rails + PostgreSQL Performance Audit Playbook | |
| A field-tested method for finding what's actually burning your database CPU, built for a | |
| Rails monolith on managed PostgreSQL (Cloud SQL / RDS / etc.). It assumes no APM β just | |
| pg_stat_statements, pg_stat_activity, and discipline. It pairs well with an LLM: point | |
| one at this file plus your query results and let it do the attribution; the queries below | |
| are ordered so a human can run them and paste results back. | |
| ## The core idea | |
| Your cloud console's query dashboard will not tell you what's wrong. Ground truth lives in | |
| two places: pg_stat_statements (cumulative, per-normalized-query) and pg_stat_activity | |
| (live, per-backend). The method is: establish the measurement window (A), read the chronic | |
| top consumers (B/C), check what's happening right now (DβG), and when "chronic" and "now" | |
| disagree, measure a delta window (H). Then map each expensive query family back to code and | |
| fix the mechanism, not the symptom. | |
| ## Why your cloud console lies | |
| - Fingerprint fragmentation. Postgres 15 and earlier fingerprint every IN (...) list | |
| length separately, and Rails' where(id: array) produces every length. One logical query | |
| shatters into hundreds of rows (we measured 1,000+ shapes for a single table's lookups) β | |
| no single row looks scary while their sum saturates the box. PG16's list-squashing helps | |
| but doesn't cover everything. Query C below is the antidote. | |
| - Short retention. Console per-query views typically keep ~7 days β you cannot | |
| time-slice a regression older than that. Fingerprint "birth-dating" (below) substitutes. | |
| - Utility statements are invisible. REFRESH MATERIALIZED VIEW, CREATE INDEX, | |
| VACUUM get one queryid per object name (or aren't tracked at all, depending on | |
| pg_stat_statements.track_utility) and never aggregate into a family. A materialized-view | |
| refresh can be your single biggest CPU consumer and appear NOWHERE in the top-N. | |
| - Only finished statements are recorded. A query that has been running for 12 hours | |
| contributes zero to pg_stat_statements until it completes. Long-running work is only | |
| visible in pg_stat_activity. | |
| - Duration includes lock waits. In pg_stat_activity, a backend blocked on a lock still | |
| shows state = 'active' with a growing duration. Always read wait_event_type: NULL = | |
| genuinely on CPU; Lock = a victim queuing behind someone else. A huge duration can be | |
| either the culprit or the queue behind it. | |
| ## The queries (run in this order, on the primary) | |
| ### A β measurement window (always first) | |
| sql | | | SELECT stats_reset, now() - stats_reset AS window FROM pg_stat_statements_info; | | | | |
| If the window is months long, B/C describe chronic load, which may not be today's problem. | |
| Compute average busy backends: sum(total_exec_time)/1000 / extract(epoch from window) and | |
| compare with current CPU (N cores pegged β N busy backends). A large gap means today's mix | |
| differs from the chronic top-N β rely on H. If the window is only days old, B/C haven't seen | |
| a full weekly cycle yet β same conclusion. | |
| ### B β top consumers with % share | |
| sql | | | SELECT | | | round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct, | | | calls, | | | round(mean_exec_time::numeric, 2) AS mean_ms, | | | round(total_exec_time::numeric / 1000, 1) AS total_s, | | | rows, | | | left(regexp_replace(query, '\s+', ' ', 'g'), 130) AS query | | | FROM pg_stat_statements | | | ORDER BY total_exec_time DESC | | | LIMIT 25; | | | | |
| ### C β fragmentation detector (collapse by prefix) | |
| sql | | | SELECT | | | left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS prefix, | | | count(*) AS shapes, | | | sum(calls) AS calls, | | | round(sum(total_exec_time)::numeric/1000, 1) AS total_s | | | FROM pg_stat_statements | | | GROUP BY 1 | | | ORDER BY total_s DESC | | | LIMIT 25; | | | | |
| High shapes + high total_s = IN-list fragmentation; that family is invisible in your | |
| console's per-query view. | |
| ### D β live wait profile (run 3β4Γ over ~30s) | |
| sql | | | SELECT state, wait_event_type, wait_event, count(*) | | | FROM pg_stat_activity | | | WHERE backend_type = 'client backend' | | | GROUP BY 1,2,3 ORDER BY count(*) DESC; | | | | |
| active + NULL wait_event = on CPU. Many of those on an N-vCPU box = CPU-bound queries | |
| (not IO or locks). | |
| ### E β maintenance and materialized views in flight | |
| sql | | | SELECT pid, wait_event_type, wait_event, now()-query_start AS dur, left(query,80) AS q | | | FROM pg_stat_activity | | | WHERE pid <> pg_backend_pid() | | | AND (query ILIKE 'autovacuum:%' OR query ILIKE '%VACUUM%' OR query ILIKE '%ANALYZE%' | | | OR query ILIKE '%MATERIALIZED VIEW%'); | | | | |
| The MATERIALIZED VIEW match exists because of the utility-statement blind spot above β | |
| this is the only place a runaway refresh shows up. | |
| ### F β dead-tuple / vacuum pressure | |
| sql | | | SELECT relname, n_live_tup, n_dead_tup, | | | round(100*n_dead_tup::numeric/nullif(n_live_tup,0),1) AS dead_pct, | | | last_autovacuum, autovacuum_count | | | FROM pg_stat_user_tables | | | ORDER BY n_dead_tup DESC LIMIT 15; | | | | |
| An outlier autovacuum_count on one table = write churn. The classic Rails cause is a sync | |
| job rewriting rows to the same values (no-op writes still create dead tuples). | |
| ### G β live sampler (run 4β5Γ, ~15s apart) | |
| sql | | | SELECT now() - query_start AS dur, wait_event_type, wait_event, | | | left(regexp_replace(query, '\s+', ' ', 'g'), 130) AS q | | | FROM pg_stat_activity | | | WHERE state = 'active' AND pid <> pg_backend_pid() | | | ORDER BY dur DESC; | | | | |
| Whatever keeps reappearing is what's on CPU right now. Ignore your replica's | |
| START_REPLICATION row. Remember: NULL wait columns = working; Lock = queued victim. | |
| ### H β snapshot diff (gold standard for "what burns CPU today") | |
| Same session/tab, the temp table must survive between steps: | |
| sql | | | -- Step 1: run now | | | CREATE TEMP TABLE pss_snap AS | | | SELECT userid, dbid, queryid, calls, total_exec_time | | | FROM pg_stat_statements; | | | | |
| Wait ~10 minutes (leave the tab open), then: | |
| sql | | | -- Step 2: exec-seconds consumed in the window, per query | | | SELECT round((p.total_exec_time - COALESCE(s.total_exec_time, 0))::numeric / 1000, 1) AS exec_s_10min, | | | p.calls - COALESCE(s.calls, 0) AS calls_10min, | | | left(regexp_replace(p.query, '\s+', ' ', 'g'), 120) AS q | | | FROM pg_stat_statements p | | | LEFT JOIN pss_snap s USING (userid, dbid, queryid) | | | WHERE p.total_exec_time - COALESCE(s.total_exec_time, 0) > 1000 | | | ORDER BY 1 DESC | | | LIMIT 30; | | | | |
| Sanity check: 10 min Γ N saturated cores β NΓ600 exec-seconds total. If the top-30 sums to | |
| ~80β90% of that, the list explains the load. Anything new-today shows here even if invisible | |
| in the cumulative totals. This diff is also independent of when stats were last reset. | |
| ### I β targeted drill-down (template) | |
| sql | | | SELECT calls, round(mean_exec_time::numeric,1) AS mean_ms, | | | round(total_exec_time::numeric/1000,1) AS total_s, | | | left(regexp_replace(query,'\s+',' ','g'),120) AS q | | | FROM pg_stat_statements | | | WHERE query LIKE '%<table_or_keyword>%' | | | ORDER BY total_exec_time DESC LIMIT 10; | | | | |
| ## Automate the capture (optional but worth it) | |
| Wire your cloud provider's "high DB CPU" alert to a webhook endpoint in your app that | |
| enqueues a job running queries AβG read-only, writes them to one text file, uploads it to | |
| object storage, and posts the link to Slack. Whoever is on call downloads the file and | |
| pastes it plus this playbook into an LLM. Two bonuses: the dump is timestamped evidence from | |
| during the incident, and two dumps N minutes apart are a free query-H β subtract the | |
| total_s columns to get exec-seconds burned per family in the window, no temp table needed. | |
| Guard the endpoint with an access key; keep every query read-only. | |
| ## Incident-time attribution without APM | |
| 1. Two dumps = a delta. As above. Families with huge chronic totals but ~0 Ξ are | |
| historical β fixes holding, not today's problem. Don't be fooled by a scary lifetime | |
| mean_ms. | |
| 2. A request-log table = your poor-man's APM. If you don't have one, build it: one row | |
| per request with controller/action, db_duration, query counts, IP, user agent, referer, | |
| indexed on time. It answers "which endpoint is burning the DB right now" and | |
| "bot or organic?" (dozens of distinct residential IPs β organic; one UA walking your | |
| sitemap at dawn β crawler). Query it on the replica so you don't load the pegged primary. | |
| 3. Web vs background jobs. application_name distinguishes your web server from your | |
| job workers: | |
| sql | | | SELECT application_name, | | | count(*) FILTER (WHERE state='active' AND wait_event IS NULL) AS on_cpu | | | FROM pg_stat_activity WHERE backend_type='client backend' | | | GROUP BY 1 ORDER BY on_cpu DESC NULLS LAST; | | | | |
| 4. CPU spikes at a fixed minute past the hour = cron. Find the crontab, note the minute | |
| it fires and the queue it lands on, and remember your graph's sampling grid can shift the | |
| apparent spike a few minutes later. The suspect list for an every-hour spike is closed: | |
| only jobs scheduled every hour can be on it. | |
| ## Interpretation heuristics | |
| - Fingerprint birth-dating: calls Γ· expected-rate estimates when a query shape first | |
| appeared. High total_s with few calls relative to the stats window = recent regression β | |
| check git log around the inferred birth date. | |
| - rows/call ratio: thousands of rows per call = a pluck/materialization that belongs in | |
| SQL (MIN/MAX, COUNT FILTER, width_bucket) or a cache. | |
| - 0 rows over millions of calls = a guard-clause bug. The recurring Rails pattern: bot | |
| traffic yields a nil user/visitor/session, and a find_by(visitor: nil, ...) runs forever | |
| matching nothing. Guard before querying. | |
| - Mean creep: if a known query's mean rises across audits without a plan change, that's | |
| box saturation stretching everything β a victim, not a culprit. Attribution rule: a 9Γ | |
| mean rise with a 20Γ call-rate rise is a new traffic source; a 9Γ mean rise with flat | |
| calls is contention. | |
| - t0_r0/ t1_r0 column aliases in a fingerprint = Rails eager_load / | |
| includes+ references. Check the call sites for group_by, .sample, .drop, [n] | |
| on relations β each forces full materialization of the join. | |
| - Query β code mapping sequence: grep distinctive SQL fragments, then scopes; check the | |
| schema dump for indexes (grep 'ON public.<table>'); fan out one investigation per query | |
| family, each returning file:line + mechanism + fix. | |
| - Never trust a dev schema dump for extension types. If your dev DBs are built by | |
| restoring a prod dump, extension-provided types (e.g. pgvector's vector) can degrade to | |
| TEXT and their indexes silently vanish β and a schema file regenerated from that DB lies | |
| to you. Verify column types and indexes against the production catalog | |
| (pg_attribute/ pg_index), never the checked-in structure file. Same for storage | |
| parameters (autovacuum flags, fillfactor). | |
| ## Recurring root-cause patterns (what we actually keep finding) | |
| Every one of these came out of a real audit; check your codebase for each: | |
| - Cache keys derived from relation.cache_key (a hash of the full SQL with binds | |
| inlined): any per-user/per-filter value in the relation fragments the key space β ~0% hit | |
| rate β the cache exists but caches nothing, and the DB eats every render. | |
| - Aggressively short cache-store timeouts (e.g. Redis read_timeout: 0.2): latency | |
| spikes count as misses, so cache degradation becomes a DB recompute stampede β a feedback | |
| loop that turns a wobble into an incident. | |
| - No-op sync writes: integration jobs writing synced_at/status columns even when | |
| nothing changed. Millions of dead tuples, autovacuum churn, WAL volume β for zero | |
| information. Skip-if-unchanged guards are one line. | |
| - counter_cache with an index on the counter column itself: every increment becomes a | |
| non-HOT update (new index tuples + heap copy), and concurrent increments serialize on row | |
| locks. Usually an EXISTS probe replaces the counter entirely. | |
| - after_touch recalculation storms: a parent recalculating aggregates by all | |
| children on every touch, amplified by batch jobs touching thousands of parents. Replace | |
| with one set-based UPDATE and no_touching around bulk paths. | |
| - Materialized view refreshes with no guard rails: refresh lists living in database | |
| tables (invisible to code review), refreshed hourly with no statement_timeout, no | |
| overlap guard, and non-CONCURRENTLY (ACCESS EXCLUSIVE lock β the next run queues behind | |
| a stuck one forever). Give every refresh a per-object advisory lock (skip, don't queue), | |
| a generous timeout, and version the view definitions in git. And check the refresh order | |
| against the dependency chain β pipelines refreshed most-derived-first serve data that's | |
| hours stale by design. | |
| - Catch-all routes running expensive lookups before 404ing: a wildcard route segment | |
| (get ":slug") that derives an expensive "is this valid" set on every request pays full | |
| price for every crawler probe and dead link. Do the cheap unique-index lookup first; | |
| compute the expensive validation only when the record exists. | |
| - Unindexed lookups inside cron loops: a scheduled job iterating an external dataset | |
| (spreadsheet, API export) doing one find_by per row against an unindexed column. It's | |
| invisible at low volume and becomes a nightly CPU plateau as the dataset grows. Batch-load | |
| with one where(col: ids) + hash lookup β and mind case-insensitive column types | |
| (citext): a SQL match is case-insensitive, a Ruby hash key is not. | |
| - Bot traffic amplifying all of the above. Check your request logs before blaming code: | |
| sanctioned crawlers (search engines, AI bots you allowlisted) walking sitemaps and tag | |
| pages at dawn produce load spikes that vanish during human peak hours. Edge-block or | |
| challenge what you don't want; cache or cheapen what you do. | |
| - The biggest systemic lever is usually replica routing. If your app has a same-sized | |
| read replica idling at 15% while the primary serves 100% of reads, Rails' multi-DB | |
| connected_to(role: :reading) around stateless public pages is worth more than any single | |
| query fix. | |
| ## Verifying a fix without breaking production | |
| - Restore a recent prod dump locally. For each fix, reconstruct the OLD expression from | |
| git show <baseline-sha>:<file> and compare against the NEW code on real data β compare | |
| ordered id arrays, not booleans ("returns something" hides reordering bugs). | |
| - Dev Rails.cache as a NullStore is a feature here: every fetch block executes, so the | |
| comparison is cache-free. | |
| - Any DB write during verification goes inside | |
| transaction { ...; raise ActiveRecord::Rollback }. Never run job workers against a | |
| prod-shaped DB β integration jobs will make real HTTP calls. | |
| - Finish with a server-level HTML diff: run the old and new code against the same local | |
| server and data, curl the affected pages, normalize per-request noise (csrf token, nonces, | |
| profiler ids), and diff. Byte-identical output is the strongest "same results" proof | |
| you can get without deploying. | |
| - Watch for the classic behavior-drift traps: eager_load dedup vs array indexing, ORDER BY | |
| tie groups (plan-dependent), LIMIT 1 without ORDER BY on duplicated keys (arbitrary row | |
| β make it deterministic with .order(:id)), enum String-vs-Symbol comparisons, and | |
| .presence/ group_by/ drop/ sample forcing full loads. | |
| - Merged β deployed β applied. A merged migration isn't live until deployed; a | |
| DB-side change (index, view definition) isn't live until someone runs it. Verify with the | |
| catalog (pg_index.indisvalid, pg_matviews.definition), not with git. | |
| ## Ops guardrails this method assumes | |
| - pg_stat_statements installed and preloaded; reset it after a major traffic change (e.g. | |
| blocking a heavy crawler) so the window describes the workload you actually have β archive | |
| the final B/C first, the cumulative totals are gone forever otherwise. | |
| - Statement timeouts on batch/cron connections. One runaway refresh or analytics query | |
| should die and alert, not camp on a core for 12 hours. | |
| - Every scheduled job's DB work bounded: batch lookups, skip-if-unchanged writes, | |
| advisory-lock overlap guards. | |
| - Alert β automated diagnostics dump (above), so every incident leaves evidence even if | |
| nobody was watching. |
Stop Guessing Your AI Performance: The Professional Guide to Edge AI Benchmarking on Android