数据库基础体系 · 第 20/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
PostgreSQL 架构全景:进程模型、共享内存、WAL 与存储布局
PostgreSQL 是一个典型的“多进程服务器 + 共享内存 + 磁盘持久化”数据库系统。一次 COMMIT 并不是简单地把一行数据写进某个文件,而是多个组件共同完成的一条路径:
- 客户端连接到一个服务端进程;
- 服务端进程读取或修改共享缓冲区中的数据页;
- 修改先形成 WAL(Write-Ahead Log,预写式日志)记录;
- 提交时保证相应 WAL 已经写入并按配置持久化;
- 后台进程异步把脏数据页写回表文件;
- 数据库重启时,通过 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_workers、max_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 中至少要区分三类并发控制对象:
-
页面或缓冲区级同步
保证多个进程读写同一数据页时不会破坏页面结构。 -
行级锁和表级锁
用于表达事务之间的逻辑冲突,例如更新同一行、修改表结构。 -
事务可见性
决定一个事务能否看到另一个事务产生的 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. 检查点
检查点建立一个恢复起点。执行检查点时,系统会:
- 记录检查点位置;
- 推动脏页写回;
- 确保恢复从该位置开始能够找到一致的重放起点;
- 在必要时刷新相关 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_wal、pg_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 读取了“未更新的同一行”,而是:
- 旧 tuple 仍然存在;
- 会话 A 的新 tuple 版本尚未被提交;
- 会话 B 的快照只能接受对它可见的版本;
- 旧版本对会话 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 是否已经生成、持久化、重放或被保留。只有把这些层次区分开,诊断结果才不会停留在“数据库在写磁盘”或“索引没有生效”这样的表面描述。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MySQL 生产运维:参数、容量、备份、监控与常见故障排查
- 下一篇:PostgreSQL 数据类型与 Schema:数组、范围、JSONB、约束和域
- 延伸:PostgreSQL MVCC 与 Vacuum:Tuple 可见性、膨胀、冻结和诊断
- 延伸:PostgreSQL 索引与优化器:B-tree、GIN、GiST、BRIN 和执行计划
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论