PostgreSQL VACUUM 清理机制详解
一、为什么需要 VACUUM?
PostgreSQL 使用 MVCC(多版本并发控制)实现事务隔离:
- UPDATE 操作:本质是 DELETE + INSERT,旧版本数据并不会立即删除
- DELETE 操作:只是将数据标记为"已删除",物理空间不释放
这导致大量死元组(dead tuples) 残留在表中:
css
┌──────────────────────────────────────────────────────┐
│ 表空间 │
│ [活跃数据] [死元组] [活跃数据] [死元组] [死元组] │
│ │
│ 死元组累积 → 空间浪费 → 查询变慢 → 需要 VACUUM │
└──────────────────────────────────────────────────────┘
VACUUM 就是负责回收这些死元组、释放空间、更新统计信息的维护命令。
二、哪些操作会产生空间碎片?
2.1 高频 UPDATE
每次 UPDATE 都会保留旧版本,旧版本变成死元组:
sql
-- 订单状态每次变更,都产生一个旧版本死元组
UPDATE orders SET status = 'PAID' WHERE order_id = 12345;
UPDATE orders SET status = 'SHIPPED' WHERE order_id = 12345;
UPDATE orders SET status = 'DELIVERED' WHERE order_id = 12345;
ini
初始: Page 1: [Row-v1] [空闲] [空闲] [空闲]
3次UPDATE后:
Page 1: [Row-v1(死)] [Row-v2(死)] [Row-v3(死)] [Row-v4]
← 60% 空间被死元组占用
2.2 高频 DELETE
大量删除后,空间被死元组占据无法重用:
sql
-- 每天删除过期日志
DELETE FROM interface_execution_log WHERE start_time < now() - interval '90 days';
⚠️ 即使删除了 500 万行,表文件大小也不会缩小,空间不会归还给操作系统。
2.3 批量数据导入 + 清理
sql
-- Step 1:导入 1000 万行临时数据
INSERT INTO odh_sell_in_inbound SELECT * FROM external_source;
-- Step 2:数据处理完毕,删除临时数据
DELETE FROM odh_sell_in_inbound WHERE batch_id = 'xxx';
-- 结果:表大小维持在 1000 万行的体量,内部全是死元组空洞
2.4 长时间未提交的事务
sql
-- 事务 A 开启但长时间未提交
BEGIN;
SELECT * FROM lorder_master_info WHERE id = 1;
-- ⏰ 业务处理了很久,事务未提交...
-- 此期间,其他事务产生的所有死元组都无法被 VACUUM 清理
-- 因为事务 A 可能还需要读到旧版本数据
⚠️ 这是线上最常见的表膨胀根因之一,尤其是跑批任务或报表查询时。
2.5 高并发小事务
sql
-- 每秒上万次库存扣减
UPDATE inventory SET stock = stock - 1 WHERE product_id = 'HOT001';
短时间内大量死元组堆积,查询性能会急剧下降。
三、如何诊断表膨胀?
sql
-- 查看死元组比例,找出需要清理的表
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
n_live_tup AS live_tuples,
n_dead_tup AS dead_tuples,
ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
判断标准:
| 死元组比例 | 状态 | 处理建议 |
|---|---|---|
| < 5% | 🟢 健康 | 无需操作 |
| 5% ~ 20% | 🟡 关注 | 执行 VACUUM ANALYZE |
| > 20% | 🔴 膨胀 | 立即执行 VACUUM,严重时用 VACUUM FULL |
四、VACUUM 的类型与使用
4.1 VACUUM ------ 日常清理(推荐)
sql
-- 清理单表
VACUUM orders;
-- 清理 + 更新统计信息(最常用)
VACUUM ANALYZE orders;
-- 查看清理详情
VACUUM VERBOSE ANALYZE orders;
特点:
- ✅ 不锁表,允许并发读写,可在线执行
- ✅ 将死元组空间标记为可重用(供后续 INSERT/UPDATE 使用)
- ❌ 不归还空间给操作系统,表文件大小不变
4.2 VACUUM FULL ------ 深度清理(维护窗口)
sql
VACUUM FULL VERBOSE ANALYZE orders;
工作原理:
markdown
1. 创建新的表文件
2. 将所有活跃数据紧凑复制到新文件
3. 删除旧文件,重建所有索引
4. ✅ 空间归还给操作系统,表文件大幅缩小
特点:
- ✅ 彻底回收空间,消除所有碎片
- ❌ 需要 ACCESS EXCLUSIVE 锁,执行期间表不可读写
- ❌ 需要约 2 倍表大小的临时磁盘空间
- ❌ 大表耗时很长(GB 级别可能需要数十分钟)
⚠️ 仅在业务低峰期(如凌晨维护窗口)执行,生产高峰期禁止使用。
4.3 VACUUM ANALYZE ------ 清理 + 更新统计信息
统计信息过时会导致查询优化器选错执行计划:
sql
-- 数据大量变更后,一定要执行 ANALYZE
VACUUM ANALYZE lorder_master_info;
-- 或只更新统计信息(不清理)
ANALYZE lorder_master_info;
典型场景:
- 大批量数据导入后
- 创建新索引后
- 某张表数据量变化超过 20% 后
效果对比:
vbnet
统计信息过时 → 优化器估算:10 行 → 选 Nested Loop → 执行 30 秒
执行 ANALYZE → 优化器估算:100 万行 → 选 Hash Join → 执行 2 秒
4.4 VACUUM FREEZE ------ 防止事务 ID 回绕
PostgreSQL 使用 32 位事务 ID(XID),用完(约 42 亿)后会回绕,导致数据混乱:
sql
-- 查看各表的事务年龄(接近 2 亿时需警惕)
SELECT
relname,
age(relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(oid)) AS size
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;
-- 出现以下告警时,立即执行:
-- WARNING: database must be vacuumed within 1000000 transactions
VACUUM FREEZE;
五、VACUUM 的核心好处
5.1 回收空间,降低 I/O
css
清理前:表 100GB,有效数据 60GB,死元组 40GB → 全表扫描读 100GB
VACUUM 后:死元组空间标记为可重用,表不再无限膨胀
VACUUM FULL 后:表缩减为 60GB → 全表扫描只需读 60GB,I/O 节省 40%
5.2 提升查询性能
ini
清理前:
Page 1: [Data][Dead][Dead][Data] ← 扫描效率 50%
Page 2: [Dead][Dead][Data][Dead]
Page 3: [Data][Data][Dead][Dead]
→ 扫描 3 页,只有 50% 有效数据
清理后(VACUUM FULL):
Page 1: [Data][Data][Data][Data] ← 扫描效率 100%
Page 2: [Data][Data][空闲][空闲]
→ 扫描 2 页,100% 有效数据,性能提升 ~60%
5.3 优化查询执行计划
统计信息过时是慢查询的常见根因:
sql
-- 统计信息过时 → 优化器估算行数偏差巨大 → 选错 Join 方式 → 慢 30 倍
-- VACUUM ANALYZE 后 → 统计准确 → 选 Hash Join → 正常速度
VACUUM ANALYZE orders;
5.4 改善 Shared Buffer 缓存命中率
清理前:Buffer 中缓存大量死元组页,热数据被挤出
清理后:Buffer 中全是有效数据,缓存命中率显著提升
5.5 防止事务 ID 回绕(数据库崩溃风险)
定期 VACUUM 会自动冻结旧事务 ID,防止 XID 回绕导致数据库不可用。
六、实战清理操作指南
6.1 标准清理(不锁表)
sql
-- Step 1:找出需要清理的表
SELECT tablename, n_dead_tup,
ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 100000
OR (n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0)) > 20
ORDER BY n_dead_tup DESC;
-- Step 2:执行清理(不锁表)
VACUUM VERBOSE ANALYZE lorder_master_info;
-- Step 3:验证效果
SELECT pg_size_pretty(pg_total_relation_size('lorder_master_info')) AS size,
n_dead_tup, last_vacuum
FROM pg_stat_user_tables
WHERE tablename = 'lorder_master_info';
6.2 深度清理(维护窗口执行)
sql
-- Step 1:确认磁盘空间(需要 2 倍表大小)
SELECT pg_size_pretty(pg_total_relation_size('orders')) AS current_size,
pg_size_pretty(pg_total_relation_size('orders') * 2) AS required_space;
-- Step 2:设置超时保护
SET statement_timeout = '2h';
-- Step 3:执行深度清理(⚠️ 会锁表)
VACUUM FULL VERBOSE ANALYZE orders;
6.3 分区表清理策略
针对项目中的大分区表,逐个分区清理,避免一次性影响范围过大:
sql
-- 逐个分区清理(推荐)
VACUUM VERBOSE ANALYZE lorder_master_info_ap_st_fy2526_q1;
VACUUM VERBOSE ANALYZE lorder_master_info_ap_st_fy2526_q2;
VACUUM VERBOSE ANALYZE lorder_master_info_ap_st_fy2526_q3;
VACUUM VERBOSE ANALYZE lorder_master_info_ap_st_fy2526_q4;
-- 批量清理所有子分区
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename LIKE 'lorder_master_info_%'
LOOP
RAISE NOTICE '正在清理: %', r.tablename;
EXECUTE 'VACUUM VERBOSE ANALYZE ' || quote_ident(r.tablename);
END LOOP;
END $$;
6.4 批量操作前后的最佳实践
sql
-- ✅ 大批量导入后,立即更新统计信息
INSERT INTO odh_sell_in_inbound SELECT * FROM staging_table;
VACUUM ANALYZE odh_sell_in_inbound;
-- ✅ 大批量删除后,回收死元组空间
DELETE FROM interface_execution_log WHERE start_time < now() - interval '90 days';
VACUUM interface_execution_log;
-- ✅ 创建索引后,更新统计信息
CREATE INDEX CONCURRENTLY idx_xxx ON lorder_master_info (geo_type, fiscal_year);
ANALYZE lorder_master_info;
6.5 在线清理方案(pg_repack)
VACUUM FULL 会锁表,生产环境推荐用 pg_repack 替代:
bash
# 安装(Ubuntu)
sudo apt-get install postgresql-16-repack
# 在线整理表,不锁表,允许读写
pg_repack -d mydb -t orders
pg_repack -d mydb -t lorder_master_info_ap_st_fy2526_q4
| 对比项 | VACUUM FULL | pg_repack |
|---|---|---|
| 锁表 | ✅ 锁表(不可读写) | ❌ 不锁表 |
| 空间回收 | ✅ 完全回收 | ✅ 完全回收 |
| 磁盘需求 | 2 倍表大小 | 2 倍表大小 |
| 生产适用 | ❌ 仅维护窗口 | ✅ 随时可用 |
适用版本:PostgreSQL 12+