1 The weakness of plain LRU
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:
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.
| Operation | What LRU does | Cost |
|---|---|---|
| Access a page | Move it to the front (most-recently-used end) | O(1) |
| Evict | Remove 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.
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.
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.
| Step | Access | Cache (MRUโLRU) | Hit/Miss |
|---|---|---|---|
| 0 | warm | A, B, C | hot set loaded |
| 1 | P1 | P1, A, B | miss โ evict C |
| 2 | P2 | P2, P1, A | miss โ evict B |
| 3 | P3 | P3, P2, P1 | miss โ evict A |
| 4 | A (hot again) | A, P3, P2 | miss! 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.
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.
2 LRU-K: counting references, not just recency
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.
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.
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.
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):
| Access | Cache (MRUโLRU) | Result |
|---|---|---|
| A B A B | B, A | warm hot set |
| P1 | P1, B, A | miss |
| P2 | P2, P1, B | miss โ evict A |
| P3 | P3, P2, P1 | miss โ evict B |
| A | A, P3, P2 | miss (A was evicted) |
| B | B, A, P3 | miss (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):
| Access | What LRU-2 keeps | Result |
|---|---|---|
| A B A B | A and B each have two references โ small, finite K-distance | both protected |
| P1 | P1 has one ref โ โ 2-distance; cache = {A, B, P1} | miss |
| P2 | evict P1 (โ distance), not A or B; cache = {A, B, P2} | miss โ evict P1 |
| P3 | evict P2 (โ distance); cache = {A, B, P3} | miss โ evict P2 |
| A | A is still resident | HIT โ |
| B | B is still resident | HIT โ |
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)); } }
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.
3 Sequential flooding & scan-resistant policies
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.
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
| Technique | Idea | Used where |
|---|---|---|
| LRU-K / LRU-2 | Require 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 scans | Evict the most-recently-used page during a detected sequential scan. | Classic textbook fix; some engines for known scans. |
| Midpoint / two-list LRU | New 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 variants | Track 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.
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.
4 Prefetching & related I/O optimizations
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
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.
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.
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.
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.
โ 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
- ๐ Original course notes / handout (source sheet) โ open the shared class material for this session.
- DBMS Session 5 โ Buffer Pool 2 (class handout for this session).
Papers, docs & deep dives
- CMU 15-445 โ Database Systems (buffer pool lectures) โ Andy Pavlo's course; the buffer-pool and replacement-policy lectures cover LRU, clock, LRU-K, and scan handling clearly.
- O'Neil, O'Neil & Weikum โ "The LRU-K Page Replacement Algorithm for Database Disk Buffering" (1993) โ the original LRU-K paper; defines backward K-distance and the correlated reference period.
- PostgreSQL documentation โ Resource Consumption (shared_buffers & buffer management) โ official docs for PostgreSQL's buffer pool, clock-sweep, and the ring-buffer access strategies for scans and VACUUM.
- MySQL Reference Manual โ The InnoDB Buffer Pool โ explains InnoDB's midpoint-insertion LRU (young/old sublists) and read-ahead, the production form of scan resistance.
- Megiddo & Modha โ "ARC: A Self-Tuning, Low Overhead Replacement Cache" (2003) โ a modern descendant of LRU-K that adapts between recency and frequency automatically.
- Silberschatz, Korth & Sudarshan โ Database System Concepts (Storage & Buffer Management chapters) โ the standard textbook treatment of buffer replacement, MRU-for-scans, and prefetching.