Commit Sequence Number (CSN) MVCC: Lock-Free Snapshot Acquisition
Every time PostgreSQL decides whether a row is visible to your query, it asks the same question: was the transaction that wrote this row already committed when my snapshot was taken? The answer lives in a list of transaction IDs that your backend captured at snapshot time — and capturing that list requires a global lock that every other backend wants at the same moment. This article is about the change that replaced that list, and that lock, with a single number.
Part I — Background
A snapshot is an active-XID list built under one global lock
PostgreSQL implements MVCC with a simple but lock-heavy rule. A snapshot is, at its core, a list of transaction IDs that were still in flight when the snapshot was taken. To build that list, GetSnapshotData() walks the ProcArray — the shared registry of all backend processes — under the global ProcArrayLock. To decide whether a tuple is visible, the engine compares the tuple’s xmin/xmax against that XID list.
Every one of PolarDB’s read and write workloads depends on this visibility check, and every check depends on that one global lock.
Part II — The problem
ProcArrayLock is a single point of serialization. As concurrency grows, the time spent acquiring snapshots — and the contention between backends doing it simultaneously — grows with it. The lock is taken once per snapshot, and under high OLTP concurrency, snapshot acquisition becomes a genuine throughput ceiling. The list-based representation also means the cost of a snapshot scales with the number of concurrent transactions, not with the amount of data being read.
Part III — What we built
A snapshot becomes one monotonic number
CSN (Commit Sequence Number) replaces “a list of XIDs” with “one monotonically increasing number”. Every committed transaction is assigned a CSN at commit time. A snapshot no longer stores the active-XID list; it stores a single snapshot CSN. Visibility then collapses to a single comparison: a transaction’s commit CSN being less than the snapshot CSN means the tuple is visible.
The implementation adds a new SLRU, the CSNLOG, which stores the commit sequence number for each XID. A new GetSnapshotDataCSN() acquires snapshots without taking ProcArrayLock — it reads atomic variables instead of scanning the process array. HeapTupleSatisfiesMVCC() gains a CSN branch, and a small set of shared-memory counters (polar_next_csn, polar_latest_completed_xid, polar_oldest_active_xid) track the in-flight state that the snapshot logic needs.
Two snapshot shapes are supported. The default is a pure CSN snapshot. Setting polar_csn_xid_snapshot = true keeps XID lists in the snapshot but still backs them with CSN, which is useful when a workload or extension depends on the XID-list representation.
The port
Porting this from an earlier PolarDB generation to the PostgreSQL 17 codebase was the bulk of the work. The upstream code the port had to match had already moved well past the structures the original design assumed: the RecentGlobalXmin cache is gone, replaced by per-backend horizon computation and the per-relation GlobalVis machinery; the PGXACT structure is no longer the home for the hot transaction fields; they now live inside PGPROC directly. The port had to adapt to those changes, to 64-bit FullTransactionId, and to the newer table-access API.
The result
The effect is the removal of a global lock from the hottest path in the database. Snapshot acquisition goes from a serialized scan to a lock-free atomic read, and visibility becomes a single integer comparison. The benefit grows with concurrency, which is exactly the workload PolarDB targets. CSN is enabled by default (polar_csn_enable = on), and the CSNLOG can optionally maintain an upper-bound cache (polar_csnlog_upperbound_enable) with shared-memory hit/miss counters for watching the visibility hot path.
The cost is correctness detail that the follow-up work had to nail down: a race in the read-only fast path of ProcArrayEndTransactionCSN, a missing page-hit stat in one SLRU path, a missing bootstrap call at install time, and a replica-side hazard where disabling CSN could corrupt a VFS error path and even unlink the shared CSNLOG. Each was fixed separately. For a DBA, the headline is that CSN is already on, already lock-free, and the visible knob to understand is polar_csn_xid_snapshot.
Part IV — Correctness, after the port
Making the CSN snapshot fast was the easy part. Getting the edge cases right took a second pass — and the bugs that pass found were the silent kind: wrong query results with no crash, on both the primary and the standby. The CSN snapshot had collapsed a serialized scan into a handful of cached fields and counters, and each of those fields now carries a correctness contract of its own.
A read that could see the future
The visibility fast path reads two fields: the snapshot’s xmin, and its snapshotcsn. The check is, roughly, “if the writing transaction is older than xmin, it is visible.” That shortcut is only valid if xmin and snapshotcsn are read in an order that cannot reorder. They were not — there was no barrier between the two reads, so the compiler or the CPU could read snapshotcsn first and xmin second, producing a snapshot where csn(xmin) > snapshotcsn. For any transaction between those two CSNs, the fast check answered visible when the correct answer was invisible. The fix adds the missing barrier so the two reads are ordered.
A related desync sat in the transaction bookkeeping: the CSN path called its own helper that updated MyProc->xmin but not the shared TransactionXmin, leaving the two out of sync. Removing the helper and updating both together closes the gap.
A snapshot that could lose running transactions
With polar_csn_xid_snapshot on, the snapshot precomputes the set of running XIDs into an array, and answers visibility from that list. When more transactions are running than the array can hold, the list is truncated to the lowest-numbered running XIDs and a suboverflowed flag is set — every running XID above the cutoff is silently dropped.
The visibility functions guarded on the xid-snapshot flag but not on suboverflowed, so a miss in the list was read as “not running.” A transaction that was running at snapshot time but fell past the cutoff was therefore reported not running; once it committed, a live commit lookup made its rows visible to a snapshot that should not see them. That is a snapshot-isolation violation — a silent wrong answer on the primary.
The fix keeps consulting the list, but trusts only a hit. A hit is authoritative even when the list overflowed, because overflow drops running XIDs and never invents them. On a miss, the previous behaviour is kept only when the list is complete; when it overflowed, the miss falls through to a CSN-log lookup that compares the commit CSN against the snapshot CSN — correct no matter how many transactions were running.
A standby that trusted a bit it should not
On a standby, a heap scan can skip the per-tuple visibility test for a page whose PD_ALL_VISIBLE bit is set — but only when the snapshot was not taken during recovery. The CSN snapshot builder never set its takenDuringRecovery field, so it kept a stale value left over in the reused snapshot struct. With the field left false on a standby, the scan trusted the all-visible bit, skipped the per-tuple test, and returned rows the snapshot should not see.
The reason the bit is untrustworthy during recovery is subtle. On the primary, VACUUM sets the all-visible bit together with a visibility-map record that carries a cutoff XID; on the standby that record drives conflict resolution, which is what makes the map trustworthy. The page-level PD_ALL_VISIBLE bit gets no such interlock — it can arrive on a standby page inside a full-page image, outside the conflict-resolution path. The fix sets takenDuringRecovery in the CSN builder, matching the non-CSN path.
Serialization, truncation, and fallback
Six smaller fixes rounded out the port. The snapshot text parser now breaks out of its loop after the CSN value, so it does not run the non-CSN invariants on it, and it treats an omitted CSN value as a format error instead of silently accepting it as zero. A 64-bit-to-32-bit cast that truncated the CSN value in pg_current_snapshot() was replaced with the correct 64-bit conversion. The fallback logic in the CSN lookup was corrected to fall back to CLOG when there are no active snapshots, rather than only when a transaction predates TransactionXmin. And aborted CSN transactions are no longer reported as still running, which had been silently corrupting internal assumptions.
A fifth was in pg_visible_in_snapshot(). Its CSN path asked the generic snapshot-membership test and negated the answer, which inverted the verdict for transactions in the [xmin, xmax] range, and it left the CSN-snapshot flag of its temporary snapshot struct uninitialised. It now goes through the same XidVisibleInSnapshotCSN() check the heap uses, and it raises “snapshot too old for CSN visibility check” rather than quietly reporting a stale status from CLOG once the CSN data for the requested transaction has been truncated away. The sixth replaced an assertion on the global TransactionXmin inside polar_csnlog_get_next_active_xid() with one on that function’s own argument — the routine never reads the global horizon at all.
The through-line of all of these fixes is the same: the CSN snapshot traded a serialized scan for a handful of cached fields and counters, and each of those fields now has a correctness contract of its own — a read that must be ordered, a list that must not be trusted past its cutoff, a flag that must be set before it is read. For a DBA, the result is that CSN’s headline benefit — lock-free snapshots — now holds up at the edges too, on the primary and the standby alike.