DB Internals AtlasPostgreSQL 18.6 Home About

Memory

SLRU caches

Alongside shared buffers, PostgreSQL keeps several small "SLRU" caches for transaction bookkeeping: whether a transaction committed, who the parent of a subtransaction is, who shares a row lock. They are tiny, very hot, and a classic source of contention.

In plain words

To decide whether a row version is visible, PostgreSQL must often answer: "did transaction 1834 commit or roll back?" That answer lives in a small file area called pg_xact (the commit log), and the hot parts of it are kept in memory in a little cache.

There are several such caches, each for one kind of bookkeeping: subtransactions (savepoints), shared row locks (multixacts), commit timestamps, LISTEN/NOTIFY messages and serializable transactions. They all use the same simple design, called SLRU ("simple least-recently-used"): a handful of 8 kB pages in shared memory, and the least recently used page is thrown out when a new one is needed.

Because they are small and every session uses them, they can become a bottleneck when an application uses many savepoints or many shared row locks. Since PostgreSQL 17 you can make them bigger.

For DBAs and operators

The caches

pg_stat_slru nameDirectorySize parameter (PG17+)Default
transactionpg_xact (2 bits per xid)transaction_buffers0 = shared_buffers/512, 16–1024 blocks
subtransactionpg_subtrans (parent xid)subtransaction_buffers0 = shared_buffers/512, 16–1024 blocks
multixact_offsetpg_multixact/offsetsmultixact_offset_buffers16 (128kB)
multixact_memberpg_multixact/membersmultixact_member_buffers32 (256kB)
commit_timestamppg_commit_tscommit_timestamp_buffers0 = shared_buffers/512, 16–1024 blocks
notifypg_notifynotify_buffers16 (128kB)
serializablepg_serialserializable_buffers32 (256kB)

With the default 128MB shared_buffers, SHOW transaction_buffers reports 256kB (32 pages). Before PostgreSQL 17 these sizes were fixed in the source (pg_xact scaled with shared_buffers up to 128 pages, subtrans 32, multixact 8/16). One pg_xact page covers 32,768 transactions; one segment file holds 32 pages (256 kB). All parameters need a restart.

Banks and locks (PostgreSQL 17+)

Each SLRU is split into banks of 16 buffers, each with its own lock, and a page can only live in one bank. Before 17 a single lock protected each whole SLRU, so lookups serialised. Explicit values must be a multiple of 16, otherwise the setting is rejected:

DETAIL: "multixact_offset_buffers" must be a multiple of 16.

Classic contention patterns

  • Subtransaction overflow: each backend caches up to 64 subtransaction ids in its PGPROC. Beyond that (many SAVEPOINTs, or PL/pgSQL EXCEPTION blocks in a loop), snapshots are marked overflowed and visibility checks must read pg_subtrans. With a long-running transaction open this produces storms of LWLock:SubtransSLRU and LWLock:SubtransBuffer waits.
  • Multixacts: SELECT … FOR SHARE/FOR KEY SHARE from several transactions on one row, and foreign key checks under concurrency, create multixacts. Heavy use shows MultiXactOffsetSLRU/MultiXactMemberSLRU waits and also consumes multixact ids that need freezing.
  • Commit log: lots of visibility checks on rows without hint bits (e.g. right after a bulk load) hit XactSLRU; VACUUM sets hint bits and freezes rows, which removes the need.

Monitoring

SELECT name, blks_hit, blks_read, blks_written, blks_zeroed, flushes, truncates
  FROM pg_stat_slru ORDER BY blks_read DESC;

SELECT wait_event_type, wait_event, count(*)
  FROM pg_stat_activity
 WHERE wait_event ~ 'SLRU|Buffer$' GROUP BY 1, 2;

A high blks_read relative to blks_hit for one cache means it is too small for the workload (reads are usually served by the OS cache, but each miss costs a system call and an exclusive bank lock to install the page, possibly evicting one another backend still needs). I/O shows up as IO:SlruRead, IO:SlruWrite and IO:SlruSync wait events.

Raising subtransaction_buffers helps, but the real fix for subtransaction storms is usually in the application: fewer savepoints per transaction and no long-running transactions next to them.

For developers: inside the source

Where it lives

  • slru.c and slru.h: the generic SLRU code.
  • Users, each calling SimpleLruInit() with the name that appears in pg_stat_slru: clog.c ("transaction"), subtrans.c, multixact.c (offsets and members), commit_ts.c, commands/async.c ("notify") and storage/lmgr/predicate.c ("serializable").
  • transam/README (sections on pg_xact and pg_subtrans) and utils/activity/pgstat_slru.c for the statistics.

Data structures

SlruSharedData (in shared memory) holds parallel arrays indexed by slot: page_buffer, page_status (SLRU_PAGE_EMPTY, SLRU_PAGE_READ_IN_PROGRESS, SLRU_PAGE_VALID, SLRU_PAGE_WRITE_IN_PROGRESS), page_dirty, page_number, page_lru_count, plus group_lsn (pg_xact only, for asynchronous commit) and the per-bank bank_locks and bank_cur_lru_count. SlruCtlData is the backend-local handle: the shared pointer, nbanks, the PagePrecedes callback for wraparound-aware truncation and the directory name.

Slots come in banks of SLRU_BANK_SIZE (16). A page can only live in bank pageno % nbanks:

static inline LWLock *
SimpleLruGetBankLock(SlruCtl ctl, int64 pageno)
{
    int bankno = pageno % ctl->nbanks;
    return &(ctl->shared->bank_locks[bankno].lock);
}

Call flow: did xid X commit?

TransactionIdDidCommit() → TransactionLogFetch() (a one-entry backend cache, cachedFetchXid) → TransactionIdGetStatus() → SimpleLruReadPage_ReadOnly(), which scans the 16 slots of the bank under a shared bank lock. On a miss it retakes the lock exclusively and calls SimpleLruReadPage() → SlruSelectLRUPage() (least recently used slot of that bank, writing it first if dirty) → SlruPhysicalReadPage(). The caller reads its 2 bits and releases the bank lock.

Commits set status with TransactionIdSetTreeStatus(); under contention TransactionGroupUpdateXidStatus() lets one backend update a whole group. Checkpoints flush with CheckPointCLOG() → SimpleLruWriteAll(); TruncateCLOG() → SimpleLruTruncate() removes old segments once the frozen horizon advances. For subtransactions, XidInMVCCSnapshot() calls SubTransGetTopmostTransaction() only when snapshot->suboverflowed is set, that is when a backend exceeded PGPROC_MAX_CACHED_SUBXIDS (64).

Sizing

The *_buffers GUCs use SimpleLruAutotuneBuffers() for the value 0 and check_slru_buffers() to require a multiple of 16; the cap is SLRU_MAX_ALLOWED_BUFFERS (1 GB worth of pages).

Extensions and tests

An extension can own an SLRU: request SimpleLruShmemSize() in shmem_request_hook, then call SimpleLruInit() in shmem_startup_hook. src/test/modules/test_slru does exactly that and exposes SQL functions such as test_slru_page_write() and test_slru_page_readonly(). Reset counters with pg_stat_reset_slru(); contention shows as the XactSLRU/XactBuffer-style LWLock wait events (one pair per SLRU).

Parameters that shape this

transaction_buffers subtransaction_buffers multixact_offset_buffers multixact_member_buffers commit_timestamp_buffers notify_buffers serializable_buffers track_commit_timestamp shared_buffers

Related topics

Commit log, hint bits and subtransactions MVCC: row versions Snapshots and visibility Locking and deadlocks XID wraparound and freezing Memory architecture Shared buffers Isolation levels