๐Ÿ“š Study Notes / Home / DBMS / Session 5
Session 05 ยท Buffer Pool โ€” Replacement

Keeping the right pages in memory

In Session 4 we built the buffer pool โ€” the database's in-memory cache of disk pages โ€” and we used plain LRU to decide what to throw out. This session is about a surprising weakness: one big query can wreck that cache and slow everything down. We'll see why, meet smarter eviction policies like LRU-K, learn how real databases stay "scan-resistant," and finish with prefetching tricks that hide disk latency. We assume you've studied none of this before โ€” every topic starts with a tiny everyday story before we go deep.

โฑ 28 min read๐Ÿ“– 4 topics

1 The weakness of plain LRU


Explain like I'm 5

Imagine your desk only fits 5 books. You keep the books you used most recently on the desk and shove the oldest one back on the shelf when you need room. That's a good habit โ€” until you spend one afternoon flipping through 500 reference books one time each for a single report. By the end, your 5 favourite everyday books are all gone โ€” pushed off the desk by 500 books you'll never open again. You wrecked your nice desk for one big task. That's exactly what one giant database query can do to memory.

First, let's recall the setup from Session 4. The buffer pool is a region of the database's RAM divided into fixed-size slots called frames. Each frame can hold one disk page (a fixed-size block, often 4 KBโ€“16 KB). When a query needs a page, the system checks the buffer pool first:

๐Ÿ”Ž
1. Look up
Is the page already in a frame?
โ†’
โœ…
2a. Hit
Found in RAM โ€” return it fast
โ†’
๐Ÿ’ฝ
2b. Miss
Not in RAM โ€” read from disk
โ†’
๐Ÿ—‘๏ธ
3. Evict
No free frame? Pick a victim to remove

That step 3 โ€” choosing which page to kick out โ€” is the job of the replacement policy (also called the eviction policy). A cache hit means the page was already in RAM (cheap, nanoseconds); a cache miss forces a disk read (expensive, often thousands of times slower). The whole game is to maximise the hit rate by keeping the pages we're most likely to reuse.

Recalling LRU

LRU (Least Recently Used) is the classic policy: when you must evict, throw out the page that hasn't been touched for the longest time. The bet is simple and usually good โ€” recently used pages are likely to be used again soon (this is called temporal locality). LRU is typically implemented with a doubly-linked list (or an equivalent) where every access moves the page to the "most recently used" front; the victim is always the page at the "least recently used" back.

OperationWhat LRU doesCost
Access a pageMove it to the front (most-recently-used end)O(1)
EvictRemove the page at the back (least-recently-used end)O(1)

Here is a complete O(1) LRU frame manager: a hash map from page_id to a node, plus a doubly-linked list ordering nodes from most- to least-recently-used. Every access splices the node to the front; eviction unlinks the node at the back.

# Classic LRU buffer-pool frame manager: hash map + doubly-linked list, O(1)
class Node:
    def __init__(self, page_id):
        self.page_id = page_id
        self.prev = self.next = None

class LRUBufferPool:
    def __init__(self, capacity):
        self.capacity = capacity
        self.table = {}                 # page_id -> Node
        # sentinel head (MRU side) and tail (LRU side)
        self.head, self.tail = Node(None), Node(None)
        self.head.next, self.tail.prev = self.tail, self.head

    def _unlink(self, n):
        n.prev.next, n.next.prev = n.next, n.prev

    def _push_front(self, n):       # insert right after head = MRU
        n.prev, n.next = self.head, self.head.next
        self.head.next.prev = n
        self.head.next = n

    def access(self, page_id):       # returns "HIT" or "MISS"
        n = self.table.get(page_id)
        if n is not None:
            self._unlink(n)
            self._push_front(n)         # promote to MRU
            return "HIT"
        if len(self.table) >= self.capacity:
            self._evict()
        n = Node(page_id)
        self.table[page_id] = n
        self._push_front(n)
        return "MISS"

    def _evict(self):                # victim = node before tail (LRU)
        victim = self.tail.prev
        self._unlink(victim)
        del self.table[victim.page_id]
        return victim.page_id

if __name__ == "__main__":
    bp = LRUBufferPool(3)
    for p in [1, 2, 3, 1, 4, 2]:   # access 4 evicts LRU page 2 (then 2 misses again)
        print(p, bp.access(p))
// Classic LRU buffer-pool frame manager: hash map + doubly-linked list, O(1)
#include <iostream>
#include <unordered_map>
#include <list>

class LRUBufferPool {
    size_t capacity_;
    std::list<int> order_;                                  // front = MRU, back = LRU
    std::unordered_map<int, std::list<int>::iterator> table_;  // page_id -> node
public:
    explicit LRUBufferPool(size_t capacity) : capacity_(capacity) {}

    std::string access(int page_id) {                       // returns "HIT" or "MISS"
        auto it = table_.find(page_id);
        if (it != table_.end()) {
            order_.splice(order_.begin(), order_, it->second);  // promote to MRU
            return "HIT";
        }
        if (table_.size() >= capacity_) evict();
        order_.push_front(page_id);
        table_[page_id] = order_.begin();
        return "MISS";
    }

    int evict() {                                          // victim = back of list (LRU)
        int victim = order_.back();
        order_.pop_back();
        table_.erase(victim);
        return victim;
    }
};

int main() {
    LRUBufferPool bp(3);
    for (int p : {1, 2, 3, 1, 4, 2})        // access 4 evicts LRU page 2
        std::cout << p << " " << bp.access(p) << "\n";
}
// Classic LRU buffer-pool frame manager: hash map + doubly-linked list, O(1)
import java.util.HashMap;

public class LRUBufferPool {
    static class Node {
        int pageId;
        Node prev, next;
        Node(int pageId) { this.pageId = pageId; }
    }

    private final int capacity;
    private final HashMap<Integer, Node> table = new HashMap<>();
    private final Node head = new Node(-1), tail = new Node(-1);  // sentinels

    public LRUBufferPool(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.prev = head;
    }

    private void unlink(Node n) { n.prev.next = n.next; n.next.prev = n.prev; }

    private void pushFront(Node n) {              // insert after head = MRU
        n.prev = head; n.next = head.next;
        head.next.prev = n; head.next = n;
    }

    public String access(int pageId) {           // returns "HIT" or "MISS"
        Node n = table.get(pageId);
        if (n != null) {
            unlink(n); pushFront(n);             // promote to MRU
            return "HIT";
        }
        if (table.size() >= capacity) evict();
        n = new Node(pageId);
        table.put(pageId, n);
        pushFront(n);
        return "MISS";
    }

    public int evict() {                          // victim = node before tail (LRU)
        Node victim = tail.prev;
        unlink(victim);
        table.remove(victim.pageId);
        return victim.pageId;
    }

    public static void main(String[] args) {
        LRUBufferPool bp = new LRUBufferPool(3);
        for (int p : new int[]{1, 2, 3, 1, 4, 2})  // access 4 evicts LRU page 2
            System.out.println(p + " " + bp.access(p));
    }
}

So where does LRU break?

LRU has one fatal blind spot: it only looks at how recently a page was touched, never at how often. To LRU, a page touched once a moment ago looks more valuable than a page touched a thousand times but a little while back. A single large query that reads many pages exactly once โ€” a full table scan โ€” can therefore stream a flood of "use-once" pages through the cache, each marked "most recently used," pushing out the genuinely hot pages.

The big idea

This failure has a name: sequential flooding (sometimes "cache pollution" or "scan thrashing"). A one-time sequential scan evicts the frequently-reused working set, so after the scan the database has to re-read everything from disk. LRU mistook "just touched" for "valuable" โ€” and got fooled by a scan that touches each page only once.

Worked example: a scan poisons a 3-frame cache

Buffer pool with 3 frames, plain LRU. Pages A, B, C are hot โ€” they get used constantly. A reporting query then scans new pages P1, P2, P3 once each.

StepAccessCache (MRUโ†’LRU)Hit/Miss
0warmA, B, Chot set loaded
1P1P1, A, Bmiss โ†’ evict C
2P2P2, P1, Amiss โ†’ evict B
3P3P3, P2, P1miss โ†’ evict A
4A (hot again)A, P3, P2miss! A was evicted

After three throwaway pages, every hot page is gone. When the app goes back to its bread-and-butter pages A, B, C, they all miss and must be re-read from disk. The scan touched P1, P2, P3 exactly once, yet it cost us our entire working set.

Watch out

This is worst when the scan is bigger than the buffer pool. If you sequentially read a table with more pages than you have frames, plain LRU evicts every earlier page before you ever reuse it โ€” so even re-scanning the same table from the top gives a 0% hit rate. We'll see the clean fix (MRU for scans) in Topic 3.

The takeaway is not "LRU is bad" โ€” for ordinary workloads it's great. The takeaway is that LRU has a known failure mode under large scans, and smarter policies try to tell "used once by accident" apart from "genuinely hot." That's exactly what Topic 2 builds.

Recap The buffer pool caches disk pages in frames; the replacement policy picks eviction victims. Plain LRU evicts the least-recently-touched page and works well on locality โ€” but it only tracks recency, not frequency. A single large scan of use-once pages floods the cache (sequential flooding) and evicts the hot working set, tanking the hit rate. We need policies that resist this.

2 LRU-K: counting references, not just recency


Explain like I'm 5

Imagine deciding which friends to keep on speed-dial. A bad rule is "whoever I called last" โ€” that puts a wrong-number stranger ahead of your best friend. A smarter rule: "look at the last two times I called each person." Your best friend was called twice, recently and often. The stranger was called only once, ever. Now it's obvious who to keep. LRU-K does this with disk pages: it remembers the last K times each page was used, so a page touched once can't fool it.

LRU-K (O'Neil, O'Neil & Weikum, 1993) generalises LRU. Plain LRU only remembers the single most recent access to each page. LRU-K instead remembers the timestamps of the last K accesses. From those it estimates each page's reuse distance โ€” roughly, "how long until I'd expect to touch this page again." It evicts the page whose predicted next use is farthest away.

The key quantity: backward K-distance

For each page, look back through history and find the timestamp of its K-th most recent access. The gap between "now" and that timestamp is the backward K-distance. A page with a small K-distance has been hit K times recently (it's hot); a page with a huge K-distance hasn't accumulated K recent hits (it's cold, or brand-new). The victim is the page with the largest backward K-distance.

Why K = 1 is just plain LRU

If K = 1, the "K-th most recent access" is simply the most recent access โ€” so LRU-1 evicts the page with the oldest single access, which is exactly LRU. Increasing K adds memory of how often a page is reused, not only when it was last touched.

Why LRU-2 resists flooding

The popular choice is LRU-2 (K = 2): track the last two references. Here's the magic. A scan touches each page exactly once, so a scanned page has only a first reference โ€” it has no second-most-recent access, so its backward 2-distance is treated as infinite (or as the oldest possible time). That makes scanned, use-once pages the first to be evicted, while a hot page that was genuinely referenced twice keeps a small finite K-distance and survives. The scan can no longer push out the working set.

Key takeaway

A page must "prove itself" by being referenced at least K times before LRU-K treats it as worth keeping. One accidental touch is no longer enough to look valuable. That single change is what makes LRU-2 scan-resistant where LRU is not.

Worked example: LRU vs LRU-2 on the same trace

3 frames. Hot pages A, B are touched repeatedly; a scan brings in P1, P2, P3 once each. Reference string: A B A B P1 P2 P3 A B.

Plain LRU (recency only):

AccessCache (MRUโ†’LRU)Result
A B A BB, Awarm hot set
P1P1, B, Amiss
P2P2, P1, Bmiss โ†’ evict A
P3P3, P2, P1miss โ†’ evict B
AA, P3, P2miss (A was evicted)
BB, A, P3miss (B was evicted)

Both hot pages were lost: 2 extra misses on the re-access of A and B.

LRU-2 (evict largest backward 2-distance; scanned pages have an infinite 2-distance because they were touched only once):

AccessWhat LRU-2 keepsResult
A B A BA and B each have two references โ†’ small, finite K-distanceboth protected
P1P1 has one ref โ†’ โˆž 2-distance; cache = {A, B, P1}miss
P2evict P1 (โˆž distance), not A or B; cache = {A, B, P2}miss โ†’ evict P1
P3evict P2 (โˆž distance); cache = {A, B, P3}miss โ†’ evict P2
AA is still residentHIT โœ…
BB is still residentHIT โœ…

LRU-2 sacrifices the throwaway scan pages among themselves and keeps A and B the whole time. The two re-accesses are hits instead of misses. The use-once pages couldn't accumulate a second reference, so they were always the cheapest to discard.

Pseudocode sketch

Each page keeps a small history of its last K access timestamps. HIST(p, K) is the timestamp of the K-th most recent access to page p, or 0 (treated as โˆž distance) if it has fewer than K accesses.

# LRU-K: keep last K access timestamps per page; evict the page whose
# K-th most recent access is OLDEST (largest backward K-distance).
from collections import deque

class LRUKBufferPool:
    def __init__(self, capacity, k):
        self.capacity = capacity
        self.k = k
        self.now = 0                     # logical clock, ticks per access
        self.hist = {}                    # page_id -> deque of last K timestamps

    def _kth_distance(self, page_id):     # backward K-distance; inf if < K refs
        h = self.hist[page_id]
        if len(h) < self.k:
            return float("inf")        # too few refs => evict first
        return self.now - h[0]         # h[0] = K-th most recent access

    def _record(self, page_id):
        self.now += 1
        h = self.hist.setdefault(page_id, deque(maxlen=self.k))
        h.append(self.now)                # oldest auto-dropped past K

    def access(self, page_id):           # returns "HIT" or "MISS"
        if page_id in self.hist:
            self._record(page_id)
            return "HIT"
        if len(self.hist) >= self.capacity:
            # victim = largest backward K-distance (ties: oldest most-recent ref)
            victim = max(self.hist, key=lambda q: (self._kth_distance(q), self.now - self.hist[q][-1]))
            del self.hist[victim]
        self._record(page_id)
        return "MISS"

if __name__ == "__main__":
    bp = LRUKBufferPool(capacity=3, k=2)            # LRU-2
    for p in [1, 2, 1, 2, 3, 4, 1, 2]:        # hot 1,2 survive scan 3,4
        print(p, bp.access(p))
// LRU-K: keep last K access timestamps per page; evict the page whose
// K-th most recent access is OLDEST (largest backward K-distance).
#include <iostream>
#include <unordered_map>
#include <deque>
#include <limits>

class LRUKBufferPool {
    size_t capacity_;
    size_t k_;
    long now_ = 0;                                       // logical clock
    std::unordered_map<int, std::deque<long>> hist_;       // page_id -> last K timestamps

    double kthDistance(int page_id) {                    // inf if < K refs
        const auto& h = hist_[page_id];
        if (h.size() < k_) return std::numeric_limits<double>::infinity();
        return static_cast<double>(now_ - h.front());     // front = K-th most recent
    }

    void record(int page_id) {
        ++now_;
        auto& h = hist_[page_id];
        h.push_back(now_);
        if (h.size() > k_) h.pop_front();                  // keep only last K
    }
public:
    LRUKBufferPool(size_t capacity, size_t k) : capacity_(capacity), k_(k) {}

    std::string access(int page_id) {                   // returns "HIT" or "MISS"
        if (hist_.count(page_id)) { record(page_id); return "HIT"; }
        if (hist_.size() >= capacity_) {
            int victim = -1;
            double best = -1;
            long bestRecent = 0;
            for (auto& [q, h] : hist_) {            // largest K-distance; tie: oldest recent ref
                double d = kthDistance(q);
                long recent = now_ - h.back();
                if (d > best || (d == best && recent > bestRecent)) {
                    best = d; bestRecent = recent; victim = q;
                }
            }
            hist_.erase(victim);
        }
        record(page_id);
        return "MISS";
    }
};

int main() {
    LRUKBufferPool bp(3, 2);                              // LRU-2
    for (int p : {1, 2, 1, 2, 3, 4, 1, 2})       // hot 1,2 survive scan 3,4
        std::cout << p << " " << bp.access(p) << "\n";
}
// LRU-K: keep last K access timestamps per page; evict the page whose
// K-th most recent access is OLDEST (largest backward K-distance).
import java.util.*;

public class LRUKBufferPool {
    private final int capacity, k;
    private long now = 0;                                  // logical clock
    private final Map<Integer, Deque<Long>> hist = new HashMap<>();  // page -> last K timestamps

    public LRUKBufferPool(int capacity, int k) { this.capacity = capacity; this.k = k; }

    private double kthDistance(int pageId) {            // inf if < K refs
        Deque<Long> h = hist.get(pageId);
        if (h.size() < k) return Double.POSITIVE_INFINITY;
        return now - h.peekFirst();                     // first = K-th most recent
    }

    private void record(int pageId) {
        now++;
        Deque<Long> h = hist.computeIfAbsent(pageId, x -> new ArrayDeque<>());
        h.addLast(now);
        if (h.size() > k) h.pollFirst();                 // keep only last K
    }

    public String access(int pageId) {              // returns "HIT" or "MISS"
        if (hist.containsKey(pageId)) { record(pageId); return "HIT"; }
        if (hist.size() >= capacity) {
            int victim = -1;
            double best = -1;
            long bestRecent = 0;
            for (int q : hist.keySet()) {           // largest K-distance; tie: oldest recent ref
                double d = kthDistance(q);
                long recent = now - hist.get(q).peekLast();
                if (d > best || (d == best && recent > bestRecent)) {
                    best = d; bestRecent = recent; victim = q;
                }
            }
            hist.remove(victim);
        }
        record(pageId);
        return "MISS";
    }

    public static void main(String[] args) {
        LRUKBufferPool bp = new LRUKBufferPool(3, 2);     // LRU-2
        for (int p : new int[]{1, 2, 1, 2, 3, 4, 1, 2})  // hot 1,2 survive scan 3,4
            System.out.println(p + " " + bp.access(p));
    }
}
Trade-offs & the "Correlated Reference Period"

LRU-K is not free. (1) It stores K timestamps per page, so it uses more bookkeeping memory than LRU. (2) A naive eviction scan over all frames is O(n); real implementations use a priority queue or heap. (3) Bursts of accesses close together โ€” e.g. an index lookup immediately followed by the row fetch โ€” shouldn't each count as a "real" separate reference. The original paper adds a Correlated Reference Period: references within a short window are collapsed into one, so a single logical operation doesn't artificially inflate a page's reference count. Because of this overhead, many production systems use cheaper LRU-2 approximations rather than the exact algorithm.

Recap LRU-K remembers the last K accesses per page and evicts the one with the largest backward K-distance (predicted-farthest reuse). LRU-2 is the sweet spot: a use-once scan page never earns a second reference, so its K-distance is infinite and it's evicted first โ€” protecting the hot set. The cost is extra per-page state and a correlated-reference rule to avoid over-counting bursts. K = 1 is just plain LRU.

3 Sequential flooding & scan-resistant policies


Explain like I'm 5

Suppose you're handing out and re-shelving library books, and one person asks to flip through every book on a whole shelf, in order, once. You wouldn't put each of those books on your tiny "favourites" cart โ€” they'll never be asked for again on this trip! Instead you'd keep them in a small "just passing through" pile and reuse that same pile's space over and over. Databases do the same: they detect a big one-time scan and quarantine it in a small corner so it can't disturb the favourites.

We met sequential flooding in Topic 1: a scan larger than the buffer pool reads each page once and, under LRU, evicts everything useful โ€” including pages it will need again later in the same scan if it loops. Let's look at the problem precisely and then at the family of fixes.

Why the problem is fundamental

For a repeated sequential scan of N pages through a buffer holding B frames where N > B, LRU is pessimal โ€” the worst possible policy. By the time the scan comes back to page 1, LRU evicted it long ago to make room for the most recent pages, so every access misses. This is sometimes called the "one bigger than the cache" pathology and it's a textbook example of LRU's weakness.

The counter-intuitive fix: MRU for scans

For a looping sequential scan of N pages with B frames (N > B), the right victim is the page you just used โ€” MRU (Most Recently Used) eviction. Why? The page you just read is the one you'll need last on the next loop, while the oldest pages in the cache are the ones you'll need soonest. So MRU keeps the front of the table resident and only cycles the newest frame. With B frames you get hits on the first B pages every loop instead of zero. MRU is terrible for normal locality but ideal for a known repeating scan โ€” the policy should match the access pattern.

The family of scan-resistant techniques

TechniqueIdeaUsed where
LRU-K / LRU-2Require K references before a page is "hot"; use-once pages evicted first.Topic 2; foundational, widely approximated.
Ring buffer (buffer ring)Give a big scan a tiny fixed set of frames it reuses cyclically, so it can't touch the main pool.PostgreSQL bulk reads/writes.
MRU for scansEvict the most-recently-used page during a detected sequential scan.Classic textbook fix; some engines for known scans.
Midpoint / two-list LRUNew pages enter at the middle of the LRU list and only get promoted to the "young/hot" end on a second access.MySQL InnoDB; Linux page cache (active/inactive lists).
Touch-count / clock variantsTrack a small reference counter per frame; a single touch isn't enough to keep a page.PostgreSQL clock-sweep; Oracle touch-count LRU.

How real databases handle large scans

PostgreSQL โ€” the ring buffer. When PostgreSQL runs a large sequential scan, bulk COPY, or VACUUM, it doesn't let those pages roam the whole pool. It allocates a small private ring buffer (a "buffer access strategy" โ€” for seq scans the default is a 256 KB ring, i.e. only 32 8 KB buffers) and recycles those few frames over and over. The scan thus reads at most that handful of frames' worth of pollution, leaving the shared buffers full of hot pages. The ring is the engineering form of "quarantine the scan."

MySQL InnoDB โ€” midpoint insertion. InnoDB splits its LRU list into a "young" (new) sublist and an "old" (free-to-evict) sublist, joined at a midpoint (by default 3/8 of the way down). A freshly read page is inserted at the midpoint, into the old sublist โ€” not at the hot front. It is only promoted to the young end if it is accessed again after a short delay. A one-pass scan therefore parks its pages in the old sublist, where they age out quickly without ever displacing the genuinely hot young pages.

Oracle & PostgreSQL clock-sweep โ€” touch counts. Rather than an exact LRU list, PostgreSQL uses a clock-sweep algorithm: each frame has a small usage_count, incremented on access and decremented as a circular "clock hand" passes; a frame is evictable only when its count reaches 0. A scan page touched once has count 1, so it's reclaimed almost immediately, while hot pages keep being bumped back up. Oracle similarly uses a touch-count LRU. These are cheap, approximate ways to get LRU-2-like "must be used more than once" behaviour without per-page timestamp histories.

๐Ÿ“ฅ
New page
Enters at midpoint / count = 1
โ†’
โณ
Wait
Used only once? It ages toward eviction
โ†’
๐Ÿ”
Reused?
Touched again โ†’ promote / bump count
โ†’
๐Ÿ›ก๏ธ
Protected
Hot pages survive the scan
A note on LRU-K's modern cousins

The same "frequency + recency" insight powers later policies you may meet: 2Q (a fast approximation of LRU-2 using two simple queues), ARC (Adaptive Replacement Cache, which self-tunes between recency and frequency), and LIRS. They all descend from the LRU-K idea that one accidental touch shouldn't make a page look valuable.

Recap A scan bigger than the pool makes plain LRU pessimal โ€” a re-scan can hit 0%. Fixes detect or quarantine scans: MRU eviction for a known looping scan, PostgreSQL's small ring buffer, InnoDB's midpoint insertion (promote only on a second touch), and touch-count / clock-sweep approximations. All share LRU-K's lesson: protect pages that prove themselves by being reused.

4 Prefetching & related I/O optimizations


Explain like I'm 5

Imagine a chef who reads a recipe out loud, one step at a time, while a helper fetches ingredients. A slow helper waits to hear each step, then walks to the pantry โ€” the chef stands idle a lot. A clever helper notices "we're going down the list in order," so they grab the next few ingredients before the chef even asks. Now the chef never waits. Prefetching is the database being that clever helper: it spots that you're reading pages in order and fetches upcoming pages from disk early, so the CPU isn't stuck waiting.

Eviction policy decides what to keep. Prefetching attacks a different cost: the brutal latency of a disk read. A page miss can stall the query for the time it takes the disk to respond. Prefetching overlaps that I/O with computation by reading pages into the buffer pool before they're explicitly requested. This is also called read-ahead.

Sequential read-ahead

When the buffer manager detects a sequential access pattern โ€” say a full table scan or a range scan reading pages 100, 101, 102โ€ฆ โ€” it issues asynchronous reads for the next chunk of pages while the query is still processing the current one. By the time the query asks for page 103, it's already in RAM: the miss became a hit. The wait for the disk happened in the background, hidden behind useful work.

# Naive: query stalls on every page read
for pid in scan_range:          # 100, 101, 102, ...
    page = read_page(pid)        # BLOCKS on disk every miss ๐Ÿ˜ด
    process(page)

# With read-ahead: fetch ahead so the next page is ready
prefetch(scan_range[0 : PREFETCH_DEPTH])   # kick off async reads
for i, pid in enumerate(scan_range):
    page = read_page(pid)        # usually a HIT now โ€” already in RAM โœ…
    if i + PREFETCH_DEPTH < len(scan_range):
        prefetch(scan_range[i + PREFETCH_DEPTH])  # stay one window ahead
    process(page)                # CPU works while disk fetches next
Sequential vs random prefetch

Read-ahead shines for sequential patterns because nearby pages are likely physically adjacent on disk, so one big request is far cheaper than many small ones. For random access (e.g. chasing index pointers all over the table), engines may instead use scattered / list prefetch: collect the page IDs the query will need, sort them by physical location, and issue them together so the disk visits them in an efficient order.

Buffer pool bypass for scans

Topic 3 taught us to keep a scan out of the main pool. Buffer pool bypass (or direct I/O for the scan) takes this further: for a large one-time scan the engine reads pages into a small local working area and discards them immediately after use, never inserting them into the shared buffer pool at all. PostgreSQL's ring buffer (Topic 3) is essentially this. It both prevents cache pollution and avoids the overhead of managing those throwaway pages in the global structures.

Parallel & asynchronous I/O

A single disk request leaves a lot of hardware idle. Modern storage (especially SSDs and disk arrays) can service many requests at once. Parallel I/O issues multiple page reads concurrently โ€” across several disks or queue slots โ€” so total wait time is the time of one request, not the sum of all of them. Combined with asynchronous I/O (the query thread fires a read and keeps working instead of blocking), the database can keep both the CPU and the disks busy at the same time.

Worked example: why overlap wins

A scan must read 8 pages. Suppose each disk read takes 10 ms and processing a page takes 2 ms.

  • No prefetch (serial): for each page, wait 10 ms then process 2 ms โ†’ 8 ร— (10 + 2) = 96 ms.
  • Read-ahead (overlap I/O with compute): pay the first 10 ms read, then each following read overlaps the previous page's processing โ†’ roughly 10 + 8 ร— 2 โ‰ˆ 26 ms (the disk stays ahead of the CPU).
  • Parallel I/O (4 reads at once): the eight 10 ms reads run in 2 waves of 10 ms โ†’ โ‰ˆ 20 ms + processing, hiding even more latency.

Same data, same disk speed โ€” the difference is purely from not standing idle while the disk works.

Watch out โ€” don't over-prefetch

Prefetching is a bet. If you guess wrong (the query doesn't read those pages), you've wasted disk bandwidth and possibly evicted useful pages to make room for ones you never needed. The prefetch window must be tuned: too small and you still stall; too large and you pollute the cache and saturate the disk. This is why read-ahead is usually triggered only once a clear sequential pattern is detected.

Milestone M1 โ€” wrap-up

This session closes Milestone M1: storage & memory management. Across Sessions 1โ€“5 you built the path a byte travels: from disk pages and files, up through the buffer pool, the replacement policy that decides what to keep, scan-resistance to protect the working set, and prefetching to hide I/O latency. You now understand how a database moves data between disk and memory efficiently. Next we move up the stack to how that data is organised for fast lookup โ€” index structures โ€” starting with B+ Trees in Session 6.

Recap Prefetching (read-ahead) overlaps slow disk I/O with computation by fetching upcoming pages early โ€” huge for sequential scans, with scattered/list prefetch for random access. Buffer-pool bypass reads big scans through a small local area to avoid polluting the cache. Parallel and async I/O keep CPU and multiple disks busy at once. Tune the prefetch window so you hide latency without wasting bandwidth or cache. That wraps Milestone M1.

โ˜… Putting it all together


This session was one connected story about which pages live in memory and when. Here it is in a single paragraph:

The buffer pool caches disk pages in frames, and a replacement policy picks eviction victims to maximise the hit rate. Plain LRU tracks only recency, so a single large scan causes sequential flooding โ€” it evicts the hot working set with use-once pages. LRU-K fixes this by remembering the last K references and evicting by backward K-distance; LRU-2 is scan-resistant because a use-once page never earns a second reference. Real systems approximate this with ring buffers (PostgreSQL), midpoint insertion (InnoDB), and touch-count / clock-sweep (PostgreSQL, Oracle), plus MRU for known looping scans. Finally, prefetching / read-ahead, buffer-pool bypass, and parallel I/O hide disk latency so the CPU never waits. Together these close Milestone M1 โ€” efficient movement of data between disk and memory.

Quick self-check

In one sentence, why does plain LRU fail on a big sequential scan?

LRU tracks only recency, so a scan's use-once pages all look "most recently used" and evict the genuinely hot working set โ€” sequential flooding.

Why is LRU-2 scan-resistant but LRU is not?

A scanned page is touched only once, so it has no second-most-recent access; its backward 2-distance is effectively infinite, making it the first to be evicted. A page must be referenced โ‰ฅ 2 times to look valuable, so a single touch can't fool LRU-2.

For a looping sequential scan of N pages with only B < N frames, which eviction policy is best, and why?

MRU. The page you just read is needed last on the next loop, while the oldest cached pages are needed soonest โ€” so evicting the most-recently-used page keeps the front of the table resident and gives hits on the first B pages each loop instead of zero.

How does PostgreSQL stop a large sequential scan from polluting its shared buffers?

It uses a small ring buffer (buffer access strategy) โ€” a handful of frames the scan recycles cyclically โ€” so the scan never displaces hot pages in the main pool.

What does prefetching / read-ahead actually save, given the disk speed is unchanged?

It overlaps disk I/O with computation: upcoming pages are read asynchronously while the CPU processes the current page, so misses become hits and the query stops standing idle waiting on the disk.

Name one risk of prefetching too aggressively.

Wrong guesses waste disk bandwidth and can evict useful pages to make room for ones never used โ€” polluting the cache and possibly saturating the disk. The prefetch window must be tuned.

๐Ÿ“š References & Further Reading


Class material

Papers, docs & deep dives