# 预写式日志

LLMS 索引： [llms.txt](/zh18/llms.txt)

---

<span id="internal-ru-chapter-10" class="pdf-anchor"></span><span id="internal-ru-page-212" class="pdf-anchor"></span>

<span id="internal-ru-dummy-82" class="pdf-anchor"></span>

## 10.1. 日志记录 {#internal-ru-section-10-1}

发生故障时，例如断电、数据库管理系统或操作系统发生故障，内存中的全部内容都会丢失；只有已经写入磁盘的信息会保留下来。要让服务器在故障后启动，就必须恢复数据的一致性。如果磁盘本身受损，从备份恢复时也需要解决同样的一致性问题。

一种理论上可行的方法，是始终保持磁盘上的数据一致。实际上，这意味着必须不断把单个页面写入磁盘（随机写入的代价高于顺序写入），而且写入顺序还必须保证一致性在任何时刻都不被破坏（这很难，尤其是索引组织复杂时）。

PostgreSQL 和大多数数据库管理系统一样，采用了另一种方法。

<span id="internal-ru-page-213" class="pdf-anchor"></span>

运行过程中，一部分最新数据只保存在内存中，延迟写入磁盘（或其他非易失性存储）。因此，在服务器运行期间，磁盘上的数据始终处于不一致状态：不同页面反映的是不同时间点的状态。不过，内存中的每个操作（例如修改缓冲区缓存中的页面）都会被*记录*：系统会创建日志记录并保存到磁盘，其中包含必要时重复执行同一操作所需的最少信息[^internal-ru-213-1]。

页面修改的日志记录必须*先于*修改后的页面写入磁盘。这就是它被称为*预写式日志*（write-ahead log）的原因。这样发生故障时，就可以从磁盘读取日志，重复执行故障前已经完成、但结果尚未从内存写入磁盘而丢失的操作。

通常，记录日志比写入单个页面更高效。日志是连续的数据流，即使 HDD 也能较好地处理；而且一条日志记录可能比一个页面小。

所有在故障时可能导致磁盘上数据不一致的操作都必须记录。具体来说，日志中会记录以下操作：

- 修改缓冲区缓存中的关系页面——修改后的页面会延迟写入磁盘；

- 提交和中止事务——状态变化发生在 clog 缓冲区中，也不会立即写入磁盘；

- 文件操作（例如创建表时创建和删除文件、目录）——这些操作必须与数据修改同步。

日志中不会记录：

- 对无日志表（`UNLOGGED`）的操作；

- 对无日志序列的操作（通常属于无日志表）；（v. 15）

- 对临时表和序列的操作——这些对象的生命周期无论如何都不会超过创建它们的会话生命周期。

<span id="internal-ru-page-214" class="pdf-anchor"></span>

日志不仅用于故障恢复，也用于从备份恢复到任意时间点，以及用于复制。

## 10.2. 日志的结构 {#internal-ru-section-10-2}

<span id="internal-ru-section-star-152" class="pdf-anchor"></span>

### 逻辑结构 {#internal-ru-subsection-star-153}

从逻辑结构看，日志[^internal-ru-214-1]可以表示为一系列长度不同的记录。每条记录包含某个操作的*数据*，前面有一个标准的*头部*[^internal-ru-214-2]。头部还包含以下信息：

- 记录所属事务的编号；

- 负责解释该记录的资源管理器[^internal-ru-214-3]；

- 用于检测数据损坏的校验和；

- 记录长度；

- 指向上一条日志记录的引用。

> [!NOTE]
> 通常日志按正向读取，不过例如 `pg_rewind` 工具就会反向扫描。

数据本身的格式和含义各不相同。例如，它可以是一个页面片段，需要在指定偏移处覆盖页面的一部分。相应的资源管理器“知道”如何解析和重放这条记录。表、每种索引类型、事务状态及其他实体各有自己的资源管理器。完整列表可用命令 `pg_waldump -r list` 获取。

<span id="internal-ru-page-215" class="pdf-anchor"></span>

服务器共享内存中为日志分配了专用缓冲区。日志缓存大小由 *wal\_buffers* 参数设置。默认值表示自动设置：分配 16 MB（一个日志段的大小）；如果缓冲区缓存非常小，则分配更少。（*默认值：* `-1`）（*第 [216](/zh18/10-write-ahead-log/#internal-ru-page-217) 页*）

日志缓存的结构与缓冲区缓存相似，但主要以环形缓冲区方式工作：记录添加到“头部”，从“尾部”开始写入磁盘。日志缓存太小会导致与磁盘同步的频率高于必要程度。

在负载很低的系统中，已经保存到磁盘的记录位置（缓冲区“尾部”）与插入位置（缓冲区“头部”）几乎总是相同：

```text {label="psql"}
=> SELECT pg_current_wal_lsn(), pg_current_wal_insert_lsn();
 pg_current_wal_lsn | pg_current_wal_insert_lsn
--------------------+---------------------------
 2/8121CB28         | 2/8121CB28
(1 row)
```

要引用某条记录，可以使用 `pg_lsn` 数据类型（log sequence number，LSN）。它表示相对于日志开头、到该日志记录为止的 64 位字节偏移量。LSN 以两个用斜线分隔的 32 位十六进制数字显示。

创建一张表：

```text {label="psql"}
=> CREATE TABLE wal(id integer);
=> INSERT INTO wal VALUES (1);
```

开始一个事务，并记下日志插入位置：

```text {label="psql"}
=> BEGIN;
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 2/81235AE8
(1 row)
```

现在执行某个操作，例如更新一行：

<span id="internal-ru-page-216" class="pdf-anchor"></span>

```text {label="psql"}
=> UPDATE wal SET id = id + 1;
```

修改在内存中的缓冲区缓存页面上执行。修改信息也会保存到内存中的日志页面。因此，插入位置会向前移动：

```text {label="psql"}
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 2/81235B30
(1 row)
```

为了保证修改后的数据页面严格晚于相应日志记录写入磁盘，页头会保存与该页面相关的最后一条日志记录的 LSN。可以使用 `pageinspect` 扩展检查这个位置：

```text {label="psql"}
=> SELECT lsn FROM page_header(get_raw_page('wal',0));
    lsn
------------
 2/81235B30
(1 row)
```

整个数据库集簇共用一个日志，新的记录会不断追加。因此，页面中的 LSN 可能小于函数 `pg_current_wal_insert_lsn` 刚才返回的值。不过，在系统没有发生任何事情时，这两个数字会相同。

现在提交事务：

```text {label="psql"}
=> COMMIT;
```

提交记录也会写入日志，插入位置再次发生变化：

```text {label="psql"}
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 2/81235B58
(1 row)
```

<span id="internal-ru-page-217" class="pdf-anchor"></span>

提交会改变 SLRU 缓存中 clog 页面里的事务状态。为了防止 clog 页面早于相应日志记录被写入磁盘，也会跟踪 clog 页面对应的最后一条日志记录的 LSN。不过，这个信息不存储在页面本身，而是保存在内存中。（*第 [85](/zh18/03-pages-and-tuples/#internal-ru-page-86) 页*）（*第 [309](/zh18/15-locks-on-memory-structures/#internal-ru-page-310) 页*）

某个时刻，创建的日志记录会写入磁盘。之后，数据页面和 clog 页面就可以从缓存中逐出。如果必须提前逐出，系统会检测到这一点，并先强制把日志记录写入磁盘[^internal-ru-217-1]。（*第 [235](/zh18/11-wal-modes/#internal-ru-page-236) 页*）

知道两个 LSN 位置后，只需用一个位置减去另一个位置，就可以得到它们之间日志记录的大小（字节）。只需先将位置转换为 `pg_lsn` 类型：

```text {label="psql"}
=> SELECT '2/81235B58'::pg_lsn - '2/81235AE8'::pg_lsn;
 ?column?
----------
      112
(1 row)
```

在这个例子中，更新行和提交事务约占用日志中的一百字节。

同样，可以估算特定负载下服务器每单位时间生成的日志量。这一信息用于设置检查点。

<span id="internal-ru-section-star-154" class="pdf-anchor"></span>

### 物理结构 {#internal-ru-subsection-star-155}

<span id="internal-ru-dummy-83" class="pdf-anchor"></span>

在磁盘上，日志以段文件形式存储在 `PGDATA/pg_wal` 目录中。只读参数 *wal\_segment\_size* 显示段大小。（*默认值：* `16MB`）

<span id="internal-ru-page-218" class="pdf-anchor"></span>

对于高负载系统，增大段大小可能降低开销，但只能在初始化集簇时设置（`initdb --wal-segsize`）。

日志记录写入当前文件；文件写满后开始使用下一个文件。

可以知道某个位置位于哪个文件，以及它相对于文件开头的偏移量：

```text {label="psql"}
=> SELECT file_name, upper(to_hex(file_offset)) file_offset
FROM pg_walfile_name_offset('2/81235AE8');
        file_name         | file_offset
--------------------------+-------------
 000000010000000200000081 | 235AE8
 └───┬──┘└───────────┬────────────┘
   ветвь      log sequence number
  времени
(1 row)
```

文件名由两部分组成。前八个十六进制位表示从备份恢复时使用的*时间线*编号，其余部分对应 LSN 的高位（LSN 的低位由 `file_offset` 字段显示）。

可以使用专门的函数查看日志文件：

```text {label="psql"}
=> SELECT *
FROM pg_ls_waldir()
WHERE name = '000000010000000200000081';
           name           |   size   |      modification
--------------------------+----------+------------------------
 000000010000000200000081 | 16777216 | 2026-04-05 20:54:38+03
(1 row)
```

可以使用 `pg_waldump` 工具或 `pg_walinspect` 扩展查看已创建日志记录的头部：（v. 15）

<span id="internal-ru-page-219" class="pdf-anchor"></span>

```text {label="psql" hl_lines="11"}
=> CREATE EXTENSION pg_walinspect;
=> SELECT start_lsn, resource_manager, xid, record_type,
  left(description,44) description, block_ref
FROM pg_get_wal_records_info('2/81235AE8', '2/81235B58') \gx
-[ RECORD 1 ]----+------------------------------------------------
start_lsn        | 2/81235AE8
resource_manager | Heap
xid              | 917
record_type      | HOT_UPDATE
description      | old_xmax: 917, old_off: 1, old_infobits: [],
block_ref        | blkref #0: rel 1663/16391/16586 fork main blk 0
-[ RECORD 2 ]----+------------------------------------------------
start_lsn        | 2/81235B30
resource_manager | Transaction
xid              | 917
record_type      | COMMIT
description      | 2026-04-05 20:54:38.825403+03
block_ref        |
```

这里可以看到两条记录的头部。

第一条是由 `Heap` 资源管理器处理的 `HOT_UPDATE` 操作。`block_ref` 列显示更新后的表页面的文件名和页号：（*第 [115](/zh18/05-page-pruning-and-hot-updates/#internal-ru-page-116) 页*）

```text {label="psql"}
=> SELECT pg_relation_filepath('wal');
 pg_relation_filepath
----------------------
 base/16391/16586
(1 row)
```

第二条是由 `Transaction` 资源管理器处理的 `COMMIT` 操作。

## 10.3. 检查点 {#internal-ru-section-10-3}

<span id="internal-ru-page-220" class="pdf-anchor"></span>

为了在故障后恢复一致性，必须依次读取日志，并把涉及丢失修改的记录应用到相应页面。要找出这些记录，需要比较磁盘上页面的 LSN 与日志记录的 LSN。不过，仍然不清楚应该从哪条记录开始恢复。如果开始得太晚，在这一时刻之前写入磁盘的页面就不会得到全部修改，从而导致不可逆的数据损坏。但从最开始恢复也不现实：既无法保存如此巨量的数据，也无法接受同样巨大的恢复时间。因此需要一个逐渐向前推进的*检查点*，从这里安全地开始恢复；检查点之前的日志记录则可以删除。

获得检查点最简单的方法，是定期暂停系统工作，把缓冲区缓存和其他缓存中的所有脏页写入磁盘。当然，这种方法完全不可行，因为系统会在这些时刻停顿一段不确定但相当长的时间。

<span id="internal-ru-dummy-84" class="pdf-anchor"></span>

因此在实践中，检查点会随时间展开，实际上成为一个时间段。检查点由名为 checkpointer 的专用后台进程执行[^internal-ru-220-1]。

**检查点开始。** 首先，检查点进程将那些由于体积小而可以一次性写入的结构刷到磁盘。这包括 SLRU 缓存中的对象（例如 clog 事务状态和嵌套事务）以及其他一些结构。（*第 [309](/zh18/15-locks-on-memory-structures/#internal-ru-page-310) 页*）

**执行检查点。** 检查点执行的大部分时间用于写入缓冲区缓存中的脏页[^internal-ru-220-2]。

首先，在检查点开始时为所有当时脏的缓冲区头部设置一个特殊标志。这个过程很快，因为不涉及输入输出。

然后，检查点进程逐步遍历所有缓冲区，把带标记的缓冲区刷到磁盘。页面不会从缓存中逐出，只是写入，因此无需关注缓冲区的访问计数或钉住状态。

页面按编号顺序刷写，使写入尽可能连续而不是随机。同时，不同表空间（它们可能对应不同的物理设备）交替写入，以均匀分摊负载。（*第 [26](/zh18/01-introduction/#internal-ru-page-27) 页*）

<span id="internal-ru-page-221" class="pdf-anchor"></span>

带标记的缓冲区也可能由服务进程写入，取决于谁先到达该缓冲区。无论谁执行写入，都会清除此前设置的标志，因此就检查点而言每个缓冲区只会写入一次。

检查点执行期间，缓冲区缓存中的页面当然还会继续变化。但新变脏的缓冲区没有设置标志，检查点进程不会写入它们。

**检查点完成。** 当检查点*开始时*已经脏的所有缓冲区都写完后，检查点就算*完成*。此时（不能更早）检查点的*开始*时刻会被用作恢复起点。之前的所有日志记录都不再需要。

![检查点：检查点开始；恢复开始；恢复所需的日志文件；故障；时间](/images/internal/zh/p221-01.svg)
{#internal-ru-fig-10-1 num="10.1" caption="检查点" .book-original-figure width=720 height=410}

进程结束时会创建一条表示检查点结束的日志记录，并写入检查点开始时刻的 LSN。

此外，`PGDATA/global/pg_control` 文件会更新为指向最近完成的检查点。（进程完成前，`pg_control` 仍指向前一个检查点。）

为了彻底弄清楚各个位置之间的指向关系，我们看一个小例子。

<span id="internal-ru-page-222" class="pdf-anchor"></span>

使缓冲区缓存中的一些页面变脏：

```text {label="psql"}
=> UPDATE big SET s = 'FOO';
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;
 count
-------
  4131
(1 row)
```

记下当前日志位置：

```text {label="psql"}
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 2/81AC6580
(1 row)
```

现在手动执行检查点。所有脏页都会写入磁盘；由于系统没有其他活动，不会出现新的脏页：

```text {label="psql"}
=> CHECKPOINT;
=> SELECT count(*) FROM pg_buffercache WHERE isdirty;
 count
-------
     0
(1 row)
```

查看检查点如何反映在日志中。`CHECKPOINT_REDO` 记录检查点的开始，`CHECKPOINT_ONLINE` 记录检查点的结束：（v. 17）

<span id="internal-ru-page-223" class="pdf-anchor"></span>

```text {label="psql" hl_lines="10 15 18"}
=> SELECT start_lsn,
  resource_manager,
  record_type,
  left(description,46)||'...' description
FROM pg_get_wal_records_info('2/81AC6580','FFFFFFFF/FFFFFFFF')
  -- pg_get_wal_records_info_till_end_of_wal до v.16
WHERE record_type LIKE 'CHECKPOINT%'
\gx
-[ RECORD 1 ]----+--------------------------------------------------
start_lsn        | 2/81AC6580
resource_manager | XLOG
record_type      | CHECKPOINT_REDO
description      | wal_level replica...
-[ RECORD 2 ]----+--------------------------------------------------
start_lsn        | 2/81AC65D8
resource_manager | XLOG
record_type      | CHECKPOINT_ONLINE
description      | redo 2/81AC6580; tli 1; prev tli 1; fpw true; ...
```

这两个位置之间会有服务器继续工作产生的其他记录。

> [!NOTE]
> 在 PostgreSQL 10 之前，预写式日志通常使用 XLOG（transaction log）这个缩写。这个名称的痕迹不仅留在资源管理器名称中，也留在源代码里。

在控制文件中也可以找到相同的信息：

```text
postgres$ /usr/local/pgsql/bin/pg_controldata \
-D /usr/local/pgsql/data | egrep 'Latest.*location'
Latest checkpoint location:           2/81AC65D8
Latest checkpoint's REDO location:    2/81AC6580
```

![检查点：PGDATA/global/pg_control；最新检查点位置；最新检查点的 REDO 位置；检查点开始与完成](/images/internal/zh/p223-02.svg)
{#internal-ru-fig-10-2 num="10.2" caption="检查点" .book-original-figure width=717 height=327}

<span id="internal-ru-page-224" class="pdf-anchor"></span>

## 10.4. 恢复 {#internal-ru-section-10-4}

<span id="internal-ru-dummy-85" class="pdf-anchor"></span>

服务器启动时，首先启动 postmaster 进程。它随后启动 startup 进程[^internal-ru-224-1]，其任务是在发生故障时执行恢复。

startup 读取控制文件 `pg_control` 并检查集簇状态，以确定是否需要恢复。`pg_controldata` 工具可以查看该文件的内容：

```text
postgres$ /usr/local/pgsql/bin/pg_controldata \
-D /usr/local/pgsql/data | grep state
Database cluster state:               in production
```

干净停止的服务器状态是“shut down”。未运行的服务器却显示“in production”，说明发生了故障。此时 startup 会从同一个 `pg_control` 文件中记录的最后完成检查点的起点开始自动恢复。

> [!NOTE]
> 如果 PGDATA 目录中存在备份中的 `backup_label` 文件，恢复起点会从该文件读取。

进程从找到的起点开始依次读取日志；如果页面 LSN 小于日志记录的 LSN，就将记录应用到数据页。如果页面 LSN 更大，就不应、准确地说不能应用这条记录，因为记录必须严格按顺序应用。

<span id="internal-ru-dummy-86" class="pdf-anchor"></span>

不过，有些记录是*整页镜像*（full page image，FPI）。整页镜像可以应用于页面的任何状态，因为页面内容无论如何都会被覆盖。这类修改称为*幂等*。另一个幂等操作是记录事务状态的变化：每个事务在 clog 中对应固定的若干位，设置这些位不依赖它们原来的值。因此，clog 页面中无需保存最后一次修改的 LSN。

<span id="internal-ru-page-225" class="pdf-anchor"></span>

日志记录应用于缓冲区缓存中的页面，和正常工作时一样。

同样，日志记录也应用于文件：例如，如果记录表明某个文件必须存在，而它不存在，就会创建该文件。

恢复结束后，所有无日志关系都会使用 init 文件中的镜像重写。（*第 [30](/zh18/01-introduction/#internal-ru-page-31) 页*）

最后执行检查点，把恢复后的状态固定到磁盘。

至此 startup 进程的工作结束。

> [!NOTE]
> 传统上，恢复过程分为两个阶段。第一阶段（roll forward）重放日志记录，服务器重复故障时丢失的工作。第二阶段（roll back）回滚故障时尚未提交的事务。
>
> PostgreSQL 不需要第二阶段。恢复后，clog 中既不会设置提交位，也不会设置中止位（从形式上看，这对应于正在执行的事务）；但由于可以确定该事务已经不再运行，它会被视为已中止[^internal-ru-225-1]。

可以通过以 immediate 模式强制停止服务器来模拟故障：

```bash
postgres$ pg_ctl stop -m immediate
```

检查集簇状态：

```text
postgres$ /usr/local/pgsql/bin/pg_controldata \
-D /usr/local/pgsql/data | grep 'state'
Database cluster state:               in production
```

启动服务器时，startup 发现发生了故障，于是启动恢复：

<span id="internal-ru-page-226" class="pdf-anchor"></span>

```bash {hl_lines="5 6 7 9 11 12"}
postgres$ pg_ctl start -l /home/postgres/logfile
postgres$ tail -n 8 /home/postgres/logfile
LOG:  database system was interrupted; last known up at 2026-04-05
20:54:39 MSK
LOG:  database system was not properly shut down; automatic recovery in
progress
LOG:  redo starts at 2/81AC6580
LOG:  invalid record length at 2/81AC6650: expected at least 24, got 0
LOG:  redo done at 2/81AC65D8 system usage: CPU: user: 0.00 s, system:
0.00 s, elapsed: 0.00 s
LOG:  checkpoint starting: end-of-recovery immediate wait
LOG:  checkpoint complete: wrote 0 buffers (0.0%), wrote 3 SLRU
buffers; 0 WAL file(s) added, 0 removed, 0 recycled; write=0.001 s,
sync=0.001 s, total=0.007 s; sync files=2, longest=0.001 s,
average=0.001 s; distance=0 kB, estimate=0 kB; lsn=2/81AC6650, redo
lsn=2/81AC6650
LOG:  database system is ready to accept connections
```

正常停止时，postmaster 会断开所有客户端，然后执行最后检查点，把脏页面写入磁盘。

记下当前日志位置：

```text {label="psql"}
=> SELECT pg_current_wal_insert_lsn();
 pg_current_wal_insert_lsn
---------------------------
 2/81AC66C8
(1 row)
```

现在正常停止服务器：

```bash
postgres$ pg_ctl stop
```

检查集簇状态：

```text
postgres$ /usr/local/pgsql/bin/pg_controldata \
-D /usr/local/pgsql/data | grep state
Database cluster state:               shut down
```

查看日志，会在末尾发现最后检查点的记录（`CHECKPOINT_SHUTDOWN`）：

<span id="internal-ru-page-227" class="pdf-anchor"></span>

```text {hl_lines="3 4"}
postgres$ /usr/local/pgsql/bin/pg_waldump \
-p /usr/local/pgsql/data/pg_wal -s 2/81AC66C8
rmgr: XLOG        len (rec/tot):    114/   114, tx:          0, lsn:
2/81AC66C8, prev 2/81AC6650, desc: CHECKPOINT_SHUTDOWN redo 2/81AC66C8;
tli 1; prev tli 1; fpw true; wal_level replica; xid 0:920; oid 24778;
multi 1; offset 0; oldest xid 744 in DB 1; oldest multi 1 in DB 1;
oldest/newest commit timestamp xid: 0/0; oldest running xid 0; shutdown
pg_waldump: error: error in WAL record at 2/81AC66C8: invalid record
length at 2/81AC6740: expected at least 24, got 0
```

`pg_waldump` 的最后一条消息表示该工具已经读到日志末尾。

再次启动实例：

```bash
postgres$ pg_ctl start -l /home/postgres/logfile
```

## 10.5. 后台写入 {#internal-ru-section-10-5}

<span id="internal-ru-dummy-87" class="pdf-anchor"></span>

如果服务进程需要从缓冲区逐出脏页，就必须自己把它写到磁盘。这种情况并不理想，因为会导致等待；最好让写入异步地在后台进行。

检查点进程承担一部分脏页刷写工作，但还不够。

因此还有后台写入进程 *bgwriter*[^internal-ru-227-1]。它使用与逐出机制相同的缓冲区查找算法，但有两个区别：

- 使用自己的“时钟指针”，可以领先于逐出时钟指针，但绝不落后于它；

- 遍历缓冲区时不减少访问计数。

<span id="internal-ru-page-228" class="pdf-anchor"></span>

如果缓冲区未被钉住且访问计数为零，脏页就会写入磁盘。这样，后台写入进程走在逐出之前，预先写入很可能很快被逐出的缓冲区。

这提高了逐出选中的缓冲区不是脏页的概率。

## 10.6. 配置 {#internal-ru-section-10-6}

<span id="internal-ru-section-star-156" class="pdf-anchor"></span>

### 检查点配置 {#internal-ru-subsection-star-157}

检查点的持续时间（更准确地说，是写入脏缓冲区这一阶段的持续时间）由 *checkpoint\_completion\_target* 参数决定。它表示两个相邻检查点开始时刻之间，有多大比例的时间用于写入。不建议把它增大到 1，因为下一次检查点可能在上一次完全结束之前就必须开始。不会发生灾难，因为任何时刻都只执行一个检查点，但正常的工作节奏可能被打乱。（*默认值：* `0.9`）（v. 14）

可以按如下方式配置其他参数。首先确定两个连续检查点之间允许保留的日志文件量。容量越大，开销越小，但这个值始终受可用空间和可接受恢复时间的限制。

可以计算在*正常*负载下生成这部分日志需要多长时间。为此，记下日志的初始插入位置，并定期检查当前位置与记住的位置之间的差值。

把得到的时间视为通常的检查点间隔，并将其写入 *checkpoint\_timeout* 参数。默认值很可能太小；通常会把时间增加到例如半小时。（*默认值：* `5min`）（*第 [245](/zh18/11-wal-modes/#internal-ru-page-246) 页*）

<span id="internal-ru-page-229" class="pdf-anchor"></span>

不过，*有时*（甚至很可能）负载会更高，在参数规定的时间内生成过多的日志记录。在这种情况下，检查点必须更频繁地执行。因此，可以用 *max\_wal\_size* 参数限制恢复所需的日志文件量。超过这个上限后[^internal-ru-229-1]，服务器会发起一次计划外检查点。（*默认值：* `1GB`）

恢复所需的日志文件既包含上一轮已完成检查点期间的记录，也包含当前尚未完成检查点期间的记录。因此，要估算总量，就要把已知的检查点间日志量乘以（1 + *checkpoint\_completion\_target*）。

按这种方式配置后，大多数检查点会按计划每隔 *checkpoint\_timeout* 个时间单位执行一次。但负载升高时，达到 *max\_wal\_size* 的日志量后，检查点会更频繁地执行。

检查点进程会持续把实际进度与预期进度进行比较[^internal-ru-229-2]：

**实际进度**由已经遍历的缓冲区缓存页面所占比例决定；

**按时间计算的预期进度**由已经消耗的时间比例决定，前提是检查点应在 *checkpoint\_timeout* × *checkpoint\_completion\_target* 这一时间间隔内完成；

**按容量计算的预期进度**由已经填满的日志文件所占比例决定，计划使用的日志文件数量根据 *max\_wal\_size* × *checkpoint\_completion\_target* 计算。

如果脏页写入领先于计划，写入就会暂停；如果在两个参数中的至少一个方面落后，就会无延迟地追赶[^internal-ru-229-3]。同时考虑时间和容量，就可以用同一种方式控制按计划和按需执行的检查点速度。

<span id="internal-ru-page-230" class="pdf-anchor"></span>

检查点完成后，服务器会清理恢复（以及其他任务）不再需要的日志记录[^internal-ru-230-1]。其中一部分文件会被删除，其他文件会被重命名并重新使用。文件数量取决于日志记录的生成速率，但无论如何，`PGDATA/pg_wal` 中都会保留总量不少于 *min\_wal\_size* 的日志文件[^internal-ru-230-2]。（*默认值：* `80MB`）

通常，重命名可以避免不断创建和删除文件，从而节省开销；如有需要，也可以通过 *wal\_recycle* 参数禁用这种行为。（v. 12）（*默认值：* `on`）

图中展示了正常条件下服务器所需日志文件量的变化。

![检查点配置：WAL 容量；max_wal_size；时间；checkpoint_timeout；检查点起点之间生成的 WAL 容量](/images/internal/zh/p230-03.svg)
{#internal-ru-fig-10-3 num="10.3" caption="检查点配置" .book-original-figure width=802 height=408}

需要理解的是，服务器上的日志文件实际总量可能更大，在某些情况下甚至可能超过 *max\_wal\_size*：

- *max\_wal\_size* 参数提供目标值，但不是硬限制。如果负载增加，写入可能落后于计划。

- <span id="internal-ru-page-231" class="pdf-anchor"></span>服务器无权删除尚未通过复制槽传送出去的日志文件，以及持续归档中尚未归档的日志文件。这个功能需要持续监控，因为服务器磁盘很容易因此被填满。

- 管理员可以通过 *wal\_keep\_size* 参数保留一定数量的文件。（v. 12）（*默认值：* `0MB`）

<span id="internal-ru-section-star-158" class="pdf-anchor"></span>

### 后台写入配置 {#internal-ru-subsection-star-159}

后台写入进程应在检查点配置之后进行配置。两个进程必须共同确保在服务进程需要这些缓冲区之前写完脏缓冲区。

后台写入进程按周期工作，在两个周期之间休眠 *bgwriter\_delay* 指定的时长。（*默认值：* `200ms`）

每个周期要写入的页面数量，根据服务进程自上次运行以来请求的缓冲区平均数量确定（使用移动平均来平滑各次运行之间的不均匀性，同时不受久远历史的影响）。计算出的数量还会乘以 *bgwriter\_lru\_multiplier* 系数。但无论如何，每个周期写入的页面数都不会超过 *bgwriter\_lru\_maxpages*。（*默认值：* `2`）（*默认值：* `100`）

如果进程没有发现任何脏缓冲区（也就是系统中没有发生任何事情），它就会“进入睡眠”。服务器进程第一次请求缓冲区后，它会醒来并继续正常工作。

<span id="internal-ru-section-star-160" class="pdf-anchor"></span>

### 监控 {#internal-ru-subsection-star-161}

可以、也应该根据监控反馈调整检查点和后台写入的配置。

如果因日志文件量超过限制而触发的检查点，比 *checkpoint\_warning* 参数指定的频率更高，消息日志中就会输出警告。应根据预计的峰值负载调整该参数。（*默认值：* `30s`）

<span id="internal-ru-page-232" class="pdf-anchor"></span>

启用 *log\_checkpoints* 参数后，服务器消息日志会输出检查点执行过程的详细信息。（*默认值：* `on`）

修改一些数据并发起检查点：

```text {label="psql"}
=> UPDATE big SET s = 'BAR';
=> CHECKPOINT;
```

在消息日志中可以看到写入的缓冲区数量、检查点后日志文件组成的变化、检查点耗时，以及相邻检查点起点之间的距离（以字节计）：

```text
postgres$ tail -n 2 /home/postgres/logfile
LOG:  checkpoint starting: immediate force wait
LOG:  checkpoint complete: wrote 4097 buffers (25.0%), wrote 3 SLRU
buffers; 0 WAL file(s) added, 1 removed, 0 recycled; write=0.012 s,
sync=0.007 s, total=0.025 s; sync files=3, longest=0.006 s,
average=0.003 s; distance=9213 kB, estimate=9213 kB; lsn=2/823C5CB0,
redo lsn=2/823C5C58
```

不过，对于配置来说，最有用的信息是 `pg_stat_checkpointer` 视图中检查点进程的工作统计。（v. 17）

> [!NOTE]
> 在 PostgreSQL 17 之前，检查点和后台写入共用一个 `pg_stat_bgwriter` 视图，因为过去这两项工作都由后台写入进程执行；在 9.2 版中，检查点被拆分为独立进程，但视图长期保持共用。

```text {label="psql"}
=> SELECT * FROM pg_stat_checkpointer \gx
-[ RECORD 1 ]-------+------------------------------
num_timed           | 0
num_requested       | 26
num_done            | 21
restartpoints_timed | 0
restartpoints_req   | 0
restartpoints_done  | 0
write_time          | 106014
sync_time           | 2660
buffers_written     | 78463
slru_written        | 26
stats_reset         | 2026-04-05 20:51:34.261075+03
```

<span id="internal-ru-page-233" class="pdf-anchor"></span>

该视图还会显示已完成的检查点数量：

- `num_timed`——按计划执行（时间间隔 *checkpoint\_timeout* 到期）；

- `num_requested`——按需执行（包括日志量达到 *max\_wal\_size* 时触发的检查点）。

如果 `num_requested` 列的值相对于 `num_timed` 很大，说明实际检查点发生得比预期更频繁。

> [!NOTE]
> 以 `restartpoints` 开头的字段只对副本有意义。

视图 `pg_stat_bgwriter` 显示后台写入的统计信息：

```text {label="psql"}
=> SELECT * FROM pg_stat_bgwriter \gx
-[ RECORD 1 ]----+------------------------------
buffers_clean    | 20899
maxwritten_clean | 192
buffers_alloc    | 513174
stats_reset      | 2026-04-05 20:51:34.261075+03
```

对于配置来说，`maxwritten_clean` 的值很有用：它表示后台写入进程因超过 *bgwriter\_lru\_maxpages* 而停止工作周期的次数。

在配置良好的系统中，服务进程几乎不执行写入；写入的主要部分由检查点进程（`buffers_written`）和后台写入进程（`buffers_clean`）完成。

除了检查点和后台写入的详细信息，还需要了解预写式日志的整体情况。日志记录数量、整页镜像数量及其占用空间等最基本的统计信息，可以从 `pg_stat_wal` 视图获得：（v. 14）

```text {label="psql"}
=> SELECT wal_records, wal_fpi, pg_size_pretty(wal_bytes) wal_bytes
FROM pg_stat_wal;
 wal_records | wal_fpi | wal_bytes
-------------+---------+-----------
    44246365 |  608009 | 10126 MB
(1 row)
```

<span id="internal-ru-page-234" class="pdf-anchor"></span>

与日志相关的输入输出信息中，最详细的部分（包括按进程类型划分的信息，本文不展开）位于熟悉的 `pg_stat_io` 视图中：（v. 18）（*第 [204](/zh18/09-buffer-cache/#internal-ru-page-205) 页*）

```text {label="psql"}
=> SELECT context, writes, pg_size_pretty(write_bytes) write_bytes,
  pg_size_pretty(write_bytes/writes) bytes_per_write, fsyncs
FROM (
  SELECT context, sum(writes) writes, sum(write_bytes) write_bytes,
    sum(fsyncs) fsyncs
  FROM pg_stat_io
  WHERE object = 'wal'
  GROUP BY context
);
 context | writes | write_bytes | bytes_per_write | fsyncs
---------+--------+-------------+-----------------+--------
 normal  | 887267 | 10 GB       | 12 kB           |   3880
 init    |     85 | 1360 MB     | 16 MB           |     85
(2 rows)
```

对于日志，`context` 字段可以取以下值：

- `init`——初始化 WAL 段（如果设置了 *wal\_init\_zero*，还会把段填零；因此每次操作写入 16 MB 数据）；（*默认值：* `on`）

- `normal`——对已有段执行操作。

可以使用 `pg_stat_get_backend_wal` 函数获取单个服务进程运行期间的统计信息。（v. 18）

[^internal-ru-213-1]: [postgrespro.ru/docs/postgresql/18/wal-intro](<https://postgrespro.ru/docs/postgresql/18/wal-intro>)。
[^internal-ru-214-1]: [postgrespro.ru/docs/postgresql/18/wal-internals](<https://postgrespro.ru/docs/postgresql/18/wal-internals>)；[backend/access/transam/README](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/README;hb=REL_18_STABLE>)。
[^internal-ru-214-2]: [include/access/xlogrecord.h](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/include/access/xlogrecord.h;hb=REL_18_STABLE>)。
[^internal-ru-214-3]: [include/access/rmgrlist.h](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/include/access/rmgrlist.h;hb=REL_18_STABLE>)。
[^internal-ru-217-1]: [backend/storage/buffer/bufmgr.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/storage/buffer/bufmgr.c;hb=REL_18_STABLE>)，函数 FlushBuffer。
[^internal-ru-220-1]: [backend/postmaster/checkpointer.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/postmaster/checkpointer.c;hb=REL_18_STABLE>)；[backend/access/transam/xlog.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/xlog.c;hb=REL_18_STABLE>)，函数 CreateCheckPoint。
[^internal-ru-220-2]: [backend/storage/buffer/bufmgr.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/storage/buffer/bufmgr.c;hb=REL_18_STABLE>)，函数 BufferSync。
[^internal-ru-224-1]: [backend/postmaster/startup.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/postmaster/startup.c;hb=REL_18_STABLE>)；[backend/access/transam/xlog.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/xlog.c;hb=REL_18_STABLE>)，函数 StartupXLOG。
[^internal-ru-225-1]: [backend/access/heap/heapam\_visibility.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/heap/heapam_visibility.c;hb=REL_18_STABLE>)，函数 HeapTupleSatisfiesMVCC。
[^internal-ru-227-1]: [backend/postmaster/bgwriter.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/postmaster/bgwriter.c;hb=REL_18_STABLE>)。
[^internal-ru-229-1]: [backend/access/transam/xlog.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/xlog.c;hb=REL_18_STABLE>)，函数 XLogCheckpointNeeded 和 CalculateCheckpointSegments。
[^internal-ru-229-2]: [backend/postmaster/checkpointer.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/postmaster/checkpointer.c;hb=REL_18_STABLE>)，函数 IsCheckpointOnSchedule。
[^internal-ru-229-3]: [backend/postmaster/checkpointer.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/postmaster/checkpointer.c;hb=REL_18_STABLE>)，函数 CheckpointWriteDelay。
[^internal-ru-230-1]: [backend/access/transam/xlog.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/xlog.c;hb=REL_18_STABLE>)，函数 RemoveOldXlogFiles。
[^internal-ru-230-2]: [backend/access/transam/xlog.c](<https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/access/transam/xlog.c;hb=REL_18_STABLE>)，函数 XLOGfileslop。
