Siven's Corner

PostgreSQL 空间回收

最近在清理 PG 数据库的一些大表时,发现 truncate 和 delete 相关表之后,数据库所占用的磁盘空间并没有减小,于是就去查了下背后的原理。

首先查询数据库中表所占的空间,按照从大到小的顺序排列,确定要处理的表,优先排查大表。

SELECT
    relname AS table_name,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

一。DELETE 的行为特点

项目 描述 说明
操作方式 标记删除 DELETE 并不立即物理删除数据,而是打上“死元组”标记(MVCC)
空间释放 不释放磁盘空间 被删除的数据仍占据磁盘,需 VACUUM 后可重用空间,但不会还给操作系统
空间复用 可复用表内部空间 后续 INSERT 可能复用这些死元组位置(autovacuum 清理后)
清理机制 Autovacuum 自动清理 PostgreSQL 会后台运行 autovacuum 将死元组“清理掉”
对表膨胀影响 可能导致表膨胀 若死元组积压、autovacuum 未跟上,会造成表文件持续变大
执行锁 行级锁 对每条被删除的行加 Row Exclusive Lock,不会锁整张表
索引维护 会修改相关索引 删除操作会在所有相关索引中打标记或维护删除信息,造成额外 I/O
性能消耗 成本高于 TRUNCATE 因为每行需要触发 WAL、索引维护、触发器等逻辑,性能相对慢
触发器支持 支持触发器 会触发 BEFORE DELETEAFTER DELETE 类型的触发器
可回滚 支持事务 可被回滚,符合 ACID

二。TRUNCATE 的行为特点

行为 说明
几乎瞬时执行 不扫描表,也不记录每行删除,适合清空大表
绕过 WAL 日志(行级别) 仅记录一条“清空表”命令,故不可回滚至具体行级
持有 ACCESS EXCLUSIVE 锁 阻塞所有并发查询/写入,直到操作完成
重置表的元信息 包括自增序列的最小值、统计信息等
标记整个表空间可重用 实际空间仍保留在磁盘上,但页可被后续 INSERT 使用
空间是否释放? 不会立即释放磁盘文件,空间只是“内部可复用”而非返还系统

三。DROP 的行为特点

动作 说明
删除 .rel 文件 表、索引、TOAST 等所有物理文件会立刻从磁盘删除
释放磁盘空间 空间立即归还操作系统df -hdu -sh 立刻反映变小
无需重写 不像 VACUUM FULL,不需要重建或锁整张表内容
彻底不可恢复 数据全无,除非提前备份或启用 PITR(时间点恢复)机制

四。对比总结

操作 释放磁盘空间(OS 层面) 空间可复用(PostgreSQL 层) 说明
DELETE ❌ 否 ❌ 否(需 VACUUM 后才可复用) 慢,行级操作
TRUNCATE ❌ 否 ✅ 是 快,逻辑清空
VACUUM ❌ 否 ✅ 是 清理死元组
VACUUM FULL ✅ 是 ✅ 是 锁表、重写
DROP TABLE ✅ 是 ❌ 不适用 最快,数据彻底删除

五。背后的原因

PostgreSQL 使用的是 MVCC(多版本并发控制),它不会立即删除旧数据,而是:

六。评估回收磁盘空间

1. 整体评估的 SQL 语句

SELECT
  relname AS table,
  pg_total_relation_size(relid) AS total_bytes,
  pg_relation_size(relid) AS data_bytes,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  pg_size_pretty(pg_relation_size(relid)) AS data_size,
  n_live_tup,
  n_dead_tup,
  round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup + 1), 2) AS dead_tuple_pct,
  round(100.0 * pg_relation_size(relid)::numeric / pg_total_relation_size(relid), 2) AS data_utilization_pct
FROM pg_stat_user_tables
WHERE relname = 'your_table_name';

2.字段释义表

字段名 含义 说明
table 表名 显示当前行对应的用户表名称
total_bytes 表总大小(字节) 包括表数据、索引、TOAST 数据等所有相关对象的空间
data_bytes 表数据大小(字节) 只包含表本体的数据,不含索引和 TOAST
total_size 表总大小(可读格式) total_bytes 转换为如 12 GB256 MB 的形式
data_size 表数据大小(可读格式) data_bytes 转换为易读格式
n_live_tup 活跃行数 当前表中可见、未被删除的行数,近似值
n_dead_tup 死元组数 被标记为删除但尚未清理的行数(MVCC 残留),近似值
dead_tuple_pct 死元组占比 计算公式:n_dead_tup / (n_live_tup + n_dead_tup + 1),评估数据陈旧程度(+1 避免除零)
data_utilization_pct 数据利用率 计算公式:data_bytes / total_bytes,反映数据本体占总空间的比例,评估是否膨胀

3.评估是否需要回收空间(关键逻辑):

死元组比例高 → 表示可能需要 VACUUMVACUUM FULL

dead_tuple_ratio = n_dead_tup / (n_live_tup + n_dead_tup)

total_size 明显大于 data_size → 表空间“膨胀”

bloat_ratio = data_size / total_size

综合评估建议表

情况 推荐操作
死元组很多、空间占用也大 VACUUM FULL
死元组少,但 total_size 非常大 表重建或 VACUUM FULL
死元组很多,但表不大 手动 VACUUM 足够
空间正常、死元组很少 无需操作

七。拓展补充

1.表索引、数据、总大小对比

SELECT
  relname AS table_name,
  pg_size_pretty(pg_relation_size(relid)) AS data_size,
  pg_size_pretty(pg_indexes_size(relid)) AS index_size,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
  round(100.0 * pg_indexes_size(relid) / nullif(pg_total_relation_size(relid), 0), 2) AS index_pct
FROM pg_stat_user_tables
WHERE relname = 'biz_stock_pretreatment'
ORDER BY pg_total_relation_size(relid) DESC;

2.查询表索引所占空间大小,及查询时用的次数,对于使用次数过低但占用空间较大的考虑清除

SELECT
  t.relname AS table_name,
  i.relname AS index_name,
  pg_size_pretty(pg_relation_size(i.oid)) AS index_size,
  pg_relation_size(i.oid) AS index_size_bytes,
  idx_stat.idx_scan AS index_scans,
  pg_size_pretty(pg_table_size(t.oid)) AS table_size,
  array_to_string(array_agg(a.attname), ', ') AS index_columns,
  ix.indisunique AS is_unique,
  ix.indisprimary AS is_primary
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
LEFT JOIN pg_stat_user_indexes idx_stat ON idx_stat.indexrelid = i.oid
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
WHERE t.relkind = 'r'
  AND t.relname = 'biz_stock_pretreatment'
GROUP BY t.relname, i.relname, pg_size_pretty(pg_relation_size(i.oid)), pg_relation_size(i.oid)
       , idx_stat.idx_scan, pg_size_pretty(pg_table_size(t.oid)), ix.indisunique, ix.indisprimary
ORDER BY index_size_bytes DESC;

#Notes