引言
你的 PostgreSQL 数据库不断增长,即使表中的行数基本保持不变。磁盘占用持续上升,查询开始变慢,你开始怀疑到底发生了什么。
这不是 Bug——这叫做 bloat(膨胀),而且它是 Postgres 核心设计的一部分。
要理解它为什么会发生,我们必须跟随一行数据的生命周期:从它被写入物理文件的那一刻开始,到它变成“死亡空间”为止。这段旅程始于最底层的物理存储结构:页(Page)与元组(Tuple)。
目录
- 物理层:页与元组
- MVCC 与旧数据为何不会立即消失
- 索引膨胀
- VACUUM
- 关键结论
1. 物理层:页与元组
Postgres 将表存储为磁盘上的文件,这些文件被划分为固定大小的页(8KB 块)。在每个页内部,存放的是 元组(tuple)——也就是你的行在磁盘上的物理版本。
可以把一个页想象成一个容量有限的盒子。当你插入一行数据时,Postgres 会把一个元组写入第一个有空闲空间的页。如果该页已满,就会分配一个新页并追加到表文件末尾。
每个元组不仅包含你的业务数据,还包含元数据,用于决定哪些事务可以看到这条数据(这将在 MVCC 部分解释)。
这种基于页的架构对膨胀的形成有关键影响:
- 页是 I/O 的基本单位。Postgres 不会读取单独的一行,而是读取整个 8KB 页。即使某页中只剩下一条存活元组,Postgres 仍然必须加载整个页,并跳过所有死亡元组才能找到它。
- 页不会缩小。一旦某个页被分配给某个表,它就永久属于该表。删除行只会释放页内空间,但不会把页归还给文件系统。
- 多版本共存。应用层的一行数据,可能对应多个分布在不同页中的物理元组。每次 UPDATE 都会创建一个新元组,而旧版本仍然保留在磁盘上。
💡 关键洞察
磁盘使用量由元组版本的总数决定,而不是你能查询到的行数。一个有 100 万行的表,如果存在大量死亡元组,可能和一个 1000 万行但干净的表占用同样的空间。
但页本身并不会导致膨胀。真正的根源,是 Postgres 如何处理并发:MVCC。
2. MVCC 与旧数据为何不会立即消失
Postgres 使用 多版本并发控制(MVCC),使读操作不会阻塞写操作,写操作也不会阻塞读操作。
机制很简单:不原地修改数据,而是创建新版本。
在物理层面发生的事情如下:
UPDATE 不会修改原数据
当你更新一行时,Postgres 不会找到原有元组并修改它,而是创建一个全新的元组(可能位于不同页)。旧元组会被标记为过时,其 xmax 字段记录执行更新的事务 ID。
DELETE 不会真正删除数据
删除一行时,Postgres 不会从页中移除元组,只是设置 xmax 表示哪个事务删除了它。数据仍然留在磁盘上,占用空间。
结果:元组不断累积
每次 UPDATE 都增加存储量,每次 DELETE 都留下死亡元组。频繁更新的表,可能为同一逻辑行保留几十个物理版本。
那为什么不立即删除旧版本?
因为并发事务的存在。一个较早开始的事务,可能仍然需要读取旧版本数据。Postgres 必须确保,在所有可能需要该版本的事务结束前,旧版本不能被清理。
当再也没有事务需要这些旧版本时,它们才会成为真正的 dead tuples(死亡元组)。
即使死亡元组对查询不可见,Postgres 仍然必须:
- 从磁盘加载它们所在的页
- 扫描跳过它们
- 在备份中包含它们
这就是膨胀产生的地方。
MVCC 实战演示
创建示例表:
CREATE TABLE users (id INT PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO users VALUES (1, 'Alice', 'alice@wonderland.com');
多次更新:
UPDATE users SET email = 'alice@newdomain.com' WHERE id = 1;
UPDATE users SET email = 'alice@company.com' WHERE id = 1;
UPDATE users SET email = 'alice@final.com' WHERE id = 1;
应用层看起来仍然只有一行,但磁盘上已有四个版本。
查询隐藏系统列:
SELECT ctid, xmin, xmax, * FROM users;
输出类似:
ctid | xmin | xmax | id | name | email
(0,4) | 768 | 0 | 1 | Alice | alice@final.com
之前的三个版本仍然存在,只是被标记为死亡。
查看统计:
SELECT n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'users';
三次更新产生三个死亡元组。
在高频更新环境中,这就是膨胀的来源。
膨胀对性能的影响
创建 500 行数据的表:
CREATE TABLE products (id INT PRIMARY KEY, name TEXT, price NUMERIC);
ALTER TABLE products SET (autovacuum_enabled = false);
INSERT INTO products SELECT generate_series(1,500), 'Product', 99.99;
查询页读取情况:
EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM products;
读取 4 个页。
删除 90% 数据:
DELETE FROM products WHERE id <= 450;
再查:
仍然读取 4 页。
尽管只剩 50 行,I/O 成本没有下降。
如果页中 80% 是死亡元组,你就会读取 5 倍无用数据。
这不仅是磁盘问题,更是性能问题。
精确测量膨胀
使用扩展:
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('products');
输出示例显示:
dead_tuple_percent | 59.05
近 60% 是垃圾数据。
3. 索引膨胀
删除或更新不仅影响表本身,还影响索引。
删除 450 行后,检查索引条目:
SELECT COUNT(*) FROM bt_page_items('products_pkey', 1);
仍然有 500 条记录。
查看索引大小:
SELECT pg_size_pretty(pg_relation_size('products_pkey'));
仍然 32kB。
只有通过:
REINDEX INDEX CONCURRENTLY products_pkey;
索引才会重建并缩小。
💡 标准 REINDEX 更快但锁表;CONCURRENTLY 不锁表但更慢。
4. VACUUM
VACUUM 是 Postgres 的垃圾回收器。
VACUUM users;
清除死亡元组统计,但不会缩小文件。
SELECT pg_size_pretty(pg_relation_size('users'));
仍然 8KB。
VACUUM FULL
VACUUM FULL users;
重写整张表并归还页给文件系统。
但它会加排他锁,可能导致长时间停机。
生产环境通常使用 pg_repack 替代。
Autovacuum
默认触发条件:
死亡元组 > 活元组 20% + 50
查询配置:
SELECT name, setting
FROM pg_settings
WHERE name IN ('autovacuum_vacuum_scale_factor', 'autovacuum_vacuum_threshold');
调整示例:
ALTER TABLE users SET (
autovacuum_vacuum_scale_factor = 0,
autovacuum_vacuum_threshold = 50000
);
检查最近运行时间:
SELECT relname, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'users';
当 VACUUM 跟不上
高吞吐更新
可能需要调整:
- autovacuum_vacuum_cost_delay
- autovacuum_vacuum_scale_factor
长事务
未提交事务会阻止清理。
查找长事务:
SELECT pid, age(clock_timestamp(), xact_start), query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;
5. 关键结论
你需要担心膨胀吗?
通常不必过度焦虑。
膨胀是 MVCC 的设计权衡。
只有在影响性能时才是问题:
- 查询延迟变慢
- 磁盘成本上升
- IOPS 升高
经验法则:
- 小数据库:30%–50% 可接受
- 大数据库(50GB+):最好控制在 20% 以下
优化策略:
- 降低 autovacuum_vacuum_scale_factor
- 调整 autovacuum_vacuum_threshold
- 增加 autovacuum_max_workers
只要持续写入,数据库就会持续增长。
现在,你理解了增长的原因,也掌握了控制它的工具。