1 Why a buffer pool?
Imagine your toys live in a huge warehouse far across town (that's the disk), but you play on a tiny rug in your room (that's memory). Walking to the warehouse every time you want a toy would take forever. So you keep the toys you've been playing with on your rug. When the rug gets full, you carry the toy you haven't touched in ages back to the warehouse to make space. That rug full of recently-used toys is the buffer pool.
In Session 3 we saw that a database stores its data on disk as fixed-size pages (often 4 KB, 8 KB, or 16 KB blocks) inside heap files. The problem: the engine can't run code directly on data sitting on disk. It has to copy a page into memory (RAM) first, work on it there, and (if it changed) copy it back. The buffer pool is the piece of the DBMS that manages this copying.
Just how big is the speed gap?
This is the whole reason the buffer pool exists, so it's worth feeling the numbers. Memory and disk are not "a bit different" in speed β they're different by orders of magnitude.
| Storage layer | Typical access time | Human-scale analogy |
|---|---|---|
| CPU register / cache | ~1 nanosecond | Grabbing something in your hand |
| Main memory (RAM) | ~100 nanoseconds | Walking across the room |
| SSD (flash) | ~100 microseconds (~1,000Γ slower than RAM) | Walking to the next town |
| Spinning hard disk | ~10 milliseconds (~100,000Γ slower than RAM) | A multi-day road trip |
Reading a page from disk can be thousands to a hundred-thousand times slower than reading it from memory. So if the database can serve a page from memory instead of going to disk, it does β every single time. The buffer pool's whole job is to make "it's already in memory" the common case.
This is just caching β a universal idea
A cache is any small, fast store that holds copies of things from a big, slow store, betting you'll want them again soon. You meet caches everywhere:
- Your browser caches images so a page loads instantly on a second visit.
- Your CPU caches recently-used memory so it doesn't wait on RAM.
- A CDN caches website files near you so they don't travel across the world.
The buffer pool is exactly this pattern: a fast cache (RAM) in front of a slow store (disk). Two bets make it pay off, and both are usually true for database workloads:
- Temporal locality β if a page was used recently, it's likely to be used again soon (think a hot "users" page touched by every login).
- Spatial locality β if you used a page, you'll probably use nearby pages too (scanning a table reads page after page in order).
Why not just let the operating system do it?
Here's a fair question. The OS already caches disk pages for every program (it's called the OS page cache). So why does a database build its own buffer pool instead of leaning on the OS? Because the DBMS knows things the OS can't:
| Decision | OS page cache | DBMS buffer pool |
|---|---|---|
| Eviction order | Generic LRU β guesses blindly. | Knows query plans & access patterns, so it evicts smarter. |
| Write timing | Flushes when it feels like it. | Must flush in a specific order for crash recovery (the write-ahead log rule β a later session). |
| Pinning | Can't be told "don't evict this, I'm using it." | Can pin a page in place (Topic 3). |
| Double copy | Data sits in OS cache and DBMS memory β wasteful. | One authoritative copy under DBMS control. |
Suppose a query reads the same 100-page index 50 times during a join. Without a buffer pool,
that's 100 Γ 50 = 5,000 disk reads. On a spinning disk at ~10 ms each,
that's 50 seconds just waiting on I/O. With a buffer pool, the 100 pages are read
from disk once (~1 second), and the other 4,900 accesses hit memory at ~100 ns each β
essentially free. Same query, ~50Γ faster, purely from caching.
The region of RAM the buffer pool manages is often called the buffer pool
or page cache; PostgreSQL calls it shared_buffers,
MySQL/InnoDB calls it the innodb_buffer_pool. Tuning its size is one of the
single biggest knobs in database performance.
2 Buffer pool design β frames & the page table
Picture a wall of identical lockers (your memory). Each locker is exactly the right size to hold one toy box from the warehouse. To find your toy fast, you keep a little notebook: "Red box β locker 7, Blue box β locker 2." When you want the red box, you check the notebook first. If it's listed, you go straight to that locker. If it's not, you fetch it from the warehouse, put it in a free locker, and write the new line in your notebook.
The buffer pool is a big chunk of memory the DBMS carves up into equal-sized slots. Each slot is
called a frame, and each frame is exactly the size of one disk page β so any
page from disk fits perfectly into any frame. If the buffer pool is 1 GB and pages are 8 KB,
you get about 1 GB / 8 KB β 131,000 frames.
The page table β the "notebook"
To find a page in memory quickly, the buffer pool keeps a page table: an
in-memory lookup (a hash map) from a page id (which page on disk) to the
frame that currently holds it. A lookup is O(1) on
average β basically instant.
The operating system also has a thing called a "page table" (for virtual memory). The DBMS buffer pool's page table is a different, software structure that just maps database page ids to buffer frames. Same name, unrelated job β a classic exam trap.
Alongside the page table, each frame carries a little bit of metadata the pool needs to manage it. We'll meet most of these in Topic 3, but here's the shape:
| Per-frame field | Meaning |
|---|---|
| page_id | Which disk page lives here (or "empty"). |
| pin_count | How many users are currently using this page (Topic 3). |
| is_dirty | Has this page been modified since it was read in? (Topic 3) |
| data | The actual page bytes copied from disk. |
The fetch path: hit vs miss
Every time some part of the database needs a page, it asks the buffer pool: getPage(page_id).
Two things can happen β the two most important words in this whole session:
- Cache hit β the page is already in a frame. The pool finds it via the page table and hands it back immediately. No disk I/O. Fast.
- Cache miss β the page isn't in memory. The pool must find a free frame (or evict something β Topic 4) and read the page from disk into it. Disk I/O happens. Slow.
The hit rate β the fraction of requests that are hits β is the headline number for buffer pool health. A well-sized pool on a typical workload hits 95β99% of the time, so the vast majority of page requests never touch the disk at all.
Pseudocode: getPage
Here is the core routine, with the eviction details deferred to Topic 4 and the pinning details to Topic 3. Read it slowly β it's the heart of the buffer pool.
# Fetch a page into memory and return the frame holding it. function getPage(page_id): # --- Cache HIT: already in memory? --- if page_id in page_table: frame = page_table[page_id] frame.pin_count = frame.pin_count + 1 # mark "in use" (Topic 3) return frame # no disk I/O π # --- Cache MISS: must bring it in from disk --- frame = findFreeFrame() # a truly empty frame, if any if frame is None: frame = evictVictim() # pick & free a victim (Topic 4) if frame is None: throw "buffer pool full: every page is pinned" # 'frame' is now empty and ours to use frame.data = disk.readPage(page_id) # the slow part frame.page_id = page_id frame.is_dirty = false frame.pin_count = 1 page_table[page_id] = frame # write the new notebook line return frame
Pool with 3 frames, all empty. Page table starts empty {}.
getPage(5)β miss. Read page 5 from disk into frame 0. Table is now{5 β f0}. (1 disk read)getPage(8)β miss. Read page 8 into frame 1. Table{5 β f0, 8 β f1}. (2 disk reads)getPage(5)β hit! Page 5 is already in frame 0. Return it instantly. (still 2 disk reads β this one was free)
Three requests, only two disk reads. The repeated access to page 5 paid for the cache.
getPage a hit
returns the frame with no disk I/O; a miss finds a free (or evicted) frame and reads
the page from disk. The fraction of hits is the all-important hit rate.
3 Pin counts & dirty flags
Two sticky notes go on each toy box on your rug. The first is a tally: "3 people are playing with this right now." While that number is above zero, nobody is allowed to put the box back in the warehouse β someone's still using it! That tally is the pin count. The second note says "DIRTY" if you scribbled on the toy. A dirty box must be re-saved to the warehouse before it leaves, or your changes are lost. That's the dirty flag.
Pin counts β "don't evict, I'm using it"
When a part of the database asks for a page, it's going to read or write the bytes in that frame for
a while. It would be a disaster if the buffer pool evicted that frame mid-use and reused it for a
different page. So before handing a frame out, the pool pins it: it increments
the frame's pin count (also called the reference count). A frame with
pin_count > 0 is off-limits for eviction.
When the caller is done, it must unpin the page (decrement the count). A frame only becomes eligible for eviction once its pin count drops back to zero β meaning nobody is using it anymore.
Every pin needs a matching unpin, just like every
malloc needs a free. Forget to unpin and that frame
is stuck forever β a pin leak. Leak enough frames and the pool can't evict
anything, and the whole database stalls with "buffer pool full." This is a real, common bug.
The dirty flag β "this page changed in memory"
When a transaction modifies a page (inserts a row, updates a value), it changes the copy in the
frame β not the disk yet. That frame is now out of sync with disk. The pool marks it with a
dirty flag (is_dirty = true). A
dirty page holds the only up-to-date version of that data; a
clean page is identical to what's on disk.
It tells the pool whether a page must be written back to disk before it's evicted. A clean page can be dropped instantly β disk already has an identical copy, so we just overwrite the frame. A dirty page must first be flushed (written to disk), or the changes vanish. So the dirty flag turns some evictions cheap and some expensive.
When do dirty pages actually get written back?
The DBMS deliberately delays writing dirty pages β it's called delayed writeback β so that many changes can be batched and a hot page can be modified repeatedly without paying for a disk write each time. A dirty page is flushed when:
- It's chosen as an eviction victim and is dirty β flush, then reuse the frame.
- A background writer / checkpointer runs β a thread that periodically flushes dirty pages so evictions later are cheap and recovery after a crash is fast.
- A checkpoint forces it β recovery logic occasionally flushes everything to create a known-good snapshot.
There's one ordering law a database must obey, called Write-Ahead Logging (WAL): the log record describing a change must hit disk before the dirty data page it describes. This is how the database survives a crash without losing or corrupting data. We cover WAL and recovery in a later session β for now, just know it's why the buffer pool can't flush pages in whatever order it likes.
The latch/concurrency angle (briefly)
Many threads hit the buffer pool at once, so its internal structures β the page table and each frame's metadata β must be protected from races. Databases use latches (lightweight, short-held locks on in-memory structures, distinct from the higher-level transaction locks you'll meet later) to guard them. Two levels show up:
- A latch on the page table so two threads don't insert the same page into two frames.
- A latch on each frame so a reader doesn't see a page half-overwritten by a writer.
A subtle point: the pin count itself is a coordination tool. It's how the buffer pool layer says "this frame is in active use" without holding a heavy lock for the whole duration.
Pseudocode: unpin, and an eviction that respects both flags
# Caller is done with a page. dirtied = did I modify it? function unpinPage(page_id, dirtied): frame = page_table[page_id] if dirtied: frame.is_dirty = true # mark it; flush happens later frame.pin_count = frame.pin_count - 1 # pin_count == 0 β now eligible for eviction (Topic 4) # Free a frame for reuse, honouring pin & dirty rules. function freeFrame(frame): if frame.pin_count > 0: throw "cannot evict a pinned page" # someone's using it if frame.is_dirty: disk.writePage(frame.page_id, frame.data) # flush BEFORE reuse frame.is_dirty = false page_table.remove(frame.page_id) # erase the notebook line frame.page_id = None
Frame 0 holds page 5. Two threads call getPage(5) β pin_count becomes 2.
Now the pool is full and wants a victim.
- Page 5 has
pin_count = 2β skipped, can't be evicted. - Thread A finishes, calls
unpinPage(5, dirtied=true)β pin_count = 1,is_dirty = true. - Thread B finishes,
unpinPage(5, dirtied=false)β pin_count = 0. Now evictable β but it's dirty. - If page 5 is later chosen as victim,
freeFramefirst writes it to disk (because it's dirty), then reuses the frame. Changes are safe.
4 Replacement policy theory
Your rug only fits a few toy boxes. When it's full and you want a new one, you have to put an old box back in the warehouse to make room. Which one do you pick? Probably the one you haven't touched in the longest time β you'll most likely want the others again sooner. Deciding which box to send back is the replacement policy, and "the one untouched longest" is the famous rule called LRU.
Memory is finite, so the buffer pool fills up. When a miss happens and there's no free frame, the pool
must throw one page out to make room β this is eviction. The rule it uses to
choose the victim is the replacement policy (a.k.a. eviction policy). A good
policy keeps the hit rate high by evicting pages you're least likely to need again soon. (Only
pages with pin_count = 0 are even candidates β pinned pages are untouchable,
from Topic 3.)
The theoretical best, and why we can't have it
The provably optimal policy, called BΓ©lΓ‘dy's OPT, evicts the page that won't be needed for the longest time in the future. It's optimal β and impossible β because it requires seeing the future. So real policies guess the future from the past. The two classics you must know:
Policy 1 β LRU (Least Recently Used)
LRU evicts the page that was accessed the longest ago, betting (via temporal locality, Topic 1) that the recently-used pages will be used again soon. Conceptually you keep pages in a list ordered by last-use; on every access you move that page to the front; to evict, you drop the page at the back.
- Pro: simple, intuitive, and good for most workloads.
- Con: a precise LRU must update a shared, ordered list on every single access β including hits. Under heavy concurrency, all threads fighting over that one list (and its latch) becomes a bottleneck.
A big sequential scan (read every page of a giant table once) pulls in page after page that will never be reused, and LRU dutifully evicts your genuinely-hot pages to cache this one-time junk. The hit rate craters. This is exactly why smarter schemes like LRU-K exist β they track the last K accesses to tell "used once" apart from "used repeatedly." We dig into LRU-K and other refinements in Session 5.
Policy 2 β Clock (a.k.a. Second-Chance)
Clock is a cheap approximation of LRU that avoids the per-access list churn. Picture all frames arranged in a circle with a single hand (like a clock), and each frame having one reference bit:
- On access, just set that frame's reference bit to 1. (Cheap β no list to reorder.)
- On eviction, sweep the hand forward. At each frame: if its bit is 1, give it a "second chance" β clear the bit to 0 and move on. If its bit is already 0 (and it's unpinned), evict it.
The effect: a page touched since the hand last passed survives one more lap; a page nobody has touched gets evicted. It approximates "least recently used" using just one bit per frame and no work on hits β which is why real systems (and the OS) favour Clock-family policies.
How the policy interacts with pinning
The replacement policy never gets to pick a pinned page. In Clock, when the hand lands on a frame with
pin_count > 0, it simply skips it (leaving the bit alone) and keeps sweeping.
In LRU, pinned pages are excluded from the candidate list. So pinning and replacement work as a team: pin
count says "is this even allowed?", and the policy says "of the allowed ones, which is least valuable?"
| Policy | Work on a hit | Approximates LRU? | Main weakness |
|---|---|---|---|
| OPT (BΓ©lΓ‘dy) | β | It is optimal | Needs the future β impossible |
| LRU | Move page to front of list | Exact recency | List contention; sequential flooding |
| Clock | Set one bit (O(1)) | Good approximation | Coarser than true LRU |
| FIFO | Nothing | No (ignores reuse) | Evicts hot pages; usually worst |
Pool with 3 frames. Access sequence of page ids:
A, B, C, A, D, B (all unpinned, none dirty for simplicity).
LRU (front = most recent, back = eviction target):
Amiss β [A]Bmiss β [B, A]Cmiss β [C, B, A] (full)Ahit β move A front β [A, C, B]Dmiss, full β evict back = B β [D, A, C]Bmiss β evict back = C β [B, D, A]
Result: 2 hits avoided⦠actually 1 hit (the second A), 5 misses, and B/C were the victims. Notice the recently-reused A was protected.
Clock (frames in a ring f0,f1,f2; bit shown as page=bit; hand starts at f0):
Amiss β f0=A(1).Bmiss β f1=B(1).Cmiss β f2=C(1). Full; hand at f0.Ahit β set f0 bit: A(1) (already 1).Dmiss β sweep: f0 A bit1βclear to 0, advance; f1 B bit1βclear to 0, advance; f2 C bit1βclear to 0, advance; back to f0 A bit0βevict A, put D, f0=D(1). Hand at f1.Bhit β set f1 bit: B(1).
Result: 2 hits (the A re-use and the final B), 4 misses. Here Clock happened to keep B and got an extra hit β the two policies make different choices, and which "wins" depends on the exact trace. The takeaway isn't that one is always better; it's that recency-based guessing is what keeps hit rates high, and Clock buys most of LRU's benefit for far less per-access cost.
pin_count = 0
pages can ever be chosen. Smarter policies like LRU-K come in Session 5.
β Putting it all together
You just learned how a database keeps its working data in memory. Here's the one-paragraph story that connects all four topics:
Because disk is thousands of times slower than RAM, the DBMS runs its own
buffer pool β a cache of disk pages in memory that it controls better than the OS could.
The pool is divided into page-sized frames, and a page table maps each
page-id to the frame holding it, so getPage is an instant hit
when the page is resident and a slow disk miss when it isn't. While a page is in use its
pin count protects it from eviction, and if it's modified its dirty flag
forces a flush to disk before the frame can be reused (lazily, respecting the WAL ordering rule). When
memory fills up, a replacement policy β LRU, or the cheaper Clock/second-chance
approximation β evicts the least-valuable unpinned page, keeping the hit rate high. Smarter
policies like LRU-K are next, in Session 5.
Quick self-check
Why does a database build its own buffer pool instead of relying on the OS page cache?
Because the DBMS knows its own access patterns and query plans (so it evicts smarter), it must control write ordering for crash recovery (the WAL rule), it can pin pages the OS can't, and it avoids the wasteful double-copy of caching the same data in both the OS cache and DBMS memory.
What's the difference between a cache hit and a cache miss in the buffer pool?
A hit means the requested page is already in a frame (found via the page table) and is returned with no disk I/O. A miss means it isn't resident, so the pool must find a free or evicted frame and read the page from disk β slow.
When can a page be evicted, and what extra step is needed if it's dirty?
Only when its pin count is 0 (nobody is using it). If the page is dirty (modified in memory), it must be flushed β written back to disk β before the frame is reused, or the changes would be lost. A clean page can be dropped for free.
What's a "pin leak" and why is it dangerous?
A pin leak is forgetting to unpin a page after use, so its pin count never returns to zero. That frame becomes permanently un-evictable. Enough leaks and the pool can't free any frames, so new misses fail with "buffer pool full" and the database stalls.
How does the Clock (second-chance) policy approximate LRU so cheaply?
Each frame has one reference bit, set on access (cheap, no list reordering on hits). To evict, a hand sweeps the ring: a frame with bit 1 gets cleared to 0 and a second chance; a frame with bit 0 (and unpinned) is evicted. Recently-touched pages survive a lap, untouched ones leave β LRU-like behaviour for one bit per frame.
Why does a plain LRU policy struggle with a large sequential scan?
Sequential flooding: scanning a huge table reads many pages that are never reused, and LRU evicts your genuinely-hot pages to cache this one-time data, tanking the hit rate. LRU-K (Session 5) fixes this by tracking multiple recent accesses to distinguish "used once" from "used repeatedly."
π References & Further Reading
Class material
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- π Class handout: "DBMS Session 4 β Buffer Pool".
- π οΈ Capstone: MiniDB β Milestone M1 (Buffer Pool) β implement frames, the page table, pin/unpin, the dirty flag, and a Clock or LRU replacement policy.
- βοΈ Written assignment: "Design a buffer pool" β specify the data structures, the
getPage/unpinpaths, and your eviction policy with its trade-offs.
Papers, docs & deep dives
- CMU 15-445/645 β Database Systems β the gold-standard course; see the "Buffer Pools / Database Storage" lectures and notes for this exact material.
- Database System Concepts (Silberschatz, Korth, Sudarshan) β the buffer management chapter is the classic textbook treatment of frames, pinning, and replacement.
- PostgreSQL docs β
shared_buffers& resource config β how a real production system exposes and tunes its buffer pool. - MySQL / InnoDB Buffer Pool documentation β a real LRU-based buffer pool with a "midpoint insertion" twist to resist sequential flooding.
- O'Neil, O'Neil & Weikum β "The LRU-K Page Replacement Algorithm" (1993) β the original paper behind the policy we study in Session 5.
- Cache replacement policies (overview) β a clear survey of OPT, LRU, Clock/second-chance, FIFO and friends.