RDS for PostgreSQL 表膨胀排查与治理:从死元组统计到 pg_repack 在线回收
在 RDS for PostgreSQL 上跑业务一段时间后,经常会遇到这样的现象:表里的行数没怎么变,磁盘占用却一路上涨,简单的按主键查询也越来越慢。绝大多数情况下,罪魁祸首是"表膨胀"——MVCC 留下的死元组没有被及时清理。本文按照"确认膨胀、找到原因、调参治理、在线回收"四步,把我在实际项目里的排查过程整理出来,所有 SQL 都可以直接在 psql 或数据管理服务 DAS 中执行。
一、先弄清楚死元组从哪里来
PostgreSQL 的 UPDATE 并不是原地修改,而是写入一个新版本、把旧版本标记为过期;DELETE 也只是打标记。这些过期版本就是死元组(dead tuple),要等 VACUUM 把它们占用的空间标记为可复用。autovacuum 进程会按阈值自动触发清理,触发条件大致是:
死元组数 > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * 表行数
默认 scale_factor 是 0.2,也就是说一张 5000 万行的大表,要累积约 1000 万个死元组才会触发一次清理。对于高频更新的订单表、状态表来说,这个阈值明显偏高。
二、第一步:确认哪些表在膨胀
先从统计视图里找死元组最多的表:
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
经验上,dead_pct 长期超过 20%,或者 last_autovacuum 已经是几天前,就值得重点关注。如果实例安装了 pgstattuple 插件(RDS for PostgreSQL 支持通过 CREATE EXTENSION 启用),可以得到更精确的数字:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple_approx('public.orders');
重点看 dead_tuple_percent 和 approx_free_percent,前者是待清理的死元组,后者是已清理但未归还给操作系统的空闲空间。
三、第二步:找出 autovacuum 清理不动的原因
阈值偏高只是其一,更常见也更隐蔽的是"清理了但删不掉"。VACUUM 只能回收比当前最老事务快照还旧的死元组,所以下面三类情况会直接让清理失效:
1. 长事务或 idle in transaction 连接。应用拿到连接后开启事务却迟迟不提交,会把整个实例的可回收边界钉住:
SELECT pid, usename, state, xact_start,
now() - xact_start AS xact_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 5;
2. 废弃的复制槽。做过逻辑订阅或 DRS 迁移后,残留的复制槽会持有 catalog_xmin:
SELECT slot_name, active, xmin, catalog_xmin,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
3. 只读实例开启了 hot_standby_feedback 且有慢查询。只读节点上的长查询会把反馈的 xmin 回传给主库,同样阻止回收。
我遇到的那次问题就是第一类:一个定时任务框架在每次任务开始时执行 BEGIN,异常分支没有回滚,连接一直保持 idle in transaction 长达 30 多个小时,期间订单表死元组涨到了 4000 多万。
四、第三步:调整参数,让清理跟得上
先解决根因:修复应用里未提交的事务,并在 RDS 控制台的参数模板中设置兜底超时,避免同类问题再次出现:
idle_in_transaction_session_timeout = 600000 -- 10 分钟,单位毫秒
statement_timeout 按业务在会话级别设置
无用的复制槽确认不再需要后删除:
SELECT pg_drop_replication_slot('old_sub_slot');
然后针对高频更新的大表单独下调阈值,而不是全局修改,以免小表被频繁清理:
ALTER TABLE public.orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 5000,
autovacuum_vacuum_cost_limit = 2000
);
如果实例规格较大、表较多,还可以在参数模板里把 autovacuum_max_workers 适当调大(修改后需重启生效,建议放在维护窗口)。
五、第四步:回收已经膨胀的空间
普通 VACUUM 只是把空间标记为可复用,并不会缩小文件。想把磁盘真正还回来,常见做法有两种:
- VACUUM FULL:会重写整张表,期间持有 ACCESS EXCLUSIVE 锁,读写全部阻塞,只适合小表或停机窗口。
- pg_repack:通过触发器加影子表在线重建,只在开始和结束时短暂加锁,适合线上大表。RDS for PostgreSQL 的插件列表中可以查看是否支持当前版本。
使用 pg_repack 时需要预留大约与目标表加索引同等大小的剩余磁盘空间,并且表必须有主键或唯一非空索引:
CREATE EXTENSION IF NOT EXISTS pg_repack;
-- 在能连接实例的 ECS 上执行客户端命令
pg_repack -h <实例内网地址> -p 5432 -U root -d appdb --table=public.orders --no-superuser-check
执行前后各跑一次第二步的统计 SQL,我那次 orders 表从 62 GB 降到了 21 GB,按主键查询的平均耗时也从十几毫秒回到了 1 毫秒左右。
六、日常巡检建议
- 把"死元组比例 Top 10"和"最长事务时长"两条 SQL 做成定时巡检,结果超过阈值就告警。
- 在云监控中关注实例的磁盘使用率和事务 ID 年龄(age(datfrozenxid)),后者接近 2 亿时要提前安排冻结清理,避免触发防回卷的强制 VACUUM。
- 应用侧统一使用连接池,并确保每个异常分支都会回滚事务。
表膨胀本身不复杂,难点在于它是慢慢积累的,等到磁盘告警时往往已经很严重。只要把"长事务"和"清理阈值"这两个点盯住,大部分问题都能在早期发现并处理。
- 点赞
- 收藏
- 关注作者
评论(0)