空闲空间映射(FSM)
UPDATE 和 DELETE 会在页里留下"空洞",但 PostgreSQL 并不会因此立刻整理页面。新插入的行如何知道"哪一页还有空间"?答案就是空闲空间映射(FSM)。本节讲解 FSM 的结构、它与 INSERT 的关系,以及为什么它"故意不精确"。
前置知识
阅读本节前,建议先了解:TOAST 机制(大对象存储)
基础概念
空闲空间映射(FSM, Free Space Map) 是每张表(以及每张索引)旁边的一个辅助文件 _fsm,用来记录每一页大约还剩多少可用空间。
如果只靠扫描数据页来寻找空闲空间,每次 INSERT 都要读很多页,代价无法接受。FSM 的作用就是把这个问题从「扫描」变成「查表」:
没有 FSM 的极端情况:
INSERT 一行
→ 需要找到一个能放下这行的页
→ 从第 0 页开始逐页读,累加到找到为止
→ 表有 100 万页时,这个操作完全不可行
有 FSM:
INSERT 一行
→ 查 FSM(一个极小的树形结构)
→ 直接得到「第 12345 页还有 3000 字节」
→ 只读那一页,命中即用| 术语 | 英文 | 说明 |
|---|---|---|
| 空闲空间映射 | FSM, Free Space Map | _fsm 文件,记录每页的剩余空间 |
| 页 | Page / Block | 8KB 的读写单位 |
| 页内空闲空间 | Free Space | 页头与行指针之外、可供新元组使用的空间 |
| FSM 树 | FSM Tree | 多层的树形结构,叶子记录单页,上层记录子树最大值 |
| 分类 | Category | 每个叶子字节记录一个 0~255 的档位值 |
| FSM 页 | FSM Page | FSM 自身也按 8KB 页组织 |
FSM 的核心设计取舍是:它不精确,只记录"档位"。这看起来像是偷懒,实际上是一个深思熟虑的决定 —— 见下文「为什么 FSM 是近似的」。
核心机制
FSM 的树形结构与档位编码
FSM 用一棵树来压缩存储。树的最底层叶子对应数据页,上层节点记录其子树中的最大值:
根节点(全表的档位最大值)
│
┌───────────┴───────────┐
内部节点 内部节点
│ │
┌────┴────┐ ┌────┴────┐
叶子 叶子 叶子 叶子
│ │ │ │
数据页0 数据页1 数据页2 数据页3
(档位) (档位) (档位) (档位)
每个叶子只占 1 字节,记录一个 0~255 的「档位」
上层节点取子树最大值,因此从根一路向下就能快速定位到「有空闲的页」档位到字节数的映射是分段非线性的。设计理由很直接:小空间必须精细区分(一行小记录能否放下,差几十字节就是能不能的问题),大空间则不需要那么精确:
| 档位(Category)区间 | 对应空闲字节数 | 精度 | 说明 |
|---|---|---|---|
| 0 | 0 | —— | 完全无可用空间 |
| 1 ~ 31 | 约 32 ~ 1000 字节 | 32 字节 | 小数空间精细刻度,覆盖绝大多数小行 |
| 32 及以后 | 约 1KB ~ 8164 字节 | 逐渐变粗(数十至上百字节) | 大空间只需粗略定位候选页 |
| 最大档位 | 约 8164 字节 | —— | 接近整页可用空间上限 |
也就是说:小于 1KB 的空间误差不超过 32 字节,大于 1KB 的误差逐渐变大但对选页决策已足够。选页只需找到一个"大概装得下"的页,真正写入时还会再确认一次。
-- 用扩展查看 FSM 的原始内容
CREATE EXTENSION IF NOT EXISTS pg_freespacemap;
-- 查看 orders 表前 10 页在 FSM 中记录的剩余空间
SELECT
blkno AS 页号,
avail AS FSM记录的可用字节,
pg_freespace('orders', blkno) AS 函数写法等价结果
FROM (
SELECT blkno, avail
FROM pg_freespacemap_pages('orders') -- PostgreSQL 18 的页级视图写法
ORDER BY blkno
LIMIT 10
) t;若当前版本不提供 pg_freespacemap_pages(该前缀视图是较新版本引入的),使用经典函数式接口:
-- 经典接口:pg_freespace() 逐页查询(适合小表或指定页范围)
SELECT
blkno AS 页号,
pg_freespace('orders', blkno) AS FSM记录的可用字节
FROM generate_series(0, 9) AS blkno; 页号 | FSM记录的可用字节
------+-------------------
0 | 1080
1 | 1536
2 | 3120
3 | 1080
4 | 8168
5 | 1536
6 | 3120
7 | 1080
8 | 6032
9 | 3120
(10 rows)注意这些值都是整齐的档位值(1080、1536、3120、6032),而不是任意字节数 —— 这就是档位编码的直接体现。
pg_freespace 与真实空闲空间的差异
pg_freespace() 返回的是 FSM 中记录的值,不是页内真实空闲字节。两者可能不一致,因为:
- FSM 本身是近似的(档位编码)
- FSM 的更新有滞后(
INSERT会更新 FSM,但细节见下文) - FSM 的精度受
max_fsm_pages(旧版本参数,已移除)等历史因素影响
要拿真实值,得用 pageinspect 的 page_header() 里的 pd_lower / pd_upper。
INSERT 如何使用 FSM
INSERT / UPDATE 需要放置新元组
│
▼
计算元组所需字节数(含页头、行指针对齐开销)
│
▼
查询该表的 FSM 树
│
├─ 找到「档位 ≥ 所需字节」的最左页(最靠前的候选页)
│
├─ 命中 → 锁定该页 → 确认空间足够 → 写入 ✔
│ (若实际不够,继续在 FSM 里找下一页)
│
└─ 未命中(全表都满)→ 扩展表:在文件末尾新建一页 ✔
│
▼
写入后,视情况更新 FSM 中该页的档位关于「写入后是否更新 FSM」,规则是保守的:
| 操作 | FSM 是否更新 | 原因 |
|---|---|---|
INSERT 后页仍有 ≥ 1/5 以上空闲 | 更新(降档) | 页仍可用,但要反映空间变少 |
INSERT 后页几乎写满 | 更新为 0 或很小档位 | 避免后续再来找它 |
DELETE 释放空间 | 不立即更新 | 需等 VACUUM 才回收并刷新 FSM |
UPDATE 产生新版本 | 视新元组落在哪页而定 | 同页(HOT 更新)则更新该页档位 |
VACUUM 清理死元组 | 更新(升档) | 这是 FSM 回收空间的主渠道 |
这是理解 FSM 的关键:DELETE 之后空间不会立刻被 FSM 认可,必须 VACUUM。这解释了为什么"删了数据但表不缩小、插入也不复用空间"。
为什么 FSM 是近似的
这是一个值得讲透的设计决策:
| 方案 | 精确维护 FSM | 近似维护 FSM(实际选择) |
|---|---|---|
| 每次写入的 WAL 记录 | 必须记录精确字节数 | 只记录档位,日志更小 |
| 并发安全性 | 精确值并发更新需加锁,冲突多 | 档位更新可容忍竞争,允许丢失 |
| 崩溃后可恢复性 | 精确值难恢复 | 近似值本身容错 |
| 空间精度 | 完全准确,但代价高 | 误差最大约一页的 1/256 |
| 实际影响 | —— | 只是「偶尔多扩展一页」,可忽略 |
PostgreSQL 的选择是:宁可偶尔浪费一点空间,也不让 FSM 维护成为写入路径的瓶颈。因为 FSM 不准确的最坏后果只是"表比理想情况略大",而精确维护的代价是每一次写都要同步更新一个热共享结构。
相关参数
| 参数 | 默认值 | 作用域 | 说明 |
|---|---|---|---|
max_fsm_pages | —— | —— | 已在 PostgreSQL 8.4 移除,FSM 改为按需扩展,不再需要预分配 |
vacuum_truncate | on | 表级存储参数 | VACUUM 是否截断文件末尾的空页 |
autovacuum_vacuum_scale_factor | 0.2 | 实例/表级 | 触发 autovacuum 的死元组比例,间接影响 FSM 刷新频率 |
autovacuum_vacuum_threshold | 50 | 实例/表级 | 触发 autovacuum 的死元组绝对数下限 |
-- 确认 max_fsm_pages 已不存在(会返回 0 行)
SELECT name, setting FROM pg_settings WHERE name LIKE 'max_fsm%';
-- 查看与 FSM 刷新相关的 autovacuum 参数
SELECT name, setting, unit
FROM pg_settings
WHERE name IN (
'autovacuum_vacuum_scale_factor',
'autovacuum_vacuum_threshold',
'autovacuum_vacuum_insert_threshold',
'autovacuum_vacuum_insert_scale_factor'
)
ORDER BY name; name | setting | unit
---------------------------------+---------+------
autovacuum_vacuum_insert_scale_factor | 0.2 |
autovacuum_vacuum_insert_threshold | 1000 |
autovacuum_vacuum_scale_factor | 0.2 |
autovacuum_vacuum_threshold | 50 |
(4 rows)autovacuum_vacuum_insert_* 与 FSM 的关系
PostgreSQL 13+ 引入了 autovacuum_vacuum_insert_threshold 与 autovacuum_vacuum_insert_scale_factor。它们让 纯插入的表也能被 autovacuum 处理 —— 这对 FSM 有间接好处:VACUUM 会标记可见性映射(VM),同时也给了 FSM 一次刷新机会。在此之前,"只插不删"的表永远不会被 autovacuum 碰,VM 也永远不更新。
详细说明
观察 DELETE 后 FSM 是否变化
-- 建表并插入足够多的行填满若干页
DROP TABLE IF EXISTS fsm_demo;
CREATE TABLE fsm_demo (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pad TEXT NOT NULL
);
INSERT INTO fsm_demo (pad)
SELECT repeat('x', 600) FROM generate_series(1, 2000);
-- 记录删除前的 FSM 分布
SELECT
count(*) AS 有FSM记录的页数,
count(*) FILTER (WHERE pg_freespace('fsm_demo', blkno) > 2000) AS 空闲较大的页数
FROM generate_series(0, (pg_relation_size('fsm_demo') / 8192) - 1) AS blkno; 有FSM记录的页数 | 空闲较大的页数
-----------------+----------------
29 | 0插入完成后,所有页都接近写满,因此"空闲大于 2000 字节"的页数为 0。
-- 删掉一半数据
DELETE FROM fsm_demo WHERE id % 2 = 0;
-- 立刻再看 FSM(此时 VACUUM 还没跑)
SELECT
count(*) AS 有FSM记录的页数,
count(*) FILTER (WHERE pg_freespace('fsm_demo', blkno) > 2000) AS 空闲较大的页数
FROM generate_series(0, (pg_relation_size('fsm_demo') / 8192) - 1) AS blkno; 有FSM记录的页数 | 空闲较大的页数
-----------------+----------------
29 | 0删除了一半的行,但 FSM 完全没变 —— 空闲的页数仍然是 0。这直接证明了 FSM 不会因为 DELETE 而更新。
-- 执行 VACUUM,回收死元组并刷新 FSM
VACUUM fsm_demo;
-- 再观察一次
SELECT
count(*) AS 有FSM记录的页数,
count(*) FILTER (WHERE pg_freespace('fsm_demo', blkno) > 2000) AS 空闲较大的页数,
max(pg_freespace('fsm_demo', blkno)) AS 单页最大空闲字节
FROM generate_series(0, (pg_relation_size('fsm_demo') / 8192) - 1) AS blkno; 有FSM记录的页数 | 空闲较大的页数 | 单页最大空闲字节
-----------------+----------------+------------------
29 | 29 | 4104VACUUM 之后,所有页都变成了"空闲较大",新插入的行现在可以复用这些空间,而不是继续扩展文件。
验证插入复用空闲空间
-- 记录当前表的页数
SELECT pg_relation_size('fsm_demo') / 8192 AS 真空后页数;
-- 再插入 1000 行(约等于删除的量)
INSERT INTO fsm_demo (pad)
SELECT repeat('y', 600) FROM generate_series(1, 1000);
-- 页数是否增长?
SELECT
pg_relation_size('fsm_demo') / 8192 AS 插入后页数,
count(*) AS 实际行数
FROM fsm_demo; 真空后页数
------------
29
(1 row)
插入后页数 | 实际行数
------------+----------
29 | 2000页数没有增长。新插入的 1000 行完全复用了 VACUUM 释放出的空间。这就是 FSM 与 VACUUM 配合产生的实际效果 —— 表不会无限膨胀,前提是 VACUUM 跑得足够及时。
观察 FSM 文件大小
-- FSM 文件与数据文件的体积对比
SELECT
c.relname,
pg_size_pretty(pg_relation_size(c.oid)) AS 数据文件,
pg_size_pretty(pg_relation_size(c.oid, 'fsm')) AS FSM文件,
pg_relation_size(c.oid) / 8192 AS 数据页数,
pg_relation_size(c.oid, 'fsm') AS FSM字节,
round(pg_relation_size(c.oid, 'fsm')::numeric
/ nullif(pg_relation_size(c.oid), 0) * 100, 2) AS FSM占比百分比
FROM pg_class c
WHERE c.relname IN ('fsm_demo', 'orders', 'users')
ORDER BY c.relname; relname | 数据文件 | FSM文件 | 数据页数 | FSM字节 | FSM占比百分比
----------+----------+---------+----------+---------+--------------
fsm_demo | 240 kB | 8192 | 29 | 8192 | 3.33
(1 row)FSM 的体积极小:大约每页数据只对应 FSM 中 1 个字节(加上树形结构的内部节点开销)。一张 1TB 的表,FSM 大约只有几 MB,完全可以常驻内存。
实战示例
场景:诊断"删了数据但新数据不复用空间"的问题
这是一个非常典型的线上问题。业务方报告:orders_archive 表删除了 800 万行历史数据,但表体积没有下降,而且后续插入速度明显变慢。
-- ---------- 第 1 步:确认表体积与死元组情况 ----------
SELECT
relname AS 表名,
n_live_tup AS 活元组数,
n_dead_tup AS 死元组数,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS 死元组占比百分比,
pg_size_pretty(pg_relation_size(relid)) AS 数据文件,
pg_size_pretty(pg_relation_size(relid, 'fsm')) AS FSM文件,
last_vacuum,
last_autovacuum,
last_analyze
FROM pg_stat_user_tables
WHERE relname = 'orders_archive'; 表名 | 活元组数 | 死元组数 | 死元组占比百分比 | 数据文件 | FSM文件 | last_vacuum | last_autovacuum
-----------------+----------+----------+------------------+----------+---------+-------------+-----------------
orders_archive | 2000000 | 8000000 | 80.0 | 12 GB | 1.2 MB | | 2026-09-18 03:12
(1 row)诊断信号很明确:
| 指标 | 数值 | 含义 |
|---|---|---|
| 死元组 800 万 | 占总量的 80% | 删除的行仍占着物理空间 |
| FSM 仅 1.2 MB | 覆盖远小于数据规模 | FSM 尚未反映这 800 万行释放的空间 |
last_autovacuum 是几天前 | 未及时跟上 | autovacuum 被大表规模拖住,或触发了但只处理了一部分 |
-- ---------- 第 2 步:确认 autovacuum 为什么跟不上 ----------
SELECT name, setting, unit, short_desc
FROM pg_settings
WHERE name IN (
'autovacuum_vacuum_scale_factor',
'autovacuum_vacuum_threshold',
'autovacuum_max_workers',
'autovacuum_vacuum_cost_limit',
'autovacuum_vacuum_cost_delay'
)
ORDER BY name; name | setting | unit | short_desc
---------------------------------+---------+------+---------------------------------------------------------
autovacuum_max_workers | 3 | | Sets the maximum number of simultaneously running autovacuum
| | | worker processes.
autovacuum_vacuum_cost_delay | 2 | ms | Sets the cost-based vacuum delay time.
autovacuum_vacuum_cost_limit | 200 | | Sets the cost limit for vacuuming in autovacuum.
autovacuum_vacuum_scale_factor | 0.2 | | Sets the fraction of table size to add to autovacuum_vacuum_
| | | threshold when deciding whether to trigger a VACUUM.
autovacuum_vacuum_scale_factor | 0.2 | | ...
autovacuum_vacuum_threshold | 50 | | Sets the minimum number of tuple updates or deletions
| | | required to trigger a VACUUM.
(5 rows)问题定位:scale_factor = 0.2 表示"死元组达到表规模的 20% 才触发"。表有 1000 万行时,需要 200 万死元组才会触发一次 autovacuum;而每次 autovacuum 又因 cost_limit = 200 的限制跑得很慢,清理完一部分就退出,需要反复触发才能清完。
-- ---------- 第 3 步:修正方案 A —— 为该表单独提高 autovacuum 优先级 ----------
ALTER TABLE orders_archive SET (
autovacuum_vacuum_scale_factor = 0.02, -- 死元组达 2% 即触发
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_cost_limit = 2000, -- 提高单轮成本上限,跑得更快
autovacuum_vacuum_cost_delay = 0 -- SSD 上可适当激进
);
-- ---------- 第 4 步:立即手工 VACUUM 一次,让 FSM 刷新 ----------
VACUUM (VERBOSE, ANALYZE) orders_archive;INFO: vacuuming "public.orders_archive"
INFO: launched 2 parallel vacuum workers
INFO: "orders_archive": found 8000000 removable, 2000000 nonremovable row versions in 1572864 pages
INFO: "orders_archive": truncated 0 to 1572864 pages
DETAIL: 0 dead row versions cannot be removed yet.
INFO: analyzing "public.orders_archive"
INFO: "orders_archive": scanned 30000 of 1572864 pages, containing 2000000 live rows and 0 dead rows; 30000 rows in sample-- ---------- 第 5 步:验证 FSM 已刷新、空间可复用 ----------
SELECT
pg_size_pretty(pg_relation_size(relid)) AS 数据文件,
pg_size_pretty(pg_relation_size(relid, 'fsm')) AS FSM文件,
n_live_tup,
n_dead_tup,
last_vacuum
FROM pg_stat_user_tables
WHERE relname = 'orders_archive'; 数据文件 | FSM文件 | n_live_tup | n_dead_tup | last_vacuum
----------+---------+------------+------------+---------------
12 GB | 12 MB | 2000000 | 0 | 2026-09-22 10:30
(1 row)FSM 从 1.2 MB 涨到 12 MB,扩大了 10 倍 —— 这正说明 FSM 现在真实记录了被释放的空间。虽然数据文件大小仍是 12 GB(VACUUM 默认不缩文件),但后续的插入会复用这 10 GB 的空洞,文件不再增长。
若确实需要把文件也缩回,用 VACUUM FULL 或 pg_repack:
-- 可选:彻底回收磁盘(会持有排他锁并重写整表,务必在维护窗口执行)
-- 生产环境更推荐 pg_repack,可在线完成
VACUUM FULL orders_archive;
SELECT pg_size_pretty(pg_total_relation_size('orders_archive')) AS 整理后总大小; 整理后总大小
--------------
2600 MB注意事项
DELETE不会更新 FSM,必须等VACUUM。这是"删了不缩、也不复用"的根本原因。pg_freespace()返回的是 FSM 记录值,不是页内真实空闲。前者受档位编码影响,后者要用pageinspect。- FSM 文件缺失或为 0 字节不会报错,但会导致
INSERT只能向文件末尾追加,性能与空间利用率双降。 max_fsm_pages早已移除,网上大量旧文章仍在教这个参数,不要照搬到现代版本。
-- 不推荐:试图调大 FSM 容量(该参数在现代版本不存在)
-- max_fsm_pages = 200000 -- 不推荐 / 无效:PostgreSQL 8.4 起已移除- 不要手工删除
_fsm文件,会导致表结构不一致。
# 不推荐:绕过数据库清理 FSM 文件
# rm -f $PGDATA/base/16384/16385_fsm # 不推荐:可能引发插入异常或性能骤降VACUUM FULL会重写整表并持有ACCESS EXCLUSIVE锁,大表不推荐;VACUUM(不 FULL)不缩文件但刷新 FSM,两者作用不同。- FSM 不参与 MVCC,它记录的是物理空闲,而不是"对当前快照可见的空间"。因此死元组占的空间在
VACUUM前不会被 FSM 认可。 - 索引也有 FSM(
_fsm文件),索引页的复用逻辑同表页。
最佳实践
- 让 autovacuum 跟得上,这是保护 FSM 有效性的前提;对高频改删的大表单独设置更激进的 autovacuum 参数。
autovacuum_vacuum_scale_factor对大表要下调(如 0.02~0.05),默认 0.2 对大表意味着极长的清理周期。- 批量
DELETE后立即手工VACUUM,不要等 autovacuum;这是批量操作后的标准收尾动作。 - 定期监控
_fsm文件体积,若远小于预期(相对于数据量),通常说明VACUUM没有有效覆盖。 - 磁盘空间规划时把 FSM 计入(约占数据量的 1% 以内),虽然很小但不能为 0。
- 不要把
VACUUM与VACUUM FULL混为一谈:前者刷新 FSM、复用空间;后者重写文件、真正缩容。 - 批量清理优先用
TRUNCATE或分区DETACH,它们直接释放文件,无死元组产生,比DELETE + VACUUM高效得多。 - 用
pg_stat_user_tables的n_dead_tup做预警,而不是等磁盘告警。 VACUUM FULL一律安排维护窗口,或改用pg_repack实现在线整理。- 理解 FSM 的近似性,不要把"表比理论值大一点"当成问题去追查。
下一节
继续学习:可见性映射(VM)