
数据库一跑几个月磁盘告警突然炸了或者明明用DELETE删掉了几百万行df -h看了一眼根本不见空间回来再或者一条本来几十毫秒的查询现在慢到像在遍历整个仓库。遇到这些情况十有八九是PostgreSQL的膨胀和碎片问题在背后作祟。这篇博文专注一个主题如何用好VACUUM把空间优雅地收回来同时搞清楚它背后的运行机制避免下一轮碎片堆积。这篇文章适合谁刚接手PostgreSQL运维、被bloat搞到焦头烂额的DBA以及那些业务表动辄上千万行、但还没建立起清理策略的后端团队。你会看到我实际维护库时用到的膨胀排查SQL、VACUUM系列参数的完整调优思路以及那几个我踩过好几次的坑。1. 先搞清楚碎片是怎么来的MVCC与膨胀的底层逻辑1.1 PostgreSQL等多版本并发控制的代价PostgreSQL和其他关系型数据库不一样它做更新、删除时并不直接修改磁盘上的原有数据而是采用多版本并发控制MVCC。简单说一条记录在存储层会留下多个版本每个版本用xmin和xmax两个系统字段标记事务范围xmin该版本是由哪个事务插入的xmax该版本是被哪个事务删除或更新掉的没有则为空。一条UPDATE在PostgreSQL内部实际上等于逻辑删除旧版本 插入新版本。旧版本的行数据并不会立刻从数据页上消失而是被打上一个删除标记变成死元组dead tuple。这些死元组占用的空间就是你会遇到的膨胀bloat的来源。你手动执行DELETE删掉海量记录时它们同样只是变成死元组物理空间仍然留在原数据文件里。比较直观的理解你在一张草稿纸上写了又划掉很多内容纸上还留着划掉的字只有用橡皮擦或者换一张纸才能彻底清干净。这里VACUUM就是橡皮擦VACUUM FULL相当于换纸。1.2 数据页与空间的割裂磁盘上表的数据被划分为固定大小的页默认8KB。死元组散落在几千几万个页中导致每个页内部都有一些空洞。你可以想象一个图书馆里每排书架都空了几本书的位置但这些空位分散到每一层新书又必须按顺序找地方插不仅利用率低扫描时还要跨越大量空洞区域性能自然往下掉。1.3 为什么要区分膨胀和磁盘碎片很多人在概念上容易混淆两件事表膨胀死元组占据文件内部空间文件大小不减反增VACUUM可以复用这部分空间但不会把空间还给操作系统磁盘碎片文件系统层面的碎片在普通机械盘上会影响顺序读但PostgreSQL自身通常很难直接干预而且现代文件系统和SSD上影响较小。大家平时说的清理磁盘空间碎片在PostgreSQL语境里绝大多数情况下要处理的是表文件内部的膨胀以及文件尾部无法自动收缩这两个问题。这两件事恰好是不同形式的VACUUM来解决的。2. VACUUM的两种形态回收空间与文件重写2.1 普通VACUUM回收并复用但不归还普通VACUUM的职责是扫描数据页清理死元组把页内部腾出来的空间登记到空闲空间映射表free space map即FSM中后续插入和更新会优先使用这些空闲空间。这个操作是在线的不会阻塞读写但它不会把空间交还给操作系统表文件在磁盘上的大小基本不变。VACUUM还有两个容易被忽视的附加责任更新统计信息帮助优化器生成更准确的执行计划推进事务ID防止事务ID回卷transaction ID wraparound这种灾难性事件。执行方式很简单VACUUM (VERBOSE, ANALYZE) your_table_name;VERBOSE能把每个表的清理明细输出到日志或客户端ANALYZE连同统计信息一起更新。考虑到日常运维生产环境我几乎不会做不带ANALYZE的裸VACUUM因为一次单独的执行还会有后续统计信息过期的风险。2.2 VACUUM FULL重写表真正的碎片整理如果表膨胀已经非常严重比如100GB逻辑大小的表实际上占用了300GB的磁盘普通VACUUM只能让后续写入复用内部空间文件体积却始终降不下来。这时需要的是VACUUM FULL它的工作方式是获取表的ACCESS EXCLUSIVE锁新建一个全新的表文件其实是重建整个表只把存活元组复制到新文件中重建索引并清理掉旧文件。这个过程会重新紧凑地排列每一个数据页表文件体积会明显缩小。代价也非常明确在重写期间该表的读写全部被阻塞。如果你的表有几十GBVACUUM FULL可能需要几分钟到几十分钟这段时间业务上任何访问这个表的操作都会卡住。所以VACUUM FULL更像外科手术式的终极手段放在维护窗口里执行不要用它做日常清理。2.3 两种方式的对比与选型我画一张用过很久的对比表方便你选型时快速判断对比项VACUUMVACUUM FULL是否阻塞读写不阻塞阻塞持ACCESS EXCLUSIVE锁是否将空间归还OS不归还仅登记到FSM归还文件重写后变小执行速度快可在线执行慢重写全表重建索引是否重建索引否是对磁盘空间要求无需额外空间需要约等于表大小的额外空间使用场景日常自动清理、死元组回收膨胀严重或需彻底压缩文件另一个容易混淆的操作是REINDEX它只重建索引不会整理表的堆数据。如果你观察到索引膨胀严重而表本身还好单独REINDEX或者REINDEX CONCURRENTLY就够未必需要动VACUUM FULL。3. 实操过程查膨胀、执行VACUUM、调好自动清理3.1 第一步如何准确评估表的膨胀情况不评估就动手很容易白忙。我通常在接到空间告警后先跑这样一组SQL快速定位膨胀大户。查询各表死元组和存活元组比例的快速排查SELECT schemaname, relname, n_live_tup, n_dead_tup, CASE WHEN n_live_tup 0 THEN round(n_dead_tup::numeric / n_live_tup * 100, 2) ELSE 0 END AS dead_ratio_pct, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables WHERE n_dead_tup 1000 ORDER BY n_dead_tup DESC LIMIT 20;只看数量还不够最准确的是计算表的实际磁盘占用与逻辑数据量之间的差距。我推荐直接装pgstattuple扩展它在服务端扫描物理页面给出精确的元组分布CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT * FROM pgstattuple(public.your_big_table);输出里最关键的字段是dead_tuple_percent和free_percent。如果dead_tuple_percent超过20%甚至30%这个表就是典型的膨胀身份如果文件很大但free_percent高说明页内碎片化明显普通VACUUM之后插入能复用但如果要缩文件还得靠VACUUM FULL。3.2 第二步手工执行普通VACUUM的正确姿势排查完再动手。普通VACUUM不阻塞业务所以可以随时做但也不能无脑全库跑那会浪费大量IO。单表执行VACUUM (VERBOSE, ANALYZE) public.your_big_table;库级别执行VACUUM (VERBOSE, ANALYZE);在维护窗口内我习惯写一个简单的循环脚本按膨胀程度由高到低处理顺便把每次耗时打印出来#!/bin/bash DB_NAMEappdb TABLESpublic.orders public.users public.order_items for table in $TABLES; do echo vacuum start: $table $(date %H:%M:%S) psql -d $DB_NAME -c VACUUM (VERBOSE, ANALYZE) $table; echo vacuum end: $table $(date %H:%M:%S) done用nohup挂后台把输出重定向到日志即可nohup bash vacuum_maintenance.sh maintenance_$(date %Y%m%d).log 21 这里有个长期踩坑的经验务必给VACUUM加上ANALYZE选项。否则清理完膨胀之后统计信息可能是旧的优化器还是按老数据量生成执行计划刚清理完查询反而变慢的情况我见过不止一次。3.3 第三步针对重度膨胀的表执行VACUUM FULL重度膨胀并需要把空间还给操作系统时才执行VACUUM FULL。执行前需要做三件事确认当前没有长事务或长查询占用这张表选择业务低谷窗口评估额外磁盘空间VACUUM FULL期间新表和旧表会同时存在确保磁盘可用空间大于目标表当前大小。检查当前是否有锁冲突SELECT pid, state, wait_event_type, wait_event, left(query, 80) AS query_text, now() - xact_start AS xact_duration FROM pg_stat_activity WHERE state idle ORDER BY xact_start;确认干净后执行VACUUM (FULL, VERBOSE, ANALYZE) public.your_big_table;一个我常用的优化是如果表有大量索引优先考虑VACUUM FULL完成后单独对索引做REINDEX或者直接在重写后顺手重建。VACUUM FULL本身重建索引但单独跑一遍也不会多余特别是那些复合索引。如果实在停不了这张表的写操作PostgreSQL 12及以上可以考虑REINDEX CONCURRENTLY但注意VACUUM FULL不支持并发模式它必须要那张表短暂地不可用。3.4 第四步把autovacuum调成自动巡航而不是事后补救手工VACUUM是事故处理autovacuum才是日常防守。PostgreSQL默认开启autovacuum但默认参数往往只适合中小型库对业务繁忙的表来说不够灵敏。关键参数如下SELECT name, setting, unit, short_desc FROM pg_settings WHERE name IN ( autovacuum, autovacuum_vacuum_threshold, autovacuum_vacuum_scale_factor, autovacuum_vacuum_insert_threshold, autovacuum_vacuum_insert_scale_factor, autovacuum_analyze_threshold, autovacuum_analyze_scale_factor, autovacuum_naptime, autovacuum_vacuum_cost_limit, autovacuum_vacuum_cost_delay );默认规则里触发autovacuum的死元组阈值是vacuum触发阈值 autovacuum_vacuum_threshold autovacuum_vacuum_scale_factor × 行数默认分别为50和0.2。也就是说一张100万行的表要有20万死元组大约20%的数据是脏的才会触发自动清理。很多业务表更新停机日常膨胀到了这种程度才开始清理空间早就吃不消了。我的建议是全局参数适度收紧autovacuum_vacuum_threshold 500 autovacuum_vacuum_scale_factor 0.05 autovacuum_vacuum_insert_threshold 1000 autovacuum_vacuum_insert_scale_factor 0.05 autovacuum_naptime 30s这样百万行级别的表大约5%的死元组就会触发清理。插入量特别大的表配合autovacuum_vacuum_insert_*参数单独加速。对于超高更新频率的核心表我更推荐用表级存储参数做精细化调优而不动全局配置ALTER TABLE public.orders SET ( autovacuum_vacuum_scale_factor 0.02, autovacuum_vacuum_threshold 500, autovacuum_vacuum_cost_delay 5, autovacuum_vacuum_cost_limit 2000 );autovacuum_vacuum_cost_delay和cost_limit决定autovacuum每轮IO的消耗速度。默认cost_delay20ms、cost_limit200比较保守目的是不干扰业务。如果希望在低峰期让autovacuum跑得更快可以把cost_delay调低同时提高cost_limit。调解后要观察业务是否出现IO压力再往下微调。3.5 第五步用定时任务兜底解决autovacuum来不及清理的情况即使autovacuum配置到位有些表依然会因为单次大批量更新比如夜间ETL任务产生海量死元组autovacuum在单轮里来不及处理。这时需要定时任务兜底。我在日常维护中采用两层定时策略每周日凌晨低峰期对膨胀率超过阈值的表执行一次普通VACUUM (ANALYZE)每月或每季度维护窗口对膨胀严重且文件大小不下降的表执行一次VACUUM FULL。可以用crontab加一个简单的检测脚本定期输出膨胀率报表#!/bin/bash psql -d appdb -c SELECT schemaname, relname, pg_size_pretty(pg_total_relation_size(schemaname || . || relname)) AS total_size, n_dead_tup, n_live_tup FROM pg_stat_user_tables WHERE n_dead_tup 10000 ORDER BY n_dead_tup DESC; /var/log/pg_bloat_report_$(date %Y%m%d).txt这套组合拳下来表膨胀基本就不会再发展成磁盘写满的事故级别问题了。4. 常见问题与排查技巧实录4.1 问题VACUUM FULL把业务卡死了最容易出问题的就是VACUUM FULL。我处理过一次线上事故某团队顺手在下午三点对一个2亿行的订单表执行了VACUUM FULL结果该表所有读写全部排队订单接口大面积超时。排查时在pg_stat_activity里看到的等待事件几乎都是relation: AccessExclusiveLockSELECT pid, state, wait_event_type, wait_event, now() - query_start AS wait_time FROM pg_stat_activity WHERE wait_event_type Lock;最终处理方案是终止掉那个VACUUM FULL恢复业务然后在凌晨重试。经验教训VACUUM FULL尽量用statement_timeout包一层避免卡死不可控选择维护窗口执行并提前通知业务方执行前检查并杀掉超过一定时长的空闲事务因为任何事务都可能阻塞锁的获取。我给VACUUM FULL建议加的保护SET statement_timeout 30min; VACUUM (FULL, VERBOSE, ANALYZE) public.your_big_table; RESET statement_timeout;4.2 问题死元组不多但文件还是很大这是高水位线问题。大量数据被删除后文件尾部的页面已经空了但文件尺寸还停留在历史高峰。死元组数量看着不多是因为之前的VACUUM已经清理过了只是空间留在页内等待复用文件没收缩。判断方法是看pgstattuple中的free_percent和dead_tuple_percentdead_tuple_percent低但free_percent很高说明纯粹是文件没收缩这种情况VACUUM FULL可以立刻释放空间但如果不想阻塞业务等文件内部空间慢慢被新数据复用也是可接受的。实际业务中还有一个细节某个表每天批量入库写入量远大于删除量时持续复用内部碎片就好不需要做VACUUM FULL。真正需要VACUUM FULL的是那种数据量先暴涨、再大范围删除、文件已定型的历史表。4.3 问题事务ID回卷风险死元组不及时清理不只是空间问题更大的风险是事务ID回卷。PostgreSQL的事务ID是32位整数约42亿个事务之后会循环。如果某个数据库长时间不执行VACUUM事务ID接近回卷点时数据库会强制进入单用户模式并要求清理这是很多运维事故的根源。检查当前数据库的年龄SELECT datname, age(datfrozenxid) AS age, current_setting(autovacuum_freeze_max_age) AS freeze_max_age FROM pg_database ORDER BY age(datfrozenxid) DESC;当age接近autovacuum_freeze_max_age默认2亿时我会主动执行VACUUM (VERBOSE, FREEZE, ANALYZE);FREEZE选项会标记尽可能多的元组为冻结状态缩短后续年龄推进的负担。这个操作在事务年龄高的时候非常关键别等到接近20亿才想起清理。4.4 问题运行了大量VACUUM还是出现锁等待普通VACUUM本身不阻塞正常读写但它需要获取表的SHARE UPDATE EXCLUSIVE锁。这个锁和DDL操作比如ALTER TABLE会发生冲突。如果你在凌晨同时执行VACUUM和某个ALTER TABLE迁移脚本就可能有一方卡住。排查方式是查pg_locks和pg_stat_activity看AccessShareLock、ShareUpdateExclusiveLock等锁的等待链SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, left(blocked.query, 60) AS blocked_query, left(blocking.query, 60) AS blocking_query FROM pg_stat_activity blocked JOIN pg_stat_activity blocking ON blocking.pid ANY(pg_blocking_pids(blocked.pid)) WHERE blocked.wait_event_type Lock;解决方案通常是调整任务编排把VACUUM和DDL放在不同的时间窗口或者用LOCK_TIMEOUT给DDL设置超时上限避免无限等待。4.5 问题长事务导致清理失效一个容易被忽视的杀手是长事务。即使死元组已经被删除标记只要有一个老事务还存活而且它的快照起点比那些死元组的xmax还早VACUUM就无法把那些元组真正清理掉。这会导致n_dead_tup持续上涨但实际清理不动。排查长事务SELECT pid, now() - xact_start AS duration, state, left(query, 100) AS query FROM pg_stat_activity WHERE xact_start IS NOT NULL AND now() - xact_start interval 5 minutes ORDER BY xact_start;如果确实存在业务长事务先和研发确认能否提前提交确认是空闲事务的话可以直接终止SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state idle in transaction AND now() - xact_start interval 15 minutes;处理掉长事务后再执行VACUUM死元组才会真正被回收。4.6 排查技巧速查表症状可能原因首选操作磁盘空间告警表文件巨大死元组膨胀或文件高水位先普通VACUUM再评估VACUUM FULL死元组占比高但清理不动长事务或复制槽阻塞查pg_stat_activity、查复制槽终止长事务清理后查询变慢统计信息过期VACUUM加ANALYZEVACUUM FULL卡住业务持有AccessExclusive锁终止VACUUM改维护窗口执行数据库age接近回卷长期未VACUUM立即VACUUM (FREEZE, ANALYZE)索引膨胀严重大量更新导致索引页膨胀单独REINDEX CONCURRENTLY写在最后PostgreSQL的VACUUM机制从原理到实操其实是一条线MVCC产生死元组死元组占着空间不松手VACUUM把空间标成可复用VACUUM FULL把整个文件重新整理。理解了这条线你就不会再把autovacuum当成可有可无的开关也不会在业务高峰期随便执行VACUUM FULL给自己挖坑。从我个人的运维习惯来看真正让数据库长期保持健康的核心不是什么高明技巧而是监控自动清理定期手术的组合监控给数据autovacuum做日常防守维护窗口的VACUUM FULL做最后兜底。每次处理完膨胀问题之后我会顺手把pg_stat_user_tables里清理前后的n_dead_tup和文件大小记录到一张表里时间一长哪些表容易膨胀、什么时候膨胀都看得出规律。这套数据比任何经验都靠谱。最后提醒一句任何清理操作上生产之前都先在测试库用同样体量的数据演练一遍你踩过的坑越早暴露越好。