一台自建 PostgreSQL 跑了将近一年,磁盘从 120G 涨到 480G,业务方说真实数据只多了三成。同一条按主键查的单行 SQL,从 3 毫秒掉到 40 毫秒;夜里跑的对账报表从 10 分钟拖到 50 分钟。运维把 pg_stat_user_tables 拉出来看了一遍,last_autovacuum 全是近期时间,autovacuum_count 也不低,结论就这么定下来了:autovacuum 干活不彻底,得上 VACUUM FULL。结果这一刀下去,表被 ACCESS EXCLUSIVE 锁了两个小时,夜里的对账、结算、清分全部排队等着,第二天的工单比原来还多。
这个诊断过程里有两个错误。第一个错误是把"autovacuum 有没有在跑"当成了判断依据,实际上该看的是"它每一次跑完到底清掉了多少"。第二个错误是把 VACUUM FULL 当成了清理手段,它其实是重写手段,代价和在线业务是互斥的。下面按"膨胀从哪来 → 为什么清不掉 → 索引为什么更隐蔽 → 三条清理路线的代价 → 怎么从源头少产 → 规格怎么反推"这条线说清楚。
把上面那台机器的现象拆开看,会发现三个现象其实是同一件事的三个投影。磁盘涨了四倍,单行主键查询慢了十几倍,全表扫描类的报表慢了五倍——这三者的共同变量是"页面数量"和"页面分布"。
单行主键查询的耗时,主要花在从根页走到叶子页的层数、叶子页到堆表的那一次回表读取、以及这些页到底在 shared_buffers 里还是得走磁盘。索引膨胀之后,同一棵 B-tree 的层数可能从 3 层变成 4 层,叶子页数量翻几倍,缓冲区里能装下的热点页比例就下降了。40 毫秒这个数字在机械盘上甚至还算客气,在低 IOPS 的云盘上会更难看。
报表变慢是另一个机制:膨胀三倍意味着顺序扫描要读三倍的页,其中相当一部分是空页。这些空页不是免费的,它们要被读进缓冲区、要占用缓存位置、要消耗 IO 队列。所以"数据只多了三成,报表慢了五倍"并不矛盾——报表读的不是数据量,是页数。
这里有个很实际的问题:大多数人第一次处理膨胀时,会先去怀疑硬件、怀疑参数、怀疑 SQL 写错了。但判断顺序应该反过来,先把 n_dead_tup 与 n_live_tup 的比值、表的实际大小与理论大小的差距、以及最老的 xmin 这三个数查一遍,几分钟就能定位是"清不掉"还是"清得太慢"还是"根本没触发"。这三个方向的处理方式完全不同,走错方向就是两个小时的锁。
PostgreSQL 的多版本并发控制(MVCC)是"行内多版本"设计。执行一条 UPDATE,数据库不会去原地覆盖那一行,而是在堆表里写入一个新版本的元组(tuple),同时把旧版本的元组标记为"对当前及之后的事务不可见"。旧版本不是立刻消失的数据,它是活着的物理数据,只是带了 xmax 标记。
这个设计的收益是读不阻塞写、写不阻塞读,快照隔离实现得非常干净。代价就是:任何一次 UPDATE 都会产生一个死元组(dead tuple),只要更新的列有任何一条索引引用它,还会额外在每条相关索引上产生一个新的索引项。一张表上建了五个索引,一次覆盖索引列的 UPDATE 就产生一个堆死元组加五个索引死项。
所以"数据没变多但表变大"是设计使然,不是故障。一行订单状态如果被更新三十次,物理上就留下了三十个版本的元组。业务角度看它还是一行,磁盘角度看它占了三十行的位置。
DELETE 走的也是标记路径:给目标行打上 xmax,标记它对后续事务不可见。行的物理内容没被抹掉,页上的空间也没被回收。这一点和很多从 MySQL 转过来的人的直觉相反——MySQL InnoDB 有 purge 线程会真正物理回收并在页内整理,PostgreSQL 的回收是靠 VACUUM 完成的独立动作。
DELETE 之后的可见性判断也不复杂:一个元组对某个快照可见,当且仅当插入它的事务已经提交且对快照可见,且删除它的事务要么没提交要么对快照不可见。在这个判断里,旧版本必须一直留着,否则老快照读到的就是错的数据。这也解释了为什么 Vacuum 不能随意删除——它删的必须是"所有现存快照都看不见"的版本。
普通 VACUUM 干的事可以概括成四件:把确认无用的死元组的行指针(line pointer)标记为可复用,把这些空闲空间的量记进空闲空间映射(FSM,free space map),把确认对所有事务可见的页面在可见性映射(VM,visibility map)里置上 all-visible 位,在条件允许时截断表尾部的连续空白页。
关键点在前两步:空间变成"可复用",意味着后续对这张表的 INSERT 和 UPDATE 可以直接往这些空洞里填,磁盘文件不会因此变大。但文件本身的长度没变,操作系统看到的还是那么大一块。这就是"磁盘只涨不跌"的全部原因。
尾页截断是唯一的例外,代价是要拿 ACCESS EXCLUSIVE 锁,而且要表尾恰好有一批连续的全空页。对一张活跃更新的表来说,尾部全空页通常是偶发出现的,指望它来把 480G 缩回 120G 不现实。真要缩短文件,只有重写一条路。
膨胀速度基本由三个因子相乘决定:单位时间的更新/删除次数、每行平均字节数、索引数量。订单状态表三个因子全占——状态每流转一次就更新一次,订单行字段多,为了支撑各种查询通常还挂了四五个索引。库存表更极端,热点 SKU 的行可能被反复更新几百次。会话表则是高频 INSERT 加高频 DELETE 的组合,一天几百万次进出。
反过来,日志类、流水类只插入不更新的表就几乎不膨胀,它们的大小和真实数据量保持线性关系。这也是判断"膨胀到底严不严重"的一个参照系:拿一张只插入不更新的表做基准,看它的大小与行数的比值,再拿这个比值去衡量那些更新频繁的表。同类业务的表之间横向对比,比拿绝对值去猜要准得多。
还有一类容易被忽略的膨胀源是失败的大事务。一条跑了一半回滚的批量 UPDATE,回滚之前产生的死元组不会凭空消失,回滚本身不回收空间,这些死元组照样得等 VACUUM 来处理。所以"我们没做过大更新"这种说法,在回滚过批量操作的环境里是不成立的。
autovacuum 的触发公式是:死元组数 ≥ autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × 表的活元组数。默认参数下 threshold 是 50,scale_factor 是 0.2,也就是说一张 1 亿行的表要攒到约 2000 万个死元组才会被触发一次 vacuum。
2000 万个死元组是什么概念?如果每行 200 字节,这就是 4GB 的垃圾常驻在表里,而且这 4GB 是在"攒"的过程中不断被扫描、不断进缓冲区的。更要命的是,攒到阈值之后的那一次 vacuum,要在一个 worker 里处理 2000 万个 TID,索引扫描可能要跑好多遍。
判断方法很简单:SELECT n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE relname='订单表'; 如果 n_dead_tup 长期在几十万量级徘徊而 last_autovacuum 是几天前,就是阈值问题。
处理方式是把阈值改成按表设置,不要动全局参数:ALTER TABLE 表名 SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 5000); 大表上甚至可以给到 0.005。这里的原则是:大表的 scale_factor 必须往下压,小表可以保持默认甚至放宽,因为小表触发代价低。
这是"autovacuum 一直在跑却一个死元组都清不掉"最常见的原因。VACUUM 只能清理"对当前所有活动事务和所有未来事务都不可见"的元组,判断基准就是集群里最老的那个 xmin。只要有一个事务的快照包含了某个 xid 之前的全部历史,那个 xid 之后产生的所有死元组都必须保留。
典型场景有四种:忘了 COMMIT 或 ROLLBACK 的交互式会话(psql 里敲了 BEGIN 就去吃饭了)、应用连接池里拿了连接却长时间不提交的事务、跑了几小时的报表或导出任务、以及连接泄漏后一直挂着 idle in transaction 状态的会话。
查法:SELECT pid, state, xact_start, now() - xact_start AS 时长, backend_xmin, left(query, 60) FROM pg_stat_activity WHERE xact_start IS NOT NULL AND now() - xact_start > interval '5 min' ORDER BY xact_start; 顺带把 idle in transaction 的也捞出来:SELECT pid, state, state_change FROM pg_stat_activity WHERE state = 'idle in transaction' AND now() - state_change > interval '10 min';
处理上,先把语句和 pid 记下来确认不是业务关键任务,再 SELECT pg_terminate_backend(pid);。但更该做的是从应用侧堵住:给连接池设 statement_timeout 和 idle_in_transaction_session_timeout(比如 60 秒和 5 分钟),让数据库自己把这类会话踢掉。靠人去查是查不过来的。
复制槽(replication slot)的作用是让主库保留备库还没消费的 WAL,同时它也会保留一个 xmin——主库必须保留这个 xmin 之后的所有元组变化,否则备库重放时对不上。问题在于:如果一个订阅端下线了、一个 Debezium 类的 CDC 任务停了、一个 pglogical 订阅被删了却没删槽,这个槽还在那儿,它的 xmin 就一直不前进,主库的死元组就一直不能清。
这类问题特别隐蔽,因为它不会报错,只是磁盘一点点涨上去。查法:SELECT slot_name, slot_type, active, active_pid, xmin, catalog_xmin, wal_status, restart_lsn FROM pg_replication_slots; 重点看 active = false 的行,以及 wal_status 已经不是 reserved 的行。
确认不用了就删:SELECT pg_drop_replication_slot('槽名'); 删之前一定要和订阅方确认,删掉之后订阅端要从头重新做同步。如果订阅端还要用,正确做法是先把订阅端拉起来消费,而不是删槽。
prepared transaction 是同类问题的另一个入口。两阶段提交里 PREPARE 之后没 COMMIT PREPARED 也没 ROLLBACK PREPARED 的事务会永久挂着,即使数据库重启也还在,它持有的 xmin 也永久生效。查法:SELECT * FROM pg_prepared_xacts; 确认后 ROLLBACK PREPARED 'gid';。顺带说一句,max_prepared_transactions 默认值在很多发行版里是 0,如果应用用了 XA 事务而这个值是 0,报错会很明显;但如果设了非 0 又没人清理,就是慢性膨胀。
hot_standby_feedback 这个参数本意是好的:备库上跑长查询时,如果主库清理了备库还在读的元组,备库重放会遇到冲突,查询被取消。开了这个参数之后,备库会周期性地把自己最老的 xmin 回报给主库,主库的 vacuum 就会绕开这些元组。
代价就是:备库上一条跑十分钟的分析查询,会把主库的清理视界往回拖十分钟。如果备库上常年有 BI 工具开着长连接跑看板,主库的膨胀就会非常严重,而且运维在主库上怎么查都查不到原因——因为 xmin 不在主库上。
查法:在主库上 SELECT application_name, state, backend_xmin, backend_type FROM pg_stat_replication;,看 backend_xmin 这一列是不是明显滞后。再结合 pg_replication_slots 里的 xmin 一起看。
取舍上只有三条路:接受备库查询被取消(关掉 feedback,配合 max_standby_streaming_delay 给一个容忍窗口,让备库延迟应用 WAL 而不是立刻取消查询);把长查询迁到专门的离线库或者逻辑复制出来的分析库;或者在业务低峰期定期把 feedback 关掉做一轮清理。不要指望有第四个选项——这个冲突是结构性的。
补一句,还有一种情况不属于上面四类但经常被混为一谈:autovacuum worker 在表上拿不到锁。DDL、长查询持有的 ACCESS SHARE 锁不会挡住 vacuum(vacuum 要的是 SHARE UPDATE EXCLUSIVE),但另一个 DDL 或者一个正在跑的 CREATE INDEX 会挡住。表现是 last_autovacuum 很久不更新,而 n_dead_tup 一直涨。查 pg_stat_progress_vacuum 可以看到正在进行的 vacuum 卡在哪个阶段。
堆表里一个死元组腾出来的空间,任何一行新数据都可以往里填,只要填得下就行。索引完全不同:B-tree 是按 key 有序组织的结构,一个索引页里空出来的位置,只有当新插入的 key 恰好落在这一页负责的 key 区间里时才能复用。
如果业务写入的 key 是随机分布的(UUID 主键、随机订单号),新项会均匀散落到各页,理论上看复用率还行。但如果 key 是单调递增的(自增 ID、时间戳),插入永远集中在最右侧那一页,左边那些因为删除空出来的位置就再也没人用了。反过来,如果更新的是随机 key,页分裂(page split)会不断发生,索引高度和页数一起涨。
还有一个更麻烦的点:索引页只有在整页变空时才会被回收进 FSM,部分为空的页会一直挂着。所以索引的"有效密度"是持续下降的,pgstatindex 里那个 avg_leaf_density 数字,常见生产库里掉到 40%–60% 是很普遍的事,意思是这个索引有一半的空间是空气。
仅索引扫描(index-only scan)是 PostgreSQL 里非常重要的一条优化路径:如果查询需要的列全在索引里,而且对应的堆页在 visibility map 里被标记为 all-visible(整页对所有事务可见),那么数据库就不需要回表确认可见性,直接从索引返回数据,省掉一次随机 IO。
VM 的更新完全依赖 VACUUM。膨胀严重、vacuum 又清不掉东西的环境里,VM 里的 all-visible 位大面积缺失,优化器虽然还是选了 index-only scan,执行时却不得不逐行回表去确认可见性。用 EXPLAIN (ANALYZE, BUFFERS) 能看到这一行:Heap Fetches: 123456。这个数字只要不是 0,这次"仅索引扫描"就名不副实了。
这就解释了文章开头那个现象——按主键查的 SQL 为什么会变慢。它慢在三处叠加:B-tree 层数变多导致索引遍历读更多页;VM 不新导致本该省掉的回表一次都没省;回表命中的堆页里大半是死元组,缓存命中率被这些无效页拖低。三处加起来,3 毫秒变 40 毫秒一点都不奇怪。
PostgreSQL 自带扩展 pgstattuple 提供了最直接的手段。CREATE EXTENSION pgstattuple; 之后,SELECT * FROM pgstatindex('索引名'); 会返回 index_size、leaf_pages、avg_leaf_density、leaf_fragmentation 等字段。avg_leaf_density 长期低于 60%、leaf_fragmentation 高于 30%,基本可以判定这个索引该重建了。
扩展的代价要说清楚:pgstatindex 会完整扫一遍索引,pgstattuple 会完整扫一遍表,对大对象来说这是实打实的 IO。生产环境建议用 pgstattuple_approx,它基于采样,代价低得多。别把它塞进每分钟跑一次的监控脚本,一天跑一次足够。
不想装扩展的时候,用大小比值做粗判:SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) FROM pg_stat_user_indexes WHERE relname='表名'; 把每个索引的大小和表大小放一起看。经验上,如果索引总大小超过堆表大小、或者单个索引比同结构的另一张表上同类索引大出一截,就值得用 pgstatindex 复查。这个判断不精确,但能帮你缩小排查范围。
字段内容和总行宽超过 TOAST 阈值(默认约 2KB)时,超长字段会被压缩并移出主表,存到 pg_toast 命名空间下对应的 TOAST 表里,主表只留一个指针。TOAST 表也是堆表,同样有 MVCC 死元组,同样会膨胀,而且它更新频繁起来比主表还狠——因为改一次 JSON 字段,整块内容会被重新压缩写入一遍。
麻烦的是 TOAST 表不在 pg_stat_user_tables 里,很多人查了一圈没查到它。查法:SELECT c.relname, t.relname AS toast名, pg_size_pretty(pg_total_relation_size(t.oid)) FROM pg_class c JOIN pg_class t ON c.reltoastrelid = t.oid WHERE c.relname='表名'; 拿到了 TOAST 表名之后,再去 pg_stat_all_tables 里查它的 n_dead_tup。
TOAST 表有自己独立的 autovacuum 参数,可以在建表或改表时单独指定:ALTER TABLE 表名 SET (toast.autovacuum_vacuum_scale_factor = 0.05, toast.autovacuum_vacuum_threshold = 1000); 与它配套的另一个参数是 toast_tuple_target,调大它(默认约 2KB)会让更多内容尝试留在主表内并参与行外存储判断,对 JSON 类字段为主的表值得试,但要实测确认。
普通 VACUUM(包括 autovacuum 触发的那些)拿的是 SHARE UPDATE EXCLUSIVE 锁,这个锁和 SELECT、INSERT、UPDATE、DELETE 都不冲突,只和其他 VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY 以及 DDL 冲突。所以它对业务基本透明。
它的产出是三样:死元组空间变成可复用、FSM 更新、VM 更新。磁盘文件长度不变(除非尾部恰好有连续空页)。换句话说,它解决的是"下一批写入不要再撑大文件"和"仅索引扫描能不能生效"这两个问题,不解决"磁盘已经涨到 480G 了怎么办"。
这条路线适合的场景是:膨胀率还在 1.5 倍以内、写入量稳定、磁盘容量还有余量的表。这种情况下频繁一点跑 VACUUM 就够了,成本几乎为零。给热点表单独设 autovacuum_vacuum_cost_delay = 0(不节流)在夜间窗口是常见做法。
VACUUM FULL 的实现方式是按当前数据重新写一份新表文件,写完用新文件替换旧文件。这意味着:整表重写、所有索引按新数据重建、全程持有 ACCESS EXCLUSIVE 锁(连 SELECT 都挡)、需要至少等同于"表 + 索引"大小的空闲磁盘。
ACCESS EXCLUSIVE 锁这件事的杀伤力在于它不只是挡住这张表的查询,它还会排队等待这张表上所有现存事务结束,而在它等待期间,后续所有想访问这张表的会话都排在它后面。一个跑了一小时的报表任务,能把一次 VACUUM FULL 的等待时间拉到一小时,同时把整张表的访问全部堵死。这就是开头那台机器被锁两小时的完整机制。
它还会产生大量 WAL,主从架构下要考虑复制延迟和备机的 WAL 磁盘。所以真正该用 VACUUM FULL 的场景其实很窄:表已经小到几十 GB 以内、有明确的维护窗口、磁盘余量充足、且膨胀率已经高到在线手段解决不了。大表上用它,本质上是在用停机时间换磁盘。
pg_repack 和 pg_squeeze 这类工具做的是同一件事:在后台建一份紧凑的新表,用触发器(pg_repack)或者逻辑复制槽(pg_squeeze)捕获重建期间的增量变更并持续追加,追平之后在极短的时间内交换原表和新表。锁的时间从两小时压到秒级。
门槛有三条。第一,磁盘照样要额外占用,量级等同于"表 + 索引",空间省不下来。第二,pg_repack 需要在表上加触发器来捕获变更,这意味着重建期间这张表上的 DML 有额外开销,写入越密集拖累越明显,重建追平的时间就越长,极端情况下追不平。第三,它需要表有主键或非空唯一索引,而且通常需要超级用户权限。
所以在线重建不是万能的,它适合的区间是:几十 GB 到几百 GB、有主键、写入压力不是极端高、磁盘余量够。再大或者写得再猛,就得回到"分区 + 拆分"的架构层面去解决,而不是靠工具硬扛。
| 清理路线 | 是否阻塞读写 | 能否归还空间 | 额外磁盘占用 | 耗时量级 | 适用表规模 |
|---|---|---|---|---|---|
| 普通 VACUUM(含 autovacuum) | 不阻塞读写,仅与 DDL、并发建索引冲突 | 不归还,只标记页内可复用;尾部连续空页可截断 | 几乎为零,仅 WAL 增长 | 分钟到小时级,取决于死元组数与索引数量 | 任意规模,日常手段 |
| VACUUM FULL | 全程 ACCESS EXCLUSIVE,读写全停,且会排在既有事务之后 | 完全归还,文件缩到实际数据大小 | 约等于表 + 索引之和,另需 WAL 空间 | 与表大小线性相关,百 GB 级常为小时量级 | 小表或大维护窗口场景,百 GB 以上慎用于生产高峰 |
| 在线重建(pg_repack / pg_squeeze) | 仅在交换瞬间持排它锁,通常秒级;期间有触发器或复制开销 | 完全归还,同时重建全部索引 | 约等于表 + 索引之和,重建完成前不能释放 | 小时量级,写入越密集追平越慢,极端情况追不平 | 数十 GB 到数百 GB,需有主键或非空唯一索引 |
HOT(Heap Only Tuple)update 是 PostgreSQL 里一个被严重低估的机制。当一个 UPDATE 满足两个条件时——被更新的列没有被任何索引引用,且新版本能放进同一个堆页——数据库就把新版本直接写在同一个页里,并且完全不产生新的索引项。索引里那条记录仍然指向旧的行指针,通过页内的 HOT 链就能走到新版本。
HOT update 的收益是双重的:堆表里少一个需要跨页处理的版本链,索引里少 N 个死项(一张表有几个索引就少几个)。对更新密集的表,这一招能把索引膨胀速度直接砍掉一大半。而且 HOT 链在页内可以被"修剪"(pruning),这个动作甚至在只读查询访问该页时就能顺手完成,不必等 vacuum。
让 HOT 生效的关键是页里得有空位,这就是 fillfactor 的作用。ALTER TABLE 表名 SET (fillfactor = 85); 之后,INSERT 只把页填到 85% 就换新页,剩下的 15% 留给后续 HOT update。更新密集的表通常给到 70–85,只插入的表保持 100(默认值)以节省空间。
两个细节必须知道。第一,fillfactor 对已经存在的页不生效,改完之后要做一次重写入(VACUUM FULL 或在线重建)才能让存量页也带上空位,之后新写入的页才自动生效。所以这个动作要提前做,别等膨胀到 480G 了再想起来。第二,索引也有自己的 fillfactor,B-tree 默认是 90,随机插入的索引可以考虑调到 70–80 来减少页分裂。
数据生命周期管理里最常见的操作是"删掉三个月前的数据"。用 DELETE 做这件事,代价是产生几千万个死元组、几千万条 WAL、一次超长事务、以及事后怎么也清不干净的膨胀。用分区表做这件事,代价是一次元数据操作。
按时间做了分区之后,清理历史数据变成 DROP TABLE 分区名; 或者 ALTER TABLE 主表 DETACH PARTITION 分区名;。前者直接把分区文件从文件系统删掉,磁盘立刻归还;后者把分区摘成独立表,可以先备份再删,安全窗口更长。两者都是秒级完成,不产生死元组,不需要后续 vacuum。
分区还带来一个额外好处:每个分区是独立的表,有自己的 autovacuum 阈值判断。一个月的分区可能只有几千万行,触发阈值按这个量级算,比一张 10 亿行的大表按 20% 算要灵敏得多。历史分区不再写入之后,一次 VACUUM FULL 或者干脆 VACUUM FREEZE 收尾,成本也低得多。
代价要说清楚:分区会增加规划时间(分区数多时明显)、需要维护分区创建逻辑(可以用 pg_partman 之类的扩展)、跨分区的唯一约束有限制、老版本里分区裁剪不如新版本完善。数据量没到千万行量级的表硬上分区是过度设计,但"每天新增百万行 + 只保留 N 个月"这种场景,分区基本是标配。不同版本的分区能力与裁剪行为可能有差异,以所用版本官方文档为准。
一次性 UPDATE 大表 SET 状态='X' 是膨胀事故的经典起因。几千万行在一个事务里更新,产生几千万个死元组,全部在一个 xid 之后,而这个事务本身还在跑——所以这批死元组在事务结束前一律清不掉,事务结束后也要等下一次 vacuum 慢慢啃。
改成拆批之后,局面完全不同:WHERE 条件按主键范围或者时间范围切成每批 1 万到 10 万行,每批一个独立事务,批与批之间 sleep 几百毫秒到几秒。这样做的收益是:每批事务提交后,它产生的死元组立刻变成"可回收",autovacuum 能在批次间隙插进来处理;单批次事务短,不会钉住 xmin;出问题可以随时中断,回滚代价小。
批次大小的取舍看两个指标:单批耗时(控制在 1–5 秒)和 autovacuum 能不能跟上(观察 n_dead_tup 是否稳定而不是持续爬升)。如果拆批之后 n_dead_tup 还在涨,说明批次太大或者 vacuum 跟不上,继续把批次调小、间隔调长。
同样的思路适用于批量 INSERT 和批量 DELETE。还有一个容易被忽略的做法:大批量变更之前,先给这张表临时把 autovacuum 参数调激进(ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 0)),变更完成后再改回来。这比事后抢救省事得多。
有一类"表变大了 SQL 变慢"的问题,根源不在膨胀而在统计信息。批量导入或者大批量更新之后,pg_class.reltuples 和 pg_stats 里的统计数字还是旧的。优化器拿这些旧数字去算代价,行数估错、选择率估错,执行计划就可能选成全表扫描、选成错误的多表连接顺序、或者把 hash join 选成 nested loop。
autovacuum 也会触发 analyze,但它有自己的阈值(autovacuum_analyze_scale_factor 默认 0.1)和自己的节奏,批量导入后要等它判断满足条件才跑。这段窗口期内,所有涉及这张表的查询都可能在跑一个错误计划。所以批量导入之后手动来一次 ANALYZE 表名; 是标准动作,代价极低。
还有一个更细的点:n_distinct。对于列值分布很特殊(比如高度倾斜的状态列、或者跨分区重复度极高的租户 ID),采样估算出来的 distinct 值数量可能严重偏低,优化器会低估或者高估某个条件的选择率。这时候可以人为钉住:ALTER TABLE 表名 ALTER COLUMN 列名 SET (n_distinct = 5000); 或者提高采样精度 ALTER TABLE 表名 ALTER COLUMN 列名 SET STATISTICS 1000; 然后重跑 ANALYZE。这个动作在多租户表上特别有效。
膨胀治理过程中经常要补索引或者重建索引,这里有个独立的坑。普通 CREATE INDEX 在表上拿 SHARE 锁,这个锁不挡 SELECT,但挡住所有 INSERT/UPDATE/DELETE。在一个写入频繁的表上建一个索引,索引构建可能要几十分钟,这段时间写入全停——这在很多业务里等同于故障。
正确做法是 CREATE INDEX CONCURRENTLY。它不拿 SHARE 锁,允许 DML 并发,代价是:需要扫两遍表、整体耗时更长、不能在事务块里执行、它会等待所有已有事务结束才进入下一阶段(所以长事务会拖住它)、以及最关键的——如果中途失败,会留下一个 indisvalid = false 的无效索引,这个索引不参与查询也不参与更新维护,但会一直拖慢这张表的写入。
所以并发建索引之后一定要确认结果:SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; 有结果就先 DROP INDEX CONCURRENTLY 再重来。建索引期间也不要在同一张表上并行做重写入操作(repack、VACUUM FULL),两者会互相等待。PG 12 之后还支持在并发建索引时观察进度:SELECT * FROM pg_stat_progress_create_index;。
膨胀这件事靠人肉巡检是不现实的,得有几个能自动报警的指标。建议至少盯这六个。
第一,n_dead_tup / n_live_tup 的比值。健康的更新型表通常稳定在个位数百分比;持续超过 20% 且 last_autovacuum 不更新,就是明确告警。第二,单表以及库级别的磁盘占用趋势。建议每天采样一次 pg_total_relation_size(含索引和 TOAST)存进时序库,看斜率而不是看绝对值,斜率异常抬升往往比"到达阈值"更早暴露问题。
第三,最长事务时长和 idle in transaction 会话数。这是 xmin 被钉住的直接信号,建议设 5 分钟和 10 分钟两级阈值。第四,pg_replication_slots 里 active = false 的槽数量,这个报警应该是零容忍,一条都不能有长期存在。
第五,数据库级别的事务 ID 年龄:SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC; 以及表级别 SELECT relname, age(relfrozenxid) FROM pg_class WHERE relkind='r' ORDER BY 2 DESC LIMIT 20; 这个数字接近 autovacuum_freeze_max_age(默认 2 亿)时,会触发 anti-wraparound vacuum,它不受 cost limit 限制、优先级最高、会抢 worker、IO 打满。而且它一旦启动是不能轻易取消的(取消会立刻重启)。真正危险的情况是年龄逼近 20 亿上限,那时候数据库会拒绝写入。
第六,备库上的 pg_stat_database_conflicts,以及主库 pg_stat_replication 里的 backend_xmin 滞后程度。这一对数字能告诉你主库膨胀是不是副本的锅。
膨胀三倍的直接后果是:任何一次逻辑读都要碰三倍的页。如果这些页不在 shared_buffers 里,就变成三倍的物理 IO。反过来推规格时,思路是这样的:先统计业务高峰期的逻辑读页数量(可以从 pg_stat_database 的 tup_fetched 和 blks_read,以及 EXPLAIN (ANALYZE, BUFFERS) 里的 buffers 行估算),再乘上膨胀系数,得到高峰期真实需要扛住的随机读页数;除以目标响应时间,就换算成了所需的随机读 IOPS。
这里有个容易忽略的点:膨胀带来的额外页,几乎全是随机分布的小块读,对机械盘极其不友好,对低 IOPS 配额的云盘也不友好。顺序带宽再大也救不了随机读 IOPS 不足。所以这类负载挑机器的时候,先看的应该是存储的随机读 IOPS 能力与延迟稳定性,而不是 CPU 核数或者顺序吞吐。
从容量和 IO 两个维度一起看,这类"单库几百 GB、更新密集、对账报表还吃全表扫描"的场景,配置上通常落在三档里:一档是单盘大容量 SATA/SAS 机械盘,容量便宜但随机读 IOPS 只有一两百,只适合冷数据或者归档库;一档是企业级 SATA/SAS SSD 或者入门 NVMe,随机读 IOPS 在几万到十几万量级,多数中小业务单库够用;再往上是 NVMe 做 RAID 或直连、配合大内存,用于更新极其密集且监控不能停的核心库。
在比选这几档机器的时候,除了随机读 IOPS,还要把"能否给到足够的磁盘余量"当成硬指标——因为 VACUUM FULL 和在线重建都需要一倍于表大小的空闲空间。像一万网络这类提供大容量存储型服务器的厂商,在这类 PostgreSQL 单库场景里通常是作为比选对象之一被拉进来对比的,重点比的就是单盘容量上限、SSD 盘位数量、以及随机读 IOPS 的实测口径;具体机型与价格需询价,以官网实时报价为准。
shared_buffers 的常规建议是从物理内存的 25% 起步,在专用数据库服务器上可以往上探,但超过一定规模后收益递减,而且带来检查点压力。膨胀对这个参数的影响不是"应该调大",而是"有效利用率被稀释"。
看命中率时建议区分两层:PostgreSQL 自己的缓冲区命中率,以及操作系统页缓存命中率。PostgreSQL 的缓冲区命中率低不代表真的走了磁盘,因为 OS 可能还有一层缓存兜着。真正要盯的是 pg_statio_user_tables 里的 heap_blks_read 与 heap_blks_hit,以及系统层面的实际磁盘读 IO。
膨胀把大量无用页塞进 shared_buffers,等于把热点数据挤了出去。这种情况下最有效的动作不是加内存,而是把膨胀清掉——用内存去填膨胀这个洞,成本明显高于清理。
VACUUM 的工作方式决定了它受内存限制很明显:它先把堆表里能找到的死元组的 TID 收集进内存数组,内存用完之后,就必须拿着这一批 TID 去把每张索引扫一遍,清完再回去扫堆表收集下一批。每条死元组大约占用 6 字节内存。
按这个口径算(工程估算,以官方文档说明的 6 字节/TID 为准):autovacuum_work_mem 沿用 maintenance_work_mem 时,默认 64MB 大约能容纳 1100 万个 TID。也就是说,一张表一次累积超过约 1100 万个死元组,vacuum 就要对每张索引多扫一遍。索引越多、死元组越多,扫描遍数越多,vacuum 越慢,而死元组又在这期间继续累积——这就是"越拖越糟"的正反馈。
所以给 autovacuum 单独设内存是很划算的:autovacuum_work_mem = 1GB 在内存 32GB 以上的机器上完全给得起,它能把大表单次 vacuum 的索引扫描遍数压下来。注意 autovacuum_max_workers 个 worker 会各自占用这么多内存,总账要算清楚:3 个 worker × 1GB 就是 3GB。
autovacuum 的节流机制是这样工作的:每做一定量的工作累加一次 cost(vacuum_cost_page_hit 默认 1、vacuum_cost_page_miss 默认 2、vacuum_cost_page_dirty 默认 20),累计超过 autovacuum_vacuum_cost_limit 就 sleep autovacuum_vacuum_cost_delay。默认参数下,粗算下来单次 vacuum 的吞吐被限制在每秒几万页量级(具体数值随版本默认值与页面命中情况变化,不同版本默认值可能略有差异,以所用版本官方文档为准)。
取舍的两头很清楚:cost_limit 调高、delay 调低,vacuum 跑得快,但和业务抢 IO;调低则 vacuum 慢,膨胀继续累积。多数生产环境的合理做法是分级——全局保持保守,对少数几张明确的热点表单独设激进参数:ALTER TABLE 表名 SET (autovacuum_vacuum_cost_delay = 0); 这样 IO 争抢被限制在有限的表上。
autovacuum_max_workers(默认 3)要不要加,看的不是表数量,而是"有没有因为 worker 不够而排队"。pg_stat_progress_vacuum 里同时在跑的 worker 数长期等于上限,且大量表的 n_dead_tup 在涨,才需要加。加到太多反而更糟:多个 worker 并行扫不同表,IO 总量叠加,而且它们共享那一份全局 cost limit(autovacuum 的 cost 是在所有 worker 之间分摊的),加 worker 会让每个 worker 都变慢。
算式是:所需空闲空间 ≈ pg_total_relation_size(表) × (1 + 索引占比) × 安全系数。展开说,VACUUM FULL 要写一份新的堆表文件,还要按新数据重建所有索引,新旧文件在交换之前同时存在。所以最保守的估法是:堆表大小 + 所有索引大小 + TOAST 大小,也就是 pg_total_relation_size 的全额,再乘 1.2 的安全系数。
在此之上还要预留 WAL 空间。VACUUM FULL 的重写过程会写 WAL(不同于某些 bulk 操作在特定 wal_level 下的优化),主从架构下主库 WAL 会暴涨,备机的 wal_keep_size 或者归档目录也要跟着算。经验上再留 max_wal_size 的 2–3 倍余量比较稳妥。
把这些加起来,一张 200GB 的表(含索引)做 VACUUM FULL,需要预留 240GB 以上的空闲空间,再叠 WAL。这个数字提前算清楚,就不会出现"跑到一半磁盘写满,数据库直接崩"这种事故。如果算完发现空间不够,正确的顺序是先扩盘,不是先赌一把。
坑一:不看 xmin 视界就直接把 autovacuum 频率调高。 发生原因是对"清不掉"和"清得慢"没有做区分,看到 n_dead_tup 高就下意识认为触发不够频繁。判断方法很简单:看 last_autovacuum 如果是近期、autovacuum_count 也不低,但 n_dead_tup 没降下来,就说明 vacuum 跑了却没清掉东西,问题在视界不在频率。规避方式是动手前先跑一遍长事务、复制槽、prepared transaction 三项排查,确认 xmin 能正常推进再谈调参。频率调得再高,视界不前进也一个都清不掉,反而白白消耗 IO。
坑二:把 autovacuum 关掉改成 crontab 定时跑。 发生原因通常是某个时刻 vacuum 抢了业务 IO,运维一怒之下关掉。这带来的问题是:autovacuum 有自适应判断,它知道哪张表该清、什么时候该清、以及事务 ID 年龄到了必须立刻 freeze;定时脚本做不到这一点。判断方法:如果环境里已经出现"只在凌晨跑 vacuum"的安排,那白天产生的死元组会全天留在表里,报表和查询会全天变慢。规避方式是保留 autovacuum,只对特定的重活(比如批量导入后立即 ANALYZE)用脚本补充,并且用 per-table 参数而不是全局开关来调节激进程度。
坑三:磁盘只剩 10% 的时候执行 VACUUM FULL。 发生原因是没算清楚需要额外一倍空间,看着当前的剩余容量觉得"应该够"。VACUUM FULL 是重写,新旧文件并存,中途空间写满会导致操作失败,严重时数据库因无法写入而崩溃,恢复时间远超预期。判断方法:执行前用 pg_total_relation_size 算出全额需求并乘 1.2,再核对 df -h 的实际可用空间。规避方式:空间不够就先扩盘,或者改用在线重建工具配合分批清理,实在不行先删掉可以丢弃的历史分区腾挪空间,而不是直接赌 VACUUM FULL 能跑完。
坑四:订阅端下线了,复制槽却留在那儿没人管。 发生原因是复制槽由订阅端创建,主库侧不主动清理,CDC 任务迁移、Kafka Connect 停掉、测试订阅忘记删除,都会留下孤儿槽。它的 xmin 和 catalog_xmin 钉住主库视界,同时 restart_lsn 之后的 WAL 也一直保留,磁盘占用是双线增长。判断方法:定期查 pg_replication_slots,凡是 active = false 的都要追责到具体负责人。规避方式是加一条监控:存在 inactive 槽超过 24 小时就告警;对不再使用的槽执行 pg_drop_replication_slot,同时设置 max_slot_wal_keep_size 给 WAL 保留量兜底。
坑五:只读副本的 hot_standby_feedback 开着,主库侧却没人知道。 发生原因是这个参数通常在搭建副本时"顺手"打开,解决当时的查询取消报错,之后没有人在文档里记录。结果是主库膨胀莫名其妙,而在主库上怎么查 pg_stat_activity 都查不到任何长事务。判断方法:主库上查 pg_stat_replication 的 backend_xmin 列,看它是否明显落后于当前 xid;再到副本上确认 hot_standby_feedback 的实际值。规避方式:把"哪些副本开了 feedback"写进架构文档;BI 类长查询迁到独立的逻辑复制库;或者在可控窗口内临时关闭 feedback 并配合 max_standby_streaming_delay,让副本延迟应用 WAL 而不是拖住主库。
Q1:VACUUM 到底多久跑一次合适,有没有一个通用频率?
A1:没有通用频率,只有判断标准。合理的做法是先让 autovacuum 自己管,然后盯 n_dead_tup / n_live_tup 这个比值:稳定在 5% 以内说明节奏合适;持续在 10%–20% 说明触发偏晚,需要按表调低 autovacuum_vacuum_scale_factor;如果在 20% 以上且 last_autovacuum 还很新,那根本不是频率问题,是 xmin 视界被钉住了。订单状态、库存、会话这类表建议给到 0.01 甚至 0.005 的 scale_factor,并把 cost_delay 设为 0;只插入不更新的流水表保持默认即可。所有参数一律用 ALTER TABLE ... SET 按表设置,不要改全局,否则小表会被无意义地反复扫描。
Q2:autovacuum 要不要关掉,改成手工定时跑,这样不是更可控吗?
A2:不建议关。autovacuum 的核心价值不只是"清理死元组",它还负责更新 visibility map(决定仅索引扫描能不能生效)、更新统计信息(决定执行计划)、以及最关键的 anti-wraparound freeze——事务 ID 年龄超过 autovacuum_freeze_max_age 时必须有 vacuum 来做 freeze,这件事如果靠人去记,一旦漏掉,事务 ID 逼近回卷上限时数据库会直接拒绝写入,那是真正的生产事故。手工脚本只能作为补充:批量导入之后补一次 ANALYZE,或者在维护窗口对热点表做一次加强 VACUUM。真正想要"可控",应该做的是给具体表设 per-table 参数、给业务低峰设更激进的窗口参数,而不是把整个机制关掉。
Q3:VACUUM FULL 要跑多久,要预留多少磁盘?
A3:耗时和表大小基本线性相关,同时受磁盘随机写能力、索引数量、CPU 影响,百 GB 级的表在普通 SSD 上通常是小时量级,具体时间只能按自己的环境实测,不要照搬别人的数字。空间上必须预留 pg_total_relation_size 的全额(堆表 + 所有索引 + TOAST),再乘 1.2 的安全系数,因为新旧文件在交换完成前是并存的;WAL 增长也要单独算进去,建议再留 max_wal_size 的 2 到 3 倍。执行期间全程持 ACCESS EXCLUSIVE 锁,且会排在这张表上所有现存事务之后,一个跑了半小时的报表能把等待时间拉长半小时。所以执行前必须先确认维护窗口、先扩盘、先确认没有长事务。
Q4:表膨胀到什么程度才值得动手处理?
A4:建议分三档看。膨胀率(实际大小 ÷ 理论紧凑大小)在 1.5 倍以内,且磁盘余量充足、查询没有明显变慢,加强 autovacuum 参数就够了,不必做重写。膨胀率在 1.5 到 3 倍之间,或者主键查询耗时已经明显上升、Heap Fetches 大量出现,说明已经影响性能,应该安排在线重建(pg_repack / pg_squeeze)。超过 3 倍,或者磁盘已经吃紧、索引总大小超过堆表,就属于必须处理的状态,这时候要优先做的是扩盘 + 在线重建 + 回头改写入模式(fillfactor、分区、拆批),三个动作一起上。判断膨胀率可以用 pgstattuple 扩展,或者按"实际大小 ÷ 估算行数 ÷ 平均行宽"粗算。
Q5:索引膨胀只能靠重建吗,有没有别的办法?
A5:重建是最直接的,但不是唯一手段,也不该是第一步。先确认索引膨胀的成因:如果是随机 key 导致的页分裂,可以调低索引的 fillfactor(默认 90,随机插入场景可试 70–80)让页分裂延后发生;如果是更新被索引引用的列,那就应该让更新走 HOT 路径——要么减少该列上的索引,要么把这些列从索引里挪出去,配合表的 fillfactor 调整。存量部分可以用 REINDEX INDEX CONCURRENTLY 在线重建单条索引,它不像 VACUUM FULL 那样锁整表,代价是要额外空间且会拖慢写入。还有一点容易被忽略:索引膨胀反复复发,说明源头(堆表的 update 模式)没改,重建完过几个月还是老样子。
Q6:上了分区表是不是就能根治膨胀?
A6:分区能解决"历史数据清理"这一类膨胀,解决不了"热数据反复更新"这一类膨胀。按时间分区之后,删除三个月前的数据从几千万行 DELETE 变成一次 DROP PARTITION,不产生死元组,磁盘立刻归还,这一块是根治的。但如果业务是频繁更新最近几天的数据(订单状态流转、库存扣减),那最近的分区照样膨胀,该清还得清。分区的额外收益是每分区独立判断 autovacuum 阈值,历史分区不再写入之后做一次 VACUUM FREEZE 收尾成本极低。所以准确的结论是:分区解决生命周期管理,fillfactor 加 HOT 解决热更新,两者是互补关系不是替代关系。
Q7:磁盘已经快满了,第一步该做什么?
A7:先降占用,再谈清理,顺序不能反。第一步看 pg_replication_slots,有 inactive 的槽且确认订阅端不用了就删掉,这一步往往能立刻释放几十 GB 的 WAL。第二步看 WAL 目录和归档目录,确认是不是归档没跟上导致 pg_wal 堆积,优先解决归档链路而不是删 WAL 文件——手动删 pg_wal 里的文件会直接破坏复制和崩溃恢复。第三步找可以丢弃的历史数据:如果有分区表,DROP 掉确认不再需要的分区。第四步才是考虑扩盘。整个过程里不要在没有空闲空间的情况下启动 VACUUM FULL,它需要先写一份完整的新表,空间不够会直接把数据库拖垮。
Q8:把 autovacuum_vacuum_cost_limit 调大,会不会把业务拖垮?
A8:有可能,取决于磁盘还剩多少余量。这个参数控制 vacuum 每秒能做多少"工作量",调大就是让 vacuum 少休息多干活,多出来的部分全部落在 IO 上。判断方法:调之前先看业务高峰期的磁盘 IO 利用率(util 与队列深度)以及读写延迟,如果高峰期已经接近饱和,那调大 cost_limit 会直接表现为业务 SQL 变慢。安全的做法分两步:全局保持不变或者小幅上调,然后对少数几张明确的热点表单独设 autovacuum_vacuum_cost_delay = 0,把 IO 争抢限制在有限范围内;或者在业务低峰用定时任务临时把参数调激进,高峰期改回来。注意 autovacuum 的 cost limit 是在所有 worker 之间分摊的,加 worker 不会让总量变大。
本文涉及的机制描述、参数名称与系统视图,参考 PostgreSQL 官方文档中关于 MVCC、VACUUM、autovacuum 守护进程、B-tree 索引实现、visibility map 与 free space map、分区表、以及监控统计视图(pg_stat_user_tables、pg_stat_activity、pg_stat_replication、pg_replication_slots、pg_prepared_xacts、pg_stat_progress_vacuum、pgstattuple 扩展)的公开说明。不同大版本的参数默认值、分区实现细节与部分视图字段可能存在差异,实际以所用版本的官方文档为准。
文中出现的耗时、空间占用、IOPS 换算均以"通用工程估算"口径给出,用于说明量级关系与决策顺序,不代表任何具体环境下的实测结果,实际数值需按机型、存储介质、数据量与并发特征实测确认。服务器机型、带宽与价格相关内容需实时询价,具体以签约时最新报价与合同为准。相关服务信息可参见 https://www.idc10000.net/ 。
结论按运维型给出,不讲"结合自身情况选择"这类废话,直接按场景排优先级。
成本型场景(磁盘还没吃紧,只是查询变慢):不要动 VACUUM FULL。先把长事务、inactive 复制槽、prepared transaction 这三件事查干净,再把热点表的 autovacuum_vacuum_scale_factor 按表调到 0.01–0.02,把 autovacuum_work_mem 提到 1GB 量级。这一套零成本动作通常就能让 n_dead_tup 曲线掉头,主键查询的耗时也会跟着回来,因为 visibility map 一旦更新,仅索引扫描就恢复正常了。
架构型场景(每天新增百万行、只保留 N 个月、报表吃全表扫描):优先级是分区表 + DROP PARTITION,这一刀解决的是最大的那块空间来源;配套动作是把 fillfactor 设到 80 并配合 HOT update,从源头压住索引膨胀;收尾动作才是周期性在线重建。这三步做完,autovacuum 基本就不需要特殊关照了。
紧急型场景(磁盘已经到 80% 以上):顺序是删槽 → 修归档 → 扩盘 → 在线重建,任何一步都不允许在空闲空间不足时启动 VACUUM FULL。扩盘这一步在物理机上通常意味着停机或者热插拔,所以磁盘规划阶段就该把"一倍表大小的余量"当成常态配置留出来,而不是等出事再补。
一句话总结:autovacuum 不该背这个锅,真正该背锅的是"没有清理视界的长事务与孤儿复制槽"、"更新密集却没设 fillfactor 的表设计"、以及"用 DELETE 做数据生命周期管理"这三个习惯。VACUUM FULL 是手术刀,不是创可贴。
一万网络深耕 19 年(成立于 2007 年),面向 PostgreSQL 这类单库体积大、更新密集、对随机读 IOPS 敏感的数据库场景,可提供从选型到上架的一对一支持。数据库主库与只读副本的机型选择上,重点协助比对三件事:单盘容量上限与 SSD 盘位数量(决定能不能留出一倍表大小的重构余量)、存储的随机读 IOPS 与延迟稳定性(决定膨胀之后查询还能不能扛住)、以及内存容量(决定 shared_buffers 与 autovacuum_work_mem 能给到什么水平)。
服务层面提供 7×24 中文工单支持、平均 5 分钟响应,硬件故障 10 分钟自动迁移;系统盘提供免费快照(每日 3 份、30 秒回滚),可为 VACUUM FULL、在线重建、大版本升级这类高风险操作提供操作前的回滚保障。节点覆盖华南、华东、华北、中国香港及海外,网络侧采用 BGP 多线 + CN2 GIA 回国线路,适合主库在国内、备库或分析库放在中国香港节点的部署形态;同时提供 5–20G 免费 DDoS 防护与免费备案协助,自营机柜最快 1 分钟上架。
需要注意的是,具体机型配置、磁盘组合、带宽与价格需实时询价,以官网实时报价与合同为准,本文不给出也不承诺任何具体价格数字。如果您正在处理 PostgreSQL 膨胀、需要扩展磁盘容量、或者要为只读副本选型,可以把当前的表大小、日均更新量、索引数量与磁盘剩余情况整理一份,交给工程师按实际数据反推需要的 IOPS 与容量余量,避免按经验值拍脑袋导致后续反复扩容。
上一篇:监控点每 10 秒采一次,半年后先撑爆的是磁盘:时序数据的压缩、降采样与留存该怎么排
下一篇:checkpoint 从 30 秒拖到 8 分钟还没跑完:Flink 状态后端、增量快照与对齐机制这三处该怎么排
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品