数据库基础体系 · 第 20/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL 架构全景:进程模型、共享内存、WAL 与存储布局

PostgreSQL 是一个典型的“多进程服务器 + 共享内存 + 磁盘持久化”数据库系统。一次 COMMIT 并不是简单地把一行数据写进某个文件,而是多个组件共同完成的一条路径:

  1. 客户端连接到一个服务端进程;
  2. 服务端进程读取或修改共享缓冲区中的数据页;
  3. 修改先形成 WAL(Write-Ahead Log,预写式日志)记录;
  4. 提交时保证相应 WAL 已经写入并按配置持久化;
  5. 后台进程异步把脏数据页写回表文件;
  6. 数据库重启时,通过 WAL 重做已经记录但尚未落盘的数据修改。

理解这条路径,是理解 PostgreSQL 事务、MVCC、Vacuum、索引、复制、故障恢复和性能诊断的基础。

本文讨论的是单个 PostgreSQL 实例(cluster)内部的架构。主从复制、分片、中间件和云厂商托管层会在此基础上增加其他组件,但不会改变这里介绍的核心语义。


一、先建立几个边界:实例、数据库、模式、关系和页面

1. PostgreSQL 的“实例”不是一个数据库

PostgreSQL 中,通常所说的一个 server instance 或 database cluster,是由同一个服务进程组管理的一套数据目录。

一个实例可以包含多个逻辑数据库:

一个 PostgreSQL 实例
├── database_a
├── database_b
└── database_c

每个数据库有独立的系统目录对象,但它们共享:

  • 服务端进程组;
  • 共享内存;
  • WAL;
  • 检查点和恢复机制;
  • 角色、认证和实例级配置中的一部分。

数据库之间通常不能直接访问彼此的普通表。连接参数中的 dbname 决定当前会话连接到哪个逻辑数据库。

2. Schema、表和索引是数据库内部对象

在数据库内部,常见层次是:

数据库
└── schema
    ├── table
    ├── index
    ├── sequence
    └── view

SQL 中看到的表名并不等于磁盘上的文件名。对象由系统目录中的 OID、relfilenode、表空间等信息共同定位。

例如:

SELECT
    current_database(),
    current_schema(),
    'public.demo'::regclass AS relation,
    pg_relation_filepath('public.demo') AS filepath;

可能得到类似结果:

 current_database | current_schema | relation    | filepath
------------------+----------------+-------------+--------------------
 app              | public         | public.demo | base/16384/24576

这个结果只说明当前关系的文件路径。它不是永久标识:

  • 表执行 TRUNCATE、某些重写操作或 REINDEX 后,文件节点可能变化;
  • 数据库 OID 和关系 OID 也不能被业务代码当作稳定主键;
  • 表还可能位于用户表空间,而不是 base/ 目录。

二、进程模型:一个连接通常对应一个服务端进程

1. 监听进程和客户端后端进程

PostgreSQL 采用进程模型。客户端通过 Unix-domain socket 或 TCP 连接服务端。

启动实例后,系统会有一个主服务进程,传统上称为 postmaster,其可执行文件通常仍是 postgres。它负责:

  • 初始化共享内存和锁结构;
  • 创建监听 socket;
  • 接收连接;
  • 派生客户端后端进程;
  • 启动和管理后台进程;
  • 在异常退出时触发恢复或重启流程。

一个普通客户端连接通常由一个独立的 backend process 服务。可以用以下查询观察:

SELECT
    pid,
    backend_type,
    usename,
    datname,
    state,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
ORDER BY pid;

输出中的 backend_type 可能包括:

  • client backend:普通客户端会话;
  • autovacuum worker:自动 Vacuum 工作进程;
  • walsender:发送 WAL 的进程;
  • walreceiver:接收 WAL 的进程;
  • checkpointer:执行检查点的进程;
  • background writer:后台写进程;
  • walwriter:WAL 写进程;
  • startup:恢复或备库重放进程;
  • logical replication worker:逻辑复制相关工作进程;
  • parallel worker:并行查询工作进程。

具体进程集合会受版本、配置、复制状态和当前负载影响。不能把某个版本观察到的进程列表硬编码为所有版本的架构保证。

2. 为什么采用进程而不是一个线程服务所有连接

进程模型带来几个重要性质:

  • 一个 backend 崩溃通常不会直接破坏其他 backend 的私有地址空间;
  • 连接之间的执行状态天然隔离;
  • 共享状态通过显式共享内存、锁和进程间同步原语管理;
  • 操作系统进程调度和内存保护可以参与故障隔离。

代价是:

  • 每个连接需要独立进程资源;
  • 进程间上下文切换和共享内存同步有开销;
  • 连接数过多会消耗内存,并增加锁、快照和调度压力。

因此,连接池并不是因为 PostgreSQL 没有并发能力,而是为了限制后端进程数量,减少大量空闲或短连接带来的系统开销。

3. 后端进程的基本生命周期

一个普通连接大致经历:

客户端连接
  ↓
主进程接受连接并派生 backend
  ↓
认证、参数协商、建立会话状态
  ↓
接收 Parse/Bind/Execute 或简单查询协议消息
  ↓
解析、重写、生成执行计划、执行
  ↓
访问共享缓冲区和锁
  ↓
返回结果
  ↓
事务结束,但连接可继续保留
  ↓
客户端断开,backend 退出

需要区分“语句结束”和“连接结束”:

  • 自动提交模式下,一条语句通常对应一个事务;
  • 显式 BEGIN 后,多条语句属于同一事务;
  • 事务提交不会退出 backend;
  • session-level 参数、临时表和准备语句可能跨多个事务存在。

4. 并行查询不是“多个客户端连接”

并行查询会由一个 leader backend 和若干 parallel worker 协同执行。parallel worker 不是独立客户端,也不能任意执行所有需要会话状态的操作。

典型数据流是:

leader backend
   ├── parallel worker 1
   ├── parallel worker 2
   └── parallel worker 3
          ↓
     共享内存队列或并行执行结构
          ↓
      leader 汇总或继续返回结果

并行度受表大小、计划、max_parallel_workersmax_parallel_workers_per_gather 等条件共同影响。看到一个查询使用了并行计划,不代表它一定会启动配置允许的最大 worker 数。


三、共享内存:进程之间如何看到同一份数据库状态

1. 共享内存与进程私有内存的区别

每个 backend 有自己的私有内存,例如:

  • 解析和计划上下文;
  • work_mem 使用的排序、哈希空间;
  • 临时结果;
  • 会话状态;
  • 执行器局部数据结构。

多个进程共同使用的状态则位于共享内存或动态共享内存中,例如:

  • shared buffers;
  • 锁表;
  • 事务和进程数组;
  • WAL 缓冲区;
  • 检查点状态;
  • 缓冲区描述符;
  • 多版本可见性相关的共享状态;
  • 某些并行查询的数据结构。

work_mem 是“每个操作、每个进程”的上限近似值,不是整个实例的总内存上限。一个查询若同时执行多个排序或哈希操作,实际使用量可能是多个 work_mem 的叠加;并行 worker 也会增加潜在消耗。

2. Shared Buffers:表页和索引页的缓存

shared_buffers 是 PostgreSQL 管理的共享缓冲区,按数据页缓存表和索引内容。

读取一个页面时,backend 通常执行:

逻辑块号
  ↓
查找 shared buffers
  ├── 命中:直接使用
  └── 未命中:
        获取可用缓冲区
        必要时写回脏页
        从磁盘读取页面
        放入 shared buffers

一个缓冲区通常有:

  • 页面内容;
  • 页面对应的 tag(关系、fork、块号);
  • 脏标记;
  • 引用计数;
  • 使用策略相关的信息;
  • 与并发访问相关的锁和状态。

页面被修改后,首先变成 dirty buffer。此时并不意味着表文件已经更新。

3. 页面锁、行锁和事务锁不是一回事

PostgreSQL 中至少要区分三类并发控制对象:

  1. 页面或缓冲区级同步
    保证多个进程读写同一数据页时不会破坏页面结构。

  2. 行级锁和表级锁
    用于表达事务之间的逻辑冲突,例如更新同一行、修改表结构。

  3. 事务可见性
    决定一个事务能否看到另一个事务产生的 tuple 版本。

“某个事务看不到新行”不一定是因为它被锁阻塞;可能只是快照不包含该事务的提交结果。反过来,“能看到旧版本”也不代表没有锁冲突。

4. 动态共享内存

固定共享内存之外,某些操作会申请动态共享内存,例如:

  • 并行查询;
  • 某些扩展;
  • 其他需要跨进程临时共享状态的功能。

动态共享内存的生命周期通常短于实例,不应与磁盘上的持久化数据库文件混淆。进程崩溃或实例重启后,动态共享内存中的内容不会作为数据库状态保留。


四、从 SQL 到磁盘:一条写事务的完整路径

考虑以下事务:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

假设目标行所在的表页已经在 shared buffers 中,执行过程可以抽象为以下步骤。

第一步:建立快照并定位目标页

backend 为事务建立或使用相应快照,通过索引或顺序扫描找到目标 tuple 所在的数据页。

如果使用 B-tree 索引,索引先定位到可能包含 id = 1 的叶子项,再访问堆表页面确认 tuple。索引项本身通常不携带完整的 MVCC 可见性结论,最终仍要检查堆 tuple。

第二步:检查并发冲突

如果另一个事务正在修改同一行,当前事务可能:

  • 等待对方结束;
  • 根据对方提交或回滚结果重新判断;
  • 在某些隔离级别下报告序列化失败;
  • 在锁模式不兼容时等待表锁或其他锁。

这一步不是简单的“先到先得”。最终行为取决于隔离级别、锁模式、事务状态和语句类型。

第三步:生成新的 tuple 版本

PostgreSQL 的 MVCC 更新通常不是原地覆盖旧 tuple,而是在表中产生新版本:

旧 tuple: xmin = T_old, xmax = T_new
新 tuple: xmin = T_new, xmax = 0

这里的 xmin 表示创建该版本的事务 ID,xmax 表示使该版本失效的事务 ID;0 表示当前没有由普通事务标记的删除或替换事务。

旧版本暂时不能立刻删除,因为其他事务的快照可能仍然需要读取它。这也是 UPDATE 和 DELETE 会导致表膨胀、需要 Vacuum 的根本原因。

第四步:修改 shared buffer,并产生 WAL

backend 修改内存中的表页,同时产生描述这些修改的 WAL 记录。WAL 记录进入 WAL buffers,并最终写入 pg_wal 中的 WAL 段文件。

关键规则是:

在数据页修改可以被安全写入持久化数据文件之前,描述该修改的 WAL 必须先被持久化到满足当前持久性语义的位置。

这就是 Write-Ahead Logging。

第五步:提交时持久化提交记录

COMMIT 会产生事务提交相关的 WAL 记录。通常,事务向客户端报告成功前,必须使提交所需的 WAL 达到 synchronous_commit 所规定的持久化边界。

这意味着:

COMMIT 返回成功
≠
目标表数据页已经写入表文件

更准确地说:

COMMIT 成功
→ 事务提交 WAL 已达到承诺的持久性位置
→ 即使表页尚未落盘,重启恢复也能重做该修改

第六步:后台进程稍后写回脏页

checkpointer、background writer 或其他触发写入的路径会把脏页写入表文件。写入时必须遵守 WAL 先行规则。

因此,一个事务的“逻辑提交”和“数据页最终落盘”是两个不同时间点。


五、WAL:不是普通日志,而是恢复所需的修改历史

1. WAL 记录什么

WAL 记录用于描述如何把持久化页面推进到新的状态。它可以包含:

  • 堆表页面修改;
  • 索引页面修改;
  • FSM、VM 等辅助结构的修改;
  • 事务提交、回滚和准备事务状态;
  • 检查点信息;
  • 复制和逻辑解码所需的信息;
  • 某些元数据和关系重写操作。

WAL 记录通常用 LSN(Log Sequence Number)定位。LSN 可以看作 WAL 日志流中的单调递增位置,常用于比较:

LSN_A < LSN_B

表示 A 出现在 WAL 流中较早的位置。

2. WAL-before-data 的形式化条件

设:

  • L(p):修改页面 p 所需的最大 WAL 位置;
  • D(p):页面 p 被写入持久化数据文件的时刻;
  • F(L):WAL 位置 L 已被持久化的时刻。

WAL 先行的核心条件是:

D(p) 发生前,必须满足 F(L(p)) 已发生

也就是:

F(L(p)) < D(p)

若先把数据页写入磁盘、后写 WAL,发生断电时可能出现:

数据页已经包含新修改
但恢复日志中没有该修改

恢复过程无法可靠判断该页如何得到当前状态,这会破坏崩溃恢复的基础。

3. full_page_writes 解决什么问题

数据库页面通常由多个底层存储写操作组成。断电时可能出现 torn page:页面的一部分来自旧版本,另一部分来自新版本。

在检查点之后第一次修改某个页面时,WAL 可以记录该页面的完整镜像。恢复时,即使磁盘上的页面是撕裂的,也能用完整页面镜像覆盖它,再继续应用后续 WAL。

full_page_writes 的代价是增加 WAL 量;关闭它会降低保护能力,通常只适用于已经有其他可靠写入原子性保障、并且明确理解风险的环境。不能把它当作普通性能开关随意关闭。

4. WAL 缓冲区、写入和刷新

WAL 大致经历以下状态:

backend 产生 WAL 记录
   ↓
WAL buffers 中可见
   ↓
walwriter 或 backend 执行 write
   ↓
操作系统页缓存
   ↓
执行 flush/fsync 等持久化动作
   ↓
满足提交或复制所需的持久化位置

这里要区分:

  • 写入(write):把数据交给操作系统;
  • 刷新(flush):要求数据到达更可靠的持久化介质边界;
  • 提交确认:向客户端承诺事务不会因普通数据库崩溃而消失。

synchronous_commit 会影响提交等待的持久化边界。例如关闭它时,客户端可能更快收到提交成功,但数据库进程刚好崩溃时,最近事务可能尚未把提交 WAL 刷到持久介质。它不是让数据库“无 WAL 提交”,而是改变等待时机和持久性承诺。

5. 检查点

检查点建立一个恢复起点。执行检查点时,系统会:

  1. 记录检查点位置;
  2. 推动脏页写回;
  3. 确保恢复从该位置开始能够找到一致的重放起点;
  4. 在必要时刷新相关 WAL 和数据页。

数据库崩溃恢复不一定从数据文件的最后修改处开始,而是从最近检查点附近开始读取 WAL,然后重做之后的记录。

检查点过于频繁会产生写入压力;检查点间隔过长则可能导致:

  • 崩溃恢复需要重放更多 WAL;
  • 脏页积累更多;
  • 检查点集中写入造成 I/O 峰值。

因此,检查点参数和 WAL 生成速率共同决定恢复时间和写入形态。

6. 崩溃恢复的状态变化

假设事务已经修改页面并生成 WAL,但系统在数据页落盘前断电:

数据文件:旧页面
WAL:包含修改和提交记录

重启后,startup process 读取检查点之后的 WAL:

读取 WAL
  ↓
判断页面上的 page LSN 是否已经达到记录要求
  ↓
若未达到,则重做页面修改
  ↓
重放提交状态
  ↓
数据库进入可服务状态

如果某条 WAL 记录已经被应用到页面,页面中的 page LSN 可用于避免重复应用。WAL 重放必须具备幂等判断,而不是盲目把同一修改无限执行。

对于已经生成但没有提交记录的事务,其数据修改在恢复后不会作为已提交事务可见。PostgreSQL 主要依靠 WAL 重做和事务状态判断完成恢复,并不是通过传统数据库那种“逐条反向撤销所有未提交更新”的通用 undo 日志模型。


六、WAL 与复制:同一条日志的不同消费者

WAL 同时服务于多个目的:

backend
  ↓
WAL
  ├── 崩溃恢复
  ├── 物理流复制
  ├── WAL 归档与时间点恢复
  └── 逻辑解码/逻辑复制

1. 物理复制

物理备库接收主库 WAL,并按数据页和内部存储格式重放。它复制的是数据库存储状态,因此主备的版本、页面格式和运行约束较强。

主库上的 walsender 读取 WAL 并发送,备库上的 walreceiver 接收,startup process 负责重放。

synchronous_commit 在配置同步复制时还涉及远端确认级别。客户端提交等待的不只是本地 WAL 刷新,还可能等待一个或多个同步备库达到指定状态。此时提交延迟会受到网络和备库 I/O 影响。

2. WAL 归档与时间点恢复

归档把已经完成的 WAL 段保存到独立位置。要实现可靠的时间点恢复,通常还需要:

  • 一份一致的基础备份;
  • 基础备份之后连续、完整的 WAL;
  • 正确的归档和恢复配置。

只复制 base/ 下的表文件而不保存对应 WAL,不能构成可靠的 PostgreSQL 备份。

3. 逻辑复制

逻辑解码从 WAL 中提取行级或事务级变化,生成逻辑变更。它不是简单地把主库页面复制给订阅端,而是:

物理页面修改
  ↓
WAL
  ↓
逻辑解码
  ↓
INSERT/UPDATE/DELETE 等逻辑变化
  ↓
订阅端应用

因此逻辑复制与物理复制在 DDL、序列、冲突处理、初始数据同步和表标识要求等方面都有不同边界。


七、磁盘存储布局:目录、关系文件、Fork 和段文件

1. 数据目录的主要区域

一个 PostgreSQL 数据目录中常见的内容包括:

PGDATA/
├── base/             # 各数据库的默认表空间目录
├── global/           # 跨数据库共享的系统目录
├── pg_wal/           # WAL 段文件
├── pg_tblspc/        # 用户表空间符号链接
├── pg_xact/          # 事务提交状态
├── pg_multixact/     # 多事务相关状态
├── pg_subtrans/      # 子事务状态
├── pg_stat/          # 持久化统计相关文件
├── pg_replslot/      # 复制槽状态
├── postgresql.conf
└── PG_VERSION

实际目录内容会随版本和配置变化。pg_walpg_xact 等目录属于数据库内部结构,不能手工删除、移动或清空。

2. 表空间和数据库目录

默认表空间中的数据库对象通常位于:

base/<database_oid>/<relfilenode>

用户表空间通常通过 pg_tblspc 下的符号链接指向外部目录。不要只看目录名判断对象归属,应通过系统函数和系统目录确认:

SELECT
    c.oid,
    n.nspname AS schema_name,
    c.relname,
    c.relkind,
    t.spcname AS tablespace_name,
    pg_relation_filepath(c.oid) AS relation_path
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_tablespace AS t ON t.oid = c.reltablespace
WHERE c.relname = 'demo';

reltablespace = 0 时,表示使用数据库默认表空间;通过 pg_tablespace 的连接关系仍可解析实际位置。

3. 一个关系由多个 fork 组成

普通表或索引不一定只有一个文件。主要 fork 包括:

  • main fork:实际表或索引数据;
  • FSM fork:Free Space Map,记录页面中可供插入使用的近似空闲空间;
  • VM fork:Visibility Map,记录页面是否对所有事务可见、是否已知全为冻结状态;
  • init fork:未记录 WAL 的初始化关系使用,例如某些未记录日志的对象。

可以观察关系文件大小:

SELECT
    pg_size_pretty(pg_relation_size('public.demo')) AS main_size,
    pg_size_pretty(pg_table_size('public.demo')) AS table_size,
    pg_size_pretty(pg_total_relation_size('public.demo')) AS total_size;

三者含义不同:

  • pg_relation_size:主关系 fork 的大小;
  • pg_table_size:表及其 TOAST、FSM、VM 等,但不含索引;
  • pg_total_relation_size:再加上索引。

如果只查看一个主文件,可能低估对象实际占用。

4. 页面和段文件

PostgreSQL 的关系文件由固定大小页面组成,默认页面大小通常是 8 KiB,但它属于构建时的 BLCKSZ,不能简单视为所有发行版和所有构建都必然相同。

关系文件达到一定大小后会拆分成多个段文件。段文件的存在是文件系统布局问题,不代表表被分成了多个独立逻辑分区。

逻辑块号到物理文件的映射可以抽象为:

segment_number = block_number / blocks_per_segment
offset_in_segment = block_number % blocks_per_segment
byte_offset = offset_in_segment * BLCKSZ

这个公式只说明关系文件的寻址方式,不应直接用于业务级数据定位,因为 tuple 还位于页面内部,且表可能经历重写、迁移或文件节点变化。


八、堆表页面:为什么一行更新会留下旧版本

1. 页面内部结构

一个典型堆表页面可以抽象为:

页面头部
行指针数组(ItemId)
空闲空间
tuple 数据

行指针把逻辑槽位映射到 tuple 的实际偏移。tuple 删除或更新时,行指针和 tuple 状态可能被修改,因此“行号”不是固定的业务 ID。

页面头部包含页面 LSN 等信息。恢复时,WAL 重放可通过页面 LSN 判断某条修改是否已经应用。

2. Tuple 的 MVCC 元数据

一个堆 tuple 通常包含:

  • xmin:创建该版本的事务 ID;
  • xmax:删除、更新或锁定该版本的事务 ID;
  • ctid:该版本当前所在的页面和槽位;
  • 事务状态相关信息;
  • 用户列数据。

ctid 不是稳定行标识。执行 UPDATE 后,旧版本和新版本通常具有不同 ctid。如果把 ctid 保存到业务表或外部系统,后续更新、表重写、Vacuum Full 等操作都可能使其失效。

3. 可见性判断的基本形式

对快照 S,某个 tuple 版本可见的大致条件是:

创建事务 xmin 已对 S 可见
且
删除事务 xmax 不对 S 可见

展开为:

Visible(tuple, S)
=
Committed(xmin)
∧ VisibleToSnapshot(xmin, S)
∧ (
    xmax = 0
    ∨ ¬VisibleToSnapshot(xmax, S)
    ∨ xmax 尚未提交
)

真实实现还要处理:

  • 当前事务自己的修改;
  • 子事务;
  • 中止事务;
  • 多事务;
  • hint bits;
  • 快照类型和隔离级别;
  • 事务 ID 回绕与冻结。

这也是为什么数据库不能只读取表文件中的一行数据,就直接判断它是否对当前查询可见。

4. UPDATE 的完整示例

两个会话分别执行以下语句。

会话 A:

CREATE TABLE mvcc_demo (
    id integer PRIMARY KEY,
    value text
);

INSERT INTO mvcc_demo VALUES (1, 'old');
COMMIT;

会话 A:

BEGIN;
UPDATE mvcc_demo SET value = 'new' WHERE id = 1;
-- 暂不提交

此时会话 B:

SELECT * FROM mvcc_demo WHERE id = 1;

通常会看到:

 id | value
----+-------
  1 | old

原因不是会话 B 读取了“未更新的同一行”,而是:

  1. 旧 tuple 仍然存在;
  2. 会话 A 的新 tuple 版本尚未被提交;
  3. 会话 B 的快照只能接受对它可见的版本;
  4. 旧版本对会话 B 仍然可见。

如果会话 B 执行:

UPDATE mvcc_demo SET value = 'b' WHERE id = 1;

它可能等待会话 A 结束,因为两个事务试图修改同一个逻辑行。MVCC 减少了读写阻塞,但不意味着所有读写和写写冲突都消失。


九、FSM、VM、TOAST 与 Vacuum 的架构位置

1. FSM:帮助寻找可用空间

FSM 记录各页面可用空间的近似信息。插入时,数据库不必扫描所有页面寻找空洞,而是可以先通过 FSM 选择候选页面。

FSM 不是绝对精确的业务事实:

  • 它可能暂时低估或高估;
  • 找到页面后仍需实际检查;
  • FSM 损坏或重建会影响寻找效率,但不直接定义 tuple 可见性。

2. VM:支持可见性优化和降低扫描成本

Visibility Map 按页面记录两个重要事实:

  • 页面中的 tuple 是否对所有事务可见;
  • 页面中的 tuple 是否都已冻结。

当一个页面被标记为 all-visible 时,某些查询可以减少对 tuple 可见性元数据的检查;当页面同时满足冻结条件时,Vacuum 可以跳过更多工作。

VM 是优化结构,不是独立真相。若页面被修改,相关 VM 位必须清除;数据库会在必要时重新确认页面状态。

3. TOAST:存放过大的字段

PostgreSQL 页面大小固定,而单个 tuple 不能无限增长。对于过大的可变长度字段,系统会通过 TOAST:

  • 压缩字段;
  • 将字段拆分成多个 chunk;
  • 把 chunk 存放到关联的 TOAST 表;
  • 主表 tuple 保存 TOAST 指针。

因此,一张表的主关系文件较小,并不意味着它没有大量数据。应使用:

SELECT
    pg_size_pretty(pg_relation_size('public.demo')) AS heap,
    pg_size_pretty(pg_table_size('public.demo')) AS heap_with_toast,
    pg_size_pretty(pg_total_relation_size('public.demo')) AS including_indexes;

4. Vacuum 为什么属于架构主路径

由于旧 tuple 不能在所有快照都不再需要前立即删除,Vacuum 会负责:

  • 回收已不可见 tuple 占用的空间;
  • 更新 FSM;
  • 更新 VM;
  • 清理索引中的失效引用;
  • 推进和冻结事务 ID;
  • 避免事务 ID 回绕风险。

普通 VACUUM 通常复用表内空间,不会把表文件整体缩小。VACUUM FULL 会重写表并需要更强的锁,产生新的物理布局,因此风险和影响都不同。

可以用以下命令观察 Vacuum 相关统计:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    autovacuum_count,
    relfrozenxid
FROM pg_stat_user_tables
WHERE relname = 'mvcc_demo';

其中 n_dead_tup 是统计估算值,不是逐行实时计数。若要判断膨胀、长事务和冻结风险,还必须结合:

SELECT
    pid,
    usename,
    state,
    xact_start,
    backend_xmin,
    query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY xact_start;

长时间打开的事务或复制槽可能保留旧快照,使 Vacuum 无法回收本应清理的 tuple。表现为:

UPDATE/DELETE 持续增加
→ 旧版本增加
→ n_dead_tup 增大
→ 表和索引膨胀
→ I/O、缓存命中率和扫描成本恶化

这条因果链不能仅通过“调大 autovacuum”解释。若根因是长事务,清理频率增加也可能无法回收被旧快照需要的版本。


十、索引文件与堆表文件的关系

索引是独立的关系对象,也有自己的页面、WAL、FSM、VM(具体可用 fork 取决于对象类型和版本实现)和文件布局。

以 B-tree 为例:

查询条件 id = 1
  ↓
扫描 B-tree 根页、中间页、叶子页
  ↓
获得堆表 TID(页面号 + 行指针)
  ↓
访问堆表页面
  ↓
执行 MVCC 可见性检查
  ↓
返回可见 tuple

因此:

  • 索引命中不等于结果一定可见;
  • 索引项指向堆 tuple 位置,而不是永远有效的逻辑主键;
  • 更新可能导致新的索引项;
  • HOT(Heap-Only Tuple)更新在满足条件时可以减少索引变更,但并非所有更新都能 HOT;
  • Vacuum 需要处理堆表旧版本和索引中的无效引用。

GIN、GiST、BRIN 的内部结构和适用场景不同,但它们仍然是关系文件,遵循 PostgreSQL 的页面、缓冲区、WAL、检查点和恢复框架。优化器选择某个索引,是执行计划层面的决定;索引页面如何被缓存和持久化,则属于存储和 WAL 层面的机制。


十一、故障路径:不同故障会产生不同结果

1. backend 进程崩溃

如果单个 backend 崩溃:

  • 该会话连接断开;
  • 其未提交事务不会作为已提交事务保留;
  • 共享状态中的锁和临时执行状态会被清理;
  • 其他进程通常继续运行;
  • 若共享状态或实例状态受到影响,主进程可能触发更严重的处理。

2. 主机断电

断电会丢失尚未持久化的内存和操作系统缓存内容。重启时 PostgreSQL 使用 WAL 恢复:

  • 已有提交 WAL 的修改可以被重做;
  • 没有提交记录的事务不会成为可见提交;
  • 数据库需要完成恢复后才对外提供正常服务。

如果存储设备、文件系统或电源保护不满足持久化假设,数据库参数无法单独修复硬件层面的错误。

3. 误删 pg_wal

pg_wal 不是普通临时目录。删除仍需要的 WAL 可能导致:

  • 无法完成崩溃恢复;
  • 备库无法继续追赶;
  • 复制槽对应的 WAL 丢失;
  • 时间点恢复链断裂。

正确的 WAL 清理由数据库根据检查点、归档、复制槽和备库需求管理。手工删除文件不是清理手段,而是破坏恢复条件。

4. 复制槽导致 WAL 持续增长

复制槽要求主库保留订阅者或备库可能仍需的 WAL。如果消费者停止,WAL 可能持续增长并最终耗尽磁盘。

诊断示例:

SELECT
    slot_name,
    slot_type,
    active,
    restart_lsn,
    confirmed_flush_lsn
FROM pg_replication_slots;

这个查询只能说明槽位状态。是否存在风险,还要结合当前 WAL 位置、磁盘空间和消费者恢复能力判断。删除复制槽可能释放 WAL,但也可能使对应消费者无法继续增量追赶,恢复时必须重新初始化。


十二、用系统视图把架构状态串起来

1. 观察会话、等待和事务

SELECT
    pid,
    backend_type,
    state,
    wait_event_type,
    wait_event,
    xact_start,
    query_start,
    query
FROM pg_stat_activity
ORDER BY xact_start NULLS LAST;

解释时应遵循因果顺序:

  • state = active 只表示正在执行或等待执行;
  • wait_event 表示当前等待点,不等于一定发生故障;
  • xact_start 说明事务持续时间长,但还要看是否持有快照或锁;
  • idle in transaction 可能长时间保留快照,是 Vacuum 无法推进的常见原因。

2. 观察 WAL 和检查点

SELECT
    pg_current_wal_lsn() AS current_lsn,
    pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS current_lsn_as_bytes;

pg_wal_lsn_diff 需要两个 LSN,示例中的 '0/0' 只是为了把当前位置转换为一个可比较的数值,并不表示当前 WAL 从零开始产生。

查看检查点统计:

SELECT
    checkpoints_timed,
    checkpoints_req,
    checkpoint_write_time,
    checkpoint_sync_time,
    buffers_checkpoint,
    buffers_clean,
    maxwritten_clean
FROM pg_stat_bgwriter;

不同版本中该视图字段可能增加或调整,生产脚本应按目标版本验证字段。看到 checkpoints_req 增加,只能说明检查点由 WAL 或其他请求触发较多,还需要结合 WAL 生成速率、I/O 和配置判断原因。

3. 观察关系大小与膨胀线索

SELECT
    schemaname,
    relname,
    pg_size_pretty(pg_table_size(relid)) AS table_size,
    pg_size_pretty(pg_indexes_size(relid)) AS indexes_size,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

这不是精确的膨胀测量工具,但可以定位候选对象。进一步诊断需要结合:

  • 表和索引的增长趋势;
  • n_dead_tup
  • 长事务;
  • 自动 Vacuum 日志;
  • 查询计划;
  • 磁盘和缓存命中率;
  • 必要时使用经过验证的扩展或离线分析工具。

十三、常见误解与对应的真实边界

误解一:COMMIT 返回后,表文件已经更新

不正确。提交首先确保提交相关 WAL 达到配置规定的持久化边界。表页可能仍在 shared buffers 中,之后才写回关系文件。

误解二:WAL 是数据库数据的第二份完整副本

不正确。WAL 是恢复和复制所需的日志流,不是可以独立当作普通表数据读取的备份。WAL 生命周期还受到检查点、归档、复制槽和备库状态影响。

误解三:删除旧 tuple 就等于释放磁盘文件空间

不正确。普通 Vacuum 主要让页面内部空间重新可用,并更新 FSM;它通常不会把关系文件截断到更小。要缩小物理文件,往往需要表重写、重建或其他专门操作,这会带来锁、I/O 和临时空间成本。

误解四:索引中的一条记录就是最终可见的一行

不正确。索引通常提供堆表定位信息,最终可见性仍要结合堆 tuple 的 MVCC 元数据和事务快照判断。

误解五:共享缓冲区命中就表示数据已经持久化

不正确。共享缓冲区中的页面可能是脏页。它能被当前进程读取,只表示内存中有一份版本,不代表该版本已经写入可靠持久介质。

误解六:进程越多,并发能力越强

不正确。并发进程过多会增加:

  • 内存消耗;
  • 锁竞争;
  • 上下文切换;
  • 快照管理成本;
  • 磁盘和 CPU 争用。

有效并发取决于事务持续时间、I/O、锁冲突、查询计划和硬件资源,而不是连接数本身。


十四、把几个层次放回同一张图

一次更新从 SQL 到恢复的完整关系可以概括为:

客户端协议
   ↓
client backend
   ↓
解析 / 重写 / 优化 / 执行
   ↓
快照、锁、MVCC 可见性
   ↓
shared buffers 中的堆页和索引页
   ├── tuple 新版本
   ├── 页面变脏
   └── 产生 WAL 记录
            ↓
        WAL buffers
            ↓
        pg_wal 持久化
            ├── COMMIT 确认
            ├── 崩溃恢复
            ├── 物理复制
            └── 归档 / 逻辑解码
   ↓
checkpointer / background writer
   ↓
关系文件 main fork
   ├── FSM:寻找可用空间
   ├── VM:可见性和冻结优化
   └── TOAST:存放过大字段

这张图中的几个时间点必须分开:

SQL 执行完成
≠ tuple 对所有会话可见
≠ WAL 已持久化
≠ 数据页已写回表文件
≠ Vacuum 已回收旧版本
≠ 备库已完成重放

它们分别由执行器、事务可见性、WAL 持久化、后台写入、Vacuum 和复制重放负责。


结语

PostgreSQL 的核心架构不是“表文件加一个日志文件”,而是一个由进程、共享内存、页面、事务状态和 WAL 共同组成的状态机:

  • 进程模型决定连接、后台任务和故障隔离的基本边界;
  • 共享内存连接了多个 backend 对缓冲区、锁和事务状态的并发访问;
  • MVCC 通过保留 tuple 版本实现快照可见性;
  • WAL 让数据页可以延迟写回,同时提供崩溃恢复、复制和归档能力;
  • 关系文件、fork、页面、FSM、VM 和 TOAST 构成实际存储布局;
  • Vacuum 负责把多版本产生的历史垃圾逐步转化为空闲空间,并推进冻结状态。

当查询变慢、WAL 增长、表膨胀、备库延迟或重启恢复时间异常时,应沿着这条数据流定位:当前状态在哪个进程中、哪个共享结构中、哪个页面或 fork 中,以及对应的 WAL 是否已经生成、持久化、重放或被保留。只有把这些层次区分开,诊断结果才不会停留在“数据库在写磁盘”或“索引没有生效”这样的表面描述。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。