跳转到主要内容

10 清理不可省

清理如何处理你的页、自动清理何时触发、什么会阻止它工作,以及如何在膨胀演变成停机事故前测量并修复它。

迟早你会看到一张表不断变大,而 pg_stat_user_tables 却显示自动清理一小时前才运行过。你已经知道增长从哪里来:每次更新和删除都会留下一个死行版本(更新就是插入)。本章讲的是本该清理这些遗留物的进程,以及它为什么有时做不到。

清理跟得上时,没有人会注意。表不断变化,却保持原来的大小。清理还会防止事务 ID 耗尽,让仅索引扫描正常工作,并保持统计信息新鲜。清理落后时,表会膨胀,查询会读取不该读取的页;最糟糕的情况下,数据库会停止接受写入。

清理是 Postgres 中唯一一个所有人都会忽略、直到它成为所有人唯一谈论的事情的部分。趁还没到那一步,先弄懂它。

用 pgstattuple 测量死元组

先测量,从同一个 pgstattuple 开始;它曾观察到 清理释放表内空间、而不是磁盘空间中的 users 变成两倍大。这次的表是 vac_demo,它是前 500,000 行 events 的副本,并且关闭了自动清理。更新每两行中的一行,然后查看:

update vac_demo set payload = payload || '{"seen": true}' where id % 2 = 0;

select tuple_count, dead_tuple_count, dead_tuple_percent, free_percent
from pgstattuple('vac_demo');
 tuple_count | dead_tuple_count | dead_tuple_percent | free_percent
-------------+------------------+--------------------+--------------
      500000 |           250000 |              31.13 |         0.59

同样是 500,000 个活行,另外还有占表 31% 的 250,000 个死行。原本紧密排列的页没有空间容纳新版本,所以新版本被写到末尾的 4,807 个新页中。现在每次顺序扫描都会多读取 55% 的页。

pgstattuple 很精确,但会读取整张表。对于大型生产表,使用会跳过全可见页的 pgstattuple_approx(),或者使用 n_live_tup 和 n_dead_tup 这两个低成本估算值,它们位于 pg_stat_user_tables 中。

动手验证 · lab 09-vacuum
select lab.prove(
  'updating every second row leaves 250,000 dead tuples, about 31% of the table',
  tuple_count = 500000 and dead_tuple_count = 250000
  and dead_tuple_percent between 29 and 33)
from pgstattuple('vac_demo');

select lab.prove(
  'the table grew about 55% (to about 13,600 pages) with nothing inserted',
  pg_relation_size('vac_demo') / 8192 between 13400 and 13800);

完整实验脚本

清理要做五件事

清除这 250,000 个死行只是清理工作的第一项。普通 VACUUM(不是 VACUUM FULL)会在原地工作,只取得一个 SHARE UPDATE EXCLUSIVE 锁,既不会阻塞读取,也不会阻塞写入。它沿着固定路线运行:遍历表,然后遍历每个索引,再次遍历表。

 heap pass 1               index pass             heap pass 2
 -----------               ----------             -----------
 for each page:            for each index:        for each page with
  - prune dead versions     - delete entries       dead line pointers:
  - remember dead TIDs        pointing at the       - mark them unused
  - freeze old tuples         dead TIDs             - update FSM
  - set VM bits                                     - set VM bits
                                                  then: truncate empty
                                                  pages at the end
来自 MySQL 的读者

InnoDB 的后台清理线程会从撤销日志中清除旧行版本。Postgres 的旧版本存在表中,因此清理要在堆和每个索引上完成清除工作;还有一个 InnoDB 没有的风险:事务 ID 回卷(Postgres 不是 MySQL)。

1. 移除死行版本

当删除或替换该行的事务已经提交,并且没有仍在运行的事务可能还需要它时,一个行版本就是死亡的。清理会计算这个边界,也就是只有没有快照能看到版本时,版本才算死亡中介绍的 xmin 清理界限(xmin horizon);清理输出把它称为可移除截点(removable cutoff):任何当前快照可能还会关心的最老事务 ID。记住“已死亡但尚不可移除”这句话。这是清理输出中最重要的一行。

索引按位置指向堆元组(TID,即一个 (page, slot) 地址),所以清理会分阶段移除死版本。它先剪枝每个堆页,并把死 TID 收集到内存中;然后完整扫描每个索引,删除指向这些 TID 的条目;最后释放堆槽位。每个索引都会放大清理成本。(普通查询经过页时也会剪枝页:页内剪枝要等到页接近填满。)

深入阅读:清理何时会把每个索引扫描两遍

死 TID 存放在 maintenance_work_mem(或 autovacuum_work_mem)中。如果放不下,清理就执行一次索引遍历,清空这份列表后继续,因此内存太少意味着要对每个索引执行多次完整扫描。详细输出中的 index scans: 1 表示扫描一次。PostgreSQL 17 用紧凑得多的结构替换了 TID 数组,并取消了 1 GB 上限,所以在 17+ 上这种情况很少见。在更早的版本中,要给清理分配足够的内存,让 index scans: 保持为 1。

2. 记录空闲空间

释放的空间会进入空闲空间映射(FSM),这样后续写入会填充已有页,而不是扩展文件(插入会找到空间,而不用扫描表)。清理让空间变得可复用,而不会让文件变小。

清理之后,vac_demo 仍然有 13,601 页,但死元组变成了零,空闲空间从 0.6% 上升到 31.7%。随后我们插入了 100,000 行。一页都没有增加;空闲空间降到了 19.3%。

这就是你想要的稳态:表不断变化,却保持相同的大小。这个循环一旦断开,得到的就是膨胀。

动手验证 · lab 09-vacuum
select lab.prove(
  'the table is the same size, and the dead space is now about 32% free space',
  pg_relation_size('vac_demo') / 8192 = :pages_after_update
  and free_percent between 29 and 34)
from pgstattuple('vac_demo');

select lab.prove(
  '100,000 new rows land in that free space: not one page added',
  pg_relation_size('vac_demo') / 8192 = :pages_after_update);

完整实验脚本

3. 截断末尾的空页

只有在末尾的整页都为空时,普通清理才会缩小文件;它会短暂取得一个 ACCESS EXCLUSIVE 锁,把这些页截掉。删除按时间排序的表中最新的一半行,再运行清理,文件会缩小一半。删除最老的一半行时,空间会留在文件前部:可以复用,却永远不会归还给操作系统。

深入阅读:删除哪一半决定文件是否缩小
create table trunc_demo with (autovacuum_enabled = off) as
  select * from events where id <= 100000;
delete from trunc_demo where id > 50000;
vacuum (verbose) trunc_demo;
INFO:  table "trunc_demo": truncated 1792 to 880 pages

events 按时间排序,因此最新的一半位于文件末尾。截断锁持续时间很短,但副本会重放它,这可能造成可见的停顿。可以按表通过存储参数 vacuum_truncate 禁用,也可以在单次运行中使用 vacuum (truncate false) 禁用;PostgreSQL 18 还新增了服务器级别的 vacuum_truncate 设置。

动手验证 · lab 09-vacuum
select lab.prove(
  'deleting the newest half and vacuuming cuts the file roughly in half (about 1,790 to 880 pages)',
  :trunc_before between 1750 and 1850
  and pg_relation_size('trunc_demo') / 8192 between 860 and 900);

select lab.prove(
  'deleting the oldest half instead frees the space at the front, and the file does not shrink',
  pg_relation_size('front_demo') / 8192 = :trunc_before);

完整实验脚本

4. 冻结旧元组

事务 ID(XID)存储在 32 位中,大约有 43 亿个值,计数器会像里程表一样回卷。因此 Postgres 会在一个圆环上比较 XID:对于任意一个 XID,大约 21 亿个在“过去”,21 亿个在“未来”。如果某个元组的插入 XID 比计数器落后超过 21 亿,它突然就会看起来像来自未来,并从所有查询中消失。这就是事务 ID 回卷。

冻结可以防止它。一旦元组的插入事务老到每个快照都能看到它已经提交,清理就会把元组标记为已冻结:对所有人可见,永久如此,不受计数器当前值影响。每张表的 relfrozenxid 表示其中所有更老的 XID 都已被冻结,而 age(relfrozenxid) 表示它落后了多少个 XID,这个值绝不能接近 20 亿。清理不会费心冻结比 vacuum_freeze_min_age 更年轻的行(5,000 万个事务);vacuum (freeze) 会立即冻结当前能冻结的一切。元组全部冻结的页会在可见性映射中标记为全冻结,因此后续清理再也不用读取它。

深入阅读:冻结是两个本来无法共存的提示位

这是 更新就是插入中那个三行的 account_usage 表,只是这次显式冻结了它:

vacuum (freeze) account_usage;

select lp, t_xmin, combined_flags
from heap_page_items(get_raw_page('account_usage', 0)),
     heap_tuple_infomask_flags(t_infomask, t_infomask2)
where lp_flags = 1;
 lp | t_xmin | combined_flags
----+--------+--------------------
  4 | 869586 | {HEAP_XMIN_FROZEN}
  5 | 869588 | {HEAP_XMIN_FROZEN}
  6 | 869580 | {HEAP_XMIN_FROZEN}

HEAP_XMIN_FROZEN 是“已提交”和“无效”两个提示位同时设置的状态,这种组合在其他情况下没有意义。原始 t_xmin 仍然保留,便于调试,但之后不再查询它;relfrozenxid 跳到当前值,可见性映射把该页标记为全可见和全冻结。正常运行时永远不需要 vacuum (freeze);下面的设置会决定自动清理何时完成冻结。

5. 维护可见性映射

可见性映射(VM)为每个堆页保留两位:全可见和全冻结(每页两位,让扫描和清理跳过工作)。清理设置它们;对页的任何修改都会清除它们。仅索引扫描会跳过全可见页的堆,清理会跳过全可见页,冻结清理会跳过全冻结页。

所以只追加的表也需要清理。它们不会有行死亡,但没有清理,它们的页就永远不会变成全可见(无法进行仅索引扫描),也不会变成全冻结(于是某天一次巨大的防回卷清理会同时冻结整张表)。而且 vacuum (analyze) 会在这里顺便刷新规划器统计信息。

深入阅读:vacuum (verbose) 如何说明每项工作
vacuum (verbose) vac_demo;
pages: 0 removed, 13601 remain, 13601 scanned (100.00% of total), 0 eagerly scanned
tuples: 250000 removed, 500000 remain, 0 are dead but not yet removable
removable cutoff: 11733, which was 5 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 13601 pages set all-visible, 0 pages set all-frozen ...
index scan needed: 8794 pages from table (64.66% of total) had 250000 dead
  item identifiers removed
...

tuples: 是工作 1;其中 dead but not yet removable 的数量是需要关注的那一行;index scan needed 是工作 1 的索引遍历;pages: 0 removed 表示工作 3 没有找到可截断的内容;frozen: 是工作 4;visibility map: 是工作 5。工作 2 的空闲空间只会出现在 pgstattuple 中。

自动清理会因死行、插入或年龄而触发

自动清理启动器每分钟唤醒一次(autovacuum_naptime),并在任何一张表超过以下三个阈值之一时启动一个工作进程;工作进程最多为 autovacuum_max_workers 个(默认 3 个):

  • 死元组。 n_dead_tup 超过 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples:50 加上活行的 20%。PostgreSQL 18 会把它限制在 autovacuum_vacuum_max_threshold(1 亿)以内。
  • 插入(PostgreSQL 13+)。n_ins_since_vacuum 超过 1,000 加上表的 20%。这会让只追加的表以小剂量获得可见性位和冻结。在 PostgreSQL 18 中,按比例计算的部分只统计尚未冻结的那部分。
  • 年龄。 age(relfrozenxid) 超过 autovacuum_freeze_max_age(2 亿)。这是防回卷清理,即使关闭了自动清理也会运行。

当变更行数超过 50 加上表的 10% 时,ANALYZE 会触发。

对大表来说,默认值不对

0.2 的比例因子意味着“表中有五分之一的行死亡时才清理”:

活行数自动清理前的死行数含义
1,000250尚可
2,000,000400,050今天的 events:先积累 400k 个死行
100,000,00020,000,05020m 个死行造成的膨胀
1,000,000,000100,000,000 (PostgreSQL 18 cap)没有上限时为 200m
表 10-1 对大表来说,默认值不对

对大表来说,20% 的死行意味着每个查询都要扫描数 GB 的死行,最终启动的清理还要运行数小时。ANALYZE 也有同样的问题:比例为 10% 时,一张像 events 这样的年度时间序列表可能一个多月都不分析,因此统计信息会漏掉最新的行,而查询最常问到的正是这些行。修复方法是设置表级参数:

alter table events set (
  autovacuum_vacuum_scale_factor        = 0.01,
  autovacuum_vacuum_threshold           = 1000,
  autovacuum_vacuum_insert_scale_factor = 0.01,
  autovacuum_analyze_scale_factor       = 0.02,
  autovacuum_vacuum_cost_limit          = 1000
);

现在 events 大约积累 21,000 个死行就会清理,而不是等到 400,000 个。频繁的小清理在各方面都胜过罕见的大清理。我们的规则是:超过几百万行后,使用 0.01 到 0.05 的比例因子,或者使用 0 和固定阈值(自动清理的默认值适合小表和慢磁盘介绍全局设置)。

动手验证 · lab 09-vacuum
select lab.prove(
  'for events (2,000,000 rows), the default trigger is 400,050 dead rows',
  (select count(*) from events) = 2000000
  and least(100000000, 50 + 0.2 * (select count(*) from events)) = 400050);

select lab.prove(
  'with scale factor 0.01 and threshold 1000, events is vacuumed at about 21,000 dead rows',
  1000 + 0.01 * (select count(*) from events) = 21000);

完整实验脚本

永远不要通过关闭自动清理来解决问题

关闭自动清理从来不能修复任何问题:死元组会堆积,统计信息会腐烂,而防回卷清理仍然会在表膨胀到最糟时才到来。如果自动清理成本很高,就让它更频繁地运行,每次少做一些工作,并增加 I/O 预算,让它能够完成。

自动清理按预算运行,而默认预算很小

自动清理用点数预算限制自身:在共享缓冲区中找到的每页 1 点,从外部读取的每页 2 点,使其变脏的每页 20 点。当一个工作进程花完 vacuum_cost_limit(200)点时,就会休眠 autovacuum_vacuum_cost_delay(2 ms)。手动执行的 VACUUM 不受此限制。

这相当于每秒 100,000 点:读取 50,000 页(约 390 MB/s,绰绰有余),但只能弄脏 5,000 页(约 39 MB/s),而且这笔预算由三个工作进程共享。在真正有大量写入的 SSD 上,清理会落后,膨胀会增长,无论比例因子设得多低。把 autovacuum_vacuum_cost_limit 提高到 1,000~2,000,是最有效的改动之一。(清理还会通过一个小型缓冲区环读取,大小由 vacuum_buffer_usage_limit 决定:大操作会获得一个环。)

清理无法移除旧快照可能需要的内容

仍可能需要旧版本的最老对象决定了可移除截点。长事务会拖住一切已经说明了原因:一个从未接触这张表的空闲 repeatable-read 会话,在提交前一直让其中 60,000 个死行无法被移除。下面从清理的角度看看这是什么样,以及如何找出每种持有者。

症状是 vacuum (verbose) 的一行:0 removed,以及一个很大的已死亡但尚不可移除数量;除了持有截点的快照之外,所有人都看不到这些行。清理会运行、消耗 I/O,却什么也移除不了,这通常就是“自动清理在运行,但表仍然不断膨胀”的答案。持有截点的会话会在 pg_stat_activity 中以非空的 backend_xmin 出现,通常状态是 idle in transaction。

代价有多大,取决于它背后的写入量。在安静的表上,几秒钟没有代价;但在每秒接收数千次更新的表上,几秒钟就足以让更新失去 HOT,并永久扩大表(长事务会关闭 HOT)。繁忙的表被拖住三小时,就会产生数百万个死行,而膨胀会比事务活得更久。

深入阅读:打开的快照会让清理什么也移除不了

在更新了 100,000 行的 vac_demo 上:

tuples: 0 removed, 654564 remain, 100000 are dead but not yet removable

remain 数量(654,564)是清理根据它扫描的 60% 页估算出来的;由于可见性映射把其余页标记为全可见,它跳过了那些页。表中有 600,000 个活行。

动手验证 · lab 09-vacuum
select lab.prove(
  'vacuum removes nothing: 100,000 rows are dead but not yet removable',
  dead_tuple_count = 100000) from pgstattuple('vac_demo');

select lab.prove(
  'once that transaction ends, the next vacuum removes all 100,000',
  dead_tuple_count = 0) from pgstattuple('vac_demo');

完整实验脚本

动手验证 · lab 09-vacuum
select lab.prove(
  'the culprit shows up in pg_stat_activity with a non-null backend_xmin',
  count(*) = 1)
from pg_stat_activity
where datname = current_database() and pid <> pg_backend_pid()
  and backend_xmin is not null and state = 'idle in transaction';

完整实验脚本

会拖住截点的对象包括:

  • 长时间运行的事务和查询,包括只读事务和查询。
  • 处于事务中空闲的会话:应用运行了 BEGIN,随后等待网络调用,或泄漏了连接。这是现实中最常见的原因;idle_in_transaction_session_timeout 是解决办法(连接并非免费)。
  • 复制槽。备库启用了 hot_standby_feedback 的物理槽会为每张表保持截点,即使备库消失后仍会继续保持。逻辑槽只为系统目录保持截点。两者都会保留 WAL。
  • 启用 hot_standby_feedback = on 的副本,其中运行时间很长的副本查询会拖住主库。
  • 被遗弃的预备事务(两阶段提交很少是答案)。

复制槽和预备事务永远不会出现在 pg_stat_activity 中,而备库反馈只会在那里显示为 walsender 的 backend_xmin(或者显示在它的槽上),所以还要检查 pg_replication_slots 和 pg_prepared_xacts(四个查询告诉你清理是否跟得上)。

一个被遗忘的事务会让一切膨胀

一个会话整个周末都处于 idle in transaction,就可能拖住整个数据库的清理;一个被遗弃的物理复制槽,则可能拖住整个集簇。为应用角色设置 idle_in_transaction_session_timeout,对最老的 backend_xmin 和不活跃槽设置告警,并设置 max_slot_wal_keep_size,这样一个死亡的槽就不会顺便填满磁盘。

深入阅读:清理也会输掉锁竞争

清理的 SHARE UPDATE EXCLUSIVE 锁会与大多数 DDL 冲突,包括 ALTER TABLE、CREATE INDEX(甚至 CONCURRENTLY)以及另一次清理。普通自动清理如果阻塞了冲突的锁请求,会在 deadlock_timeout 之后自行取消,因此频繁的 DDL 或 LOCK TABLE 可能让一张表永远无法完成一次清理(log_autovacuum_min_duration 会记录这些取消)。防回卷清理不会让步;这也是一次迁移最终排在它后面的原因之一。

索引膨胀通常比表膨胀代价更大

查找会在索引中逐页进行,因此膨胀的索引会拖慢所有经过它的查询。膨胀是既不是活数据、近期也不会被复用的空间;一些空闲空间是健康的,因为下一次更新会落在那里。对于表,pgstattuple 会给出 dead_tuple_percent 和 free_percent。对于 B-树索引,pgstatindex 会报告叶子密度。

新的 B-树会把叶子打包到大约 90%。本章的更新每次都会为新行版本添加一个主键条目,因为没有任何一次更新能让新版本留在原来的页上;之后 vac_demo 的主键密度降到了 49%。REINDEX CONCURRENTLY(PostgreSQL 12+)会在线重建索引:创建一份新副本,再把它交换进来:

select index_size, avg_leaf_density from pgstatindex('vac_demo_pkey');
reindex index concurrently vac_demo_pkey;
select index_size, avg_leaf_density from pgstatindex('vac_demo_pkey');
 index_size | avg_leaf_density
------------+------------------
   24731648 |            49.22

 index_size | avg_leaf_density
------------+------------------
   13500416 |               90

从 23.6 MB 降到 12.9 MB,小了 45%,而且没有阻塞写入。无需读取每一页的更便宜的膨胀估算见观察它运行。

动手验证 · lab 09-vacuum
select lab.prove(
  'after all those non-HOT updates, the primary key''s leaves are only about half full',
  avg_leaf_density between 40 and 65) from idx_before;

select lab.prove(
  'REINDEX CONCURRENTLY packs the leaves back to about 90%',
  avg_leaf_density between 88 and 92) from pgstatindex('vac_demo_pkey');

select lab.prove(
  'and the index is over a third smaller',
  (select index_size from pgstatindex('vac_demo_pkey'))
    < 0.67 * (select index_size from idx_before));

完整实验脚本

大表执行 VACUUM FULL 会造成停机

VACUUM FULL 会把表重写到一份只包含活行的新文件中,然后重建所有索引。在 vac_demo 上,它从 13,601 页降到了 10,961 页。代价是:整个重写期间都要持有 ACCESS EXCLUSIVE 锁,阻塞所有读取和写入,还要额外占用一份副本大小的磁盘空间。对大表来说,这就是一次事故。(CLUSTER 是同一种操作,只是按索引对行排序。)把它留给小表和维护窗口;大表要在线重建(无锁重建膨胀的表)。

但首先要问,表为什么膨胀。如果清理被阻塞或太慢,重建只是把时钟拨回去。修复原因后,新写入会像插入测试所展示的那样复用已有的空闲空间。

深入阅读:VACUUM FULL 会写出一份全新的文件
select pg_relation_filenode('vac_demo');   -- 18189
vacuum full vac_demo;
select pg_relation_filenode('vac_demo');   -- 19527

filenode 发生了变化:这是一个全新的文件,旧文件会在事务提交时删除。

动手验证 · lab 09-vacuum
select lab.prove(
  'VACUUM FULL writes a new file (the filenode changes)',
  pg_relation_filenode('vac_demo') <> :filenode_before);

select lab.prove(
  'compacted from about 13,600 to about 11,000 pages, under 1% free',
  table_len / 8192 between 10700 and 11200 and tuple_count = 600000 and free_percent < 1)
from pgstattuple('vac_demo');

完整实验脚本

回卷会在任何内容损坏前停止写入

数据库会拒绝新的写入,日志会说它这样做是为了防止数据丢失。没有任何东西损坏。你已经用掉了大部分 XID 空间,Postgres 在计数器回卷前停了下来。

每个写事务都会消耗一个 XID(只读事务不会)。每秒 1,000 个写事务就是每天 8,600 万个,因此繁忙系统每月会消耗数亿个。防线分成几层:

XID 年龄设置或限制发生什么
150 millionvacuum_freeze_table_age清理变得激进:处理所有不是全冻结的页
200 millionautovacuum_freeze_max_age防回卷自动清理,即使自动清理已关闭
1.6 billionvacuum_failsafe_age(PostgreSQL 14+)清理放弃节流和索引清理,以便完成
距离末尾 40 百万在日志和客户端处发出警告
距离末尾 3 百万不再有新的 XID:完全不能写入
表 10-2 回卷会在任何内容损坏前停止写入

“末尾”就是回卷点,大约是 21 亿个 XID。

通常,防回卷清理每隔几亿个事务运行一次,没有人会注意。只有当某些东西阻止它完成时,才会出现紧急情况:有阻塞者拖住截点(清理不能冻结比它更新的元组)、节流太慢导致大表永远做不完,或者一次运行反复被阻塞或杀掉。

这发生在真实公司身上:Sentry 在 2015 年停机了大半个工作日,Mandrill 在 2019 年停机了大约 40 小时。两次事故的模式相同:大而繁忙的表早就让清理落后了,只是没人查看。

深入阅读:两次事故是如何结束的

两起事故都以相同方式结束。Sentry 的高写入数据库在自动清理跟不上时停止接受写入;经过数小时清理后,截断一张巨大且不重要的表恢复了写入。Mandrill 在一个分片上发生了回卷,因为哈希让它比其他分片更热;清理的最高估计时间是 40 天,而截断最大的表(以 TB 计,其中大多数表只有 MB)让它在大约一小时后完成。

要避免这种情况:

  • 监控每个数据库的 age(datfrozenxid),以及最大表的 age(relfrozenxid)。 要在防故障保护阈值之前很久就告警,比如到 5 亿至 10 亿时。
  • 控制清理阻塞者。 几乎每起回卷事故背后都有它们。
  • 让由插入驱动的自动清理以小剂量冻结只追加的表。
  • 不要为了让迁移通过而杀掉防回卷清理。 只有一次运行完成时 relfrozenxid 才会前进,因此杀掉它会丢失已经做的工作,而自动清理会立即再启动一次。

如果写入已经被拒绝:移除阻塞者(终止旧会话、处理预备事务、删除死亡的槽),然后在最老的表上不节流地运行普通 VACUUM(不是需要 XID 的 VACUUM FULL)。当前文档建议使用普通多用户模式,而不是旧的单用户步骤;关于你的版本的确切步骤,见 Routine Vacuuming。

深入阅读:多事务 ID 也会回卷,而 PostgreSQL 18 会更早冻结

多事务 ID用于多个事务锁住同一行的情况(例如,许多会话的外键检查取得 FOR KEY SHARE),它们有自己的计数器、年龄(mxid_age(relminmxid))和冻结设置,也可能以同样方式回卷(共享行锁会产生多事务 ID,多事务 ID 也会回卷)。

PostgreSQL 18 还会在普通清理期间提前冻结一小部分全可见页(详细输出中的 eagerly scanned 数量,由 vacuum_max_eager_freeze_failure_rate 调整),把原本会由激进清理一次性承担的成本摊开。

四个查询告诉你清理是否跟得上

调查问题时运行下面这些查询,并把重要的结果做成图表和告警(观察它运行会把它们和其他指标放在一起)。它们回答四个问题:

  1. 哪些表最接近触发自动清理,它最近运行过吗? 如果一张表的 n_dead_tup 长期远高于阈值,它要么被阻塞,要么跟不上。
  2. 每张表距离强制冻结还有多远? 比较 age(relfrozenxid) 和 autovacuum_freeze_max_age。
  3. 是什么拖住了截点? 会话(pg_stat_activity)、槽(pg_replication_slots)和预备事务(pg_prepared_xacts):三者都要检查。
  4. 清理现在正在做什么? 看 pg_stat_progress_vacuum。

还要让自动清理报告自身:log_autovacuum_min_duration(默认 10 分钟)会为每次更长的运行记录包含 VACUUM VERBOSE 细节的日志。设为 1s 后,你就会得到清理历史,知道清理了什么、用了多长时间,以及哪些内容已死亡但尚不可移除。

深入阅读:清理仪表盘的四个查询

哪些表最接近触发自动清理,它最近运行过吗? 在更新了 6,000 个事件和 300 个账户的新样本数据副本上:

select s.relname,
       s.n_dead_tup,
       round(current_setting('autovacuum_vacuum_threshold')::int
             + current_setting('autovacuum_vacuum_scale_factor')::float8
               * c.reltuples) as vacuum_at,
       s.last_autovacuum
from pg_stat_user_tables s
join pg_class c on c.oid = s.relid
order by s.n_dead_tup desc
limit 10;
 relname  | n_dead_tup | vacuum_at |       last_autovacuum
----------+------------+-----------+------------------------------
 events   |       6000 |    400052 |
 accounts |          0 |       250 | 2026-10-06 05:57:21.08907+00
...

300 个变更跨过了 accounts 的 250 行阈值,自动清理在一分钟内就把它们清掉了。events 的 6,000 个死行离 400,052 还远得很。这个查询使用全局设置,因此有表级覆盖设置的表会比 vacuum_at 显示的时间更早触发。

每张表距离强制冻结还有多远?

select c.oid::regclass as table_name,
       age(c.relfrozenxid) as xid_age,
       round(100.0 * age(c.relfrozenxid)
             / current_setting('autovacuum_freeze_max_age')::int, 1)
         as pct_of_freeze_max,
       pg_size_pretty(pg_total_relation_size(c.oid)) as size
from pg_class c
where c.relkind in ('r', 'm', 't')
order by age(c.relfrozenxid) desc
limit 10;

select datname, age(datfrozenxid), mxid_age(datminmxid)
from pg_database order by 2 desc;

是什么拖住了截点?

select pid, datname, usename, state,
       now() - xact_start as xact_age,
       backend_xmin, left(query, 60) as query
from pg_stat_activity
where backend_xmin is not null
order by age(backend_xmin) desc
limit 5;

select slot_name, slot_type, active, xmin, catalog_xmin,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as retained_wal
from pg_replication_slots;

select gid, prepared, owner, database, age(transaction) as xid_age
from pg_prepared_xacts;

清理现在正在做什么?

select p.pid, p.relid::regclass as table_name, p.phase,
       p.heap_blks_scanned, p.heap_blks_total, p.index_vacuum_count,
       now() - a.xact_start as running_for
from pg_stat_progress_vacuum p
join pg_stat_activity a using (pid);

上面 index_vacuum_count 在大表上如果超过 1,意味着清理存不下所有死 TID,因而不止一次扫描索引。第一个查询中的 vacuum_at 使用的是 reltuples 这个采样估计值,因此会接近精确 2,000,000 行得到的 400,050,而不会正好等于它。

本章要点

  • 更新和删除会留下死行版本;清理会移除它们,并记录空间以便复用。普通清理不会缩小文件,除非末尾存在空页。
  • 用 pgstattuple(精确)或 n_dead_tup(便宜)测量;用 pgstatindex 测量索引,并用 REINDEX CONCURRENTLY 修复索引膨胀。
  • 清理还会冻结旧元组并设置可见性映射位。只追加的表也需要清理。
  • 默认情况下,自动清理在 50 + 20% 死行时触发。对于超过几百万行的表,把表级比例因子设为 0.01 到 0.05。
  • 在 SSD 上把 autovacuum_vacuum_cost_limit 提高到远高于 200;默认预算由所有工作进程共享。
  • “已死亡但尚不可移除”意味着有什么东西拖住了截点:长事务或处于事务中空闲的会话、复制槽、启用了反馈的副本,或预备事务。找出它并结束它。
  • VACUUM FULL 会在整个重写期间阻塞表;在线重建大表,并先修复造成膨胀的原因。
  • 在 16 亿的防故障保护阈值之前很久就对 age(datfrozenxid) 告警,永远不要关闭自动清理,也不要杀掉防回卷清理。

延伸阅读

表格阅读