Buffer Management

Imagine a database engine that must answer a query touching ten million disk blocks. Reading a single block from disk costs roughly one hundred thousand times more than reading it from memory. If every request for a block triggered an actual disk I/O, the system would be unusable. Yet real databases sustain thousands of transactions per second against terabyte-scale files. The gap between these two facts is bridged by one component: the buffer manager. This software layer decides which disk blocks live in memory, for how long, and what happens when memory runs out. Buffer management sits directly beneath every other part of the storage engine. The Storage Hierarchy for Databases tells us that memory is fast and scarce. Disk, by contrast, is slow and abundant. Buffer management is the concrete mechanism that exploits that hierarchy. Every access to a record funnels through a single call. Every traversal of a B+-Tree Index funnels through that same call. Every scan governed by a chosen Disk Blocks and File Organization scheme does too. That call is simply: "give me this block." The buffer manager is the only code in the system allowed to say yes or no to that call. Its decisions ripple through the performance of every query the database ever runs. This article dissects the buffer pool as an engineered object. It consists of an array of memory frames, a table mapping disk blocks to frames, per-frame pin counts and dirty bits, and a replacement policy. That policy chooses a victim when the pool is full. We treat the classic policies — LRU, Clock, and MRU — not as folklore but as algorithms. They have precise state machines, complexity bounds, and well-understood failure modes. One such failure mode is the sequential-flooding pathology, in particular. We also confront a key subtlety: databases cannot simply defer to the operating system's virtual memory manager. A DBMS must control exactly when a page is written to disk. This guarantees correctness after a crash. By the end, you will be able to trace a sequence of page requests through a buffer pool by hand. You will predict which policy causes a thrashing pattern and which avoids it. You will also explain why buffer management decisions directly shape which access paths the query optimizer should prefer. This is a preview of the concerns behind Index Selection.

Key takeaways
  • The buffer pool is a fixed set of memory frames; a page table maps disk block IDs to frames currently holding them.
  • Each frame carries a pin count (how many callers are using it) and a dirty bit (has it been modified since being read in).
  • A page can only be evicted when its pin count is zero; dirty pages must be written back before their frame is reused.
  • LRU evicts the least-recently-used unpinned page; Clock approximates LRU cheaply with a circular scan and reference bits; MRU evicts the most-recently-used page, which helps in specific scan patterns like repeated full scans (nested-loop joins).
  • No general-purpose policy is optimal for every access pattern; sequential scans defeat LRU (sequential flooding), motivating specialized hints and policies.
  • The DBMS cannot rely on OS virtual memory because it needs precise control over when dirty pages hit disk, which is essential for crash recovery correctness.
The core insight

The buffer manager is a cache with a crash-safety contract: it must hide disk latency from every other component while still letting the recovery subsystem control exactly when each dirty page reaches stable storage.

The idea

A buffer pool is a region of main memory divided into fixed-size slots called frames, each large enough to hold exactly one disk block (page). The buffer manager maintains a page table — a hash map from page identifiers to frame numbers — so that any component needing a page can ask "is it already in memory?" in expected O(1) time rather than issuing a disk read on every access. Every frame carries two pieces of metadata beyond the raw bytes. The pin count (sometimes called the reference count) records how many active callers currently hold a reference to that frame; a frame with pin count zero is a candidate for eviction, while a pinned frame must never be evicted or overwritten, because doing so would corrupt data underneath a live pointer. The dirty bit records whether the page's contents have been modified since it was loaded from disk; a dirty page must be flushed to disk before its frame can be reused for a different page, or the modification is lost. When a component (the query executor, the index manager, the recovery manager) needs a page, it calls a single operation, conventionally named `pin(page_id)` or `fix(page_id)`. If the page is already resident, the buffer manager increments its pin count and returns the frame. If not, the buffer manager must find a victim frame — an unpinned frame, chosen according to a replacement policy — evict whatever page currently sits there (writing it out first if dirty), read the requested page from disk into that frame, and return it, pinned. When the caller is done, it calls `unpin(page_id, dirty)`, decrementing the pin count and marking the page dirty if it was modified. The replacement policy — LRU, Clock, MRU, or others — is the decision procedure invoked only at the moment a victim is needed and no free frame exists.

How it works

The buffer manager exposes a small, disciplined interface, and every other subsystem is expected to use it religiously rather than touching disk directly. ``` structure Frame: page_id data[PAGE_SIZE] pin_count // integer, starts at 0 dirty // boolean, starts at false // policy-specific metadata: last_used_time (LRU), // reference_bit (Clock), etc. structure BufferPool: frames[0 .. N-1] page_table: map from page_id -> frame_index // O(1) average lookup function PIN(page_id): if page_id in page_table: f = page_table[page_id] frames[f].pin_count += 1 NOTIFY_POLICY_ACCESS(f) // update LRU stamp / Clock ref bit return frames[f] // page not resident: need a frame f = FIND_FREE_FRAME() if f is None: f = SELECT_VICTIM() // replacement policy if f is None: error "buffer pool full: no unpinned frame available" if frames[f].dirty: WRITE_TO_DISK(frames[f].page_id, frames[f].data) page_table.remove(frames[f].page_id) READ_FROM_DISK(page_id, frames[f].data) frames[f].page_id = page_id frames[f].pin_count = 1 frames[f].dirty = false page_table[page_id] = f NOTIFY_POLICY_LOAD(f) return frames[f] function UNPIN(page_id, was_modified): f = page_table[page_id] frames[f].pin_count -= 1 if was_modified: frames[f].dirty = true function FLUSH(page_id): f = page_table[page_id] if frames[f].dirty: WRITE_TO_DISK(page_id, frames[f].data) frames[f].dirty = false ``` `SELECT_VICTIM` is where the replacement policy lives. Three classic strategies:

LRU (Least Recently Used). Maintain frames in an ordering by last access time — typically an intrusive doubly linked list plus a hash map, so both "move to most-recently-used end" and "remove least-recently-used unpinned frame" run in O(1). On every `PIN` hit or load, move the frame to the head. To select a victim, scan from the tail toward the head until an unpinned frame is found. ``` function SELECT_VICTIM_LRU(): node = list.tail while node is not None: if node.pin_count == 0: list.remove(node) return node.frame_index node = node.prev return None // all frames pinned ```

Clock (Second-Chance). An approximation of LRU that avoids the overhead of maintaining an exact recency list. Frames are arranged conceptually in a circle with a single moving "hand" pointer. Each frame has one reference bit. On access, set the reference bit to 1. To find a victim, the hand advances; if it finds a frame with reference bit 0, that frame is evicted; if it finds reference bit 1, it clears the bit to 0 (giving the page a "second chance") and moves on. ``` function SELECT_VICTIM_CLOCK(): loop forever: f = frames[hand] if f.pin_count > 0: hand = (hand + 1) mod N continue if f.reference_bit == 1: f.reference_bit = 0 hand = (hand + 1) mod N else: hand = (hand + 1) mod N return f.index ```

MRU (Most Recently Used). Evicts the most recently used unpinned frame instead of the least. This sounds perverse until you consider repeated full scans: in a block nested-loop join, the inner relation is scanned repeatedly in the same order, and the page just used is the one least likely to be needed again soon (the scan has moved past it and won't return until it wraps around), while pages used long ago are about to be needed next. MRU is implemented with the same linked-list machinery as LRU, just selecting from the opposite end.

Figure
Anatomy of a buffer pool: page table, frames, pin counts, dirty bits, and the LRU chain
Page Table P7 → frame 2 P12 → frame 0 P3 → frame 3 P9 → frame 1 hash map: page_id → frame# Buffer Pool Frames Frame 0 page = P12 pin = 2 dirty = true Frame 1 page = P9 pin = 0 dirty = false Frame 2 page = P7 pin = 0 dirty = true Frame 3 page = P3 pin = 1 dirty = false LRU chain (tail = eviction candidate) F1 (MRU) F3 F0 F2 (LRU) Victim search from LRU end: F2 → pin=0, dirty ⇒ flush then evict. Frame 0 & Frame 2 (orange) = dirty, must be flushed before reuse. Frame 1 & Frame 3 (green) = clean; Frame 3 is pinned (pin=1), never a victim.

Complexity & correctness

Time complexity of the core operations. With a hash-based page table, checking whether a page is resident and locating its frame is O(1) expected time. Updating recency information is O(1) for both LRU (splice a node to the head of a doubly linked list) and Clock (flip a bit). Victim selection differs:

  • LRU: O(1) amortized in the common textbook implementation, because the tail of the list is examined and, once an unpinned frame is found, it is removed directly. In the worst case, if many frames near the tail are pinned, the scan can take O(k) where k is the number of consecutive pinned frames encountered — bounded by O(N) in the pathological case where nearly the whole pool is pinned.
  • Clock: O(1) amortized. Each frame's reference bit is cleared at most once per full sweep of the hand before being considered for eviction, so the total work across a sequence of evictions is linear in the number of frame visits, and in practice the hand rarely sweeps the whole pool. Worst case is O(N) per eviction if all bits are set (a full sweep clearing everything), matching LRU's worst case but with lower constant-factor overhead since no list-splicing is needed on every hit.
  • MRU: identical asymptotic profile to LRU; only the end of the list examined differs.

Space overhead. The buffer pool itself uses N × PAGE_SIZE bytes for data, plus O(N) additional words for pin counts, dirty bits, and policy metadata (list pointers or reference bits), plus O(N) for the page table entries. This metadata overhead is negligible relative to page size (typically 4–16 KB per page versus a handful of bytes of metadata).

Correctness invariants. The buffer manager must maintain three invariants at all times: 1. No frame is evicted while pinned. This is enforced structurally: `SELECT_VICTIM` only ever considers frames with pin count exactly zero. Since pin count is only decremented by `UNPIN`, and every `PIN` is matched by an eventual `UNPIN` in correct client code, a frame in active use (pin count ≥ 1) can never be chosen, so no live pointer into buffer pool memory is ever invalidated by eviction. 2. No modification is lost. Whenever a dirty frame is chosen as a victim, its contents are written to disk before the frame is repurposed. This is the WAL-adjacent guarantee: buffer management does not itself guarantee durability against crashes (that is the job of write-ahead logging and the recovery manager), but it guarantees that within a running system, a modification recorded via `UNPIN(..., dirty=true)` is never silently discarded before being persisted. 3. The page table is a bijection between resident pages and frames. At most one frame maps to a given page_id, and the table is updated atomically with eviction/load, so a lookup never returns a stale or half-loaded frame. This is what makes repeated `PIN` calls for the same page cheap and consistent.

Honesty about tradeoffs. LRU requires touching a shared data structure (the linked list) on every single page access, which under high concurrency becomes a serialization bottleneck — every thread pinning a page must take a lock to splice the list. Clock avoids this: setting a reference bit can be done with a relaxed atomic write, and the sweeping hand only needs coordination at eviction time, which is far rarer than access time. This is precisely why most production database buffer managers (PostgreSQL among them) use Clock or Clock-variants rather than exact LRU, despite LRU's cleaner textbook guarantees — the constant-factor concurrency cost dominates in practice. MRU, meanwhile, is not a general-purpose improvement; it is a specialist tool that only helps under specific repeated-scan access patterns and can be worse than LRU otherwise, since it deliberately discards the page most likely to be reused under typical locality assumptions.

Why not just use the OS page cache / virtual memory? A DBMS could, in principle, memory-map its files and let the operating system's paging mechanism evict pages under memory pressure. This is rejected for two reasons that are worth stating precisely. First, the OS replacement policy (commonly a global LRU-like or clock approximation over all processes' pages) has no notion of pin counts or transaction semantics — it can evict a page the DBMS is mid-transaction with, or write a dirty page to disk at a moment that violates the write-ahead-logging rule ("the log record for a change must reach disk before the changed data page does"). Second, the DBMS has application-specific knowledge (e.g., "this page belongs to a sequential scan and won't be revisited soon") that a general OS cannot exploit. Buffer management is therefore implemented entirely in DBMS user space, precisely to retain this fine-grained control.

Worked example

Consider a buffer pool with N = 3 frames, initially empty, and the request sequence below (each request is a page pin immediately followed by an unpin; "d" marks that the page was modified before unpinning). We trace both LRU and MRU to show how they diverge. Request sequence: `P1, P2, P3, P4(d), P1, P5, P1`

LRU trace:

StepRequestPool state (MRU→LRU order)Action
1P1[P1]miss, load into free frame
2P2[P2, P1]miss, load into free frame
3P3[P3, P2, P1]miss, load into free frame (pool now full)
4P4(d)[P4, P3, P2]miss; evict LRU = P1 (clean, no flush needed)
5P1[P1, P4, P3]miss; evict LRU = P2 (clean) — note P1 must be reloaded from disk!
6P5[P5, P1, P4]miss; evict LRU = P3 (clean)
7P1[P1, P5, P4]hit — P1 already resident

LRU total: 6 disk reads, 1 hit, 1 dirty flush (when P4 is eventually evicted later, not shown here).

MRU trace, same sequence:

StepRequestPool state (MRU→LRU order)Action
1P1[P1]miss, load
2P2[P2, P1]miss, load
3P3[P3, P2, P1]miss, load (full)
4P4(d)[P4, P2, P1]miss; evict MRU = P3 (clean)
5P1[P1, P4, P2]hit — P1 still resident!
6P5[P5, P4, P2]miss; evict MRU = P1 (clean)
7P1[P1, P5, P4]miss; evict MRU = P5 — reload P1

MRU total: 5 disk reads, 2 hits. The interesting contrast is at step 4→5: LRU evicts P1 (the true least-recently-used page at that instant) and immediately pays for it at step 5 when P1 is re-requested — a textbook instance of thrashing under a workload where an "old" page is about to be reused. MRU happened to keep P1 around because it was not the most recently touched, producing a hit. This does not mean MRU is "better" in general — it means the outcome is workload-dependent, which is exactly the point: no single fixed policy dominates on every access pattern, and this is the concrete evidence for that claim rather than an assertion of it.

Sequential flooding illustration. Suppose instead the workload is a full table scan reading pages `P1, P2, P3, P4, P5, P6, ...` once each, interleaved with an index lookup that repeatedly touches a small "hot" page `H`. With a pool of size 3 under LRU: ``` H, P1, P2, P3, H, P4, P5, P6, H, ... ``` Every time the scan brings in three fresh pages, `H` — despite being genuinely hot — gets pushed to the LRU end and evicted, because the scan's one-time-use pages keep looking "more recent" than H by the time H is needed again if the pool is small relative to the scan length. This is sequential flooding: a large one-pass scan flushes genuinely reusable pages out of a small buffer pool. It is the standard justification for giving the buffer manager scan-aware hints (e.g., PostgreSQL's ring buffers for large sequential scans, or MRU-style buffers for such scans) rather than relying on pure LRU everywhere.

Common pitfalls & traps

PitfallWhy it happensConsequence
Forgetting to `unpin` after useCaller treats pin/unpin as optional bookkeepingPin count never returns to zero; frame becomes permanently un-evictable, silently shrinking the effective pool size
Evicting a dirty page without flushingVictim selection logic checks pin count but skips the dirty-bit checkCommitted-looking updates vanish; violates durability and can corrupt on-disk state
Treating Clock's reference bit as an exact recency measureConfusing an approximation with the real thingUnder bursty access with many pinned pages, Clock degrades toward FIFO-like behavior, surprising engineers expecting LRU-quality decisions
Using MRU as a default, general-purpose policyOver-generalizing from the nested-loop-join special casePerformance regressions on normal, locality-heavy workloads where recently used pages genuinely are likely to be reused
Assuming OS virtual memory can replace the buffer managerBelieving "the kernel already does caching, why duplicate it"Loses control over exact flush timing, breaking the write-ahead-log ordering guarantee needed for crash recovery
Off-by-one in Clock's hand advancementAdvancing the hand before checking pin/reference bit, or vice versa, inconsistentlyEither skips a candidate frame every sweep or infinite-loops when all frames are pinned

Pioneers & history

Buffer management as a discipline grew out of early virtual memory and paging research, then was specialized and hardened by the database systems community once it became clear that generic OS paging policies were unsuited to transactional correctness requirements.

Origins · 1 minPeter J. Denning and how this was discovered
PJ
Peter J. Denning
born 1942 · Pioneered working-set theory of memory management, giving the theoretical vocabulary — locality, thrashing, working set — later adapted to explain buffer pool behavior and sequential flooding.

history 1968 — Denning formalizes the working-set model, explaining thrashing as a mismatch between allocated frames and a program's locality. 1970s — Relational database prototypes (System R, Ingres) build dedicated buffer managers distinct from OS virtual memory, asserting explicit control over page residency. 1976 — The ARIES-precursor line of work on write-ahead logging begins clarifying why buffer eviction timing must be coordinated with log flushing. 1992 — The ARIES recovery algorithm (Mohan et al.) formalizes the precise contract between the buffer manager's dirty-page flushing and crash recovery correctness. 2000s–present — Production engines (PostgreSQL's Clock-sweep buffer replacement, InnoDB's midpoint-insertion LRU) refine replacement policies for concurrency and scan-resistance at scale.

Where this matters

Every relational database engine — PostgreSQL, MySQL/InnoDB, Oracle, SQL Server, SQLite — implements a buffer pool as one of its earliest-loaded, most performance-critical subsystems; PostgreSQL calls it `shared_buffers` and uses a Clock-sweep variant, while InnoDB uses a midpoint-insertion LRU list specifically designed to resist sequential-scan flooding by inserting newly read pages partway into the list rather than at the very head. Buffer management also underlies key-value stores and search engines (e.g., page caches inside storage engines like RocksDB and WiredTiger), any system built on B-tree or LSM-tree storage, and the buffer pool's behavior is a first-class input to query optimizers: an optimizer estimating the cost of a join or scan must account for how many of the needed pages are likely to already be buffer-resident, which is exactly the concern that motivates the next layer of reasoning in Index Selection — choosing access paths partly based on how well they interact with buffer pool locality. Understanding pin counts and dirty bits is also prerequisite to understanding write-ahead logging and crash recovery, since the recovery manager's correctness guarantees are literally statements about the order in which the buffer manager is allowed to flush dirty pages relative to log records.

Ask the tutor: How would you redesign the buffer pool's replacement policy to automatically detect and resist sequential flooding without hurting normal random-access workloads?

What exactly is a pin count, and why not just use a boolean "in use" flag?

A pin count is an integer because multiple callers can legitimately hold a reference to the same page simultaneously — for instance, two concurrent index lookups traversing the same root node, or a scan operator and a concurrent update touching the same leaf page. A boolean flag could only represent "in use by someone" versus "free," which would force the buffer manager to either block the second caller unnecessarily or incorrectly report the frame as free the instant the first caller finishes, even though a second caller still depends on it. The integer count lets `unpin` safely decrement without disturbing other legitimate holders, and only when the count reaches exactly zero does the frame become eligible for eviction.

How does Clock actually approximate LRU without maintaining an ordered list?

Clock keeps one reference bit per frame and a single circulating "hand." Any access sets the bit to 1, marking "recently used" cheaply. When a victim is needed, the hand advances frame by frame; a bit of 1 is cleared to 0 and skipped (giving a "second chance"), while a bit of 0 means the frame has not been touched since the hand last passed it, and it is evicted. Frames touched frequently keep having their bit re-set to 1 before the hand returns, so they survive; frames touched once and forgotten get evicted on the hand's next pass. It is an approximation because it only distinguishes "used since last sweep" from "not used," not a full recency ordering — but this coarser signal is far cheaper to maintain concurrently.

Does a bigger buffer pool always help performance?

Generally yes up to a point, since more resident pages means fewer disk I/Os, but returns diminish and can even reverse. If the pool is much larger than the working set, extra frames sit mostly idle. If the workload is dominated by one-time sequential scans larger than the pool, no amount of extra buffer space up to the scan's own size fully prevents eviction pressure on other hot pages, since flooding is about competition between working sets, not raw pool size alone. There is also a real cost: memory devoted to the buffer pool is memory unavailable to sort/hash operators, connection state, or other processes, so sizing it is a genuine systems tradeoff, not a "bigger is strictly better" knob.

What happens if every frame in the pool is pinned and a new page must be loaded?

The buffer manager cannot find a victim and must block or fail the request. In practice, this typically manifests as the requesting transaction waiting until some other transaction unpins a frame, or, in severe cases, the DBMS raising an out-of-memory / buffer-pool-exhausted error if waiting cannot resolve the situation (for example, in a deadlock-like configuration where all active transactions are mutually waiting on pins). This is one reason production systems size the buffer pool conservatively relative to expected concurrent pin usage, and why some operators are written to release pins as early as correctness permits rather than holding them for an entire query's duration.

How does the dirty bit interact with write-ahead logging?

The write-ahead-logging rule states that the log record describing a change must reach stable storage before the corresponding data page is written to stable storage. The buffer manager's dirty bit is the trigger for that data-page write: when a dirty frame is chosen as a victim (or flushed at a checkpoint), the buffer manager must consult the log manager and ensure the relevant log record has already been forced to disk before the data page write proceeds. This is precisely why buffer eviction cannot be delegated to a generic OS pager, which has no notion of "the log record for this page's last change" and could flush the data page at the wrong moment, breaking the recovery algorithm's correctness guarantees.

Is LRU or Clock "the right answer" for a production system?

Neither is universally right; the honest answer is that they trade exactness for concurrency cost differently. Exact LRU gives a cleaner theoretical recency guarantee but requires synchronized updates to a shared list on every single access, which becomes a scalability bottleneck under many concurrent threads. Clock gets a looser approximation of recency but only needs a relaxed bit-set on access and coordination only at the rarer eviction moment, so it scales better under contention. Most production systems (PostgreSQL, many storage engines) choose Clock or Clock-like variants for exactly this reason, sometimes combined with scan-resistant tweaks (midpoint insertion, separate ring buffers for large scans) to also blunt sequential flooding.

Why is MRU not simply "wrong," given LRU is the textbook default?

Replacement policies encode an assumption about future access patterns, and no single assumption is correct for every workload. LRU assumes recently used pages are likely reused soon, which holds under typical locality-of-reference patterns like index traversals revisiting hot upper levels. MRU assumes the opposite — that the just-used page is the least likely to be needed again soon — which is precisely true in a bounded, repeated cyclic scan such as the inner loop of a block nested-loop join, where the scan will not revisit the current page until it has cycled through all the others. The lesson is not that one policy is universally correct, but that the right choice is workload-dependent, which is why real systems expose scan-aware buffering strategies rather than a single fixed global policy.

Before this

Where this leads