Internals · Buffer PoolAtlasDB V6 · Buffer Poolbuffer poolcache hitpage tabledirty pagepinning

The Buffer Pool

A B+ tree finds the right page in four reads — but four SSD reads are 400 µs, and the same four pages are wanted ten thousand times a second. The buffer pool keeps recently used pages in RAM frames, maps page ids to frames with a hash table, tracks which frames are dirty or pinned, and evicts under its own rules instead of the operating system's.

▶ InteractiveInterview question
Progress

Why this exists

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

  1. Problem

    After B+ Tree Internals: Pages, Splits, Merges, a point lookup is root → internal → leaf → heap: four page reads. On an SSD each is ~100 µs; ten thousand lookups a second is four seconds of I/O per second — the correct page is found, and reading it is still the bottleneck.

  2. Naive solution

    Read every page from storage each time it is needed, and let the operating system's file cache absorb the repetition.

  3. Why it breaks

    The OS cache does not know which pages are index roots, cannot promise a page stays in memory while the executor is using it, and flushes dirty data in whatever order it likes — which breaks the ordering the write-ahead log depends on. And every cached page is copied twice: once in the OS, once in the process.

  4. Better idea

    Give the engine its own cache of pages in RAM, sized and managed by the engine: keep the pages that are used, write back the ones that changed when it suits the engine, and never evict a page someone is reading.

  5. Internal mechanism

    A buffer pool: N fixed-size frames, a page table (hash map page id → frame), per-frame metadata (dirty bit, pin count, replacement state), and a replacement policy that picks a victim when every frame is occupied. Hits cost a hash lookup; misses cost one storage read, plus one write if the victim is dirty.

  6. Trade-offs

    RAM is finite and the pool competes with the OS cache, sort memory and connections. Dirty pages are durable only once flushed, which forces a log to exist. A single bad workload — a sequential scan — can evict everything useful.

  7. Real database

    PostgreSQL shared_buffers (8 KB pages, clock-sweep, sitting on top of the OS cache); InnoDB innodb_buffer_pool_size (16 KB pages, LRU with a young/old midpoint, typically 70–80 % of RAM with the OS cache bypassed via O_DIRECT).

Choose your depth

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

A cache of pages, run by the database

Reading a page from RAM costs about a microsecond; reading it from an SSD costs about a hundred. The buffer pool is the block of RAM where the database keeps the pages it has read recently, so that the second and thousandth read of the same page cost the microsecond, not the hundred.

When the pool is full and a new page is needed, an old page has to go. If that old page was changed in memory, it must be written to storage first. Everything else — pinning, dirty bits, hit rates, replacement policies — is bookkeeping around those two sentences.

The problem: the right page, read again and again

Everything before this point made lookups cheap in page reads: a B+ tree of height 3 plus one heap page is four reads whatever the table size. But a read is not free. A page in RAM is reachable in ~100 ns; a random 8 KB read from an NVMe SSD is ~100 µs, from a SATA SSD ~200 µs, from a spinning disk ~10 ms. Four reads per lookup at ten thousand lookups per second is 40,000 page reads per second — 4 seconds of SSD time per wall-clock second. The correct page is being found; fetching it is the bottleneck.

The same pages are wanted over and over. The root page of users_pkey is read by every lookup on users. The internal pages number a few dozen. Even the leaf and heap pages are skewed: in almost every application a small fraction of rows receives most of the traffic. Reading from storage a page that was read a millisecond ago is the waste to eliminate, and the tool is a cache.

Latency ladder (rough, 2020s hardware) — the buffer pool moves page reads up one rung
L1 cache hit              ~1 ns
RAM access             ~100 ns     ← page already in the buffer pool
NVMe SSD random 8 KB   ~100 µs     ← buffer pool miss
SATA SSD random 8 KB   ~200 µs
HDD random 8 KB         ~10 ms
network round trip     ~500 µs (same datacenter)

Frames, the page table and pinning

The buffer pool is a contiguous region of RAM divided into frames, each exactly one page (8 KB in PostgreSQL, 16 KB in InnoDB). A page table — a hash map from (file, page number) to frame index — answers "is this page in memory, and where?" in O(1). Each frame has a small descriptor: the page it holds, a dirty bit, a pin count, and the replacement policy's state.

A hit: the page table finds the frame; the pin count is incremented; the executor gets a pointer into the frame. A miss: the buffer manager chooses a frame to reuse, writes it back if it is dirty, reads the wanted page from storage into it, updates the page table, and pins it. The pin is a promise: while any operation holds a pin, the frame is not a candidate for eviction, because evicting a page out from under a running executor node would hand it garbage. Unpinning happens when the operation is done with the page — a scan that forgets to unpin, or a cursor left open, quietly removes frames from the pool.

A 6-frame pool after a few lookups (AtlasDB V6 layout)
page table (hash map)          frames (6 × 8 KB)
  users_pkey:1  → frame 0        f0 [ users_pkey p1   root      pin=0 dirty=0 ]
  users_pkey:41 → frame 1        f1 [ users_pkey p41  internal  pin=0 dirty=0 ]
  users_pkey:388→ frame 3        f2 [ users     p2093 heap      pin=1 dirty=1 ]  ← being modified
  users:1271    → frame 4        f3 [ users_pkey p388 leaf      pin=0 dirty=0 ]
  users:2093    → frame 2        f4 [ users     p1271 heap      pin=0 dirty=0 ]
                                 f5 [ (free)                                  ]

Dirty pages, flushing and eviction

Writes never go to storage directly. An UPDATE changes bytes inside a frame and sets its dirty bit; storage still holds the old page. The dirty page is written back — flushed — later: when its frame is chosen as a victim, when a background writer cleans frames ahead of demand, or when a checkpoint writes every dirty page. A page updated a hundred times between flushes is written once. This is the write-back behaviour that makes the pool a write cache as much as a read cache, and it has a consequence: between the update and the flush, the only durable record of the change is the write-ahead log, which is why Write-Ahead Logging is not optional in a write-back design.

Eviction is what happens on a miss when no frame is free: the replacement policy picks an unpinned frame, the manager writes it if dirty, and the frame is reused. A miss on a clean victim costs one read; on a dirty victim, one write and then one read — twice the I/O, on the critical path of the query that missed. Background writers exist to move that write off the critical path. Which frame to pick is a question large enough for its own lesson: Buffer Replacement: LRU, Clock and Scan Resistance.

One page request through the buffer manager
foundmissingrequest page Npage table lookuphit: pin framechoose victim (unpinned)dirty? write it backread N from storagepinned frame
UserLLMAgentToolDataDecisionHumanGuardrail

The working set and the hit rate

The working set is the set of pages a workload touches in a representative interval. If it fits in the pool, the hit rate climbs toward 100 % after warm-up and storage is barely involved; if it does not, the pool can only hold a fraction of it and the hit rate drops toward pool size ÷ working set, whatever the replacement policy does. A hit rate of 99 % sounds excellent, but at four page requests per lookup it still means one lookup in 25 pays for a storage read; 99.9 % is ten times better in latency, not 0.9 % better.

Sizing is therefore a question about the working set, not about the data. A 500 GB database whose active rows and index upper levels amount to 20 GB is happy with a 32 GB pool; a 50 GB database scanned uniformly is not helped much by a 40 GB pool. The signal to watch is the miss rate under load and, in PostgreSQL, EXPLAIN (ANALYZE, BUFFERS): shared hit versus read per query tells you where each plan node's time went. Cold-cache latency after a restart or a failover is the same plan with every request missing — expect it, and warm the pool deliberately if it matters.

The same primary-key lookup (4 pages) at different pool states — simulated numbers
Pool stateHits / missesLatencyWhat it means
Cold (after restart)0 / 4~400 µsEvery page from storage; first query on a table after a failover
Typical (upper levels resident)2 / 2~200 µsRoot and internal pages shared by all lookups stay; leaf and heap page miss
Warm (working set fits)4 / 0~4 µsThe benchmark number — real only if production stays warm

Why the database manages its own cache

The operating system already caches file blocks. Why duplicate it? Four reasons. Double buffering: a page read through the OS cache exists twice in RAM — once in the kernel's page cache, once in the database's pool — halving the effective memory. Control over write ordering: the write-ahead rule says a data page may not reach storage before the log records describing it; the kernel flushes dirty file pages in whatever order suits it, so the database must either hold pages itself and write them explicitly or open files with O_DIRECT to bypass the cache. Pinning: the kernel gives no way to say "keep this page in memory while I hold a pointer into it"; a page cache eviction under a running executor is a crash. Knowledge: the engine knows that this read is a sequential scan of a 40 GB table and should not evict the index root, that this page is a B+ tree root and will be needed again, that a sort's temporary pages will never be read twice. The kernel sees a stream of read() calls.

The cost is that the engine is now responsible for everything the kernel would have done: replacement, write scheduling, read-ahead, and the accounting that tells an operator whether the pool is the right size.

  • Double buffering — the same 8 KB in kernel and user memory; avoided with O_DIRECT, at the cost of losing the kernel's read-ahead and its opportunistic use of free RAM.
  • Write ordering — the WAL rule (log before page) requires the engine to control when a dirty page hits storage.
  • Pinning — a frame in use must not move; the kernel offers no such contract for its page cache.
  • Access-pattern knowledge — scans, index roots, temp files: the engine can treat them differently; the kernel cannot.

PostgreSQL: shared_buffers on top of the OS cache

PostgreSQL implementation

PostgreSQL keeps its pool in shared memory (shared_buffers, default 128 MB, commonly set to ~25 % of RAM) and reads files through the ordinary kernel page cache, so a page that misses in shared_buffers may still be served from RAM by the OS — a two-layer cache with double buffering as the price. effective_cache_size tells the planner how much of both layers to expect, and affects plan choice, not memory allocation. Each buffer has a descriptor with a usage count (0–5) for the clock-sweep policy, a pin count, and a dirty flag; the background writer and the checkpointer flush dirty buffers on their own schedules.

The pg_buffercache extension exposes the pool page by page: which relation, which block, whether it is dirty, and its usage count. Aggregating it shows what the pool actually holds — often a surprise, and the most direct evidence in a "should we buy more RAM" conversation. EXPLAIN (ANALYZE, BUFFERS) shows per plan node how many pages hit in shared_buffers and how many were read (from the OS cache or the disk — PostgreSQL cannot tell which).

What is in shared_buffers right now? (requires the pg_buffercache extension)
1CREATE EXTENSION IF NOT EXISTS pg_buffercache;
2
3SELECT c.relname,
4 count(*) AS buffers,
5 pg_size_pretty(count(*) * 8192) AS in_pool,
6 round(100.0 * count(*) / (SELECT setting::int FROM pg_settings WHERE name = 'shared_buffers'), 1) AS pct_of_pool,
7 count(*) FILTER (WHERE b.isdirty) AS dirty,
8 round(avg(b.usagecount), 2) AS avg_usage
9FROM pg_buffercache b
10JOIN pg_class c ON c.relfilenode = b.relfilenode
11GROUP BY c.relname
12ORDER BY buffers DESC
13LIMIT 15;

InnoDB: one big pool with young and old sublists

MySQL / InnoDB implementation

InnoDB's buffer pool (innodb_buffer_pool_size, typically 70–80 % of RAM on a dedicated server, split into innodb_buffer_pool_instances to reduce lock contention) holds 16 KB pages and, with innodb_flush_method = O_DIRECT, bypasses the OS cache entirely — one copy of each page, and the engine alone decides what stays. Because InnoDB tables are clustered indexes, the pool holds the B+ tree that *is* the table; there is no separate heap.

The list is an LRU with a midpoint: the young sublist (5/8) holds pages accessed more than once, the old sublist (3/8) receives newly read pages at its head. A page is promoted to young only if it is accessed again after innodb_old_blocks_time milliseconds (default 1000) — so a scan that reads a page once, or reads it twice within a millisecond, cannot push the working set out. SHOW ENGINE INNODB STATUS reports the young/old split, the hit rate and pages made young, which is the direct measure of whether the midpoint is protecting the working set. The change buffer, the adaptive hash index and the doublewrite buffer also live in or beside the pool; they are covered in InnoDB Internals: Clustered Index, Buffer Pool, Redo, Undo, Locks.

Key points

  • A page in the pool costs ~1 µs to reach; a page on an SSD costs ~100 µs. The buffer pool exists to make the second read of a page cost the first number.
  • Frames hold pages; a hash-table page table maps page ids to frames; each frame carries a dirty bit, a pin count and replacement state.
  • The pool is write-back: dirty pages are flushed later, once, which is why the write-ahead log must exist.
  • Pinned frames cannot be evicted; forgotten pins and open cursors shrink the pool silently.
  • The hit rate is decided by whether the working set fits, not by the size of the data. Size the pool for the working set and read BUFFERS in plans.
  • The engine manages its own cache to avoid double buffering, to control write ordering for the WAL, to pin pages, and because it knows the access pattern.

Buffer pool

The buffer pool: a page cache the database controls
RAM holds a handful of 8 KB frames; storage holds 24 pages. Step through a request stream and watch hits, misses, evictions and write-backs. Click a frame to target it with Pin / Modify.
Workload
Request stream
33121277737123312737373123123333712312
RAM · buffer pool · 6 × 8 KB framesLRU rank: 1 = most recently used
Storage · 24 pages · ~100 µs per page read (SSD)
1
v0
2
v0
3
v0
4
v0
5
v0
6
v0
7
v0
8
v0
9
v0
10
v0
11
v0
12
v0
13
v0
14
v0
15
v0
16
v0
17
v0
18
v0
19
v0
20
v0
21
v0
22
v0
23
v0
24
v0
accent = resident in the pool · orange version = storage is behind the dirty frame
Page table · hash map page id → frame
(empty)
Empty pool. Every frame is free; the first requests are compulsory misses.
Hits
0
Misses
0
Hit rate
0%
Pages read
0
Pages written
0
Dirty frames
0
Est. I/O time
0 µs
Working set
3 pages

Three hot pages fit in 6 frames: after 3 compulsory misses everything is a hit. This is the working set fitting in the pool — the normal, healthy case.

Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.
0/30 requests

When to use — and when not

Use it when
  • Any engine with a page-oriented storage layer: the pool is the layer that makes repeated page access affordable.
  • Workloads with locality — hot rows, hot index upper levels — where a pool smaller than the data still yields hit rates above 99 %.
Avoid it when
  • This design fits poorly when access is uniformly random over data far larger than RAM: the hit rate is bounded by pool ÷ data and the pool becomes overhead; column stores and scan-oriented engines often stream instead.
  • It fits poorly for a single pass over data that will not be read again (bulk loads, one-off scans): those should bypass or ring-fence the pool rather than flood it.

Failure modes

  • A pool sized for the data, not the working set — or the reverse: 25 % of RAM on a machine where the working set is 60 % of RAM.
  • A sequential scan or bulk load evicting the entire working set; latency spikes for minutes afterwards.
  • Double buffering: shared_buffers plus the OS cache each holding the same pages, so effective cache is half of what was paid for.
  • Dirty pages accumulating with no background writer keeping up, so every miss pays a write before its read.
  • Benchmarks run warm and production runs cold after a failover; the "same" query is 100× slower and nobody planned for it.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.