DB Internals AtlasPostgreSQL 18.6 Home About

Memory

Shared buffers

Shared buffers is PostgreSQL's own cache of 8 kB table and index pages. Every read and write goes through it; when it is full, a clock-sweep picks a page that has not been used recently and reuses its slot.

In plain words

PostgreSQL never works on table data directly in the files. It first copies the 8 kB page it needs into shared buffers, a big area of shared memory that every connection can see. If the page is already there, that is a hit and costs almost nothing. If not, it is a miss: the page must be read from the operating system, which may still have it in its own cache, or from disk.

When every slot is taken, PostgreSQL needs to throw a page out. It uses a clock hand that goes round the slots. Every page has a small popularity counter (0 to 5). Each time the hand passes, the counter drops by one; a page is only thrown out when its counter has reached zero. Popular pages keep getting their counter bumped back up, so they stay.

A page that was changed in memory is dirty. It must be written back to the file before its slot can be reused. Huge one-off jobs (a full scan of a big table, VACUUM, COPY) use a small private ring of slots instead, so they do not push everybody else's pages out.

Try the simulation: make the working set bigger than the cache and watch the hit ratio fall and evictions rise. Then switch to a big sequential scan and turn the ring buffer off.

For DBAs and operators

Finding a page: the buffer mapping table

Every page is identified by a buffer tag (tablespace, database, relfilenode, fork, block number). A shared hash table maps tags to buffer ids. It is split into 128 partitions, each protected by its own BufferMapping LWLock. Lots of misses on a busy server show up as LWLock:BufferMapping waits in pg_stat_activity.

Pins, locks and the dirty flag

  • A backend pins a buffer while it uses it (a reference count); a pinned buffer can never be evicted. VACUUM needs an exclusive "cleanup lock" (pin count 1) to prune some pages and may wait on BufferPin.
  • The page contents are protected by a separate content lock (LWLock:BufferContent waits).
  • A change sets the dirty flag. Dirty buffers are written by the background writer, the checkpointer, or, worst case, by the backend itself when its victim is dirty.

Clock-sweep

Each access increments the buffer's usage_count up to a maximum of 5. To find a victim, the shared clock hand (nextVictimBuffer) advances; unpinned buffers with a usage count above zero are decremented and skipped, the first unpinned buffer at zero is taken. Buffers that were never used come from a free list first. A page that is hot keeps "earning" its count back, which approximates LRU without a global list.

Ring buffers (buffer access strategies)

StrategyUsed byRing size
BULKREADSeq scans of tables bigger than ¼ of shared_buffers256 kB plus room for effective_io_concurrency × io_combine_limit reads in flight (up to about 2.3 MB with the default settings), capped by the per-backend pin limit (about 750 kB at the default 128 MB shared_buffers)
VACUUMVACUUM, ANALYZE, autovacuumvacuum_buffer_usage_limit (added in PG16; 2 MB default since PG17)
BULKWRITECOPY FROM, CREATE TABLE AS, ALTER TABLE rewrites16 MB

This is why a large SELECT count(*) does not "warm" the cache: it cycles through its small ring. If a ring buffer is dirty when its turn comes, the backend writes it itself, flushing WAL first if needed; a bulk read instead leaves a buffer that would need a WAL flush to the main pool and picks another victim (visible in pg_stat_io as writes and reuses in contexts bulkread/vacuum).

Double buffering

PostgreSQL uses buffered I/O, so a page read from disk is usually cached by the kernel as well. A miss in shared buffers that hits the OS cache is cheap (microseconds); a real disk read is not. The common starting point is shared_buffers ≈ 25% of RAM, leaving the rest to the OS cache and to work_mem. Tell the planner the combined size with effective_cache_size.

Looking inside

-- hit ratio per database (shared buffers only, OS cache hits count as "read")
SELECT datname, blks_hit, blks_read,
       round(blks_hit * 100.0 / nullif(blks_hit + blks_read, 0), 2) AS hit_pct
  FROM pg_stat_database WHERE datname IS NOT NULL;

-- what is in the cache right now
CREATE EXTENSION pg_buffercache;
SELECT * FROM pg_buffercache_summary();
SELECT usagecount, count(*), count(*) FILTER (WHERE isdirty) AS dirty
  FROM pg_buffercache GROUP BY 1 ORDER BY 1;

-- who writes buffers, and in which context
SELECT backend_type, context, reads, writes, evictions, reuses, hits
  FROM pg_stat_io WHERE object = 'relation';

A 99% hit ratio is not automatically good, and 90% is not automatically bad: misses served by the OS cache are cheap. Look at EXPLAIN (ANALYZE, BUFFERS) (buffers are shown by default in PostgreSQL 18) and at I/O timing with track_io_timing = on before resizing.

For developers: inside the source

Where it lives

  • buf_init.c: BufferManagerShmemInit() carves four arrays out of the main segment: BufferDescriptors (one 64-byte-padded BufferDesc per buffer), BufferBlocks (NBuffers × BLCKSZ, aligned to PG_IO_ALIGN_SIZE), BufferIOCVArray (a condition variable per buffer for I/O waits) and CkptBufferIds (the checkpoint sort array). NBuffers is shared_buffers in pages.
  • freelist.c: BufferStrategyControl (the atomic clock hand nextVictimBuffer, completePasses, numBufferAllocs, the free-list head and buffer_strategy_lock), StrategyGetBuffer(), ClockSweepTick(), and the ring code (GetAccessStrategy(), GetBufferFromRing(), StrategyRejectBuffer()).
  • storage/buffer/README: the access rules (pins vs. content locks, cleanup locks) and the replacement and ring strategies in prose.

One atomic word per buffer

BufferDesc.state packs an 18-bit refcount, a 4-bit usage count and 10 flag bits (BM_LOCKED, BM_DIRTY, BM_VALID, BM_TAG_VALID, BM_IO_IN_PROGRESS, BM_CHECKPOINT_NEEDED, BM_PERMANENT, …) into one pg_atomic_uint32. PinBuffer() updates it with a compare-and-swap loop; BM_LOCKED is the header "spinlock" taken by LockBufHdr() for the rarer multi-field changes:

buf_state += BUF_REFCOUNT_ONE;
if (strategy == NULL)
{
    if (BUF_STATE_GET_USAGECOUNT(buf_state) < BM_MAX_USAGE_COUNT)
        buf_state += BUF_USAGECOUNT_ONE;
}
else if (BUF_STATE_GET_USAGECOUNT(buf_state) == 0)
    buf_state += BUF_USAGECOUNT_ONE;

So a ring-strategy access never pushes a buffer above usage count 1. Each backend also counts its own pins in PrivateRefCountArray (8 entries, overflow into PrivateRefCountHash), so pinning a buffer it already holds does not touch shared memory; pins are tracked by the current ResourceOwner and released on error or at end of transaction.

Clock sweep and the bgwriter

StrategyGetBuffer() tries the ring (if any), then the free list, then the sweep: ClockSweepTick() advances the hand with an atomic fetch-add, pinned buffers are passed over and unpinned ones lose one usage count. After a full lap that changed nothing it fails with ERROR: no unpinned buffers available. Every non-ring allocation bumps numBufferAllocs and may set the bgwriter's latch; BgBufferSync() reads both through StrategySyncStart() to clean buffers just ahead of the hand. Checkpoints use BufferSync().

Observe and experiment

  • pg_buffercache: pg_buffercache_summary(), pg_buffercache_usage_counts(), pg_buffercache_evict() (17+), pg_buffercache_evict_relation() / pg_buffercache_evict_all() and the pg_buffercache_numa view (18). Evicting is handy to force misses in a test.
  • pg_prewarm and its autoprewarm worker reload a saved buffer list after restart.
  • With --enable-dtrace, probes such as buffer__read__start and buffer__flush__start (in src/backend/utils/probes.d) fire on every read and write.

The miss path (tag lookup, victim choice, read completion) is traced step by step on Buffer manager internals.

Parameters that shape this

shared_buffers effective_cache_size vacuum_buffer_usage_limit bgwriter_lru_maxpages huge_pages track_io_timing

Related topics

Memory architecture Background writer Checkpoints Checkpointer Page layout Scan methods VACUUM