Performance Internals: Why the Slow Node Is Slow
EXPLAIN names the slow node; the internals layer explains its price. Every slow query is some number of page reads times the fraction that missed the buffer pool, and every slow write is index pages dirtied, an fsync, a lock wait or a compaction debt — each with a metric that exposes it and a lesson one layer down.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
The query is slow and
EXPLAIN ANALYZEis not enough: it saysIndex Scan … actual time=0.4..380 ms, and the same node ran in 8 ms last month. Nothing in the SQL or the plan shape changed.↓ - Naive solution
Add hardware, or add an index on every column that appears in a WHERE clause. Both are cheap to decide and expensive to live with.
↓ - Why it breaks
More RAM helps only if the working set is the problem; more indexes make every INSERT dirty more pages and give the planner more ways to be wrong. The plan node is still 380 ms because you never learned what it was paying for.
↓ - Better idea
Descend one layer and count pages. A plan node costs pages read × (hit ? 1 µs : 100 µs) plus CPU per row; a write costs pages dirtied plus one fsync. Ask which of those numbers grew, and the fix names itself.
↓ - Internal mechanism
Run
EXPLAIN (ANALYZE, BUFFERS):Buffers: shared hit=412 read=3 880on the slow node says 3,880 pages came from disk. The table doubled, the index leaves and heap pages the query touches no longer fit the buffer pool, and each miss is 100 µs — 3,880 × 100 µs ≈ 388 ms. That is the whole slowdown.↓ - Trade-offs
Counting pages is exact but local: it explains one node under one cache state. The buffer pool is shared, so fixing this query by giving it more resident pages can evict another query’s. Performance internals is a budget, not a list of tricks.
↓ - Real database
PostgreSQL:
pg_stat_statements(shared_blks_hit,shared_blks_read,temp_blks_writtenper statement),pg_stat_databasehit ratio,pg_stat_bgwritercheckpoints. InnoDB:Innodb_buffer_pool_read_requestsvsInnodb_buffer_pool_reads,Innodb_row_lock_waits,Innodb_os_log_fsyncs. RocksDB:rocksdb.compaction.pendingand stall counters.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Every performance complaint is either a slow read or a slow write, and both are paid in the same currency: pages moved between disk and memory, plus a few fixed costs (an fsync, a lock wait). A query is slow because it touches many pages or because the pages it touches are not in memory. A write is slow because it dirties many pages, waits for the log, or waits for another transaction.
The practical layer (Query Optimization: Finding the Actual Bottleneck) tells you which plan node is slow. This lesson tells you which of the internal costs that node is paying, which metric proves it, and which internals lesson explains the mechanism.
The descent: from a symptom to a page
The practical lessons stop at the plan node. The internals lessons continue downward until the cost is a count of 8 KB pages and a fraction of them that missed the buffer pool. Three chains cover almost everything: a slow query descends through the plan to pages on storage; a fast index query descends through the tree to the buffer pool; a COMMIT descends through the log to a dirty page and its recovery. Each arrow below is one lesson.
The reason this works is that every layer only ever asks the next one for pages. The executor asks the buffer pool for page 4,217; the buffer pool asks the file for it if it is not resident; the file asks the SSD. A query cannot be slow in a way that does not show up as page reads, CPU per row, or a fixed wait (fsync, lock). That is the whole diagnostic method: find which of the three grew.
Slow query → Execution plan → Sequential scan → Pages → Storage reads
Reading EXPLAIN Sequential scan internals Slotted pages How data is stored
Fast index query → B+ tree → Internal pages → Leaf page → Buffer pool → Record
B+ tree internals Buffer pool Records on disk
COMMIT → Transaction → WAL → Durability → Dirty page → Recovery
Transactions internally Write-ahead logging Crash recoverySlow query: the read branches
Six branches, in the order you should check them. Page reads: the query returns 20 rows but touches 2,000 pages, because rows are wide, dead tuples pad the pages, or the rows are scattered — Buffers: far above rows returned; see Slotted Pages and UPDATE, DELETE and Dead Tuples. Buffer misses: the same pages, but no longer resident — shared read= high, hit ratio falling as the data grows; see The Buffer Pool and Buffer Replacement: LRU, Clock and Scan Resistance. Scan choice: Seq Scan with Rows Removed by Filter: 9,998,120 — either no usable index or a bad estimate; see Sequential Scan, Page by Page, The Planner: Enumerating Ways to Answer, Cost-Based Optimization.
Index behaviour: the index is used and still slow because each match is a random heap page, or unused because it is a hash index and the predicate is a range — Heap Fetches, idx_scan = 0; see B+ Tree Internals: Pages, Splits, Merges and Hash Index Internals. Join algorithm: a nested loop with loops=50000, or a hash join with Batches: 8; see Join Algorithms: Nested Loop, Hash, Merge. Sorting: Sort Method: external merge Disk: 184320kB — the sort spilled; provide the order from an index or raise work_mem; see Inside the Query Engine.
| Branch | Internal cause | Metric that reveals it | Read |
|---|---|---|---|
| Page reads | rows are wide, dead, or scattered across pages | Buffers: shared hit/read ≫ rows returned | Slotted Pages |
| Buffer misses | working set larger than the pool | hit ratio blks_hit/(blks_hit+blks_read), shared read= | The Buffer Pool |
| Scan choice | no usable index, or estimate says scan | Rows Removed by Filter, est vs actual rows | Sequential Scan, Page by Page |
| Index behaviour | random heap fetches, hash index on a range, bloat | Heap Fetches, idx_scan, index size | B+ Tree Internals: Pages, Splits, Merges |
| Join algorithm | nested loop × outer rows, hash join in batches | loops=, Hash Batches | Join Algorithms: Nested Loop, Hash, Merge |
| Sorting | sort larger than work_mem | Sort Method: external merge | Inside the Query Engine |
Slow writes: the write branches
Index maintenance: every index is one more leaf page dirtied per row; random keys make that page a miss and then a flush. Inserting 900 rows of 128 bytes with a UUID index can write 450 pages — 3.6 MB for 115 KB of data, a write amplification above 30×. Metric: index count, idx_tup_insert; lesson B+ Tree Internals: Pages, Splits, Merges, Follow a Write Through the Engine. WAL: COMMIT is an fsync, ~200 µs on an SSD and 5–10 ms on a network volume; group commit amortises it across concurrent committers; checkpoints spike it. Metric: wal_sync_time, checkpoints_req; lesson Write-Ahead Logging, Crash Recovery.
Contention: writes to the same row serialise on its lock; a hot counter row is a queue, and under MVCC it also grows a version chain that VACUUM must trim. Metric: wait_event_type = Lock, deadlocks, n_dead_tup; lesson The Lock Manager, MVCC Internals: Version Chains and Snapshots. Compaction (LSM engines): writes are cheap because the work is deferred; when compaction falls behind, L0 piles up, reads probe every file, and the engine stalls writers. Metric: pending compaction bytes, L0 file count, stall counters; lesson Compaction: The Merge That Pays for Cheap Writes, Write, Read and Space Amplification. Storage: random 8 KB writes and fsyncs are paid at the device’s latency, sequential writes at its bandwidth; metric: iostat await, pg_test_fsync; lesson How Is Database Data Physically Stored?, LSM Trees: Why Some Engines Favour Writes.
| Branch | Internal cause | Metric that reveals it | Read |
|---|---|---|---|
| Index maintenance | one dirty leaf per index per row; random keys miss | index count, idx_tup_insert, write amplification | B+ Tree Internals: Pages, Splits, Merges |
| WAL / fsync | one fsync per commit group; checkpoint flush + full-page writes | wal_sync_time, checkpoints_req, checkpoint frequency | Write-Ahead Logging |
| Contention | row lock queue on hot rows; long version chains | lock waits, deadlocks, n_dead_tup | The Lock Manager |
| Compaction | deferred rewrite cannot keep up with ingest | compaction backlog, L0 files, write stalls | Compaction: The Merge That Pays for Cheap Writes |
| Storage | random vs sequential I/O, fsync latency | iostat await, fsync latency | How Is Database Data Physically Stored? |
Worked example: the table doubled
A lookup by order_id on a 60 GB table with 32 GB of buffer pool: the B+ tree has height 4, the three internal levels are resident, the leaf usually is, and the heap page often is. Buffers: shared hit=5 read=0, 0.3 ms. The table grows to 130 GB. The tree is still height 4 — 200⁴ addresses 1.6 billion rows — but the leaf and the heap page are now resident perhaps half the time. Buffers: shared hit=3 read=2, 0.3 ms + 2 × 100 µs. Under a 200-row IN (…) list: 400 misses, 40 ms. Under a 5,000-row nested loop: a second.
Nothing in the plan changed. The metric that moved is the hit ratio, and the mechanisms are The Buffer Pool (which pages stay) and B+ Tree Internals: Pages, Splits, Merges (which pages the lookup needs). Fixes in order of cost: a covering index so the heap page is not read at all; clustering so consecutive orders share heap pages; partitioning old orders away so the hot leaves are a smaller set; RAM.
Index Scan using orders_pkey on orders (actual time=0.041..0.043 rows=1 loops=200)
Index Cond: (id = ANY ('{…}'::bigint[]))
Buffers: shared hit=1000 ← everything resident: ~9 ms total
Index Scan using orders_pkey on orders (actual time=0.038..0.212 rows=1 loops=200)
Index Cond: (id = ANY ('{…}'::bigint[]))
Buffers: shared hit=602 read=398 ← 398 misses × ~100 µs: ~49 ms totalThe write side, worked
A service inserts events with a random UUID primary key and two secondary indexes, 2,000 rows per second. Each row dirties one heap page (resident: inserts append) and three index leaf pages (random: each is resident with probability equal to the hit ratio). At a 70% hit ratio, 0.9 leaf pages per row are flushed before another row lands on them — 1,800 random 8 KB writes per second plus the heap flushes plus the WAL. The disk sees 15 MB/s of writes for 256 KB/s of data: write amplification ≈ 60×. As the indexes grow past the pool the hit ratio falls and the number climbs, which is what “inserts slow down as the day goes on” looks like from the inside.
The internals fix is to change the shape of the writes: a time-ordered key (ULID, uuidv7, a sequence) makes every insert hit the same right-most leaf, which stays resident and absorbs hundreds of rows per flush; an LSM engine (LSM Trees: Why Some Engines Favour Writes) removes the dirty page entirely and pays compaction instead — the playground in The Database Internals Playground lets you watch the amplification move between the two.
1-- index maintenance: indexes that are written but never read2SELECT relname, indexrelname, idx_scan, idx_tup_insert3FROM pg_stat_user_indexes JOIN pg_stat_user_tables USING (relid)4WHERE idx_scan = 0 ORDER BY idx_tup_insert DESC;5 6-- WAL and checkpoints7SELECT checkpoints_timed, checkpoints_req, checkpoint_write_time FROM pg_stat_bgwriter;8SELECT wal_sync, wal_sync_time, wal_buffers_full FROM pg_stat_wal;9 10-- contention right now11SELECT pid, wait_event_type, wait_event, state, left(query, 60)12FROM pg_stat_activity WHERE wait_event_type = 'Lock';13 14-- per-statement page traffic (needs pg_stat_statements)15SELECT calls, mean_exec_time, shared_blks_hit, shared_blks_read, temp_blks_written, left(query, 60)16FROM pg_stat_statements ORDER BY shared_blks_read DESC LIMIT 10;InnoDB: the same branches, different names
InnoDB tables are clustered on the primary key, so a primary-key lookup is one B+ tree descent with the row in the leaf — no separate heap page — while a secondary-index lookup is two descents (secondary → primary key → clustered tree). A random UUID primary key is therefore worse than in PostgreSQL: it scatters the rows themselves, not just an index. The buffer pool metric is Innodb_buffer_pool_reads (misses) against Innodb_buffer_pool_read_requests; the redo log fsync count is Innodb_os_log_fsyncs, tuned with innodb_flush_log_at_trx_commit; lock waits are Innodb_row_lock_waits and Innodb_row_lock_time_avg; the change buffer hides some secondary-index maintenance by deferring it.
Key points
- Every slow node is pages read × (1 µs hit or 100 µs miss) plus CPU per row plus fixed waits. Find which of the three grew.
EXPLAIN (ANALYZE, BUFFERS)is the bridge between layers:shared hit/read,Rows Removed by Filter,Sort Method,loops=each name a mechanism.- Slow reads: page reads, buffer misses, scan choice, index behaviour, join algorithm, sort spill — check in that order.
- Slow writes: index maintenance, WAL and fsync, contention, compaction, storage — each has one metric that moves.
- Growth without a plan change means the buffer hit ratio moved. Periodic commit spikes mean checkpoints. Inserts degrading over a day mean something accumulating: cold leaves, compaction debt, version chains.
- Internals do not replace the practical loop (one change, re-EXPLAIN); they tell you which change to make.
Why is it slow? Descend a layer
The same query is fast the second time and slow the first; latency degraded gradually as the table grew, with no plan change.
The working set no longer fits the buffer pool. Every page the engine needs that is not resident is a ~100 µs SSD read (10 ms on a disk); a plan that touched 500 resident pages in 1 ms touches the same 500 pages in 50 ms once half of them miss.
Buffer hit ratio: pg_stat_database.blks_hit / (blks_hit + blks_read), pg_statio_user_tables, InnoDB Innodb_buffer_pool_reads vs read_requests. Buffers: shared read= in the plan.
More RAM, or a smaller working set: partition old data away, drop unused indexes that compete for the pool, cluster the hot rows together so fewer pages are hot.
When to use — and when not
- This method fits when the plan shape is fine and the query is still slow, or got slower without a change: the cost moved inside a node, not between nodes.
- When a write path is slow and “add an index” is obviously not the answer.
- When choosing between fixes (RAM, covering index, clustering, partitioning, a different engine) and you need the page arithmetic to rank them.
- Descending a layer does not fit when the practical checklist has not been run: an N+1, a missing index or a fan-out is visible in the plan and fixed without page counting.
- It does not fit a query that runs once a night; the page budget only matters when multiplied by frequency.
- It does not fit as a substitute for measuring: the model says where to look, the metric says whether you were right.
Failure modes
- Tuning
work_memglobally because one sort spilled: every connection now claims that much per sort node, and the buffer pool is squeezed. - Reading the hit ratio as a target (“99% is good”): a 99% hit ratio at a million page reads per second is 10,000 misses per second, a full second of SSD time.
- Adding RAM to fix a query whose problem is
Rows Removed by Filter— it reads the same useless pages faster. - Turning off
synchronous_committo fix commit latency without deciding whether losing the last few hundred milliseconds of commits on a crash is acceptable. - Diagnosing an LSM stall as a disk problem and buying faster disks for a compaction-strategy mistake.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSALinear search → Sequential scan with Rows Removed by FilterO(n) pages, and the constant is the disk.
- DSALRU cache → Buffer pool hit ratio
- DSAExternal merge sort → Sort Method: external merge
- Operating SystemsPage cache and fsync → WAL durability and checkpoint cost