表与索引膨胀检查
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 类操作重写表)。
一个 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_upd | HOT 更新次数 | HOT 比例低 → 索引压力大 |
用一条查询把「死元组占比 + 清理时间」拉出来,就是最快速的膨胀风险扫描:
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; 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 自带的扩展,提供对表与索引物理布局的精确测量。安装与使用:
-- 安装(数据库级,一次即可)
CREATE EXTENSION pgstattuple;-- 精确测量一张表(需要全表扫描,会产生读 I/O)
SELECT * FROM pgstattuple('public.orders'); 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% 就值得处理。
-- 精确测量一个 B-tree 索引
SELECT * FROM pgstatindex('public.orders_pkey'); 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:已标记删除待复用的页数。
-- 近似测量(PG14+,依赖可见性映射,速度快得多,代价是不扫描死元组所在页的细节)
SELECT * FROM pgstattuple_approx('public.orders'); 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)是生产环境最常用的在线重整工具。它的核心思路是:用触发器捕获重整期间的增量变更,在后台把表数据重新组织到新的文件组里,最后用一个极短的排他锁窗口完成切换换名——除最后一步的毫秒级锁外,全程业务可读写。
使用前提与基本用法:
# 目标库安装扩展(要求超级用户,或按官方说明授权)
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运行期间它做了什么(理解原理有助于评估风险):
阶段 锁级别 说明
─────────────────────────────────────────────────────────────────
1. 加 ACCESS EXCLUSIVE(瞬间) 建日志表与触发器,随即释放
2. 建触发器捕获增量 业务正常读写
3. 全量拷贝旧表数据到新表 业务正常读写(消耗 I/O)
4. 应用日志表里的增量变更 业务正常读写
5. 短暂 ACCESS EXCLUSIVE 锁 切换表名(毫秒~秒级,视增量大小)
6. 删除旧表、清理对象注意两个约束:目标表必须有主键(或可用的唯一约束,用于增量捕获与切换),重整期间会额外消耗约一倍表空间与可观的 WAL 量。
VACUUM (FULL) 与 CLUSTER 的对比
VACUUM (FULL) 与 CLUSTER 都属于「离线重写」手段:把表数据紧凑地重写一遍,之后文件大小与真实数据匹配、空间完全归还。它们的代价是全程持有 ACCESS EXCLUSIVE 锁(阻塞一切读写)。
| 维度 | VACUUM (FULL) | CLUSTER | pg_repack |
|---|---|---|---|
| 锁 | ACCESS EXCLUSIVE,全程 | ACCESS EXCLUSIVE,全程 | 仅末尾瞬间排他锁 |
| 期间业务 | 完全停 | 完全停 | 可读写(有触发器开销) |
| 空间归还 | 完全归还 | 完全归还 | 完全归还 |
| 额外磁盘 | 需要约一倍表空间副本 | 同左 | 同左 + 增量日志表 |
| 物理排序 | 不保证 | 按索引聚簇(提升范围查询局部性) | 按原表顺序(可选指定按索引排序) |
| 依赖 | 内置 | 内置 | 扩展 + 外部工具 |
| 适用 | 停机窗口内的小表、维护窗口 | 停机窗口 + 需要物理聚簇 | 生产在线场景首选 |
两者详细的行为对比与语法,参见 VACUUM FULL 与空间归还 与 表膨胀治理。
膨胀率的估算 SQL(基于统计的近似模板)
当表太多、pgstattuple 逐个测量成本太高时,可以先用「统计信息估算」的模板做全局扫描。下面是巡检中常用的版本,思路是:用 n_dead_tup、n_live_tup 与平均元组长度估算「死空间」,与表实际大小对比得出近似膨胀率:
-- 估算各表近似膨胀大小(依赖统计信息,结果为粗估值,仅用于排序优先级)
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; 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 能解决)与空闲空间占比(历史包袱,只能重整解决)。
索引膨胀的机理与特点
索引膨胀比表膨胀更隐蔽,原因有三:
- 每次非 HOT 更新都会在所有索引中插入新索引项,旧索引项要等页清理或 vacuum 后才能删除。HOT 更新比例(
n_tup_hot_upd / n_tup_upd)低的表,索引压力天然大。 - 索引页删项后并不合并:B-tree 页面中的死项空间可复用,但页面不会变「紧凑」,长期更新后
avg_leaf_density显著下降。 - 索引没有 pg_stat 的死元组计数,只有
pg_stat_user_indexes.idx_scan这类使用统计,膨胀必须用pgstatindex或 pg_repack 的索引模式去测。
判断索引是否值得重建的经验阈值(供参考,非官方数字):avg_leaf_density < 60%~70% 或 deleted_pages 占比高,且该索引扫描频繁(重建有收益);扫描次数为 0 的索引(见巡检一节的未使用索引)应先考虑删除而不是重建。
autovacuum 为什么会跟不上
autovacuum 的触发条件由每表的复本阈值控制(详见 autovacuum 调优):
触发清理的变更行数 = 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 参数:
-- 对高频更新的大表收紧触发阈值、提高并行预算
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 链路)使用——更新后的新版本直接写进同一页,索引不用动:
-- 查看当前 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'); relname | relkind | reloptions
------------+---------+-------------------
orders | r | {fillfactor=80}
orders_pkey| i | {fillfactor=90}调优逻辑是一笔交换账:fillfactor 调低 → 页内预留更多 → HOT 更新比例上升 → 索引膨胀与写放大下降;代价是表占用空间的「标称值」变大、纯追加写的表白费空间。因此它只适合更新密集的表:
-- 高频更新表:表 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 膨胀会持续累积。
-- 找出主表与 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%,查询团队反馈按时间范围的统计查询越来越慢。目标:在线把表和索引重整一遍。
第一步:确认膨胀与嫌疑索引。
-- 精确确认表膨胀(表 18GB,可接受精确扫描一次)
SELECT * FROM pgstattuple('public.user_events'); 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 无能为力,必须重整。
-- 看看各索引的稀疏程度
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; 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.5idx_events_user_time 密度只有 58.9%,索引膨胀同样明显。
第二步:评估重整成本。
-- 表大小(含索引/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; total | heap | indexes
-------+-------+---------
18 GB | 12 GB | 6.0 GB
(1 row)磁盘需要预留约 18~20 GB 空闲;同时评估重整期间约 12~18 GB 的额外 WAL 对主备延迟的影响,选择业务低峰窗口执行。
第三步:执行 pg_repack。
# 干跑检查连接与前置条件(不实际执行)
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=60INFO: repacking table "public.user_events"
INFO: repacking table "public.user_events" and its indexes第四步:验证效果。
SELECT * FROM pgstattuple_approx('public.user_events'); approx_free_percent | approx_tuple_percent
---------------------+----------------------
2.9 | 96.4SELECT 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; 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:
ANALYZE public.user_events;示例二:只重整索引
表本身健康、只有索引碎裂的场景,用 --only-indexes 代价最小:
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 承受几分钟只读:
# 停机窗口内执行
psql -d appdb -c "VACUUM (FULL, VERBOSE) public.sessions;"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%,索引持续膨胀。诊断与处置:
-- 写入模式画像: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'; n_tup_upd | n_tup_hot_upd | hot_pct
-----------+---------------+---------
82133990 | 32853596 | 40.0-- 表页预留 20% 空间给原位更新,主键索引预留 15%
ALTER TABLE public.stock SET (fillfactor = 80);
ALTER INDEX public.stock_pkey SET (fillfactor = 85);# 存量页重排:在线重整让 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)的实测。
最佳实践
- 把「表膨胀 Top N」与「索引密度抽样」纳入每周巡检。 用统计估算 SQL 全量扫描(秒级),对 Top 10 嫌疑表跑
pgstattuple_approx,形成趋势记录而非只看单次值。 - 为每个库安装
pgstattuple扩展。 它是膨胀测量的标准工具,_approx版本让大表测量成本可控。 - 按表分级设置 autovacuum scale factor。 大表 0.01~0.05、中表 0.05~0.1、小表保持默认;配合
last_autovacuum检查是否真的在跑,详细参数见 autovacuum 调优。 - 优先消灭「清理欠账」,再谈「历史包袱」。 死元组堆积是过程问题(调参、治理长事务),free space 空洞是结果问题(重整),两者手段完全不同。
- 在线重整首选 pg_repack,离线窗口才用 VACUUM (FULL) / CLUSTER。 两者的锁语义差异决定了适用边界,对比见本页「语法与用法」。
- 重整前做三查:主键存在、磁盘余量 ≥ 一倍表体积、备库与归档有 WAL 消化能力。 缺一即推迟窗口。
- 重整避开业务高峰并控制并发度。
-j并行可以加快多表任务,但单张大表建议串行,并加--wait-timeout防止切换锁久等不决。 - 重整完成后立即 ANALYZE。 让计划器拿到新的统计与物理相关性,收益才完整落地。
- 对「只删不插」型表建立例行重整或改用 TRUNCATE 语义。 每日全删重建的中间表改为
TRUNCATE(或定期VACUUM (FULL)),从模式上消除膨胀来源。 - 把 HOT 更新比例纳入健康度指标。
n_tup_hot_upd / n_tup_upd长期偏低说明更新都在污染索引,从索引设计上改进(减少更新型索引、增大 fillfactor)。 - 建立膨胀趋势档案。 每周记录核心表的 size / tuple_percent / leaf_density,重整决策看趋势斜率而不是绝对值,避免过度运维。
下一节
继续学习:数据库巡检
参考链接
- PostgreSQL 官方文档:pgstattuple 扩展
- PostgreSQL 官方文档:pg_stat_user_tables 视图
- pg_repack 官方仓库(GitHub reorg/pg_repack)
- pg_repack 官方文档(README / 使用说明)
- PostgreSQL 官方文档:VACUUM
- PostgreSQL 官方文档:CLUSTER
- PostgreSQL 官方文档:Routine Vacuuming(膨胀与 MVCC)
- PostgreSQL 官方文档:REINDEX
- PostgreSQL 官方文档:Visibility Map(pgstattuple_approx 依赖)
- PostgreSQL 官方文档:pg_stat_user_indexes 视图