# Buffer Pools: Why Databases Don't Trust Your OS

> Databases read disk through fixed-size pages cached in a buffer pool they manage themselves — duplicating what the kernel's page cache already does. The reasons why (eviction control, WAL ordering, O_DIRECT) are a masterclass in when to bypass the OS.

URL: https://harshith.in/blog/buffer-pools-and-database-pages/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: databases, memory, performance, systems

Ask why PostgreSQL has `shared_buffers` and InnoDB has a buffer pool
when Linux already caches file pages, and you've asked one of the best
questions in systems design — because the answer isn't "legacy." Every
serious database re-implements caching *on top of* or *instead of* the
kernel's, and the reasons illuminate exactly where general-purpose OS
policies stop being good enough.

## The Page Is the Atom

Storage engines see tables as arrays of fixed-size **pages** (8 KB
Postgres, 16 KB InnoDB) — the unit of I/O, locking granularity, WAL
reference, and caching. Inside, the classic **slotted layout**: header,
a slot directory growing forward, tuples growing backward, so
variable-size rows can be shuffled within a page without external
pointers changing (slot indirection is the whole trick; it's also why
a "tiny" one-column update dirties an entire page). The **buffer pool**
is a big array of page-sized frames plus a hash table (page id → frame)
and per-frame metadata: pin count (in use — unevictable), dirty flag,
and usage tracking for eviction. Every read pins, uses, unpins; every
miss picks a victim, evicts (writing it first if dirty), and reads in.

## Why Not Just the Page Cache?

The database knows things the kernel structurally can't:

- **Access semantics.** A sequential scan of a huge table would flush
  the entire cache under naive LRU — one query evicting your working
  set. Databases special-case it (Postgres ring buffers for scans,
  InnoDB's midpoint insertion: new pages enter 3/8 from the tail and
  must be re-touched to reach the hot end; classic clock-sweep with
  usage counts elsewhere). The kernel sees anonymous reads and can't
  distinguish your OLTP index root from a batch scan's confetti.
- **Write *ordering*.** WAL discipline requires the log flushed before
  a dirty data page reaches disk, checkpoints require controlled
  flush scheduling — the kernel's writeback flushes whatever, whenever.
  A database must at minimum control its own dirty-page destiny.
- **Double caching and double copying.** Buffered I/O keeps every hot
  page twice (pool + page cache) and copies on each miss. Hence
  **O_DIRECT**: InnoDB defaults to it and owns the whole path;
  Postgres, notably, still rides the page cache (double-caching is why
  the standard advice is shared_buffers ≈ 25% of RAM and let the
  kernel hold the rest, and why `effective_cache_size` exists to tell
  the *planner* about the kernel's share) — an ongoing architectural
  debate you can watch in the async-I/O work landing in recent
  releases.

The through-line generalizes past databases: bypass the OS when you
have *semantic knowledge* (future access patterns, ordering
constraints) that its interface can't express — and pay the price of
reimplementing readahead, writeback scheduling, and eviction yourself.
Kafka is the perfect counter-example that proves the rule: its access
pattern (sequential log append/tail-read) is exactly what the page
cache is already optimal for, so it deliberately owns *no* cache and
that's equally principled.

## Reading the Pool in Production

The metrics that matter map straight to the mechanics. **Hit ratio**
(`pg_stat_database.blks_hit/blks_read`, InnoDB
`Buffer pool hit rate`) — with the caveat that Postgres's number
counts kernel-cache hits as "reads," flattering nobody. **Dirty/
flush pressure**: checkpoint-time write storms and
"buffer pool wait free" conditions mean writeback can't keep pace —
tune background writers before buying disks. **Pin/latch contention**
on a handful of pages (the root of a hot index, a counter row) shows
up as buffer-content lock waits and is a schema problem wearing a
cache costume. And when sizing: bigger pools help until the working
set fits, then flatten — while making checkpoints heavier and restart
warmup longer (hence pg_prewarm and InnoDB's buffer-pool dump/restore,
which exist because a cold 200 GB pool after failover is its own
incident).

## Takeaways

- Fixed-size slotted pages are the atom of I/O, logging, and locking;
  the buffer pool is a hash-indexed frame cache with pins, dirty
  flags, and clock-ish eviction.
- Databases self-cache for semantics the kernel can't see: scan
  resistance, WAL-before-data ordering, checkpoint control.
- O_DIRECT (InnoDB) vs ride-the-page-cache (Postgres) is a live
  design fork — the 25%-of-RAM rule is double-caching arithmetic,
  not superstition.
- The bypass principle: own the path only when you know more than the
  OS *and* can afford to reimplement its machinery (Kafka rightly
  doesn't).
- Watch hit ratios skeptically, flush pressure seriously, and hot-page
  contention as the schema smell it is; prewarm after failovers.
