PostgreSQL 原理图解 · 存储与 MVCC

第一辑。这五个考点是 PG 和 MySQL 差别最大的地方——也是从 MySQL 转过来最容易出错的地方。

一连接一进程:为什么 PG 必须上连接池

MySQL 是多线程,一个进程里跑几十个连接;PostgreSQL 是多进程,每一个连接都 fork 出一个独立的后端进程。这一个设计差别,决定了两边完全不同的容量规划方式。

postmaster
父进程
只负责监听、fork、崩溃后统一重启

把连接数拉起来看看

并发连接 100
进程数
100
私有内存
2.2 GB
共享内存
128 MB
每连接私有—
总内存估算—
结论—
为什么是「每连接 × 每算子」而不是「每连接」:work_mem 不是每连接只分一份,而是每个排序 / 哈希节点各分一份。一条 SQL 里有 3 个 Sort 节点,这一个连接就能吃掉 3 份 work_mem。所以 work_mem 调大是「按查询复杂度爆炸」的,不是线性增长。
默认 max_connections 只有 100。这跟 MySQL 默认 151 差不多,但 PG 的每个连接重得多——基础私有内存约 10MB,加上 work_mem 之后一条复杂查询能到几十 MB。500 个连接直接把机器吃光,而且 fork 本身有成本,高并发下 context switch 会把 CPU 打满。

所以生产上必须挂连接池

方式说明适用
PgBouncer(transaction 模式)连接在后端复用,一个事务结束就归还。几百个应用连接只对应几十个真实后端进程绝大多数 Web 应用。这是默认答案
PgBouncer(session 模式)整个会话独占一个后端用了 SET、临时表、advisory lock 这类会话级状态的场景
应用侧连接池(HikariCP 等)只能减少新建连接,不能减少真实后端数必要但不充分,通常和 PgBouncer 叠加用
PG 内置的 max_connections 调大调到几百也能跑只在「连接不多但都很重」时用,不是解法
面试里怎么答这一题:「PG 是一连接一进程,连接数直接等于进程数,内存和调度成本都比 MySQL 的线程模型高,所以生产上默认挂 PgBouncer 用 transaction 模式,把几百个应用连接收敛成几十个真实会话。代价是 session 级特性(临时表、SET、advisory lock)在 transaction 模式下不可靠。」——最后半句是加分项,说明你知道它的坑。

顺带说清 memory 三层

参数是什么默认值怎么定
shared_buffersPG 自己的页缓存(8KB 页),所有进程共享128MB物理内存的 25% 左右,一般不超过 8~16GB(再大不如给 OS cache)
work_mem每个排序 / 哈希算子可用的内存,私有4MB从 4~16MB 起步,按「最大连接数 × 平均算子数」反算,别拍脑袋调大
maintenance_work_memVACUUM、CREATE INDEX 用的内存64MB可以开大(1~2GB),因为维护操作并发少
PG 和 MySQL 在缓存上最大的不同:MySQL 的 innodb_buffer_pool_size 通常开到物理内存 70~80%,因为 InnoDB 不依赖 OS 缓存。PG 依赖 OS 页缓存——它读数据走的是普通文件 IO,OS 会再缓存一份。所以 shared_buffers 只给 25% 是合理的,剩下的交给 OS。给 shared_buffers 开太大会和 OS 缓存打架(双缓冲)。

堆表 + 独立索引:PG 里每一次索引查找都要回表

MySQL 的 InnoDB 是聚簇索引——主键索引的叶子节点就是整行数据,按主键查一次就拿到全部。PG 完全不是这样:表是一个无序的堆文件,所有索引(包括主键)都是独立的 B-tree,叶子节点只存 ctid。

users_pkey B-tree · 叶子存 ctid idx_users_name B-tree · 叶子存 ctid idx_users_created B-tree · 叶子存 ctid ctid → 回堆 ctid → 回堆 ctid → 回堆 只读索引,不回堆 users 堆文件(无序,按插入顺序追加) (0,1) (0,2) (0,3) ··· (7,11) (7,12) ← 每行一个 ctid,就是物理地址
走几棵树—
回堆几次—
MySQL 里会怎样—

这个结构带来三个直接后果

一、主键在 PG 里没有「聚集」的优待。InnoDB 的主键索引就是数据本身,按主键范围查是顺序 IO;PG 按主键查是先走一棵 B-tree 拿到 ctid,再去堆里按物理位置取行——两个随机 IO。行数一大,这个差距很明显。

二、没有「按主键聚簇存储」这回事。CLUSTER 命令能按某个索引重排一次物理顺序,但它是一次性的,后续的插入不会维护这个顺序,而且执行期间会锁表(8.0 之后可以 CLUSTER ... USING,但仍然要排他锁,通常配合 pg_repack 做)。

三、所有索引都平等。PG 里建 5 个索引就是 5 棵独立的树,没有「主键索引特殊、二级索引叶子存主键值」这一层。代价是二级索引的叶子直接存 ctid(6 字节),比存主键值更省——但如果行被更新导致换了物理位置,所有索引都要更新。

index-only scan:PG 版的「覆盖索引」,但有个陷阱

当查询要的列全在索引里,PG 可以做 index-only scan,不取堆。但——它仍然要判断这一行的可见性,而可见性信息不在索引里,在可见性映射表(visibility map)里。

所以 index-only scan 能不能真省掉回堆,取决于 VACUUM 有没有跑过。新插入或刚更新的页,可见性映射位是 0,PG 还是得回堆确认一次。你会看到 EXPLAIN 里写着 Index Only Scan,但 Heap Fetches 却是几万——这就是 VACUUM 没跟上。

这个细节在面试里很少有人说得出,但它直接连到第四个考点:VACUUM 不只是回收空间,它还决定索引能不能真正免回表。
EXPLAIN (ANALYZE, BUFFERS)
SELECT name FROM users WHERE name = 'zhangwei';

 Index Only Scan using idx_users_name on users
   Index Cond: (name = 'zhangwei'::text)
   Heap Fetches: 0          ← 0 才是真的没回堆
   Buffers: shared hit=4
InnoDB(MySQL)PostgreSQL
表数据存在哪主键 B+ 树的叶子节点独立的堆文件(无序)
主键索引叶子存什么整行ctid(物理地址)
二级索引叶子存什么主键值ctid
按主键查一次1 次 B+ 树查找索引树 + 回堆,2 次
覆盖索引二级索引包含全部列index-only scan(依赖 visibility map)
物理顺序重排天然按主键有序CLUSTER / pg_repack,一次性
最典型的误解:「PG 的主键也是聚簇索引」。不是。PG 没有聚簇索引,只有「堆表 + 一堆独立索引」。这就是为什么在 PG 里用 UUID 做主键的代价没有 MySQL 那么夸张(不需要维护有序插入),但随机主键仍然会让索引变大、缓存命中变差。

MVCC:旧版本不在 undo log 里,就在表里

这是 PG 和 MySQL 最本质的分歧。InnoDB 把旧版本放在 undo log,回滚段里;PG 把旧版本直接留在表的数据页里,每一行都带自己的出生和死亡标记。

堆里真实存在的东西

三个会话各自看到什么

会话 A(RR)
—
会话 B(RR,T1 之后开始)
—
会话 C(RC)
—
物理行数—
有几种版本—
会不会产生垃圾—

每一行头上的四个隐藏列

列含义作用
xmin创建这一行的事务 ID判断「这行是不是我能看到的」
xmax删除 / 更新这一行的事务 ID;0 表示还活着判断「这行是不是已经被别人删了」
ctid物理位置(块号, 行偏移)索引指向它;UPDATE 之后会变
infomask 位一堆标志位(是否已提交、是否被冻结等)为了不每次都去查 clog(事务状态表),把结果缓存在行头上

一条 UPDATE 到底做了什么

-- 表里原来有一行 balance = 100,由事务 1000 插入
UPDATE accounts SET balance = 200 WHERE id = 1;   -- 当前事务 1001

-- PG 内部做的是:
-- 1) 把旧行的 xmax 改成 1001(标记「被我删了」)
-- 2) 在同一个页(或新页)插入一条新行:xmin = 1001, xmax = 0, balance = 200
-- 3) 新行 ctid 变了 —— 所有索引都需要指向新 ctid

SELECT ctid, xmin, xmax, balance FROM accounts;
--  (0,1) | 1000 | 1001 | 100      ← 旧版本,还占着空间
--  (0,5) | 1001 |    0 | 200      ← 新版本
注意第 3 步。ctid 变了,意味着所有索引都要插一条指向新位置的新条目(除了 HOT 更新,见下一段)。这是 PG 更新比 MySQL 贵的一个原因:更新不修改原地,而是「标记旧行 + 插入新行 + 更新所有索引」。

HOT 更新:PG 的一个优化,也是一道面试题

如果满足两个条件,PG 可以走 HOT(Heap-Only Tuple),不新建索引条目,只在新旧行之间加一条指针,索引还指向旧位置,顺着指针能找到新版本:

① 被更新的列没有被任何索引引用;② 新版本能放进同一个页的空闲空间(这时候 fillfactor 就很重要)。

所以:给不常查的列建索引,会害了更新。一张高频更新的表,如果索引太多,每次更新都可能触发非 HOT 更新——所有索引都要改,还得清理旧条目。这也是「索引不是越多越好」在 PG 里的具体表现。

实践做法:高频更新的表把 fillfactor 调低(比如 70~80),给页里留出更新空间,让 HOT 有机会生效。

三种隔离级别的实现

级别PG 里的实现会不会幻读
READ COMMITTED(默认)每条语句开始时取一个新快照会。同一条 SQL 两次执行结果可能不同
REPEATABLE READ快照隔离(SI):整个事务用同一个快照不会,也不需要间隙锁
SERIALIZABLESSI(可串行化快照隔离),在 SI 之上检测读写冲突不会,冲突时直接报错让你重试
这里有个 MySQL 转过来最容易犯的错:PG 没有间隙锁。
MySQL 的 RR 靠「MVCC + 间隙锁」两套机制防幻读;PG 的 RR 只靠快照——因为快照读到的是一个时间点,本来就不会出现幻影行。

代价是写冲突不排队,直接报错:ERROR: could not serialize access due to concurrent update。业务必须捕获这个错并重试整个事务,否则用户看到的是失败。这一点在 PG 里是常态,不是异常。

没有 undo log,代价是什么

InnoDB 的事务一回滚或提交,undo 里的旧版本就可以被 purge 掉,空间能复用。PG 的旧版本就躺在表里,谁都不能动——因为可能还有别的长事务的事务快照需要看到它。

所以:PG 的 UPDATE 越多、事务越长,表就越大。这个「越大」不是逻辑行数变多,是物理文件膨胀。这就引出了下一个考点:VACUUM。

一句话对比背下来:
「InnoDB 是把旧版本写在别处(undo log),PG 是把旧版本留在原地(堆表里)。所以 MySQL 需要 purge 线程,PG 需要 VACUUM;MySQL 的表空间不会因为更新而暴涨(会复用),PG 的表会——如果不 vacuum 的话。」

VACUUM 与表膨胀:PG 最常见的线上问题

VACUUM 干两件事,很多人只知道第一件:① 把死元组占的空间标记为可复用;② 推进事务 ID 的冻结(防止回卷)。注意是「标记可复用」,不是「还给操作系统」。

表文件的实际构成(8KB 页里)
活元组 死元组 可复用空间
活元组(还能被看到) 死元组(旧版本,等回收) 空闲 / 已复用
此刻状态—
文件大小—
空间还给 OS 了吗—

autovacuum 什么时候才动手

不是「一有死元组就清」,而是有一个触发阈值:

触发条件:n_dead_tup > autovacuum_vacuum_threshold
                     + autovacuum_vacuum_scale_factor × 表的估算行数

默认值:threshold = 50,scale_factor = 0.2   (PG 默认)
表行数 100 万
触发阈值—
意思是—
0.2 这个系数是个大坑。100 万行的表,要积累 20 万个死元组才触发;1 亿行的表要 2000 万。也就是说表越大,autovacuum 越懒得动——而大表恰恰最需要 vacuum。

标准做法:大表按表单独调低系数,比如 ALTER TABLE big_table SET (autovacuum_vacuum_scale_factor = 0.02);,让它按 2% 触发。

为什么会出现「vacuum 跑了但表还是越来越大」

原因机制怎么查
长事务老事务的快照还要看旧版本,vacuum 只能跳过这些行,不敢清SELECT pid, now()-xact_start, state, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY 2 DESC;
失效的复制槽slot 保留了 WAL,也保留了「必须还能解析出的旧版本」的约束SELECT slot_name, active, restart_lsn FROM pg_replication_slots;
长 SQL 的游标没关DECLARE CURSOR WITH HOLD 会一直持有快照同上,看 pg_stat_activity
autovacuum 被别的表占满autovacuum_max_workers 默认只有 3,大表一跑就是几小时SELECT * FROM pg_stat_progress_vacuum;
「长事务」是 PG 线上事故的第一大来源。它同时造成三件事:表膨胀、索引膨胀、以及第五个考点里的 xid 回卷风险。所以 PG 的监控里必须有「最长事务时长」这条,超过几分钟就该报警。

VACUUM 的四种形态,别搞混

命令做什么锁什么时候用
VACUUM标记死元组空间可复用不阻塞读写日常,autovacuum 自动跑
VACUUM ANALYZE顺带更新统计信息同上大批量增删改之后手动跑一次
VACUUM FULL重写整张表,真正把空间还给 OSACCESS EXCLUSIVE,全程锁表几乎不用。大表上等于停服
pg_repack在线重写表,效果同 VACUUM FULL只在最后切换时短暂锁真的需要缩表时用这个
回答「表膨胀了怎么办」的正确顺序:先查为什么(长事务?复制槽?autovacuum 参数?)→ 处理根因 → 让 autovacuum 把空间标记为可复用 → 只有当「文件确实需要变小」时才考虑 pg_repack。一上来就 VACUUM FULL 是新手答案,在生产大表上会直接锁死业务。

PG 19 的两个改进(2026 年 11 月 GA)

当前生产稳定版是 18.6,19 计划 11 月 19 日 GA。19 里和这一块相关的有两个:autovacuum 支持并行 worker(autovacuum_max_parallel_workers),以及新增 REPACK 命令(带 CONCURRENTLY),算是把 pg_repack 的能力收进了内核。面试里提一句「19 把在线重表做进内核了」,是个很新的加分点。

事务 ID 回卷:PG 独有的停机事故

PG 的事务 ID 是 32 位无符号整数,约 42 亿个。写满之后会绕回 0——一旦绕回,新事务的 ID 比老事务还小,所有过去的数据看起来都「来自未来」,整个库就废了。所以 PG 必须定期把老行的 xid「冻结」成特殊值。

把数据库的年龄拖一拖

2 亿 · autovacuum 强制 freeze
20 亿 · 停止接受写入
现在的 age
事务年龄 5.0 亿
状态—
距 20 亿还有—
该做什么—

为什么「28 亿」和「20 亿」是两个不同的数字

阈值数值发生什么
vacuum_freeze_max_age2 亿(默认)autovacuum 强制对该表做 freeze,忽略其他参数。这是「防回卷 vacuum」
告警线(惯例)10 ~ 15 亿该查为什么 age 涨这么快了,通常是有表没被 vacuum 到
硬上限约 20 亿数据库拒绝新的写入事务:ERROR: database is not accepting commands to avoid wraparound data loss
理论回绕点约 42 亿真正的数据损坏点,实际上到不了这里,前面就会拦
这个错误一旦出现,只能单用户模式启动做 VACUUM,期间业务全停。而 VACUUM 要跑多久,取决于那张表多大——几 TB 的表可能是几个小时。所以它不是一个「错误」问题,是一次计划外停机。

三个必须会查的 SQL

-- 1. 每个库离回卷还有多远(最该进监控的一条)
SELECT datname, age(datfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age')::bigint
         - age(datfrozenxid) AS remaining
FROM pg_database ORDER BY xid_age DESC;

-- 2. 哪张表最老(找出那个没被 vacuum 的表)
SELECT relname, age(relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class
WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace
ORDER BY age(relfrozenxid) DESC LIMIT 20;

-- 3. 谁在阻止 vacuum 推进(长事务 / 复制槽,必查)
SELECT pid, now() - xact_start AS dur, state, left(query, 60)
FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY dur DESC LIMIT 5;

SELECT slot_name, active, age(xmin), age(catalog_xmin)
FROM pg_replication_slots;

常见的三种成因

① 有一张冷表从来没被访问过。autovacuum 是按「表的变更量」触发的,一张只读的冷表没有变更,就不会被 vacuum——但它的 relfrozenxid 一直停在那里,把整个库的 age 顶上去。这是最常见的原因。

② 长事务或失效的复制槽。它们的 xmin 卡在很早的位置,vacuum 无法冻结更晚的行。

③ autovacuum 被关掉过。有些「优化」教程会建议关掉 autovacuum,这在 PG 里是自杀行为。

面试怎么答这一题(这题几乎是 PG DBA 的筛选题):
「PG 的 xid 是 32 位,会绕回。防止绕回靠 VACUUM 把老行冻结。有两个阈值:2 亿触发强制 freeze,20 亿直接拒绝写入。日常要看 age(datfrozenxid),超过 10 亿就该查原因。最常见的成因不是大表,是没人访问的冷表——它没有变更就不会触发 autovacuum,但它的 relfrozenxid 会把整个库的年龄顶上去。所以我们会对冷表配定期手动 vacuum,并且在监控里同时盯库级和表级 age。」

和 MySQL 的对照

MySQL(InnoDB)PostgreSQL
事务 ID 位宽8 字节(48 位有效),不会绕回32 位,约 42 亿
旧版本存在哪undo log,purge 线程清理表里,VACUUM 标记可复用
需要防回卷吗不需要需要,且是硬约束
膨胀风险有,但表空间能复用更明显,靠 autovacuum 兜底
一句话记住:MySQL 的问题通常是「undo 太大、purge 跟不上」;PG 的问题是「表太大、age 太高」。前者影响性能,后者直接停机。这是两个 DBA 岗位在运维重点上最真实的差别。