Internals · Performanceperformancebuffer hit ratiopage readsexplain buffersfsync

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The query is slow and EXPLAIN ANALYZE is not enough: it says Index 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.

  2. 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.

  3. 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.

  4. 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.

  5. Internal mechanism

    Run EXPLAIN (ANALYZE, BUFFERS): Buffers: shared hit=412 read=3 880 on 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.

  6. 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.

  7. Real database

    PostgreSQL: pg_stat_statements (shared_blks_hit, shared_blks_read, temp_blks_written per statement), pg_stat_database hit ratio, pg_stat_bgwriter checkpoints. InnoDB: Innodb_buffer_pool_read_requests vs Innodb_buffer_pool_reads, Innodb_row_lock_waits, Innodb_os_log_fsyncs. RocksDB: rocksdb.compaction.pending and stall counters.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Two roots, one currency

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.

Three descent chains. Each → is one layer down, and one lesson.
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 recovery

Slow 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.

Read branches: symptom → cause → metric → lesson
BranchInternal causeMetric that reveals itRead
Page readsrows are wide, dead, or scattered across pagesBuffers: shared hit/read ≫ rows returnedSlotted Pages
Buffer missesworking set larger than the poolhit ratio blks_hit/(blks_hit+blks_read), shared read=The Buffer Pool
Scan choiceno usable index, or estimate says scanRows Removed by Filter, est vs actual rowsSequential Scan, Page by Page
Index behaviourrandom heap fetches, hash index on a range, bloatHeap Fetches, idx_scan, index sizeB+ Tree Internals: Pages, Splits, Merges
Join algorithmnested loop × outer rows, hash join in batchesloops=, Hash BatchesJoin Algorithms: Nested Loop, Hash, Merge
Sortingsort larger than work_memSort Method: external mergeInside 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.

Write branches: symptom → cause → metric → lesson
BranchInternal causeMetric that reveals itRead
Index maintenanceone dirty leaf per index per row; random keys missindex count, idx_tup_insert, write amplificationB+ Tree Internals: Pages, Splits, Merges
WAL / fsyncone fsync per commit group; checkpoint flush + full-page writeswal_sync_time, checkpoints_req, checkpoint frequencyWrite-Ahead Logging
Contentionrow lock queue on hot rows; long version chainslock waits, deadlocks, n_dead_tupThe Lock Manager
Compactiondeferred rewrite cannot keep up with ingestcompaction backlog, L0 files, write stallsCompaction: The Merge That Pays for Cheap Writes
Storagerandom vs sequential I/O, fsync latencyiostat await, fsync latencyHow Is Database Data Physically Stored?

Worked example: the table doubled

PostgreSQL implementation

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.

The same node before and after growth (PostgreSQL EXPLAIN (ANALYZE, BUFFERS) shape)
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 total

The write side, worked

PostgreSQL implementation

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.

The metrics for each write branch, PostgreSQL
1-- index maintenance: indexes that are written but never read
2SELECT relname, indexrelname, idx_scan, idx_tup_insert
3FROM 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 checkpoints
7SELECT 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 now
11SELECT 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

MySQL / InnoDB implementation

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

Why is it slow? Descend one layer
Two roots, eleven branches. Every practical symptom has an internal cause, a metric that exposes it, and a lesson one layer down. Click a branch, or start from a symptom card.
Slow queryreads take too longPage readsBuffer missesScan choiceIndex behaviourJoin algorithmSort spillSlow writesINSERT / UPDATE / COMMIT take too longIndex maintenanceWAL & fsyncContentionCompaction (LSM)Storage
Descent chains. Slow query → execution plan → sequential scan → pages → storage reads. Fast index query → B+ tree → internal pages → leaf page → buffer pool → record. COMMIT → transaction → WAL → durability → dirty page → recovery. Each arrow is one lesson down.
Buffer misses
Symptom

The same query is fast the second time and slow the first; latency degraded gradually as the table grew, with no plan change.

Internal cause

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.

Metric that reveals it

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.

What to change

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.

Symptoms, causes and metric names are real; the tree is a teaching map, not a decision procedure. Always confirm with the metric before changing anything.
Diagnose this

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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_mem globally 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_commit to 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.