SCN(System Change Number)是 pgrac 用于跨节点事务排序的 Lamport 时钟。每个节点独立维护一个单调递增的本地计数器;事务提交时递增并广播给其他节点;收到消息时取 max 推进。这个看似简单的 Lamport 规则在 pgrac 集群中承担着三重职责:为 MVCC 可见性判断提供因果基准、为 WAL 记录打上全局时间戳、为 Cache Fusion 和 GES 消息提供因果排序。没有 SCN,跨节点事务就没有可比的"发生先后";有了 SCN,每个节点都能从本地信息判断哪些事务在自己的快照之前提交。
本章建立理解 SCN 所需的概念框架:为什么 PG 原生的 LSN 不够用、SCN 的 8 字节编码如何组织、Lamport 推进规则的三条路径、BOC 与 Piggyback 两种传播机制、per-thread xl_scn 优化的设计动机,以及持久化与 crash 防倒退机制。协议细节(消息格式、字段定义、CAS 算法、持久化文件布局)留给深度页;本章只建立概念词汇表。
PG 单机用 LSN(Log Sequence Number) 标识 WAL 位置:LSN 是 WAL 文件的字节偏移,在单机环境内单调递增,语义清晰。但 LSN 不携带任何"事务时序"信息——PG 的事务可见性完全由 xmin / xmax 加 CLOG 表决定,LSN 仅用于确定 WAL 持久化位置,与"哪个事务先提交"无关。
在 pgrac 集群中,这一机制暴露出两个根本性的不足:
第一,LSN 跨节点不可比。节点 1 的 WAL 偏移量 0/4A3F8B0 与节点 2 的 0/4A3F8B0 是完全独立的两个值,它们不反映任何因果关系。若节点 2 的读事务要判断节点 1 的某次提交是否在自己的快照之前,仅凭 LSN 无法做到。
第二,xmin / xmax 只在单节点可见。在集群中,一个事务的 XID 分配在提交节点,其他节点并不能仅凭 CLOG 查到它的完整可见性信息——这要求额外的跨节点协调,开销不可忽视。
pgrac 按照 Oracle RAC 的参考设计,引入 SCN 作为集群范围的 Lamport 时钟。每次提交分配一个 SCN,写入 WAL 记录、block 的 pd_block_scn、ITL slot 的 commit_scn;读事务快照用提交时的 SCN 作为可见性基准。这样,任意节点上的读事务只需比较 tuple 的 commit_scn 与自己的快照 SCN,即可在本地完成可见性判断,无需问询提交节点。
LSN 与 SCN 在 pgrac 中共存且职责不重叠:LSN 继续标识 WAL 物理位置(用于 recovery、replication slot、checkpoint 管理);SCN 负责跨节点事务因果排序和 MVCC 可见性判断。两者都写入 WAL record header,但语义完全独立。
pgrac SCN 是一个 **64-bit(8 字节)**无符号整数,分为两个字段:
node_id,取值 0–255,标识产生该 SCN 的节点。集群最多支持 256 个节点。local_scn,该节点的本地 Lamport 计数器值。这一编码有两个重要属性。首先,全局唯一性由构造保证:不同节点的 node_id 不同,因此它们生成的 SCN 高 8 位不同,不可能产生相同的 64-bit 值。其次,时序比较只用低 56 位:可见性判断时比较 local_scn 部分,丢弃 node_id 高位——这是因为高位反映的是来源节点而非因果先后,用高位参与大小比较会污染 Lamport 的 happens-before 语义。
+-------+--------------------------+
| node | local SCN |
| 8 bit | 56 bit |
+-------+--------------------------+
↑ ↑
256 nodes max ~280K years @ 100K events/sec
56 位的本地计数器可表示约 72 千万亿个值。在 OLTP 集群中,每秒约产生 10 万次提交 + 10 万次 piggyback 推进,合计约 20 万 SCN/秒,理论溢出时间超过 280,000 年。这个数字不是设计余量,而是编码选择的直接结果:8 位 node_id 支持 256 节点上限,已被证明对单集群足够,剩余 56 位全部用作计数器。
InvalidScn = 0 是协议保留值,表示"尚未设置"的哨兵,与 PG 的 InvalidTransactionId = 0 约定对齐。所有真实 SCN 值均 ≥ 1,因此零值可以安全用于零初始化的结构体成员。
SCN 按照经典 Lamport 时钟规则推进,pgrac 实现了三条推进路径:
路径 1 — 本地提交:事务提交时,节点对 local_scn 执行原子加一,得到新的提交 SCN,写入 WAL commit record、TT slot、ITL slot。提交 SCN 携带本节点的 node_id 编码,形成完整的 64-bit SCN。
路径 2 — 接收外部 SCN(BOC 或 Piggyback):收到来自远程节点的 SCN 时,节点执行 local_scn = max(local_scn, remote.local_scn) + 1。这个 max+1 操作确保本节点此后产生的所有 SCN 在因果上晚于收到的消息——这正是 Lamport 的 happens-before 保证。
路径 3 — WAL 写入时 stamp xl_scn:WAL 记录插入时,读取当前 local_scn 并写入 record header 的 xl_scn 字段。这一操作不推进 local_scn,只是快照当前值;推进只发生在提交和 piggyback。
Node 1: ─●────●─────────────●───── (commit @ 42)
↘ BOC(43)
Node 2: ───────●●──────────●────── (recv → max(12,43)+1 = 44)
↘ BOC(44)
Node 3: ────────────●─────●─────── (recv → max(8,44)+1 = 45)
Lamport 规则给出的是因果一致性(causal consistency),不是全序。两个并发事务(没有因果关联的事务)的 SCN 可以无法比较先后,但这在 MVCC 可见性判断中是允许的——快照隔离的正确性不依赖于并发事务的全序,只要求因果事件有序。
跨节点 SCN 比较有两种语义,各有其专属 API:时序比较(visibility 路径)只比较低 56 位的 local_scn,对应 scn_time_cmp();全序比较(ITL slot 排序、deadlock 检测)包含高 8 位 node_id,对应 scn_total_cmp();Recovery merge 比较在同 local_scn 时加入 LSN + node_id 的二级 tie-break,对应 scn_recovery_cmp()。业务代码禁止对 SCN 值进行裸 uint64 大小比较。
SCN 通过两种机制在集群中传播,互相补充:
BOC(Broadcast on Commit) 是主动传播:事务提交后,节点立即向所有其他节点广播一条轻量消息,携带 commit_scn。BOC 保证即使集群当前没有任何其他跨节点消息,新的提交 SCN 也能及时被其他节点感知,维持 SCN 同步的下界。为避免高 TPS 下的消息风暴,BOC 采用批量 flush策略:每 100 μs 将期间积累的所有提交 SCN 合并为一条消息发出,单节点的 BOC 消息数从 100K/s 降至约 10K/s。
Piggyback 是被动传播:所有 Cache Fusion 和 GES 消息的 header 中都内嵌一个 msg_scn 字段,填充发送方的当前 local_scn。接收方在处理消息时,顺带执行 receive_piggyback(msg_scn),完成 Lamport 推进,无需额外消息。在 OLTP 繁忙的集群中,跨节点消息密度极高(100K TPS 集群约 60 万条/秒),piggyback 的推进频率远超 BOC,各节点 SCN 滞后通常在 1 ms 以内。
| 机制 | 推进方式 | 优势 | 适用场景 |
|---|---|---|---|
| BOC | 主动广播 | 保证最终传播,无消息空窗 | 低活动期、关键事件提交后 |
| Piggyback | 搭车现有消息 | 零额外消息,高频自动同步 | OLTP 繁忙期、CF / GES 密集路径 |
两者同时运行,BOC 兜底、Piggyback 主力。若仅有 Piggyback 而无 BOC,当集群某两节点之间没有直接消息交互时(例如低活动期),SCN 同步可能滞后至秒级;BOC 的存在将滞后上界约束在 100 μs 批量 flush 周期内。
xl_scn Optimization在高并发写入场景下,如果每个 backend 在 WAL 插入时都争抢同一个全局 local_scn 原子计数器,会产生明显的 cache-line bouncing 热点。pgrac 通过 per-thread xl_scn 优化规避这一问题。
WAL 记录 header 中的 xl_scn 字段(8 字节)在 WAL insert critical section 内填充,读取的是当时的 local_scn 快照值,不执行原子加一。多个 backend 可以在同一时刻读取 local_scn 并写入各自的 WAL record,彼此互不干扰——因为读取操作只需原子 load,不需要 CAS 或 fetch-and-add。
local_scn 的真正推进只发生在两个串行化点:提交路径(事务提交时原子 +1)和 piggyback 接收路径(CAS loop 推进)。这两个路径本身就是串行化的,不存在额外竞争。
WAL stamp 的结果是:同一时刻多个并发事务可以拥有相同的 xl_scn(都读到同一个 local_scn 快照),但不同提交事务的 commit_scn 互不相同(提交路径原子 +1 保证严格递增)。这对协议正确性没有影响,因为可见性判断基于 commit_scn,WAL record 的 xl_scn 仅用于 WAL k-way merge 和 crash recovery 的 SCN 重建,在 recovery 路径通过 scn_recovery_cmp() 进行确定性 tie-break 排序。
这一优化将 WAL 写入热路径的 SCN 相关开销从原子 CAS(~30 ns)降至原子 load(~10 ns),在 100K WAL record/s 的规模下,节省约 2 ms/s 的单核开销。
local_scn 存储在节点的 shared memory 中,是易失数据。若节点在 local_scn = N 时崩溃,重启后无法直接得知崩溃前的最后 SCN 值——若从 0 重启,会给出比已提交事务更小的 SCN,违反单调性,破坏可见性判断。
pgrac 通过以下机制保证崩溃安全:
周期持久化:后台 worker 每 100 ms 将当前 local_scn fsync 到独立的 SCN 持久化文件(每节点一个,位于 pg_scn/ 目录)。这确保崩溃时最多丢失 100 ms 内的 SCN 推进记录。
safety_gap 补偿:重启时,节点读取持久化 SCN 值 P,并将起始值设为 P + safety_gap(默认 1,000,000)。这个 gap 覆盖了从上次持久化到崩溃之间可能已被分配但未持久化的 SCN 值,确保重启后的所有新 SCN 不会与崩溃前已分配的值重叠。
safety_gap = 1,000,000 的选取逻辑:即便节点在 100 ms 内以 100K commit/s 的速率运行,最多产生约 10,000 个新 SCN;1M 的 gap 比这个峰值大 100 倍,且相对于 56 位计数器的 72 千万亿上限,额外"浪费"的 SCN 值可以忽略不计。
与 pg_control 的等价地位:SCN 持久化文件的设计目标与 pg_control(PostgreSQL 的控制文件)对等——任何可能影响 SCN 单调性的事件(正常 shutdown、checkpoint)都会触发强制持久化,而不等待下一个 100 ms 周期。
节点重启后,通过 KEEPALIVE 消息将带 safety_gap 的起始 SCN 通告给其他节点;其他节点收到后通过 piggyback 推进自身 local_scn。这一过程无需专门的"SCN 协商"协议——Lamport 的 max+1 规则自然完成收敛。
本章建立了 SCN 的概念框架:LSN 跨节点不可比是引入 Lamport 时钟的根本动机;8 字节编码将 node_id(高 8 位)与 local_scn(低 56 位)分离,前者保证全局唯一、后者承载因果序;三条推进路径(提交 +1、piggyback max+1、WAL stamp 只读)覆盖所有 SCN 写入场景;BOC 与 Piggyback 互补,分别保证低活动期的传播兜底与高活动期的零开销同步;per-thread xl_scn 把 WAL 热路径的 CAS 竞争降为原子 load;safety_gap + 周期 fsync 保证崩溃后 SCN 不倒退。
深度协议细节请参阅以下资源:
scn_encode / scn_time_cmp / scn_recovery_cmp)、BOC 批量 flush 时序、持久化文件布局、Reconfig freeze 期间的 SCN 处理、可见性路径与 WAL k-way merge 的完整算法msg_scn piggyback 字段;block 的 pd_block_scn 与 ITL commit_scn 字段定义Chapter 5 — Reconfiguration 介绍节点拓扑变化时的集群状态重建:节点离开或加入时,SCN 如何在 freeze / unfreeze 阶段维持单调性,故障节点的 local_scn 如何由 WAL recovery 的 xl_scn 重建,以及新节点加入时通过 KEEPALIVE + piggyback 完成 SCN 收敛的完整流程。