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.
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:
| Field | Meaning | On the canvas |
|---|---|---|
| tag | which block is in the buffer: relation + fork + block number | acct/3 in the box |
usage_count | how popular the page is lately, 0 to 5 (BM_MAX_USAGE_COUNT) | green squares |
refcount (pins) | how many backends are using the buffer right now | pins 1 |
BM_DIRTY | changed in memory, not yet written to the data file | orange 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.
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.
| Strategy | Used by | Ring size |
|---|---|---|
BAS_BULKREAD | seq scan of a table > shared_buffers / 4 | 256 KB |
BAS_BULKWRITE | COPY FROM, CREATE TABLE AS | 16 MB |
BAS_VACUUM | VACUUM, ANALYZE | vacuum_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_ioandEXPLAIN (ANALYZE, BUFFERS): hits, reads and writes per backend type, like the counters on the canvas. Thepg_buffercacheextension 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.