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 文件,标记哪些页「全是活元组」或「全是冻结元组」 |
| TOAST | The Oversized-Attribute Storage Technique | 大字段的溢出存储机制,见下一节 |
| 段文件 | Segment File | 单文件上限 1GB,超出后拆成 .1、.2…… |
一个「表」其实是一组文件
初学者常以为 orders 表就是磁盘上的一个文件。实际上,一张普通表在 PGDATA 中至少会有:
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 的基本单位。它的结构如下:
┌───────────────────────────────────────────────────────┐ 偏移
│ 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)不只是「业务字段」,它前面还挂着系统列信息:
┌──────────────────────────────────────────────────────────┐
│ 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 里的表名走到磁盘文件,需要经过四层:
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 可以一次性走完这条路:
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'; 表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),超过后自动切出后续文件:
一张 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 保证性能的关键辅助结构,它们本身不存业务数据:
┌─────────────────────────────────────┐
│ 表主文件(数据页) │
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
│ │ P0 ││ P1 ││ P2 ││ P3 ││ P4 │... │
│ └────┘└────┘└────┘└────┘└────┘ │
└────┬───────────────┬────────────────┘
│ │
FSM 记录 │ │ VM 记录
「每页剩多少空闲」 │ │ 「每页是否全可见」
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 16385_fsm │ │ 16385_vm │
│ 用树形结构压缩存储 │ │ 每页 2 个 bit: │
│ 每页可用空间的近似值│ │ ALL_VISIBLE │
│ → 插入时快速找空位 │ │ ALL_FROZEN │
└──────────────────┘ └──────────────────┘
→ Index Only Scan 的依据
→ VACUUM 只扫未全可见的页| 文件 | 全称 | 主要用途 | 典型问题 |
|---|---|---|---|
_fsm | Free Space Map | INSERT 时快速找到有足够空闲空间的页 | FSM 不准会导致插入「找不到位置」而误扩表 |
_vm | Visibility Map | 加速 VACUUM 与 Index Only Scan | 长期不 VACUUM 会导致 VM 全为 0,索引扫描退化 |
_vm 的两个 bit 意义重大:
- ALL_VISIBLE:该页所有元组对所有事务都可见 → 索引扫描可直接返回,无需回表(Index Only Scan 生效的前提)。
- ALL_FROZEN:该页所有元组都已冻结 →
VACUUM可跳过该页。
-- 查看某表的 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'; relname | 主文件大小 | fsm大小 | vm大小 | 总页数 | vm可覆盖页数
---------+------------+---------+--------+--------+--------------
orders | 73400320 | 18432 | 8192 | 8960 | 4096如果「vm可覆盖页数」远小于「总页数」(例如只覆盖一半),说明表的另一半尚未被标记为全可见,VACUUM 还没扫到或没跑完。
表文件的物理增长规律
表的物理大小只会增,不会因为删除而缩小(除非 VACUUM FULL / TRUNCATE):
时间轴(某表 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 扩展,允许把页的原始结构读成可查询的行,是理解存储格式最直接的工具。
-- 安装扩展(需要 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;-- 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 |
-- 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:
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; 对象名 | 类型 | 主文件 | 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() 才是「这张表到底占了多少磁盘」的正确答案。
页内的空间管理与碎片化
页内空间的三个关键概念:
① 空闲空间(Free Space)
= upper - lower - ItemId 数组剩余空间
新插入的行必须能放进某个页的这块空间
② 碎片化(Fragmentation)
删除第 3、5、7 行后,ItemId 槽还占着。
这些「死元组槽」的空间不会被自动整理。
③ 页修剪(Page Pruning / HOT Prune)
PostgreSQL 在页内空间不足时会自动尝试「修剪」:
把死元组的 ItemId 标记为可复用,重定向指针,从而让页重新可用。
这是 HOT(Heap-Only Tuple)更新的基础,无需 VACUUM 介入。一个实用的观测手段:把页的 lower/upper 差值换算成「页的填充率」,找出填充率异常低的页:
-- 抽样检查前 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; 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:定位一个表的全部物理文件
# 先拿到相对路径
psql -At -c "SELECT pg_relation_filepath('orders');"
# 输出:base/16384/16385# 用它列出所有相关文件(含段文件、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+)?$"-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
-- 用一张带索引与 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 总计; 仅主文件 | 表含TOAST不含索引 | 索引合计 | 总计
----------+-------------------+----------+---------
12 MB | 18 MB | 112 kB | 18 MB「仅主文件」明显小于「表含TOAST」——差值就是溢出到 TOAST 的 payload 数据。
示例 3:观察 UPDATE 如何让表文件膨胀
-- 建一张小表,记录每一步的物理大小
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后; 初始大小
----------------
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:找出填充率低、需要整理的页
-- 统计全表「低填充率页」占比,评估是否需要 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; 总页数 | 空闲超一半的页 | 低填充率占比百分比
--------+----------------+--------------------
8960 | 3120 | 34.8超过 30% 就值得考虑整理。注意:逐页调用 page_header() 需要把每一页都读进内存,对大表非常昂贵,只应在维护窗口对候选表执行。
示例 5:确认 FSM 与 VM 是否被正确维护
-- 检查 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'; 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()逐页调用对大表代价极高,务必要限制页范围。
-- 不推荐:对大表全页扫描 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'") -- 不推荐# 不推荐:绕过 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(大字段溢出存储)