Skip to content

空闲空间映射(FSM) ​

UPDATE 和 DELETE 会在页里留下"空洞",但 PostgreSQL 并不会因此立刻整理页面。新插入的行如何知道"哪一页还有空间"?答案就是空闲空间映射(FSM)。本节讲解 FSM 的结构、它与 INSERT 的关系,以及为什么它"故意不精确"。

前置知识

阅读本节前,建议先了解:TOAST 机制(大对象存储)

基础概念 ​

空闲空间映射(FSM, Free Space Map) 是每张表(以及每张索引)旁边的一个辅助文件 _fsm,用来记录每一页大约还剩多少可用空间。

如果只靠扫描数据页来寻找空闲空间,每次 INSERT 都要读很多页,代价无法接受。FSM 的作用就是把这个问题从「扫描」变成「查表」:

text
没有 FSM 的极端情况:
  INSERT 一行
    → 需要找到一个能放下这行的页
    → 从第 0 页开始逐页读,累加到找到为止
    → 表有 100 万页时,这个操作完全不可行

有 FSM:
  INSERT 一行
    → 查 FSM(一个极小的树形结构)
    → 直接得到「第 12345 页还有 3000 字节」
    → 只读那一页,命中即用
术语英文说明
空闲空间映射FSM, Free Space Map_fsm 文件,记录每页的剩余空间
页Page / Block8KB 的读写单位
页内空闲空间Free Space页头与行指针之外、可供新元组使用的空间
FSM 树FSM Tree多层的树形结构,叶子记录单页,上层记录子树最大值
分类Category每个叶子字节记录一个 0~255 的档位值
FSM 页FSM PageFSM 自身也按 8KB 页组织

FSM 的核心设计取舍是:它不精确,只记录"档位"。这看起来像是偷懒,实际上是一个深思熟虑的决定 —— 见下文「为什么 FSM 是近似的」。

核心机制 ​

FSM 的树形结构与档位编码 ​

FSM 用一棵树来压缩存储。树的最底层叶子对应数据页,上层节点记录其子树中的最大值:

text
              根节点(全表的档位最大值)
                    │
        ┌───────────┴───────────┐
     内部节点                 内部节点
        │                        │
   ┌────┴────┐              ┌────┴────┐
  叶子      叶子            叶子      叶子
    │         │               │         │
  数据页0   数据页1        数据页2   数据页3
  (档位)    (档位)         (档位)    (档位)

每个叶子只占 1 字节,记录一个 0~255 的「档位」
上层节点取子树最大值,因此从根一路向下就能快速定位到「有空闲的页」

档位到字节数的映射是分段非线性的。设计理由很直接:小空间必须精细区分(一行小记录能否放下,差几十字节就是能不能的问题),大空间则不需要那么精确:

档位(Category)区间对应空闲字节数精度说明
00——完全无可用空间
1 ~ 31约 32 ~ 1000 字节32 字节小数空间精细刻度,覆盖绝大多数小行
32 及以后约 1KB ~ 8164 字节逐渐变粗(数十至上百字节)大空间只需粗略定位候选页
最大档位约 8164 字节——接近整页可用空间上限

也就是说:小于 1KB 的空间误差不超过 32 字节,大于 1KB 的误差逐渐变大但对选页决策已足够。选页只需找到一个"大概装得下"的页,真正写入时还会再确认一次。

sql
-- 用扩展查看 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(该前缀视图是较新版本引入的),使用经典函数式接口:

sql
-- 经典接口:pg_freespace() 逐页查询(适合小表或指定页范围)
SELECT
  blkno AS 页号,
  pg_freespace('orders', blkno) AS FSM记录的可用字节
FROM generate_series(0, 9) AS blkno;
text
 页号 | 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 中记录的值,不是页内真实空闲字节。两者可能不一致,因为:

  1. FSM 本身是近似的(档位编码)
  2. FSM 的更新有滞后(INSERT 会更新 FSM,但细节见下文)
  3. FSM 的精度受 max_fsm_pages(旧版本参数,已移除)等历史因素影响

要拿真实值,得用 pageinspect 的 page_header() 里的 pd_lower / pd_upper。

INSERT 如何使用 FSM ​

text
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_truncateon表级存储参数VACUUM 是否截断文件末尾的空页
autovacuum_vacuum_scale_factor0.2实例/表级触发 autovacuum 的死元组比例,间接影响 FSM 刷新频率
autovacuum_vacuum_threshold50实例/表级触发 autovacuum 的死元组绝对数下限
sql
-- 确认 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;
text
              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 是否变化 ​

sql
-- 建表并插入足够多的行填满若干页
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;
text
 有FSM记录的页数 | 空闲较大的页数
-----------------+----------------
              29 |              0

插入完成后,所有页都接近写满,因此"空闲大于 2000 字节"的页数为 0。

sql
-- 删掉一半数据
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;
text
 有FSM记录的页数 | 空闲较大的页数
-----------------+----------------
              29 |              0

删除了一半的行,但 FSM 完全没变 —— 空闲的页数仍然是 0。这直接证明了 FSM 不会因为 DELETE 而更新。

sql
-- 执行 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;
text
 有FSM记录的页数 | 空闲较大的页数 | 单页最大空闲字节
-----------------+----------------+------------------
              29 |             29 |             4104

VACUUM 之后,所有页都变成了"空闲较大",新插入的行现在可以复用这些空间,而不是继续扩展文件。

验证插入复用空闲空间 ​

sql
-- 记录当前表的页数
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;
text
 真空后页数
------------
         29
(1 row)

 插入后页数 | 实际行数
------------+----------
         29 |     2000

页数没有增长。新插入的 1000 行完全复用了 VACUUM 释放出的空间。这就是 FSM 与 VACUUM 配合产生的实际效果 —— 表不会无限膨胀,前提是 VACUUM 跑得足够及时。

观察 FSM 文件大小 ​

sql
-- 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;
text
 relname  | 数据文件 | FSM文件 | 数据页数 | FSM字节 | FSM占比百分比
----------+----------+---------+----------+---------+--------------
 fsm_demo | 240 kB   | 8192    |       29 |    8192 |        3.33
(1 row)

FSM 的体积极小:大约每页数据只对应 FSM 中 1 个字节(加上树形结构的内部节点开销)。一张 1TB 的表,FSM 大约只有几 MB,完全可以常驻内存。

实战示例 ​

场景:诊断"删了数据但新数据不复用空间"的问题 ​

这是一个非常典型的线上问题。业务方报告:orders_archive 表删除了 800 万行历史数据,但表体积没有下降,而且后续插入速度明显变慢。

sql
-- ---------- 第 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';
text
    表名        | 活元组数 | 死元组数 | 死元组占比百分比 | 数据文件 | 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 被大表规模拖住,或触发了但只处理了一部分
sql
-- ---------- 第 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;
text
              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 的限制跑得很慢,清理完一部分就退出,需要反复触发才能清完。

sql
-- ---------- 第 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;
text
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
sql
-- ---------- 第 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';
text
 数据文件 | 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:

sql
-- 可选:彻底回收磁盘(会持有排他锁并重写整表,务必在维护窗口执行)
-- 生产环境更推荐 pg_repack,可在线完成
VACUUM FULL orders_archive;

SELECT pg_size_pretty(pg_total_relation_size('orders_archive')) AS 整理后总大小;
text
 整理后总大小
--------------
 2600 MB

注意事项 ​

  • DELETE 不会更新 FSM,必须等 VACUUM。这是"删了不缩、也不复用"的根本原因。
  • pg_freespace() 返回的是 FSM 记录值,不是页内真实空闲。前者受档位编码影响,后者要用 pageinspect。
  • FSM 文件缺失或为 0 字节不会报错,但会导致 INSERT 只能向文件末尾追加,性能与空间利用率双降。
  • max_fsm_pages 早已移除,网上大量旧文章仍在教这个参数,不要照搬到现代版本。
sql
-- 不推荐:试图调大 FSM 容量(该参数在现代版本不存在)
-- max_fsm_pages = 200000     -- 不推荐 / 无效:PostgreSQL 8.4 起已移除
  • 不要手工删除 _fsm 文件,会导致表结构不一致。
bash
# 不推荐:绕过数据库清理 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)

参考链接 ​