Skip to content

LSN(日志序列号) ​

上一节建立了 WAL 的整体图景,其中反复出现一个词:LSN。pg_current_wal_lsn() 返回它、段文件名由它换算、检查点记录它、复制槽绑定它、pg_wal_lsn_diff() 计算它。LSN 是 WAL 世界的坐标系——没有它,「写到哪了」「从哪开始恢复」「备库落后多少」这些问题全都无法回答。

本节把 LSN 讲透:它的编码方式、与段文件名和字节偏移的换算、在内存与磁盘上的表示、以及与检查点/复制的绑定关系。理解 LSN 之后,前面所有关于 WAL 的讨论都会变得可以量化。

前置知识

阅读本节前,建议先了解:WAL 概述(预写式日志)

基础概念 ​

LSN 是一个逻辑地址,而不是物理磁盘地址。理解它需要先统一以下术语:

术语英文说明
LSNLog Sequence Number日志序列号,WAL 中字节位置的 64 位逻辑地址
高 32 位Log File Number / Logical Log ID逻辑日志文件号,等价于「第几个 4GB 区间」
低 32 位Segment Offset区间内偏移,决定段号与段内字节位置
段号Segment Number低 32 位 ÷ wal_segment_size
段内偏移Segment Offset within file低 32 位 mod wal_segment_size
WAL 插入位点Insert PositionWAL 缓冲区中下一条记录将写入的位置
WAL 刷盘位点Flush Position已 fsync 到磁盘的最后位置
WAL 重放位点Replay Position备库已重放到的位置
redo 位点Redo LSN检查点记录的「恢复起点」
复制槽replication slot备库/订阅端保留 WAL 的锚点,绑定一个 LSN
pg_wal_lsn_diff()—计算两个 LSN 之间的字节数

LSN 的编码:一个 64 位无符号整数 ​

LSN 是 64 位无符号整数,但显示时被拆成「高 32 位/低 32 位」两段十六进制,以 / 分隔:

text
LSN = 0/2A4F1B8
      │  │
      │  └── 低 32 位(hex):0x02A4F1B8 = 44,334,520
      │      · 段号        = 0x2A4F1B8 ÷ 0x1000000 = 2
      │      · 段内偏移    = 0x2A4F1B8 mod 0x1000000 = 0xA4F1B8
      │
      └───── 高 32 位(hex):0x00000000
             · 逻辑日志文件号,每 4GB 递增 1

完整数值 = 高32位 × 2³² + 低32位
8 字节头:00 00 00 00 02 A4 F1 B8

为什么 LSN 要写成「A/B」而不是一个十进制大数

因为 WAL 是按段文件组织的。写成一个 64 位大数虽然数学上等价,但无法一眼看出「落在哪个段文件、段内什么位置」。写成 高/低 后,高 32 位直接就是逻辑文件号,低 32 位除以段大小就是段号——显示格式本身就是一次预计算的索引。这也是为什么 WAL 段文件名的中间 8 位永远等于 LSN 高 32 位。

LSN 与段文件名的换算 ​

前两节已经出现过这个换算,这里给出完整的双向公式:

text
正向:LSN → 段文件名
─────────────────────────────────────────────────────────
LSN = 0/2000028(默认 wal_segment_size = 16MB = 0x1000000)

① 逻辑文件号 = LSN 高 32 位 = 0x00000000
② 段号       = LSN 低 32 位 ÷ 0x1000000 = 0x2000028 ÷ 0x1000000 = 2
③ 段内偏移   = LSN 低 32 位 mod 0x1000000 = 0x0028 = 40
④ 文件名     = 时间线(8位) + 逻辑文件号(8位) + 段号(8位)
             = 00000001 00000000 00000002

反向:段文件名 + 段内偏移 → LSN
─────────────────────────────────────────────────────────
文件名 000000010000000000000002,段内偏移 40
① 逻辑文件号 = 0x00000000
② 段号       = 0x00000002 → 低 32 位 = 2 × 0x1000000 + 40 = 0x2000028
③ LSN        = 0/2000028

两个必须记住的结论:

  • 段文件名的中间 8 位 = LSN 高 32 位,永远相等。
  • 同一个段文件内的所有 LSN,高 32 位与「低 32 位 ÷ 段大小」都相同。

为什么 LSN 是「逻辑地址」而非物理地址 ​

一个刚接触 WAL 的人常有的误解是「LSN 就是文件里的字节偏移」。不是的:

对比项物理地址(文件偏移)LSN(逻辑地址)
表示形式单个数,依赖当前文件高/低 32 位,自包含
跨段文件会重置为 0持续单调递增
受段大小影响是高 32 位不含段大小信息
能否直接比较需先确定文件可以直接比大小
是否含时间线信息否否(时间线在文件名里,不在 LSN 里)
可持久化引用否(文件会被回收复用)是(全局唯一)

关键性质:LSN 在实例整个生命周期内单调递增且永不重复。这正是它可以作为「恢复起点」「复制锚点」被写进控制文件和复制槽的前提。物理文件偏移做不到这一点——段文件会被回收改名,同一个偏移在不同时间代表完全不同的内容。

核心机制 ​

三个 LSN:插入、刷盘、重放 ​

WAL 的写入不是「写完就落盘」,而是有明确的阶段位点。区分这三个 LSN 是理解 WAL 性能与延迟的关键:

text
                    wal_buffers(共享内存)
   ┌───────────────────────────────────────────────────────┐
   │  ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
   └───▲────────────────▲──────────────────────────────────┘
       │                │
   flush 位点      insert 位点
(已 fsync 到磁盘)  (下一条记录写这里)
       │                │
       │                └── 已生成但未刷盘,断电会丢
       └── 已落盘,断电不丢

pg_wal(磁盘)
   ┌───────────────────────────────────────────────────────┐
   │  ████████████████                                     │
   └───▲───────────────────────────────────────────────────┘
       │
   flush 位点(磁盘上有效内容的末尾)
位点查询函数含义崩溃后是否保留
插入位点pg_current_wal_insert_lsn()已写入缓冲区的最新位置否(缓冲区内容丢失)
刷盘位点pg_current_wal_flush_lsn()已 fsync 到磁盘的位置是
写入位点pg_current_wal_lsn()已写到内核/磁盘的可见位置通常是
重放位点pg_last_wal_replay_lsn()(备库)备库已重放的位置是
sql
-- 同时观察三个位点,差值就是"尚未持久化"的 WAL 量
SELECT pg_current_wal_insert_lsn() AS insert_lsn,
       pg_current_wal_lsn()        AS write_lsn,
       pg_current_wal_flush_lsn()  AS flush_lsn,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
                         pg_current_wal_flush_lsn())
       ) AS unflushed;
text
 insert_lsn | write_lsn  | flush_lsn  | unflushed
------------+------------+------------+-----------
 0/2A4F5C0  | 0/2A4F5C0  | 0/2A4F1B8  | 1032 bytes

unflushed = 1032 bytes 说明有约 1KB 的 WAL 停在缓冲区里。正常情况下这个值很小(wal_writer_delay 默认 200ms 就会刷一次);如果它持续很大,说明刷盘跟不上生成速度。

提交延迟从哪来

事务 COMMIT 的延迟 = 把 insert_lsn 推进到 flush_lsn 所需的时间,也就是一次 fsync 的耗时。这是 WAL 写入路径上唯一不可协商的等待。commit_delay 参数的原理就是「让多个并发事务攒在一起,共用一次 fsync」,用极小的额外等待换取 fsync 次数的大幅下降。

LSN 在 WAL 记录里的位置 ​

每条 WAL 记录都携带自己的 LSN 与前一条记录的 LSN,形成一条可追溯的链:

text
pg_wal/000000010000000000000002
┌──────────────────────────────────────────────────────────────────┐
│ XLogRecord @ LSN 0/2000028                                       │
│   total_len=50  xid=745  prev=0/2000000  rmgr=Standby           │
├──────────────────────────────────────────────────────────────────┤
│ XLogRecord @ LSN 0/2000080                                       │
│   total_len=78  xid=745  prev=0/2000028  rmgr=Heap              │
├──────────────────────────────────────────────────────────────────┤
│ XLogRecord @ LSN 0/20000D0                                       │
│   total_len=72  xid=745  prev=0/2000080  rmgr=Btree             │
├──────────────────────────────────────────────────────────────────┤
│ XLogRecord @ LSN 0/2000118                                       │
│   total_len=78  xid=745  prev=0/20000D0  rmgr=Heap              │
├──────────────────────────────────────────────────────────────────┤
│ XLogRecord @ LSN 0/2000168                                       │
│   total_len=34  xid=745  prev=0/2000118  rmgr=Transaction COMMIT│
└──────────────────────────────────────────────────────────────────┘

两条链同时存在,用途不同:

  • prev 链:把同一事务产生的所有记录串起来。恢复时若发现某条记录的 prev 找不到,就说明中间有记录丢失或损坏。
  • lsn 递增:段文件内的记录严格按 LSN 递增排列,因此「按 LSN 顺序重放」等价于「按文件顺序重放」。

用 pg_waldump 可以直接看到这个结构:

bash
# 查看段文件中的前几条记录,注意 lsn 与 prev 字段
pg_waldump -n 5 $PGDATA/pg_wal/000000010000000000000002
text
rmgr: Standby     len (rec/tot):     50/    50, tx:     0, lsn: 0/2000028, prev 0/2000000, desc: RUNNING_XACTS nextXid 745 latestCompletedXid 744 oldestRunningXid 745
rmgr: Heap        len (rec/tot):     78/    78, tx:   745, lsn: 0/2000080, prev 0/2000028, desc: INSERT off 2 flags 0x00, blkref #0: rel 1663/16384/16385 blk 0
rmgr: Btree       len (rec/tot):     72/    72, tx:   745, lsn: 0/20000D0, prev 0/2000080, desc: INSERT_LEAF off 2, blkref #0: rel 1663/16384/16386 blk 1
rmgr: Heap        len (rec/tot):     78/    78, tx:   745, lsn: 0/2000118, prev 0/20000D0, desc: INSERT off 3 flags 0x00, blkref #0: rel 1663/16384/16385 blk 0
rmgr: Transaction len (rec/tot):     34/    34, tx:   745, lsn: 0/2000168, prev 0/2000118, desc: COMMIT 2026-09-20 12:00:11.302914 CST

LSN 与检查点 ​

检查点是「给 WAL 划定可用范围」的动作,它的核心产物就是一个 LSN:

text
        redo LSN                  当前 LSN
           │                          │
WAL ───────┼──────────────────────────┼──────────────►
           │                          │
     ┌─────┴─────┐              ┌─────┴─────┐
     │ 不再需要   │              │  可能需要  │
     │ 用于恢复   │              │  用于恢复  │
     └───────────┘              └───────────┘
     可回收(若无归档/复制槽需求)

崩溃恢复的完整过程:
  ① 读 pg_control → 取最后一次检查点的 redo LSN
  ② 从 redo LSN 所在的段文件开始顺序读 WAL
  ③ 逐条重放,遇到无效 CRC 或文件末尾停止
  ④ 恢复完成,实例进入正常运行
sql
-- 检查点信息:redo LSN、对应的段文件名、距当前多远
SELECT redo_lsn,
       pg_walfile_name(redo_lsn) AS redo_file,
       redo_wal_file,
       checkpoint_time,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), redo_lsn)
       ) AS wal_to_replay
FROM pg_control_checkpoint();
text
 redo_lsn  |        redo_file          |          redo_wal_file          |     checkpoint_time      | wal_to_replay
-----------+---------------------------+---------------------------------+--------------------------+---------------
 0/1A2B4C8 | 000000010000000000000001  | 000000010000000000000001        | 2026-09-20 12:00:11+08   | 256 MB

wal_to_replay = 256MB 是「崩溃恢复需要重放多少 WAL」的直接答案。它约等于两次检查点之间累积的 WAL 量,也是「恢复时间(RTO)」的主要决定因素:重放 256MB 通常几秒内完成,重放 20GB 就可能要几分钟。

LSN 与复制 ​

复制本质上是「把主库的 LSN 推进同步到备库」。备库落后多少,就是两个 LSN 的差值:

text
主库                                        备库
pg_current_wal_lsn()  = 0/8C4D220           pg_last_wal_replay_lsn() = 0/8A1B0C0
        │                                            │
        └──────── pg_wal_lsn_diff = 37MB ────────────┘
                  = 复制延迟的字节度量
sql
-- 主库上查看所有备库的 LSN 落后情况
SELECT client_addr,
       state,
       sent_lsn,
       write_lsn,
       flush_lsn,
       replay_lsn,
       pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn))   AS sent_to_replay,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS total_lag
FROM pg_stat_replication;
text
 client_addr |   state   | sent_lsn  | write_lsn | flush_lsn | replay_lsn | sent_to_replay | total_lag
-------------+-----------+-----------+-----------+-----------+------------+----------------+-----------
 10.0.0.22   | streaming | 0/8C4D220 | 0/8C4D120 | 0/8C4D120 | 0/8A1B0C0  | 9472 kB        | 37 MB
  • sent_lsn 是主库已通过网络发出的位置。
  • write_lsn / flush_lsn 是备库已写入 / 已 fsync 到本地 WAL 的位置。
  • replay_lsn 是备库已重放到数据页的位置。
  • 三者依次落后:sent ≥ flush ≥ replay。write 与 flush 差值反映网络与磁盘写,flush 与 replay 差值反映重放慢(常见于备库资源不足或有冲突查询)。

相关参数 ​

参数默认值生效方式说明
wal_segment_size16MB仅 initdb决定 LSN 低 32 位与段文件名/段内偏移的换算比例
wal_buffers-1(自动)重启缓冲区越大,insert_lsn 与 flush_lsn 的差距容忍度越高
wal_writer_delay200msreload刷盘间隔,直接决定 unflushed 的典型大小
wal_writer_flush_after1MBreload累积该量立即 flush,压缩 insert→flush 差距
synchronous_commitonreload + 会话提交时要求 LSN 推进到哪个阶段(本地 flush / 备库 flush / 备库 replay)
commit_delay0reload组提交等待,用于合并 fsync、摊薄 LSN 推进成本
checkpoint_timeout5minreload决定两个 redo LSN 之间的最大时间跨度
checkpoint_completion_target0.9reload检查点刷盘在超时周期内的分摊比例,影响 redo LSN 推进的平滑度
min_wal_size / max_wal_size80MB / 1GBreload决定两次 redo LSN 之间允许累积的 WAL 量
ini
# postgresql.conf
# —— 让 insert → flush 的差距更小 ——
wal_writer_delay = 200ms
wal_writer_flush_after = 1MB         # 不要设为 0,否则可能长时间不 flush

# —— 让 redo LSN 推进更平滑(避免检查点"尖峰") ——
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9

# —— 用 LSN 差距做监控时,这两个值是参照基准 ——
min_wal_size = 256MB
max_wal_size = 4GB                   # wal_to_replay 的正常上限约等于此值

详细说明 ​

用 SQL 做 LSN 的进制换算 ​

LSN 的高/低 32 位可以在 SQL 里拆解出来,方便做自定义监控:

sql
-- 把 LSN 拆成高 32 位、低 32 位、段号、段内偏移
WITH lsn AS (
  SELECT pg_current_wal_lsn() AS l
)
SELECT l AS lsn,
       pg_walfile_name(l) AS segment_file,
       ('x' || split_part(l::text, '/', 1))::bit(32)::bigint AS hi32,
       ('x' || split_part(l::text, '/', 2))::bit(32)::bigint AS lo32,
       ('x' || split_part(l::text, '/', 2))::bit(32)::bigint
         / (16 * 1024 * 1024)                             AS segment_no,
       ('x' || split_part(l::text, '/', 2))::bit(32)::bigint
         % (16 * 1024 * 1024)                             AS offset_in_segment
FROM lsn;
text
    lsn    |        segment_file        |  hi32  |   lo32    | segment_no | offset_in_segment
-----------+----------------------------+--------+-----------+------------+-------------------
 0/2A4F5C0 | 000000010000000000000002   |      0 |  44332480 |          2 |          10891200

验证:2 × 16777216 + 10891200 = 44332480,与 lo32 一致,换算正确。

用 LSN 差值量化活动 ​

pg_wal_lsn_diff() 是 WAL 相关分析里最有用的函数,它把「两个位置之间的距离」变成字节数:

sql
-- 量化四件事:未刷盘量、待重放量、复制延迟、WAL 目录压力
SELECT '未刷盘(insert-flush)' AS metric,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
                                      pg_current_wal_flush_lsn())) AS value
UNION ALL
SELECT '待重放(cur-redo)',
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(),
                                      (SELECT redo_lsn FROM pg_control_checkpoint())))
UNION ALL
SELECT '归档积压(cur-last_archived)',
       pg_size_pretty(COALESCE(pg_wal_lsn_diff(pg_current_wal_lsn(),
                          (SELECT last_archived_wal::pg_lsn FROM pg_stat_archiver)), 0))
UNION ALL
SELECT '复制槽钉住(cur-slot.restart_lsn)',
       pg_size_pretty(COALESCE(
         (SELECT max(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
          FROM pg_replication_slots), 0));
text
            metric             |  value
-------------------------------+---------
 未刷盘(insert-flush)          | 1032 bytes
 待重放(cur-redo)              | 256 MB
 归档积压(cur-last_archived)   | 96 MB
 复制槽钉住(cur-slot.restart_lsn)| 0 bytes

这四条指标构成了 WAL 健康度的完整画像:未刷盘量看提交延迟,待重放量看恢复时间,归档积压与复制槽钉住看磁盘风险。

LSN 的时间维度:elapsed 与 lag ​

LSN 是空间维度,还需要时间维度才能完整描述复制延迟:

sql
-- 主库视角:字节延迟 + 时间延迟
SELECT application_name,
       client_addr,
       state,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS byte_lag,
       replay_lag,
       sync_state
FROM pg_stat_replication
ORDER BY replay_lsn;
text
 application_name | client_addr |   state   | byte_lag | replay_lag | sync_state
------------------+-------------+-----------+----------+------------+------------
 standby_1        | 10.0.0.22   | streaming | 37 MB    | 00:00:01.2 | async
 standby_2        | 10.0.0.23   | streaming | 128 kB   | 00:00:00.1 | sync
  • byte_lag 大但 replay_lag 小 → 写入速率高,但重放跟得上,属于健康状态。
  • byte_lag 大且 replay_lag 持续增长 → 备库重放能力不足,需要排查备库资源或冲突查询。
  • replay_lag 只在 PG10+ 有;更早版本只能用 pg_last_xact_replay_timestamp() 间接估算。

LSN 边界:什么时候会「绕回」 ​

LSN 是 64 位,高 32 位与低 32 位各 2³²。理论上低 32 位写满就会进位到高 32 位,段号重置:

text
低 32 位接近满时(段大小 16MB,即段号到 255)
  LSN = 0/FFFFF...   段号 = 0xFF,段内偏移接近 16MB
        │
        ▼ 继续写
  LSN = 1/0000000    高 32 位 +1,段号归 0
        │
        ▼ 文件名随之变化
  00000001 00000000 000000FF  →  00000001 00000001 00000000
                  ▲                          ▲
              逻辑文件号 0                逻辑文件号 1

高 32 位溢出需要写满 4GB × 2³² = 2⁶⁴ 字节,现实中不可能达到。所以可以放心认为 LSN 永不回绕。

但有一个真实存在的边界:时间线(Timeline)不在 LSN 内。同一个 LSN 在不同时间线上指向不同的历史:

text
时间线 1:  A ──► B ──► C(崩溃点)
                       │
                       ├─ 恢复并提升 ──► 时间线 2: C ──► D ──► E
                       │
                       └─ 从 B 重新恢复并提升 ──► 时间线 3: B ──► X ──► Y

LSN 0/2000028 在时间线 2 上是 D,在时间线 3 上可能是 X。

因此引用 WAL 位置时必须同时给出时间线与 LSN——这就是段文件名包含 8 位时间线的原因,也是 pg_walfile_name() 内部需要读 pg_control 取当前时间线的原因。

实战示例 ​

示例 1:完整反解一个 LSN ​

用一条脚本把 LSN 的每个字段都拆开,并与磁盘上的实际文件对照:

sql
-- 1) 取当前 LSN 并反解
SELECT pg_current_wal_lsn() AS lsn,
       pg_walfile_name(pg_current_wal_lsn()) AS file_name,
       pg_walfile_name_offset(pg_current_wal_lsn()) AS file_and_offset;

-- 2) 确认该文件确实存在于 pg_wal 目录
SELECT name, pg_size_pretty(size) AS size,
       modification
FROM pg_ls_waldir()
WHERE name = pg_walfile_name(pg_current_wal_lsn());

-- 3) 从该位置读取接下来的 WAL 记录,验证 LSN 与文件内容一致
SELECT pg_current_wal_lsn() AS lsn_for_dump;
text
-- 步骤 1
   lsn    |          file_name           |            file_and_offset
----------+------------------------------+--------------------------------------
 0/2A4F5C0| 000000010000000000000002      | (000000010000000000000002,10891200)

-- 步骤 2
        name                |   size   |      modification
----------------------------+----------+------------------------
 000000010000000000000002   | 16777216 | 2026-09-20 12:41:07+08

-- 步骤 3
 lsn_for_dump
--------------
 0/2A4F5C0
bash
# 用步骤 3 的 LSN 作为起点,从对应段文件读取记录
pg_waldump --start='0/2A4F5C0' -n 5 $PGDATA/pg_wal/000000010000000000000002
text
rmgr: Heap        len (rec/tot):     78/    78, tx:   812, lsn: 0/2A4F5C0, prev 0/2A4F578, desc: INSERT off 5 flags 0x00, blkref #0: rel 1663/16384/16820 blk 0
rmgr: Standby     len (rec/tot):     50/    50, tx:     0, lsn: 0/2A4F610, prev 0/2A4F5C0, desc: RUNNING_XACTS nextXid 813 latestCompletedXid 812 oldestRunningXid 813
rmgr: Transaction len (rec/tot):     34/    34, tx:   812, lsn: 0/2A4F648, prev 0/2A4F610, desc: COMMIT 2026-09-20 12:41:02.114907 CST

结果解读:

  • 步骤 1 中 file_and_offset 给出的 (000000010000000000000002,10891200) 与手算结果完全一致:低 32 位 0x2A4F5C0 = 44332480,除以 16MB 得段号 2,余数 10891200 就是段内偏移。
  • 步骤 2 确认该段文件真实存在且是完整 16MB,修改时间与步骤 1 的时间吻合。
  • 步骤 3 的 pg_waldump 输出中,第一条记录的 lsn 正是 0/2A4F5C0——这证明 LSN 确实可以作为「精确到字节的读取起点」,--start 参数就是按这个语义工作的。
  • 记录按 lsn 严格递增排列(2A4F5C0 → 2A4F610 → 2A4F648),且每条 len 恰好等于与下一条的 LSN 差(0x610 - 0x5C0 = 0x50 = 80,含 2 字节对齐填充的头部)。

示例 2:测量一次操作的 LSN 推进量与耗时 ​

用一个完整序列把「WAL 字节数」与「实际耗时」关联起来:

sql
-- 准备测试表
DROP TABLE IF EXISTS lsn_bench;
CREATE TABLE lsn_bench (id bigserial PRIMARY KEY, payload text);

-- 1) 记录起点 LSN 与时间
SELECT pg_current_wal_lsn() AS start_lsn, clock_timestamp() AS start_time;

-- 2) 执行一批写入
INSERT INTO lsn_bench (payload)
SELECT repeat('a', 200) FROM generate_series(1, 100000);

-- 3) 记录终点,算出 WAL 量与耗时
SELECT pg_current_wal_lsn() AS end_lsn,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), '0/2A4F5C0')
       ) AS wal_generated,
       clock_timestamp() - '2026-09-20 12:41:00'::timestamptz AS elapsed;

-- 4) 与 pg_stat_wal 的累计值交叉验证
SELECT wal_records, pg_size_pretty(wal_bytes) AS wal_bytes, wal_fpi
FROM pg_stat_wal;
text
-- 步骤 1
 start_lsn |         start_time
-----------+----------------------------
 0/2A4F5C0 | 2026-09-20 12:41:00.114+08

-- 步骤 3
 end_lsn   | wal_generated |   elapsed
-----------+---------------+------------
 0/6E1A2C0 | 108 MB        | 00:00:08.42

-- 步骤 4
 wal_records | wal_bytes | wal_fpi
-------------+-----------+---------
      812430 | 1180 MB   |    3218

结果解读:

  • 这次 10 万行插入产生了 108MB WAL,平均每行约 1.1KB——元组本身 200 字节,其余主要是 B 树索引记录与 WAL 头部开销。
  • 耗时 8.42 秒,折合 WAL 生成速率约 12.8 MB/s。这个数字直接决定了 pg_wal 目录的增长速度和备库需要跟上多少带宽。
  • 步骤 4 的 pg_stat_wal.wal_bytes = 1180MB 是实例自统计重置以来的累计值,远大于单次操作的 108MB,说明这台实例的写入总量已有一定规模。
  • 一个实用的容量公式:如果持续以 12.8 MB/s 写入,pg_wal 目录在 max_wal_size = 1GB 下约每 80 秒触发一次检查点。据此可以判断 max_wal_size 是否需要调大。

示例 3:写满一个段文件并观察 LSN 进位 ​

用受控写入把 LSN 从段尾推到下一个段,观察三处同步变化:

sql
-- 1) 记录起点:LSN、段文件名、段内偏移
SELECT pg_current_wal_lsn() AS before_lsn,
       pg_walfile_name(pg_current_wal_lsn()) AS before_file,
       pg_walfile_name_offset(pg_current_wal_lsn()) AS before_offset;

-- 2) 写入足够数据以越过段边界(16MB 段的尾部)
INSERT INTO lsn_bench (payload)
SELECT repeat('b', 500) FROM generate_series(1, 40000);

-- 3) 观察终点
SELECT pg_current_wal_lsn() AS after_lsn,
       pg_walfile_name(pg_current_wal_lsn()) AS after_file,
       pg_walfile_name_offset(pg_current_wal_lsn()) AS after_offset;

-- 4) 确认段文件个数增加
SELECT count(*) AS segment_count, pg_size_pretty(sum(size)) AS total
FROM pg_ls_waldir();
text
-- 步骤 1
 before_lsn |          before_file          |           before_offset
------------+-------------------------------+--------------------------------------
 0/FE0A4C8  | 000000010000000000000000      | (000000010000000000000000,15780040)

-- 步骤 3
 after_lsn  |           after_file          |           after_offset
------------+-------------------------------+--------------------------------------
 1/01A2B40  | 000000010000000000000001      | (000000010000000000000001,1715008)

-- 步骤 4
 segment_count | total
---------------+---------
            66 | 1056 MB

结果解读:

  • 步骤 1 的段内偏移 15780040 已接近段大小 16777216(16MB),说明该段文件快写满了。
  • 步骤 3 中 before_lsn = 0/FE0A4C8 → after_lsn = 1/01A2B40:高 32 位从 0 进位到 1,段号部分从 0 变为 1,文件名第三段也从 00000000 变为 00000001。
  • after_offset 的段内偏移 1715008 重新从段头开始计算,说明新段文件的写入位置被重置。这是「LSN 连续、段内偏移不连续」的直观体现。
  • 步骤 4 显示段文件数从之前的 64 增到 66,说明新段被创建。若 wal_recycle = on 且有可回收的旧段,这里不会看到文件总数增加(旧文件被改名复用)。
  • 一个容易踩的坑:段号归零(FF → 00)与高 32 位进位(0 → 1)看起来相似,但只有高 32 位进位才改变文件名的中间 8 位。观察文件名时要看清是哪一段变了。

注意事项 ​

  • LSN 是只读的观测值,不能手工设置。没有任何 SQL 能把 pg_current_wal_lsn() 改回某个值;要做时间点恢复必须在恢复配置里指定 recovery_target_lsn。
  • 不要用 LSN 做业务语义判断。LSN 是内部的物理地址,段文件会被回收复用,今天的 0/2000028 和明天的 0/2000028 内容可能不同(若发生过时间线切换或实例重建)。
sql
-- 错误用法:把 LSN 当作永久唯一的业务标记存下来
-- LSN 会随时间线分叉、实例重建而变化,不适合作为业务主键或审计锚点

-- 正确用法:把它当作运行时的观测指标
SELECT pg_current_wal_lsn() AS lsn_for_monitoring_now;
  • pg_current_wal_lsn() 与 pg_current_wal_insert_lsn() 不要混用:前者是「已写出缓冲区」的位置,后者是「已生成待刷」的位置。做持久化保证必须看 flush 位点。
sql
-- 想判断"某个操作是否已持久化"应该看 flush 位点
SELECT pg_current_wal_insert_lsn() AS just_generated,
       pg_current_wal_flush_lsn()  AS already_safe,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
                                      pg_current_wal_flush_lsn())) AS at_risk;
  • 备库的 pg_current_wal_lsn() 返回的是重放位置,而不是「备库接收到的 WAL 位置」。要区分这两个概念需查 pg_stat_wal_receiver。
  • pg_wal_lsn_diff() 的参数顺序是「后面的减前面的」:pg_wal_lsn_diff(a, b) = a - b。传反会得到负数,pg_size_pretty() 会显示成 -xxx kB。
  • 不要用 LSN 差值替代时间延迟监控。同一个字节差值在高写入速率下可能只对应几百毫秒,在低速率下可能对应几小时——两者都需要监控。
  • 复制槽的 restart_lsn 是 WAL 无法回收的硬约束。复制槽长期不活跃会让 pg_wal 无限膨胀,必须监控并清理(见前一节)。

最佳实践 ​

  • 用 LSN 差值而非「文件个数」做 WAL 健康监控,前者能精确到字节,反应更灵敏。
sql
-- 建议固化为监控查询:四类 LSN 距离一次算清
SELECT now() AS ts,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),
                                      pg_current_wal_flush_lsn())) AS unflushed,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(),
                        (SELECT redo_lsn FROM pg_control_checkpoint())))  AS to_replay,
       (SELECT count(*) FROM pg_replication_slots WHERE NOT active)      AS dead_slots;
  • 为「待重放量」设阈值告警,建议取 max_wal_size 的 1.5 倍。超过它意味着崩溃恢复时间将明显拉长。
  • 复制延迟同时监控 byte_lag 与 replay_lag,只看字节数会误判(低写入速率下字节差很小但时间差可能很大)。
  • 用 pg_walfile_name_offset() 而不是手工拆字符串,避免段大小变化(非 16MB 实例)导致换算错误。
  • 把 pg_current_wal_lsn() 采集为时间序列,它的斜率就是实例的 WAL 生成速率,是容量规划最直接的输入。
  • 永远用 LSN 而不依赖外部时钟做恢复点判断:跨主机时钟同步不可靠,LSN 才是单调可信的。
  • recovery_target_lsn 优先于 recovery_target_time:LSN 精确无歧义,时间点可能命中多条记录或受时钟漂移影响。
  • 文档化实例的 wal_segment_size:如果 initdb 时改过(如设成 64MB),所有段文件名换算的手工计算都要相应调整,容易出错。

下一节

继续学习:WAL 写入流程(Write Flow)

参考链接 ​