Skip to content

表与索引膨胀检查 ​

PostgreSQL 的 MVCC 机制决定了「UPDATE = 新版本 + 旧版本标记删除」。旧版本的元组(tuple)在事务可见性上已经死亡,但它们占用的物理空间不会立刻归还操作系统——要等到 VACUUM 把死元组清理掉、且清理发生的位置恰好能让页面空间被复用,空间才算「回来了」。如果 DELETE/UPDATE 频繁而清理跟不上,表与索引文件就会越长越大,实际数据却没变多——这就是膨胀(bloat)。

膨胀是「慢性病」:它不会像死锁那样立刻让业务报错,而是悄悄拖慢每一次顺序扫描、每一次索引探测、每一次备份与恢复,直到某个集中写高峰让磁盘水位或查询延迟越线。因此膨胀检查必须成为日常运维的固定项目——不仅要定期测量,还要理解测量结果背后「死元组、空闲空间、索引稀疏度」三者的关系,并掌握在线重整(pg_repack)与离线重整(VACUUM FULL / CLUSTER)两套手段的适用边界。

本页承接上一节的连接管理,是「日常运维」的第二块拼图。我们将先从 MVCC 与死元组讲起,看懂 pg_stat_user_tables 中 n_dead_tup 等统计列的含义与局限;然后引入 pgstattuple 扩展获得精确到页的膨胀测量能力;再介绍生产上最常用的在线重整工具 pg_repack;随后把 VACUUM (FULL) 与 CLUSTER 放在同一个坐标系里比较;最后给出可用于巡检的膨胀率估算 SQL 模板与一套完整的「发现 → 评估 → 在线重整 → 预防」实战。

前置知识

建议先阅读:连接管理

基础概念 ​

死元组是怎么产生的 ​

一次 UPDATE 在 PostgreSQL 内部并不是「原地修改」,而是写入一条新元组、把旧元组标记为「已死」(max \cmax 超过当前快照可见性后即视为死元组);一次 DELETE 则直接把命中的元组标记为死。这些死元组:

  • 对新的快照不可见,普通查询不会再读到它们;
  • 仍占用表页面(page)内的物理空间,直到 VACUUM 处理;
  • VACUUM 清理后,空间成为页面内的空闲空间,可被后续 INSERT/UPDATE 复用,但不会归还给操作系统(表文件不缩短,除非 VACUUM FULL 类操作重写表)。
text
 一个 8KB 页面在 UPDATE 前后的样子:

 UPDATE 前                       VACUUM 前                     VACUUM 后
┌──────────────────┐          ┌──────────────────┐          ┌──────────────────┐
│ 元组1 (live)     │          │ 元组1 (dead)     │          │ (空闲空间)       │
│ 元组2 (live)     │          │ 元组2 (live)     │          │ 元组2 (live)     │
│ 元组3 (live)     │          │ 元组3 (live)     │          │ 元组3 (live)     │
│ ...              │          │ ...              │          │ ...              │
└──────────────────┘          │ 元组1' (live 新版)│          │ 元组1'(可能搬移)  │
                              └──────────────────┘          └──────────────────┘
                              死元组占着空间                 空间可复用但文件不缩

理解了这条链路,就能解释膨胀的三种典型来源:

来源机制典型表现
清理跟不上产生autovacuum 频率/预算不足,死元组持续累积n_dead_tup 常年高位
清理了但文件不缩VACUUM 只回收页内空间,文件大小不变n_dead_tup 为 0 但表文件巨大、free space 占比高
索引连带膨胀HOT 之外每次 UPDATE 都在所有索引里插入新索引项,旧项待清理;索引页删项后有空洞表不胀但索引胀,avg_leaf_density 低

pg_stat_user_tables:零成本的第一道望远镜 ​

pg_stat_user_tables 是统计收集器(stats collector / PG15+ 起的共享内存统计)维护的每表计数器,查询它不产生任何 I/O 扫描,是巡检的第一站:

列含义膨胀分析用途
n_live_tup估计的活元组数与 pg_class.reltuples 互证
n_dead_tup估计的死元组数膨胀风险的直接信号
n_mod_since_analyze上次 ANALYZE 以来修改的行数判断统计信息是否过期
n_ins_since_vacuum上次 VACUUM 以来插入的行数(PG14+)插入型表的清理评估
last_vacuum / last_autovacuum最近手工/自动清理时间判断 autovacuum 是否正常工作
last_analyze / last_autoanalyze最近统计时间同上
vacuum_count / autovacuum_count累计清理次数长期趋势
n_tup_ins / n_tup_upd / n_tup_del累计插入/更新/删除行数写入模式画像
n_tup_hot_updHOT 更新次数HOT 比例低 → 索引压力大

用一条查询把「死元组占比 + 清理时间」拉出来,就是最快速的膨胀风险扫描:

sql
SELECT relname,
       n_live_tup,
       n_dead_tup,
       round(n_dead_tup * 100.0 / greatest(n_live_tup + n_dead_tup, 1), 1) AS dead_pct,
       last_autovacuum,
       last_vacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
text
   relname    | n_live_tup | n_dead_tup | dead_pct |      last_autovacuum      | last_vacuum
--------------+------------+------------+----------+---------------------------+-------------
 orders       |    8213390 |     917622 |     10.1 | 2026-09-30 02:13:44+08    |
 order_items  |   24511902 |    1802331 |      6.9 | 2026-09-30 03:07:02+08    |
 user_events  |   58204110 |   12473309 |     17.6 | 2026-09-29 23:55:31+08    |
(3 rows)

但要注意它的两个局限:其一,n_live_tup / n_dead_tup 是估计值(依赖上次 VACUUM/ANALYZE 的采样与增量修正),不是精确值;其二,它只能反映「行级」的死元组,无法反映「索引页级」的稀疏与碎片。因此它适合当雷达,不适合当判决书——判决要靠 pgstattuple。

语法与用法 ​

pgstattuple 扩展 ​

pgstattuple 是 contrib 自带的扩展,提供对表与索引物理布局的精确测量。安装与使用:

sql
-- 安装(数据库级,一次即可)
CREATE EXTENSION pgstattuple;
sql
-- 精确测量一张表(需要全表扫描,会产生读 I/O)
SELECT * FROM pgstattuple('public.orders');
text
 table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space | free_percent
-----------+-------------+-----------+---------------+------------------+----------------+--------------------+------------+--------------
 1129525248|     8213390 | 852286336 |          75.5 |           417562 |       30266522 |                2.7 |  243182448 |         21.5
(1 row)

解读这份「体检报告」:

  • table_len:表文件总字节数(含 TOAST 不含索引)。
  • tuple_len / tuple_percent:活元组实际占用的空间与占比——这是「真数据」。
  • dead_tuple_len / dead_tuple_percent:尚未被 VACUUM 清理的死元组占用。它高说明清理欠账。
  • free_space / free_percent:页内空闲空间。它高但 dead_tuple_percent 低,说明「清理已经做过,但空间没有被数据重新填满」——文件不会缩小,这部分就是「已回收但未归还」的膨胀。

一个常用的判断式:膨胀体积 ≈ free_space + dead_tuple_len 中「本不该有」的部分。对于追加写(append-only)的表,free_percent 高是正常的(留给未来插入);对于更新型表,free_percent 长期高于 20%~30% 就值得处理。

sql
-- 精确测量一个 B-tree 索引
SELECT * FROM pgstatindex('public.orders_pkey');
text
     version      | 4
     tree_level   | 2
     index_size   | 228720640
     root_block_no | 241
 internal_pages   | 190
     leaf_pages   | 27197
    empty_pages   | 0
   deleted_pages  | 2123
avg_leaf_density  | 61.7
leaf_fragmentation| 34.2

索引侧的关键指标:

  • avg_leaf_density:叶子页的平均密度。全新索引约 90% 以上;低于 70% 意味着明显的索引膨胀。
  • leaf_fragmentation:叶子页之间的物理乱序程度,过高影响大范围索引扫描的性能。
  • deleted_pages:已标记删除待复用的页数。
sql
-- 近似测量(PG14+,依赖可见性映射,速度快得多,代价是不扫描死元组所在页的细节)
SELECT * FROM pgstattuple_approx('public.orders');
text
 table_len | scanned_percent | approx_tuple_count | approx_tuple_len | approx_tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | approx_free_space | approx_free_percent
-----------+-----------------+--------------------+------------------+----------------------+------------------+----------------+--------------------+-------------------+---------------------
 1129525248|             8.9 |            8201210 |        849921376 |                 75.2 |             9733 |         1517096|                0.1 |         271279501 |               24.0
(1 row)

pgstattuple_approx 只扫描 VM(visibility map)中「可能包含死元组」的页面,对 TB 级表也能在分钟级出结果,是巡检批量测量的首选;精确版留给对目标表的最终确认。

pg_repack:在线重表 ​

pg_repack(GitHub:reorg/pg_repack)是生产环境最常用的在线重整工具。它的核心思路是:用触发器捕获重整期间的增量变更,在后台把表数据重新组织到新的文件组里,最后用一个极短的排他锁窗口完成切换换名——除最后一步的毫秒级锁外,全程业务可读写。

使用前提与基本用法:

bash
# 目标库安装扩展(要求超级用户,或按官方说明授权)
psql -d appdb -c "CREATE EXTENSION pg_repack;"

# 在线重整一张表(需要连接凭据,普通操作由超级用户执行)
pg_repack -h 127.0.0.1 -p 5432 -U postgres -d appdb -t public.orders

# 重整一张表及其所有索引
pg_repack -d appdb -t public.orders

# 只重建索引不动表(--only-indexes)
pg_repack -d appdb -t public.orders --only-indexes

# 并行任务数(多表/多索引并行重整)
pg_repack -d appdb -j 4

# 不杀阻塞会话:遇到无法取得的锁时让任务失败而不是 cancel 别人
pg_repack -d appdb -t public.orders --no-kill-backend

# 控制等待排他锁的超时秒数
pg_repack -d appdb -t public.orders --wait-timeout=60

运行期间它做了什么(理解原理有助于评估风险):

text
 阶段                锁级别                说明
─────────────────────────────────────────────────────────────────
 1. 加 ACCESS EXCLUSIVE(瞬间)    建日志表与触发器,随即释放
 2. 建触发器捕获增量               业务正常读写
 3. 全量拷贝旧表数据到新表          业务正常读写(消耗 I/O)
 4. 应用日志表里的增量变更          业务正常读写
 5. 短暂 ACCESS EXCLUSIVE 锁       切换表名(毫秒~秒级,视增量大小)
 6. 删除旧表、清理对象

注意两个约束:目标表必须有主键(或可用的唯一约束,用于增量捕获与切换),重整期间会额外消耗约一倍表空间与可观的 WAL 量。

VACUUM (FULL) 与 CLUSTER 的对比 ​

VACUUM (FULL) 与 CLUSTER 都属于「离线重写」手段:把表数据紧凑地重写一遍,之后文件大小与真实数据匹配、空间完全归还。它们的代价是全程持有 ACCESS EXCLUSIVE 锁(阻塞一切读写)。

维度VACUUM (FULL)CLUSTERpg_repack
锁ACCESS EXCLUSIVE,全程ACCESS EXCLUSIVE,全程仅末尾瞬间排他锁
期间业务完全停完全停可读写(有触发器开销)
空间归还完全归还完全归还完全归还
额外磁盘需要约一倍表空间副本同左同左 + 增量日志表
物理排序不保证按索引聚簇(提升范围查询局部性)按原表顺序(可选指定按索引排序)
依赖内置内置扩展 + 外部工具
适用停机窗口内的小表、维护窗口停机窗口 + 需要物理聚簇生产在线场景首选

两者详细的行为对比与语法,参见 VACUUM FULL 与空间归还 与 表膨胀治理。

膨胀率的估算 SQL(基于统计的近似模板) ​

当表太多、pgstattuple 逐个测量成本太高时,可以先用「统计信息估算」的模板做全局扫描。下面是巡检中常用的版本,思路是:用 n_dead_tup、n_live_tup 与平均元组长度估算「死空间」,与表实际大小对比得出近似膨胀率:

sql
-- 估算各表近似膨胀大小(依赖统计信息,结果为粗估值,仅用于排序优先级)
WITH stats AS (
    SELECT s.relid,
           c.relname,
           pg_total_relation_size(c.oid)                 AS total_bytes,
           s.n_live_tup,
           s.n_dead_tup,
           GREATEST(s.n_live_tup, 1)                     AS live_safe
    FROM pg_stat_user_tables s
    JOIN pg_class c ON c.oid = s.relid
)
SELECT relname,
       pg_size_pretty(total_bytes)                                    AS total_size,
       n_live_tup,
       n_dead_tup,
       round(n_dead_tup * 100.0 / (n_live_tup + n_dead_tup + 1), 1)   AS est_dead_pct,
       -- 用死元组比例粗略映射膨胀字节数(未计入 free space,偏保守)
       pg_size_pretty(total_bytes * n_dead_tup / (n_live_tup + n_dead_tup + 1)) AS est_bloat_bytes
FROM stats
ORDER BY total_bytes * n_dead_tup / (n_live_tup + n_dead_tup + 1) DESC
LIMIT 30;
text
   relname   | total_size | n_live_tup | n_dead_tup | est_dead_pct | est_bloat_bytes
-------------+------------+------------+------------+--------------+-----------------
 user_events | 18 GB      |   58204110 |   12473309 |         17.6 | 3289 MB
 order_items | 9.1 GB     |   24511902 |    1802331 |          6.9 | 620 MB
 orders      | 4.2 GB     |    8213390 |     917622 |         10.1 | 419 MB
(3 rows)

社区还有基于数据类型宽度的列级估算方法(逐列估算元组宽度再对比表大小),精度更高但维护成本也高。务实的做法是两层漏斗:先用统计估算找出 Top N 嫌疑表 → 用 pgstattuple_approx 复核 → 对确认的重整目标用 pgstattuple 精确评估。

详细说明 ​

膨胀为什么会「清不掉」:free space 与文件不收缩 ​

初学者最常见的困惑是:n_dead_tup 已经是 0、autovacuum 也跑了,为什么表文件还是那么大?答案在页面的物理结构上:VACUUM 把死元组占用的空间变成页内空闲空间(记录在 free space map 中),这些空间只能被写入这个表自己的新元组复用,而且表文件不会自动截断(除了末尾的空洞页可以被截断外,中间的空闲页会一直留在文件里)。于是:

  • 一张曾经有过 1 亿行、后来稳定在 2000 万行的表,文件会长期停留在 1 亿行的规模,直到某次 VACUUM FULL / CLUSTER / pg_repack 把它重写。
  • 「先删后插」型业务(例如每天全删重建的中间表)是文件不缩的重灾区。

所以测量膨胀要同时看两个数:未清理的死元组(清理欠账,autovacuum 能解决)与空闲空间占比(历史包袱,只能重整解决)。

索引膨胀的机理与特点 ​

索引膨胀比表膨胀更隐蔽,原因有三:

  1. 每次非 HOT 更新都会在所有索引中插入新索引项,旧索引项要等页清理或 vacuum 后才能删除。HOT 更新比例(n_tup_hot_upd / n_tup_upd)低的表,索引压力天然大。
  2. 索引页删项后并不合并:B-tree 页面中的死项空间可复用,但页面不会变「紧凑」,长期更新后 avg_leaf_density 显著下降。
  3. 索引没有 pg_stat 的死元组计数,只有 pg_stat_user_indexes.idx_scan 这类使用统计,膨胀必须用 pgstatindex 或 pg_repack 的索引模式去测。

判断索引是否值得重建的经验阈值(供参考,非官方数字):avg_leaf_density < 60%~70% 或 deleted_pages 占比高,且该索引扫描频繁(重建有收益);扫描次数为 0 的索引(见巡检一节的未使用索引)应先考虑删除而不是重建。

autovacuum 为什么会跟不上 ​

autovacuum 的触发条件由每表的复本阈值控制(详见 autovacuum 调优):

text
 触发清理的变更行数 = autovacuum_vacuum_threshold
                    + autovacuum_vacuum_scale_factor × 表行数

默认 scale_factor = 0.2 意味着 1 亿行的表要积累 2000 万死元组才触发——对高频更新表来说「永远在追」。此外还有三类常见欠账:

  • 成本预算限制:autovacuum_vacuum_cost_delay / cost_limit 让清理进程主动放慢,避免影响业务,但高峰期可能被打断。
  • 被长事务阻挡:与上一节讲的一样,长事务/空闲事务会让清理「心有余而力不足」(死元组无法越过老快照回收)。
  • 清理与膨胀的错位:即使每次都清干净,文件大小也不会回落——预防的重点是让 free space 及时被复用,而不是攒出一堆「只删不插」的空洞。

pg_repack 的内部机制再深入 ​

从运维视角看 pg_repack 有几个值得注意的细节:

  • 触发器成本:重整期间对目标表的每次 DML 都会额外写一份到日志表,写入密集的表在重整期间会有可感知的性能回退,建议避开业务高峰。
  • 切换窗口:最终换名需要 ACCESS EXCLUSIVE 锁,虽然通常毫秒级,但如果增量日志很大、重放慢,窗口会拉长。--wait-timeout 控制的是等待这把锁的时间,超时默认会 cancel 阻塞者(除非 --no-kill-backend)。
  • 磁盘与 WAL 预算:全程需要约一倍表空间的临时占用,且拷贝 + 重放产生的 WAL 量约等于表本身大小——对主备复制的 WAL 传输与归档压力要有预估。
  • 权限与连接:需要能创建扩展、触发器与临时对象;默认不允许在有 idle in transaction 会话干扰时启动(会等待)。
  • 失败安全:中途失败会留下临时对象(形如 repack.table_xxx),工具支持恢复/清理模式处理残留,重试前先清理。

什么时候选择 VACUUM (FULL) / CLUSTER ​

在线工具并非万能,以下场景离线手段反而更合适:

  • 表很小(几百 MB 以内):锁窗口短到可以忽略,VACUUM (FULL) 一条命令最省事。
  • 维护窗口天然存在:夜里有例行业务静默期,直接 FULL 重写最干净。
  • 需要物理聚簇:CLUSTER 按某个索引把物理顺序重排,对范围查询(如按时间扫)能显著提升局部性,pg_repack 也支持按索引排序但语义细节略有差异。
  • pg_repack 环境受限:无法创建扩展、或表无主键且不便改造时。

膨胀与统计信息的互相污染 ​

一个容易被忽略的恶性循环:表膨胀 → 查询计划高估(reltuples 与实际严重不符或统计过期)→ 计划劣化 → 查询变慢。反过来,统计过期也会让 autovacuum 的判断失准。所以处理膨胀的完整动作里永远包含 ANALYZE:重整完成后立即对新表做统计,必要时配合 vacuumdb --analyze-in-stages(版本升级场景常见)。

膨胀预防:autovacuum 参数的按表调优 ​

预防永远优于治疗。除了全局参数,PostgreSQL 支持对单表覆盖 autovacuum 参数:

sql
-- 对高频更新的大表收紧触发阈值、提高并行预算
ALTER TABLE public.user_events SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_analyze_scale_factor = 0.005,
    autovacuum_vacuum_cost_delay = 1
);

-- 对「只删不插」的每日重建表,干脆每天业务后手工 vacuum
-- 结合 cron 调度,见巡检一节的自动化

对写入模式各异的库,按表调参(scale factor 按表大小分级)比全局调一个值有效得多。

fillfactor:用预留空间换更新友好 ​

fillfactor 控制表(与索引)页面在写入时预留多少比例的空间不填满(表默认 100,B-tree 索引默认 90)。预留的空间专供本页元组的原位更新(HOT 链路)使用——更新后的新版本直接写进同一页,索引不用动:

sql
-- 查看当前 fillfactor
SELECT relname, relkind, reloptions
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND relname IN ('orders', 'orders_pkey');
text
  relname   | relkind |    reloptions
------------+---------+-------------------
 orders     | r       | {fillfactor=80}
 orders_pkey| i       | {fillfactor=90}

调优逻辑是一笔交换账:fillfactor 调低 → 页内预留更多 → HOT 更新比例上升 → 索引膨胀与写放大下降;代价是表占用空间的「标称值」变大、纯追加写的表白费空间。因此它只适合更新密集的表:

sql
-- 高频更新表:表 80、索引 85~90
ALTER TABLE public.orders SET (fillfactor = 80);
ALTER INDEX public.orders_pkey SET (fillfactor = 85);

-- 注意:ALTER TABLE 的 fillfactor 只对新写入的页生效,
-- 存量页要靠一次重整(pg_repack / VACUUM FULL)才会按新值重排

fillfactor 与膨胀的关系是双向的:fillfactor 设太低而表实际是追加型,free space 居高不下,会被膨胀检测误判;fillfactor 设太高(100)而表是高频更新型,页溢出分裂频繁,索引碎片化加速。把 fillfactor 决策与「表的更新模式画像」绑定,而不是套用统一模板。

TOAST 表的膨胀 ​

大字段(超页的 text、jsonb、bytea 等)被移入 TOAST 表(pg_toast.pg_toast_<oid>)单独存储。TOAST 表同样走 MVCC,同样会膨胀,而且有两个特有陷阱:

  • 主表看起来不大,TOAST 表巨大:巡检只看主表大小会漏掉它,pg_total_relation_size 已包含 TOAST,但逐项对比时要区分;
  • TOAST 的清理依赖主表 vacuum:TOAST 死元组由主表的 VACUUM 一并处理,主表若长期不被清理(例如只有大字段被频繁更新),TOAST 膨胀会持续累积。
sql
-- 找出主表与 TOAST 的大小倒挂:TOAST 占比过高的表
SELECT c.relname AS table_name,
       pg_size_pretty(pg_relation_size(c.oid))               AS heap_size,
       pg_size_pretty(pg_relation_size(reltoastrelid))       AS toast_size,
       round(pg_relation_size(reltoastrelid) * 100.0
             / greatest(pg_relation_size(c.oid), 1), 1)      AS toast_pct
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
  AND pg_relation_size(reltoastrelid) > pg_relation_size(c.oid)
ORDER BY pg_relation_size(reltoastrelid) DESC;

TOAST 膨胀的处理与主表同源:pg_repack 重整主表时会连带重整其 TOAST 表;预防则靠减少「大字段原地反复 UPDATE」的模式(改为版本化新行或外置存储)。

实战示例 ​

示例一:巡检发现一张表膨胀并在线重整 ​

背景:巡检发现 user_events 表 18 GB,n_dead_tup 占比 17.6%,pgstattuple_approx 显示 free 占比 24%,查询团队反馈按时间范围的统计查询越来越慢。目标:在线把表和索引重整一遍。

第一步:确认膨胀与嫌疑索引。

sql
-- 精确确认表膨胀(表 18GB,可接受精确扫描一次)
SELECT * FROM pgstattuple('public.user_events');
text
 table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space | free_percent
-----------+-------------+-----------+---------------+------------------+----------------+--------------------+------------+--------------
 19327352832|    58204110 |13834378312|          71.6 |          3271054 |       208849352|                1.1 | 5231128236 |         27.1
(1 row)

结论清晰:死元组只剩 1.1%(清理是跟上了),但 free space 占了 27.1%(约 5.2 GB)——典型的「历史包袱」型膨胀,autovacuum 无能为力,必须重整。

sql
-- 看看各索引的稀疏程度
SELECT c.relname AS index_name,
       i.index_size,
       round(i.avg_leaf_density::numeric, 1) AS leaf_density,
       i.leaf_fragmentation
FROM pg_index ix
JOIN pg_class c ON c.oid = ix.indexrelid
CROSS JOIN LATERAL pgstatindex(c.relname::text) i
WHERE ix.indrelid = 'public.user_events'::regclass;
text
        index_name        | index_size | leaf_density | leaf_fragmentation
--------------------------+------------+--------------+--------------------
 user_events_pkey         | 649 MB     |         74.3 |               22.1
 idx_events_user_time     | 3.4 GB     |         58.9 |               41.7
 idx_events_type          | 2.1 GB     |         66.2 |               30.5

idx_events_user_time 密度只有 58.9%,索引膨胀同样明显。

第二步:评估重整成本。

sql
-- 表大小(含索引/TOAST)确认磁盘余量是否够「一份副本 + 日志表」
SELECT pg_size_pretty(pg_total_relation_size('public.user_events')) AS total,
       pg_size_pretty(pg_relation_size('public.user_events'))       AS heap,
       pg_size_pretty(pg_indexes_size('public.user_events'))        AS indexes;
text
 total | heap  | indexes
-------+-------+---------
 18 GB | 12 GB | 6.0 GB
(1 row)

磁盘需要预留约 18~20 GB 空闲;同时评估重整期间约 12~18 GB 的额外 WAL 对主备延迟的影响,选择业务低峰窗口执行。

第三步:执行 pg_repack。

bash
# 干跑检查连接与前置条件(不实际执行)
pg_repack -h 127.0.0.1 -U postgres -d appdb -t public.user_events --dry-run

# 正式执行:串行重整该表(含全部索引)
pg_repack -h 127.0.0.1 -U postgres -d appdb -t public.user_events \
          --wait-timeout=60
text
INFO: repacking table "public.user_events"
INFO: repacking table "public.user_events" and its indexes

第四步:验证效果。

sql
SELECT * FROM pgstattuple_approx('public.user_events');
text
 approx_free_percent | approx_tuple_percent
---------------------+----------------------
                 2.9 |                 96.4
sql
SELECT c.relname, round(i.avg_leaf_density::numeric, 1) AS leaf_density
FROM pg_index ix
JOIN pg_class c ON c.oid = ix.indexrelid
CROSS JOIN LATERAL pgstatindex(c.relname::text) i
WHERE ix.indrelid = 'public.user_events'::regclass;
text
        index_name        | leaf_density
--------------------------+--------------
 user_events_pkey         |         90.1
 idx_events_user_time     |         88.7
 idx_events_type          |         89.4

表 free 从 27.1% 降到 2.9%,索引密度回到 89% 左右。表文件从 18 GB 回落到约 13 GB,随后补一次 ANALYZE:

sql
ANALYZE public.user_events;

示例二:只重整索引 ​

表本身健康、只有索引碎裂的场景,用 --only-indexes 代价最小:

bash
pg_repack -h 127.0.0.1 -U postgres -d appdb \
          -t public.orders --only-indexes

它等价于在线版 REINDEX,期间原索引仍可读,切换同样是短暂锁窗口。

示例三:小表直接 VACUUM FULL ​

一张 300 MB 的会话表膨胀明显,且业务可以在夜里 02:00~03:00 承受几分钟只读:

bash
# 停机窗口内执行
psql -d appdb -c "VACUUM (FULL, VERBOSE) public.sessions;"
text
INFO:  vacuuming "public.sessions"
INFO:  "public.sessions": found 0 removable, 4123884 nonremovable row versions in 36412 pages
DETAIL:  0 dead row versions cannot be removed yet, ... avg 252 bytes, 28% wasted
VACUUM

执行期间表上持有 ACCESS EXCLUSIVE 锁——这就是为什么它只适合有窗口的小表。

示例四:更新密集表的 fillfactor 调优 ​

一张 stock 表(库存表,每秒数百次 UPDATE 同一批行),HOT 比例只有 40%,索引持续膨胀。诊断与处置:

sql
-- 写入模式画像:HOT 更新占比
SELECT n_tup_upd,
       n_tup_hot_upd,
       round(n_tup_hot_upd * 100.0 / greatest(n_tup_upd, 1), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE relname = 'stock';
text
 n_tup_upd | n_tup_hot_upd | hot_pct
-----------+---------------+---------
  82133990 |      32853596 |    40.0
sql
-- 表页预留 20% 空间给原位更新,主键索引预留 15%
ALTER TABLE public.stock SET (fillfactor = 80);
ALTER INDEX public.stock_pkey SET (fillfactor = 85);
bash
# 存量页重排:在线重整让 fillfactor 生效
pg_repack -d appdb -t public.stock --wait-timeout=60

重整完成并运行一周后复查:HOT 比例从 40% 升至 78%(更新几乎都落在同页预留空间内),pgstatindex 显示主键密度稳定在 87% 不再持续下滑。这个例子说明:fillfactor + 重整是「预防索引膨胀」的组合拳,比事后反复重建索引有效得多。

注意事项 ​

n_dead_tup 高 ≠ 需要重整,n_dead_tup 低 ≠ 不需要重整。 前者可能只是清理欠账(调 autovacuum 即可),后者可能意味着「清干净了但文件不缩」(才需要重整)。判断依据始终是 pgstattuple 的 free space 与 dead 元组的组合,以及文件大小与活数据量的差距。

pg_repack 要求表有主键。 没有主键的表会直接报错退出。如果确实要重整无主键表,要么补主键/唯一约束,要么退回离线方案(VACUUM FULL / CLUSTER)。

重整期间 WAL 会翻倍,警惕主备延迟与归档压力。 全量拷贝 + 增量重放的 WAL 量约等于表体积。大表重整前先确认备库有追平能力、归档通道没有积压(WAL 监控见本阶段后续的 WAL 积压监控一节)。

重整会弄丢「表上的某些属性关联」吗? 不会——权限、约束、触发器、依赖关系都会被复制到新表。但会话中已打开的游标、prepared statement 引用可能因表文件切换而失效,应用需要容错重试。

VACUUM (FULL) 需要「一倍以上」的磁盘空间,且失败不自动回滚磁盘占用。 磁盘水位必须在执行前确认;FULL 失败(如磁盘满)后表保持原样但可能留下临时文件,需要人工清理。

CLUSTER 之后统计信息需要重建。 CLUSTER 会按索引重排物理顺序,执行后对同表跑 ANALYZE 让计划器感知新的相关性(correlation),否则可能拿到次优计划。

不要对分区表误判膨胀。 分区父表本身不存数据,膨胀检查要逐分区进行;pg_repack 对分区的支持有版本差异,逐个分区处理(每个分区即普通表)是最稳妥的做法。

膨胀率是估算,别用它直接指挥重整。 统计估算模板适合「排序找嫌疑」,执行决策前的最后一手信息应当来自 pgstattuple(或 _approx)的实测。

最佳实践 ​

  1. 把「表膨胀 Top N」与「索引密度抽样」纳入每周巡检。 用统计估算 SQL 全量扫描(秒级),对 Top 10 嫌疑表跑 pgstattuple_approx,形成趋势记录而非只看单次值。
  2. 为每个库安装 pgstattuple 扩展。 它是膨胀测量的标准工具,_approx 版本让大表测量成本可控。
  3. 按表分级设置 autovacuum scale factor。 大表 0.01~0.05、中表 0.05~0.1、小表保持默认;配合 last_autovacuum 检查是否真的在跑,详细参数见 autovacuum 调优。
  4. 优先消灭「清理欠账」,再谈「历史包袱」。 死元组堆积是过程问题(调参、治理长事务),free space 空洞是结果问题(重整),两者手段完全不同。
  5. 在线重整首选 pg_repack,离线窗口才用 VACUUM (FULL) / CLUSTER。 两者的锁语义差异决定了适用边界,对比见本页「语法与用法」。
  6. 重整前做三查:主键存在、磁盘余量 ≥ 一倍表体积、备库与归档有 WAL 消化能力。 缺一即推迟窗口。
  7. 重整避开业务高峰并控制并发度。 -j 并行可以加快多表任务,但单张大表建议串行,并加 --wait-timeout 防止切换锁久等不决。
  8. 重整完成后立即 ANALYZE。 让计划器拿到新的统计与物理相关性,收益才完整落地。
  9. 对「只删不插」型表建立例行重整或改用 TRUNCATE 语义。 每日全删重建的中间表改为 TRUNCATE(或定期 VACUUM (FULL)),从模式上消除膨胀来源。
  10. 把 HOT 更新比例纳入健康度指标。 n_tup_hot_upd / n_tup_upd 长期偏低说明更新都在污染索引,从索引设计上改进(减少更新型索引、增大 fillfactor)。
  11. 建立膨胀趋势档案。 每周记录核心表的 size / tuple_percent / leaf_density,重整决策看趋势斜率而不是绝对值,避免过度运维。

下一节

继续学习:数据库巡检

参考链接 ​