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 name | Directory | Size parameter (PG17+) | Default |
|---|---|---|---|
| transaction | pg_xact (2 bits per xid) | transaction_buffers | 0 = shared_buffers/512, 16–1024 blocks |
| subtransaction | pg_subtrans (parent xid) | subtransaction_buffers | 0 = shared_buffers/512, 16–1024 blocks |
| multixact_offset | pg_multixact/offsets | multixact_offset_buffers | 16 (128kB) |
| multixact_member | pg_multixact/members | multixact_member_buffers | 32 (256kB) |
| commit_timestamp | pg_commit_ts | commit_timestamp_buffers | 0 = shared_buffers/512, 16–1024 blocks |
| notify | pg_notify | notify_buffers | 16 (128kB) |
| serializable | pg_serial | serializable_buffers | 32 (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
EXCEPTIONblocks in a loop), snapshots are marked overflowed and visibility checks must readpg_subtrans. With a long-running transaction open this produces storms ofLWLock:SubtransSLRUandLWLock:SubtransBufferwaits. - Multixacts:
SELECT … FOR SHARE/FOR KEY SHAREfrom several transactions on one row, and foreign key checks under concurrency, create multixacts. Heavy use showsMultiXactOffsetSLRU/MultiXactMemberSLRUwaits 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.candslru.h: the generic SLRU code.- Users, each calling
SimpleLruInit()with the name that appears inpg_stat_slru:clog.c("transaction"),subtrans.c,multixact.c(offsets and members),commit_ts.c,commands/async.c("notify") andstorage/lmgr/predicate.c("serializable"). transam/README(sections on pg_xact and pg_subtrans) andutils/activity/pgstat_slru.cfor 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