The idea: every page goes through a small shared cache

PostgreSQL never reads a table block straight into a backend's private memory. Every block goes through shared buffers: one array of 8 KB buffers in shared memory, sized by shared_buffers. All backends see the same buffers, so a page one query read is a free hit for the next one. When the page is not there it is a miss: some other page must leave to make room. Which one leaves is decided by the clock sweep.

Backend calls ReadBuffer(acct, 8). It looks up the tag in the buffer mapping hash table. On a hit the table gives the buffer id; on a miss the clock sweep picks a victim buffer, a dirty victim is written to disk first, and the block is read from the data file into it.
ReadBuffer: look the tag up in the hash table; on a miss the clock sweep frees a buffer and the block is read from disk.

The page has 8 buffers holding blocks of a hot table acct, and a bigger table logs on disk. Backend A runs your SELECT or UPDATE, backend B is an open cursor that can hold a pin, and the bgwriter cleans buffers in the background.

Buffer descriptors

Next to each buffer sits a small buffer descriptor. The page shows its four important fields:

FieldMeaningOn the canvas
tagwhich block is in the buffer: relation + fork + block numberacct/3 in the box
usage_counthow popular the page is lately, 0 to 5 (BM_MAX_USAGE_COUNT)green squares
refcount (pins)how many backends are using the buffer right nowpins 1
BM_DIRTYchanged in memory, not yet written to the data fileorange box, "dirty"

The buffer mapping hash table maps a tag to the buffer id that holds it. In PostgreSQL it is split into 128 partitions, each with its own lock, so many backends can look up pages at once.

Hit and miss

On a hit the backend pins the buffer, adds 1 to usage_count (up to 5) and reads the page. No disk I/O at all (Demo: hit). When it is done it unpins; usage_count stays up. On a miss the backend needs a free buffer. Once shared_buffers has filled up there is none, so it asks StrategyGetBuffer() for a victim.

The clock sweep

A single hand, nextVictimBuffer, goes round the buffers like the hand of a clock. At each buffer:

  • pinned: someone is using it, skip it (Demo: pinned buffer is skipped);
  • usage_count > 0: subtract 1 and move on, the page gets another chance;
  • unpinned and usage_count = 0: this is the victim.
Before: buf 0 acct/0 usage 3, buf 1 acct/1 usage 1, buf 2 acct/2 usage 0, hand at buf 0. After a miss for acct/8: buf 0 usage 2, buf 1 usage 0, buf 2 now holds acct/8 with usage 1, and the hand is at buf 3.
One miss: the hand takes one point off each popular page it passes and stops at the first page nobody used since its last visit.

A page read 5 times in a row survives 5 laps of the hand without being touched again. A page read once is gone after one lap. That makes the clock an approximation of LRU that needs no list to reorder on every hit, only a counter to bump. Compare it with the textbook policies on Cache Eviction: LRU vs LFU vs FIFO vs CLOCK vs OPT and with the OS's version on Page Replacement.

Dirty victims and the bgwriter

An UPDATE changes the page in its buffer and marks it dirty. The change is safe in the WAL, so the page itself can be written later. But a dirty buffer can't be reused until it is written: when the sweep picks one, the backend must call FlushBuffer() and wait for the write before it can read its own block (Demo: miss, clock sweep, dirty victim). That wait is query latency.

The background writer (bgwriter) avoids it. Every bgwriter_delay (200 ms) it looks ahead of the clock hand and writes buffers that are dirty, unpinned and have usage_count 0: the ones the sweep will take next. They stay cached, now clean, so the backend's miss costs one read instead of a write plus a read (Demo: bgwriter cleans ahead). Popular dirty pages are left to the checkpointer (see PostgreSQL Checkpoints).

Ring buffers for big scans

A sequential scan of a big table reads every block once. Through the normal sweep each block would push out a hot page, and the hot pages would all miss afterwards. So when a table is bigger than shared_buffers / 4, a seq scan gets a buffer access strategy: a small ring of its own buffers (BAS_BULKREAD, 256 KB = 32 buffers; 2 on this page). Each new block goes into the next ring slot, reusing that buffer if it is unpinned and its usage_count is at most 1. Only an empty or busy slot falls back to the clock sweep.

After a scan of the 6 blocks of logs: with a ring, shared buffers still hold acct 0, 1, 3, 4, 5 and 7 and only two buffers hold logs pages; without a ring, only acct 0, 4 and 7 are left and five buffers hold logs pages.
The same 6-block scan: with a ring it costs 2 hot pages, without one it costs 5 of 8.
StrategyUsed byRing size
BAS_BULKREADseq scan of a table > shared_buffers / 4256 KB
BAS_BULKWRITECOPY FROM, CREATE TABLE AS16 MB
BAS_VACUUMVACUUM, ANALYZEvacuum_buffer_usage_limit (2 MB)

Pages read through a ring get usage_count 1 and never more, so they are cheap to evict (Demo: seq scan with a ring vs Demo: seq scan without a ring).

Settings and views that matter

  • shared_buffers: often about 25% of RAM. The OS page cache sits underneath, so a PostgreSQL miss is often still a memory copy, not a disk read.
  • bgwriter_delay, bgwriter_lru_maxpages, bgwriter_lru_multiplier: how often and how much the bgwriter cleans ahead.
  • pg_stat_io and EXPLAIN (ANALYZE, BUFFERS): hits, reads and writes per backend type, like the counters on the canvas. The pg_buffercache extension shows each buffer's tag, usage_count, pins and dirty flag.

See also How PostgreSQL Runs a Query for the pages themselves, the 8 KB heap page, WAL internals, the process model, and MySQL, whose InnoDB buffer pool uses an LRU list with a midpoint insert for the same scan problem.

Compare the InnoDB buffer pool, which uses a midpoint-split LRU list and innodb_old_blocks_time where PostgreSQL uses a clock sweep and scan rings. MySQL's InnoDB can delay secondary index changes in a change buffer; PostgreSQL has nothing like it.

What the page leaves out

Real PostgreSQL first takes buffers from a free list (filled at startup and when relations are dropped) before sweeping; here all buffers start full. The page skips the buffer header spinlock and content locks, the 128 hash partitions, local buffers for temporary tables, the WAL-flush check that makes a BAS_BULKREAD ring reject a dirty buffer, the bgwriter's adaptive estimate of how many buffers to clean, checkpoints, and asynchronous / direct I/O. Everything is one step at a time; in reality many backends sweep concurrently.