10 · Project — Diagnose a Slow Database¶
Here is the situation you will eventually be dropped into: an application that "has got slow",
nobody knows why, and you have database access. This project simulates that end to end. You will
build a help-desk database with realistic volumes and one inherited, unhelpful index, replay a
mixed application workload against it with pgbench, find the expensive statements with
pg_stat_statements, read their plans, fix them, and prove the improvement with numbers.
Everything below was run on PostgreSQL 18.6 on a laptop; the absolute numbers are specific to that machine, but the method and the ratios are what you should expect anywhere.
Step 0 — enable pg_stat_statements¶
It must be preloaded, so it needs a restart:
It records every normalised statement (WHERE id = 42 and WHERE id = 7 become WHERE id = $1),
with call counts, total and mean time, rows, and buffer usage. It is the first thing to enable on any
server you are responsible for.
Step 1 — the database¶
CREATE TABLE accounts (id int PRIMARY KEY, name text NOT NULL, plan text NOT NULL);
CREATE TABLE users (id int PRIMARY KEY, account_id int NOT NULL REFERENCES accounts, email text NOT NULL);
CREATE TABLE tickets (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id int NOT NULL REFERENCES accounts,
requester_id int NOT NULL REFERENCES users,
status text NOT NULL,
priority int NOT NULL,
subject text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE comments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ticket_id bigint NOT NULL REFERENCES tickets,
author_id int NOT NULL REFERENCES users,
body text NOT NULL,
created_at timestamptz NOT NULL
);
CREATE INDEX tickets_status_idx ON tickets (status); -- added "for the dashboard" years ago
SELECT setseed(0.7);
INSERT INTO accounts SELECT g, 'Account ' || g, (ARRAY['free','team','enterprise'])[1 + g % 3]
FROM generate_series(1, 20000) g;
INSERT INTO users SELECT g, 1 + (g % 20000), 'Agent' || g || '@Desk.example'
FROM generate_series(1, 200000) g;
INSERT INTO tickets (account_id, requester_id, status, priority, subject, created_at, updated_at)
SELECT a, 1 + ((a - 1) + 20000 * (g % 10)) % 200000,
CASE WHEN random() < 0.85 THEN 'closed' WHEN random() < 0.6 THEN 'open' ELSE 'pending' END,
1 + (random() * 3)::int, 'Ticket ' || g, ts, ts + random() * interval '10 days'
FROM (SELECT g, 1 + (random() * 19999)::int AS a,
timestamptz '2024-01-01' + random() * interval '1000 days' AS ts
FROM generate_series(1, 2000000) g) s;
INSERT INTO comments (ticket_id, author_id, body, created_at)
SELECT 1 + (random() * 1999999)::bigint, 1 + (random() * 199999)::int,
repeat('comment text ', 8), timestamptz '2024-01-01' + random() * interval '1000 days'
FROM generate_series(1, 4000000);
VACUUM ANALYZE;
Loading took about a minute and a half. Resulting sizes:
relname | pg_size_pretty
----------+----------------
accounts | 1576 kB
users | 17 MB
tickets | 233 MB
comments | 724 MB
Ticket status mix: 1.7 million closed, 180,000 open, 120,000 pending.
Step 2 — the workload¶
Five pgbench script files, each modelling an application screen. pgbench's \set draws random
parameters, so every execution hits different rows.
-- inbox.sql: an agent's open tickets
\set acct random(1, 20000)
SELECT id, subject, status, updated_at FROM tickets
WHERE account_id = :acct AND status IN ('open', 'pending')
ORDER BY updated_at DESC LIMIT 20;
-- ticket_page.sql: the conversation on one ticket
\set t random(1, 2000000)
SELECT c.id, c.body, c.created_at, u.email FROM comments c JOIN users u ON u.id = c.author_id
WHERE c.ticket_id = :t ORDER BY c.created_at;
-- login.sql: case-insensitive email lookup
\set u random(1, 200000)
SELECT id, account_id FROM users WHERE lower(email) = lower('agent' || :u || '@desk.example');
-- dashboard.sql: last 90 days by status
\set acct random(1, 20000)
SELECT status, count(*) FROM tickets
WHERE account_id = :acct AND created_at >= timestamptz '2026-09-27' - interval '90 days'
GROUP BY status;
-- reply.sql: an agent replies
\set t random(1, 2000000)
\set u random(1, 200000)
BEGIN;
INSERT INTO comments (ticket_id, author_id, body, created_at) VALUES (:t, :u, 'reply', now());
UPDATE tickets SET updated_at = now(), status = 'pending' WHERE id = :t;
COMMIT;
Run them mixed 30/30/20/10/10, with 8 concurrent clients for 30 seconds:
pgbench -n -c 8 -j 4 -T 30 \
-f inbox.sql@30 -f ticket_page.sql@30 -f login.sql@20 -f dashboard.sql@10 -f reply.sql@10 desk
Step 3 — the baseline¶
number of transactions actually processed: 888
latency average = 274.820 ms
tps = 29.109955 (without initial connection time)
SQL script 1: inbox.sql - latency average = 166.228 ms
SQL script 2: ticket_page.sql - latency average = 646.800 ms
SQL script 3: login.sql - latency average = 64.389 ms
SQL script 4: dashboard.sql - latency average = 225.135 ms
SQL script 5: reply.sql - latency average = 4.406 ms
29 transactions per second, and opening a ticket takes two-thirds of a second. Now ask the database
where the time went. (My first attempt at this query failed with
function round(double precision, integer) does not exist — the time columns are double precision,
and the two-argument round exists only for numeric, hence the casts.)
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time::numeric, 2) AS mean_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct,
(shared_blks_hit + shared_blks_read) / calls AS blks_per_call,
left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = 'desk')
ORDER BY total_exec_time DESC LIMIT 6;
calls | total_ms | mean_ms | pct | blks_per_call | query
-------+----------+---------+------+---------------+--------------------------------------------------------------
258 | 166462 | 645.20 | 69.0 | 81470 | SELECT c.id, c.body, c.created_at, u.email FROM comments c J
262 | 43182 | 164.82 | 17.9 | 22960 | SELECT id, subject, status, updated_at FROM tickets WHERE ac
91 | 20315 | 223.24 | 8.4 | 22758 | SELECT status, count(*) FROM tickets WHERE account_id = $1 A
178 | 11118 | 62.46 | 4.6 | 1568 | SELECT id, account_id FROM users WHERE lower(email) = lower(
99 | 118 | 1.20 | 0.0 | 24 | INSERT INTO comments (ticket_id, author_id, body, created_at
99 | 13 | 0.13 | 0.0 | 15 | UPDATE tickets SET updated_at = now(), status = $1 WHERE id
Sort by total time, not mean: a 1 ms query called a million times matters more than a 2-second
query called once a day. Here the ticket page is 69% of all database time, and blks_per_call —
81,470 pages, about 640 MB touched to show one ticket — says why before you even open a plan.
Step 4 — read the plans¶
Ticket page:
Gather Merge (actual time=480.015..481.462 rows=0.00 loops=1)
Buffers: shared hit=4574 read=77075
-> Sort ...
-> Nested Loop (actual time=474.381..474.383 rows=0.00 loops=3)
-> Parallel Seq Scan on comments c (actual time=474.370..474.370 rows=0.00 loops=3)
Filter: (ticket_id = 123456)
Rows Removed by Filter: 1333366
A full scan of 4 million comments per page view. The cause is the most common missing index in PostgreSQL: foreign-key columns are not indexed automatically (Level 1 · 06).
Inbox:
Limit (actual time=281.176..282.759 rows=17.00 loops=1)
-> Parallel Bitmap Heap Scan on tickets (actual time=70.814..279.326 rows=5.67 loops=3)
Recheck Cond: (status = ANY ('{open,pending}'::text[]))
Filter: (account_id = 777)
Rows Removed by Filter: 100039
-> Bitmap Index Scan on tickets_status_idx (actual time=31.636..31.636 rows=300149.00 loops=1)
The old status index matches 300,149 tickets for every account, then discards all but 17. The
selective column, account_id, has no index.
Dashboard: a parallel sequential scan of all 2 million tickets, removing 666,661 rows per process.
Login: lower(email) with no expression index — a sequential scan of 200,000 users (lesson 4).
Step 5 — fix, without locking the application out¶
On a live system, use CREATE INDEX CONCURRENTLY, which does not block writes (it takes longer and
cannot run inside a transaction block):
SET maintenance_work_mem = '512MB';
CREATE INDEX CONCURRENTLY comments_ticket_created_idx ON comments (ticket_id, created_at);
CREATE INDEX CONCURRENTLY tickets_account_open_idx ON tickets (account_id, updated_at DESC)
WHERE status IN ('open', 'pending');
CREATE INDEX CONCURRENTLY tickets_account_created_idx ON tickets (account_id, created_at) INCLUDE (status);
CREATE UNIQUE INDEX CONCURRENTLY users_email_lower_idx ON users (lower(email));
DROP INDEX CONCURRENTLY tickets_status_idx;
ANALYZE users;
CREATE INDEX Time: 3409.880 ms (00:03.410)
CREATE INDEX Time: 454.521 ms
CREATE INDEX Time: 1478.610 ms (00:01.479)
CREATE INDEX Time: 740.229 ms
DROP INDEX Time: 4.328 ms
The reasoning behind each:
comments (ticket_id, created_at)— equality then sort column, so the comments come out already ordered (lesson 4). It also makes deleting a ticket cheap.tickets (account_id, updated_at DESC) WHERE status IN ('open','pending')— a partial index of only the 300,000 live tickets: 18 MB, against 77 MB for the full-table dashboard index below, and exactly the rows the inbox wants.tickets (account_id, created_at) INCLUDE (status)— lets the dashboard run as an index-only scan.- The unique expression index on
lower(email)both speeds up login and enforces that two users cannot differ only by case — a bug the data had been free to contain. tickets_status_idxwas useless for every query (status has three values) and cost write work on every status change, so it goes.ANALYZE userscollects statistics for the new expression.
Step 6 — verify each plan¶
-- ticket page
Nested Loop (actual time=0.290..0.290 rows=0.00 loops=1)
Buffers: shared hit=3 read=3
-> Index Scan using comments_ticket_created_idx on comments c
Execution Time: 0.610 ms
-- inbox
Limit (actual time=0.140..0.141 rows=17.00 loops=1)
Buffers: shared hit=3 read=17
-> Sort ...
-> Bitmap Heap Scan on tickets
-> Bitmap Index Scan on tickets_account_open_idx
Execution Time: 0.150 ms
-- dashboard
HashAggregate (actual time=0.063..0.063 rows=2.00 loops=1)
-> Index Only Scan using tickets_account_created_idx on tickets
Heap Fetches: 0
Execution Time: 0.075 ms
-- login
Index Scan using users_email_lower_idx on users (actual time=0.014..0.014 rows=1.00 loops=1)
Execution Time: 0.017 ms
Every query now touches a handful of pages instead of tens of thousands.
Step 7 — measure again, the same way¶
number of transactions actually processed: 677611
latency average = 0.354 ms
tps = 22584.123745 (without initial connection time)
SQL script 1: inbox.sql - latency average = 0.374 ms
SQL script 2: ticket_page.sql - latency average = 0.375 ms
SQL script 3: login.sql - latency average = 0.134 ms
SQL script 4: dashboard.sql - latency average = 0.236 ms
SQL script 5: reply.sql - latency average = 0.782 ms
| Before | After | |
|---|---|---|
| Throughput | 29 tps | 22,584 tps |
| Ticket page | 646.8 ms | 0.375 ms |
| Inbox | 166.2 ms | 0.374 ms |
| Dashboard | 225.1 ms | 0.236 ms |
| Login | 64.4 ms | 0.134 ms |
| Reply (write) | 4.4 ms | 0.78 ms |
Even the write got faster — not because writes are cheaper (they now maintain more indexes) but because they no longer compete for CPU and I/O with full-table scans.
Step 8 — check what the fix cost¶
No HOT updates (lesson 3). Every reply updates updated_at and status, and both are now part of
tickets_account_open_idx (as key and predicate), so every update must insert new index entries.
(Before the fix, status was indexed too, so it was not HOT then either.) Here that is the right
trade: reads outnumber writes 9 to 1 and the write still takes under a millisecond. On a write-heavy
table you would weigh it, perhaps with an index on (account_id) WHERE status IN (...) and a sort.
Also check that nothing you created is dead weight:
indexrelname | idx_scan | pg_size_pretty
-----------------------------+----------+----------------
comments_ticket_created_idx | 202827 | 120 MB
tickets_account_created_idx | 67889 | 77 MB
tickets_account_open_idx | 204003 | 18 MB
users_email_lower_idx | 135439 | 8848 kB
How It Actually Works¶
pg_stat_statements hooks into the executor. After parsing, it computes a query ID from the
normalised parse tree (constants removed), so statements that differ only in literal values share
one entry. At the end of execution it adds that run's timing, row count and buffer counters to the
entry in shared memory (persisted across clean restarts). Because it aggregates by query shape, it
shows total cost to the system, which is what you want for prioritising.
pgbench runs each client in a loop: pick a script by weight, substitute the \set variables, send
the statements, wait for results. Latency is measured on the client side, so it includes network
round trips — with 8 clients on a laptop that also runs the server, the client and server compete for
the same CPU, which is one reason not to treat these numbers as a server benchmark.
CREATE INDEX CONCURRENTLY builds the index in phases: it registers the index as not-yet-valid
(so new writes start maintaining it), waits for transactions that might not know about it to finish,
does one table scan to build it, waits again, does a second pass to add rows inserted during the
first, and finally marks it valid. If it fails part-way it leaves an INVALID index behind that you
must drop and retry — check pg_index.indisvalid after any concurrent build.
Extensions¶
- Write a
close_ticket.sqlscript that closes 5% of open tickets and add it to the mix. What happens to the size oftickets_account_open_idxover a few runs, and why? - Replace
tickets_account_open_idxwith(account_id) WHERE status IN ('open','pending')and compare read latency, write latency and HOT ratio. - Run the workload with 32 clients. Where does throughput stop scaling, and what does
pg_stat_activity.wait_eventshow during the run? - Turn on
auto_explainwithauto_explain.log_min_duration = '50ms'(Level 4 · 06), revert one fix, and find the slow plan in the server log.
Exercise¶
Repeat the whole project on your own machine, recording your own before/after table. Then write a
one-page incident report as if this were production: symptoms, evidence (the pg_stat_statements
output and plans), root causes, fixes, verification, and follow-up actions to stop it recurring
(for example, a CI check that every foreign-key column has an index).