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 DELETE 、AFTER DELETE 类型的触发器 |
可回滚 | 支持事务 | 可被回滚,符合 ACID |
二。TRUNCATE 的行为特点
行为 | 说明 |
---|---|
几乎瞬时执行 | 不扫描表,也不记录每行删除,适合清空大表 |
绕过 WAL 日志(行级别) | 仅记录一条“清空表”命令,故不可回滚至具体行级 |
持有 ACCESS EXCLUSIVE 锁 | 阻塞所有并发查询/写入,直到操作完成 |
重置表的元信息 | 包括自增序列的最小值、统计信息等 |
标记整个表空间可重用 | 实际空间仍保留在磁盘上,但页可被后续 INSERT 使用 |
空间是否释放? | 不会立即释放磁盘文件,空间只是“内部可复用”而非返还系统 |
三。DROP 的行为特点
动作 | 说明 |
---|---|
删除 .rel 文件 |
表、索引、TOAST 等所有物理文件会立刻从磁盘删除 |
释放磁盘空间 | 空间立即归还操作系统,df -h 或 du -sh 立刻反映变小 |
无需重写 | 不像 VACUUM FULL ,不需要重建或锁整张表内容 |
彻底不可恢复 | 数据全无,除非提前备份或启用 PITR(时间点恢复)机制 |
四。对比总结
操作 | 释放磁盘空间(OS 层面) | 空间可复用(PostgreSQL 层) | 说明 |
---|---|---|---|
DELETE |
❌ 否 | ❌ 否(需 VACUUM 后才可复用) |
慢,行级操作 |
TRUNCATE |
❌ 否 | ✅ 是 | 快,逻辑清空 |
VACUUM |
❌ 否 | ✅ 是 | 清理死元组 |
VACUUM FULL |
✅ 是 | ✅ 是 | 锁表、重写 |
DROP TABLE |
✅ 是 | ❌ 不适用 | 最快,数据彻底删除 |
五。背后的原因
PostgreSQL 使用的是 MVCC(多版本并发控制),它不会立即删除旧数据,而是:
- 对于每一次 UPDATE / DELETE:
- 原来的行被标记为“死元组”;
- 新的行(或删除标记)会追加在后面;
- 所以表文件会不断膨胀,哪怕你已经“删除”了很多数据。
六。评估回收磁盘空间
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 GB 、256 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.评估是否需要回收空间(关键逻辑):
死元组比例高 → 表示可能需要 VACUUM
或 VACUUM FULL
dead_tuple_ratio = n_dead_tup / (n_live_tup + n_dead_tup)
- 超过 10%:建议手动
VACUUM
- 超过 30% 且表很大:考虑
VACUUM FULL
或表重建
total_size 明显大于 data_size → 表空间“膨胀”
bloat_ratio = data_size / total_size
- 如果 data_size 占比 < 60%,说明表文件中大量空洞未被重用
- 膨胀严重(< 40%),但死元组少 → 考虑
VACUUM FULL
或DROP + CREATE
- 考虑是索引或 TOAST 膨胀,如果索引过大 → 考虑 REINDEX 或重建索引
综合评估建议表
情况 | 推荐操作 |
---|---|
死元组很多、空间占用也大 | 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;