执行 SELECT * FROM orders WHERE id = 42 时,PostgreSQL 最终要把磁盘上某个 8KB 页里的一小段字节读进内存并解析成元组。理解这层物理结构,是解释「为什么删了数据磁盘不释放」「为什么一行大字段会让整表变慢」「为什么 fillfactor 能减少页分裂」这类问题的唯一途径。
核心认知:堆表是「无序的行集合」,页是 IO 的最小单位,行只是页内一个带偏移的字节片段。所有空间管理与可见性判断都建立在这两个事实上。
一、物理文件布局
1.1 数据目录与 base 子目录
PGDATA 中真正存放表数据的只有 base/、global/ 和可选的 pg_tblspc/。
ls -la $PGDATA
# base/ 每个数据库一个以 OID 命名的子目录,内含表与索引文件
# global/ 共享系统目录(pg_database、pg_authid 等)
# pg_wal/ 预写日志
# pg_xact/ 事务提交状态(CLOG)
# pg_multixact/ 多事务状态
psql -c "SELECT oid, datname FROM pg_database ORDER BY oid;"
ls $PGDATA/base
1.2 关系文件节点命名
文件名的核心是 relfilenode,不是表的 OID。二者初始相同,但 TRUNCATE、REINDEX、VACUUM FULL、CLUSTER 会重写文件并改变 relfilenode。
SELECT relname, oid, relfilenode, reltablespace
FROM pg_class WHERE relname IN ('orders', 'orders_pkey');
-- 内置函数直接给出路径
SELECT pg_relation_filepath('orders');
-- 输出示例:base/16384/24576
主文件后缀为空,FSM 加 _fsm,VM 加 _vm,初始化 fork 加 _init。
1.3 段文件切分与 1GB 上限
每个关系按 1GB 一段切分,命名在主文件名后追加段号。
$PGDATA/base/16384/24576 # 第 0 段,0 ~ 1GB
$PGDATA/base/16384/24576.1 # 第 1 段,1GB ~ 2GB
$PGDATA/base/16384/24576.2 # 第 2 段,2GB ~ 2.5GB
段大小由编译期常量 RELSEG_SIZE * BLCKSZ 决定,默认 131072 * 8192 正好 1GB。
SELECT pg_size_pretty(pg_relation_size('orders')) AS pretty,
pg_relation_size('orders') / 8192 AS blocks;
二、页头与 PageHeaderData
2.1 8KB 页的宏观布局
+-------------------------+ 偏移 0
| PageHeaderData (24 字节) |
+-------------------------+
| ItemIdData 数组 | <- pd_lower 指向行指针区末尾
+-------------------------+
| 空闲空间 |
+-------------------------+ <- pd_upper 指向元组区起点
| 元组数据(向下生长) |
+-------------------------+
| 特殊区域 special space | <- pd_special
+-------------------------+ 偏移 8192
空闲空间即 pd_upper - pd_lower。行指针从头往上加,元组从尾往下加,相遇即页满。
2.2 PageHeaderData 字段
/* src/include/storage/bufpage.h */
typedef struct PageHeaderData
{
XLogRecPtr pd_lsn; /* 最后一次修改此页的 WAL 记录 LSN */
uint16 pd_checksum; /* 页校验和 */
uint16 pd_flags; /* 标志位:PD_HAS_FREE_LINES 等 */
LocationIndex pd_lower; /* 空闲空间起始偏移 */
LocationIndex pd_upper; /* 空闲空间结束偏移 */
LocationIndex pd_special; /* 特殊区域起始偏移 */
uint16 pd_pagesize_version;
TransactionId pd_prune_xid; /* 最老可清理 XID,加速 prune */
ItemIdData pd_linp[FLEXIBLE_ARRAY_MEMBER];
} PageHeaderData;
pd_lsn:写页时记录的 LSN,用于判断「此页是否已落盘」,也是 WAL 一致性校验的依据。pd_checksum:仅在initdb -k开启校验和时有意义,用于检测静默损坏。pd_flags:PD_HAS_FREE_LINES表示存在可复用的行指针槽位。pd_prune_xid:提示本页可能存在可回收元组,加速heap_page_prune。
SHOW data_checksums; -- 确认是否开启页校验和
三、行指针与元组头部
3.1 ItemIdData 行指针
typedef struct ItemIdData
{
unsigned lp_off:15, /* 元组偏移,15 位足以表示 8192 */
lp_flags:2, /* 状态 */
lp_len:15; /* 元组字节长度 */
} ItemIdData;
LP_UNUSED = 0 未使用,可复用
LP_NORMAL = 1 正常指向元组
LP_REDIRECT = 2 重定向(HOT 更新指向新元组)
LP_DEAD = 3 已死亡但尚未清理
行指针的存在使元组能在页内移动(VACUUM 紧凑化)而不必更新索引——索引存的是 ctid,即「页号 + 行指针序号」。
3.2 HeapTupleHeaderData 元组头部
元组头 23 字节(含对齐),紧接其后是 null bitmap 与用户数据。
typedef struct HeapTupleFields
{
TransactionId t_xmin; /* 插入此元组的事务 */
TransactionId t_xmax; /* 删除/更新此元组的事务,0 表示有效 */
union { CommandId t_cid; TransactionId t_xvac; } t_field3;
} HeapTupleFields;
typedef struct HeapTupleHeaderData
{
union { HeapTupleFields t_heap; DatumTupleFields t_datum; } t_choice;
ItemPointerData t_ctid; /* 当前物理位置,或新版本位置 */
uint16 t_infomask2; /* 属性数 + HOT 标志 */
uint16 t_infomask; /* 可见性标志位 */
uint8 t_hoff; /* 头部到用户数据的偏移 */
} HeapTupleHeaderData;
t_xmin/t_xmax:MVCC 基石,可见性判断就是与快照的xmin/xmax比较。t_ctid:普通元组指向自己;HOT 更新时旧元组指向新元组,形成更新链。t_infomask:缓存「已提交/已回滚」「有 null 列」「有外部 TOAST」等位,避免每次读 CLOG。
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('orders', 0)) LIMIT 5;
3.3 null bitmap 与对齐填充
存在 NULL 列时,元组头后紧跟 null bitmap,每个可空列 1 bit,按属性顺序排列。
对齐规则由 pg_type.typalign 决定:char 对齐 1,int2 对齐 2,int4/text 对齐 4,int8/timestamp 对齐 8。列顺序影响对齐填充量。
SELECT typname, typlen, typalign, typstorage FROM pg_type
WHERE typname IN ('int4', 'int8', 'text', 'timestamptz', 'uuid');
-- 对比两种列顺序:boolean 在前,后面 8 字节对齐的 bigint 要补 7 字节填充
CREATE TABLE bad_order (flag boolean, id bigint, tag text);
CREATE TABLE good_order (id bigint, tag text, flag boolean);
四、页内空闲空间与 fillfactor
fillfactor 表示「插入时最多把页填到百分之多少」,剩余空间留给后续 UPDATE 在同一页放下新版本(HOT 更新),避免跨页找空间并触发索引插入。
ALTER TABLE orders SET (fillfactor = 85);
VACUUM FULL orders; -- 对存量页生效
-- 或 CLUSTER orders USING orders_pkey;
只读或极少更新 -> 100
高频 UPDATE 的宽表 -> 70 ~ 85
频繁 UPDATE 的热点小表 -> 85 ~ 90
-- 验证 HOT 更新比例
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / greatest(n_tup_upd, 1), 2) AS hot_pct
FROM pg_stat_user_tables WHERE n_tup_upd > 0
ORDER BY hot_pct ASC LIMIT 10;
hot_pct 偏低说明页内空间不足,应考虑降低 fillfactor 或提高 autovacuum 频率。
五、空闲空间映射与可见性映射
5.1 空闲空间映射 FSM
FSM 用树形结构记录每页剩余空间(父节点汇总子节点最大值),使插入能快速找到放得下行的那一页,而不必扫描全表。FSM 由 VACUUM 更新,只服务插入。
SELECT * FROM pg_freespace('orders') LIMIT 10; -- 返回 blkno 与 avail
5.2 可见性映射 VM
VM 有两个 bit:全可见与全冻结。全可见页的顺序扫描可跳过可见性检查;全冻结页不必被 VACUUM 再次处理。
SELECT * FROM pg_visibility_map('orders') LIMIT 10;
SELECT pg_visibility_map_summary('orders');
VM 解释了「为什么 VACUUM 之后顺序扫描变快」——它让 Index Only Scan 走免回表的快路径。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders WHERE id BETWEEN 1000 AND 2000; -- 关注 Heap Fetches
六、TOAST 机制
6.1 阈值与触发条件
TOAST(The Oversized-Attribute Storage Technique)把大字段挪到行外。判定阈值是单行用户数据超过约 2KB(TOAST_TUPLE_THRESHOLD)就尝试压缩或行外存储,目标压到 TOAST_TUPLE_TARGET(默认 2KB)以下。
只有 varlena 类型(text、bytea、jsonb、数组、varchar)可被 TOAST;int、timestamptz 等定长类型不参与。
SELECT relname, reltoastrelid::regclass AS toast_table
FROM pg_class WHERE relname = 'documents';
6.2 四种压缩策略
p (plain) 不压缩也不行外存储,如 point
e (external) 允许行外存储,但不压缩
m (main) 允许压缩,优先压缩,不够才行外
x (extended) 允许压缩与行外,默认,最灵活
SELECT attname, atttypid::regtype, attstorage
FROM pg_attribute WHERE attrelid = 'documents'::regclass AND attnum > 0;
ALTER TABLE documents ALTER COLUMN meta SET STORAGE PLAIN;
6.3 toast 表与 pg_toast 关联
TOAST 数据存在独立的 TOAST 表中,位于 pg_toast schema,命名形如 pg_toast_<oid>,与主表一一对应。
SELECT c.relname AS main_table, t.relname AS toast_table,
pg_size_pretty(pg_relation_size(t.oid)) AS toast_size,
pg_size_pretty(pg_relation_size(c.oid)) AS main_size
FROM pg_class c JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relname = 'documents';
TOAST 表只有三列:chunk_id(OID)、chunk_seq(片序)、chunk_data(每片约 2KB),可用 SELECT chunk_id, chunk_seq, length(chunk_data) FROM pg_toast.pg_toast_16400 直接观察切片。
6.4 行外存储与切片
行内放不下时,大字段被切成约 2KB 的 chunk 存入 TOAST 表,主表元组里只保留 18 字节的 TOAST 指针。
主表元组: [头][null bitmap][TOAST 指针 18 字节][其他列]
|
v
TOAST 表: chunk_id=16401, chunk_seq=0..N, chunk_data
后果很直接:读取主表时不碰 TOAST,除非真的 SELECT 那个大列。所以 SELECT * 在大字段表上代价极高。
EXPLAIN (ANALYZE, BUFFERS) SELECT id, title FROM documents LIMIT 100;
EXPLAIN (ANALYZE, BUFFERS) SELECT id, body FROM documents LIMIT 100;
七、pageinspect 实测
7.1 查看页头
SELECT * FROM page_header(get_raw_page('orders', 0));
-- lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
lower 与 upper 之差就是页内空闲空间;堆表页的 special 等于页大小,B-tree 页则指向页尾特殊数据。
7.2 查看行指针与元组
SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('orders', 0)) ORDER BY lp LIMIT 20;
lp_flags = 1正常,3表示已死但未清理。t_xmax = 0表示元组仍有效;非 0 表示已被删除或更新。t_ctid与lp不一致说明该元组已被 HOT 更新。
7.3 表膨胀与页回收的关系
删除元组只是写上 t_xmax,页不会自动还给操作系统。空间只能被同页后续插入复用,或由 VACUUM FULL / pg_repack 重写文件归还。
SELECT relname, n_live_tup, n_dead_tup,
pg_size_pretty(pg_relation_size(relid)) AS size,
round(100.0 * n_dead_tup / greatest(n_live_tup + n_dead_tup, 1), 2) AS dead_pct
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders');
-- table_len | tuple_count | dead_tuple_count | free_space | free_percent
free_space 高但 tuple_count 低就是典型膨胀。三条解决路径:VACUUM(可复用但不归还)、VACUUM FULL(归还但持排他锁)、pg_repack(在线重写)。
常见问题(FAQ)
为什么删除大量数据后磁盘空间没释放
DELETE 只在元组头写 t_xmax,页仍被表文件占用,空闲空间只对同页新插入可见。普通 VACUUM 把死元组标记为可复用(lp_flags = 0),但不把页还给文件系统。要让文件变小必须 VACUUM FULL、CLUSTER 或 pg_repack,三者都会重写关系文件并产生新的 relfilenode。
一行最多能存多大
理论上不含行外 TOAST 的单行不能超过一个 8KB 页。因为有 TOAST,超大字段会被切片存到 TOAST 表,text 列可存到约 1GB。真正约束来自页内能放下的元组头加非 TOAST 部分,通常受约 2KB 的 TOAST 阈值限制。
fillfactor 设低了有什么副作用
低 fillfactor 意味着每页保留更多空闲空间,同样数据量需要更多页,顺序扫描 IO 量变大、表文件更大。它是一笔「用空间换更新效率」的交易,只对高频 UPDATE 的表值得;只读表设低是纯损失。
为什么索引里存的是 ctid 而不是主键
堆表没有聚簇概念,ctid(页号加行指针序号)是唯一能 O(1) 定位元组的方式。索引项存 ctid 后,元组在页内移动(VACUUM 紧凑化)不影响索引,只有跨页移动才需更新索引。这也是 HOT 更新能减少索引写入的原因。
TOAST 压缩会让数据变大吗
对已经高压缩率的数据(已 gzip 的二进制、JPEG)不会变小,甚至可能略大,因为压缩尝试本身要付出代价且无收益。pglz 与 lz4 都内置了「压缩后不变小就放弃」的判断。此时把列设为 STORAGE EXTERNAL 可跳过压缩直接行外存储,减少 CPU 消耗。
相关阅读
- PostgreSQL VACUUM 与表膨胀治理 — 死元组回收、autovacuum 调优与膨胀诊断
- PostgreSQL 索引类型深度实战 — B-tree 页结构与索引膨胀
- PostgreSQL 性能调优 — 存储参数与 fillfactor 调优
- PostgreSQL 监控与诊断体系 — 表膨胀与页统计监控
- PostgreSQL 数据类型深入 — 类型对齐、存储策略与 varlena
- PostgreSQL 专题导航
延伸阅读
- PostgreSQL 备份与恢复 — 物理备份与段文件的关系
- PostgreSQL 统计信息与 ANALYZE — 页级采样如何生成统计
完整示例(一键复制)
-- ========== 1. 安装扩展 ==========
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pgstattuple;
-- ========== 2. 定位物理文件 ==========
SELECT pg_relation_filepath('documents') AS relpath;
SELECT relname, oid, relfilenode, reltoastrelid::regclass AS toast_table
FROM pg_class WHERE relname = 'documents';
SELECT pg_size_pretty(pg_relation_size('documents')) AS size,
pg_relation_size('documents') / 8192 AS blocks;
-- ========== 3. 页头与空闲空间 ==========
SELECT * FROM page_header(get_raw_page('documents', 0));
-- lower 与 upper 之差即页内空闲字节数
-- ========== 4. 行指针与元组头 ==========
SELECT lp, lp_off, lp_flags, lp_len, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('documents', 0)) ORDER BY lp LIMIT 20;
-- ========== 5. TOAST 表体积 ==========
SELECT pg_size_pretty(pg_relation_size('pg_toast.pg_toast_' || reltoastrelid)) AS toast_size
FROM pg_class WHERE relname = 'documents';
-- ========== 6. 精确膨胀测量 ==========
SELECT * FROM pgstattuple('documents'); -- 关注 free_percent 与 dead_tuple_percent
-- ========== 7. 降低 fillfactor 并重写 ==========
ALTER TABLE documents SET (fillfactor = 85);
VACUUM FULL documents; -- 或使用 pg_repack 在线重写
ANALYZE documents;
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。