πŸ“š Study Notes / Home / DBMS / Session 4
Session 04 Β· Buffer Pool

Where the database keeps its pages while it works

Disks are slow and memory is fast β€” so a database keeps a private stash of recently used disk pages in RAM and reuses them instead of fetching from disk every time. That stash is the buffer pool. We assume you've studied none of this before. Every topic starts with a tiny "explain like I'm 5" story, then we go deeper with real examples and pseudocode. By the end you'll understand exactly how the database decides what stays in memory and what gets thrown out.

⏱ 22 min readπŸ“– 4 topics

1 Why a buffer pool?


Explain like I'm 5

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 layerTypical access timeHuman-scale analogy
CPU register / cache~1 nanosecondGrabbing something in your hand
Main memory (RAM)~100 nanosecondsWalking 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
The one big idea

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:

DecisionOS page cacheDBMS buffer pool
Eviction orderGeneric LRU β€” guesses blindly.Knows query plans & access patterns, so it evicts smarter.
Write timingFlushes when it feels like it.Must flush in a specific order for crash recovery (the write-ahead log rule β€” a later session).
PinningCan't be told "don't evict this, I'm using it."Can pin a page in place (Topic 3).
Double copyData sits in OS cache and DBMS memory β€” wasteful.One authoritative copy under DBMS control.
Concrete example: the cost of not caching

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.

A vocabulary heads-up

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.

Recap Disk is thousands of times slower than RAM, so the DBMS keeps recently-used disk pages in a memory cache called the buffer pool. It's the same caching idea used by browsers and CPUs, and it pays off because database access has temporal and spatial locality. The DBMS manages its own cache (rather than trusting the OS) because it knows access patterns and must control write ordering for recovery.

2 Buffer pool design β€” frames & the page table


Explain like I'm 5

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.

Don't confuse two "page tables"

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 fieldMeaning
page_idWhich disk page lives here (or "empty").
pin_countHow many users are currently using this page (Topic 3).
is_dirtyHas this page been modified since it was read in? (Topic 3)
dataThe 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.
πŸ™‹
1. Request
getPage(P) is called
β†’
πŸ“’
2. Look up
Is P in the page table?
β†’
βœ…
3a. Hit
Return the frame β€” no disk
β†’
πŸ’Ύ
3b. Miss
Find a frame, read P from disk
β†’
πŸ“Œ
4. Pin & return
Hand the page to the caller

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
Worked example: a hit and a miss

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.

Recap The buffer pool is memory split into page-sized frames. A page table maps page-id β†’ frame for instant lookup. On 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


Explain like I'm 5

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.

Pin/unpin must balance

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.

Why the dirty flag is so important

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.
The recovery rule (a peek ahead)

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
Worked example: pinning protects a page

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, freeFrame first writes it to disk (because it's dirty), then reuses the frame. Changes are safe.
Recap The pin count says how many users hold a page; a page is evictable only when it hits zero. The dirty flag says the in-memory copy was modified, so it must be flushed to disk before eviction (clean pages are dropped for free). Dirty pages are written back lazily β€” on eviction, by a background writer, or at a checkpoint β€” and the WAL rule constrains the order. Latches protect the page table and frames from concurrent threads.

4 Replacement policy theory


Explain like I'm 5

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.
LRU's classic weakness: sequential flooding

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.

πŸ•
Hand at frame
Check its ref bit
β†’
1️⃣
Bit = 1
Clear to 0, give 2nd chance
β†’
0️⃣
Bit = 0
Unpinned? Evict it!
β†’
⏭️
Advance
Move hand to next frame

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?"

PolicyWork on a hitApproximates LRU?Main weakness
OPT (BΓ©lΓ‘dy)β€”It is optimalNeeds the future β€” impossible
LRUMove page to front of listExact recencyList contention; sequential flooding
ClockSet one bit (O(1))Good approximationCoarser than true LRU
FIFONothingNo (ignores reuse)Evicts hot pages; usually worst
Worked example: LRU vs Clock on the same trace

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):

  • A miss β†’ [A]
  • B miss β†’ [B, A]
  • C miss β†’ [C, B, A] (full)
  • A hit β†’ move A front β†’ [A, C, B]
  • D miss, full β†’ evict back = B β†’ [D, A, C]
  • B miss β†’ 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):

  • A miss β†’ f0=A(1). B miss β†’ f1=B(1). C miss β†’ f2=C(1). Full; hand at f0.
  • A hit β†’ set f0 bit: A(1) (already 1).
  • D miss β†’ 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.
  • B hit β†’ 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.

Recap When the pool is full, a replacement policy chooses an unpinned victim to evict. The optimal policy (OPT) needs the future, so real systems approximate it. LRU evicts the least-recently-used page but contends on a shared list and suffers from sequential flooding. Clock/second-chance approximates LRU with one reference bit and no work on hits, which is why it's so widely used. Pinning constrains the policy: only 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/unpin paths, and your eviction policy with its trade-offs.

Papers, docs & deep dives