Skip to content

Limit concurrent backend WAL flushers - #2

Open
vbp1 wants to merge 3 commits into
REL_18_STABLEfrom
feat/wal-flush-backend-flushers-limit
Open

Limit concurrent backend WAL flushers#2
vbp1 wants to merge 3 commits into
REL_18_STABLEfrom
feat/wal-flush-backend-flushers-limit

Conversation

@vbp1

@vbp1 vbp1 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Adds wal_flush_backend_flushers (integer, default 0 = disabled, reloadable via SIGHUP): a cap on how many client backends may run the WAL write/fsync loop in XLogFlush() concurrently.

With many hundreds of concurrently committing sessions, backends pile up on WALWriteLock: the queue itself (lock acquire/release traffic, wait-list churn, spin delays) burns CPU and cache-coherency bandwidth without producing more flushed WAL, since the writes are serialized by the lock anyway. The limiter keeps the group-commit batching but replaces the crowd at the lock with a cheap sleep:

  • A backend that cannot take a flusher slot publishes the LSN it needs into a monotonically-advancing shared maximum (pg_atomic_monotonic_advance_u64) and sleeps on a ConditionVariable. Slot holders extend their flush requests to cover the published maximum, so waiters piggyback on the holders' fsyncs.
  • Slot handoff uses ConditionVariableSignal() (the wakeup is bound to the dequeue, so it cannot be lost); a woken backend that leaves without taking the slot passes the signal on. Wakeups after the flushed position advances are broadcast after WALWriteLock is released, mirroring how walsender wakeups are handled.
  • Only client backends are throttled; autovacuum/background workers and walsenders are exempt. Throttled backends show a dedicated wait event IPC/WalFlushLimit in pg_stat_activity.
  • The published flush request is clamped with the end-position conversion of the insert position (GetXLogInsertEndRecPtr()), matching what WaitXLogInsertionsToFinish() compares against, so a corrupted page LSN cannot poison every slot holder and the "request to flush past end of generated WAL" complaint cannot fire spuriously.
  • The limiter's shared state is padded onto its own cache lines: perf c2c profiling showed the counters and the CV's internal spinlock false-sharing a line with info_lck.

Benchmark results

4-socket server (4 NUMA nodes, 240 CPUs), RAID WAL storage, pgbench -c 750 -j 60 -T 900 --protocol=prepared, scale 75000, remote client over 100GbE, synchronous_commit=on, full_page_writes=on, wal_compression=lz4, build with NUM_XLOGINSERT_LOCKS=32. Clean data restore before each run; the baseline is the same binary with the feature disabled.

wal_flush_backend_flushers TPS vs baseline avg latency
0 (baseline) 129 906 5.77 ms
16 146 819 +13.0% 5.11 ms
32 149 780 +15.3% 5.01 ms
64 145 851 +12.3% 5.14 ms
128 144 022 +10.9% 5.21 ms

Wait-event sampling (1 s, average waiting backends of 750): LWLock/WALWrite drops from 125 to 10–20; the waiting moves into the passive IPC/WalFlushLimit sleep. User CPU per transaction nearly halves (0.79 → 0.54 core-ms), run queue drops 163 → 124, Timeout/SpinDelay drops 3x. The group-commit size stays the same (~73–76 commits per fsync): the gain comes from removing the contention overhead, not from batching changes. Per-commit durability wait is unchanged (0.93 ms vs 0.96 ms at baseline).

Testing

  • New TAP test src/test/recovery/t/055_wal_flush_backend_flushers.pl:
    • injection-point scenarios for the slot handoff and for the wakeup-arrives-before-sleep race (the waiter must complete via the post-ConditionVariablePrepareToSleep recheck);
    • concurrent pgbench commits at limit=1;
    • live reload churn between limits 3/1/0/1 under concurrent commits (an acquire/release imbalance would wedge the slots and hang);
    • crash-recovery durability of commits made through the limited path;
    • the server log must contain no flush-past-end complaints.
  • make check-world green; pgindent/perltidy clean; docs (config.sgml) updated.

The injection-point scenarios require --enable-injection-points and preload the module (the wait callbacks fire inside critical sections); without the module the test skips those sections gracefully.

vbp1 added 3 commits July 3, 2026 21:31
Address review findings against the initial implementation:

- Replace the hand-rolled 16-shard wait queue (spinlock + proclist +
  10ms latch polling) with a single ConditionVariable.  Wakeups are now
  event-driven: a released slot is handed over with
  ConditionVariableSignal(), which dequeues the process it wakes, and a
  process that leaves without taking the slot passes the signal on, so
  no wakeup can be lost and no thundering herd occurs.  This also
  removes the O(waiters) proclist scans under a spinlock and the extra
  PGPROC fields; CV wait links are cleaned up at proc exit by the
  existing machinery.

- Track the maximum requested flush LSN in one monotonically-advancing
  atomic (pg_atomic_monotonic_advance_u64) instead of per-shard values
  recomputed under a spinlock.  The published value is clamped to the
  end of inserted WAL so that a corrupted page LSN cannot make every
  slot holder repeatedly request a flush past end of WAL.  The clamp
  uses the end-position conversion of the insert position
  (GetXLogInsertEndRecPtr), matching what WaitXLogInsertionsToFinish()
  compares against: the start-position variant points past the page
  header whenever the insert position sits exactly on a page boundary,
  and benchmarking showed such requests tripping that function's
  "request to flush past end of generated WAL" complaint.

- Defer waiter wakeups out of XLogWrite(): they now run after
  WALWriteLock is released, mirroring how walsender wakeups are handled.
  A slot is kept for the whole XLogFlush() loop instead of being
  released and re-acquired around LWLockAcquireOrWait().

- Handle injection points safely around critical sections.  Plain
  INJECTION_POINT() could allocate inside one; worse, XLogFlush() is
  often entered with the caller (e.g. RecordTransactionCommit) already
  holding a critical section, so even a load placed before
  START_CRIT_SECTION is not always safe.  Load the points only when no
  critical section is active, have the TAP test pre-load them in each
  session with injection_points_load(), and preload the module so that
  the wait callback's lazily-initialized state cannot allocate either.
  Both injection points now live in code reachable only by client
  backends, so background processes can no longer consume them and hang
  the test.

- Report the wait with a new IPC wait event WalFlushLimit instead of
  reusing IO/WALSync, which made throttled backends indistinguishable
  from real fsync waits in pg_stat_activity.

- Make the GUC PGC_SIGHUP: no shared memory is sized by it, and slot
  accounting stays balanced across reloads (config reload happens only
  between statements, so each XLogFlush call sees one value).  Document
  that only client backends are subject to the limit.

- Drop the guc.sql default-value check, which would break installcheck
  against a cluster with a non-default setting; the TAP test covers the
  GUC.

- Extend the TAP test: a second injection scenario where both the slot
  handoff signal and the flush broadcast arrive while the waiter is
  registered but not yet sleeping (it must complete via the recheck
  after preparing to sleep), commit traffic across limit reloads
  bouncing between off, high and one (an acquire/release imbalance
  would wedge the slots and hang), and a crash-recovery check that
  commits made through the limited flush path are durable.  Also verify
  that the server log stays free of flush-past-end complaints.
perf c2c profiling on a 4-socket stand (750 clients, limit 32) showed
the limiter counters and the condition variable sharing one cache line
with info_lck: the CV's internal spinlock alone accounted for 70% of
that line's remote HITM traffic, competing with every XLogFlush entry,
page-crossing insert, and hint-bit GetRedoRecPtr call.  Give the
counters and the condition variable cache lines of their own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant