Scaling Databases & Data Consistency · Finding the Bottleneck

Finding the Queries That Matter

Use pg_stat_statements and the slow query log to find what really loads your database — usually not the slowest query.

Scenario

Example app: a B2B invoicing SaaS on Postgres. Over three months, API p99 latency crept from 180 ms to 900 ms and database CPU sits at 85% at peak. The team's first idea is a bigger instance or read replicas.

What you see in production

The slow query log is full of a monthly revenue report that takes 14 seconds — everyone blames it. But it runs a few times a day. Upgrading the instance buys two weeks before CPU is back at 85%.

The naive code

Nobody looks at aggregate load — only at individual slow queries:

-- what the team looked at: the slowest single statements
SELECT query, max_exec_time FROM pg_stat_statements ORDER BY max_exec_time DESC LIMIT 5;

Reproduce it

A few rows of pg_stat_statements. Sorting by total time (calls × mean) instead of the slowest single call tells a different story:

Slowest query vs biggest total loadTry it yourself
stats = [   # query, calls per day, mean ms
    ("monthly revenue report", 4, 14_000),
    ("GET /invoices list: SELECT … WHERE account_id = $1 ORDER BY issued_at DESC LIMIT 50", 2_400_000, 38),
    ("SELECT * FROM users WHERE id = $1", 9_000_000, 0.4),
    ("SELECT count(*) FROM invoices WHERE account_id = $1", 2_400_000, 21),
    ("UPDATE sessions SET last_seen = now() WHERE id = $1", 6_000_000, 1.1),
]
total_ms = sum(calls * mean for _, calls, mean in stats)
print("by slowest single call:", max(stats, key=lambda s: s[2])[0])
print("by total time per day:")
for name, calls, mean in sorted(stats, key=lambda s: s[1] * s[2], reverse=True):
    share = calls * mean / total_ms * 100
    print(f"  {share:5.1f}%  {calls * mean / 3_600_000:7.1f} h/day  {name[:70]}")

Output

by slowest single call: monthly revenue report
by total time per day:
   60.1%     25.3 h/day  GET /invoices list: SELECT … WHERE account_id = $1 ORDER BY issued_at 
   33.2%     14.0 h/day  SELECT count(*) FROM invoices WHERE account_id = $1
    4.3%      1.8 h/day  UPDATE sessions SET last_seen = now() WHERE id = $1
    2.4%      1.0 h/day  SELECT * FROM users WHERE id = $1
    0.0%      0.0 h/day  monthly revenue report

Root cause

Database load is calls × time per call. A fast query (38 ms) called 2.4 million times a day costs ~25 hours of database time; the "terrible" report costs 15 minutes. Scaling hardware without knowing this just postpones the problem — and read replicas wouldn't even help a query that must read fresh data.

The fix

Make total time the first view of database performance:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;   -- also add it to shared_preload_libraries
SELECT round(total_exec_time / 1000 / 3600, 1) AS hours,
       calls,
       round(mean_exec_time::numeric, 1) AS mean_ms,
       round(100 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct,
       left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Then fix the top entries: here, the invoice list needs an index on (account_id, issued_at) (next lessons), and the per-request count(*) can be cached or dropped from the page.

Trade-offs

  • pg_stat_statements adds a little overhead and normalises parameters away — combine it with auto_explain (log plans of slow statements) to see why a query is slow.
  • Statistics accumulate since the last reset; reset after big changes (pg_stat_statements_reset()) or compare snapshots over time.
  • Application-side timing adds network and pool wait — useful, but different from server execution time.

Detect it in production

  • A weekly "top 10 by total time" review; alert when a new fingerprint enters the top 5 after a deploy.
  • Track calls per request per endpoint: a jump usually means an N+1 slipped in.
  • MySQL: enable the slow log with long_query_time low enough (e.g. 0.2 s) and use pt-query-digest to aggregate it.

Key points

  • Optimise by total time (calls × mean), not by the slowest single query.
  • Enable pg_stat_statements / performance_schema digests everywhere, including staging.
  • Measure before buying hardware or adding replicas.

Practice questions

Why can a 38 ms query be a bigger problem than a 14-second report?

Answer

Load is calls × time per call. Called millions of times a day, the 38 ms query consumes far more total database time than a report that runs a few times.

What does pg_stat_statements record?

Answer

Execution statistics per normalized statement: number of calls, total/mean/min/max execution time, rows, buffer hits and reads — so you can rank queries by total load.

How do you find the heaviest statements on MySQL?

Answer

Use performance_schema.events_statements_summary_by_digest ordered by SUM_TIMER_WAIT, or aggregate the slow query log with a tool such as pt-query-digest.

Open in the app to mark lessons complete, take notes and track your progress.