Skip to content

Table Files(表文件) ​

表是 PostgreSQL 中最基本的存储对象,但它在磁盘上并不是「一个文件」,而是一组按块组织的关系文件:主文件存放数据行,旁的 _fsm 文件记录空闲空间,_vm 文件记录可见性,超大字段则被挪到 _toast 文件。本节将深入这些物理文件内部的页结构,讲清「一行数据到底存在哪里、怎样被找到」,并给出用 pageinspect 直接翻阅磁盘页的方法。

前置知识

阅读本节前,建议先了解:Archiver 与统计信息(Archiver Process)

基础概念 ​

上一节提到的「磁盘文件结构」是从 PGDATA 整体视角看的;本节换成从单张表的视角往下钻。理解表文件,需要先统一以下术语:

术语英文说明
关系Relation表、索引、序列等「有物理文件的对象」的统一称呼
关系文件节点relfilenode物理文件名,多数情况下等于 pg_class.oid
页Page / Block读写的最小单位,默认 8KB,由 BLCKSZ 编译决定
页头Page Header页开头的 24 字节,记录页内空闲位置、校验和、可见性标志
行指针ItemId(Line Pointer)4 字节,指向页内某一行的实际偏移与长度
元组Tuple / Heap Tuple页内实际存放的一行数据(含 xmin、xmax 等系统列)
空闲空间映射FSM, Free Space Map_fsm 文件,记录每页还剩多少可用空间
可见性映射VM, Visibility Map_vm 文件,标记哪些页「全是活元组」或「全是冻结元组」
TOASTThe Oversized-Attribute Storage Technique大字段的溢出存储机制,见下一节
段文件Segment File单文件上限 1GB,超出后拆成 .1、.2……

一个「表」其实是一组文件

初学者常以为 orders 表就是磁盘上的一个文件。实际上,一张普通表在 PGDATA 中至少会有:

text
base/16384/16385          # 主数据文件(存放行)
base/16384/16385_fsm      # 空闲空间映射
base/16384/16385_vm       # 可见性映射
base/16384/16385.1        # 主文件超过 1GB 后的第二个段文件(按需出现)
base/16384/16385_fsm.1    # FSM 超过 1GB 后的后续文件(按需出现)

如果表里有大字段,还会有 pg_toast 模式下的 _toast 与 _toast_index 文件。「表 = 一个文件」这个直觉在 PostgreSQL 里是错的,这是排查磁盘占用时最常见的误判来源。

页的内部布局 ​

PostgreSQL 的页(Page)默认 8KB,是所有 I/O 的基本单位。它的结构如下:

text
┌───────────────────────────────────────────────────────┐  偏移
│                     Page Header (24 bytes)            │  0
│  校验和 | flags | 空闲空间起点 | 空闲空间终点 | 特殊区   │
├───────────────────────────────────────────────────────┤  24
│                     ItemId 数组(行指针)               │
│  ItemId[1] ItemId[2] ItemId[3] ... ItemId[N]           │
│  ──────► 每个 4 字节,指向下方对应的元组                │
├───────────────────────────────────────────────────────┤
│                        ...空闲空间...                  │
│              (新行从这里向下生长)                     │
├───────────────────────────────────────────────────────┤
│                    元组 N (从页尾向上生长)            │
│                    元组 N-1                            │
│                       ...                              │
│                    元组 2                              │
│                    元组 1                              │
├───────────────────────────────────────────────────────┤
│                Special Space(索引页专用,表页为空)     │  8192
└───────────────────────────────────────────────────────┘

关键规则:
  · ItemId 数组从页头向下生长,元组从页尾向上生长
  · 二者相遇即「页已满」,此时插件需要新页
  · 删除一行不移动其他行,只在 ItemId 上标记为「已死」
    → 这是更新只占空间、不释放空间的根本原因(后续 MVCC 章节展开)

单个元组的内部布局 ​

元组(Tuple)不只是「业务字段」,它前面还挂着系统列信息:

text
┌──────────────────────────────────────────────────────────┐
│  HeapTupleHeader(23 bytes)                              │
│   xmin (事务ID) │ xmax (事务ID) │ t_ctid │ t_infomask ...  │
├──────────────────────────────────────────────────────────┤
│  Null Bitmap(可选,1 字节/8 列,标记哪些列为 NULL)       │
├──────────────────────────────────────────────────────────┤
│  数据区:按列顺序、按类型对齐紧密排列                        │
│   [列1 值] [列2 值] [列3 值] ...                          │
│   变长列的头部含 1~4 字节长度信息                          │
└──────────────────────────────────────────────────────────┘

其中:
  · xmin = 创建这一行的事务 ID
  · xmax = 删除/更新这一行的事务 ID(0 表示尚未删除)
  · t_ctid = 本行的物理地址(页号 + 行号),更新后指向新版本
  · t_infomask = 各种标志位(是否已提交、是否已冻结等)

这就是「MVCC 把版本信息存在行内」的物理基础:每一行自带 xmin/xmax,可见性判断不需要额外的外部结构。

核心机制 ​

逻辑表如何映射到物理文件 ​

从 SQL 里的表名走到磁盘文件,需要经过四层:

text
  orders(表名)
       │  ① 查系统目录 pg_class,按 (relname, relnamespace) 定位
       ▼
  pg_class.oid = 16385
       │  ② 取 pg_class.relfilenode(可能 ≠ oid,见下文)
       ▼
  relfilenode = 16385
       │  ③ 取 pg_class.reltablespace → 得到物理目录
       ▼
  $PGDATA/base/16384/       (reltablespace = 0 时是所属数据库的 OID 目录)
       │  ④ 拼接文件名
       ▼
  $PGDATA/base/16384/16385

用 SQL 可以一次性走完这条路:

sql
SELECT
  c.oid                AS 表OID,
  c.relname            AS 表名,
  c.relfilenode        AS 物理文件名,
  n.nspname            AS 模式名,
  d.datname            AS 数据库名,
  c.reltablespace      AS 表空间OID,
  ts.spcname           AS 表空间名,
  pg_relation_filepath(c.oid) AS 相对路径
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_database d ON d.oid = (
  SELECT d2.oid FROM pg_database d2 WHERE d2.datname = current_database()
)
LEFT JOIN pg_tablespace ts ON ts.oid = c.reltablespace
WHERE c.relname = 'orders';
text
 表OID | 表名   | 物理文件名 | 模式名 | 数据库名 | 表空间OID | 表空间名 |          相对路径
-------+--------+------------+--------+----------+-----------+----------+------------------------------
 16385 | orders |      16385 | public | mydb     |         0 |          | base/16384/16385

重要细节:relfilenode 不一定等于 oid。以下操作会让二者分道扬镳:

操作对 relfilenode 的影响
TRUNCATE新建一个文件,relfilenode 改变
REWRITE(如 ALTER TABLE ... TYPE)重写整表,relfilenode 改变
CLUSTER / VACUUM FULL重写整表,relfilenode 改变
REINDEX索引的 relfilenode 改变
CREATE TABLE初始时二者相同

所以永远不要用 oid 去猜文件路径,要用 pg_relation_filepath()。

段文件切分规则 ​

单个物理文件被限制在 1GB(RELSEG_SIZE = 131072 页 × 8KB),超过后自动切出后续文件:

text
一张 2.5GB 的表(relfilenode = 16385):

base/16384/16385       ← 第 0 ~ 131071 页    (第 1 个 1GB)
base/16384/16385.1     ← 第 131072 ~ 262143 页 (第 2 个 1GB)
base/16384/16385.2     ← 第 262144 ~ 327679 页 (第 3 个,约 0.5GB)
base/16384/16385_fsm   ← 空闲空间映射(体积约为表的 1/4000)
base/16384/16385_vm    ← 可见性映射(体积约为表的 1/32768)

为什么要切分?因为某些文件系统(尤其老式 32 位环境与部分备份工具)对单文件大小有限制,1GB 是保守而安全的选择。这对使用者的实际影响是:

  • ls -l 看到的单个文件大小不代表表大小,必须把所有段文件加起来。
  • 备份/复制工具必须能处理多段文件,pg_basebackup 天然支持。
  • 删除表时必须清理所有段,手动删文件极易漏掉 .N 而损坏集群。

FSM 与 VM:两个旁路文件的职责 ​

这两个文件是 PostgreSQL 保证性能的关键辅助结构,它们本身不存业务数据:

text
              ┌─────────────────────────────────────┐
              │        表主文件(数据页)             │
              │  ┌────┐┌────┐┌────┐┌────┐┌────┐    │
              │  │ P0 ││ P1 ││ P2 ││ P3 ││ P4 │... │
              │  └────┘└────┘└────┘└────┘└────┘    │
              └────┬───────────────┬────────────────┘
                   │               │
       FSM 记录      │               │   VM 记录
    「每页剩多少空闲」 │               │ 「每页是否全可见」
                   ▼               ▼
        ┌──────────────────┐  ┌──────────────────┐
        │ 16385_fsm        │  │ 16385_vm         │
        │ 用树形结构压缩存储  │  │ 每页 2 个 bit:    │
        │ 每页可用空间的近似值│  │  ALL_VISIBLE     │
        │ → 插入时快速找空位  │  │  ALL_FROZEN      │
        └──────────────────┘  └──────────────────┘
                                  → Index Only Scan 的依据
                                  → VACUUM 只扫未全可见的页
文件全称主要用途典型问题
_fsmFree Space MapINSERT 时快速找到有足够空闲空间的页FSM 不准会导致插入「找不到位置」而误扩表
_vmVisibility Map加速 VACUUM 与 Index Only Scan长期不 VACUUM 会导致 VM 全为 0,索引扫描退化

_vm 的两个 bit 意义重大:

  • ALL_VISIBLE:该页所有元组对所有事务都可见 → 索引扫描可直接返回,无需回表(Index Only Scan 生效的前提)。
  • ALL_FROZEN:该页所有元组都已冻结 → VACUUM 可跳过该页。
sql
-- 查看某表的 VM / FSM 状态:有多少页是「全可见」的
SELECT
  c.relname,
  pg_relation_size(c.oid)                                    AS 主文件大小,
  pg_relation_size(c.oid, 'fsm')                             AS fsm大小,
  pg_relation_size(c.oid, 'vm')                              AS vm大小,
  pg_relation_size(c.oid) / 8192                             AS 总页数,
  pg_relation_size(c.oid, 'vm') * 8192 / 2                   AS vm可覆盖页数
FROM pg_class c
WHERE c.relname = 'orders';
text
 relname | 主文件大小 | fsm大小 | vm大小 | 总页数 | vm可覆盖页数
---------+------------+---------+--------+--------+--------------
 orders  |   73400320 |   18432 |   8192 |   8960 |         4096

如果「vm可覆盖页数」远小于「总页数」(例如只覆盖一半),说明表的另一半尚未被标记为全可见,VACUUM 还没扫到或没跑完。

表文件的物理增长规律 ​

表的物理大小只会增,不会因为删除而缩小(除非 VACUUM FULL / TRUNCATE):

text
时间轴(某表 100 万行,每行 100 字节 ≈ 约 13MB)

  t0: INSERT 100万行        → 文件 13MB,全部是活元组
  t1: DELETE 一半(50万行)  → 文件仍是 13MB!只是 50 万行的 ItemId 被标记为死元组
  t2: UPDATE 剩余一半(修改)→ 文件涨到 19MB!
                             因为 UPDATE = DELETE + INSERT:
                             旧版本标记为死元组,新版本插入到有空闲空间的地方
  t3: VACUUM(普通)        → 文件仍是 19MB,但死元组空间被标记为「可重用」
                             后续 INSERT 会复用这些空间,文件不再增长
  t4: VACUUM FULL           → 文件缩回 13MB(但会持有 ACCESS EXCLUSIVE 锁)

结论:普通 VACUUM 控制的是「增长上限」,VACUUM FULL 才能「缩小体积」。
      这就是「表膨胀(Bloat)」的来源。

详细说明 ​

用 pageinspect 直接翻阅磁盘页 ​

pageinspect 是官方 contrib 扩展,允许把页的原始结构读成可查询的行,是理解存储格式最直接的工具。

sql
-- 安装扩展(需要 superuser,每个数据库装一次)
CREATE EXTENSION IF NOT EXISTS pageinspect;

-- 取第 0 页的页头信息
SELECT * FROM page_header(get_raw_page('orders', 0));

-- 取第 0 页的所有行指针(ItemId)
SELECT * FROM heap_page_item_attrs(get_raw_page('orders', 0), 'orders'::regclass);

-- 更易读的行指针视图:显示每个 ItemId 的偏移、长度与标志
SELECT * FROM heap_page_items(get_raw_page('orders', 0)) LIMIT 5;
text
-- page_header 输出示例
    lsn     | checksum | flags | lower | upper | special | pagesize | version | prune_xid
------------+----------+-------+-------+-------+---------+----------+---------+-----------
 0/1A2B3C48 |    16384 |     0 |   156 |  6120 |    8192 |     8192 |       4 |         0

字段含义:

字段含义观测价值
lsn本页最后一次修改对应的 WAL 位置可用来确认页是否已刷盘(与 pg_current_wal_flush_lsn 对比)
checksum页校验和,0 表示未启用校验data_checksums 开启时非 0
flags页标志位判断页是否「有未修剪的死元组」等
lower空闲空间起点(ItemId 数组末尾)upper - lower 即当前空闲空间
upper空闲空间终点(元组区起点)与 lower 一起反映页内碎片
special特殊区起点,普通表固定为 8192索引页此项有意义
version页布局版本号目前为 4
text
-- heap_page_items 输出示例(前 5 个 ItemId)
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid  | t_infomask2 | t_infomask
----+--------+----------+--------+--------+--------+----------+---------+-------------+------------
  1 |   6120 |        1 |    136 |  20001 |      0 |        0 | (0,1)   |           3 |       2050
  2 |   5984 |        1 |    136 |  20001 |  20005 |        0 | (0,3)   |           3 |        258
  3 |   5848 |        1 |    136 |  20005 |      0 |        0 | (0,3)   |           3 |       2050
  4 |   5712 |        1 |    136 |  20002 |      0 |        0 | (0,4)   |           3 |       2050
  5 |      0 |        0 |      0 |        |        |          |         |             |

解读要点:

  • lp_flags = 1 表示该行指针是「正常」的;lp_flags = 0 表示「未使用」(如第 5 项,页尾还有余量但没数据)。
  • 第 2 行的 t_xmax = 20005 且 t_ctid = (0,3),说明它被事务 20005 更新/删除,新版本在同页第 3 行——这就是行内版本链。
  • 第 3 行的 t_xmin = 20005,正好是被事务 20005 插入的新版本。

这段输出把 MVCC 讲的「更新不覆盖,而是新增一版」变成了可见的物理事实。

表、索引、TOAST 三类文件的磁盘占比分析 ​

生产环境排查「磁盘去哪了」时,需要区分主文件、索引与 TOAST:

sql
SELECT
  c.relname                                              AS 对象名,
  CASE c.relkind
    WHEN 'r' THEN '表'
    WHEN 'i' THEN '索引'
    WHEN 't' THEN 'TOAST表'
    WHEN 'm' THEN '物化视图'
    ELSE c.relkind::text
  END                                                    AS 类型,
  pg_size_pretty(pg_relation_size(c.oid))                AS 主文件,
  pg_size_pretty(pg_relation_size(c.oid, 'fsm'))         AS fsm,
  pg_size_pretty(pg_relation_size(c.oid, 'vm'))          AS vm,
  pg_size_pretty(pg_total_relation_size(c.oid))          AS 含索引与TOAST总计
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND c.relkind IN ('r', 'i', 't', 'm')
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 15;
text
    对象名      |   类型   | 主文件  |  fsm   |   vm   | 含索引与TOAST总计
----------------+----------+---------+--------+--------+------------------
 orders         | 表       | 7340 kB | 18 kB  | 8192 B | 11 MB
 orders_pkey    | 索引     | 2200 kB | 24 kB  |        | 2200 kB
 orders_idx_uid | 索引     | 1896 kB | 24 kB  |        | 1896 kB
 pg_toast_16385 | TOAST表  | 512 kB  | 8192 B | 8192 B | 640 kB

三个函数的区别必须分清:

函数统计范围
pg_relation_size(oid)仅主文件(不含 _fsm、_vm、TOAST)
pg_relation_size(oid, 'fsm')仅 FSM 文件
pg_table_size(oid)主文件 + _fsm + _vm + TOAST(不含索引)
pg_indexes_size(oid)该表所有索引之和
pg_total_relation_size(oid)以上全部(表 + TOAST + 索引)

只有 pg_total_relation_size() 才是「这张表到底占了多少磁盘」的正确答案。

页内的空间管理与碎片化 ​

页内空间的三个关键概念:

text
① 空闲空间(Free Space)
   = upper - lower - ItemId 数组剩余空间
   新插入的行必须能放进某个页的这块空间

② 碎片化(Fragmentation)
   删除第 3、5、7 行后,ItemId 槽还占着。
   这些「死元组槽」的空间不会被自动整理。

③ 页修剪(Page Pruning / HOT Prune)
   PostgreSQL 在页内空间不足时会自动尝试「修剪」:
   把死元组的 ItemId 标记为可复用,重定向指针,从而让页重新可用。
   这是 HOT(Heap-Only Tuple)更新的基础,无需 VACUUM 介入。

一个实用的观测手段:把页的 lower/upper 差值换算成「页的填充率」,找出填充率异常低的页:

sql
-- 抽样检查前 100 页的填充率(页越大越满)
SELECT
  blkno,
  lsn,
  lower,
  upper,
  (upper - lower)                          AS 空闲字节,
  round(100.0 * (8192 - (upper - lower)) / 8192, 1) AS 填充率百分比
FROM generate_series(0, 99) AS blkno
CROSS JOIN LATERAL page_header(get_raw_page('orders', blkno))
ORDER BY 填充率百分比
LIMIT 10;
text
 blkno |    lsn     | lower | upper | 空闲字节 | 填充率百分比
-------+------------+-------+-------+----------+--------------
    42 | 0/2F1A0048 |   260 |  7000 |     6740 |         17.8
    77 | 0/2F1B2200 |   232 |  6900 |     6668 |         18.6
    13 | 0/2F0C1840 |   204 |  6600 |     6396 |         21.9

这些「低填充率」页通常说明:曾发生过大量删除,且后续没有合适的插入来复用空间。若这类页占比很高,需要考虑 VACUUM FULL 或 pg_repack。

实战示例 ​

示例 1:定位一个表的全部物理文件 ​

bash
# 先拿到相对路径
psql -At -c "SELECT pg_relation_filepath('orders');"
# 输出:base/16384/16385
bash
# 用它列出所有相关文件(含段文件、fsm、vm、toast)
PGDATA=$(psql -At -c "SHOW data_directory;")
RELNODE=$(psql -At -c "SELECT relfilenode FROM pg_class WHERE relname='orders';")
DBOID=$(psql -At -c "SELECT oid FROM pg_database WHERE datname=current_database();")

ls -lh "$PGDATA/base/$DBOID/" | grep -E "^.*[ /]${RELNODE}(_[a-z]+)?(\.\d+)?$"
text
-rw-------  1 postgres postgres  70M Sep 22 10:12 16385
-rw-------  1 postgres postgres  18K Sep 22 10:12 16385_fsm
-rw-------  1 postgres postgres 8.0K Sep 22 10:12 16385_vm

示例 2:对比 pg_relation_size 与 pg_total_relation_size ​

sql
-- 用一张带索引与 TOAST 的表演示各函数口径差异
CREATE TABLE IF NOT EXISTS demo_files (
  id      bigserial PRIMARY KEY,
  tag     text,
  payload text
);

INSERT INTO demo_files (tag, payload)
SELECT 'row-' || g, repeat('x', 5000)   -- 每行 5KB,必然触发 TOAST 溢出
FROM generate_series(1, 2000) AS g;

VACUUM ANALYZE demo_files;

SELECT
  pg_size_pretty(pg_relation_size('demo_files'))        AS 仅主文件,
  pg_size_pretty(pg_table_size('demo_files'))           AS 表含TOAST不含索引,
  pg_size_pretty(pg_indexes_size('demo_files'))         AS 索引合计,
  pg_size_pretty(pg_total_relation_size('demo_files'))  AS 总计;
text
 仅主文件 | 表含TOAST不含索引 | 索引合计 |  总计
----------+-------------------+----------+---------
 12 MB    | 18 MB             | 112 kB   | 18 MB

「仅主文件」明显小于「表含TOAST」——差值就是溢出到 TOAST 的 payload 数据。

示例 3:观察 UPDATE 如何让表文件膨胀 ​

sql
-- 建一张小表,记录每一步的物理大小
CREATE TABLE IF NOT EXISTS bloat_demo (id int PRIMARY KEY, val text);
INSERT INTO bloat_demo SELECT g, repeat('a', 200) FROM generate_series(1, 20000) AS g;

SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS 初始大小;

-- 更新全表:每行都会产生一个新版本(UPDATE = DELETE + INSERT)
UPDATE bloat_demo SET val = val || 'b';

SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS 全表UPDATE后;

-- 用 pg_stat_all_tables 看死元组数量
SELECT n_live_tup, n_dead_tup, n_tup_upd, n_tup_hot_upd
FROM pg_stat_all_tables WHERE relname = 'bloat_demo';

-- 普通 VACUUM:死元组被清理,空间可复用,但文件不缩小
VACUUM bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS 普通VACUUM后;

-- VACUUM FULL:真正重写文件,体积缩小(会加 ACCESS EXCLUSIVE 锁)
VACUUM FULL bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo')) AS VACUUM_FULL后;
text
    初始大小
----------------
 4600 kB

    全表UPDATE后
----------------
 9200 kB

 n_live_tup | n_dead_tup | n_tup_upd | n_tup_hot_upd
------------+------------+-----------+---------------
      20000 |      20000 |     20000 |             0

 普通VACUUM后
----------------
 9200 kB        ← 文件没缩!只是死元组空间标记为可复用

 VACUUM_FULL后
----------------
 4600 kB

示例 4:找出填充率低、需要整理的页 ​

sql
-- 统计全表「低填充率页」占比,评估是否需要 VACUUM FULL / pg_repack
WITH pages AS (
  SELECT
    blkno,
    (upper - lower) AS free_bytes
  FROM generate_series(
         0,
         (pg_relation_size('orders') / 8192) - 1
       ) AS blkno
  CROSS JOIN LATERAL page_header(get_raw_page('orders', blkno))
)
SELECT
  count(*)                                                        AS 总页数,
  count(*) FILTER (WHERE free_bytes > 8192 * 0.5)                 AS 空闲超一半的页,
  round(100.0 * count(*) FILTER (WHERE free_bytes > 8192 * 0.5) / count(*), 1) AS 低填充率占比百分比
FROM pages;
text
 总页数 | 空闲超一半的页 | 低填充率占比百分比
--------+----------------+--------------------
   8960 |           3120 |               34.8

超过 30% 就值得考虑整理。注意:逐页调用 page_header() 需要把每一页都读进内存,对大表非常昂贵,只应在维护窗口对候选表执行。

示例 5:确认 FSM 与 VM 是否被正确维护 ​

sql
-- 检查 VM 覆盖情况:vm 文件每 8192 字节覆盖 32768 个数据页
SELECT
  c.relname,
  pg_relation_size(c.oid)                        AS 主文件字节,
  pg_relation_size(c.oid, 'vm')                  AS vm字节,
  pg_relation_size(c.oid) / 8192                 AS 数据页数,
  pg_relation_size(c.oid, 'vm') * 8192 / 2       AS vm已覆盖页数,
  CASE
    WHEN pg_relation_size(c.oid, 'vm') = 0 THEN 'VM 缺失,需执行 VACUUM'
    WHEN pg_relation_size(c.oid, 'vm') * 8192 / 2 >= pg_relation_size(c.oid) / 8192
      THEN 'VM 已全表覆盖'
    ELSE 'VM 仅部分覆盖'
  END                                             AS 结论
FROM pg_class c
WHERE c.relname = 'orders';
text
 relname | 主文件字节 | vm字节 | 数据页数 | vm已覆盖页数 |       结论
---------+------------+--------+----------+--------------+------------------
 orders  |   73400320 |   8192 |     8960 |         4096 | VM 仅部分覆盖

「VM 仅部分覆盖」通常意味着最近的 VACUUM 只处理了一部分页(可能因为成本限制提前结束),或之后又有大量写入。

注意事项 ​

  • relfilenode 与 oid 可能不同,定位文件必须用 pg_relation_filepath(),不要用 oid 拼路径。
  • _vm 文件可能为空(大小为 0),这表示该表从未被 VACUUM 标记过全可见页,Index Only Scan 会退化为普通索引扫描。
  • _fsm 数据是近似值,PostgreSQL 有意不保证精确,避免更新开销;这可能导致表「比预期多占一点空间」。
  • pageinspect 需要 superuser 权限,且 get_raw_page() 会绕过缓冲区直接读文件,在实例运行中读取时页内容可能与内存不一致,仅用于诊断。
  • VACUUM FULL 会持有 ACCESS EXCLUSIVE 锁并重写整表,生产环境的大表应改用 pg_repack 扩展实现不停机整理。
  • 不要手动删除 .1、.2 段文件或 _fsm、_vm 文件,会导致集群损坏。
  • page_header() 逐页调用对大表代价极高,务必要限制页范围。
sql
-- 不推荐:对大表全页扫描 page_header,可能耗尽 I/O 与内存
-- SELECT * FROM generate_series(0, (pg_relation_size('huge_table')/8192)-1) b
--   CROSS JOIN LATERAL page_header(get_raw_page('huge_table', b));   -- 不推荐

-- 不推荐:用 oid 拼物理路径(relfilenode 可能已变化)
-- ls $PGDATA/base/16384/$(psql -At -c "SELECT oid FROM pg_class WHERE relname='orders'")  -- 不推荐
bash
# 不推荐:绕过 PostgreSQL 直接清理文件
# rm -f $PGDATA/base/16384/16385_fsm   # 不推荐:FSM 缺失会导致插入性能骤降甚至报错

最佳实践 ​

  • 用 pg_total_relation_size() 回答「表占多少磁盘」,不要用 pg_relation_size()。
  • 排查磁盘占用时按「表 + TOAST + 索引」三类拆分,索引往往是意外的大头。
  • 定期用 pg_stat_all_tables.n_dead_tup 监控膨胀,而不是等磁盘报警。
  • 大表整理优先用 pg_repack,避免 VACUUM FULL 的长时间排他锁。
  • 保证 VACUUM 能覆盖全表以维护 VM,这对 Index Only Scan 的收益非常显著。
  • 给关键查询设计覆盖索引,配合 VM 才能让 Index Only Scan 真正生效。
  • 不要用文件大小推算行数,行长度可变且有 TOAST、对齐填充,误差可能很大。
  • 理解「删除不缩表」的机制,把「容量增长」与「数据量增长」分开评估。
  • pageinspect 只在诊断时使用,并限定页范围,不要在生产高峰期全表扫描。
  • 建立「表 + 索引 + TOAST 体积」的定期快照,这样才能看出增长趋势而非单点数值。

下一节

继续学习:TOAST(大字段溢出存储)

参考链接 ​